// @orca-managed-pi-extension function processName(value: unknown): string { return String(value || '').split(/[\\/]/).pop()?.toLowerCase() || '' } const CONFIGURED_HOOK_PATH = '/hook/pi' let cachedOmpRuntime: boolean | null = null function isOmpRuntime(): boolean { if (cachedOmpRuntime !== null) return cachedOmpRuntime if (CONFIGURED_HOOK_PATH === '/hook/omp') { cachedOmpRuntime = true return true } const executableNames = [ processName(process.title), processName(process.env._), processName(process.argv[1]), processName(process.argv[0]) ] cachedOmpRuntime = executableNames.some((name) => ['omp', 'omp.js', 'omp.sh', 'omp.cmd', 'omp.exe', 'omp.bat'].includes(name) ) return cachedOmpRuntime } const BRAILLE_FRAMES = [ '\u280b', '\u2819', '\u2839', '\u2838', '\u283c', '\u2834', '\u2826', '\u2827', '\u2807', '\u280f' ] const FRAME_INTERVAL_MS = 80 // Why: pi repaints the title from its own writers (session_info_changed, the win32 // update-check restore) with no event we observe, so the marker has to be re-asserted // even when no spinner frame is due. Coarse on purpose: it only rewrites one string. const MARKER_REASSERT_MS = 1000 const AGENT_END_IDLE_RECHECK_MS = 25 const AGENT_END_IDLE_RECHECK_MAX_MS = 250 // Why: a failed idle compaction can end without auto_compaction_end, and no agent turn will // close a maintenance spinner — cap it so idle maintenance cannot strand a working title. const IDLE_COMPACTION_MAX_FRAMES = Math.ceil(300000 / FRAME_INTERVAL_MS) // Why: `-` is the plain separator; `!` is the state marker Orca reads as needs-input // (src/shared/pi-state-title-marker.ts), so mobile and the CLI see the wait too. function getMarkedTitle(pi, marker) { const cwd = process.cwd().split(/[\\/]/).filter(Boolean).at(-1) || process.cwd() const session = pi.getSessionName() return session ? `\u03c0 ${marker} ${session} - ${cwd}` : `\u03c0 ${marker} ${cwd}` } function getBaseTitle(pi) { return getMarkedTitle(pi, '-') } // Why: the ctx.ui pi passes is a getter that calls assertActive() and throws once a // session-replacing dialog invalidates the runner; optional chaining cannot screen // that out. Read it behind a try and never mutate state before a paint has succeeded. function resolvePainter(ctx) { try { return typeof ctx?.ui?.setTitle === 'function' ? ctx : null } catch { return null } } export default function (pi) { if (!process.env.ORCA_PANE_KEY) return // Why: child agents inherit the pane env, and the spinner is harmlessly // per-process — but the needs-input marker is status the pane reports, so only // one process may assert it. Mirrors ORCA_PI_STATUS_OWNED in the status hook. const markerOwnerPid = process.env.ORCA_PI_TITLE_MARKER_OWNED const ownsMarker = !markerOwnerPid || markerOwnerPid === String(process.pid) if (ownsMarker) process.env.ORCA_PI_TITLE_MARKER_OWNED = String(process.pid) // Why: replacement factories share the process realm; retire the old owner before painting. const ownersKey = Symbol.for('orca.pi.titlebar.owners') const owners = globalThis[ownersKey] ??= new Map() const paneKey = process.env.ORCA_PANE_KEY owners.get(paneKey)?.() let disposed = false function clearOwnedTimers() { clearPendingAgentEndCheck() clearAnimation() stopMarkerReassert() } function dispose() { disposed = true clearOwnedTimers() resetPromptState() if (owners.get(paneKey) === dispose) owners.delete(paneKey) } owners.set(paneKey, dispose) function on(name, handler) { pi.on(name, (event, ctx) => { if (!disposed) return handler(event, ctx) }) } let timer = null let frameIndex = 0 // Why: only idle maintenance owns a spinner of its own. A threshold compaction runs // inside an agent turn, whose spinner must outlive it, and any newer start clears the // marker so a late idle completion cannot stop current work (#16470). let idleCompactionOwnsSpinner = false // Why: pi already collapses nested prompts into one start/end pair, so this counter // guards a close that never arrives, not nesting. A new turn cannot start under a // dialog holding input focus, so agent_start doubles as recovery. let promptDepth = 0 let markerPainted = false let promptCtx = null // Why: a separate handle from `timer`, which clearAnimation() nulls — the marker must // survive a turn settling, a shutdown of the spinner, and the idle-maintenance cap. let markerTimer = null let pendingAgentEndCheck = null let pendingAgentEndContext = null let agentEndIdleRecheckMs = AGENT_END_IDLE_RECHECK_MS // Why: buildTitle runs inside the try because it is not safe either — getSessionName() // calls assertActive() and process.cwd() throws ENOENT once the worktree is deleted. // Most call sites are timer callbacks, where an escape is an uncaught exception and pi // exits(1) through its own uncaughtException handler. function paintTitle(ctx, buildTitle) { if (disposed || !ctx) return false try { ctx.ui.setTitle(buildTitle()) return true } catch { clearOwnedTimers() return false } } function resetPromptState() { stopMarkerReassert() promptDepth = 0 markerPainted = false promptCtx = null } function clearPendingAgentEndCheck() { if (pendingAgentEndCheck !== null) clearTimeout(pendingAgentEndCheck) pendingAgentEndCheck = null pendingAgentEndContext = null } function stopMarkerReassert() { if (markerTimer) clearInterval(markerTimer) markerTimer = null } function startMarkerReassert(ctx) { stopMarkerReassert() markerTimer = setInterval(() => paintTitle(ctx, () => getMarkedTitle(pi, '!')), MARKER_REASSERT_MS) if (typeof markerTimer.unref === 'function') markerTimer.unref() } function clearAnimation() { if (timer) { clearInterval(timer) timer = null } frameIndex = 0 idleCompactionOwnsSpinner = false } function stopAnimation(ctx) { clearPendingAgentEndCheck() clearAnimation() // Why: settling under an open dialog still leaves the pane waiting on the user, so // the idle title must not retire the marker the dialog is holding. paintTitle(ctx, () => (markerPainted ? getMarkedTitle(pi, '!') : getBaseTitle(pi))) } function renderFrame(ctx) { // Why: the maintenance cap runs before the dialog guard so a dialog left open // cannot suspend it; stopAnimation keeps the marker while a dialog is open. if (idleCompactionOwnsSpinner && frameIndex >= IDLE_COMPACTION_MAX_FRAMES) { stopAnimation(ctx) return } // Why: an 80ms working frame would repaint over the needs-input marker within one // tick, so a mid-turn dialog would still look busy everywhere the title is the // only evidence. Re-assert rather than skip: pi repaints the title on its own // (session_info_changed, resetExtensionUI, rebindCurrentSession) and would // otherwise wipe the marker with nothing to restore it. The frame still counts, // so the cap above keeps accruing in wall-clock. if (markerPainted) { const painted = paintTitle(ctx, () => getMarkedTitle(pi, '!')) frameIndex++ return painted } const painted = paintTitle(ctx, () => { const frame = BRAILLE_FRAMES[frameIndex % BRAILLE_FRAMES.length] const cwd = process.cwd().split(/[\\/]/).filter(Boolean).at(-1) || process.cwd() const session = pi.getSessionName() return session ? `${frame} \u03c0 - ${session} - ${cwd}` : `${frame} \u03c0 - ${cwd}` }) frameIndex++ return painted } function startAnimation(ctx) { clearPendingAgentEndCheck() clearAnimation() if (!renderFrame(ctx)) return timer = setInterval(() => renderFrame(ctx), FRAME_INTERVAL_MS) } function checkPendingAgentEnd() { pendingAgentEndCheck = null const ctx = pendingAgentEndContext if (!ctx) return try { if (ctx.isIdle()) { pendingAgentEndContext = null stopAnimation(ctx) return } } catch { clearOwnedTimers() return } pendingAgentEndCheck = setTimeout(checkPendingAgentEnd, agentEndIdleRecheckMs) if (typeof pendingAgentEndCheck.unref === 'function') pendingAgentEndCheck.unref() agentEndIdleRecheckMs = Math.min(agentEndIdleRecheckMs * 2, AGENT_END_IDLE_RECHECK_MAX_MS) } on('agent_start', async (_event, ctx) => { resetPromptState() startAnimation(ctx) }) // Why: pi drops an open dialog through resetExtensionUI without resolving its promise, // so a replaced or reloaded session never sends the matching close. Both boundaries // prove no dialog from the old session is still on screen. on('session_start', async () => { clearOwnedTimers() resetPromptState() }) // Why: modern Pi emits agent_end mid-run and only settle later, so settlement is the // authoritative completion boundary. Legacy runtimes never emit it, so agent_end stays. on('agent_settled', async (_event, ctx) => { stopAnimation(ctx) }) on('agent_end', async (event, ctx) => { if (event?.willContinue === true) { clearPendingAgentEndCheck() return } if (isOmpRuntime() || !ctx || typeof ctx.isIdle !== 'function') { stopAnimation(ctx) return } clearPendingAgentEndCheck() agentEndIdleRecheckMs = AGENT_END_IDLE_RECHECK_MS pendingAgentEndContext = ctx pendingAgentEndCheck = setTimeout(checkPendingAgentEnd, 0) if (typeof pendingAgentEndCheck.unref === 'function') pendingAgentEndCheck.unref() }) on('ui_prompt_start', async (_event, ctx) => { if (isOmpRuntime() || !ownsMarker) return promptDepth++ // Why: retry on every open rather than only the outermost, so an outer ctx // that could not paint cannot decide the whole stack stays unmarked. if (markerPainted) return const painter = resolvePainter(ctx) // Why: only hold the spinner off once the marker is actually up, or a ctx // that cannot paint would freeze the title on its last working frame. if (!paintTitle(painter, () => getMarkedTitle(pi, '!'))) return markerPainted = true promptCtx = painter startMarkerReassert(painter) }) on('ui_prompt_end', async (_event, ctx) => { if (isOmpRuntime() || !ownsMarker || promptDepth === 0) return promptDepth-- if (promptDepth > 0) return // Why: the opening ctx already painted once, so a close whose own ctx is stale // does not leave the needs-input marker up until the next turn. const painter = resolvePainter(ctx) ?? promptCtx markerPainted = false promptCtx = null stopMarkerReassert() // Why: a still-live turn resumes its spinner in place; otherwise the pane is idle // and must drop the needs-input marker rather than keep asking for attention. if (timer) { renderFrame(painter) return } paintTitle(painter, () => getBaseTitle(pi)) }) on('auto_compaction_start', async (event, ctx) => { if (event?.reason !== 'idle') return // Why: the idle worker can fire against a turn that just started, and reason alone does // not prove the pane is idle. Adopting a live agent spinner would let the matching // auto_compaction_end mark the pane idle mid-run — the inverse of #16470. if (timer && !idleCompactionOwnsSpinner) return startAnimation(ctx) idleCompactionOwnsSpinner = true }) on('auto_compaction_end', async (_event, ctx) => { if (!idleCompactionOwnsSpinner) return stopAnimation(ctx) }) on('session_shutdown', async (_event, ctx) => { resetPromptState() stopAnimation(ctx) }) }