// @orca-managed-pi-extension
// Why: no package-specific type import here. Pi and OMP expose the same
// extension API, but publish their types under different package names.
// Why: warn-once so a recurring parse error on a malformed endpoint
// file does not spam stderr inside the pi TUI on every event.
let warnedBadEndpoint = false
// Why: Pi awaits extension handlers. Status delivery stays off that
// critical path, and the latest-only pending slot prevents a stalled
// Orca receiver from building an unbounded queue of obsolete snapshots.
const HOOK_POST_TIMEOUT_MS = 1000
type HookPost = { hookEventName: string; extra: Record<string, unknown>; metadata: Record<string, unknown>; ompRuntime: boolean; revision: number; attempts: number; delivered: boolean }
let activePost = false
let pendingPost: HookPost | null = null
let latestPost: HookPost | null = null
// A newer snapshot or session boundary retires every older retry.
let postRevision = 0
let retryTimer: ReturnType<typeof setTimeout> | null = null
function cancelPostRetry(): void {
if (retryTimer !== null) clearTimeout(retryTimer)
retryTimer = null
}
function resetPostQueue(): void {
cancelPostRetry()
postRevision++
pendingPost = null
latestPost = null
}
function drainPosts(): void {
if (activePost || !pendingPost) return
const next = pendingPost
pendingPost = null
activePost = true
void postOnce(next.hookEventName, next.extra, next.metadata, next.ompRuntime)
.then(() => { next.delivered = true })
.catch(() => {
if (!next.ompRuntime || next.revision !== postRevision) return
if (next.attempts >= 3) {
console.warn('[orca-pi-status] hook delivery failed after retries:', next.hookEventName)
return
}
const delay = 250 * 2 ** next.attempts++
retryTimer = setTimeout(() => {
retryTimer = null
if (next.revision !== postRevision) return
pendingPost = next
drainPosts()
}, delay)
if (typeof retryTimer.unref === 'function') retryTimer.unref()
})
.finally(() => {
activePost = false
drainPosts()
})
}
let piUiPromptDepth = 0
let piTurnInFlight = false
let modelMetadata: Record<string, unknown> = {}
let ompModelSwitchSupported = false
let modelSessionId: unknown = undefined
// Why: OMP switches sessions in-process; a model belongs to the session that
// reported it, so it must not leak onto the first posts of the next one.
function trackModelSession(sessionId: unknown): void {
if (sessionId === modelSessionId) return
modelSessionId = sessionId
modelMetadata = {}
}
// Why: pi and OMP expose the active model as { provider, id } on both the event
// context and model_select events; the joined selector is what --model accepts.
function updateModelMetadata(source: unknown): void {
try {
if (!source || typeof source !== 'object' || !('model' in source)) return
const model = source.model
if (!model || typeof model !== 'object') return
const provider = 'provider' in model && typeof model.provider === 'string' ? model.provider : ''
const id = 'id' in model && typeof model.id === 'string' ? model.id : ''
if (provider && id) modelMetadata = { model: provider + '/' + id, ...(ompModelSwitchSupported ? { model_switch_command: 'orca-model' } : {}) }
} catch {
// Why: a throwing model getter must never break status delivery.
}
}
let sessionMetadata: Record<string, unknown> = {}
let runtimeOmpSessionMetadata: Record<string, unknown> = {}
function updateSessionMetadata(ctx: unknown): void {
const sessionManager = (ctx as { sessionManager?: { getSessionId?: () => unknown; getSessionFile?: () => unknown } } | null)?.sessionManager
const sessionId = sessionManager?.getSessionId?.()
const sessionFile = sessionManager?.getSessionFile?.()
sessionMetadata = typeof sessionId === 'string' && sessionId ? {
session_id: sessionId,
...(typeof sessionFile === 'string' && sessionFile ? { session_file: sessionFile } : {}),
} : {}
}
function updateRuntimeOmpSessionMetadata(ctx: unknown): void {
if (!isOmpRuntime()) return
const sessionManager = (ctx as { sessionManager?: { getSessionId?: () => unknown; getSessionFile?: () => unknown } } | null)?.sessionManager
const sessionId = sessionManager?.getSessionId?.()
const sessionFile = sessionManager?.getSessionFile?.()
runtimeOmpSessionMetadata = typeof sessionId === 'string' && sessionId && typeof sessionFile === 'string' && sessionFile ? { session_id: sessionId, session_file: sessionFile } : {}
trackModelSession(runtimeOmpSessionMetadata.session_id)
updateModelMetadata(ctx)
}
function getPostSessionMetadata(ompRuntime: boolean): Record<string, unknown> {
return ompRuntime ? { ...runtimeOmpSessionMetadata, ...modelMetadata } : sessionMetadata
}
function getPersistedSessionMetadata(): Record<string, unknown> {
const sessionFile = sessionMetadata.session_file
if (typeof sessionFile !== 'string' || !sessionFile) return {}
try {
const fs = require('fs')
// Why: Pi publishes its planned path before creating the transcript;
// recheck on every post so the first completed turn becomes resumable.
return fs.existsSync(sessionFile) ? sessionMetadata : {}
} catch {
return {}
}
}
// Why: re-reading the endpoint file on every event is cheap (small file,
// rare changes) but stat+mtime caching avoids re-parsing on every event
// during streaming tool execution. Mirrors the OpenCode plugin cache shape.
let cachedEndpointKey = ''
let cachedEndpointValues: Record<string, string> | null = null
function readEndpointFile(): Record<string, string> | null {
const path = process.env.ORCA_AGENT_HOOK_ENDPOINT
if (!path) return null
try {
const fs = require('fs')
try {
const stat = fs.statSync(path)
const cacheKey = stat.mtimeMs + ':' + stat.size + ':' + stat.ino
if (cacheKey === cachedEndpointKey && cachedEndpointValues) {
return cachedEndpointValues
}
const contents: string = fs.readFileSync(path, 'utf8')
const out: Record<string, string> = {}
for (const line of contents.split(/\r?\n/)) {
// Why: parse `KEY=VALUE` (POSIX endpoint.env) and `set KEY=VALUE`
// (Windows endpoint.cmd) with one regex; strip a trailing CR so
// mixed-EOL files do not leak \r into the value.
const m = line.match(/^(?:set\s+)?([A-Z0-9_]+)=(.*)$/)
if (m) out[m[1]] = m[2].replace(/\r$/, '')
}
cachedEndpointKey = cacheKey
cachedEndpointValues = out
return out
} catch (ioErr) {
cachedEndpointKey = ''
cachedEndpointValues = null
throw ioErr
}
} catch (err: unknown) {
const code = (err as { code?: string } | null)?.code
if (err && code !== 'ENOENT' && !warnedBadEndpoint) {
warnedBadEndpoint = true
console.warn('[orca-pi-status] failed to parse endpoint file:', (err as Error).message)
}
return null
}
}
function resolveHookCoords() {
const fileEnv = readEndpointFile() || {}
return {
port: fileEnv.ORCA_AGENT_HOOK_PORT || process.env.ORCA_AGENT_HOOK_PORT,
token: fileEnv.ORCA_AGENT_HOOK_TOKEN || process.env.ORCA_AGENT_HOOK_TOKEN,
env: fileEnv.ORCA_AGENT_HOOK_ENV || process.env.ORCA_AGENT_HOOK_ENV || '',
version: fileEnv.ORCA_AGENT_HOOK_VERSION || process.env.ORCA_AGENT_HOOK_VERSION || '',
}
}
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
}
function resolveHookPath(ompRuntime: boolean): string {
// Why: runtime detection keeps a bare-shell OMP launch from reporting as Pi.
if (ompRuntime) return '/hook/omp'
return CONFIGURED_HOOK_PATH
}
function post(hookEventName: string, extra: Record<string, unknown> = {}): void {
const ompRuntime = isOmpRuntime()
cancelPostRetry()
const metadata = getPostSessionMetadata(ompRuntime)
// Model changes must not erase an unacknowledged completion in the latest-only slot.
const previousCompletion = latestPost?.hookEventName === 'agent_end' && !latestPost.delivered && latestPost.metadata.session_id === metadata.session_id
pendingPost = {
revision: ++postRevision,
attempts: 0,
delivered: false,
hookEventName: ompRuntime && hookEventName === 'model_select' && previousCompletion ? 'agent_end' : hookEventName,
extra: { ...extra, ...(!ompRuntime && piUiPromptDepth > 0 ? { ui_prompt_active: true } : {}) },
metadata,
ompRuntime,
}
latestPost = pendingPost
drainPosts()
}
async function postOnce(
hookEventName: string,
extra: Record<string, unknown>,
metadata: Record<string, unknown>,
ompRuntime: boolean
): Promise<void> {
const coords = resolveHookCoords()
const paneKey = process.env.ORCA_PANE_KEY
if (!coords.port || !coords.token || !paneKey) return
const url = `http://127.0.0.1:${coords.port}${resolveHookPath(ompRuntime)}`
const body = JSON.stringify({
paneKey,
launchToken: process.env.ORCA_AGENT_LAUNCH_TOKEN || '',
tabId: process.env.ORCA_TAB_ID || '',
worktreeId: process.env.ORCA_WORKTREE_ID || '',
env: coords.env,
version: coords.version,
payload: { hook_event_name: hookEventName, ...(ompRuntime ? metadata : getPersistedSessionMetadata()), ...extra },
})
const controller = typeof AbortController !== 'undefined' ? new AbortController() : null
let timeout: ReturnType<typeof setTimeout> | undefined
const timeoutPromise = new Promise<never>((_resolve, reject) => {
timeout = setTimeout(() => {
controller?.abort()
reject(new Error('Orca hook delivery timed out'))
}, HOOK_POST_TIMEOUT_MS)
if (typeof timeout.unref === 'function') timeout.unref()
})
try {
const response = await Promise.race([
fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Orca-Agent-Hook-Token': coords.token,
},
body,
...(controller ? { signal: controller.signal } : {}),
}),
timeoutPromise,
])
if (!response.ok) throw new Error('Orca hook HTTP ' + response.status)
} catch (error) {
// Why: status reporting must never fail the pi run just because Orca
// is unavailable or the loopback request failed (e.g. Orca restart).
if (!isWslRuntime()) throw error
await postViaWindowsCurl(body, ompRuntime)
} finally {
if (timeout) clearTimeout(timeout)
}
}
// Why: WSL-ness and curl.exe presence cannot change within a process
// lifetime; re-probing /proc and /mnt/c on every failed event would add
// filesystem work to the per-event path.
let cachedIsWslRuntime: boolean | null = null
let cachedWindowsCurlPath: string | null | undefined
function isWslRuntime(): boolean {
if (cachedIsWslRuntime !== null) return cachedIsWslRuntime
cachedIsWslRuntime = detectWslRuntime()
return cachedIsWslRuntime
}
function detectWslRuntime(): boolean {
if (process.env.WSL_DISTRO_NAME) return true
try {
const fs = require('fs')
for (const path of ['/proc/sys/kernel/osrelease', '/proc/version']) {
try {
const contents = String(fs.readFileSync(path, 'utf8'))
if (/microsoft|wsl/i.test(contents)) return true
} catch {
// Why: probe the next runtime hint when a proc file is absent or unreadable.
}
}
} catch {
return false
}
return false
}
function resolveWindowsCurlPath(): string | null {
if (cachedWindowsCurlPath !== undefined) return cachedWindowsCurlPath
try {
const fs = require('fs')
const curlPath = '/mnt/c/Windows/System32/curl.exe'
cachedWindowsCurlPath = fs.existsSync(curlPath) ? curlPath : null
} catch {
cachedWindowsCurlPath = null
}
return cachedWindowsCurlPath
}
// Why: WSL loopback is not the Windows loopback, so use curl.exe on the host.
async function postViaWindowsCurl(body: string, ompRuntime: boolean): Promise<void> {
const curlPath = resolveWindowsCurlPath()
const windowsPort = process.env.ORCA_AGENT_HOOK_PORT
const windowsToken = process.env.ORCA_AGENT_HOOK_TOKEN
if (!curlPath || !windowsPort || !windowsToken) throw new Error('Orca WSL hook bridge unavailable')
// Why: a stale guest endpoint must fall back to current host coordinates.
const windowsUrl = `http://127.0.0.1:${windowsPort}${resolveHookPath(ompRuntime)}`
await new Promise<void>((resolve, reject) => {
const { spawn } = require('child_process')
const child = spawn(
curlPath,
[
'-sS', '--fail',
// WSL interop needs a bounded, acknowledged delivery window.
'--connect-timeout', '3',
'--max-time', '10',
'--noproxy', '127.0.0.1',
'-o', 'NUL',
'-X', 'POST',
'-H', 'Content-Type: application/json',
'-H', `X-Orca-Agent-Hook-Token: ${windowsToken}`,
'--data-binary', '@-',
windowsUrl
],
{ stdio: ['pipe', 'ignore', 'ignore'] }
)
const timer = setTimeout(() => {
try { child.kill() } catch {}
reject(new Error('Orca WSL hook delivery timed out'))
}, 11000)
if (typeof timer.unref === 'function') timer.unref()
const fail = (error: Error): void => { clearTimeout(timer); reject(error) }
child.on('error', fail)
child.stdin.on('error', fail)
child.on('close', (code: number | null) => {
clearTimeout(timer)
if (code === 0) resolve(); else reject(new Error('Orca WSL hook exit ' + code))
})
child.stdin.end(body)
})
}
function statusInputReferencesCredentialPath(value: string): boolean {
const path = value.replace(/\\/g, '/')
return /(?:^|[^a-z0-9_.-]|\$(?:[a-z_][a-z0-9_]*|[0-9]))\.(?:ssh|ssh-mcp)(?=$|[^a-z0-9_.-])/i.test(path) ||
/\.mcp-secrets\.env(?=$|[^a-z0-9_.-])/i.test(path) ||
/(?:^|[^a-z0-9_.-]|\$(?:[a-z_][a-z0-9_]*|[0-9]))\.omp-backups-archive\/omp-bak-keyfile(?=$|[^a-z0-9_.-])/i.test(path)
}
function sanitizeStatusToolInput(input: unknown): unknown {
const ancestors = new WeakSet<object>()
let remainingNodes = 4096
let remainingChars = 262144
function checkText(text: string): void {
remainingChars -= text.length
if (remainingChars < 0 || statusInputReferencesCredentialPath(text)) throw new Error('redact')
}
function copy(value: unknown, depth: number): unknown {
if (--remainingNodes < 0 || depth > 64) throw new Error('redact')
if (typeof value === 'string') { checkText(value); return value }
if (value === null || value === undefined || typeof value === 'boolean') return value
if (typeof value === 'number' && Number.isFinite(value)) return value
if (typeof value !== 'object' || ancestors.has(value)) throw new Error('redact')
const array = Array.isArray(value)
if (array) {
const length = Object.getOwnPropertyDescriptor(value, 'length')?.value
if (!Number.isSafeInteger(length) || length < 0 || length > remainingNodes) throw new Error('redact')
remainingNodes -= length
}
const prototype = Object.getPrototypeOf(value)
if (!array && prototype !== null) {
const constructor = Object.getOwnPropertyDescriptor(prototype, 'constructor')
if (Object.getPrototypeOf(prototype) !== null || !constructor || !('value' in constructor) ||
typeof constructor.value !== 'function' ||
Object.getOwnPropertyDescriptor(constructor.value, 'name')?.value !== 'Object') {
throw new Error('redact')
}
}
ancestors.add(value)
// Copy data descriptors only; never return an object that can run toJSON later.
const result = array ? [] : Object.create(null)
if (array) Object.setPrototypeOf(result, null)
for (const key of Reflect.ownKeys(value)) {
if (typeof key !== 'string') throw new Error('redact')
checkText(key)
const descriptor = Object.getOwnPropertyDescriptor(value, key)
if (!descriptor || !('value' in descriptor)) throw new Error('redact')
const copied = copy(descriptor.value, depth + 1)
Object.defineProperty(result, key, { value: copied, enumerable: descriptor.enumerable,
writable: true, configurable: key !== 'length' || !array })
}
ancestors.delete(value)
return result
}
try { return copy(input, 0) } catch { return { redacted: true } }
}
// Why: pi assistant messages carry content as an array of parts
// ({ type: 'text', text } / tool_use / tool_result / reasoning). We only
// surface the concatenated text parts as the visible 'last assistant
// message' for the dashboard preview — tool_use / reasoning would be
// noise (the dashboard already shows the active tool name + input).
function extractAssistantText(message: unknown): string {
if (!message || typeof message !== 'object') return ''
const content = (message as { content?: unknown }).content
if (typeof content === 'string') return content
if (!Array.isArray(content)) return ''
let out = ''
for (const part of content) {
if (part && typeof part === 'object' && (part as { type?: unknown }).type === 'text') {
const text = (part as { text?: unknown }).text
if (typeof text === 'string') out += text
}
}
return out
}
// Preserve ordinary preview data; credential references never leave the agent host.
// Why: a restarted agent inherits the previous owner PID through env, so a
// dead owner must be claimable or the pane goes silent for good. Only ESRCH
// proves the owner is gone -- every other probe result keeps suppression, so
// a live foreign owner still cannot double-report. Mirrors the tri-state in
// main/agent-hooks/managed-hook-owner-identity.ts, which this runtime cannot
// import (the extension loads inside pi/omp with no Orca deps).
function isStatusOwnerAlive(pid: string): boolean {
const parsed = Number(pid)
if (!Number.isSafeInteger(parsed) || parsed < 1 || parsed > 0x7fffffff) return false
if (typeof process.kill !== 'function') return true
try {
process.kill(parsed, 0)
return true
} catch (err: unknown) {
return (err as { code?: string } | null)?.code !== 'ESRCH'
}
}
// Why: child agents inherit the lead's pane env; only its process may
// register status hooks. PID identity keeps in-process reloads reporting.
export default function (pi): void {
const ownerPid = process.env.ORCA_PI_STATUS_OWNED
const selfPid = String(process.pid)
if (ownerPid && ownerPid !== selfPid && isStatusOwnerAlive(ownerPid)) return
process.env.ORCA_PI_STATUS_OWNED = selfPid
resetPostQueue()
const piEventBus = (pi as { events?: { on?: (name: string, handler: (event: unknown) => void) => void } }).events
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 }
if (piEventBus) (piEventBus as { __orcaPiSubagents?: unknown }).__orcaPiSubagents = lifecycleState
if (piEventBus?.on && !(lifecycleState as { listener?: unknown }).listener) {
const listener = (event: unknown) => lifecycleState.onEvent?.(event)
lifecycleState.listener = listener
piEventBus.on('task:subagent:lifecycle', listener)
piEventBus.on('subagent:async-started', (event: unknown) => lifecycleState.onEvent?.(event, 'started'))
piEventBus.on('subagent:async-complete', (event: unknown) => lifecycleState.onEvent?.(event, 'completed'))
}
if (piEventBus?.on && !lifecycleState.runnerExitListener) {
const runnerExitListener = (event: unknown) => lifecycleState.onRunnerExit?.(event)
lifecycleState.runnerExitListener = runnerExitListener
piEventBus.on('subagent:process-terminal', runnerExitListener)
}
pi.on('session_switch', (_event, ctx) => {
if (!isOmpRuntime()) return
lifecycleState.active.clear()
lifecycleState.exited?.clear()
lifecycleState.waiting = false
resetPostQueue()
clearPendingAgentEndCheck()
updateRuntimeOmpSessionMetadata(ctx)
})
// SessionManager survives reload/new/resume; task children own a different instance.
function sessionProvenance(ctx): { manager: unknown; id?: string; file?: string; parent?: string } | undefined {
const manager = ctx?.sessionManager
if (!manager || typeof manager !== 'object') return undefined
const id = typeof manager.getSessionId === "function" ? manager.getSessionId() : undefined
const file = typeof manager.getSessionFile === "function" ? manager.getSessionFile() : undefined
const header = typeof manager.getHeader === "function" ? manager.getHeader() : undefined
return { manager, id, file, parent: typeof header?.parentSession === "string" ? header.parentSession : undefined }
}
function normalizeSessionPath(file): string {
const normalized = file.replace(/\\/g, "/")
return /^[A-Za-z]:\//.test(normalized) ? normalized.toLowerCase() : normalized
}
function isNestedTaskTranscript(parentFile, candidateFile): boolean {
if (typeof parentFile !== "string" || typeof candidateFile !== "string") return false
const parent = normalizeSessionPath(parentFile)
const candidate = normalizeSessionPath(candidateFile)
const root = parent.endsWith(".jsonl") ? parent.slice(0, -6) : parent
return candidate.startsWith(`${root}/`) && candidate !== parent
}
function ownsSessionStatus(ctx): boolean {
if (!isOmpRuntime()) return true
// Newer OMP builds may expose this computed runtime provenance directly.
if (ctx?.agentKind === "sub") return false
const current = sessionProvenance(ctx)
if (!current) return true
// A task transcript is never the pane's resumable root, even when its
// callback arrives before the root session reports on the shared hook.
if (current.parent) return false
// Keep ownership through module reload and shutdown while child sessions drain.
const key = Symbol.for('orca.omp.status-session-owners')
let owners = Reflect.get(globalThis, key)
if (!(owners instanceof Map)) {
owners = new Map()
Reflect.set(globalThis, key, owners)
}
const pane = JSON.stringify([process.env.ORCA_PANE_KEY, process.env.ORCA_AGENT_LAUNCH_TOKEN])
const owner = owners.get(pane)
if (owner) {
if (owner.manager === current.manager) return true
if (current.parent === owner.file || current.parent === owner.id) return false
if (isNestedTaskTranscript(owner.file, current.file)) return false
return false
}
owners.set(pane, current)
return true
}
function onStatus(name, handler): void {
pi.on(name, (event, ctx) => {
if (!ownsSessionStatus(ctx)) return
return handler(event, ctx)
})
}
onStatus('session_start', () => {})
if (isOmpRuntime() && typeof pi.registerCommand === 'function' && typeof pi.setModel === 'function') {
pi.registerCommand('orca-model', {
description: 'Switch the model selected in Orca',
handler: async (selector, ctx) => {
const models = ctx.modelRegistry.getAvailable()
const model = models.find((candidate) => candidate.provider + '/' + candidate.id === selector.trim())
if (!model) {
ctx.ui.notify('Model is no longer available. Refresh the Orca model picker.', 'error')
return
}
if (!await pi.setModel(model)) {
ctx.ui.notify('Could not switch model: no API key is available.', 'error')
return
}
updateRuntimeOmpSessionMetadata(ctx)
updateModelMetadata({ model })
post('model_select')
}
})
ompModelSwitchSupported = true
}
onStatus('session_start', (event, ctx) => {
updateSessionMetadata(ctx)
piUiPromptDepth = 0
// Why: /reload re-registers the active session, but it is not a
// turn boundary and must not clear the visible status or unread state.
if (event.reason === 'reload') return
post('session_start')
})
onStatus('before_agent_start', (event, ctx) => {
updateRuntimeOmpSessionMetadata(ctx)
post('before_agent_start', { prompt: event.prompt ?? '' })
})
onStatus('agent_start', (_event, ctx) => {
updateRuntimeOmpSessionMetadata(ctx)
clearPendingAgentEndCheck()
lifecycleState.waiting = false
runGeneration += 1
piUiPromptDepth = 0
piTurnInFlight = true
post('agent_start')
})
onStatus('tool_execution_start', (event, ctx) => {
updateRuntimeOmpSessionMetadata(ctx)
post('tool_execution_start', {
tool_name: event.toolName,
tool_input: sanitizeStatusToolInput(event.args),
})
})
onStatus('tool_call', (event, ctx) => {
updateRuntimeOmpSessionMetadata(ctx)
post('tool_call', {
tool_name: event.toolName,
tool_input: sanitizeStatusToolInput(event.input),
})
})
onStatus('tool_execution_end', (event, ctx) => {
updateRuntimeOmpSessionMetadata(ctx)
post('tool_execution_end', {
tool_name: event.toolName,
})
})
onStatus('tool_approval_requested', (event, ctx) => {
updateRuntimeOmpSessionMetadata(ctx)
if (!isOmpRuntime()) return
post('tool_approval_requested', {
tool_name: event.toolName,
reason: event.reason,
approval_mode: event.approvalMode,
})
})
onStatus('tool_approval_resolved', (event, ctx) => {
updateRuntimeOmpSessionMetadata(ctx)
if (!isOmpRuntime()) return
post('tool_approval_resolved', {
tool_name: event.toolName,
approved: event.approved,
})
})
onStatus('ui_prompt_start', () => {
// Idle utility dialogs are not agent work; do not create a completion boundary for them.
if (isOmpRuntime() || !piTurnInFlight) return
piUiPromptDepth++
if (piUiPromptDepth > 1) return
post('ui_prompt_start')
})
onStatus('ui_prompt_end', (_event, ctx) => {
if (isOmpRuntime() || piUiPromptDepth === 0) return
piUiPromptDepth--
if (piUiPromptDepth > 0) return
// Why: ctx.isIdle throws outright once a session-switching modal invalidates the
// runner (it calls assertActive), so local turn state is the floor, not a fallback:
// with no turn in flight, no later event is coming to correct a working verdict, so
// only consult ctx when this process believes work is running.
let isIdle = !piTurnInFlight
try {
if (!isIdle && typeof ctx?.isIdle === 'function') isIdle = ctx.isIdle() === true
} catch {
// Why: a runner this very modal invalidated cannot answer; keep the local verdict.
}
post('ui_prompt_end', { is_idle: isIdle })
})
onStatus('session_shutdown', () => {
resetPostQueue()
clearPendingAgentEndCheck()
if (isOmpRuntime()) return
// Why: pi tears an open dialog down through resetExtensionUI without resolving its
// promise, so a replaced session never emits the matching ui_prompt_end and the wait
// would stick forever. Reset without posting: shutdown is not a turn boundary, and
// the session_start that follows republishes the corrected state.
piUiPromptDepth = 0
})
pi.on('model_select', (event, ctx) => {
updateRuntimeOmpSessionMetadata(ctx)
if (!isOmpRuntime()) return
updateModelMetadata(event)
post('model_select')
})
// Why: capture the assistant's final text on each completed message
// so the dashboard preview reflects the most recent reply even before
// agent_end fires. message_end is the right hook because pi guarantees
// it fires after the message is finalized (post-streaming).
onStatus('message_end', (event, ctx) => {
updateRuntimeOmpSessionMetadata(ctx)
if (event.message?.role !== 'assistant') return
const text = extractAssistantText(event.message)
if (!text) return
post('message_end', { role: 'assistant', text })
})
// Why: modern Pi stays non-idle across retry/compaction/follow-up work,
// while legacy Pi becomes idle after its final agent_end handlers.
// OMP instead marks non-terminal agent_end events with willContinue, so it
// returns before the recheck timer is ever armed.
const AGENT_END_IDLE_RECHECK_MS = 25
const AGENT_END_IDLE_RECHECK_MAX_MS = 250
let agentSettledSupported = false
let runGeneration = 0
let endedRunGeneration = 0
let completionPostedGeneration = -1
let agentEndIdleRecheckMs = AGENT_END_IDLE_RECHECK_MS
let pendingAgentEndCheck: ReturnType<typeof setTimeout> | null = null
let pendingAgentEndContext: { isIdle: () => boolean } | null = null
function clearPendingAgentEndCheck(): void {
if (pendingAgentEndCheck !== null) clearTimeout(pendingAgentEndCheck)
pendingAgentEndCheck = null
pendingAgentEndContext = null
}
const RUNNER_EXIT_GRACE_MS = 2000
let runnerExitCheck: ReturnType<typeof setTimeout> | null = null
lifecycleState.onEvent = (event: unknown, forcedStatus?: string): void => {
if (!event || typeof event !== 'object') return
const record = event as { id?: unknown; runId?: unknown }
const id = typeof record.id === 'string' && record.id ? record.id : typeof record.runId === 'string' ? record.runId : ''
const status = forcedStatus ?? (event as { status?: unknown }).status
if (!id) return
if (status === 'started') { lifecycleState.active.add(id); post('agent_start'); return }
if (status !== 'completed' && status !== 'failed' && status !== 'aborted') return
lifecycleState.active.delete(id)
lifecycleState.exited?.delete(id)
if (lifecycleState.waiting) postAgentEndOnce()
}
lifecycleState.onRunnerExit = (event: unknown): void => {
const runId = event && typeof event === 'object' ? (event as { runId?: unknown }).runId : undefined
if (typeof runId !== 'string' || !lifecycleState.active.has(runId)) return
if (!lifecycleState.exited) lifecycleState.exited = new Set<string>()
lifecycleState.exited.add(runId)
if (!lifecycleState.waiting) return
if (runnerExitCheck !== null) clearTimeout(runnerExitCheck)
runnerExitCheck = setTimeout(() => {
runnerExitCheck = null
if (lifecycleState.waiting) postAgentEndOnce()
}, RUNNER_EXIT_GRACE_MS)
if (typeof runnerExitCheck.unref === 'function') runnerExitCheck.unref()
}
function postAgentEndOnce(): void {
for (const id of lifecycleState.exited ?? []) lifecycleState.active.delete(id)
lifecycleState.exited?.clear()
if (lifecycleState.active.size > 0) {
lifecycleState.waiting = true
return
}
lifecycleState.waiting = false
if (completionPostedGeneration === endedRunGeneration) return
completionPostedGeneration = endedRunGeneration
piTurnInFlight = false
post('agent_end')
}
function checkPendingAgentEnd(): void {
pendingAgentEndCheck = null
const ctx = pendingAgentEndContext
if (!ctx || agentSettledSupported || completionPostedGeneration === endedRunGeneration) {
pendingAgentEndContext = null
return
}
try {
if (ctx.isIdle()) {
pendingAgentEndContext = null
postAgentEndOnce()
return
}
} catch {
pendingAgentEndContext = null
return
}
pendingAgentEndCheck = setTimeout(checkPendingAgentEnd, agentEndIdleRecheckMs)
if (typeof pendingAgentEndCheck.unref === 'function') pendingAgentEndCheck.unref()
agentEndIdleRecheckMs = Math.min(agentEndIdleRecheckMs * 2, AGENT_END_IDLE_RECHECK_MAX_MS)
}
onStatus('agent_settled', (_event, ctx) => {
updateRuntimeOmpSessionMetadata(ctx)
agentSettledSupported = true
clearPendingAgentEndCheck()
postAgentEndOnce()
})
onStatus('agent_end', (event, ctx) => {
updateRuntimeOmpSessionMetadata(ctx)
if (event?.willContinue === true) {
clearPendingAgentEndCheck()
return
}
endedRunGeneration = runGeneration
if (isOmpRuntime()) {
postAgentEndOnce()
return
}
if (agentSettledSupported) return
if (!ctx || typeof ctx.isIdle !== 'function') {
postAgentEndOnce()
return
}
clearPendingAgentEndCheck()
agentEndIdleRecheckMs = AGENT_END_IDLE_RECHECK_MS
pendingAgentEndContext = ctx
pendingAgentEndCheck = setTimeout(checkPendingAgentEnd, 0)
if (typeof pendingAgentEndCheck.unref === 'function') pendingAgentEndCheck.unref()
})
}