mirror of
https://github.com/stablyai/orca.git
synced 2026-10-01 08:01:56 +00:00
* fix(pi): carry modal waits to mobile and stop losing the dialog close Follow-ups to #18836, from its readiness review. - Paint pi's `!` needs-input state marker while a dialog is open, so the 80ms spinner frame stops repainting a working title over a mid-turn wait. Mobile and the CLI read the title, so they saw `working` where the desktop already showed `waiting`. - Keep the assistant reply that lands while a dialog is open. The modal guard cleared tool fields and the `message_end` capture with them, so a turn ending under a dialog left the preview on the previous message. - Report `ui_prompt_end` even when `ctx.isIdle()` throws on a runner the modal itself invalidated; the lost post stranded the pane on `waiting`. - Declare the `esbuild` the runtime smoke tool imports. * fix(pi): hold the needs-input marker until the dialog actually closes From review of the previous commit. - Settling under an open dialog no longer retires the marker. stopAnimation painted the plain title unconditionally, so agent_settled, a resolved agent_end, or an idle auto_compaction_end erased it mid-dialog — and because that also cleared the timer, the close then painted the plain title again and the wait was lost for good. - Track the dialog as a boolean, not a depth counter. Pi does its own nesting accounting and emits one pair per stack, which is what the status extension already assumes; two files disagreeing on that would have let an inner close release the outer wait. - Reset the flag on agent_start in both extensions. A turn cannot begin under a dialog holding input focus, so it is the one boundary that can recover a close that never arrived instead of pinning the pane forever. - Leave OMP to its approval events: it reports waits through those already, and painting the marker there too would put title and hook in disagreement. * fix(pi): do not ring the completion bell for a dialog that lost its close From review of the previous commit. - Report working, not done, when ui_prompt_end's isIdle() throws. done is not cosmetic: it reaches dispatchCompletion and fires the pane's finished notification, so a turn that is still running would announce itself. The real done still arrives from agent_end/agent_settled. - Keep the idle-maintenance frame cap accruing while a dialog holds the title, so a dialog left open cannot suspend the guard that stops a compaction spinner whose end event never came. - Guard the dialog handlers against a ctx without ui. The source is generated and untypechecked, and pi does not document the ctx it passes these two events; a TypeError there would surface on every dialog. * fix(pi): let a turn still complete after a dialog loses its runner From review of the previous commit. - Re-arm the completion report when ui_prompt_end's isIdle() throws. The fallback posts working, but the finished turn had already reported its end, so nothing further would ever fire and an idle pane sat spinning. - Count dialog depth in both extensions instead of trusting pi to emit one pair per stack. The guarantee is undocumented, and if it ever does emit a pair per dialog, an inner close would release the wait the outer dialog still holds. A counter costs nothing and drops the dependency. * fix(pi): decide a dialog close from turn state, not from a guess From review of the previous commit. - Fall back to agentEndReported when ctx.isIdle is unavailable or throws. The previous guess of working stranded the common case — a dialog opened at idle — because no later event was coming to correct it, and the agentEndReported re-arm it relied on could not fire either. A turn that already reported its end is not still running, and that is knowledge this process holds without needing ctx at all. - Only suppress spinner frames once the marker is actually painted. Pi may pass a ctx with no ui, and freezing the title on its last working frame is the opposite of what the marker is for. - Gate the titlebar dialog handlers on the OMP runtime too, not just the installed kind: a bare-shell OMP launch runs inside a pi-kind pane, and the status extension already defers there. Extracted that check so both extensions share it rather than carrying two copies. * fix(pi): treat a pane that never ran a turn as idle, not busy From review of the previous commit. - Track turn-in-flight separately from agentEndReported. That flag also dedupes the completion post, so it starts false on a pane that has not run a turn — which read as still-running and left a dialog opened before the first prompt spinning forever. - Retry the marker paint on each dialog open instead of only the outermost, so an outer ctx without ui cannot decide the whole nested stack goes unmarked. - Fall back to the opening ctx when the close carries no ui. Nothing else clears the needs-input marker, so the pane would have kept asking for attention until the next turn. * fix(pi): keep a dying dialog ctx from stranding the needs-input marker The close path paints through the ctx captured at open time, which is the one a session-switching modal is most likely to have invalidated. Guard both paint sites so a throw cannot reject the handler and leave the title on the needs-input marker, and make local turn state the floor for the status extension's idleness verdict instead of a fallback. * fix(pi): hold the dialog wait against pi's own title writes and lost closes Reviewed against real Pi 0.85.1 source rather than inference: - ctx.ui is a getter that calls assertActive() and throws once a session- replacing dialog invalidates the runner, so optional chaining never screened it out and the probe sat outside the try. A throw landed after the depth decrement but before markerPainted cleared, stranding the needs-input marker until the next turn. - Pi writes the same terminal title from its own writers with no event we observe, so the marker is now re-asserted rather than merely not overwritten, on a slow timer that outlives the spinner and its cap. - resetExtensionUI drops an open dialog without resolving its promise, so a replaced or reloaded session never emits the matching ui_prompt_end. Both extensions now release the wait on session_start and shutdown. * fix(pi): build the title inside the guard, not as an argument to it paintTitle caught the setTitle throw but not the two calls one argument to its left: pi.getSessionName() asserts runner liveness the same way ctx.ui does, and process.cwd() throws ENOENT once the worktree is unlinked under a live pane. Four of the six call sites are timer callbacks, where an escape is an uncaught exception and pi exits(1) through its own handler — so the cwd route was reachable today. paintTitle now takes a builder and runs it inside the existing try. * fix(pi): let only the pane-owning process assert the needs-input marker The spinner is harmlessly per-process, but the marker is status the pane reports, and child agents inherit ORCA_PANE_KEY. Gate the two dialog handlers on a PID claim, mirroring ORCA_PI_STATUS_OWNED in the status hook.
235 lines
9.8 KiB
TypeScript
235 lines
9.8 KiB
TypeScript
import type { PiAgentKind } from '../../shared/pi-agent-kind'
|
|
import { getPiAgentStatusUiPromptHandlerSourceLines } from './agent-status-ui-prompt-source'
|
|
|
|
// Why: keep the generated handler registrations separate from hook transport;
|
|
// both are independently sizeable and the installed extension concatenates them.
|
|
export function getPiAgentStatusHandlerSourceLines(kind: PiAgentKind): string[] {
|
|
const sessionStartHandler =
|
|
kind !== 'omp'
|
|
? [
|
|
" pi.on('session_start', (event, ctx) => {",
|
|
' updateSessionMetadata(ctx)',
|
|
...(kind === 'pi' ? [' 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')",
|
|
' })',
|
|
''
|
|
]
|
|
: []
|
|
|
|
// Why: OMP can switch sessions in-process, so each latest-only post needs fresh identity.
|
|
const ctxParam = ', ctx'
|
|
const bareCtxParams = '_event, ctx'
|
|
const captureSessionMetadata = [' updateRuntimeOmpSessionMetadata(ctx)']
|
|
const primeDaemonWorkerGuard =
|
|
kind === 'prime-agent'
|
|
? [
|
|
' // Why: Prime loads extensions in both its frontend and event-emitting daemon worker.',
|
|
' if (!process.env.PRIME_AGENT_INTERNAL_DAEMON_WORKER) return'
|
|
]
|
|
: []
|
|
const ownerEnv = kind === 'prime-agent' ? 'ORCA_PRIME_AGENT_STATUS_OWNED' : 'ORCA_PI_STATUS_OWNED'
|
|
|
|
// Why: OMP suppresses its approval lifecycle unless an extension listens for it,
|
|
// and it is the only signal that the run is parked on a permission prompt rather
|
|
// than still working. Prime has no OMP runtime, so the handlers would be dead there.
|
|
const approvalHandlers =
|
|
kind === 'prime-agent'
|
|
? []
|
|
: [
|
|
` pi.on('tool_approval_requested', (event${ctxParam}) => {`,
|
|
...captureSessionMetadata,
|
|
' if (!isOmpRuntime()) return',
|
|
" post('tool_approval_requested', {",
|
|
' tool_name: event.toolName,',
|
|
' reason: event.reason,',
|
|
' approval_mode: event.approvalMode,',
|
|
' })',
|
|
' })',
|
|
'',
|
|
` pi.on('tool_approval_resolved', (event${ctxParam}) => {`,
|
|
...captureSessionMetadata,
|
|
' if (!isOmpRuntime()) return',
|
|
" post('tool_approval_resolved', {",
|
|
' tool_name: event.toolName,',
|
|
' approved: event.approved,',
|
|
' })',
|
|
' })',
|
|
''
|
|
]
|
|
|
|
return [
|
|
'// 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',
|
|
'}',
|
|
'',
|
|
"// Why: pi's tool_call event input shape is tool-specific (event.input is",
|
|
'// the raw args object). The agent-hooks server already runs',
|
|
'// deriveToolInputPreview(toolName, input) to render a friendly preview',
|
|
"// for known tool names ('bash' → command, 'read'/'write'/'edit' → path,",
|
|
'// etc.), so we forward the raw object verbatim under the same field',
|
|
'// names Claude uses (tool_name / tool_input) and let the server pick the',
|
|
'// preview. Keeps tool-name knowledge centralized on the receiver side.',
|
|
"// 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 {',
|
|
...primeDaemonWorkerGuard,
|
|
` const ownerPid = process.env.${ownerEnv}`,
|
|
' const selfPid = String(process.pid)',
|
|
' if (ownerPid && ownerPid !== selfPid) return',
|
|
` process.env.${ownerEnv} = selfPid`,
|
|
...sessionStartHandler,
|
|
` pi.on('before_agent_start', (event${ctxParam}) => {`,
|
|
...captureSessionMetadata,
|
|
" post('before_agent_start', { prompt: event.prompt ?? '' })",
|
|
' })',
|
|
'',
|
|
` pi.on('agent_start', (${bareCtxParams}) => {`,
|
|
...captureSessionMetadata,
|
|
' clearPendingAgentEndCheck()',
|
|
' agentEndReported = false',
|
|
// Why: a turn cannot begin under a dialog holding input focus, so this is the one
|
|
// boundary that can recover a modal whose close never arrived.
|
|
...(kind === 'pi' ? [' piUiPromptDepth = 0', ' piTurnInFlight = true'] : []),
|
|
" post('agent_start')",
|
|
' })',
|
|
'',
|
|
` pi.on('tool_execution_start', (event${ctxParam}) => {`,
|
|
...captureSessionMetadata,
|
|
" post('tool_execution_start', {",
|
|
' tool_name: event.toolName,',
|
|
' tool_input: event.args,',
|
|
' })',
|
|
' })',
|
|
'',
|
|
` pi.on('tool_call', (event${ctxParam}) => {`,
|
|
...captureSessionMetadata,
|
|
" post('tool_call', {",
|
|
' tool_name: event.toolName,',
|
|
' tool_input: event.input,',
|
|
' })',
|
|
' })',
|
|
'',
|
|
` pi.on('tool_execution_end', (event${ctxParam}) => {`,
|
|
...captureSessionMetadata,
|
|
" post('tool_execution_end', {",
|
|
' tool_name: event.toolName,',
|
|
' })',
|
|
' })',
|
|
'',
|
|
...approvalHandlers,
|
|
...getPiAgentStatusUiPromptHandlerSourceLines(kind),
|
|
" // 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).',
|
|
` pi.on('message_end', (event${ctxParam}) => {`,
|
|
...captureSessionMetadata,
|
|
" 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 agentEndReported = false',
|
|
' 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',
|
|
' }',
|
|
'',
|
|
' // Why: isIdle flips before agent_settled handlers run, so both paths',
|
|
' // share a per-run guard instead of racing duplicate completion posts.',
|
|
' function postAgentEndOnce(): void {',
|
|
' if (agentEndReported) return',
|
|
' agentEndReported = true',
|
|
// Why: distinct from agentEndReported, which also dedupes the completion post and so
|
|
// starts false on a pane that has not run a turn yet — that pane is idle, not busy.
|
|
...(kind === 'pi' ? [' piTurnInFlight = false'] : []),
|
|
" post('agent_end')",
|
|
' }',
|
|
'',
|
|
' function checkPendingAgentEnd(): void {',
|
|
' pendingAgentEndCheck = null',
|
|
' const ctx = pendingAgentEndContext',
|
|
' if (!ctx || agentSettledSupported || agentEndReported) {',
|
|
' 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)',
|
|
' }',
|
|
'',
|
|
` pi.on('agent_settled', (${bareCtxParams}) => {`,
|
|
...captureSessionMetadata,
|
|
' agentSettledSupported = true',
|
|
' clearPendingAgentEndCheck()',
|
|
' postAgentEndOnce()',
|
|
' })',
|
|
'',
|
|
" pi.on('agent_end', (event, ctx) => {",
|
|
...captureSessionMetadata,
|
|
' if (event?.willContinue === true) {',
|
|
' clearPendingAgentEndCheck()',
|
|
' return',
|
|
' }',
|
|
' 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()",
|
|
' })',
|
|
'}',
|
|
''
|
|
]
|
|
}
|