Vectrum
Sign In

My PI config

JjohannSocommitted commitb1a31ec

Showing 10 changed files with 1293 additions and 0 deletions.

+21−0.gitignoreaddedView File
@@ -0,0 +1,21 @@
1+ # Allowlist: ignore everything, then opt in safe files only.
2+ # New files pi creates (auth, caches, sessions) stay ignored by default.
3+ /*
4+
5+ !/.gitignore
6+ !/settings.json
7+ !/keybindings.json
8+ !/mcp.json
9+ !/extensions/
10+ !/themes/
11+ !/skills/
12+ !/agents/
13+ !/prompts/
14+ !/workflows/
15+
16+ # Never commit, even if allowlisted above by mistake
17+ auth.json
18+ mcp-auth.json
19+ *.key
20+ *.pem
21+ .env*
+808−0extensions/orca-agent-status.tsaddedView File
@@ -0,0 +1,808 @@
1+ // @orca-managed-pi-extension
2+ // Why: no package-specific type import here. Pi and OMP expose the same
3+ // extension API, but publish their types under different package names.
4+ // Why: warn-once so a recurring parse error on a malformed endpoint
5+ // file does not spam stderr inside the pi TUI on every event.
6+ let warnedBadEndpoint = false
7+ // Why: Pi awaits extension handlers. Status delivery stays off that
8+ // critical path, and the latest-only pending slot prevents a stalled
9+ // Orca receiver from building an unbounded queue of obsolete snapshots.
10+ const HOOK_POST_TIMEOUT_MS = 1000
11+ type HookPost = { hookEventName: string; extra: Record<string, unknown>; metadata: Record<string, unknown>; ompRuntime: boolean; revision: number; attempts: number; delivered: boolean }
12+ let activePost = false
13+ let pendingPost: HookPost | null = null
14+ let latestPost: HookPost | null = null
15+ // A newer snapshot or session boundary retires every older retry.
16+ let postRevision = 0
17+ let retryTimer: ReturnType<typeof setTimeout> | null = null
18+
19+ function cancelPostRetry(): void {
20+ if (retryTimer !== null) clearTimeout(retryTimer)
21+ retryTimer = null
22+ }
23+
24+ function resetPostQueue(): void {
25+ cancelPostRetry()
26+ postRevision++
27+ pendingPost = null
28+ latestPost = null
29+ }
30+
31+ function drainPosts(): void {
32+ if (activePost || !pendingPost) return
33+ const next = pendingPost
34+ pendingPost = null
35+ activePost = true
36+ void postOnce(next.hookEventName, next.extra, next.metadata, next.ompRuntime)
37+ .then(() => { next.delivered = true })
38+ .catch(() => {
39+ if (!next.ompRuntime || next.revision !== postRevision) return
40+ if (next.attempts >= 3) {
41+ console.warn('[orca-pi-status] hook delivery failed after retries:', next.hookEventName)
42+ return
43+ }
44+ const delay = 250 * 2 ** next.attempts++
45+ retryTimer = setTimeout(() => {
46+ retryTimer = null
47+ if (next.revision !== postRevision) return
48+ pendingPost = next
49+ drainPosts()
50+ }, delay)
51+ if (typeof retryTimer.unref === 'function') retryTimer.unref()
52+ })
53+ .finally(() => {
54+ activePost = false
55+ drainPosts()
56+ })
57+ }
58+
59+ let piUiPromptDepth = 0
60+ let piTurnInFlight = false
61+ let modelMetadata: Record<string, unknown> = {}
62+ let ompModelSwitchSupported = false
63+ let modelSessionId: unknown = undefined
64+
65+ // Why: OMP switches sessions in-process; a model belongs to the session that
66+ // reported it, so it must not leak onto the first posts of the next one.
67+ function trackModelSession(sessionId: unknown): void {
68+ if (sessionId === modelSessionId) return
69+ modelSessionId = sessionId
70+ modelMetadata = {}
71+ }
72+
73+ // Why: pi and OMP expose the active model as { provider, id } on both the event
74+ // context and model_select events; the joined selector is what --model accepts.
75+ function updateModelMetadata(source: unknown): void {
76+ try {
77+ if (!source || typeof source !== 'object' || !('model' in source)) return
78+ const model = source.model
79+ if (!model || typeof model !== 'object') return
80+ const provider = 'provider' in model && typeof model.provider === 'string' ? model.provider : ''
81+ const id = 'id' in model && typeof model.id === 'string' ? model.id : ''
82+ if (provider && id) modelMetadata = { model: provider + '/' + id, ...(ompModelSwitchSupported ? { model_switch_command: 'orca-model' } : {}) }
83+ } catch {
84+ // Why: a throwing model getter must never break status delivery.
85+ }
86+ }
87+
88+ let sessionMetadata: Record<string, unknown> = {}
89+ let runtimeOmpSessionMetadata: Record<string, unknown> = {}
90+
91+ function updateSessionMetadata(ctx: unknown): void {
92+ const sessionManager = (ctx as { sessionManager?: { getSessionId?: () => unknown; getSessionFile?: () => unknown } } | null)?.sessionManager
93+ const sessionId = sessionManager?.getSessionId?.()
94+ const sessionFile = sessionManager?.getSessionFile?.()
95+ sessionMetadata = typeof sessionId === 'string' && sessionId ? {
96+ session_id: sessionId,
97+ ...(typeof sessionFile === 'string' && sessionFile ? { session_file: sessionFile } : {}),
98+ } : {}
99+ }
100+
101+ function updateRuntimeOmpSessionMetadata(ctx: unknown): void {
102+ if (!isOmpRuntime()) return
103+ const sessionManager = (ctx as { sessionManager?: { getSessionId?: () => unknown; getSessionFile?: () => unknown } } | null)?.sessionManager
104+ const sessionId = sessionManager?.getSessionId?.()
105+ const sessionFile = sessionManager?.getSessionFile?.()
106+ runtimeOmpSessionMetadata = typeof sessionId === 'string' && sessionId && typeof sessionFile === 'string' && sessionFile ? { session_id: sessionId, session_file: sessionFile } : {}
107+ trackModelSession(runtimeOmpSessionMetadata.session_id)
108+ updateModelMetadata(ctx)
109+ }
110+
111+ function getPostSessionMetadata(ompRuntime: boolean): Record<string, unknown> {
112+ return ompRuntime ? { ...runtimeOmpSessionMetadata, ...modelMetadata } : sessionMetadata
113+ }
114+
115+ function getPersistedSessionMetadata(): Record<string, unknown> {
116+ const sessionFile = sessionMetadata.session_file
117+ if (typeof sessionFile !== 'string' || !sessionFile) return {}
118+ try {
119+ const fs = require('fs')
120+ // Why: Pi publishes its planned path before creating the transcript;
121+ // recheck on every post so the first completed turn becomes resumable.
122+ return fs.existsSync(sessionFile) ? sessionMetadata : {}
123+ } catch {
124+ return {}
125+ }
126+ }
127+
128+
129+ // Why: re-reading the endpoint file on every event is cheap (small file,
130+ // rare changes) but stat+mtime caching avoids re-parsing on every event
131+ // during streaming tool execution. Mirrors the OpenCode plugin cache shape.
132+ let cachedEndpointKey = ''
133+ let cachedEndpointValues: Record<string, string> | null = null
134+
135+ function readEndpointFile(): Record<string, string> | null {
136+ const path = process.env.ORCA_AGENT_HOOK_ENDPOINT
137+ if (!path) return null
138+ try {
139+ const fs = require('fs')
140+ try {
141+ const stat = fs.statSync(path)
142+ const cacheKey = stat.mtimeMs + ':' + stat.size + ':' + stat.ino
143+ if (cacheKey === cachedEndpointKey && cachedEndpointValues) {
144+ return cachedEndpointValues
145+ }
146+ const contents: string = fs.readFileSync(path, 'utf8')
147+ const out: Record<string, string> = {}
148+ for (const line of contents.split(/\r?\n/)) {
149+ // Why: parse `KEY=VALUE` (POSIX endpoint.env) and `set KEY=VALUE`
150+ // (Windows endpoint.cmd) with one regex; strip a trailing CR so
151+ // mixed-EOL files do not leak \r into the value.
152+ const m = line.match(/^(?:set\s+)?([A-Z0-9_]+)=(.*)$/)
153+ if (m) out[m[1]] = m[2].replace(/\r$/, '')
154+ }
155+ cachedEndpointKey = cacheKey
156+ cachedEndpointValues = out
157+ return out
158+ } catch (ioErr) {
159+ cachedEndpointKey = ''
160+ cachedEndpointValues = null
161+ throw ioErr
162+ }
163+ } catch (err: unknown) {
164+ const code = (err as { code?: string } | null)?.code
165+ if (err && code !== 'ENOENT' && !warnedBadEndpoint) {
166+ warnedBadEndpoint = true
167+ console.warn('[orca-pi-status] failed to parse endpoint file:', (err as Error).message)
168+ }
169+ return null
170+ }
171+ }
172+
173+ function resolveHookCoords() {
174+ const fileEnv = readEndpointFile() || {}
175+ return {
176+ port: fileEnv.ORCA_AGENT_HOOK_PORT || process.env.ORCA_AGENT_HOOK_PORT,
177+ token: fileEnv.ORCA_AGENT_HOOK_TOKEN || process.env.ORCA_AGENT_HOOK_TOKEN,
178+ env: fileEnv.ORCA_AGENT_HOOK_ENV || process.env.ORCA_AGENT_HOOK_ENV || '',
179+ version: fileEnv.ORCA_AGENT_HOOK_VERSION || process.env.ORCA_AGENT_HOOK_VERSION || '',
180+ }
181+ }
182+
183+ function processName(value: unknown): string {
184+ return String(value || '').split(/[\\/]/).pop()?.toLowerCase() || ''
185+ }
186+
187+ const CONFIGURED_HOOK_PATH = '/hook/pi'
188+ let cachedOmpRuntime: boolean | null = null
189+
190+ function isOmpRuntime(): boolean {
191+ if (cachedOmpRuntime !== null) return cachedOmpRuntime
192+ if (CONFIGURED_HOOK_PATH === '/hook/omp') {
193+ cachedOmpRuntime = true
194+ return true
195+ }
196+ const executableNames = [
197+ processName(process.title),
198+ processName(process.env._),
199+ processName(process.argv[1]),
200+ processName(process.argv[0])
201+ ]
202+ cachedOmpRuntime = executableNames.some((name) =>
203+ ['omp', 'omp.js', 'omp.sh', 'omp.cmd', 'omp.exe', 'omp.bat'].includes(name)
204+ )
205+ return cachedOmpRuntime
206+ }
207+
208+ function resolveHookPath(ompRuntime: boolean): string {
209+ // Why: runtime detection keeps a bare-shell OMP launch from reporting as Pi.
210+ if (ompRuntime) return '/hook/omp'
211+ return CONFIGURED_HOOK_PATH
212+ }
213+
214+ function post(hookEventName: string, extra: Record<string, unknown> = {}): void {
215+ const ompRuntime = isOmpRuntime()
216+ cancelPostRetry()
217+ const metadata = getPostSessionMetadata(ompRuntime)
218+ // Model changes must not erase an unacknowledged completion in the latest-only slot.
219+ const previousCompletion = latestPost?.hookEventName === 'agent_end' && !latestPost.delivered && latestPost.metadata.session_id === metadata.session_id
220+ pendingPost = {
221+ revision: ++postRevision,
222+ attempts: 0,
223+ delivered: false,
224+ hookEventName: ompRuntime && hookEventName === 'model_select' && previousCompletion ? 'agent_end' : hookEventName,
225+ extra: { ...extra, ...(!ompRuntime && piUiPromptDepth > 0 ? { ui_prompt_active: true } : {}) },
226+ metadata,
227+ ompRuntime,
228+ }
229+ latestPost = pendingPost
230+ drainPosts()
231+ }
232+
233+ async function postOnce(
234+ hookEventName: string,
235+ extra: Record<string, unknown>,
236+ metadata: Record<string, unknown>,
237+ ompRuntime: boolean
238+ ): Promise<void> {
239+ const coords = resolveHookCoords()
240+ const paneKey = process.env.ORCA_PANE_KEY
241+ if (!coords.port || !coords.token || !paneKey) return
242+ const url = `http://127.0.0.1:${coords.port}${resolveHookPath(ompRuntime)}`
243+ const body = JSON.stringify({
244+ paneKey,
245+ launchToken: process.env.ORCA_AGENT_LAUNCH_TOKEN || '',
246+ tabId: process.env.ORCA_TAB_ID || '',
247+ worktreeId: process.env.ORCA_WORKTREE_ID || '',
248+ env: coords.env,
249+ version: coords.version,
250+ payload: { hook_event_name: hookEventName, ...(ompRuntime ? metadata : getPersistedSessionMetadata()), ...extra },
251+ })
252+ const controller = typeof AbortController !== 'undefined' ? new AbortController() : null
253+ let timeout: ReturnType<typeof setTimeout> | undefined
254+ const timeoutPromise = new Promise<never>((_resolve, reject) => {
255+ timeout = setTimeout(() => {
256+ controller?.abort()
257+ reject(new Error('Orca hook delivery timed out'))
258+ }, HOOK_POST_TIMEOUT_MS)
259+ if (typeof timeout.unref === 'function') timeout.unref()
260+ })
261+ try {
262+ const response = await Promise.race([
263+ fetch(url, {
264+ method: 'POST',
265+ headers: {
266+ 'Content-Type': 'application/json',
267+ 'X-Orca-Agent-Hook-Token': coords.token,
268+ },
269+ body,
270+ ...(controller ? { signal: controller.signal } : {}),
271+ }),
272+ timeoutPromise,
273+ ])
274+ if (!response.ok) throw new Error('Orca hook HTTP ' + response.status)
275+ } catch (error) {
276+ // Why: status reporting must never fail the pi run just because Orca
277+ // is unavailable or the loopback request failed (e.g. Orca restart).
278+ if (!isWslRuntime()) throw error
279+ await postViaWindowsCurl(body, ompRuntime)
280+ } finally {
281+ if (timeout) clearTimeout(timeout)
282+ }
283+ }
284+
285+ // Why: WSL-ness and curl.exe presence cannot change within a process
286+ // lifetime; re-probing /proc and /mnt/c on every failed event would add
287+ // filesystem work to the per-event path.
288+ let cachedIsWslRuntime: boolean | null = null
289+ let cachedWindowsCurlPath: string | null | undefined
290+
291+ function isWslRuntime(): boolean {
292+ if (cachedIsWslRuntime !== null) return cachedIsWslRuntime
293+ cachedIsWslRuntime = detectWslRuntime()
294+ return cachedIsWslRuntime
295+ }
296+
297+ function detectWslRuntime(): boolean {
298+ if (process.env.WSL_DISTRO_NAME) return true
299+ try {
300+ const fs = require('fs')
301+ for (const path of ['/proc/sys/kernel/osrelease', '/proc/version']) {
302+ try {
303+ const contents = String(fs.readFileSync(path, 'utf8'))
304+ if (/microsoft|wsl/i.test(contents)) return true
305+ } catch {
306+ // Why: probe the next runtime hint when a proc file is absent or unreadable.
307+ }
308+ }
309+ } catch {
310+ return false
311+ }
312+ return false
313+ }
314+
315+ function resolveWindowsCurlPath(): string | null {
316+ if (cachedWindowsCurlPath !== undefined) return cachedWindowsCurlPath
317+ try {
318+ const fs = require('fs')
319+ const curlPath = '/mnt/c/Windows/System32/curl.exe'
320+ cachedWindowsCurlPath = fs.existsSync(curlPath) ? curlPath : null
321+ } catch {
322+ cachedWindowsCurlPath = null
323+ }
324+ return cachedWindowsCurlPath
325+ }
326+
327+ // Why: WSL loopback is not the Windows loopback, so use curl.exe on the host.
328+ async function postViaWindowsCurl(body: string, ompRuntime: boolean): Promise<void> {
329+ const curlPath = resolveWindowsCurlPath()
330+ const windowsPort = process.env.ORCA_AGENT_HOOK_PORT
331+ const windowsToken = process.env.ORCA_AGENT_HOOK_TOKEN
332+ if (!curlPath || !windowsPort || !windowsToken) throw new Error('Orca WSL hook bridge unavailable')
333+ // Why: a stale guest endpoint must fall back to current host coordinates.
334+ const windowsUrl = `http://127.0.0.1:${windowsPort}${resolveHookPath(ompRuntime)}`
335+ await new Promise<void>((resolve, reject) => {
336+ const { spawn } = require('child_process')
337+ const child = spawn(
338+ curlPath,
339+ [
340+ '-sS', '--fail',
341+ // WSL interop needs a bounded, acknowledged delivery window.
342+ '--connect-timeout', '3',
343+ '--max-time', '10',
344+ '--noproxy', '127.0.0.1',
345+ '-o', 'NUL',
346+ '-X', 'POST',
347+ '-H', 'Content-Type: application/json',
348+ '-H', `X-Orca-Agent-Hook-Token: ${windowsToken}`,
349+ '--data-binary', '@-',
350+ windowsUrl
351+ ],
352+ { stdio: ['pipe', 'ignore', 'ignore'] }
353+ )
354+ const timer = setTimeout(() => {
355+ try { child.kill() } catch {}
356+ reject(new Error('Orca WSL hook delivery timed out'))
357+ }, 11000)
358+ if (typeof timer.unref === 'function') timer.unref()
359+ const fail = (error: Error): void => { clearTimeout(timer); reject(error) }
360+ child.on('error', fail)
361+ child.stdin.on('error', fail)
362+ child.on('close', (code: number | null) => {
363+ clearTimeout(timer)
364+ if (code === 0) resolve(); else reject(new Error('Orca WSL hook exit ' + code))
365+ })
366+ child.stdin.end(body)
367+ })
368+ }
369+
370+ function statusInputReferencesCredentialPath(value: string): boolean {
371+ const path = value.replace(/\\/g, '/')
372+ return /(?:^|[^a-z0-9_.-]|\$(?:[a-z_][a-z0-9_]*|[0-9]))\.(?:ssh|ssh-mcp)(?=$|[^a-z0-9_.-])/i.test(path) ||
373+ /\.mcp-secrets\.env(?=$|[^a-z0-9_.-])/i.test(path) ||
374+ /(?:^|[^a-z0-9_.-]|\$(?:[a-z_][a-z0-9_]*|[0-9]))\.omp-backups-archive\/omp-bak-keyfile(?=$|[^a-z0-9_.-])/i.test(path)
375+ }
376+
377+ function sanitizeStatusToolInput(input: unknown): unknown {
378+ const ancestors = new WeakSet<object>()
379+ let remainingNodes = 4096
380+ let remainingChars = 262144
381+ function checkText(text: string): void {
382+ remainingChars -= text.length
383+ if (remainingChars < 0 || statusInputReferencesCredentialPath(text)) throw new Error('redact')
384+ }
385+ function copy(value: unknown, depth: number): unknown {
386+ if (--remainingNodes < 0 || depth > 64) throw new Error('redact')
387+ if (typeof value === 'string') { checkText(value); return value }
388+ if (value === null || value === undefined || typeof value === 'boolean') return value
389+ if (typeof value === 'number' && Number.isFinite(value)) return value
390+ if (typeof value !== 'object' || ancestors.has(value)) throw new Error('redact')
391+ const array = Array.isArray(value)
392+ if (array) {
393+ const length = Object.getOwnPropertyDescriptor(value, 'length')?.value
394+ if (!Number.isSafeInteger(length) || length < 0 || length > remainingNodes) throw new Error('redact')
395+ remainingNodes -= length
396+ }
397+ const prototype = Object.getPrototypeOf(value)
398+ if (!array && prototype !== null) {
399+ const constructor = Object.getOwnPropertyDescriptor(prototype, 'constructor')
400+ if (Object.getPrototypeOf(prototype) !== null || !constructor || !('value' in constructor) ||
401+ typeof constructor.value !== 'function' ||
402+ Object.getOwnPropertyDescriptor(constructor.value, 'name')?.value !== 'Object') {
403+ throw new Error('redact')
404+ }
405+ }
406+ ancestors.add(value)
407+ // Copy data descriptors only; never return an object that can run toJSON later.
408+ const result = array ? [] : Object.create(null)
409+ if (array) Object.setPrototypeOf(result, null)
410+ for (const key of Reflect.ownKeys(value)) {
411+ if (typeof key !== 'string') throw new Error('redact')
412+ checkText(key)
413+ const descriptor = Object.getOwnPropertyDescriptor(value, key)
414+ if (!descriptor || !('value' in descriptor)) throw new Error('redact')
415+ const copied = copy(descriptor.value, depth + 1)
416+ Object.defineProperty(result, key, { value: copied, enumerable: descriptor.enumerable,
417+ writable: true, configurable: key !== 'length' || !array })
418+ }
419+ ancestors.delete(value)
420+ return result
421+ }
422+ try { return copy(input, 0) } catch { return { redacted: true } }
423+ }
424+ // Why: pi assistant messages carry content as an array of parts
425+ // ({ type: 'text', text } / tool_use / tool_result / reasoning). We only
426+ // surface the concatenated text parts as the visible 'last assistant
427+ // message' for the dashboard preview — tool_use / reasoning would be
428+ // noise (the dashboard already shows the active tool name + input).
429+ function extractAssistantText(message: unknown): string {
430+ if (!message || typeof message !== 'object') return ''
431+ const content = (message as { content?: unknown }).content
432+ if (typeof content === 'string') return content
433+ if (!Array.isArray(content)) return ''
434+ let out = ''
435+ for (const part of content) {
436+ if (part && typeof part === 'object' && (part as { type?: unknown }).type === 'text') {
437+ const text = (part as { text?: unknown }).text
438+ if (typeof text === 'string') out += text
439+ }
440+ }
441+ return out
442+ }
443+
444+ // Preserve ordinary preview data; credential references never leave the agent host.
445+ // Why: a restarted agent inherits the previous owner PID through env, so a
446+ // dead owner must be claimable or the pane goes silent for good. Only ESRCH
447+ // proves the owner is gone -- every other probe result keeps suppression, so
448+ // a live foreign owner still cannot double-report. Mirrors the tri-state in
449+ // main/agent-hooks/managed-hook-owner-identity.ts, which this runtime cannot
450+ // import (the extension loads inside pi/omp with no Orca deps).
451+ function isStatusOwnerAlive(pid: string): boolean {
452+ const parsed = Number(pid)
453+ if (!Number.isSafeInteger(parsed) || parsed < 1 || parsed > 0x7fffffff) return false
454+ if (typeof process.kill !== 'function') return true
455+ try {
456+ process.kill(parsed, 0)
457+ return true
458+ } catch (err: unknown) {
459+ return (err as { code?: string } | null)?.code !== 'ESRCH'
460+ }
461+ }
462+
463+ // Why: child agents inherit the lead's pane env; only its process may
464+ // register status hooks. PID identity keeps in-process reloads reporting.
465+ export default function (pi): void {
466+ const ownerPid = process.env.ORCA_PI_STATUS_OWNED
467+ const selfPid = String(process.pid)
468+ if (ownerPid && ownerPid !== selfPid && isStatusOwnerAlive(ownerPid)) return
469+ process.env.ORCA_PI_STATUS_OWNED = selfPid
470+ resetPostQueue()
471+ const piEventBus = (pi as { events?: { on?: (name: string, handler: (event: unknown) => void) => void } }).events
472+ const lifecycleState = (piEventBus as { __orcaPiSubagents?: { active: Set<string>; exited?: Set<string>; waiting: boolean; onEvent?: (event: unknown, forcedStatus?: string) => void; listener?: (event: unknown) => void; onRunnerExit?: (event: unknown) => void; runnerExitListener?: (event: unknown) => void } } | undefined)?.__orcaPiSubagents ?? { active: new Set<string>(), waiting: false }
473+ if (piEventBus) (piEventBus as { __orcaPiSubagents?: unknown }).__orcaPiSubagents = lifecycleState
474+ if (piEventBus?.on && !(lifecycleState as { listener?: unknown }).listener) {
475+ const listener = (event: unknown) => lifecycleState.onEvent?.(event)
476+ lifecycleState.listener = listener
477+ piEventBus.on('task:subagent:lifecycle', listener)
478+ piEventBus.on('subagent:async-started', (event: unknown) => lifecycleState.onEvent?.(event, 'started'))
479+ piEventBus.on('subagent:async-complete', (event: unknown) => lifecycleState.onEvent?.(event, 'completed'))
480+ }
481+ if (piEventBus?.on && !lifecycleState.runnerExitListener) {
482+ const runnerExitListener = (event: unknown) => lifecycleState.onRunnerExit?.(event)
483+ lifecycleState.runnerExitListener = runnerExitListener
484+ piEventBus.on('subagent:process-terminal', runnerExitListener)
485+ }
486+ pi.on('session_switch', (_event, ctx) => {
487+ if (!isOmpRuntime()) return
488+ lifecycleState.active.clear()
489+ lifecycleState.exited?.clear()
490+ lifecycleState.waiting = false
491+ resetPostQueue()
492+ clearPendingAgentEndCheck()
493+ updateRuntimeOmpSessionMetadata(ctx)
494+ })
495+ // SessionManager survives reload/new/resume; task children own a different instance.
496+ function sessionProvenance(ctx): { manager: unknown; id?: string; file?: string; parent?: string } | undefined {
497+ const manager = ctx?.sessionManager
498+ if (!manager || typeof manager !== 'object') return undefined
499+ const id = typeof manager.getSessionId === "function" ? manager.getSessionId() : undefined
500+ const file = typeof manager.getSessionFile === "function" ? manager.getSessionFile() : undefined
501+ const header = typeof manager.getHeader === "function" ? manager.getHeader() : undefined
502+ return { manager, id, file, parent: typeof header?.parentSession === "string" ? header.parentSession : undefined }
503+ }
504+
505+ function normalizeSessionPath(file): string {
506+ const normalized = file.replace(/\\/g, "/")
507+ return /^[A-Za-z]:\//.test(normalized) ? normalized.toLowerCase() : normalized
508+ }
509+
510+ function isNestedTaskTranscript(parentFile, candidateFile): boolean {
511+ if (typeof parentFile !== "string" || typeof candidateFile !== "string") return false
512+ const parent = normalizeSessionPath(parentFile)
513+ const candidate = normalizeSessionPath(candidateFile)
514+ const root = parent.endsWith(".jsonl") ? parent.slice(0, -6) : parent
515+ return candidate.startsWith(`${root}/`) && candidate !== parent
516+ }
517+
518+ function ownsSessionStatus(ctx): boolean {
519+ if (!isOmpRuntime()) return true
520+ // Newer OMP builds may expose this computed runtime provenance directly.
521+ if (ctx?.agentKind === "sub") return false
522+ const current = sessionProvenance(ctx)
523+ if (!current) return true
524+ // A task transcript is never the pane's resumable root, even when its
525+ // callback arrives before the root session reports on the shared hook.
526+ if (current.parent) return false
527+ // Keep ownership through module reload and shutdown while child sessions drain.
528+ const key = Symbol.for('orca.omp.status-session-owners')
529+ let owners = Reflect.get(globalThis, key)
530+ if (!(owners instanceof Map)) {
531+ owners = new Map()
532+ Reflect.set(globalThis, key, owners)
533+ }
534+ const pane = JSON.stringify([process.env.ORCA_PANE_KEY, process.env.ORCA_AGENT_LAUNCH_TOKEN])
535+ const owner = owners.get(pane)
536+ if (owner) {
537+ if (owner.manager === current.manager) return true
538+ if (current.parent === owner.file || current.parent === owner.id) return false
539+ if (isNestedTaskTranscript(owner.file, current.file)) return false
540+ return false
541+ }
542+ owners.set(pane, current)
543+ return true
544+ }
545+
546+ function onStatus(name, handler): void {
547+ pi.on(name, (event, ctx) => {
548+ if (!ownsSessionStatus(ctx)) return
549+ return handler(event, ctx)
550+ })
551+ }
552+
553+ onStatus('session_start', () => {})
554+
555+ if (isOmpRuntime() && typeof pi.registerCommand === 'function' && typeof pi.setModel === 'function') {
556+ pi.registerCommand('orca-model', {
557+ description: 'Switch the model selected in Orca',
558+ handler: async (selector, ctx) => {
559+ const models = ctx.modelRegistry.getAvailable()
560+ const model = models.find((candidate) => candidate.provider + '/' + candidate.id === selector.trim())
561+ if (!model) {
562+ ctx.ui.notify('Model is no longer available. Refresh the Orca model picker.', 'error')
563+ return
564+ }
565+ if (!await pi.setModel(model)) {
566+ ctx.ui.notify('Could not switch model: no API key is available.', 'error')
567+ return
568+ }
569+ updateRuntimeOmpSessionMetadata(ctx)
570+ updateModelMetadata({ model })
571+ post('model_select')
572+ }
573+ })
574+ ompModelSwitchSupported = true
575+ }
576+ onStatus('session_start', (event, ctx) => {
577+ updateSessionMetadata(ctx)
578+ piUiPromptDepth = 0
579+ // Why: /reload re-registers the active session, but it is not a
580+ // turn boundary and must not clear the visible status or unread state.
581+ if (event.reason === 'reload') return
582+ post('session_start')
583+ })
584+
585+ onStatus('before_agent_start', (event, ctx) => {
586+ updateRuntimeOmpSessionMetadata(ctx)
587+ post('before_agent_start', { prompt: event.prompt ?? '' })
588+ })
589+
590+ onStatus('agent_start', (_event, ctx) => {
591+ updateRuntimeOmpSessionMetadata(ctx)
592+ clearPendingAgentEndCheck()
593+ lifecycleState.waiting = false
594+ runGeneration += 1
595+ piUiPromptDepth = 0
596+ piTurnInFlight = true
597+ post('agent_start')
598+ })
599+
600+ onStatus('tool_execution_start', (event, ctx) => {
601+ updateRuntimeOmpSessionMetadata(ctx)
602+ post('tool_execution_start', {
603+ tool_name: event.toolName,
604+ tool_input: sanitizeStatusToolInput(event.args),
605+ })
606+ })
607+
608+ onStatus('tool_call', (event, ctx) => {
609+ updateRuntimeOmpSessionMetadata(ctx)
610+ post('tool_call', {
611+ tool_name: event.toolName,
612+ tool_input: sanitizeStatusToolInput(event.input),
613+ })
614+ })
615+
616+ onStatus('tool_execution_end', (event, ctx) => {
617+ updateRuntimeOmpSessionMetadata(ctx)
618+ post('tool_execution_end', {
619+ tool_name: event.toolName,
620+ })
621+ })
622+
623+ onStatus('tool_approval_requested', (event, ctx) => {
624+ updateRuntimeOmpSessionMetadata(ctx)
625+ if (!isOmpRuntime()) return
626+ post('tool_approval_requested', {
627+ tool_name: event.toolName,
628+ reason: event.reason,
629+ approval_mode: event.approvalMode,
630+ })
631+ })
632+
633+ onStatus('tool_approval_resolved', (event, ctx) => {
634+ updateRuntimeOmpSessionMetadata(ctx)
635+ if (!isOmpRuntime()) return
636+ post('tool_approval_resolved', {
637+ tool_name: event.toolName,
638+ approved: event.approved,
639+ })
640+ })
641+
642+ onStatus('ui_prompt_start', () => {
643+ // Idle utility dialogs are not agent work; do not create a completion boundary for them.
644+ if (isOmpRuntime() || !piTurnInFlight) return
645+ piUiPromptDepth++
646+ if (piUiPromptDepth > 1) return
647+ post('ui_prompt_start')
648+ })
649+
650+ onStatus('ui_prompt_end', (_event, ctx) => {
651+ if (isOmpRuntime() || piUiPromptDepth === 0) return
652+ piUiPromptDepth--
653+ if (piUiPromptDepth > 0) return
654+ // Why: ctx.isIdle throws outright once a session-switching modal invalidates the
655+ // runner (it calls assertActive), so local turn state is the floor, not a fallback:
656+ // with no turn in flight, no later event is coming to correct a working verdict, so
657+ // only consult ctx when this process believes work is running.
658+ let isIdle = !piTurnInFlight
659+ try {
660+ if (!isIdle && typeof ctx?.isIdle === 'function') isIdle = ctx.isIdle() === true
661+ } catch {
662+ // Why: a runner this very modal invalidated cannot answer; keep the local verdict.
663+ }
664+ post('ui_prompt_end', { is_idle: isIdle })
665+ })
666+
667+ onStatus('session_shutdown', () => {
668+ resetPostQueue()
669+ clearPendingAgentEndCheck()
670+ if (isOmpRuntime()) return
671+ // Why: pi tears an open dialog down through resetExtensionUI without resolving its
672+ // promise, so a replaced session never emits the matching ui_prompt_end and the wait
673+ // would stick forever. Reset without posting: shutdown is not a turn boundary, and
674+ // the session_start that follows republishes the corrected state.
675+ piUiPromptDepth = 0
676+ })
677+
678+ pi.on('model_select', (event, ctx) => {
679+ updateRuntimeOmpSessionMetadata(ctx)
680+ if (!isOmpRuntime()) return
681+ updateModelMetadata(event)
682+ post('model_select')
683+ })
684+
685+ // Why: capture the assistant's final text on each completed message
686+ // so the dashboard preview reflects the most recent reply even before
687+ // agent_end fires. message_end is the right hook because pi guarantees
688+ // it fires after the message is finalized (post-streaming).
689+ onStatus('message_end', (event, ctx) => {
690+ updateRuntimeOmpSessionMetadata(ctx)
691+ if (event.message?.role !== 'assistant') return
692+ const text = extractAssistantText(event.message)
693+ if (!text) return
694+ post('message_end', { role: 'assistant', text })
695+ })
696+
697+ // Why: modern Pi stays non-idle across retry/compaction/follow-up work,
698+ // while legacy Pi becomes idle after its final agent_end handlers.
699+ // OMP instead marks non-terminal agent_end events with willContinue, so it
700+ // returns before the recheck timer is ever armed.
701+ const AGENT_END_IDLE_RECHECK_MS = 25
702+ const AGENT_END_IDLE_RECHECK_MAX_MS = 250
703+ let agentSettledSupported = false
704+ let runGeneration = 0
705+ let endedRunGeneration = 0
706+ let completionPostedGeneration = -1
707+ let agentEndIdleRecheckMs = AGENT_END_IDLE_RECHECK_MS
708+ let pendingAgentEndCheck: ReturnType<typeof setTimeout> | null = null
709+ let pendingAgentEndContext: { isIdle: () => boolean } | null = null
710+
711+ function clearPendingAgentEndCheck(): void {
712+ if (pendingAgentEndCheck !== null) clearTimeout(pendingAgentEndCheck)
713+ pendingAgentEndCheck = null
714+ pendingAgentEndContext = null
715+ }
716+ const RUNNER_EXIT_GRACE_MS = 2000
717+ let runnerExitCheck: ReturnType<typeof setTimeout> | null = null
718+ lifecycleState.onEvent = (event: unknown, forcedStatus?: string): void => {
719+ if (!event || typeof event !== 'object') return
720+ const record = event as { id?: unknown; runId?: unknown }
721+ const id = typeof record.id === 'string' && record.id ? record.id : typeof record.runId === 'string' ? record.runId : ''
722+ const status = forcedStatus ?? (event as { status?: unknown }).status
723+ if (!id) return
724+ if (status === 'started') { lifecycleState.active.add(id); post('agent_start'); return }
725+ if (status !== 'completed' && status !== 'failed' && status !== 'aborted') return
726+ lifecycleState.active.delete(id)
727+ lifecycleState.exited?.delete(id)
728+ if (lifecycleState.waiting) postAgentEndOnce()
729+ }
730+ lifecycleState.onRunnerExit = (event: unknown): void => {
731+ const runId = event && typeof event === 'object' ? (event as { runId?: unknown }).runId : undefined
732+ if (typeof runId !== 'string' || !lifecycleState.active.has(runId)) return
733+ if (!lifecycleState.exited) lifecycleState.exited = new Set<string>()
734+ lifecycleState.exited.add(runId)
735+ if (!lifecycleState.waiting) return
736+ if (runnerExitCheck !== null) clearTimeout(runnerExitCheck)
737+ runnerExitCheck = setTimeout(() => {
738+ runnerExitCheck = null
739+ if (lifecycleState.waiting) postAgentEndOnce()
740+ }, RUNNER_EXIT_GRACE_MS)
741+ if (typeof runnerExitCheck.unref === 'function') runnerExitCheck.unref()
742+ }
743+ function postAgentEndOnce(): void {
744+ for (const id of lifecycleState.exited ?? []) lifecycleState.active.delete(id)
745+ lifecycleState.exited?.clear()
746+ if (lifecycleState.active.size > 0) {
747+ lifecycleState.waiting = true
748+ return
749+ }
750+ lifecycleState.waiting = false
751+ if (completionPostedGeneration === endedRunGeneration) return
752+ completionPostedGeneration = endedRunGeneration
753+ piTurnInFlight = false
754+ post('agent_end')
755+ }
756+
757+ function checkPendingAgentEnd(): void {
758+ pendingAgentEndCheck = null
759+ const ctx = pendingAgentEndContext
760+ if (!ctx || agentSettledSupported || completionPostedGeneration === endedRunGeneration) {
761+ pendingAgentEndContext = null
762+ return
763+ }
764+ try {
765+ if (ctx.isIdle()) {
766+ pendingAgentEndContext = null
767+ postAgentEndOnce()
768+ return
769+ }
770+ } catch {
771+ pendingAgentEndContext = null
772+ return
773+ }
774+ pendingAgentEndCheck = setTimeout(checkPendingAgentEnd, agentEndIdleRecheckMs)
775+ if (typeof pendingAgentEndCheck.unref === 'function') pendingAgentEndCheck.unref()
776+ agentEndIdleRecheckMs = Math.min(agentEndIdleRecheckMs * 2, AGENT_END_IDLE_RECHECK_MAX_MS)
777+ }
778+
779+ onStatus('agent_settled', (_event, ctx) => {
780+ updateRuntimeOmpSessionMetadata(ctx)
781+ agentSettledSupported = true
782+ clearPendingAgentEndCheck()
783+ postAgentEndOnce()
784+ })
785+
786+ onStatus('agent_end', (event, ctx) => {
787+ updateRuntimeOmpSessionMetadata(ctx)
788+ if (event?.willContinue === true) {
789+ clearPendingAgentEndCheck()
790+ return
791+ }
792+ endedRunGeneration = runGeneration
793+ if (isOmpRuntime()) {
794+ postAgentEndOnce()
795+ return
796+ }
797+ if (agentSettledSupported) return
798+ if (!ctx || typeof ctx.isIdle !== 'function') {
799+ postAgentEndOnce()
800+ return
801+ }
802+ clearPendingAgentEndCheck()
803+ agentEndIdleRecheckMs = AGENT_END_IDLE_RECHECK_MS
804+ pendingAgentEndContext = ctx
805+ pendingAgentEndCheck = setTimeout(checkPendingAgentEnd, 0)
806+ if (typeof pendingAgentEndCheck.unref === 'function') pendingAgentEndCheck.unref()
807+ })
808+ }
+13−0extensions/orca-prefill.tsaddedView File
@@ -0,0 +1,13 @@
1+ // @orca-managed-pi-extension
2+ export default function (pi) {
3+ pi.on('session_start', async (event, ctx) => {
4+ if (!process.env.ORCA_PANE_KEY || ctx?.hasUI === false) return
5+ if (event.reason !== 'startup') return
6+ const prefill = process.env.ORCA_PI_PREFILL
7+ if (!prefill || typeof ctx?.ui?.setEditorText !== 'function') return
8+ delete process.env.ORCA_PI_PREFILL
9+ try {
10+ ctx.ui.setEditorText(prefill)
11+ } catch {}
12+ })
13+ }
+327−0extensions/orca-titlebar-spinner.tsaddedView File
@@ -0,0 +1,327 @@
1+ // @orca-managed-pi-extension
2+ function processName(value: unknown): string {
3+ return String(value || '').split(/[\\/]/).pop()?.toLowerCase() || ''
4+ }
5+
6+ const CONFIGURED_HOOK_PATH = '/hook/pi'
7+ let cachedOmpRuntime: boolean | null = null
8+
9+ function isOmpRuntime(): boolean {
10+ if (cachedOmpRuntime !== null) return cachedOmpRuntime
11+ if (CONFIGURED_HOOK_PATH === '/hook/omp') {
12+ cachedOmpRuntime = true
13+ return true
14+ }
15+ const executableNames = [
16+ processName(process.title),
17+ processName(process.env._),
18+ processName(process.argv[1]),
19+ processName(process.argv[0])
20+ ]
21+ cachedOmpRuntime = executableNames.some((name) =>
22+ ['omp', 'omp.js', 'omp.sh', 'omp.cmd', 'omp.exe', 'omp.bat'].includes(name)
23+ )
24+ return cachedOmpRuntime
25+ }
26+
27+ const BRAILLE_FRAMES = [
28+ '\u280b',
29+ '\u2819',
30+ '\u2839',
31+ '\u2838',
32+ '\u283c',
33+ '\u2834',
34+ '\u2826',
35+ '\u2827',
36+ '\u2807',
37+ '\u280f'
38+ ]
39+
40+ const FRAME_INTERVAL_MS = 80
41+ // Why: pi repaints the title from its own writers (session_info_changed, the win32
42+ // update-check restore) with no event we observe, so the marker has to be re-asserted
43+ // even when no spinner frame is due. Coarse on purpose: it only rewrites one string.
44+ const MARKER_REASSERT_MS = 1000
45+ const AGENT_END_IDLE_RECHECK_MS = 25
46+ const AGENT_END_IDLE_RECHECK_MAX_MS = 250
47+ // Why: a failed idle compaction can end without auto_compaction_end, and no agent turn will
48+ // close a maintenance spinner — cap it so idle maintenance cannot strand a working title.
49+ const IDLE_COMPACTION_MAX_FRAMES = Math.ceil(300000 / FRAME_INTERVAL_MS)
50+
51+ // Why: `-` is the plain separator; `!` is the state marker Orca reads as needs-input
52+ // (src/shared/pi-state-title-marker.ts), so mobile and the CLI see the wait too.
53+ function getMarkedTitle(pi, marker) {
54+ const cwd = process.cwd().split(/[\\/]/).filter(Boolean).at(-1) || process.cwd()
55+ const session = pi.getSessionName()
56+ return session
57+ ? `\u03c0 ${marker} ${session} - ${cwd}`
58+ : `\u03c0 ${marker} ${cwd}`
59+ }
60+
61+ function getBaseTitle(pi) {
62+ return getMarkedTitle(pi, '-')
63+ }
64+
65+ // Why: the ctx.ui pi passes is a getter that calls assertActive() and throws once a
66+ // session-replacing dialog invalidates the runner; optional chaining cannot screen
67+ // that out. Read it behind a try and never mutate state before a paint has succeeded.
68+ function resolvePainter(ctx) {
69+ try {
70+ return typeof ctx?.ui?.setTitle === 'function' ? ctx : null
71+ } catch {
72+ return null
73+ }
74+ }
75+
76+ export default function (pi) {
77+ if (!process.env.ORCA_PANE_KEY) return
78+ // Why: child agents inherit the pane env, and the spinner is harmlessly
79+ // per-process — but the needs-input marker is status the pane reports, so only
80+ // one process may assert it. Mirrors ORCA_PI_STATUS_OWNED in the status hook.
81+ const markerOwnerPid = process.env.ORCA_PI_TITLE_MARKER_OWNED
82+ const ownsMarker = !markerOwnerPid || markerOwnerPid === String(process.pid)
83+ if (ownsMarker) process.env.ORCA_PI_TITLE_MARKER_OWNED = String(process.pid)
84+ // Why: replacement factories share the process realm; retire the old owner before painting.
85+ const ownersKey = Symbol.for('orca.pi.titlebar.owners')
86+ const owners = globalThis[ownersKey] ??= new Map()
87+ const paneKey = process.env.ORCA_PANE_KEY
88+ owners.get(paneKey)?.()
89+ let disposed = false
90+ function clearOwnedTimers() {
91+ clearPendingAgentEndCheck()
92+ clearAnimation()
93+ stopMarkerReassert()
94+ }
95+
96+ function dispose() {
97+ disposed = true
98+ clearOwnedTimers()
99+ resetPromptState()
100+ if (owners.get(paneKey) === dispose) owners.delete(paneKey)
101+ }
102+ owners.set(paneKey, dispose)
103+
104+ function on(name, handler) {
105+ pi.on(name, (event, ctx) => {
106+ if (!disposed) return handler(event, ctx)
107+ })
108+ }
109+
110+ let timer = null
111+ let frameIndex = 0
112+ // Why: only idle maintenance owns a spinner of its own. A threshold compaction runs
113+ // inside an agent turn, whose spinner must outlive it, and any newer start clears the
114+ // marker so a late idle completion cannot stop current work (#16470).
115+ let idleCompactionOwnsSpinner = false
116+ // Why: pi already collapses nested prompts into one start/end pair, so this counter
117+ // guards a close that never arrives, not nesting. A new turn cannot start under a
118+ // dialog holding input focus, so agent_start doubles as recovery.
119+ let promptDepth = 0
120+ let markerPainted = false
121+ let promptCtx = null
122+ // Why: a separate handle from `timer`, which clearAnimation() nulls — the marker must
123+ // survive a turn settling, a shutdown of the spinner, and the idle-maintenance cap.
124+ let markerTimer = null
125+ let pendingAgentEndCheck = null
126+ let pendingAgentEndContext = null
127+ let agentEndIdleRecheckMs = AGENT_END_IDLE_RECHECK_MS
128+
129+ // Why: buildTitle runs inside the try because it is not safe either — getSessionName()
130+ // calls assertActive() and process.cwd() throws ENOENT once the worktree is deleted.
131+ // Most call sites are timer callbacks, where an escape is an uncaught exception and pi
132+ // exits(1) through its own uncaughtException handler.
133+ function paintTitle(ctx, buildTitle) {
134+ if (disposed || !ctx) return false
135+ try {
136+ ctx.ui.setTitle(buildTitle())
137+ return true
138+ } catch {
139+ clearOwnedTimers()
140+ return false
141+ }
142+ }
143+
144+ function resetPromptState() {
145+ stopMarkerReassert()
146+ promptDepth = 0
147+ markerPainted = false
148+ promptCtx = null
149+ }
150+
151+ function clearPendingAgentEndCheck() {
152+ if (pendingAgentEndCheck !== null) clearTimeout(pendingAgentEndCheck)
153+ pendingAgentEndCheck = null
154+ pendingAgentEndContext = null
155+ }
156+
157+ function stopMarkerReassert() {
158+ if (markerTimer) clearInterval(markerTimer)
159+ markerTimer = null
160+ }
161+
162+ function startMarkerReassert(ctx) {
163+ stopMarkerReassert()
164+ markerTimer = setInterval(() => paintTitle(ctx, () => getMarkedTitle(pi, '!')), MARKER_REASSERT_MS)
165+ if (typeof markerTimer.unref === 'function') markerTimer.unref()
166+ }
167+
168+ function clearAnimation() {
169+ if (timer) {
170+ clearInterval(timer)
171+ timer = null
172+ }
173+ frameIndex = 0
174+ idleCompactionOwnsSpinner = false
175+ }
176+
177+ function stopAnimation(ctx) {
178+ clearPendingAgentEndCheck()
179+ clearAnimation()
180+ // Why: settling under an open dialog still leaves the pane waiting on the user, so
181+ // the idle title must not retire the marker the dialog is holding.
182+ paintTitle(ctx, () => (markerPainted ? getMarkedTitle(pi, '!') : getBaseTitle(pi)))
183+ }
184+
185+ function renderFrame(ctx) {
186+ // Why: the maintenance cap runs before the dialog guard so a dialog left open
187+ // cannot suspend it; stopAnimation keeps the marker while a dialog is open.
188+ if (idleCompactionOwnsSpinner && frameIndex >= IDLE_COMPACTION_MAX_FRAMES) {
189+ stopAnimation(ctx)
190+ return
191+ }
192+ // Why: an 80ms working frame would repaint over the needs-input marker within one
193+ // tick, so a mid-turn dialog would still look busy everywhere the title is the
194+ // only evidence. Re-assert rather than skip: pi repaints the title on its own
195+ // (session_info_changed, resetExtensionUI, rebindCurrentSession) and would
196+ // otherwise wipe the marker with nothing to restore it. The frame still counts,
197+ // so the cap above keeps accruing in wall-clock.
198+ if (markerPainted) {
199+ const painted = paintTitle(ctx, () => getMarkedTitle(pi, '!'))
200+ frameIndex++
201+ return painted
202+ }
203+ const painted = paintTitle(ctx, () => {
204+ const frame = BRAILLE_FRAMES[frameIndex % BRAILLE_FRAMES.length]
205+ const cwd = process.cwd().split(/[\\/]/).filter(Boolean).at(-1) || process.cwd()
206+ const session = pi.getSessionName()
207+ return session ? `${frame} \u03c0 - ${session} - ${cwd}` : `${frame} \u03c0 - ${cwd}`
208+ })
209+ frameIndex++
210+ return painted
211+ }
212+
213+ function startAnimation(ctx) {
214+ clearPendingAgentEndCheck()
215+ clearAnimation()
216+ if (!renderFrame(ctx)) return
217+ timer = setInterval(() => renderFrame(ctx), FRAME_INTERVAL_MS)
218+ }
219+
220+ function checkPendingAgentEnd() {
221+ pendingAgentEndCheck = null
222+ const ctx = pendingAgentEndContext
223+ if (!ctx) return
224+ try {
225+ if (ctx.isIdle()) {
226+ pendingAgentEndContext = null
227+ stopAnimation(ctx)
228+ return
229+ }
230+ } catch {
231+ clearOwnedTimers()
232+ return
233+ }
234+ pendingAgentEndCheck = setTimeout(checkPendingAgentEnd, agentEndIdleRecheckMs)
235+ if (typeof pendingAgentEndCheck.unref === 'function') pendingAgentEndCheck.unref()
236+ agentEndIdleRecheckMs = Math.min(agentEndIdleRecheckMs * 2, AGENT_END_IDLE_RECHECK_MAX_MS)
237+ }
238+
239+ on('agent_start', async (_event, ctx) => {
240+ resetPromptState()
241+ startAnimation(ctx)
242+ })
243+
244+ // Why: pi drops an open dialog through resetExtensionUI without resolving its promise,
245+ // so a replaced or reloaded session never sends the matching close. Both boundaries
246+ // prove no dialog from the old session is still on screen.
247+ on('session_start', async () => {
248+ clearOwnedTimers()
249+ resetPromptState()
250+ })
251+
252+ // Why: modern Pi emits agent_end mid-run and only settle later, so settlement is the
253+ // authoritative completion boundary. Legacy runtimes never emit it, so agent_end stays.
254+ on('agent_settled', async (_event, ctx) => {
255+ stopAnimation(ctx)
256+ })
257+
258+ on('agent_end', async (event, ctx) => {
259+ if (event?.willContinue === true) {
260+ clearPendingAgentEndCheck()
261+ return
262+ }
263+ if (isOmpRuntime() || !ctx || typeof ctx.isIdle !== 'function') {
264+ stopAnimation(ctx)
265+ return
266+ }
267+ clearPendingAgentEndCheck()
268+ agentEndIdleRecheckMs = AGENT_END_IDLE_RECHECK_MS
269+ pendingAgentEndContext = ctx
270+ pendingAgentEndCheck = setTimeout(checkPendingAgentEnd, 0)
271+ if (typeof pendingAgentEndCheck.unref === 'function') pendingAgentEndCheck.unref()
272+ })
273+
274+ on('ui_prompt_start', async (_event, ctx) => {
275+ if (isOmpRuntime() || !ownsMarker) return
276+ promptDepth++
277+ // Why: retry on every open rather than only the outermost, so an outer ctx
278+ // that could not paint cannot decide the whole stack stays unmarked.
279+ if (markerPainted) return
280+ const painter = resolvePainter(ctx)
281+ // Why: only hold the spinner off once the marker is actually up, or a ctx
282+ // that cannot paint would freeze the title on its last working frame.
283+ if (!paintTitle(painter, () => getMarkedTitle(pi, '!'))) return
284+ markerPainted = true
285+ promptCtx = painter
286+ startMarkerReassert(painter)
287+ })
288+
289+ on('ui_prompt_end', async (_event, ctx) => {
290+ if (isOmpRuntime() || !ownsMarker || promptDepth === 0) return
291+ promptDepth--
292+ if (promptDepth > 0) return
293+ // Why: the opening ctx already painted once, so a close whose own ctx is stale
294+ // does not leave the needs-input marker up until the next turn.
295+ const painter = resolvePainter(ctx) ?? promptCtx
296+ markerPainted = false
297+ promptCtx = null
298+ stopMarkerReassert()
299+ // Why: a still-live turn resumes its spinner in place; otherwise the pane is idle
300+ // and must drop the needs-input marker rather than keep asking for attention.
301+ if (timer) {
302+ renderFrame(painter)
303+ return
304+ }
305+ paintTitle(painter, () => getBaseTitle(pi))
306+ })
307+
308+ on('auto_compaction_start', async (event, ctx) => {
309+ if (event?.reason !== 'idle') return
310+ // Why: the idle worker can fire against a turn that just started, and reason alone does
311+ // not prove the pane is idle. Adopting a live agent spinner would let the matching
312+ // auto_compaction_end mark the pane idle mid-run — the inverse of #16470.
313+ if (timer && !idleCompactionOwnsSpinner) return
314+ startAnimation(ctx)
315+ idleCompactionOwnsSpinner = true
316+ })
317+
318+ on('auto_compaction_end', async (_event, ctx) => {
319+ if (!idleCompactionOwnsSpinner) return
320+ stopAnimation(ctx)
321+ })
322+
323+ on('session_shutdown', async (_event, ctx) => {
324+ resetPromptState()
325+ stopAnimation(ctx)
326+ })
327+ }
+3−0keybindings.jsonaddedView File
@@ -0,0 +1,3 @@
1+ {
2+ "app.clipboard.pasteImage": []
3+ }
+7−0mcp.jsonaddedView File
@@ -0,0 +1,7 @@
1+ {
2+ "mcpServers": {
3+ "openship": {
4+ "url": "https://dash.timetablex.space/api/mcp"
5+ }
6+ }
7+ }
+17−0settings.jsonaddedView File
@@ -0,0 +1,17 @@
1+ {
2+ "theme": "omarchy-system",
3+ "lastChangelogVersion": "1.0.0",
4+ "defaultProvider": "CLIProxyAPI",
5+ "defaultModel": "claude-opus-5-5",
6+ "packages": [
7+ "npm:pi-cliproxyapi-provider",
8+ "npm:pi-web-access",
9+ "npm:@tintinweb/pi-subagents",
10+ "npm:@pi-archimedes/image-paste",
11+ "npm:@juicesharp/rpiv-ask-user-question",
12+ "npm:@quintinshaw/pi-dynamic-workflows"
13+ ],
14+ "markdown": {
15+ "mermaid": "streaming"
16+ }
17+ }
+1−0skills/diagnose-crashaddedView File
@@ -0,0 +1 @@
1+ /usr/share/omarchy/default/agents/skills/diagnose-crash
+1−0skills/omarchyaddedView File
@@ -0,0 +1 @@
1+ /usr/share/omarchy/default/agents/skills/omarchy
+95−0themes/omarchy-system.jsonaddedView File
@@ -0,0 +1,95 @@
1+ {
2+ "$schema": "https://raw.githubusercontent.com/earendil-works/pi/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json",
3+ "name": "omarchy-system",
4+ "vars": {
5+ "background": "#1e1e2e",
6+ "foreground": "#cdd6f4",
7+ "accent": "#89b4fa",
8+ "selectionBackground": "#45475a",
9+ "selectionForeground": "#cdd6f4",
10+ "selectedBackground": "#363f5b",
11+ "color0": "#1e1e2e",
12+ "color1": "#f38ba8",
13+ "color2": "#a6e3a1",
14+ "color3": "#f9e2af",
15+ "color4": "#89b4fa",
16+ "color5": "#f5c2e7",
17+ "color6": "#94e2d5",
18+ "color7": "#cdd6f4",
19+ "color8": "#585b70",
20+ "color9": "#f38ba8",
21+ "color10": "#a6e3a1",
22+ "color11": "#f9e2af",
23+ "color12": "#89b4fa",
24+ "color13": "#f5c2e7",
25+ "color14": "#94e2d5",
26+ "color15": "#cdd6f4",
27+ "panel": "#29293a",
28+ "panelAlt": "#303042",
29+ "panelPending": "#2b3046",
30+ "panelSuccess": "#2e363c",
31+ "panelError": "#382b3d",
32+ "border": "#535569",
33+ "borderMuted": "#414356",
34+ "mutedText": "#9197b1",
35+ "dimText": "#72768d"
36+ },
37+ "colors": {
38+ "accent": "accent",
39+ "border": "border",
40+ "borderAccent": "accent",
41+ "borderMuted": "borderMuted",
42+ "success": "color2",
43+ "error": "color1",
44+ "warning": "color3",
45+ "muted": "mutedText",
46+ "dim": "dimText",
47+ "text": "foreground",
48+ "thinkingText": "dimText",
49+ "selectedBg": "selectedBackground",
50+ "userMessageBg": "panel",
51+ "userMessageText": "foreground",
52+ "customMessageBg": "panel",
53+ "customMessageText": "foreground",
54+ "customMessageLabel": "accent",
55+ "toolPendingBg": "panelPending",
56+ "toolSuccessBg": "panelSuccess",
57+ "toolErrorBg": "panelError",
58+ "toolTitle": "accent",
59+ "toolOutput": "foreground",
60+ "mdHeading": "color5",
61+ "mdLink": "accent",
62+ "mdLinkUrl": "color6",
63+ "mdCode": "color6",
64+ "mdCodeBlock": "foreground",
65+ "mdCodeBlockBorder": "background",
66+ "mdQuote": "mutedText",
67+ "mdQuoteBorder": "borderMuted",
68+ "mdHr": "borderMuted",
69+ "mdListBullet": "color5",
70+ "toolDiffAdded": "color2",
71+ "toolDiffRemoved": "color1",
72+ "toolDiffContext": "mutedText",
73+ "syntaxComment": "dimText",
74+ "syntaxKeyword": "color5",
75+ "syntaxFunction": "color4",
76+ "syntaxVariable": "accent",
77+ "syntaxString": "color2",
78+ "syntaxNumber": "color3",
79+ "syntaxType": "color4",
80+ "syntaxOperator": "color5",
81+ "syntaxPunctuation": "mutedText",
82+ "thinkingOff": "borderMuted",
83+ "thinkingMinimal": "accent",
84+ "thinkingLow": "color4",
85+ "thinkingMedium": "color5",
86+ "thinkingHigh": "color3",
87+ "thinkingXhigh": "color1",
88+ "bashMode": "color3"
89+ },
90+ "export": {
91+ "pageBg": "#1e1e2e",
92+ "cardBg": "#29293a",
93+ "infoBg": "#303042"
94+ }
95+ }