// @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)
})
}