From 6940eaac1aceb43e9d4dd1ca9910694fec259b18 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:17:03 -0700 Subject: [PATCH 01/11] fix(agent-status): admit hook process presence on the execution host --- src/main/agent-hooks/hook-post-command.ts | 2 + .../agent-hooks/hook-process-capture.test.ts | 47 ++++++ src/main/agent-hooks/hook-process-capture.ts | 22 +++ src/main/agent-hooks/hook-stdin-contract.ts | 4 +- .../agent-hooks/runtime-home-hook-command.ts | 3 +- .../agent-hooks/server-agent-presence.test.ts | 146 ++++++++++++++++++ src/main/agent-hooks/server.ts | 4 +- .../server/server-agent-presence.ts | 53 +++++++ src/main/agent-hooks/server/server-cleanup.ts | 8 +- .../server/server-ingest-remote.ts | 9 ++ .../server/server-persistence-validation.ts | 2 + src/main/agent-hooks/server/server-state.ts | 11 ++ .../server/server-status-update.ts | 20 ++- src/main/claude/hook-script.ts | 2 + src/main/orcad/orcad-entry.ts | 1 + ...e-get-unpersisted-tracked-title-for-pty.ts | 4 + ...-runtime-mark-pty-liveness-unverifiable.ts | 3 + .../orca-runtime-preserved-branch-cleanup.ts | 4 + ...ntime-serialize-agent-prompt-submission.ts | 52 ++++++- src/main/runtime/orca-runtime-state-fields.ts | 4 + .../terminal-side-effect-facts.spec.ts | 6 +- .../startup/main-process-runtime-service.ts | 1 + src/relay/agent-hook-envelope-build.ts | 2 + src/relay/agent-hook-request.ts | 84 ++++++++++ src/relay/agent-hook-server.ts | 100 +++++------- src/relay/pty-handler.ts | 18 +++ src/relay/relay-agent-hook-runtime.ts | 3 + src/relay/relay-agent-presence.test.ts | 59 +++++++ src/relay/relay-agent-presence.ts | 34 ++++ ...onnection-command-finished-cleanup.test.ts | 2 +- .../pty-connection/pane-agent-identity.ts | 2 +- ...ent-hook-listener-relay-dependency.test.ts | 1 + src/shared/agent-hook-listener.ts | 34 ++++ .../agent-hook-listener/hook-envelope.ts | 1 + .../agent-hook-listener/listener-event.ts | 3 + src/shared/agent-hook-presence-transition.ts | 44 ++++++ src/shared/agent-hook-relay.ts | 3 + src/shared/agent-hook-spool.ts | 1 + .../agent-process-presence-platform.test.ts | 63 ++++++++ .../agent-process-presence-probe.test.ts | 36 +++++ src/shared/agent-process-presence-probe.ts | 90 +++++++++++ src/shared/agent-process-presence.ts | 66 ++++++++ 42 files changed, 978 insertions(+), 76 deletions(-) create mode 100644 src/main/agent-hooks/hook-process-capture.test.ts create mode 100644 src/main/agent-hooks/hook-process-capture.ts create mode 100644 src/main/agent-hooks/server-agent-presence.test.ts create mode 100644 src/main/agent-hooks/server/server-agent-presence.ts create mode 100644 src/relay/agent-hook-request.ts create mode 100644 src/relay/relay-agent-presence.test.ts create mode 100644 src/relay/relay-agent-presence.ts create mode 100644 src/shared/agent-hook-presence-transition.ts create mode 100644 src/shared/agent-process-presence-platform.test.ts create mode 100644 src/shared/agent-process-presence-probe.test.ts create mode 100644 src/shared/agent-process-presence-probe.ts create mode 100644 src/shared/agent-process-presence.ts diff --git a/src/main/agent-hooks/hook-post-command.ts b/src/main/agent-hooks/hook-post-command.ts index 1595e681899e..8cb8e2071cb0 100644 --- a/src/main/agent-hooks/hook-post-command.ts +++ b/src/main/agent-hooks/hook-post-command.ts @@ -18,6 +18,7 @@ export function buildPosixAgentHookPostCommand( ` ${indent}-H "X-Orca-Agent-Hook-Token: \${ORCA_AGENT_HOOK_TOKEN}" \\`, ` ${indent}-H "X-Orca-Agent-Hook-Meta-Encoding: base64" \\`, ` ${indent}-H "X-Orca-Agent-Hook-Meta: \${orca_hook_metadata}" \\`, + ` ${indent}-H "X-Orca-Agent-Process: \${orca_agent_process:-}" \\`, ` ${indent}--data-binary @-`, 'else', ` printf '%s' "$payload" | ${curlCommand} -sS -X POST "http://127.0.0.1:\${ORCA_AGENT_HOOK_PORT}/hook/${source}" \\`, @@ -31,6 +32,7 @@ export function buildPosixAgentHookPostCommand( ` ${indent}--data-urlencode "worktreeId=\${ORCA_WORKTREE_ID}" \\`, ` ${indent}--data-urlencode "env=\${ORCA_AGENT_HOOK_ENV}" \\`, ` ${indent}--data-urlencode "version=\${ORCA_AGENT_HOOK_VERSION}" \\`, + ` ${indent}--data-urlencode "agentProcess=\${orca_agent_process:-}" \\`, ` ${indent}--data-urlencode "payload@-"`, 'fi' ] diff --git a/src/main/agent-hooks/hook-process-capture.test.ts b/src/main/agent-hooks/hook-process-capture.test.ts new file mode 100644 index 000000000000..5fa34da63f3c --- /dev/null +++ b/src/main/agent-hooks/hook-process-capture.test.ts @@ -0,0 +1,47 @@ +import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { runProcess } from '../../shared/child-process/run-process' +import { wrapRuntimeHomeHookCommand } from './runtime-home-hook-command' +import { buildHookProcessCapture } from './hook-process-capture' +import { readAgentProcessIdentity } from '../../shared/agent-process-presence' +import { probeAgentProcessPresence } from '../../shared/agent-process-presence-probe' + +describe('Claude outer hook process capture', () => { + it.skipIf(process.platform === 'win32')( + 'captures the parent before the managed script shell, under an empty environment', + async () => { + const home = await mkdtemp(join(tmpdir(), 'orca-presence-hook-')) + try { + const dir = join(home, '.orca', 'agent-hooks') + await mkdir(dir, { recursive: true }) + await writeFile( + join(dir, 'claude-hook.sh'), + ['#!/bin/sh', ...buildHookProcessCapture(), 'printf "%s" "$orca_agent_process"'].join( + '\n' + ), + { mode: 0o700 } + ) + const result = await runProcess({ + program: '/usr/bin/env', + args: [ + '-i', + `HOME=${home}`, + 'PATH=/usr/bin:/bin', + '/bin/sh', + '-c', + wrapRuntimeHomeHookCommand('claude-hook') + ], + timeoutMs: 5000 + }) + expect(result.code).toBe(0) + const identity = readAgentProcessIdentity(result.stdout) + expect(identity?.pid).toBe(process.pid) + expect(await probeAgentProcessPresence(identity)).toBe('live') + } finally { + await rm(home, { recursive: true, force: true }) + } + } + ) +}) diff --git a/src/main/agent-hooks/hook-process-capture.ts b/src/main/agent-hooks/hook-process-capture.ts new file mode 100644 index 000000000000..c5168dae2dc9 --- /dev/null +++ b/src/main/agent-hooks/hook-process-capture.ts @@ -0,0 +1,22 @@ +/** Capture before Orca inserts its managed-script shell. */ +export const CLAUDE_HOOK_PARENT_CAPTURE = + 'ORCA_HOOK_AGENT_PID="${PPID:-}"; export ORCA_HOOK_AGENT_PID; ' + +export function buildHookProcessCapture(): string[] { + return [ + 'orca_agent_process=', + 'case "${ORCA_HOOK_AGENT_PID:-}" in ""|*[!0-9]*) ;; *)', + ' if [ -r "/proc/$ORCA_HOOK_AGENT_PID/stat" ]; then', + ' orca_agent_stat=$(cat "/proc/$ORCA_HOOK_AGENT_PID/stat" 2>/dev/null) || orca_agent_stat=', + ' orca_agent_boot=$(cat /proc/sys/kernel/random/boot_id 2>/dev/null) || orca_agent_boot=', + ' orca_agent_fields=${orca_agent_stat##*) }', + ' orca_agent_start=$(printf "%s" "$orca_agent_fields" | awk \'{print $20}\')', + ' case "$orca_agent_start" in ""|*[!0-9]*) ;; *)', + ' [ -z "$orca_agent_boot" ] || orca_agent_process=$(printf \'{"pid":%s,"platform":"linux","startTime":"%s:%s"}\' "$ORCA_HOOK_AGENT_PID" "$orca_agent_boot" "$orca_agent_start") ;; esac', + ' elif [ "$(uname -s 2>/dev/null)" = Darwin ]; then', + ' orca_agent_start=$(LC_ALL=C /bin/ps -p "$ORCA_HOOK_AGENT_PID" -o lstart= 2>/dev/null | sed \'s/^ *//;s/ *$//\')', + ' [ -z "$orca_agent_start" ] || orca_agent_process=$(printf \'{"pid":%s,"platform":"darwin","startTime":"%s"}\' "$ORCA_HOOK_AGENT_PID" "$orca_agent_start")', + ' fi ;;', + 'esac' + ] +} diff --git a/src/main/agent-hooks/hook-stdin-contract.ts b/src/main/agent-hooks/hook-stdin-contract.ts index eeec395b5785..3d4fe2392c5d 100644 --- a/src/main/agent-hooks/hook-stdin-contract.ts +++ b/src/main/agent-hooks/hook-stdin-contract.ts @@ -128,11 +128,11 @@ export function buildPosixHookSpoolLines(source: string, eventNameVar?: string): const eventArg = eventNameVar ? ` "$(spool_json_escape "\${${eventNameVar}:-}")"` : '' const spoolRecordLine = " { printf '\\n{".concat( eventFormat, - '"paneKey":"%s","tabId":"%s","worktreeId":"%s","env":"%s","version":"%s","launchToken":"%s","source":"%s","receivedAt":%s,"payload":%s}\\n\'', + '"paneKey":"%s","tabId":"%s","worktreeId":"%s","env":"%s","version":"%s","launchToken":"%s","source":"%s","receivedAt":%s,"agentProcess":"%s","payload":%s}\\n\'', eventArg, ' "$(spool_json_escape "${ORCA_PANE_KEY:-}")" "$(spool_json_escape "${ORCA_TAB_ID:-}")" "$(spool_json_escape "${ORCA_WORKTREE_ID:-}")" "$(spool_json_escape "${ORCA_AGENT_HOOK_ENV:-}")" "$(spool_json_escape "${ORCA_AGENT_HOOK_VERSION:-}")" "$(spool_json_escape "${ORCA_AGENT_LAUNCH_TOKEN:-}")" "$(spool_json_escape "', source, - '")" "$spool_now" "$payload"; } >> "$spool_file" 2>/dev/null || :' + '")" "$spool_now" "$(spool_json_escape "${orca_agent_process:-}")" "$payload"; } >> "$spool_file" 2>/dev/null || :' ) return [ 'spool_hook_event() {', diff --git a/src/main/agent-hooks/runtime-home-hook-command.ts b/src/main/agent-hooks/runtime-home-hook-command.ts index e56fc603b436..4b59f7b53ed9 100644 --- a/src/main/agent-hooks/runtime-home-hook-command.ts +++ b/src/main/agent-hooks/runtime-home-hook-command.ts @@ -1,3 +1,4 @@ +import { CLAUDE_HOOK_PARENT_CAPTURE } from './hook-process-capture' import { POSIX_HOOK_STDIN_DRAIN_COMMAND, WINDOWS_GIT_BASH_HOOK_ENVIRONMENT_GUARD, @@ -46,7 +47,7 @@ export function wrapRuntimeHomeHookCommand( const powershellInvocation = `${powershell} ${WINDOWS_POWERSHELL_HOOK_SWITCHES} -EncodedCommand ${encodedCommand}` const encodedWindowsBranch = `if [ -f ${powershell} ]; then ${powershellInvocation}; else ${windowsMissingScriptFallback}; fi` const windowsBranch = `if [ -f ${windowsScript} ]; then case "\${HOME-}" in ${WINDOWS_GIT_BASH_RUNTIME_HOME_UNSAFE}) ${encodedWindowsBranch} ;; *) ${windowsScript} ;; esac; else ${windowsMissingScriptFallback}; fi` - const posixBranch = `if [ -f ${posixScript} ] && [ -r ${posixScript} ] && [ -x ${posixScript} ]; then /bin/sh ${posixScript}; else ${posixMissingScriptFallback}; fi` + const posixBranch = `if [ -f ${posixScript} ] && [ -r ${posixScript} ] && [ -x ${posixScript} ]; then ${scriptBaseName === 'claude-hook' || scriptBaseName === 'openclaude-hook' ? CLAUDE_HOOK_PARENT_CAPTURE : ''}/bin/sh ${posixScript}; else ${posixMissingScriptFallback}; fi` // Why: OSTYPE is shell-owned, so platform selection adds no process to every hook invocation. return `if [ -z "\${HOME-}" ]; then ${missingScriptFallback}; else case "\${OSTYPE-}" in msys*|cygwin*|win32*) ${windowsBranch} ;; *) ${posixBranch} ;; esac; fi` } diff --git a/src/main/agent-hooks/server-agent-presence.test.ts b/src/main/agent-hooks/server-agent-presence.test.ts new file mode 100644 index 000000000000..a7e5b1268577 --- /dev/null +++ b/src/main/agent-hooks/server-agent-presence.test.ts @@ -0,0 +1,146 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { AgentHookServer } from './server' +import { buildBody, PANE, postHookEvent } from './server.test-fixtures' + +vi.mock('../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: () => ({}) })) + +const probe = vi.hoisted(() => + vi.fn(async (): Promise<'live' | 'unverifiable' | 'exited'> => 'unverifiable') +) +vi.mock('../../shared/agent-process-presence-probe', () => ({ probeAgentProcessPresence: probe })) +const servers: AgentHookServer[] = [] +afterEach(() => { + for (const server of servers.splice(0)) { + server.stop() + } + probe.mockReset() + probe.mockResolvedValue('unverifiable') +}) + +async function createServer(): Promise { + const server = new AgentHookServer() + servers.push(server) + await server.start({ env: 'production' }) + return server +} + +async function hook( + server: AgentHookServer, + event: string, + session = 'session-a', + reason?: string +): Promise { + const response = await postHookEvent( + server, + buildBody({ + hook_event_name: event, + session_id: session, + source: 'startup', + reason + }) + ) + expect(response.status).toBe(204) +} + +function visible(server: AgentHookServer): boolean { + return server.getStatusSnapshot().some((row) => row.paneKey === PANE && !row.providerSessionOnly) +} + +describe('host-owned hook presence', () => { + it('clears the status on SessionEnd while the terminal survives, without a renderer', async () => { + const server = await createServer() + await hook(server, 'SessionStart') + expect(visible(server)).toBe(true) + await hook(server, 'SessionEnd', 'session-a', 'prompt_input_exit') + expect(visible(server)).toBe(false) + }) + + it.each(['clear', 'resume'])( + 'keeps process presence through %s and rejects the previous session goodbye', + async (reason) => { + const server = await createServer() + await hook(server, 'SessionStart') + await hook(server, 'SessionEnd', 'session-a', reason) + expect(visible(server)).toBe(true) + await hook(server, 'SessionStart', 'session-b') + await hook(server, 'SessionEnd', 'session-a', 'prompt_input_exit') + expect(visible(server)).toBe(true) + await hook(server, 'SessionEnd', 'session-b', 'prompt_input_exit') + expect(visible(server)).toBe(false) + } + ) + + it('does not resurrect an ended session on a late Stop', async () => { + const server = await createServer() + await hook(server, 'SessionStart') + await hook(server, 'SessionEnd', 'session-a', 'prompt_input_exit') + await hook(server, 'Stop') + expect(visible(server)).toBe(false) + }) + it('keeps unanswered reads and clears only a positive process exit', async () => { + const server = await createServer() + await hook(server, 'SessionStart') + expect(await server.checkAgentPresence(PANE)).toBe('unverifiable') + expect(visible(server)).toBe(true) + probe.mockResolvedValue('exited') + expect(await server.checkAgentPresence(PANE)).toBe('exited') + expect(visible(server)).toBe(false) + }) + + it('does not apply a delayed process exit to a replacement session', async () => { + const server = await createServer() + await hook(server, 'SessionStart') + await server.checkAgentPresence(PANE) + let finish: (value: 'exited') => void = () => {} + probe.mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve + }) + ) + const pending = server.checkAgentPresence(PANE) + await hook(server, 'SessionStart', 'replacement') + finish('exited') + expect(await pending).toBe('unverifiable') + expect(visible(server)).toBe(true) + }) + it('admits an explicit same-session resume when no PID was available', async () => { + const server = await createServer() + await hook(server, 'SessionStart') + await hook(server, 'SessionEnd', 'session-a', 'prompt_input_exit') + await hook(server, 'SessionStart') + expect(visible(server)).toBe(true) + }) + + it('accepts a remote exit and never probes that remote PID locally', async () => { + const server = await createServer() + const envelope = { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + source: 'claude', + hookEventName: 'SessionStart', + providerSession: { provider: 'claude', id: 'remote-session' }, + agentPresence: { + sessionId: 'remote-session', + process: { pid: process.pid, platform: process.platform, startTime: 'remote-birth' } + }, + payload: { state: 'working', prompt: 'remote task', agentType: 'claude' } + } + server.ingestRemote(envelope, 'ssh-1') + expect(visible(server)).toBe(true) + expect(await server.checkAgentPresence(PANE)).toBe('unverifiable') + expect(probe).not.toHaveBeenCalled() + server.ingestRemote( + { + ...envelope, + hookEventName: 'AgentProcessExit', + providerSessionOnly: true, + agentPresence: { ...envelope.agentPresence, ended: true } + }, + 'ssh-1' + ) + expect(visible(server)).toBe(false) + }) +}) diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index b2aedfc2dfcb..5c88dfd62d8c 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -4,7 +4,7 @@ import { normalizeHookPayload } from '../../shared/agent-hook-listener' import { parseFormEncodedBody } from '../../shared/agent-hook-listener/request-body' import type { AgentHookEventPayload } from '../../shared/agent-hook-listener/listener-event' import type { AgentHookSource } from '../../shared/agent-hook-relay' -import { AgentHookServerLifecycle } from './server/server-lifecycle' +import { AgentHookServerAgentPresence } from './server/server-agent-presence' import { isValidPaneKey } from './server/server-status-identity' export type { @@ -26,7 +26,7 @@ export { export { isValidPaneKey } /** Public composition seam for the loopback hook listener and relay status adapter. */ -export class AgentHookServer extends AgentHookServerLifecycle {} +export class AgentHookServer extends AgentHookServerAgentPresence {} export const agentHookServer = new AgentHookServer() diff --git a/src/main/agent-hooks/server/server-agent-presence.ts b/src/main/agent-hooks/server/server-agent-presence.ts new file mode 100644 index 000000000000..eee2cf3be8dd --- /dev/null +++ b/src/main/agent-hooks/server/server-agent-presence.ts @@ -0,0 +1,53 @@ +import type { AgentHookEventPayload } from '../../../shared/agent-hook-listener/listener-event' +import type { AgentProcessVerdict } from '../../../shared/agent-process-presence' +import { probeAgentProcessPresence } from '../../../shared/agent-process-presence-probe' +import { AgentHookServerLifecycle } from './server-lifecycle' + +export abstract class AgentHookServerAgentPresence extends AgentHookServerLifecycle { + private readonly presenceChecks = new WeakMap< + AgentHookEventPayload, + Promise + >() + + checkAgentPresence(paneKey: string): Promise { + const resolved = this.resolvePaneKeyAlias(paneKey) + const row = this.state.lastStatusByPaneKey.get(resolved) + if (!row?.agentPresence) { + return Promise.resolve(null) + } + if (row.agentPresence.ended) { + return Promise.resolve('exited') + } + if (row.connectionId !== null) { + return Promise.resolve('unverifiable') + } + const pending = this.presenceChecks.get(row) + if (pending) { + return pending + } + const presence = row.agentPresence + const check = probeAgentProcessPresence(presence.process) + .then((verdict) => { + if ( + this.state.lastStatusByPaneKey.get(resolved) !== row || + row.agentPresence !== presence + ) { + return 'unverifiable' as const + } + if (verdict === 'exited') { + this.reconcileEndedProcessForPaneKeys([resolved], { + preserveResumeIdentity: true, + endedPresence: { ...presence, ended: true } + }) + } + return verdict + }) + .finally(() => { + if (this.presenceChecks.get(row) === check) { + this.presenceChecks.delete(row) + } + }) + this.presenceChecks.set(row, check) + return check + } +} diff --git a/src/main/agent-hooks/server/server-cleanup.ts b/src/main/agent-hooks/server/server-cleanup.ts index f92e34478669..71be07e61703 100644 --- a/src/main/agent-hooks/server/server-cleanup.ts +++ b/src/main/agent-hooks/server/server-cleanup.ts @@ -1,3 +1,4 @@ +import type { AgentProcessPresence } from '../../../shared/agent-process-presence' import { admitLegacyAgentStatus, deleteLegacyAgentStatus, @@ -115,6 +116,7 @@ export abstract class AgentHookServerCleanup extends AgentHookServerAuthorityFen reconcileEndedProcessForPaneKeys( paneKeys: Iterable, options?: { + endedPresence?: AgentProcessPresence /** The pane's PTY outlived its agent (a confirmed shell foreground), so the session can still * be resumed in place — keep the `providerSessionOnly` remnant the paired `agentStatus:drop` * minted for exactly this case. A certified PTY exit passes nothing: there is no pane left to @@ -129,13 +131,17 @@ export abstract class AgentHookServerCleanup extends AgentHookServerAuthorityFen if (!this.hasLiveClaimsForPaneKey(resolvedPaneKey)) { continue } - const retained = options?.preserveResumeIdentity + const resumeRow = options?.preserveResumeIdentity ? this.toRetainedProviderSessionRow( this.state.lastStatusByPaneKey.get(resolvedPaneKey) as | EnrichedAgentHookEventPayload | undefined ) : null + const retained = + resumeRow && options?.endedPresence + ? { ...resumeRow, agentPresence: options.endedPresence } + : resumeRow const previous = this.state.lastStatusByPaneKey.get(resolvedPaneKey) as | EnrichedAgentHookEventPayload | undefined diff --git a/src/main/agent-hooks/server/server-ingest-remote.ts b/src/main/agent-hooks/server/server-ingest-remote.ts index e2e1c91fe80b..fba61044a3e0 100644 --- a/src/main/agent-hooks/server/server-ingest-remote.ts +++ b/src/main/agent-hooks/server/server-ingest-remote.ts @@ -1,3 +1,4 @@ +import { readAgentProcessPresence } from '../../../shared/agent-process-presence' import { track } from '../../telemetry/client' import { normalizeAgentStatusPayload } from '../../../shared/agent-status-types' import { restoreShedStatusFields } from '../../../shared/agent-hook-relay' @@ -35,6 +36,8 @@ export abstract class AgentHookServerIngestRemote extends AgentHookServerIngestS launchToken?: string hasExplicitPrompt?: boolean promptInteractionKey?: string + agentPresence?: unknown + hookSessionEndReason?: unknown hookEventName?: string source?: unknown providerPromptId?: unknown @@ -156,6 +159,7 @@ export abstract class AgentHookServerIngestRemote extends AgentHookServerIngestS ) if ( envelope.providerSessionOnly === true && + !readAgentProcessPresence(envelope.agentPresence)?.ended && !isValidPiProviderSessionOnly(providerSession, normalizedPayload.agentType) ) { return @@ -256,6 +260,11 @@ export abstract class AgentHookServerIngestRemote extends AgentHookServerIngestS }) const event: AgentHookEventPayload & { authorityRestartId?: string } = { paneKey, + agentPresence: readAgentProcessPresence(envelope.agentPresence), + hookSessionEndReason: + typeof envelope.hookSessionEndReason === 'string' + ? envelope.hookSessionEndReason + : undefined, source: effectiveSource, ...(restartedAuthority?.authorityRestartId ? { authorityRestartId: restartedAuthority.authorityRestartId } diff --git a/src/main/agent-hooks/server/server-persistence-validation.ts b/src/main/agent-hooks/server/server-persistence-validation.ts index 7203f174a0ee..c4913e29c0e7 100644 --- a/src/main/agent-hooks/server/server-persistence-validation.ts +++ b/src/main/agent-hooks/server/server-persistence-validation.ts @@ -1,3 +1,4 @@ +import { readAgentProcessPresence } from '../../../shared/agent-process-presence' import { createHash } from 'node:crypto' import { normalizeAgentProviderSession } from '../../../shared/agent-session-resume' @@ -152,6 +153,7 @@ export function sanitizeHydratedEntry( const turnStartedAt = record.turnStartedAt return { paneKey, + agentPresence: readAgentProcessPresence(record.agentPresence), source, tabId: typeof tabId === 'string' ? tabId : undefined, worktreeId: typeof worktreeId === 'string' ? worktreeId : undefined, diff --git a/src/main/agent-hooks/server/server-state.ts b/src/main/agent-hooks/server/server-state.ts index 8ba89b9663bb..5d74847dc8e4 100644 --- a/src/main/agent-hooks/server/server-state.ts +++ b/src/main/agent-hooks/server/server-state.ts @@ -1,3 +1,7 @@ +import type { + AgentProcessPresence, + AgentProcessVerdict +} from '../../../shared/agent-process-presence' import type { createServer } from 'node:http' import { randomBytes, randomUUID } from 'node:crypto' @@ -255,6 +259,13 @@ export abstract class AgentHookServerState { entry: EnrichedAgentHookEventPayload | null | undefined ): EnrichedAgentHookEventPayload | null protected abstract hasLiveClaimsForPaneKey(paneKey: string): boolean + abstract checkAgentPresence(paneKey: string): Promise + + abstract reconcileEndedProcessForPaneKeys( + paneKeys: Iterable, + options?: { preserveResumeIdentity?: boolean; endedPresence?: AgentProcessPresence } + ): number + protected abstract clearPaneState( paneKey: string, options?: { emitStatusRowMutation?: boolean } diff --git a/src/main/agent-hooks/server/server-status-update.ts b/src/main/agent-hooks/server/server-status-update.ts index a37cbb02eb44..33e621b64c5f 100644 --- a/src/main/agent-hooks/server/server-status-update.ts +++ b/src/main/agent-hooks/server/server-status-update.ts @@ -1,3 +1,4 @@ +import { transitionHookPresence } from '../../../shared/agent-hook-presence-transition' import { reconcileRemoteCodexState, markCodexLeadTurnInterrupted @@ -27,10 +28,24 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA observedAt?: number, mutationBefore?: EnrichedAgentHookEventPayload ): EnrichedAgentHookEventPayload | undefined { - const { authorityRestartId, ...payload } = incoming + const transitioned = transitionHookPresence( + incoming, + this.state.lastStatusByPaneKey.get(incoming.paneKey) + ) + if (!transitioned) { + return undefined + } + const { authorityRestartId, ...payload } = { ...incoming, ...transitioned } if (!this.canWriteLegacyStatusRow(payload)) { return undefined } + if (payload.agentPresence?.ended) { + this.reconcileEndedProcessForPaneKeys([payload.paneKey], { + preserveResumeIdentity: true, + endedPresence: payload.agentPresence + }) + return undefined + } if (payload.hookEventName === 'UserPromptSubmit') { // Why: the prompt boundary is authoritative even when text is unchanged; its next OSC working row must not inherit the prior cron/background turn stamp. this.activeHookTurnCompletedAtByPaneKey.delete(payload.paneKey) @@ -227,6 +242,9 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA return undefined } this.commitStatusRowMutation(rowBefore, enriched) + if (enriched.agentPresence && enriched.connectionId === null) { + void this.checkAgentPresence(enriched.paneKey) + } // Why skipped for structured rows: the serializer drops them, so the whole walk and stringify // can only ever reproduce the last file — once per debounce window for a streaming chat. if (!enriched.structuredHost) { diff --git a/src/main/claude/hook-script.ts b/src/main/claude/hook-script.ts index e02fe3747016..4595c7f264cb 100644 --- a/src/main/claude/hook-script.ts +++ b/src/main/claude/hook-script.ts @@ -1,3 +1,4 @@ +import { buildHookProcessCapture } from '../agent-hooks/hook-process-capture' /** The managed Claude-compatible hook script, built for local, POSIX-remote and Windows targets. * Split from hook-service.ts so the service owns install/status and this owns script text, * mirroring the same split under src/main/cursor/. */ @@ -58,6 +59,7 @@ export function getManagedScript( return [ '#!/bin/sh', + ...(source === 'claude' ? buildHookProcessCapture() : []), // Why: Claude-compatible permission hooks fail closed on empty stdout (#14818). 'printf "{}\\n"', ...buildPosixHookPayloadCapture(), diff --git a/src/main/orcad/orcad-entry.ts b/src/main/orcad/orcad-entry.ts index 02533588e9dd..872fbb0a1275 100644 --- a/src/main/orcad/orcad-entry.ts +++ b/src/main/orcad/orcad-entry.ts @@ -235,6 +235,7 @@ async function startOrcadRuntime( publishChildWork: (subject, evidence, provider) => agentHookServer.ingestStructuredChildWork(subject, evidence, provider) }, + checkHookAgentPresence: (paneKey) => agentHookServer.checkAgentPresence(paneKey), reconcileAgentStatusForEndedProcess: (paneKeys) => agentHookServer.reconcileEndedProcessForPaneKeys(paneKeys), buildAgentHookPtyEnv: () => diff --git a/src/main/runtime/orca-runtime-get-unpersisted-tracked-title-for-pty.ts b/src/main/runtime/orca-runtime-get-unpersisted-tracked-title-for-pty.ts index 3ece144bc248..56341301e201 100644 --- a/src/main/runtime/orca-runtime-get-unpersisted-tracked-title-for-pty.ts +++ b/src/main/runtime/orca-runtime-get-unpersisted-tracked-title-for-pty.ts @@ -68,6 +68,9 @@ export class OrcaRuntimeWithGetUnpersistedTrackedTitleForPty extends OrcaRuntime const live = this.ptyTitleTrackersByPtyId.get(ptyId) const gateKey = this.makeDecorativeTitleGateKey(rawTitle, normalizedTitle) const decorativeOnly = live?.lastMobileTitleGateKey === gateKey + if (!decorativeOnly && !meta?.staleWorkingTitleClear) { + void this.recheckHookAgentPresenceForPty(ptyId) + } if (live) { live.lastMobileTitleGateKey = gateKey } @@ -144,6 +147,7 @@ export class OrcaRuntimeWithGetUnpersistedTrackedTitleForPty extends OrcaRuntime this.confirmPtyAgentExit(ptyId) }, onCommandFinished: (exitCode: number | null) => { + void this.recheckHookAgentPresenceForPty(ptyId) this.retirePtyAgentLaunchAuthority(ptyId) this.recordTerminalSideEffectFact(ptyId, { kind: 'command-finished', exitCode }) }, diff --git a/src/main/runtime/orca-runtime-mark-pty-liveness-unverifiable.ts b/src/main/runtime/orca-runtime-mark-pty-liveness-unverifiable.ts index c4e4324e47d3..446083fff030 100644 --- a/src/main/runtime/orca-runtime-mark-pty-liveness-unverifiable.ts +++ b/src/main/runtime/orca-runtime-mark-pty-liveness-unverifiable.ts @@ -28,6 +28,9 @@ export class OrcaRuntimeWithMarkPtyLivenessUnverifiable extends OrcaRuntimeWithO if (observedNoLaterThan !== undefined && tracked && tracked.observedAt > observedNoLaterThan) { return } + if (tracked?.verdict.status === 'unverifiable') { + void this.recheckHookAgentPresenceForPty(ptyId) + } this.rememberPtyLivenessVerdict(ptyId, { status: 'live', ptyIds: [ptyId] }) } diff --git a/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts b/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts index 404e09bf085e..37f8121f99ae 100644 --- a/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts +++ b/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts @@ -90,6 +90,10 @@ export class OrcaRuntimeWithPreservedBranchCleanup extends OrcaRuntimeWithTermin protected readonly retireAgentHookCompatibilityAuthorityFn: ((paneKey: string) => void) | null + protected readonly checkHookAgentPresenceFn: + | ((paneKey: string) => Promise<'live' | 'unverifiable' | 'exited' | null>) + | null + protected readonly reconcileAgentStatusForEndedProcessFn: | ((paneKeys: Iterable) => void) | null diff --git a/src/main/runtime/orca-runtime-serialize-agent-prompt-submission.ts b/src/main/runtime/orca-runtime-serialize-agent-prompt-submission.ts index dd3afbfa23e6..8a8df667e043 100644 --- a/src/main/runtime/orca-runtime-serialize-agent-prompt-submission.ts +++ b/src/main/runtime/orca-runtime-serialize-agent-prompt-submission.ts @@ -6,6 +6,7 @@ import type { RuntimeTerminalAgentStatusSnapshot } from './runtime-terminal-agen import type { RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' import { hasCompatibleAgentTitleIdentity } from '../../shared/agent-title-owner' import type { PtyForegroundProcessRead } from './runtime-terminal-contracts' +import { isShellProcess } from '../../shared/shell-process-detection' import { recognizeAgentProcess } from '../../shared/agent-process-recognition' import type { AgentPromptActivity, @@ -70,7 +71,44 @@ export class OrcaRuntimeWithSerializeAgentPromptSubmission extends OrcaRuntimeWi return this.ptyForegroundAgent.read(ptyId, afterTitleObservation) } + protected async recheckHookAgentPresenceForPty( + ptyId: string + ): Promise<'live' | 'unverifiable' | 'exited' | null> { + if (!this.checkHookAgentPresenceFn) { + return null + } + const verdicts = await Promise.all( + Array.from(this.collectAgentStatusPaneKeysForPty(ptyId), (paneKey) => + this.checkHookAgentPresenceFn(paneKey) + ) + ) + if (verdicts.includes('live')) { + return 'live' + } + if (verdicts.includes('unverifiable')) { + return 'unverifiable' + } + return verdicts.includes('exited') ? 'exited' : null + } + protected confirmPtyAgentExit(ptyId: string, recoverCompletedHook = false): void { + const current = this.ptysById.get(ptyId) + const incarnation = current?.incarnationId + void this.recheckHookAgentPresenceForPty(ptyId).then((verdict) => { + if (this.ptysById.get(ptyId) !== current || current?.incarnationId !== incarnation) { + return + } + if (verdict === null) { + this.confirmLegacyPtyAgentExit(ptyId, recoverCompletedHook) + } else if (verdict === 'exited' && !recoverCompletedHook) { + this.recordTerminalSideEffectFact(ptyId, { kind: 'agent-exited' }) + } else { + this.ptyTitleTrackersByPtyId.get(ptyId)?.tracker.restoreLastAgentExit() + } + }) + } + + private confirmLegacyPtyAgentExit(ptyId: string, recoverCompletedHook: boolean): void { const pty = this.ptysById.get(ptyId) const handle = this.handleByPtyId.get(ptyId) if ( @@ -84,9 +122,7 @@ export class OrcaRuntimeWithSerializeAgentPromptSubmission extends OrcaRuntimeWi const titleObservedAt = pty?.lastOscTitleAt ?? null const foregroundRead = this.readPtyForegroundProcessFromController(ptyId, titleObservedAt ?? 0) if (!pty?.connected || !foregroundRead) { - if (!recoverCompletedHook) { - this.recordTerminalSideEffectFact(ptyId, { kind: 'agent-exited' }) - } + this.ptyTitleTrackersByPtyId.get(ptyId)?.tracker.restoreLastAgentExit() return } void foregroundRead.then((result) => { @@ -149,8 +185,16 @@ export class OrcaRuntimeWithSerializeAgentPromptSubmission extends OrcaRuntimeWi } return } - if (!recoverCompletedHook) { + if ( + !recoverCompletedHook && + result.controller === this.ptyController && + result.available && + typeof result.process === 'string' && + isShellProcess(result.process) + ) { this.recordTerminalSideEffectFact(ptyId, { kind: 'agent-exited' }) + } else { + this.ptyTitleTrackersByPtyId.get(ptyId)?.tracker.restoreLastAgentExit() } }) } diff --git a/src/main/runtime/orca-runtime-state-fields.ts b/src/main/runtime/orca-runtime-state-fields.ts index ef54b73252c7..ca95bf9dc270 100644 --- a/src/main/runtime/orca-runtime-state-fields.ts +++ b/src/main/runtime/orca-runtime-state-fields.ts @@ -82,6 +82,9 @@ export class OrcaRuntimeWithStateFields extends OrcaRuntimeWithLinearCommands { terminalProvenance: 'current_runtime' | 'restored' }) => AgentHookAuthorityAttestation | null retireAgentHookCompatibilityAuthority?: (paneKey: string) => void + checkHookAgentPresence?: ( + paneKey: string + ) => Promise<'live' | 'unverifiable' | 'exited' | null> reconcileAgentStatusForEndedProcess?: (paneKeys: Iterable) => void canRecoverPersistentLocalPtys?: () => boolean // Why: the device registry lives on the RPC server, which is constructed with this runtime; @@ -230,6 +233,7 @@ export class OrcaRuntimeWithStateFields extends OrcaRuntimeWithLinearCommands { deps?.attestAgentHookCompatibilityAuthority ?? null this.retireAgentHookCompatibilityAuthorityFn = deps?.retireAgentHookCompatibilityAuthority ?? null + this.checkHookAgentPresenceFn = deps?.checkHookAgentPresence ?? null this.reconcileAgentStatusForEndedProcessFn = deps?.reconcileAgentStatusForEndedProcess ?? null this.canRecoverPersistentLocalPtysFn = deps?.canRecoverPersistentLocalPtys ?? (() => true) this.getPairedDeviceNameFn = deps?.getPairedDeviceName ?? (() => null) diff --git a/src/main/runtime/orca-runtime-tests/terminal-side-effect-facts.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-side-effect-facts.spec.ts index 79feca3268c3..44dba8c6bd71 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-side-effect-facts.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-side-effect-facts.spec.ts @@ -489,10 +489,8 @@ describe('terminal side-effect fact channel', () => { runtime.onPtyData('pty-1', '\x1b]0;bichir\x07', 100) - await vi.waitFor(() => - expect(batches.flatMap((batch) => batch.facts)).toContainEqual({ kind: 'agent-exited' }) - ) - expect(getForegroundProcess).toHaveBeenCalledOnce() + await vi.waitFor(() => expect(getForegroundProcess).toHaveBeenCalledOnce()) + expect(batches.flatMap((batch) => batch.facts)).not.toContainEqual({ kind: 'agent-exited' }) }) it('aligns a restored session and pre-response bytes to the provider sequence', async () => { diff --git a/src/main/startup/main-process-runtime-service.ts b/src/main/startup/main-process-runtime-service.ts index a000c57760a9..7ab3bad8957d 100644 --- a/src/main/startup/main-process-runtime-service.ts +++ b/src/main/startup/main-process-runtime-service.ts @@ -116,6 +116,7 @@ export function initializeMainProcessRuntime(): OrcaRuntimeService { agentHookServer.attestCompatibilityAuthority(candidate), retireAgentHookCompatibilityAuthority: (paneKey) => agentHookServer.retirePaneAuthority(paneKey), + checkHookAgentPresence: (paneKey) => agentHookServer.checkAgentPresence(paneKey), reconcileAgentStatusForEndedProcess: (paneKeys) => agentHookServer.reconcileEndedProcessForPaneKeys(paneKeys), canRecoverPersistentLocalPtys: () => getDaemonProvider() !== null, diff --git a/src/relay/agent-hook-envelope-build.ts b/src/relay/agent-hook-envelope-build.ts index 6b1b45432d8b..25af43e2d3b6 100644 --- a/src/relay/agent-hook-envelope-build.ts +++ b/src/relay/agent-hook-envelope-build.ts @@ -16,6 +16,8 @@ export function buildRelayHookEnvelope( ): AgentHookRelayEnvelope { return { source, + agentPresence: event.agentPresence, + hookSessionEndReason: event.hookSessionEndReason, paneKey: event.paneKey, ...(event.launchToken ? { launchToken: event.launchToken } : {}), tabId: event.tabId, diff --git a/src/relay/agent-hook-request.ts b/src/relay/agent-hook-request.ts new file mode 100644 index 000000000000..ddef056c382f --- /dev/null +++ b/src/relay/agent-hook-request.ts @@ -0,0 +1,84 @@ +import type { IncomingMessage, ServerResponse } from 'node:http' +import { HOOK_REQUEST_SLOWLORIS_MS } from '../shared/agent-hook-listener/listener-limits' +import { normalizeHookPayload } from '../shared/agent-hook-listener' +import { mergeAgentHookRequestHeaders } from '../shared/agent-hook-listener/hook-envelope' +import { readRequestBody } from '../shared/agent-hook-listener/request-body' +import { resolveHookSource } from '../shared/agent-hook-listener/source-routing' +import type { createHookTransportInterferenceTracker } from '../shared/agent-hook-transport-interference' +import { isHookRequestTruncatedError } from '../shared/agent-hook-transport-interference' +import type { HookListenerState } from '../shared/agent-hook-listener/listener-state' +import type { AgentHookEventPayload } from '../shared/agent-hook-listener/listener-event' +import type { AgentHookSource } from '../shared/agent-hook-relay' +import type { AgentHookResultRetryScheduler } from './agent-hook-result-retry-scheduler' +import { hookBodyEnv, hookBodyVersion } from './agent-hook-envelope-build' + +export async function handleRelayHookRequest( + req: IncomingMessage, + res: ServerResponse, + options: { + token: string + env: string + state: HookListenerState + applyEvent: ( + event: AgentHookEventPayload, + source: AgentHookSource, + env?: string, + version?: string + ) => void + retryScheduler: AgentHookResultRetryScheduler + transportInterference: ReturnType + } +): Promise { + if (req.method !== 'POST') { + res.writeHead(404) + res.end() + return + } + if (req.headers['x-orca-agent-hook-token'] !== options.token) { + res.writeHead(403) + res.end() + return + } + // Why: track our own destroy so the slowloris cap can't be misread as outside interference. + let destroyedBySlowlorisCap = false + req.setTimeout(HOOK_REQUEST_SLOWLORIS_MS, () => { + destroyedBySlowlorisCap = true + req.destroy() + }) + try { + const pathname = new URL(req.url ?? '/', 'http://127.0.0.1').pathname + const source = resolveHookSource(pathname) + if (!source) { + res.writeHead(404) + res.end() + return + } + const body = await readRequestBody(req) + const hookBody = mergeAgentHookRequestHeaders(body, req.headers) + const event = normalizeHookPayload(options.state, source, hookBody, options.env, { + deferCompactOwnershipToClient: true + }) + if (event) { + // TODO: once normalizeHookPayload returns validated env/version, drop bodyEnv/bodyVersion and source them from the listener result. + const env = hookBodyEnv(hookBody) + const version = hookBodyVersion(hookBody) + options.applyEvent(event, source, env, version) + options.retryScheduler.scheduleAssistantMessageRetry(source, hookBody, event, env, version) + options.retryScheduler.scheduleTranscriptPoll(source, hookBody, event, env, version) + } + res.writeHead(204) + res.end() + } catch (err) { + // Why (#11217): a remote host can run the same IDS; count truncations here so a blocked SSH + // relay reports the cause instead of an anonymous "hook request failed". + if (isHookRequestTruncatedError(err) && !destroyedBySlowlorisCap) { + options.transportInterference.record({ source: null, error: err }) + } + // Why: hooks fail open (204 on any error) so a buggy agent never blocks the run; still log so the 204 doesn't mask bugs. + process.stderr.write( + `[relay-hook-server] hook request failed: ${err instanceof Error ? err.message : String(err)}\n` + ) + res.writeHead(204) + res.end() + } +} diff --git a/src/relay/agent-hook-server.ts b/src/relay/agent-hook-server.ts index e75b11a24505..91523114b6e7 100644 --- a/src/relay/agent-hook-server.ts +++ b/src/relay/agent-hook-server.ts @@ -1,3 +1,6 @@ +import { handleRelayHookRequest } from './agent-hook-request' +import { transitionHookPresence } from '../shared/agent-hook-presence-transition' +import { RelayAgentPresence } from './relay-agent-presence' import { createServer, type IncomingMessage, type ServerResponse } from 'node:http' import { randomUUID } from 'node:crypto' import { join } from 'node:path' @@ -17,16 +20,11 @@ import { getEndpointFileName, writeEndpointFile } from '../shared/agent-hook-listener/endpoint-publication' -import { HOOK_REQUEST_SLOWLORIS_MS } from '../shared/agent-hook-listener/listener-limits' import { normalizeHookPayload } from '../shared/agent-hook-listener' -import { mergeAgentHookRequestHeaders } from '../shared/agent-hook-listener/hook-envelope' -import { readRequestBody } from '../shared/agent-hook-listener/request-body' -import { resolveHookSource } from '../shared/agent-hook-listener/source-routing' import type { AgentHookEventPayload } from '../shared/agent-hook-listener/listener-event' import { createHookTransportInterferenceTracker, - describeHookTransportInterference, - isHookRequestTruncatedError + describeHookTransportInterference } from '../shared/agent-hook-transport-interference' import { isAgentHookSource, @@ -68,7 +66,6 @@ export type RelayHookServerOptions = { export type RelayHookServerStartOptions = { publishEndpoint?: boolean } - export class RelayAgentHookServer { private server: ReturnType | null = null private port = 0 @@ -92,6 +89,7 @@ export class RelayAgentHookServer { private fixedToken: string | undefined private preferredPort: number private portFallbackApplied = false + private readonly presenceChecks = new RelayAgentPresence() private retryScheduler: AgentHookResultRetryScheduler constructor(options: RelayHookServerOptions) { @@ -215,6 +213,7 @@ export class RelayAgentHookServer { dropPane: (paneKey) => this.clearPaneState(paneKey) }) for (const { event, meta } of replayable) { + void this.checkAgentPresence(event.paneKey) this.forward( buildRelayHookEnvelope(event, meta.source, meta.env, meta.version, { isReplay: true }) ) @@ -222,6 +221,20 @@ export class RelayAgentHookServer { return replayable.length } + checkAgentPresence(paneKey: string): Promise { + const row = this.state.lastStatusByPaneKey.get(paneKey) + const meta = this.lastEnvelopeMetaByPaneKey.get(paneKey) + return this.presenceChecks.check( + row, + () => this.state.lastStatusByPaneKey.get(paneKey), + (event) => { + if (meta) { + this.applyEvent(event, meta.source, meta.env, meta.version) + } + } + ) + } + /** Drop a paneKey's cached entries on PTY exit so a terminated pane can't resurface as a ghost event on reconnect. */ clearPaneState(paneKey: string): void { this.retryScheduler.clearAssistantMessageRetry(paneKey) @@ -249,67 +262,33 @@ export class RelayAgentHookServer { // ─── Private ────────────────────────────────────────────────────── private async handleRequest(req: IncomingMessage, res: ServerResponse): Promise { - if (req.method !== 'POST') { - res.writeHead(404) - res.end() - return - } - if (req.headers['x-orca-agent-hook-token'] !== this.token) { - res.writeHead(403) - res.end() - return - } - // Why: track our own destroy so the slowloris cap can't be misread as outside interference. - let destroyedBySlowlorisCap = false - req.setTimeout(HOOK_REQUEST_SLOWLORIS_MS, () => { - destroyedBySlowlorisCap = true - req.destroy() + await handleRelayHookRequest(req, res, { + token: this.token, + env: this.env, + state: this.state, + applyEvent: (event, source, env, version) => this.applyEvent(event, source, env, version), + retryScheduler: this.retryScheduler, + transportInterference: this.transportInterference }) - try { - const pathname = new URL(req.url ?? '/', 'http://127.0.0.1').pathname - const source = resolveHookSource(pathname) - if (!source) { - res.writeHead(404) - res.end() - return - } - const body = await readRequestBody(req) - const hookBody = mergeAgentHookRequestHeaders(body, req.headers) - const event = normalizeHookPayload(this.state, source, hookBody, this.env, { - deferCompactOwnershipToClient: true - }) - if (event) { - // TODO: once normalizeHookPayload returns validated env/version, drop bodyEnv/bodyVersion and source them from the listener result. - const env = hookBodyEnv(hookBody) - const version = hookBodyVersion(hookBody) - this.applyEvent(event, source, env, version) - this.retryScheduler.scheduleAssistantMessageRetry(source, hookBody, event, env, version) - this.retryScheduler.scheduleTranscriptPoll(source, hookBody, event, env, version) - } - res.writeHead(204) - res.end() - } catch (err) { - // Why (#11217): a remote host can run the same IDS; count truncations here so a blocked SSH - // relay reports the cause instead of an anonymous "hook request failed". - if (isHookRequestTruncatedError(err) && !destroyedBySlowlorisCap) { - this.transportInterference.record({ source: null, error: err }) - } - // Why: hooks fail open (204 on any error) so a buggy agent never blocks the run; still log so the 204 doesn't mask bugs. - process.stderr.write( - `[relay-hook-server] hook request failed: ${err instanceof Error ? err.message : String(err)}\n` - ) - res.writeHead(204) - res.end() - } } private applyEvent( - event: AgentHookEventPayload, + incoming: AgentHookEventPayload, source: AgentHookSource, env?: string, version?: string, options: { isReplay?: boolean } = {} ): void { + const transitioned = transitionHookPresence( + incoming, + this.state.lastStatusByPaneKey.get(incoming.paneKey) + ) + if (!transitioned) { + return + } + const event = transitioned.agentPresence?.ended + ? { ...transitioned, providerSessionOnly: true } + : transitioned // Why: this post came from a process still running inside a pane whose tab the user closed. // Caching or forwarding it makes every connected client advertise a live, resumable agent pane // that no tab owns — the advertisement that ends up auto-typing a second `--resume` onto a @@ -334,6 +313,9 @@ export class RelayAgentHookServer { this.lastEnvelopeMetaByPaneKey.delete(event.paneKey) this.lastEnvelopeMetaByPaneKey.set(event.paneKey, { source, env, version }) this.forward(buildRelayHookEnvelope(event, source, env, version, options)) + if (event.agentPresence && !event.agentPresence.ended) { + void this.checkAgentPresence(event.paneKey) + } } private ingestSpoolRecord(record: SpoolRecord): void { diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index 58a987852028..0d7ccac38bf0 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -1,3 +1,4 @@ +import { createTerminalTitleTracker } from '../shared/terminal-output-side-effects' import { FreebuffStatusProjection } from './freebuff-status-projection' import { applyRelayAgentWorkspaceTrust } from './agent-workspace-trust-spawn' /* oxlint-disable max-lines */ @@ -714,6 +715,12 @@ export class PtyHandler { } /** Subscribe to PTY-exit events (relay-hook server uses this to evict per-paneKey caches). */ + private agentPresenceTrigger: ((paneKey: string) => void) | null = null + + setAgentPresenceTrigger(listener: ((paneKey: string) => void) | null): void { + this.agentPresenceTrigger = listener + } + setExitListener(listener: PtyExitListener | null): void { this.exitListener = listener } @@ -1017,7 +1024,17 @@ export class PtyHandler { } }) } + const recheckAgentPresence = (): void => { + if (managed.paneKey) { + this.agentPresenceTrigger?.(managed.paneKey) + } + } + const presenceTriggers = createTerminalTitleTracker({ + onTitle: recheckAgentPresence, + onCommandFinished: recheckAgentPresence + }) managed.pty.onData((data: string) => { + presenceTriggers.handleChunk(data) const startup = managed.startupCommand if (startup?.waitForShellReady && startup.outputScanState && !startup.delivered) { const scanned = scanShellStartupOutput(startup.outputScanState, data) @@ -1039,6 +1056,7 @@ export class PtyHandler { } }) managed.pty.onExit(({ exitCode }: { exitCode: number }) => { + presenceTriggers.dispose() managed.physicalExit?.markExited() if (managed.disposed) { return diff --git a/src/relay/relay-agent-hook-runtime.ts b/src/relay/relay-agent-hook-runtime.ts index bc1cef971370..6c21a9d6fc70 100644 --- a/src/relay/relay-agent-hook-runtime.ts +++ b/src/relay/relay-agent-hook-runtime.ts @@ -64,6 +64,9 @@ export class RelayAgentHookRuntime { } private registerPtyEnvironment(): void { + this.ptyHandler.setAgentPresenceTrigger((paneKey) => { + void this.hookServer.checkAgentPresence(paneKey) + }) this.ptyHandler.addEnvAugmenter(() => this.hookServer.buildPtyEnv()) this.ptyHandler.addEnvAugmenter((context) => this.buildPluginEnvironment(context)) this.ptyHandler.setExitListener(({ paneKey, id }) => { diff --git a/src/relay/relay-agent-presence.test.ts b/src/relay/relay-agent-presence.test.ts new file mode 100644 index 000000000000..90347bc4c484 --- /dev/null +++ b/src/relay/relay-agent-presence.test.ts @@ -0,0 +1,59 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { RelayAgentHookServer } from './agent-hook-server' +import type { AgentHookRelayEnvelope } from '../shared/agent-hook-relay' +import { makePaneKey } from '../shared/stable-pane-id' + +const probe = vi.hoisted(() => + vi.fn(async (): Promise<'live' | 'unverifiable' | 'exited'> => 'unverifiable') +) +vi.mock('../shared/agent-process-presence-probe', () => ({ probeAgentProcessPresence: probe })) +afterEach(() => { + probe.mockReset() + probe.mockResolvedValue('unverifiable') +}) + +const paneKey = makePaneKey('tab-1', '11111111-1111-4111-8111-111111111111') + +describe('relay process presence', () => { + it('keeps unavailable reads, preserves resume, and publishes a real exit without a window', async () => { + const dir = await mkdtemp(join(tmpdir(), 'relay-presence-')) + const forward = vi.fn<(envelope: AgentHookRelayEnvelope) => void>() + const server = new RelayAgentHookServer({ endpointDir: dir, forward }) + try { + await server.start() + const { port, token } = server.getCoordinates() + const post = async (event: string, session: string, reason?: string) => { + const response = await fetch(`http://127.0.0.1:${port}/hook/claude`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Orca-Agent-Hook-Token': token }, + body: JSON.stringify({ + paneKey, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { hook_event_name: event, session_id: session, source: 'startup', reason } + }) + }) + expect(response.status).toBe(204) + } + await post('SessionStart', 'a') + expect(forward.mock.lastCall?.[0].agentPresence?.sessionId).toBe('a') + await server.checkAgentPresence(paneKey) + expect(forward.mock.lastCall?.[0].agentPresence?.ended).toBeUndefined() + await post('SessionEnd', 'a', 'resume') + expect(forward.mock.lastCall?.[0].agentPresence?.sessionSwitch).toBe(true) + await post('SessionStart', 'b') + await post('SessionEnd', 'a', 'prompt_input_exit') + expect(forward.mock.lastCall?.[0].agentPresence?.sessionId).toBe('b') + probe.mockResolvedValue('exited') + await server.checkAgentPresence(paneKey) + expect(forward.mock.lastCall?.[0].agentPresence?.ended).toBe(true) + expect(forward.mock.lastCall?.[0].providerSessionOnly).toBe(true) + } finally { + server.stop() + await rm(dir, { recursive: true, force: true }) + } + }) +}) diff --git a/src/relay/relay-agent-presence.ts b/src/relay/relay-agent-presence.ts new file mode 100644 index 000000000000..102c92d9636b --- /dev/null +++ b/src/relay/relay-agent-presence.ts @@ -0,0 +1,34 @@ +import type { AgentHookEventPayload } from '../shared/agent-hook-listener/listener-event' +import { probeAgentProcessPresence } from '../shared/agent-process-presence-probe' + +export class RelayAgentPresence { + private readonly pending = new WeakMap>() + + check( + row: AgentHookEventPayload | undefined, + current: () => AgentHookEventPayload | undefined, + publish: (event: AgentHookEventPayload) => void + ): Promise { + if (!row?.agentPresence || row.agentPresence.ended) { + return Promise.resolve() + } + const existing = this.pending.get(row) + if (existing) { + return existing + } + const presence = row.agentPresence + const check = probeAgentProcessPresence(presence.process) + .then((verdict) => { + if (verdict === 'exited' && current() === row) { + publish({ + ...row, + hookEventName: 'AgentProcessExit', + agentPresence: { ...presence, ended: true } + }) + } + }) + .finally(() => this.pending.delete(row)) + this.pending.set(row, check) + return check + } +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection-command-finished-cleanup.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-command-finished-cleanup.test.ts index 45a603f9e7fb..62b890237110 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-command-finished-cleanup.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-command-finished-cleanup.test.ts @@ -695,7 +695,7 @@ describe('connectPanePty', () => { dataCallbackRef.current?.('\x1b]133;D;0\x07') await vi.advanceTimersByTimeAsync(350 + 1200 + 6000) - expect(mockStoreState.clearAgentLaunchConfig).toHaveBeenCalledExactlyOnceWith(paneKey) + expect(mockStoreState.clearAgentLaunchConfig).not.toHaveBeenCalled() expect(mockStoreState.paneForegroundAgentByPaneKey[paneKey]).toEqual({ agent: null, shellForeground: false diff --git a/src/renderer/src/components/terminal-pane/pty-connection/pane-agent-identity.ts b/src/renderer/src/components/terminal-pane/pty-connection/pane-agent-identity.ts index 2c86932c499d..932f23b53322 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/pane-agent-identity.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/pane-agent-identity.ts @@ -122,8 +122,8 @@ export function installPaneAgentIdentity(session: ConnectPanePtySession): void { const reconcile = session.deferredConfirmedShellReconcile session.deferredCommandFinishedStatusDrop = null session.deferredConfirmedShellReconcile = null - dropStatus?.() if (options.confirmedShell) { + dropStatus?.() reconcile?.() } } diff --git a/src/shared/agent-hook-listener-relay-dependency.test.ts b/src/shared/agent-hook-listener-relay-dependency.test.ts index 81151f04e8a3..62dc8ff38778 100644 --- a/src/shared/agent-hook-listener-relay-dependency.test.ts +++ b/src/shared/agent-hook-listener-relay-dependency.test.ts @@ -78,6 +78,7 @@ describe('agent hook listener relay dependency boundary', () => { const listenerPathPrefix = resolve(sharedRoot, 'agent-hook-listener') const relayConsumers = [ resolve(sharedRoot, '../relay/agent-hook-server.ts'), + resolve(sharedRoot, '../relay/agent-hook-request.ts'), resolve(sharedRoot, '../relay/agent-hook-result-retry-scheduler.ts') ] const pending = relayConsumers.flatMap((consumer) => diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index 73f05c1d7637..b39a3270fdb2 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -1,3 +1,4 @@ +import { readAgentProcessIdentity } from './agent-process-presence' import { normalizeAgentStatusPayload } from './agent-status-types' import type { AgentHookSource } from './agent-hook-relay' import { extractAgentProviderSession } from './agent-session-resume' @@ -136,6 +137,38 @@ export function normalizeHookPayload( } } + const rootPresenceEvent = + (source === 'claude' || source === 'codex') && + !readString(hookPayloadRecord, 'agent_id') && + providerSession + const agentPresence = rootPresenceEvent + ? { + sessionId: providerSession.id, + process: source === 'claude' ? readAgentProcessIdentity(record.agentProcess) : undefined + } + : undefined + if (source === 'claude' && eventName === 'SessionEnd' && agentPresence) { + const payload = + previousStatus?.payload ?? + normalizeAgentStatusPayload({ state: 'done', prompt: '', agentType: source }) + if (!payload) { + return null + } + return { + paneKey, + source, + launchToken, + tabId, + worktreeId, + connectionId: null, + providerSession: providerSession ?? undefined, + hookEventName: 'SessionEnd', + hookSessionEndReason: readString(hookPayloadRecord, 'reason'), + agentPresence, + payload + } + } + const extractedPrompt = extractPromptText(hookPayloadRecord) const promptText = extractedPrompt.text const dispatched = normalizeProviderEvent({ @@ -168,6 +201,7 @@ export function normalizeHookPayload( return { paneKey, source, + agentPresence, launchToken, tabId, worktreeId, diff --git a/src/shared/agent-hook-listener/hook-envelope.ts b/src/shared/agent-hook-listener/hook-envelope.ts index 57c29da338f7..caadb3d50aca 100644 --- a/src/shared/agent-hook-listener/hook-envelope.ts +++ b/src/shared/agent-hook-listener/hook-envelope.ts @@ -83,6 +83,7 @@ export function mergeAgentHookRequestHeaders(body: unknown, headers: IncomingHtt } return { ...metadata, + agentProcess: readHookHeader(headers, 'x-orca-agent-process'), payload: body } } diff --git a/src/shared/agent-hook-listener/listener-event.ts b/src/shared/agent-hook-listener/listener-event.ts index 48797d09d8f9..f251176481aa 100644 --- a/src/shared/agent-hook-listener/listener-event.ts +++ b/src/shared/agent-hook-listener/listener-event.ts @@ -1,9 +1,12 @@ +import type { AgentProcessPresence } from '../agent-process-presence' import type { ParsedAgentStatusPayload } from '../agent-status-types' import type { AgentHookSource } from '../agent-hook-relay' import type { AgentProviderSessionMetadata } from '../agent-session-resume' export type AgentHookEventPayload = { paneKey: string + agentPresence?: AgentProcessPresence + hookSessionEndReason?: string /** Authenticated hook route that produced this event. */ source?: AgentHookSource /** Ephemeral Orca launch identity stamped into the PTY env for this process. */ diff --git a/src/shared/agent-hook-presence-transition.ts b/src/shared/agent-hook-presence-transition.ts new file mode 100644 index 000000000000..6dd87cf453cf --- /dev/null +++ b/src/shared/agent-hook-presence-transition.ts @@ -0,0 +1,44 @@ +import type { AgentHookEventPayload } from './agent-hook-listener/listener-event' + +/** Apply lifecycle evidence only to the session that supplied it. */ +export function transitionHookPresence( + incoming: AgentHookEventPayload, + previous: AgentHookEventPayload | undefined +): AgentHookEventPayload | undefined { + if (previous && previous.payload.agentType !== incoming.payload.agentType) { + return incoming + } + const next = incoming.agentPresence + const prior = previous?.agentPresence + if (!next && prior?.ended) { + return undefined + } + if (!next) { + return prior ? { ...incoming, agentPresence: prior } : incoming + } + if (incoming.hookEventName === 'SessionEnd') { + if (!prior || prior.sessionId !== next.sessionId || prior.ended) { + return undefined + } + const switching = + incoming.hookSessionEndReason === 'clear' || incoming.hookSessionEndReason === 'resume' + return { + ...incoming, + payload: previous.payload, + providerSession: switching ? undefined : incoming.providerSession, + agentPresence: { ...prior, ...(switching ? { sessionSwitch: true } : { ended: true }) } + } + } + if ( + prior && + incoming.hookEventName !== 'SessionStart' && + (prior.ended || prior.sessionSwitch || prior.sessionId !== next.sessionId) + ) { + return undefined + } + const sameProcess = !prior?.ended && (prior?.sessionId === next.sessionId || prior?.sessionSwitch) + return { + ...incoming, + agentPresence: { ...next, process: next.process ?? (sameProcess ? prior?.process : undefined) } + } +} diff --git a/src/shared/agent-hook-relay.ts b/src/shared/agent-hook-relay.ts index bb2dca99f729..9a8791bd23ba 100644 --- a/src/shared/agent-hook-relay.ts +++ b/src/shared/agent-hook-relay.ts @@ -1,3 +1,4 @@ +import type { AgentProcessPresence } from './agent-process-presence' // Why: defines the wire shape carried by the JSON-RPC `agent.hook` notification // the relay sends to Orca. Consumed by `src/relay/agent-hook-server.ts` (which // produces it after the shared listener parses an HTTP POST) and by @@ -77,6 +78,8 @@ export const REMOTE_AGENT_HOOK_ENV = 'remote' as const export type AgentHookRelayEnvelope = { source: AgentHookSource paneKey: string + agentPresence?: AgentProcessPresence + hookSessionEndReason?: string /** Ephemeral Orca launch identity stamped into the PTY env for this process. */ launchToken?: string tabId?: string diff --git a/src/shared/agent-hook-spool.ts b/src/shared/agent-hook-spool.ts index 275e6f5ce52a..95694ce5dac4 100644 --- a/src/shared/agent-hook-spool.ts +++ b/src/shared/agent-hook-spool.ts @@ -40,6 +40,7 @@ export function buildSpoolHookBody(record: SpoolRecord): Record version: record.version, launchToken: record.launchToken, hookEventName: record.hookEventName, + agentProcess: record.agentProcess, payload: record.payload } } diff --git a/src/shared/agent-process-presence-platform.test.ts b/src/shared/agent-process-presence-platform.test.ts new file mode 100644 index 000000000000..e11717ed488a --- /dev/null +++ b/src/shared/agent-process-presence-platform.test.ts @@ -0,0 +1,63 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { readAgentProcess } from './agent-process-presence-probe' + +const nativeRead = vi.hoisted(() => vi.fn()) +const run = vi.hoisted(() => vi.fn()) +const read = vi.hoisted(() => vi.fn()) +vi.mock('../main/windows/windows-process-table', () => ({ + readWindowsProcessCreationTime: nativeRead +})) +vi.mock('./child-process/run-process', () => ({ runProcess: run })) +vi.mock('node:fs/promises', () => ({ readFile: read })) +const hostProcess = process +function platform(name: string): void { + vi.stubGlobal('process', { ...hostProcess, platform: name }) +} +afterEach(() => { + vi.unstubAllGlobals() + vi.resetAllMocks() +}) + +describe('platform process evidence', () => { + it('never turns Windows native null or unavailable capability into exit', async () => { + platform('win32') + nativeRead.mockReturnValue(null) + expect(await readAgentProcess(4242)).toEqual({ verdict: 'unverifiable' }) + nativeRead.mockImplementation(() => { + throw new Error('missing addon') + }) + expect(await readAgentProcess(4242)).toEqual({ verdict: 'unverifiable' }) + nativeRead.mockReturnValue(100) + expect(await readAgentProcess(4242)).toEqual({ + verdict: 'live', + startTime: '100', + zombie: false + }) + expect(run).not.toHaveBeenCalled() + }) + + it('treats a missing macOS utility as unanswered', async () => { + platform('darwin') + run.mockRejectedValue(Object.assign(new Error('missing ps'), { code: 'ENOENT' })) + expect(await readAgentProcess(4242)).toEqual({ verdict: 'unverifiable' }) + }) + + it('requires Linux process-file absence rather than an unreadable boot identity', async () => { + platform('linux') + read.mockRejectedValueOnce(Object.assign(new Error('gone'), { code: 'ENOENT' })) + expect(await readAgentProcess(4242)).toEqual({ verdict: 'exited' }) + const fields = ['S', ...Array(18).fill('0'), '100'] + read + .mockResolvedValueOnce(`4242 (agent (name)) ${fields.join(' ')}`) + .mockRejectedValueOnce(new Error('permission denied')) + expect(await readAgentProcess(4242)).toEqual({ verdict: 'unverifiable' }) + read + .mockResolvedValueOnce(`4242 (agent (name)) ${fields.join(' ')}`) + .mockResolvedValueOnce('boot-id\n') + expect(await readAgentProcess(4242)).toEqual({ + verdict: 'live', + startTime: 'boot-id:100', + zombie: false + }) + }) +}) diff --git a/src/shared/agent-process-presence-probe.test.ts b/src/shared/agent-process-presence-probe.test.ts new file mode 100644 index 000000000000..5a805c468ef1 --- /dev/null +++ b/src/shared/agent-process-presence-probe.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it, vi } from 'vitest' +import { + probeAgentProcessPresence, + type AgentProcessObservation +} from './agent-process-presence-probe' +import type { AgentProcessIdentity } from './agent-process-presence' + +const identity: AgentProcessIdentity = { pid: 4242, platform: 'linux', startTime: 'boot:100' } + +describe('single-process presence evidence', () => { + it.each<[AgentProcessObservation, string]>([ + [{ verdict: 'live', startTime: 'boot:100', zombie: false }, 'live'], + [{ verdict: 'live', startTime: 'boot:101', zombie: false }, 'exited'], + [{ verdict: 'live', startTime: 'boot:100', zombie: true }, 'exited'], + [{ verdict: 'exited' }, 'exited'], + [{ verdict: 'unverifiable' }, 'unverifiable'] + ])('judges %j as %s', async (observation, verdict) => { + const read = vi.fn(async () => observation) + expect(await probeAgentProcessPresence(identity, read, 'linux')).toBe(verdict) + expect(read).toHaveBeenCalledExactlyOnceWith(4242) + }) + + it('keeps a timeout or permission error unverifiable', async () => { + const read = vi.fn(async () => { + throw new Error('EPERM') + }) + expect(await probeAgentProcessPresence(identity, read, 'linux')).toBe('unverifiable') + }) + + it('does not inspect a missing identity or a different execution platform', async () => { + const read = vi.fn() + expect(await probeAgentProcessPresence(undefined, read, 'linux')).toBe('unverifiable') + expect(await probeAgentProcessPresence(identity, read, 'win32')).toBe('unverifiable') + expect(read).not.toHaveBeenCalled() + }) +}) diff --git a/src/shared/agent-process-presence-probe.ts b/src/shared/agent-process-presence-probe.ts new file mode 100644 index 000000000000..78d0ba9432ef --- /dev/null +++ b/src/shared/agent-process-presence-probe.ts @@ -0,0 +1,90 @@ +import { readFile } from 'node:fs/promises' +import { runProcess } from './child-process/run-process' +import type { AgentProcessIdentity, AgentProcessVerdict } from './agent-process-presence' + +export type AgentProcessObservation = + | { verdict: 'live'; startTime: string; zombie: boolean } + | { verdict: 'unverifiable' | 'exited' } + +function isMissing(error: unknown): boolean { + return ( + error instanceof Error && 'code' in error && (error.code === 'ESRCH' || error.code === 'ENOENT') + ) +} + +export async function readAgentProcess(pid: number): Promise { + try { + if (process.platform === 'linux') { + const stat = await readFile(`/proc/${pid}/stat`, 'utf8').catch((error) => { + if (isMissing(error)) { + return null + } + throw error + }) + if (stat === null) { + return { verdict: 'exited' } + } + const fields = stat + .slice(stat.lastIndexOf(')') + 2) + .trim() + .split(/\s+/) + const boot = ( + await readFile('/proc/sys/kernel/random/boot_id', 'utf8').catch(() => '') + ).trim() + if (!/^\d+$/.test(fields[19] ?? '') || !boot) { + return { verdict: 'unverifiable' } + } + return { verdict: 'live', startTime: `${boot}:${fields[19]}`, zombie: fields[0] === 'Z' } + } + if (process.platform === 'win32') { + const { readWindowsProcessCreationTime } = + await import('../main/windows/windows-process-table') + const startTime = readWindowsProcessCreationTime(pid) + if (startTime !== null) { + return { verdict: 'live', startTime: String(startTime), zombie: false } + } + // A native null includes denied access and missing capability, not just absence. + return { verdict: 'unverifiable' } + } + if (process.platform === 'darwin') { + const result = await runProcess({ + program: '/bin/ps', + args: ['-p', String(pid), '-o', 'stat=,lstart='], + env: { ...process.env, LC_ALL: 'C', LANG: 'C' }, + timeoutMs: 1000, + maxOutputBytes: 4096 + }) + if (!result.timedOut && result.code === 0) { + const match = /^\s*(\S+)\s+(.+?)\s*$/.exec(result.stdout) + if (match) { + return { verdict: 'live', startTime: match[2], zombie: match[1].startsWith('Z') } + } + } + try { + process.kill(pid, 0) + } catch (error) { + if (isMissing(error)) { + return { verdict: 'exited' } + } + } + } + } catch { + return { verdict: 'unverifiable' } + } + return { verdict: 'unverifiable' } +} + +export async function probeAgentProcessPresence( + identity: AgentProcessIdentity | undefined, + read: (pid: number) => Promise = readAgentProcess, + platform: NodeJS.Platform = process.platform +): Promise { + if (!identity || identity.platform !== platform) { + return 'unverifiable' + } + const observed = await read(identity.pid).catch(() => ({ verdict: 'unverifiable' as const })) + if (observed.verdict !== 'live') { + return observed.verdict + } + return observed.zombie || observed.startTime !== identity.startTime ? 'exited' : 'live' +} diff --git a/src/shared/agent-process-presence.ts b/src/shared/agent-process-presence.ts new file mode 100644 index 000000000000..46c50e9abd7d --- /dev/null +++ b/src/shared/agent-process-presence.ts @@ -0,0 +1,66 @@ +export type AgentProcessIdentity = { + pid: number + platform: 'darwin' | 'linux' | 'win32' + startTime: string +} + +/** Hook presence is independent of whether the last turn finished. */ +export type AgentProcessPresence = { + sessionId: string + process?: AgentProcessIdentity + ended?: true + sessionSwitch?: true +} + +export type AgentProcessVerdict = 'live' | 'unverifiable' | 'exited' + +export function readAgentProcessIdentity(value: unknown): AgentProcessIdentity | undefined { + if (typeof value === 'string') { + try { + value = JSON.parse(value) + } catch { + return undefined + } + } + if (!value || typeof value !== 'object') { + return undefined + } + if (!('pid' in value) || !('platform' in value) || !('startTime' in value)) { + return undefined + } + const { pid, platform, startTime } = value + if ( + typeof pid !== 'number' || + !Number.isSafeInteger(pid) || + pid <= 1 || + pid > 0xffffffff || + (platform !== 'darwin' && platform !== 'linux' && platform !== 'win32') || + typeof startTime !== 'string' || + startTime.length === 0 || + startTime.length > 160 + ) { + return undefined + } + return { pid, platform, startTime } +} + +export function readAgentProcessPresence(value: unknown): AgentProcessPresence | undefined { + if ( + !value || + typeof value !== 'object' || + !('sessionId' in value) || + typeof value.sessionId !== 'string' || + !value.sessionId || + value.sessionId.length > 512 + ) { + return undefined + } + return { + sessionId: value.sessionId, + process: 'process' in value ? readAgentProcessIdentity(value.process) : undefined, + ...('ended' in value && value.ended === true ? { ended: true as const } : {}), + ...('sessionSwitch' in value && value.sessionSwitch === true + ? { sessionSwitch: true as const } + : {}) + } +} From 9f844a8e167f945b9068d9e7fa093f4489c07dfc Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:24:05 -0700 Subject: [PATCH 02/11] fix(agent-status): restrict process checks to real hook ingress --- .../agent-hooks/server-agent-presence.test.ts | 33 ++++++++++++++++-- .../server/server-ingest-normalization.ts | 5 ++- .../agent-hooks/server/server-lifecycle.ts | 1 + .../server/server-status-update.ts | 3 -- src/relay/agent-hook-server.ts | 6 ++-- src/relay/relay-agent-presence.test.ts | 34 +++++++++++++++++++ 6 files changed, 73 insertions(+), 9 deletions(-) diff --git a/src/main/agent-hooks/server-agent-presence.test.ts b/src/main/agent-hooks/server-agent-presence.test.ts index a7e5b1268577..5aac38d714bd 100644 --- a/src/main/agent-hooks/server-agent-presence.test.ts +++ b/src/main/agent-hooks/server-agent-presence.test.ts @@ -18,8 +18,20 @@ afterEach(() => { probe.mockResolvedValue('unverifiable') }) -async function createServer(): Promise { - const server = new AgentHookServer() +class PresenceTestServer extends AgentHookServer { + applyTranscriptUpdate(): void { + const row = this.state.lastStatusByPaneKey.get(PANE) + if (row) { + this.applyNormalizedStatus({ + ...row, + payload: { ...row.payload, lastAssistantMessage: 'late transcript result' } + }) + } + } +} + +async function createServer(): Promise { + const server = new PresenceTestServer() servers.push(server) await server.start({ env: 'production' }) return server @@ -143,4 +155,21 @@ describe('host-owned hook presence', () => { ) expect(visible(server)).toBe(false) }) + it('checks real hooks but never a transcript retry status write', async () => { + const server = await createServer() + await hook(server, 'SessionStart') + expect(probe).toHaveBeenCalledOnce() + probe.mockClear() + probe.mockResolvedValue('exited') + server.applyTranscriptUpdate() + await Promise.resolve() + expect( + server.getStatusSnapshot().find((row) => row.paneKey === PANE)?.lastAssistantMessage + ).toBe('late transcript result') + expect(probe).not.toHaveBeenCalled() + expect(visible(server)).toBe(true) + await hook(server, 'Stop') + expect(probe).toHaveBeenCalledOnce() + expect(visible(server)).toBe(false) + }) }) diff --git a/src/main/agent-hooks/server/server-ingest-normalization.ts b/src/main/agent-hooks/server/server-ingest-normalization.ts index 171a15ac5cc8..974937476d69 100644 --- a/src/main/agent-hooks/server/server-ingest-normalization.ts +++ b/src/main/agent-hooks/server/server-ingest-normalization.ts @@ -82,7 +82,10 @@ export abstract class AgentHookServerIngestNormalization extends AgentHookServer this.observations.rebind(event.paneKey) } this.recordCurrentAuthorityObservation(event) - this.applyNormalizedStatus(event, normalized.onAccepted) + const enriched = this.applyNormalizedStatus(event, normalized.onAccepted) + if (enriched) { + void this.checkAgentPresence(enriched.paneKey) + } if (event.payload.state !== 'done') { this.withdrawReplayObservation(this.resolvePaneKeyAlias(event.paneKey)) } diff --git a/src/main/agent-hooks/server/server-lifecycle.ts b/src/main/agent-hooks/server/server-lifecycle.ts index 816058e55fdd..6201caa59c2d 100644 --- a/src/main/agent-hooks/server/server-lifecycle.ts +++ b/src/main/agent-hooks/server/server-lifecycle.ts @@ -131,6 +131,7 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv this.recordCurrentAuthorityObservation(event) const enriched = this.applyNormalizedStatus(event, normalized.onAccepted) if (enriched) { + void this.checkAgentPresence(enriched.paneKey) this.scheduleAssistantMessageRetry(source, aliasedBody, enriched) this.scheduleTranscriptPoll(source, aliasedBody, enriched) } diff --git a/src/main/agent-hooks/server/server-status-update.ts b/src/main/agent-hooks/server/server-status-update.ts index 33e621b64c5f..bb40c187134f 100644 --- a/src/main/agent-hooks/server/server-status-update.ts +++ b/src/main/agent-hooks/server/server-status-update.ts @@ -242,9 +242,6 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA return undefined } this.commitStatusRowMutation(rowBefore, enriched) - if (enriched.agentPresence && enriched.connectionId === null) { - void this.checkAgentPresence(enriched.paneKey) - } // Why skipped for structured rows: the serializer drops them, so the whole walk and stringify // can only ever reproduce the last file — once per debounce window for a streaming chat. if (!enriched.structuredHost) { diff --git a/src/relay/agent-hook-server.ts b/src/relay/agent-hook-server.ts index 91523114b6e7..bb9b221f1487 100644 --- a/src/relay/agent-hook-server.ts +++ b/src/relay/agent-hook-server.ts @@ -105,7 +105,7 @@ export class RelayAgentHookServer { env: this.env, isListening: () => this.server !== null, applyEvent: (event, source, env, version) => { - this.applyEvent(event, source, env, version) + this.applyEvent(event, source, env, version, { checkPresence: false }) } }) } @@ -277,7 +277,7 @@ export class RelayAgentHookServer { source: AgentHookSource, env?: string, version?: string, - options: { isReplay?: boolean } = {} + options: { isReplay?: boolean; checkPresence?: boolean } = {} ): void { const transitioned = transitionHookPresence( incoming, @@ -313,7 +313,7 @@ export class RelayAgentHookServer { this.lastEnvelopeMetaByPaneKey.delete(event.paneKey) this.lastEnvelopeMetaByPaneKey.set(event.paneKey, { source, env, version }) this.forward(buildRelayHookEnvelope(event, source, env, version, options)) - if (event.agentPresence && !event.agentPresence.ended) { + if (options.checkPresence !== false && event.agentPresence && !event.agentPresence.ended) { void this.checkAgentPresence(event.paneKey) } } diff --git a/src/relay/relay-agent-presence.test.ts b/src/relay/relay-agent-presence.test.ts index 90347bc4c484..4efb795e4d9e 100644 --- a/src/relay/relay-agent-presence.test.ts +++ b/src/relay/relay-agent-presence.test.ts @@ -2,6 +2,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' +import type * as RetrySchedulerModule from './agent-hook-result-retry-scheduler' +import type { AgentHookResultRetryHost } from './agent-hook-result-retry-scheduler' import { RelayAgentHookServer } from './agent-hook-server' import type { AgentHookRelayEnvelope } from '../shared/agent-hook-relay' import { makePaneKey } from '../shared/stable-pane-id' @@ -10,7 +12,24 @@ const probe = vi.hoisted(() => vi.fn(async (): Promise<'live' | 'unverifiable' | 'exited'> => 'unverifiable') ) vi.mock('../shared/agent-process-presence-probe', () => ({ probeAgentProcessPresence: probe })) +const retryHosts = vi.hoisted(() => { + const hosts: AgentHookResultRetryHost[] = [] + return hosts +}) +vi.mock('./agent-hook-result-retry-scheduler', async (importOriginal) => { + const actual = await importOriginal() + return { + AgentHookResultRetryScheduler: class extends actual.AgentHookResultRetryScheduler { + constructor(host: AgentHookResultRetryHost) { + super(host) + retryHosts.push(host) + } + } + } +}) + afterEach(() => { + retryHosts.length = 0 probe.mockReset() probe.mockResolvedValue('unverifiable') }) @@ -40,6 +59,21 @@ describe('relay process presence', () => { } await post('SessionStart', 'a') expect(forward.mock.lastCall?.[0].agentPresence?.sessionId).toBe('a') + expect(probe).toHaveBeenCalledOnce() + probe.mockClear() + const retryHost = retryHosts[0] + const original = retryHost.state.lastStatusByPaneKey.get(paneKey) + if (!original) { + throw new Error('missing hook status') + } + retryHost.applyEvent( + { ...original, payload: { ...original.payload, lastAssistantMessage: 'late result' } }, + 'claude' + ) + await Promise.resolve() + expect(forward.mock.lastCall?.[0].payload.lastAssistantMessage).toBe('late result') + expect(probe).not.toHaveBeenCalled() + await server.checkAgentPresence(paneKey) expect(forward.mock.lastCall?.[0].agentPresence?.ended).toBeUndefined() await post('SessionEnd', 'a', 'resume') From fe8d2e33c6007577b030883d7777d5a98e0d2cc1 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:59:53 -0700 Subject: [PATCH 03/11] fix(agent-status): keep presence checks from causing false exits or losing real ones - Pin the macOS process start time to UTC on both the hook and the host so a shell TZ or a time-zone change cannot turn a live Claude into an exit. - An unanswered process check falls back to the foreground confirmation, so Codex, SSH and Windows panes still leave the agent state on a real exit and the Codex late-completion recovery still runs. - A nested agent that inherits the pane key cannot take over the pane's presence; a retired session is replaced by the next session even when its SessionStart was lost. - Drop the unused Windows process read (no Windows hook captures an identity yet) so shared code no longer imports main-process modules. - Skip the capture outside Orca panes, gate relay re-checks to real title changes, and list the capture module in the CLI project. --- config/tsconfig.cli.json | 1 + .../agent-hooks/hook-process-capture.test.ts | 9 ++-- src/main/agent-hooks/hook-process-capture.ts | 6 ++- .../agent-hooks/server-agent-presence.test.ts | 53 ++++++++++++++++--- src/main/agent-hooks/server.test-fixtures.ts | 1 + src/main/claude/hook-script.ts | 2 +- ...e-get-unpersisted-tracked-title-for-pty.ts | 8 +-- ...ntime-serialize-agent-prompt-submission.ts | 3 +- ...rca-runtime-test-scenario-builders.spec.ts | 7 ++- .../terminal-side-effect-facts.spec.ts | 22 ++++++++ ...chestration-codex-completion-title.test.ts | 21 +++++--- ...ation-mailbox-notification-test-harness.ts | 2 + ...gent-hook-retired-pane-suppression.test.ts | 2 + src/relay/pty-handler.ts | 13 ++++- .../agent-decorative-title-signature.ts | 9 ++++ src/shared/agent-hook-presence-transition.ts | 47 +++++++++++++--- .../agent-process-presence-platform.test.ts | 18 +------ src/shared/agent-process-presence-probe.ts | 14 ++--- 18 files changed, 173 insertions(+), 65 deletions(-) diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index 85f3529b2dcb..6d184b8ccac2 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -12,6 +12,7 @@ "../src/main/codebuddy/hook-service.ts", "../src/main/agent-hooks/hook-stdin-contract.ts", "../src/main/agent-hooks/hook-post-command.ts", + "../src/main/agent-hooks/hook-process-capture.ts", "../src/main/agent-hooks/hook-config-write-path.ts", "../src/main/agent-hooks/hooks-json-read.ts", "../src/main/agent-hooks/installer-utils.ts", diff --git a/src/main/agent-hooks/hook-process-capture.test.ts b/src/main/agent-hooks/hook-process-capture.test.ts index 5fa34da63f3c..6cf965ae261f 100644 --- a/src/main/agent-hooks/hook-process-capture.test.ts +++ b/src/main/agent-hooks/hook-process-capture.test.ts @@ -9,9 +9,9 @@ import { readAgentProcessIdentity } from '../../shared/agent-process-presence' import { probeAgentProcessPresence } from '../../shared/agent-process-presence-probe' describe('Claude outer hook process capture', () => { - it.skipIf(process.platform === 'win32')( - 'captures the parent before the managed script shell, under an empty environment', - async () => { + it.skipIf(process.platform === 'win32').each([undefined, 'Asia/Tokyo'])( + 'captures the parent before the managed script shell, under an empty environment (TZ=%s)', + async (timeZone) => { const home = await mkdtemp(join(tmpdir(), 'orca-presence-hook-')) try { const dir = join(home, '.orca', 'agent-hooks') @@ -29,6 +29,9 @@ describe('Claude outer hook process capture', () => { '-i', `HOME=${home}`, 'PATH=/usr/bin:/bin', + 'ORCA_PANE_KEY=presence-test-pane', + // Why: the agent's shell zone may differ from Orca's; the identity must not. + ...(timeZone ? [`TZ=${timeZone}`] : []), '/bin/sh', '-c', wrapRuntimeHomeHookCommand('claude-hook') diff --git a/src/main/agent-hooks/hook-process-capture.ts b/src/main/agent-hooks/hook-process-capture.ts index c5168dae2dc9..0e68c51fed1b 100644 --- a/src/main/agent-hooks/hook-process-capture.ts +++ b/src/main/agent-hooks/hook-process-capture.ts @@ -5,7 +5,8 @@ export const CLAUDE_HOOK_PARENT_CAPTURE = export function buildHookProcessCapture(): string[] { return [ 'orca_agent_process=', - 'case "${ORCA_HOOK_AGENT_PID:-}" in ""|*[!0-9]*) ;; *)', + // Why: skip the process reads for Claude sessions outside an Orca pane. + '[ -z "${ORCA_PANE_KEY:-}" ] || case "${ORCA_HOOK_AGENT_PID:-}" in ""|*[!0-9]*) ;; *)', ' if [ -r "/proc/$ORCA_HOOK_AGENT_PID/stat" ]; then', ' orca_agent_stat=$(cat "/proc/$ORCA_HOOK_AGENT_PID/stat" 2>/dev/null) || orca_agent_stat=', ' orca_agent_boot=$(cat /proc/sys/kernel/random/boot_id 2>/dev/null) || orca_agent_boot=', @@ -14,7 +15,8 @@ export function buildHookProcessCapture(): string[] { ' case "$orca_agent_start" in ""|*[!0-9]*) ;; *)', ' [ -z "$orca_agent_boot" ] || orca_agent_process=$(printf \'{"pid":%s,"platform":"linux","startTime":"%s:%s"}\' "$ORCA_HOOK_AGENT_PID" "$orca_agent_boot" "$orca_agent_start") ;; esac', ' elif [ "$(uname -s 2>/dev/null)" = Darwin ]; then', - ' orca_agent_start=$(LC_ALL=C /bin/ps -p "$ORCA_HOOK_AGENT_PID" -o lstart= 2>/dev/null | sed \'s/^ *//;s/ *$//\')', + // Why: lstart is local time; the host probe pins the same zone so the strings compare. + ' orca_agent_start=$(TZ=UTC0 LC_ALL=C /bin/ps -p "$ORCA_HOOK_AGENT_PID" -o lstart= 2>/dev/null | sed \'s/^ *//;s/ *$//\')', ' [ -z "$orca_agent_start" ] || orca_agent_process=$(printf \'{"pid":%s,"platform":"darwin","startTime":"%s"}\' "$ORCA_HOOK_AGENT_PID" "$orca_agent_start")', ' fi ;;', 'esac' diff --git a/src/main/agent-hooks/server-agent-presence.test.ts b/src/main/agent-hooks/server-agent-presence.test.ts index 5aac38d714bd..5d6b56b8a725 100644 --- a/src/main/agent-hooks/server-agent-presence.test.ts +++ b/src/main/agent-hooks/server-agent-presence.test.ts @@ -41,20 +41,34 @@ async function hook( server: AgentHookServer, event: string, session = 'session-a', - reason?: string + reason?: string, + pid?: number ): Promise { + const agentProcess = + pid === undefined + ? undefined + : JSON.stringify({ pid, platform: process.platform, startTime: `birth-${pid}` }) const response = await postHookEvent( server, - buildBody({ - hook_event_name: event, - session_id: session, - source: 'startup', - reason - }) + buildBody( + { + hook_event_name: event, + session_id: session, + source: 'startup', + reason, + ...(event === 'UserPromptSubmit' ? { prompt: `${session} task` } : {}) + }, + { agentProcess } + ) ) expect(response.status).toBe(204) } +function state(server: AgentHookServer): string | null { + const row = server.getStatusSnapshot().find((entry) => entry.paneKey === PANE) + return row && !row.providerSessionOnly ? row.state : null +} + function visible(server: AgentHookServer): boolean { return server.getStatusSnapshot().some((row) => row.paneKey === PANE && !row.providerSessionOnly) } @@ -83,6 +97,31 @@ describe('host-owned hook presence', () => { } ) + it('keeps the pane owned by its agent while a nested agent in it starts and ends', async () => { + const server = await createServer() + await hook(server, 'SessionStart', 'outer', undefined, 4001) + await hook(server, 'UserPromptSubmit', 'outer', undefined, 4001) + await hook(server, 'SessionStart', 'nested', undefined, 4002) + await hook(server, 'UserPromptSubmit', 'nested', undefined, 4002) + await hook(server, 'SessionEnd', 'nested', 'other', 4002) + expect(visible(server)).toBe(true) + await hook(server, 'PostToolUse', 'outer', undefined, 4001) + expect(state(server)).toBe('working') + await hook(server, 'SessionEnd', 'outer', 'other', 4001) + expect(visible(server)).toBe(false) + }) + + it('lets the next session replace a switched one even if its SessionStart was lost', async () => { + const server = await createServer() + await hook(server, 'SessionStart', 'session-a', undefined, 4001) + await hook(server, 'SessionEnd', 'session-a', 'clear', 4001) + await hook(server, 'Stop', 'session-a', undefined, 4001) + await hook(server, 'UserPromptSubmit', 'session-b', undefined, 4001) + expect(state(server)).toBe('working') + await hook(server, 'SessionEnd', 'session-b', 'other', 4001) + expect(visible(server)).toBe(false) + }) + it('does not resurrect an ended session on a late Stop', async () => { const server = await createServer() await hook(server, 'SessionStart') diff --git a/src/main/agent-hooks/server.test-fixtures.ts b/src/main/agent-hooks/server.test-fixtures.ts index 12ef7e9c6e95..99bb0aeee696 100644 --- a/src/main/agent-hooks/server.test-fixtures.ts +++ b/src/main/agent-hooks/server.test-fixtures.ts @@ -20,6 +20,7 @@ export type Body = { worktreeId?: string env?: string version?: string + agentProcess?: string payload: Record } diff --git a/src/main/claude/hook-script.ts b/src/main/claude/hook-script.ts index 4595c7f264cb..3640fbee2134 100644 --- a/src/main/claude/hook-script.ts +++ b/src/main/claude/hook-script.ts @@ -59,7 +59,6 @@ export function getManagedScript( return [ '#!/bin/sh', - ...(source === 'claude' ? buildHookProcessCapture() : []), // Why: Claude-compatible permission hooks fail closed on empty stdout (#14818). 'printf "{}\\n"', ...buildPosixHookPayloadCapture(), @@ -78,6 +77,7 @@ export function getManagedScript( 'if [ -n "$CLAUDE_JOB_DIR" ]; then', ' exit 0', 'fi', + ...(source === 'claude' ? buildHookProcessCapture() : []), // Why: refresh endpoint coordinates for PTYs surviving an Orca restart. // Why: suppress parse errors so they neither leak nor trip outer set -e. 'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then', diff --git a/src/main/runtime/orca-runtime-get-unpersisted-tracked-title-for-pty.ts b/src/main/runtime/orca-runtime-get-unpersisted-tracked-title-for-pty.ts index 56341301e201..aff21ba53cb3 100644 --- a/src/main/runtime/orca-runtime-get-unpersisted-tracked-title-for-pty.ts +++ b/src/main/runtime/orca-runtime-get-unpersisted-tracked-title-for-pty.ts @@ -1,6 +1,6 @@ // @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. import { OrcaRuntimeWithEmitDaemonPtyTransientFact } from './orca-runtime-emit-daemon-pty-transient-fact' -import { getDecorativeAgentTitleSignature } from '../../shared/agent-decorative-title-signature' +import { getDecorativeTitleGateKey } from '../../shared/agent-decorative-title-signature' import { shouldEmitTitleFactForFrame } from './decorative-title-fact-emission' import type { RuntimePtyTitleTrackerEntry } from './runtime-terminal-state-records' import { createTerminalTitleTracker } from '../../shared/terminal-output-side-effects' @@ -37,11 +37,7 @@ export class OrcaRuntimeWithGetUnpersistedTrackedTitleForPty extends OrcaRuntime /** Decorative comparison key: only recognized agent titles fold leading spinner frames. */ protected makeDecorativeTitleGateKey(rawTitle: string, normalizedTitle: string): string { - // Stable Pi/Gemini/Grok display normalization also defines their semantic gate. - const normalizedSignature = - rawTitle === normalizedTitle ? null : getDecorativeAgentTitleSignature(normalizedTitle) - const signature = normalizedSignature ?? getDecorativeAgentTitleSignature(rawTitle) - return signature === null ? `literal\u0000${normalizedTitle}` : `agent\u0000${signature}` + return getDecorativeTitleGateKey(rawTitle, normalizedTitle) } protected getOrCreatePtyTitleTrackerEntry(ptyId: string): RuntimePtyTitleTrackerEntry { diff --git a/src/main/runtime/orca-runtime-serialize-agent-prompt-submission.ts b/src/main/runtime/orca-runtime-serialize-agent-prompt-submission.ts index 8a8df667e043..29c658bfe54f 100644 --- a/src/main/runtime/orca-runtime-serialize-agent-prompt-submission.ts +++ b/src/main/runtime/orca-runtime-serialize-agent-prompt-submission.ts @@ -98,7 +98,8 @@ export class OrcaRuntimeWithSerializeAgentPromptSubmission extends OrcaRuntimeWi if (this.ptysById.get(ptyId) !== current || current?.incarnationId !== incarnation) { return } - if (verdict === null) { + // Why: an unanswered process check falls back to the foreground read, which itself never exits on silence. + if (verdict === null || verdict === 'unverifiable') { this.confirmLegacyPtyAgentExit(ptyId, recoverCompletedHook) } else if (verdict === 'exited' && !recoverCompletedHook) { this.recordTerminalSideEffectFact(ptyId, { kind: 'agent-exited' }) diff --git a/src/main/runtime/orca-runtime-test-scenario-builders.spec.ts b/src/main/runtime/orca-runtime-test-scenario-builders.spec.ts index 1d63e09c4c7f..d0cd3608414d 100644 --- a/src/main/runtime/orca-runtime-test-scenario-builders.spec.ts +++ b/src/main/runtime/orca-runtime-test-scenario-builders.spec.ts @@ -437,13 +437,16 @@ async function reconcileWithToken(runtime: RuntimeService, token: string): Promi fetchPromise: Promise.resolve({ ok: true }) }) } -function createSideEffectRuntime(): { +function createSideEffectRuntime( + checkHookAgentPresence?: (paneKey: string) => Promise<'live' | 'unverifiable' | 'exited' | null> +): { runtime: RuntimeService batches: TerminalSideEffectBatch[] } { const batches: TerminalSideEffectBatch[] = [] const runtime = new OrcaRuntimeService(store, undefined, { - onTerminalSideEffects: (batch) => batches.push(batch) + onTerminalSideEffects: (batch) => batches.push(batch), + checkHookAgentPresence }) return { runtime, batches } } diff --git a/src/main/runtime/orca-runtime-tests/terminal-side-effect-facts.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-side-effect-facts.spec.ts index 44dba8c6bd71..00fd3d612b56 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-side-effect-facts.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-side-effect-facts.spec.ts @@ -474,6 +474,28 @@ describe('terminal side-effect fact channel', () => { ) }) + it.each([ + { presence: 'unverifiable' as const, exited: true }, + { presence: 'exited' as const, exited: true }, + { presence: 'live' as const, exited: false } + ])('confirms an exit title from hook presence $presence', async ({ presence, exited }) => { + const checkPresence = vi.fn(async () => presence) + const { runtime, batches } = createSideEffectRuntime(checkPresence) + syncSinglePty(runtime) + const getForegroundProcess = vi.fn(async () => 'zsh') + runtime.setPtyController({ write: () => true, kill: () => true, getForegroundProcess }) + runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;Codex ready\x07') + + runtime.onPtyData('pty-1', '\x1b]0;~/repo\x07', 100) + + await vi.waitFor(() => expect(checkPresence).toHaveBeenCalled()) + await new Promise((resolve) => setTimeout(resolve, 50)) + // Why: an unanswered process check (Codex, SSH, Windows) must still reach the foreground read. + expect(batches.flatMap((batch) => batch.facts).some((f) => f.kind === 'agent-exited')).toBe( + exited + ) + }) + it('treats synchronous foreground read failures as unavailable', async () => { const { runtime, batches } = createSideEffectRuntime() syncSinglePty(runtime) diff --git a/src/main/runtime/orchestration-codex-completion-title.test.ts b/src/main/runtime/orchestration-codex-completion-title.test.ts index 92f55d166ff4..70de0c987fed 100644 --- a/src/main/runtime/orchestration-codex-completion-title.test.ts +++ b/src/main/runtime/orchestration-codex-completion-title.test.ts @@ -28,7 +28,7 @@ vi.mock('electron', () => ({ webContents: { fromId: vi.fn(() => null) } })) -function completionFixture(delayMs = 0) { +function completionFixture(delayMs = 0, presence?: 'unverifiable') { const db = createDatabase('orca-codex-completion-title-') const hook: AgentStatusIpcPayload = { paneKey: PANE_KEY, @@ -40,7 +40,11 @@ function completionFixture(delayMs = 0) { receivedAt: Date.now(), stateStartedAt: Date.now() } - const { runtime } = createRuntime(db, { getAgentStatusSnapshot: () => [hook] }) + const { runtime } = createRuntime(db, { + getAgentStatusSnapshot: () => [hook], + // Why: a Codex hook row has presence but no process id, so the store cannot answer. + ...(presence ? { checkHookAgentPresence: async () => presence } : {}) + }) const write = vi.fn((_ptyId: string, _data: string) => true) const getForegroundProcess = vi.fn(async (): Promise => { if (delayMs) { @@ -75,12 +79,17 @@ describe('Codex completion title mailbox delivery', () => { { arrival: 'before', delay: 0 }, { arrival: 'after', delay: 0 }, { arrival: 'before', delay: 750 }, - { arrival: 'after', delay: 750 } + { arrival: 'after', delay: 750 }, + { arrival: 'before', delay: 0, presence: 'unverifiable' as const }, + { arrival: 'after', delay: 750, presence: 'unverifiable' as const } ])( - 'submits mail arriving $arrival completion with a $delay ms host probe', - async ({ arrival, delay }) => { + 'submits mail arriving $arrival completion with a $delay ms host probe (presence $presence)', + async ({ arrival, delay, presence }) => { vi.useFakeTimers() - const { db, runtime, write, run, completeWithNativeTitles } = completionFixture(delay) + const { db, runtime, write, run, completeWithNativeTitles } = completionFixture( + delay, + presence + ) await runtime.listTerminals() if (arrival === 'before') { insertDirectRunMessage(db, run.id, 'Worker progress') diff --git a/src/main/runtime/orchestration-mailbox-notification-test-harness.ts b/src/main/runtime/orchestration-mailbox-notification-test-harness.ts index 7bd3ae4665ee..2622416d74f0 100644 --- a/src/main/runtime/orchestration-mailbox-notification-test-harness.ts +++ b/src/main/runtime/orchestration-mailbox-notification-test-harness.ts @@ -89,10 +89,12 @@ export function createRuntime( connectionId?: string isWsl?: boolean getAgentStatusSnapshot?: () => AgentStatusIpcPayload[] + checkHookAgentPresence?: (paneKey: string) => Promise<'live' | 'unverifiable' | 'exited' | null> } = {} ): MailboxNotificationHarness { const runtime = new OrcaRuntimeService(null, undefined, { getAgentStatusSnapshot: options.getAgentStatusSnapshot, + checkHookAgentPresence: options.checkHookAgentPresence, attestAgentHookCompatibilityAuthority: ({ paneKey }) => paneKey === PANE_KEY || paneKey.startsWith(`${SECOND_TAB_ID}:`) ? { paneKey, source: 'current_hook' } diff --git a/src/relay/agent-hook-retired-pane-suppression.test.ts b/src/relay/agent-hook-retired-pane-suppression.test.ts index ab48893f76db..23285c9e3d1b 100644 --- a/src/relay/agent-hook-retired-pane-suppression.test.ts +++ b/src/relay/agent-hook-retired-pane-suppression.test.ts @@ -162,8 +162,10 @@ describe('RelayAgentHookRuntime wiring', () => { it('routes hook admission through the PTY handler and drops the cache on retirement', async () => { const retired = new RetiredPaneSurfaceRegistry() const surfaceRetiredListeners: PtySurfaceRetiredListener[] = [] + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this stand-in implements every PtyHandler member the hook runtime registers or reads. const ptyHandler = { addEnvAugmenter: vi.fn(), + setAgentPresenceTrigger: vi.fn(), setExitListener: vi.fn(), setSurfaceRetiredListener: vi.fn((listener: PtySurfaceRetiredListener | null) => { if (listener) { diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index 0d7ccac38bf0..17e52e9ea7bd 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -1,4 +1,5 @@ import { createTerminalTitleTracker } from '../shared/terminal-output-side-effects' +import { getDecorativeTitleGateKey } from '../shared/agent-decorative-title-signature' import { FreebuffStatusProjection } from './freebuff-status-projection' import { applyRelayAgentWorkspaceTrust } from './agent-workspace-trust-spawn' /* oxlint-disable max-lines */ @@ -714,13 +715,13 @@ export class PtyHandler { return this.graceTimeMs } - /** Subscribe to PTY-exit events (relay-hook server uses this to evict per-paneKey caches). */ private agentPresenceTrigger: ((paneKey: string) => void) | null = null setAgentPresenceTrigger(listener: ((paneKey: string) => void) | null): void { this.agentPresenceTrigger = listener } + /** Subscribe to PTY-exit events (relay-hook server uses this to evict per-paneKey caches). */ setExitListener(listener: PtyExitListener | null): void { this.exitListener = listener } @@ -1029,8 +1030,16 @@ export class PtyHandler { this.agentPresenceTrigger?.(managed.paneKey) } } + let lastTitleGateKey: string | null = null const presenceTriggers = createTerminalTitleTracker({ - onTitle: recheckAgentPresence, + onTitle: (normalizedTitle, rawTitle, meta) => { + // Why: spinner frames arrive several times a second; only a real title change re-checks. + const gateKey = getDecorativeTitleGateKey(rawTitle, normalizedTitle) + if (gateKey !== lastTitleGateKey && !meta?.staleWorkingTitleClear) { + recheckAgentPresence() + } + lastTitleGateKey = gateKey + }, onCommandFinished: recheckAgentPresence }) managed.pty.onData((data: string) => { diff --git a/src/shared/agent-decorative-title-signature.ts b/src/shared/agent-decorative-title-signature.ts index 317a744d0984..f3aa63c6d8fa 100644 --- a/src/shared/agent-decorative-title-signature.ts +++ b/src/shared/agent-decorative-title-signature.ts @@ -17,6 +17,15 @@ export function getDecorativeAgentTitleSignature(title: string): string | null { return `${status}:${normalizeDecorativeAgentTitleText(title)}` } +/** Equal keys mean the title changed only by spinner decoration. */ +export function getDecorativeTitleGateKey(rawTitle: string, normalizedTitle: string): string { + // Stable Pi/Gemini/Grok display normalization also defines their semantic gate. + const normalizedSignature = + rawTitle === normalizedTitle ? null : getDecorativeAgentTitleSignature(normalizedTitle) + const signature = normalizedSignature ?? getDecorativeAgentTitleSignature(rawTitle) + return signature === null ? `literal\u0000${normalizedTitle}` : `agent\u0000${signature}` +} + export function isDecorativeAgentTitleFrameChange(prevTitle: string, nextTitle: string): boolean { const prevSignature = getDecorativeAgentTitleSignature(prevTitle) return prevSignature !== null && prevSignature === getDecorativeAgentTitleSignature(nextTitle) diff --git a/src/shared/agent-hook-presence-transition.ts b/src/shared/agent-hook-presence-transition.ts index 6dd87cf453cf..0280ce528f00 100644 --- a/src/shared/agent-hook-presence-transition.ts +++ b/src/shared/agent-hook-presence-transition.ts @@ -1,11 +1,26 @@ import type { AgentHookEventPayload } from './agent-hook-listener/listener-event' +import type { AgentProcessIdentity } from './agent-process-presence' + +function claimsAgent(event: AgentHookEventPayload): boolean { + const agentType = event.payload.agentType + return agentType !== undefined && agentType !== 'unknown' +} + +function isOtherProcess(a: AgentProcessIdentity, b: AgentProcessIdentity): boolean { + return a.pid !== b.pid || a.platform !== b.platform || a.startTime !== b.startTime +} /** Apply lifecycle evidence only to the session that supplied it. */ export function transitionHookPresence( incoming: AgentHookEventPayload, previous: AgentHookEventPayload | undefined ): AgentHookEventPayload | undefined { - if (previous && previous.payload.agentType !== incoming.payload.agentType) { + if ( + previous && + claimsAgent(previous) && + claimsAgent(incoming) && + previous.payload.agentType !== incoming.payload.agentType + ) { return incoming } const next = incoming.agentPresence @@ -16,8 +31,9 @@ export function transitionHookPresence( if (!next) { return prior ? { ...incoming, agentPresence: prior } : incoming } + const retired = prior?.ended || prior?.sessionSwitch if (incoming.hookEventName === 'SessionEnd') { - if (!prior || prior.sessionId !== next.sessionId || prior.ended) { + if (!prior || prior.sessionId !== next.sessionId || retired) { return undefined } const switching = @@ -29,16 +45,31 @@ export function transitionHookPresence( agentPresence: { ...prior, ...(switching ? { sessionSwitch: true } : { ended: true }) } } } + if (!prior) { + return incoming + } + if (retired) { + // Why: a late event from the retired session must not revive it; any other session replaces it. + if (prior.sessionId === next.sessionId && incoming.hookEventName !== 'SessionStart') { + return undefined + } + const process = next.process ?? (prior.sessionSwitch ? prior.process : undefined) + return { ...incoming, agentPresence: { ...next, process } } + } if ( - prior && - incoming.hookEventName !== 'SessionStart' && - (prior.ended || prior.sessionSwitch || prior.sessionId !== next.sessionId) + prior.sessionId !== next.sessionId && + prior.process && + next.process && + isOtherProcess(prior.process, next.process) ) { - return undefined + // Why: nested agents inherit ORCA_PANE_KEY; their sessions must not own the pane's presence. + return { ...incoming, agentPresence: prior } } - const sameProcess = !prior?.ended && (prior?.sessionId === next.sessionId || prior?.sessionSwitch) return { ...incoming, - agentPresence: { ...next, process: next.process ?? (sameProcess ? prior?.process : undefined) } + agentPresence: { + ...next, + process: next.process ?? (prior.sessionId === next.sessionId ? prior.process : undefined) + } } } diff --git a/src/shared/agent-process-presence-platform.test.ts b/src/shared/agent-process-presence-platform.test.ts index e11717ed488a..cc6295773c7a 100644 --- a/src/shared/agent-process-presence-platform.test.ts +++ b/src/shared/agent-process-presence-platform.test.ts @@ -1,12 +1,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { readAgentProcess } from './agent-process-presence-probe' -const nativeRead = vi.hoisted(() => vi.fn()) const run = vi.hoisted(() => vi.fn()) const read = vi.hoisted(() => vi.fn()) -vi.mock('../main/windows/windows-process-table', () => ({ - readWindowsProcessCreationTime: nativeRead -})) vi.mock('./child-process/run-process', () => ({ runProcess: run })) vi.mock('node:fs/promises', () => ({ readFile: read })) const hostProcess = process @@ -19,21 +15,11 @@ afterEach(() => { }) describe('platform process evidence', () => { - it('never turns Windows native null or unavailable capability into exit', async () => { + it('leaves Windows unanswered until a Windows hook captures an identity', async () => { platform('win32') - nativeRead.mockReturnValue(null) expect(await readAgentProcess(4242)).toEqual({ verdict: 'unverifiable' }) - nativeRead.mockImplementation(() => { - throw new Error('missing addon') - }) - expect(await readAgentProcess(4242)).toEqual({ verdict: 'unverifiable' }) - nativeRead.mockReturnValue(100) - expect(await readAgentProcess(4242)).toEqual({ - verdict: 'live', - startTime: '100', - zombie: false - }) expect(run).not.toHaveBeenCalled() + expect(read).not.toHaveBeenCalled() }) it('treats a missing macOS utility as unanswered', async () => { diff --git a/src/shared/agent-process-presence-probe.ts b/src/shared/agent-process-presence-probe.ts index 78d0ba9432ef..49f4c37938ae 100644 --- a/src/shared/agent-process-presence-probe.ts +++ b/src/shared/agent-process-presence-probe.ts @@ -36,21 +36,12 @@ export async function readAgentProcess(pid: number): Promise Date: Tue, 29 Sep 2026 17:22:30 -0700 Subject: [PATCH 04/11] refactor(agent-status): own pane presence by the agent's process, not its session Presence now exists only when a hook carries the agent's process identity, and only that process's evidence changes it: its SessionEnd ends the pane, its /clear and /resume keep it, and hooks from any other process (a nested agent inheriting the pane key) update status without taking ownership. Hooks without an identity (Windows, sessions started before the capture) behave exactly as before, so a nested agent can no longer end a pane it does not own. An ended owner stops answering 'exited', and the host probes the owner only when another process reports in the pane. --- .../agent-hooks/server-agent-presence.test.ts | 100 +++++++++--------- .../server/server-agent-presence.ts | 20 +++- .../server/server-ingest-remote.ts | 5 - .../agent-hooks/server/server-lifecycle.ts | 2 +- src/main/agent-hooks/server/server-state.ts | 4 + src/relay/agent-hook-envelope-build.ts | 1 - src/relay/agent-hook-server.ts | 12 ++- src/relay/relay-agent-presence.test.ts | 17 +-- src/shared/agent-hook-listener.ts | 23 ++-- .../agent-hook-listener/listener-event.ts | 1 - src/shared/agent-hook-presence-transition.ts | 63 +++-------- src/shared/agent-hook-relay.ts | 1 - src/shared/agent-process-presence.ts | 31 +++--- 13 files changed, 133 insertions(+), 147 deletions(-) diff --git a/src/main/agent-hooks/server-agent-presence.test.ts b/src/main/agent-hooks/server-agent-presence.test.ts index 5d6b56b8a725..54229ef2168c 100644 --- a/src/main/agent-hooks/server-agent-presence.test.ts +++ b/src/main/agent-hooks/server-agent-presence.test.ts @@ -42,10 +42,10 @@ async function hook( event: string, session = 'session-a', reason?: string, - pid?: number + pid: number | null = 4001 ): Promise { const agentProcess = - pid === undefined + pid === null ? undefined : JSON.stringify({ pid, platform: process.platform, startTime: `birth-${pid}` }) const response = await postHookEvent( @@ -82,53 +82,60 @@ describe('host-owned hook presence', () => { expect(visible(server)).toBe(false) }) - it.each(['clear', 'resume'])( - 'keeps process presence through %s and rejects the previous session goodbye', - async (reason) => { - const server = await createServer() - await hook(server, 'SessionStart') - await hook(server, 'SessionEnd', 'session-a', reason) - expect(visible(server)).toBe(true) - await hook(server, 'SessionStart', 'session-b') - await hook(server, 'SessionEnd', 'session-a', 'prompt_input_exit') - expect(visible(server)).toBe(true) - await hook(server, 'SessionEnd', 'session-b', 'prompt_input_exit') - expect(visible(server)).toBe(false) - } - ) + it.each(['clear', 'resume'])('keeps the running process present through %s', async (reason) => { + const server = await createServer() + await hook(server, 'SessionStart') + await hook(server, 'SessionEnd', 'session-a', reason) + expect(visible(server)).toBe(true) + await hook(server, 'UserPromptSubmit', 'session-b') + expect(state(server)).toBe('working') + await hook(server, 'SessionEnd', 'session-b', 'prompt_input_exit') + expect(visible(server)).toBe(false) + }) + + it('does not resurrect an ended process on a late Stop', async () => { + const server = await createServer() + await hook(server, 'SessionStart') + await hook(server, 'SessionEnd', 'session-a', 'prompt_input_exit') + await hook(server, 'Stop') + expect(visible(server)).toBe(false) + }) it('keeps the pane owned by its agent while a nested agent in it starts and ends', async () => { const server = await createServer() - await hook(server, 'SessionStart', 'outer', undefined, 4001) - await hook(server, 'UserPromptSubmit', 'outer', undefined, 4001) + await hook(server, 'SessionStart', 'outer') + await hook(server, 'UserPromptSubmit', 'outer') await hook(server, 'SessionStart', 'nested', undefined, 4002) await hook(server, 'UserPromptSubmit', 'nested', undefined, 4002) await hook(server, 'SessionEnd', 'nested', 'other', 4002) expect(visible(server)).toBe(true) - await hook(server, 'PostToolUse', 'outer', undefined, 4001) + await hook(server, 'PostToolUse', 'outer') expect(state(server)).toBe('working') - await hook(server, 'SessionEnd', 'outer', 'other', 4001) + await hook(server, 'SessionEnd', 'outer', 'other') expect(visible(server)).toBe(false) }) - it('lets the next session replace a switched one even if its SessionStart was lost', async () => { + it('never ends a pane from a SessionEnd without a process identity', async () => { const server = await createServer() - await hook(server, 'SessionStart', 'session-a', undefined, 4001) - await hook(server, 'SessionEnd', 'session-a', 'clear', 4001) - await hook(server, 'Stop', 'session-a', undefined, 4001) - await hook(server, 'UserPromptSubmit', 'session-b', undefined, 4001) + await hook(server, 'SessionStart', 'outer', undefined, null) + await hook(server, 'UserPromptSubmit', 'outer', undefined, null) + await hook(server, 'SessionStart', 'nested', undefined, null) + await hook(server, 'SessionEnd', 'nested', 'other', null) + expect(visible(server)).toBe(true) + await hook(server, 'PostToolUse', 'outer', undefined, null) expect(state(server)).toBe('working') - await hook(server, 'SessionEnd', 'session-b', 'other', 4001) - expect(visible(server)).toBe(false) }) - it('does not resurrect an ended session on a late Stop', async () => { + it('lets an unidentified agent keep reporting after an identified nested agent ends', async () => { const server = await createServer() - await hook(server, 'SessionStart') - await hook(server, 'SessionEnd', 'session-a', 'prompt_input_exit') - await hook(server, 'Stop') - expect(visible(server)).toBe(false) + await hook(server, 'UserPromptSubmit', 'outer', undefined, null) + await hook(server, 'SessionStart', 'nested', undefined, 4002) + await hook(server, 'SessionEnd', 'nested', 'other', 4002) + await hook(server, 'PostToolUse', 'outer', undefined, null) + expect(state(server)).toBe('working') + expect(await server.checkAgentPresence(PANE)).toBeNull() }) + it('keeps unanswered reads and clears only a positive process exit', async () => { const server = await createServer() await hook(server, 'SessionStart') @@ -137,12 +144,12 @@ describe('host-owned hook presence', () => { probe.mockResolvedValue('exited') expect(await server.checkAgentPresence(PANE)).toBe('exited') expect(visible(server)).toBe(false) + expect(await server.checkAgentPresence(PANE)).toBeNull() }) - it('does not apply a delayed process exit to a replacement session', async () => { + it('does not apply a delayed process exit to a relaunched agent', async () => { const server = await createServer() await hook(server, 'SessionStart') - await server.checkAgentPresence(PANE) let finish: (value: 'exited') => void = () => {} probe.mockImplementationOnce( () => @@ -151,18 +158,12 @@ describe('host-owned hook presence', () => { }) ) const pending = server.checkAgentPresence(PANE) - await hook(server, 'SessionStart', 'replacement') + await hook(server, 'SessionEnd', 'session-a', 'prompt_input_exit') + await hook(server, 'SessionStart', 'relaunch', undefined, 4002) finish('exited') expect(await pending).toBe('unverifiable') expect(visible(server)).toBe(true) }) - it('admits an explicit same-session resume when no PID was available', async () => { - const server = await createServer() - await hook(server, 'SessionStart') - await hook(server, 'SessionEnd', 'session-a', 'prompt_input_exit') - await hook(server, 'SessionStart') - expect(visible(server)).toBe(true) - }) it('accepts a remote exit and never probes that remote PID locally', async () => { const server = await createServer() @@ -174,7 +175,6 @@ describe('host-owned hook presence', () => { hookEventName: 'SessionStart', providerSession: { provider: 'claude', id: 'remote-session' }, agentPresence: { - sessionId: 'remote-session', process: { pid: process.pid, platform: process.platform, startTime: 'remote-birth' } }, payload: { state: 'working', prompt: 'remote task', agentType: 'claude' } @@ -194,11 +194,11 @@ describe('host-owned hook presence', () => { ) expect(visible(server)).toBe(false) }) - it('checks real hooks but never a transcript retry status write', async () => { + + it('probes the owner only when another process reports, never on its own hooks or retries', async () => { const server = await createServer() await hook(server, 'SessionStart') - expect(probe).toHaveBeenCalledOnce() - probe.mockClear() + await hook(server, 'UserPromptSubmit') probe.mockResolvedValue('exited') server.applyTranscriptUpdate() await Promise.resolve() @@ -207,8 +207,10 @@ describe('host-owned hook presence', () => { ).toBe('late transcript result') expect(probe).not.toHaveBeenCalled() expect(visible(server)).toBe(true) - await hook(server, 'Stop') - expect(probe).toHaveBeenCalledOnce() - expect(visible(server)).toBe(false) + await hook(server, 'SessionStart', 'relaunch', undefined, 4002) + await vi.waitFor(() => expect(probe).toHaveBeenCalledOnce()) + await vi.waitFor(() => expect(visible(server)).toBe(false)) + await hook(server, 'UserPromptSubmit', 'relaunch', undefined, 4002) + expect(state(server)).toBe('working') }) }) diff --git a/src/main/agent-hooks/server/server-agent-presence.ts b/src/main/agent-hooks/server/server-agent-presence.ts index eee2cf3be8dd..c9507cf61221 100644 --- a/src/main/agent-hooks/server/server-agent-presence.ts +++ b/src/main/agent-hooks/server/server-agent-presence.ts @@ -1,5 +1,8 @@ import type { AgentHookEventPayload } from '../../../shared/agent-hook-listener/listener-event' -import type { AgentProcessVerdict } from '../../../shared/agent-process-presence' +import { + isSameAgentProcess, + type AgentProcessVerdict +} from '../../../shared/agent-process-presence' import { probeAgentProcessPresence } from '../../../shared/agent-process-presence-probe' import { AgentHookServerLifecycle } from './server-lifecycle' @@ -9,15 +12,22 @@ export abstract class AgentHookServerAgentPresence extends AgentHookServerLifecy Promise >() + /** A live hook proves its own process alive; only another process's hook casts doubt on the owner. */ + checkAgentPresenceAfterHook(event: AgentHookEventPayload, row: AgentHookEventPayload): void { + const sender = event.agentPresence?.process + const owner = row.agentPresence + if (sender && owner && !owner.ended && !isSameAgentProcess(sender, owner.process)) { + void this.checkAgentPresence(row.paneKey) + } + } + checkAgentPresence(paneKey: string): Promise { const resolved = this.resolvePaneKeyAlias(paneKey) const row = this.state.lastStatusByPaneKey.get(resolved) - if (!row?.agentPresence) { + // Why: an ended owner already published its exit; whatever runs in the pane now is not it. + if (!row?.agentPresence || row.agentPresence.ended) { return Promise.resolve(null) } - if (row.agentPresence.ended) { - return Promise.resolve('exited') - } if (row.connectionId !== null) { return Promise.resolve('unverifiable') } diff --git a/src/main/agent-hooks/server/server-ingest-remote.ts b/src/main/agent-hooks/server/server-ingest-remote.ts index fba61044a3e0..28eac6e56dc1 100644 --- a/src/main/agent-hooks/server/server-ingest-remote.ts +++ b/src/main/agent-hooks/server/server-ingest-remote.ts @@ -37,7 +37,6 @@ export abstract class AgentHookServerIngestRemote extends AgentHookServerIngestS hasExplicitPrompt?: boolean promptInteractionKey?: string agentPresence?: unknown - hookSessionEndReason?: unknown hookEventName?: string source?: unknown providerPromptId?: unknown @@ -261,10 +260,6 @@ export abstract class AgentHookServerIngestRemote extends AgentHookServerIngestS const event: AgentHookEventPayload & { authorityRestartId?: string } = { paneKey, agentPresence: readAgentProcessPresence(envelope.agentPresence), - hookSessionEndReason: - typeof envelope.hookSessionEndReason === 'string' - ? envelope.hookSessionEndReason - : undefined, source: effectiveSource, ...(restartedAuthority?.authorityRestartId ? { authorityRestartId: restartedAuthority.authorityRestartId } diff --git a/src/main/agent-hooks/server/server-lifecycle.ts b/src/main/agent-hooks/server/server-lifecycle.ts index 6201caa59c2d..cc1d56a744a8 100644 --- a/src/main/agent-hooks/server/server-lifecycle.ts +++ b/src/main/agent-hooks/server/server-lifecycle.ts @@ -131,7 +131,7 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv this.recordCurrentAuthorityObservation(event) const enriched = this.applyNormalizedStatus(event, normalized.onAccepted) if (enriched) { - void this.checkAgentPresence(enriched.paneKey) + this.checkAgentPresenceAfterHook(event, enriched) this.scheduleAssistantMessageRetry(source, aliasedBody, enriched) this.scheduleTranscriptPoll(source, aliasedBody, enriched) } diff --git a/src/main/agent-hooks/server/server-state.ts b/src/main/agent-hooks/server/server-state.ts index 5d74847dc8e4..49bb8ade3ac9 100644 --- a/src/main/agent-hooks/server/server-state.ts +++ b/src/main/agent-hooks/server/server-state.ts @@ -260,6 +260,10 @@ export abstract class AgentHookServerState { ): EnrichedAgentHookEventPayload | null protected abstract hasLiveClaimsForPaneKey(paneKey: string): boolean abstract checkAgentPresence(paneKey: string): Promise + abstract checkAgentPresenceAfterHook( + event: AgentHookEventPayload, + row: AgentHookEventPayload + ): void abstract reconcileEndedProcessForPaneKeys( paneKeys: Iterable, diff --git a/src/relay/agent-hook-envelope-build.ts b/src/relay/agent-hook-envelope-build.ts index 25af43e2d3b6..ba2779c684a1 100644 --- a/src/relay/agent-hook-envelope-build.ts +++ b/src/relay/agent-hook-envelope-build.ts @@ -17,7 +17,6 @@ export function buildRelayHookEnvelope( return { source, agentPresence: event.agentPresence, - hookSessionEndReason: event.hookSessionEndReason, paneKey: event.paneKey, ...(event.launchToken ? { launchToken: event.launchToken } : {}), tabId: event.tabId, diff --git a/src/relay/agent-hook-server.ts b/src/relay/agent-hook-server.ts index bb9b221f1487..ec632a964d4e 100644 --- a/src/relay/agent-hook-server.ts +++ b/src/relay/agent-hook-server.ts @@ -1,6 +1,7 @@ import { handleRelayHookRequest } from './agent-hook-request' import { transitionHookPresence } from '../shared/agent-hook-presence-transition' import { RelayAgentPresence } from './relay-agent-presence' +import { isSameAgentProcess } from '../shared/agent-process-presence' import { createServer, type IncomingMessage, type ServerResponse } from 'node:http' import { randomUUID } from 'node:crypto' import { join } from 'node:path' @@ -313,7 +314,16 @@ export class RelayAgentHookServer { this.lastEnvelopeMetaByPaneKey.delete(event.paneKey) this.lastEnvelopeMetaByPaneKey.set(event.paneKey, { source, env, version }) this.forward(buildRelayHookEnvelope(event, source, env, version, options)) - if (options.checkPresence !== false && event.agentPresence && !event.agentPresence.ended) { + const sender = incoming.agentPresence?.process + const owner = event.agentPresence + // Why: a live hook proves its own process alive; only another process's hook casts doubt on the owner. + if ( + options.checkPresence !== false && + sender && + owner && + !owner.ended && + !isSameAgentProcess(sender, owner.process) + ) { void this.checkAgentPresence(event.paneKey) } } diff --git a/src/relay/relay-agent-presence.test.ts b/src/relay/relay-agent-presence.test.ts index 4efb795e4d9e..b87617f139e0 100644 --- a/src/relay/relay-agent-presence.test.ts +++ b/src/relay/relay-agent-presence.test.ts @@ -44,7 +44,7 @@ describe('relay process presence', () => { try { await server.start() const { port, token } = server.getCoordinates() - const post = async (event: string, session: string, reason?: string) => { + const post = async (event: string, session: string, reason?: string, pid = 4001) => { const response = await fetch(`http://127.0.0.1:${port}/hook/claude`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Orca-Agent-Hook-Token': token }, @@ -52,15 +52,19 @@ describe('relay process presence', () => { paneKey, tabId: 'tab-1', worktreeId: 'wt-1', + agentProcess: JSON.stringify({ + pid, + platform: process.platform, + startTime: `birth-${pid}` + }), payload: { hook_event_name: event, session_id: session, source: 'startup', reason } }) }) expect(response.status).toBe(204) } await post('SessionStart', 'a') - expect(forward.mock.lastCall?.[0].agentPresence?.sessionId).toBe('a') - expect(probe).toHaveBeenCalledOnce() - probe.mockClear() + expect(forward.mock.lastCall?.[0].agentPresence?.process.pid).toBe(4001) + expect(probe).not.toHaveBeenCalled() const retryHost = retryHosts[0] const original = retryHost.state.lastStatusByPaneKey.get(paneKey) if (!original) { @@ -77,10 +81,9 @@ describe('relay process presence', () => { await server.checkAgentPresence(paneKey) expect(forward.mock.lastCall?.[0].agentPresence?.ended).toBeUndefined() await post('SessionEnd', 'a', 'resume') - expect(forward.mock.lastCall?.[0].agentPresence?.sessionSwitch).toBe(true) await post('SessionStart', 'b') - await post('SessionEnd', 'a', 'prompt_input_exit') - expect(forward.mock.lastCall?.[0].agentPresence?.sessionId).toBe('b') + await post('SessionEnd', 'nested', 'other', 4002) + expect(forward.mock.lastCall?.[0].agentPresence?.ended).toBeUndefined() probe.mockResolvedValue('exited') await server.checkAgentPresence(paneKey) expect(forward.mock.lastCall?.[0].agentPresence?.ended).toBe(true) diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index b39a3270fdb2..ea8866ac3f41 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -137,17 +137,19 @@ export function normalizeHookPayload( } } - const rootPresenceEvent = - (source === 'claude' || source === 'codex') && + // Why: presence needs the agent's own process; without it the hook cannot speak for liveness. + const agentProcess = + source === 'claude' ? readAgentProcessIdentity(record.agentProcess) : undefined + const agentPresence = agentProcess ? { process: agentProcess } : undefined + const sessionEndReason = readString(hookPayloadRecord, 'reason') + if ( + eventName === 'SessionEnd' && + agentPresence && !readString(hookPayloadRecord, 'agent_id') && - providerSession - const agentPresence = rootPresenceEvent - ? { - sessionId: providerSession.id, - process: source === 'claude' ? readAgentProcessIdentity(record.agentProcess) : undefined - } - : undefined - if (source === 'claude' && eventName === 'SessionEnd' && agentPresence) { + // Why: /clear and /resume switch sessions inside the same running process. + sessionEndReason !== 'clear' && + sessionEndReason !== 'resume' + ) { const payload = previousStatus?.payload ?? normalizeAgentStatusPayload({ state: 'done', prompt: '', agentType: source }) @@ -163,7 +165,6 @@ export function normalizeHookPayload( connectionId: null, providerSession: providerSession ?? undefined, hookEventName: 'SessionEnd', - hookSessionEndReason: readString(hookPayloadRecord, 'reason'), agentPresence, payload } diff --git a/src/shared/agent-hook-listener/listener-event.ts b/src/shared/agent-hook-listener/listener-event.ts index f251176481aa..a954313cf158 100644 --- a/src/shared/agent-hook-listener/listener-event.ts +++ b/src/shared/agent-hook-listener/listener-event.ts @@ -6,7 +6,6 @@ import type { AgentProviderSessionMetadata } from '../agent-session-resume' export type AgentHookEventPayload = { paneKey: string agentPresence?: AgentProcessPresence - hookSessionEndReason?: string /** Authenticated hook route that produced this event. */ source?: AgentHookSource /** Ephemeral Orca launch identity stamped into the PTY env for this process. */ diff --git a/src/shared/agent-hook-presence-transition.ts b/src/shared/agent-hook-presence-transition.ts index 0280ce528f00..40432444beed 100644 --- a/src/shared/agent-hook-presence-transition.ts +++ b/src/shared/agent-hook-presence-transition.ts @@ -1,16 +1,12 @@ import type { AgentHookEventPayload } from './agent-hook-listener/listener-event' -import type { AgentProcessIdentity } from './agent-process-presence' +import { isSameAgentProcess } from './agent-process-presence' function claimsAgent(event: AgentHookEventPayload): boolean { const agentType = event.payload.agentType return agentType !== undefined && agentType !== 'unknown' } -function isOtherProcess(a: AgentProcessIdentity, b: AgentProcessIdentity): boolean { - return a.pid !== b.pid || a.platform !== b.platform || a.startTime !== b.startTime -} - -/** Apply lifecycle evidence only to the session that supplied it. */ +/** The pane's presence belongs to one agent process; only that process's evidence changes it. */ export function transitionHookPresence( incoming: AgentHookEventPayload, previous: AgentHookEventPayload | undefined @@ -23,53 +19,26 @@ export function transitionHookPresence( ) { return incoming } + const owner = previous?.agentPresence const next = incoming.agentPresence - const prior = previous?.agentPresence - if (!next && prior?.ended) { - return undefined - } if (!next) { - return prior ? { ...incoming, agentPresence: prior } : incoming - } - const retired = prior?.ended || prior?.sessionSwitch - if (incoming.hookEventName === 'SessionEnd') { - if (!prior || prior.sessionId !== next.sessionId || retired) { - return undefined - } - const switching = - incoming.hookSessionEndReason === 'clear' || incoming.hookSessionEndReason === 'resume' - return { - ...incoming, - payload: previous.payload, - providerSession: switching ? undefined : incoming.providerSession, - agentPresence: { ...prior, ...(switching ? { sessionSwitch: true } : { ended: true }) } - } - } - if (!prior) { - return incoming + // Why: evidence without an identity can neither extend nor revive the owner's presence. + return { ...incoming, agentPresence: owner && !owner.ended ? owner : undefined } } - if (retired) { - // Why: a late event from the retired session must not revive it; any other session replaces it. - if (prior.sessionId === next.sessionId && incoming.hookEventName !== 'SessionStart') { + const fromOwner = owner !== undefined && isSameAgentProcess(owner.process, next.process) + // Why: SessionEnd, or an exit the execution host already proved (relay-forwarded). + if (incoming.hookEventName === 'SessionEnd' || next.ended) { + if (!owner || !fromOwner || owner.ended) { return undefined } - const process = next.process ?? (prior.sessionSwitch ? prior.process : undefined) - return { ...incoming, agentPresence: { ...next, process } } + return { ...incoming, payload: previous.payload, agentPresence: { ...owner, ended: true } } } - if ( - prior.sessionId !== next.sessionId && - prior.process && - next.process && - isOtherProcess(prior.process, next.process) - ) { - // Why: nested agents inherit ORCA_PANE_KEY; their sessions must not own the pane's presence. - return { ...incoming, agentPresence: prior } + if (fromOwner) { + return owner.ended ? undefined : { ...incoming, agentPresence: owner } } - return { - ...incoming, - agentPresence: { - ...next, - process: next.process ?? (prior.sessionId === next.sessionId ? prior.process : undefined) - } + if (owner && !owner.ended) { + // Why: nested agents inherit ORCA_PANE_KEY; another process's hooks update status, not ownership. + return { ...incoming, agentPresence: owner } } + return incoming } diff --git a/src/shared/agent-hook-relay.ts b/src/shared/agent-hook-relay.ts index 9a8791bd23ba..4a29fca868bf 100644 --- a/src/shared/agent-hook-relay.ts +++ b/src/shared/agent-hook-relay.ts @@ -79,7 +79,6 @@ export type AgentHookRelayEnvelope = { source: AgentHookSource paneKey: string agentPresence?: AgentProcessPresence - hookSessionEndReason?: string /** Ephemeral Orca launch identity stamped into the PTY env for this process. */ launchToken?: string tabId?: string diff --git a/src/shared/agent-process-presence.ts b/src/shared/agent-process-presence.ts index 46c50e9abd7d..38908827c819 100644 --- a/src/shared/agent-process-presence.ts +++ b/src/shared/agent-process-presence.ts @@ -4,12 +4,10 @@ export type AgentProcessIdentity = { startTime: string } -/** Hook presence is independent of whether the last turn finished. */ +/** The pane's agent process, owned from its first identified hook until it ends. */ export type AgentProcessPresence = { - sessionId: string - process?: AgentProcessIdentity + process: AgentProcessIdentity ended?: true - sessionSwitch?: true } export type AgentProcessVerdict = 'live' | 'unverifiable' | 'exited' @@ -45,22 +43,19 @@ export function readAgentProcessIdentity(value: unknown): AgentProcessIdentity | } export function readAgentProcessPresence(value: unknown): AgentProcessPresence | undefined { - if ( - !value || - typeof value !== 'object' || - !('sessionId' in value) || - typeof value.sessionId !== 'string' || - !value.sessionId || - value.sessionId.length > 512 - ) { + if (!value || typeof value !== 'object' || !('process' in value)) { + return undefined + } + const process = readAgentProcessIdentity(value.process) + if (!process) { return undefined } return { - sessionId: value.sessionId, - process: 'process' in value ? readAgentProcessIdentity(value.process) : undefined, - ...('ended' in value && value.ended === true ? { ended: true as const } : {}), - ...('sessionSwitch' in value && value.sessionSwitch === true - ? { sessionSwitch: true as const } - : {}) + process, + ...('ended' in value && value.ended === true ? { ended: true as const } : {}) } } + +export function isSameAgentProcess(a: AgentProcessIdentity, b: AgentProcessIdentity): boolean { + return a.pid === b.pid && a.platform === b.platform && a.startTime === b.startTime +} From 75f921135fe58dad18ba8e0dd6647da7e196e7f8 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 17:49:40 -0700 Subject: [PATCH 05/11] fix(agent-status): close round-3 review gaps in presence handling - Relay retries and transcript polls schedule against the row the relay cached, and identity-less events pass through the transition unchanged, so SSH Grok replies and Codex transcript polls deliver again. - A live process check restores the runtime's agent status and releases queued orchestration mail, like a foreground read that finds the agent. - An answered foreground read naming a non-agent (wsl.exe, tmux) is still an exit; only silence is not. - A suspended (Ctrl-Z) agent is unverifiable, not live, so the foreground read decides as before. - An agent of another type started mid-turn cannot own or end the pane. - Replayed spool hooks check each pane once, and SessionEnd ends presence only for reasons that end the process. --- .../agent-hooks/server-agent-presence.test.ts | 70 +++++++++++++++++ .../server/server-ingest-normalization.ts | 5 +- .../agent-hooks/server/server-lifecycle.ts | 10 ++- src/main/agent-hooks/spool.test.ts | 4 +- ...ntime-serialize-agent-prompt-submission.ts | 61 ++++++++------- .../terminal-side-effect-facts.spec.ts | 20 +++++ ...estration-live-agent-title-restore.test.ts | 75 +++++++++++++++++++ src/relay/agent-hook-request.ts | 10 ++- src/relay/agent-hook-server.ts | 10 ++- src/shared/agent-hook-listener.ts | 14 +++- src/shared/agent-hook-presence-transition.ts | 14 +++- .../agent-process-presence-platform.test.ts | 7 +- .../agent-process-presence-probe.test.ts | 1 + src/shared/agent-process-presence-probe.ts | 22 +++++- 14 files changed, 272 insertions(+), 51 deletions(-) create mode 100644 src/main/runtime/orchestration-live-agent-title-restore.test.ts diff --git a/src/main/agent-hooks/server-agent-presence.test.ts b/src/main/agent-hooks/server-agent-presence.test.ts index 54229ef2168c..b347e6220add 100644 --- a/src/main/agent-hooks/server-agent-presence.test.ts +++ b/src/main/agent-hooks/server-agent-presence.test.ts @@ -1,3 +1,6 @@ +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { AgentHookServer } from './server' import { buildBody, PANE, postHookEvent } from './server.test-fixtures' @@ -136,6 +139,73 @@ describe('host-owned hook presence', () => { expect(await server.checkAgentPresence(PANE)).toBeNull() }) + it('does not let a Claude started inside a working Codex turn end the pane', async () => { + const server = await createServer() + const base = { paneKey: PANE, tabId: 'tab-1', worktreeId: 'wt-1' } + server.ingestRemote( + { + ...base, + source: 'codex', + hookEventName: 'UserPromptSubmit', + payload: { state: 'working', prompt: 'codex task', agentType: 'codex' } + }, + 'ssh-1' + ) + // The relay has no identity resolution, so it can hand the nested Claude ownership. + server.ingestRemote( + { + ...base, + source: 'claude', + hookEventName: 'SessionEnd', + providerSessionOnly: true, + agentPresence: { + process: { pid: 4002, platform: 'linux', startTime: 'boot:1' }, + ended: true + }, + payload: { state: 'done', prompt: '', agentType: 'claude' } + }, + 'ssh-1' + ) + expect(state(server)).toBe('working') + }) + + it('checks each pane once after replaying its spooled hooks', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-presence-spool-')) + const first = new AgentHookServer() + servers.push(first) + await first.start({ env: 'production', userDataPath }) + await hook(first, 'SessionStart') + first.flushStatusPersistSync() + first.stop() + const spoolDir = join(userDataPath, 'agent-hooks', 'spool') + mkdirSync(spoolDir, { recursive: true }) + const agentProcess = JSON.stringify({ + pid: 4001, + platform: process.platform, + startTime: 'birth-4001' + }) + const records = Array.from({ length: 40 }, (_, index) => + JSON.stringify({ + paneKey: PANE, + source: 'claude', + receivedAt: Date.now(), + agentProcess, + payload: { + hook_event_name: index % 2 ? 'PreToolUse' : 'PostToolUse', + session_id: 'session-a', + tool_name: 'Bash', + tool_input: { command: 'ls' } + } + }) + ) + writeFileSync(join(spoolDir, 'pane-spooled.jsonl'), `\n${records.join('\n')}\n`) + probe.mockClear() + const restarted = new AgentHookServer() + servers.push(restarted) + await restarted.start({ env: 'production', userDataPath }) + expect(probe).toHaveBeenCalledOnce() + }) + it('keeps unanswered reads and clears only a positive process exit', async () => { const server = await createServer() await hook(server, 'SessionStart') diff --git a/src/main/agent-hooks/server/server-ingest-normalization.ts b/src/main/agent-hooks/server/server-ingest-normalization.ts index 974937476d69..171a15ac5cc8 100644 --- a/src/main/agent-hooks/server/server-ingest-normalization.ts +++ b/src/main/agent-hooks/server/server-ingest-normalization.ts @@ -82,10 +82,7 @@ export abstract class AgentHookServerIngestNormalization extends AgentHookServer this.observations.rebind(event.paneKey) } this.recordCurrentAuthorityObservation(event) - const enriched = this.applyNormalizedStatus(event, normalized.onAccepted) - if (enriched) { - void this.checkAgentPresence(enriched.paneKey) - } + this.applyNormalizedStatus(event, normalized.onAccepted) if (event.payload.state !== 'done') { this.withdrawReplayObservation(this.resolvePaneKeyAlias(event.paneKey)) } diff --git a/src/main/agent-hooks/server/server-lifecycle.ts b/src/main/agent-hooks/server/server-lifecycle.ts index cc1d56a744a8..e6e1bf80061d 100644 --- a/src/main/agent-hooks/server/server-lifecycle.ts +++ b/src/main/agent-hooks/server/server-lifecycle.ts @@ -45,12 +45,20 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv this.captureHydratedAuthorityCommitments() // Drain before binding the listener so replay cannot race a live hook during startup. if (this.endpointDir) { + const replayedPaneKeys = new Set() drainAgentHookSpool({ endpointDir: this.endpointDir, getPersistedLaunchTokenHash: (paneKey) => this.hydratedLaunchTokenHashByPaneKey.get(this.resolvePaneKeyAlias(paneKey)), - ingest: (record: SpoolRecord) => this.ingestSpoolRecord(record) + ingest: (record: SpoolRecord) => { + this.ingestSpoolRecord(record) + replayedPaneKeys.add(this.resolvePaneKeyAlias(record.paneKey)) + } }) + // Why: the owner may have died while Orca was down; check each replayed pane once. + for (const paneKey of replayedPaneKeys) { + void this.checkAgentPresence(paneKey) + } } this.ownerStateInitialized = true } diff --git a/src/main/agent-hooks/spool.test.ts b/src/main/agent-hooks/spool.test.ts index 3e460fdc3ba6..4f97e830e45a 100644 --- a/src/main/agent-hooks/spool.test.ts +++ b/src/main/agent-hooks/spool.test.ts @@ -32,7 +32,9 @@ describe('agent hook spool', () => { ) expect(spoolLine).toBeDefined() expect(spoolLine!.match(/printf/g)).toHaveLength(1) - expect(spoolLine).toContain('"$spool_now" "$payload"') + expect(spoolLine).toContain( + '"$spool_now" "$(spool_json_escape "${orca_agent_process:-}")" "$payload"' + ) }) it('drops torn lines while retaining complete records', () => { diff --git a/src/main/runtime/orca-runtime-serialize-agent-prompt-submission.ts b/src/main/runtime/orca-runtime-serialize-agent-prompt-submission.ts index 29c658bfe54f..f8503753ba71 100644 --- a/src/main/runtime/orca-runtime-serialize-agent-prompt-submission.ts +++ b/src/main/runtime/orca-runtime-serialize-agent-prompt-submission.ts @@ -6,7 +6,6 @@ import type { RuntimeTerminalAgentStatusSnapshot } from './runtime-terminal-agen import type { RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' import { hasCompatibleAgentTitleIdentity } from '../../shared/agent-title-owner' import type { PtyForegroundProcessRead } from './runtime-terminal-contracts' -import { isShellProcess } from '../../shared/shell-process-detection' import { recognizeAgentProcess } from '../../shared/agent-process-recognition' import type { AgentPromptActivity, @@ -103,12 +102,43 @@ export class OrcaRuntimeWithSerializeAgentPromptSubmission extends OrcaRuntimeWi this.confirmLegacyPtyAgentExit(ptyId, recoverCompletedHook) } else if (verdict === 'exited' && !recoverCompletedHook) { this.recordTerminalSideEffectFact(ptyId, { kind: 'agent-exited' }) + } else if (verdict === 'live') { + this.restoreDisprovedAgentExit(ptyId) } else { this.ptyTitleTrackersByPtyId.get(ptyId)?.tracker.restoreLastAgentExit() } }) } + private restoreDisprovedAgentExit(ptyId: string, confirmedStatus?: 'idle'): void { + const current = this.ptysById.get(ptyId) + const restoredStatus = this.ptyTitleTrackersByPtyId + .get(ptyId) + ?.tracker.restoreLastAgentExit(confirmedStatus) + if (!current || restoredStatus === null || restoredStatus === undefined) { + return + } + current.lastAgentStatus = restoredStatus + if (restoredStatus === 'idle') { + this.resolvePtyTuiIdleWaiters(current, ptyId) + } + for (const leaf of this.getLeavesForPty(ptyId)) { + if (leaf.lastAgentStatus !== null) { + continue + } + // Why: the live agent disproved the neutral title's exit signal; keep runtime delivery state aligned with the restored tracker. + leaf.lastAgentStatus = restoredStatus + if (restoredStatus === 'idle') { + this.resolveTuiIdleWaiters(leaf) + // Why gated like every other delivery edge: a neutral-title restoration can + // reinstate `idle` from a name-only title, which is not evidence a turn ended. + if (this.checkDeliverySettledAndArmRecheck(leaf)) { + this.deliverPendingMessagesForLeaf(leaf) + } + } + } + } + private confirmLegacyPtyAgentExit(ptyId: string, recoverCompletedHook: boolean): void { const pty = this.ptysById.get(ptyId) const handle = this.handleByPtyId.get(ptyId) @@ -160,38 +190,15 @@ export class OrcaRuntimeWithSerializeAgentPromptSubmission extends OrcaRuntimeWi recoverCompletedHook && recognizeAgentProcess(result.process)?.agent === 'codex' ? 'idle' : undefined - const restoredStatus = this.ptyTitleTrackersByPtyId - .get(ptyId) - ?.tracker.restoreLastAgentExit(confirmedStatus) - if (restoredStatus !== null && restoredStatus !== undefined) { - current.lastAgentStatus = restoredStatus - if (restoredStatus === 'idle') { - this.resolvePtyTuiIdleWaiters(current, ptyId) - } - for (const leaf of this.getLeavesForPty(ptyId)) { - if (leaf.lastAgentStatus !== null) { - continue - } - // Why: the foreground agent disproved the neutral title's exit signal; keep runtime delivery state aligned with the restored tracker. - leaf.lastAgentStatus = restoredStatus - if (restoredStatus === 'idle') { - this.resolveTuiIdleWaiters(leaf) - // Why gated like every other delivery edge: a neutral-title restoration can - // reinstate `idle` from a name-only title, which is not evidence a turn ended. - if (this.checkDeliverySettledAndArmRecheck(leaf)) { - this.deliverPendingMessagesForLeaf(leaf) - } - } - } - } + this.restoreDisprovedAgentExit(ptyId, confirmedStatus) return } + // Why: only an answered read naming a non-agent is an exit; silence is never one. if ( !recoverCompletedHook && result.controller === this.ptyController && result.available && - typeof result.process === 'string' && - isShellProcess(result.process) + typeof result.process === 'string' ) { this.recordTerminalSideEffectFact(ptyId, { kind: 'agent-exited' }) } else { diff --git a/src/main/runtime/orca-runtime-tests/terminal-side-effect-facts.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-side-effect-facts.spec.ts index 00fd3d612b56..28dfe38b6888 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-side-effect-facts.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-side-effect-facts.spec.ts @@ -496,6 +496,26 @@ describe('terminal side-effect fact channel', () => { ) }) + it.each(['wsl.exe', 'tmux'])( + 'confirms an exit when the foreground answers with non-agent %s', + async (foreground) => { + const { runtime, batches } = createSideEffectRuntime(async () => null) + syncSinglePty(runtime) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: vi.fn(async () => foreground) + }) + runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;Codex ready\x07') + + runtime.onPtyData('pty-1', '\x1b]0;~/repo\x07', 100) + + await vi.waitFor(() => + expect(batches.flatMap((batch) => batch.facts)).toContainEqual({ kind: 'agent-exited' }) + ) + } + ) + it('treats synchronous foreground read failures as unavailable', async () => { const { runtime, batches } = createSideEffectRuntime() syncSinglePty(runtime) diff --git a/src/main/runtime/orchestration-live-agent-title-restore.test.ts b/src/main/runtime/orchestration-live-agent-title-restore.test.ts new file mode 100644 index 000000000000..a22db7c0d63e --- /dev/null +++ b/src/main/runtime/orchestration-live-agent-title-restore.test.ts @@ -0,0 +1,75 @@ +import { rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AgentStatusIpcPayload } from '../../shared/agent-status-types' +import { settledWriteStub } from '../providers/settled-pty-write-stub' +import { + createBoundRun, + createDatabase, + createRuntime, + insertDirectRunMessage, + PANE_KEY, + PTY_ID, + TERMINAL_HANDLE, + temporaryDirectories +} from './orchestration-mailbox-notification-test-harness' + +vi.mock('electron', () => ({ + app: { getPath: vi.fn(() => tmpdir()), isPackaged: false }, + BrowserWindow: { fromId: vi.fn(() => null) }, + ipcMain: { on: vi.fn(), removeListener: vi.fn() }, + webContents: { fromId: vi.fn(() => null) } +})) + +describe('neutral title while the hook-owned agent process is alive', () => { + afterEach(() => { + vi.useRealTimers() + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } + }) + + it('restores the idle agent and delivers mail without a foreground read', async () => { + vi.useFakeTimers() + const db = createDatabase('orca-live-agent-title-restore-') + const hook: AgentStatusIpcPayload = { + paneKey: PANE_KEY, + terminalHandle: TERMINAL_HANDLE, + agentType: 'claude', + state: 'done', + prompt: '', + connectionId: null, + receivedAt: Date.now(), + stateStartedAt: Date.now() + } + const { runtime } = createRuntime(db, { + getAgentStatusSnapshot: () => [hook], + checkHookAgentPresence: async () => 'live' + }) + const write = vi.fn((_ptyId: string, _data: string) => true) + const getForegroundProcess = vi.fn(async () => 'claude') + runtime.setPtyController({ + write, + writeWithSettlement: settledWriteStub(write), + kill: vi.fn(), + getForegroundProcess + }) + const run = createBoundRun(db, 'Claude Run') + await runtime.listTerminals() + + runtime.onPtyData(PTY_ID, '\x1b]0;✳ Claude Code\x07', 1) + await vi.advanceTimersByTimeAsync(50) + runtime.onPtyData(PTY_ID, '\x1b]0;some-tool-title\x07', 2) + await vi.advanceTimersByTimeAsync(200) + insertDirectRunMessage(db, run.id, 'Worker progress') + runtime.notifyMessageArrived(`run:${run.id}`, 'status') + await vi.advanceTimersByTimeAsync(3000) + + expect(getForegroundProcess).not.toHaveBeenCalled() + expect(write.mock.calls.map(([, data]) => data)).toEqual([ + expect.stringContaining('You have 1 orchestration message'), + '\r' + ]) + db.close() + }) +}) diff --git a/src/relay/agent-hook-request.ts b/src/relay/agent-hook-request.ts index ddef056c382f..e852f66cf69b 100644 --- a/src/relay/agent-hook-request.ts +++ b/src/relay/agent-hook-request.ts @@ -24,7 +24,7 @@ export async function handleRelayHookRequest( source: AgentHookSource, env?: string, version?: string - ) => void + ) => AgentHookEventPayload | undefined retryScheduler: AgentHookResultRetryScheduler transportInterference: ReturnType } @@ -62,9 +62,11 @@ export async function handleRelayHookRequest( // TODO: once normalizeHookPayload returns validated env/version, drop bodyEnv/bodyVersion and source them from the listener result. const env = hookBodyEnv(hookBody) const version = hookBodyVersion(hookBody) - options.applyEvent(event, source, env, version) - options.retryScheduler.scheduleAssistantMessageRetry(source, hookBody, event, env, version) - options.retryScheduler.scheduleTranscriptPoll(source, hookBody, event, env, version) + const stored = options.applyEvent(event, source, env, version) + if (stored) { + options.retryScheduler.scheduleAssistantMessageRetry(source, hookBody, stored, env, version) + options.retryScheduler.scheduleTranscriptPoll(source, hookBody, stored, env, version) + } } res.writeHead(204) res.end() diff --git a/src/relay/agent-hook-server.ts b/src/relay/agent-hook-server.ts index ec632a964d4e..0d43809294eb 100644 --- a/src/relay/agent-hook-server.ts +++ b/src/relay/agent-hook-server.ts @@ -279,13 +279,13 @@ export class RelayAgentHookServer { env?: string, version?: string, options: { isReplay?: boolean; checkPresence?: boolean } = {} - ): void { + ): AgentHookEventPayload | undefined { const transitioned = transitionHookPresence( incoming, this.state.lastStatusByPaneKey.get(incoming.paneKey) ) if (!transitioned) { - return + return undefined } const event = transitioned.agentPresence?.ended ? { ...transitioned, providerSessionOnly: true } @@ -296,7 +296,7 @@ export class RelayAgentHookServer { // transcript the orphan is still writing (#12447). Drop the stale cache with it. if (this.isPaneSurfaceRetired(event.paneKey)) { this.clearPaneState(event.paneKey) - return + return undefined } if (event.payload.state !== 'done' || event.payload.lastAssistantMessage) { this.retryScheduler.clearAssistantMessageRetry(event.paneKey) @@ -309,7 +309,7 @@ export class RelayAgentHookServer { this.clearPaneState(paneKey) ) ) { - return + return undefined } this.lastEnvelopeMetaByPaneKey.delete(event.paneKey) this.lastEnvelopeMetaByPaneKey.set(event.paneKey, { source, env, version }) @@ -326,6 +326,8 @@ export class RelayAgentHookServer { ) { void this.checkAgentPresence(event.paneKey) } + // Why: retries compare against the cached row by identity, so they must hold that exact row. + return this.state.lastStatusByPaneKey.get(event.paneKey) } private ingestSpoolRecord(record: SpoolRecord): void { diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index ea8866ac3f41..aae2ec9d585d 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -25,6 +25,13 @@ import { } from './agent-hook-listener/opencode-session-registry' import { readString } from './agent-hook-listener/tool-input-preview' /** Canonical transport-agnostic normalization entry shared by main and relay listeners. */ +const CLAUDE_EXIT_SESSION_END_REASONS = new Set([ + 'prompt_input_exit', + 'logout', + 'other', + 'bypass_permissions_disabled' +]) + export function normalizeHookPayload( state: HookListenerState, source: AgentHookSource, @@ -146,9 +153,10 @@ export function normalizeHookPayload( eventName === 'SessionEnd' && agentPresence && !readString(hookPayloadRecord, 'agent_id') && - // Why: /clear and /resume switch sessions inside the same running process. - sessionEndReason !== 'clear' && - sessionEndReason !== 'resume' + // Why: only reasons that end the process; /clear and /resume keep it running, and an unknown + // reason is left to the process check rather than guessed. + sessionEndReason !== undefined && + CLAUDE_EXIT_SESSION_END_REASONS.has(sessionEndReason) ) { const payload = previousStatus?.payload ?? diff --git a/src/shared/agent-hook-presence-transition.ts b/src/shared/agent-hook-presence-transition.ts index 40432444beed..dd245b612a90 100644 --- a/src/shared/agent-hook-presence-transition.ts +++ b/src/shared/agent-hook-presence-transition.ts @@ -17,13 +17,23 @@ export function transitionHookPresence( claimsAgent(incoming) && previous.payload.agentType !== incoming.payload.agentType ) { - return incoming + // Why: an agent of another type started mid-turn is nested; it may report status, not own the pane. + if ( + previous.payload.state === 'done' || + previous.providerSessionOnly || + !incoming.agentPresence + ) { + return incoming + } + return incoming.hookEventName === 'SessionEnd' || incoming.agentPresence.ended + ? undefined + : { ...incoming, agentPresence: undefined } } const owner = previous?.agentPresence const next = incoming.agentPresence if (!next) { // Why: evidence without an identity can neither extend nor revive the owner's presence. - return { ...incoming, agentPresence: owner && !owner.ended ? owner : undefined } + return owner && !owner.ended ? { ...incoming, agentPresence: owner } : incoming } const fromOwner = owner !== undefined && isSameAgentProcess(owner.process, next.process) // Why: SessionEnd, or an exit the execution host already proved (relay-forwarded). diff --git a/src/shared/agent-process-presence-platform.test.ts b/src/shared/agent-process-presence-platform.test.ts index cc6295773c7a..ce29f18f094e 100644 --- a/src/shared/agent-process-presence-platform.test.ts +++ b/src/shared/agent-process-presence-platform.test.ts @@ -43,7 +43,12 @@ describe('platform process evidence', () => { expect(await readAgentProcess(4242)).toEqual({ verdict: 'live', startTime: 'boot-id:100', - zombie: false + zombie: false, + stopped: false }) + read + .mockResolvedValueOnce(`4242 (agent) ${['T', ...fields.slice(1)].join(' ')}`) + .mockResolvedValueOnce('boot-id\n') + expect(await readAgentProcess(4242)).toMatchObject({ verdict: 'live', stopped: true }) }) }) diff --git a/src/shared/agent-process-presence-probe.test.ts b/src/shared/agent-process-presence-probe.test.ts index 5a805c468ef1..07e055969a1f 100644 --- a/src/shared/agent-process-presence-probe.test.ts +++ b/src/shared/agent-process-presence-probe.test.ts @@ -12,6 +12,7 @@ describe('single-process presence evidence', () => { [{ verdict: 'live', startTime: 'boot:100', zombie: false }, 'live'], [{ verdict: 'live', startTime: 'boot:101', zombie: false }, 'exited'], [{ verdict: 'live', startTime: 'boot:100', zombie: true }, 'exited'], + [{ verdict: 'live', startTime: 'boot:100', zombie: false, stopped: true }, 'unverifiable'], [{ verdict: 'exited' }, 'exited'], [{ verdict: 'unverifiable' }, 'unverifiable'] ])('judges %j as %s', async (observation, verdict) => { diff --git a/src/shared/agent-process-presence-probe.ts b/src/shared/agent-process-presence-probe.ts index 49f4c37938ae..22fac38b7dbd 100644 --- a/src/shared/agent-process-presence-probe.ts +++ b/src/shared/agent-process-presence-probe.ts @@ -3,7 +3,7 @@ import { runProcess } from './child-process/run-process' import type { AgentProcessIdentity, AgentProcessVerdict } from './agent-process-presence' export type AgentProcessObservation = - | { verdict: 'live'; startTime: string; zombie: boolean } + | { verdict: 'live'; startTime: string; zombie: boolean; stopped?: boolean } | { verdict: 'unverifiable' | 'exited' } function isMissing(error: unknown): boolean { @@ -34,7 +34,12 @@ export async function readAgentProcess(pid: number): Promise Date: Tue, 29 Sep 2026 18:30:23 -0700 Subject: [PATCH 06/11] refactor(agent-status): record the pane's owning agent even without a process id Ownership is now decided only by comparing the recorded owner with the sender, never from the row's agent type or turn state (which identity resolution rewrites). The first agent hook in an empty pane claims it; a live owner keeps it against any other agent (a nested claude -p, a Codex started inside Claude or the reverse); only the owner's own proven process can end it. An owner no hook identified never ends from a hook and is not probed, so those panes behave as before. --- .../agent-hooks/server-agent-presence.test.ts | 29 ++++++++ .../server/server-agent-presence.ts | 6 +- src/relay/agent-hook-server.ts | 2 +- src/relay/relay-agent-presence.ts | 2 +- src/shared/agent-hook-listener.ts | 2 +- src/shared/agent-hook-presence-transition.ts | 72 ++++++++----------- src/shared/agent-process-presence.ts | 25 ++++--- 7 files changed, 83 insertions(+), 55 deletions(-) diff --git a/src/main/agent-hooks/server-agent-presence.test.ts b/src/main/agent-hooks/server-agent-presence.test.ts index b347e6220add..aaacd841e5d2 100644 --- a/src/main/agent-hooks/server-agent-presence.test.ts +++ b/src/main/agent-hooks/server-agent-presence.test.ts @@ -134,6 +134,7 @@ describe('host-owned hook presence', () => { await hook(server, 'UserPromptSubmit', 'outer', undefined, null) await hook(server, 'SessionStart', 'nested', undefined, 4002) await hook(server, 'SessionEnd', 'nested', 'other', 4002) + expect(visible(server)).toBe(true) await hook(server, 'PostToolUse', 'outer', undefined, null) expect(state(server)).toBe('working') expect(await server.checkAgentPresence(PANE)).toBeNull() @@ -159,6 +160,7 @@ describe('host-owned hook presence', () => { hookEventName: 'SessionEnd', providerSessionOnly: true, agentPresence: { + agent: 'claude', process: { pid: 4002, platform: 'linux', startTime: 'boot:1' }, ended: true }, @@ -169,6 +171,32 @@ describe('host-owned hook presence', () => { expect(state(server)).toBe('working') }) + it.each([ + ['working', []], + ['idle', ['Stop']] + ])('keeps a %s Codex pane when a Claude run inside it ends', async (_label, codexTail) => { + const server = await createServer() + const codex = async (event: string) => { + const response = await postHookEvent( + server, + buildBody({ hook_event_name: event, session_id: 'codex-a', prompt: 'task' }), + '/hook/codex' + ) + expect(response.status).toBe(204) + } + await codex('UserPromptSubmit') + for (const event of codexTail) { + await codex(event) + } + const before = state(server) + await hook(server, 'SessionStart', 'nested', undefined, 4002) + await hook(server, 'UserPromptSubmit', 'nested', undefined, 4002) + await hook(server, 'SessionEnd', 'nested', 'other', 4002) + expect(visible(server)).toBe(true) + expect(before).not.toBeNull() + expect(await server.checkAgentPresence(PANE)).toBeNull() + }) + it('checks each pane once after replaying its spooled hooks', async () => { const userDataPath = mkdtempSync(join(tmpdir(), 'orca-presence-spool-')) const first = new AgentHookServer() @@ -245,6 +273,7 @@ describe('host-owned hook presence', () => { hookEventName: 'SessionStart', providerSession: { provider: 'claude', id: 'remote-session' }, agentPresence: { + agent: 'claude', process: { pid: process.pid, platform: process.platform, startTime: 'remote-birth' } }, payload: { state: 'working', prompt: 'remote task', agentType: 'claude' } diff --git a/src/main/agent-hooks/server/server-agent-presence.ts b/src/main/agent-hooks/server/server-agent-presence.ts index c9507cf61221..a955e8fcfef3 100644 --- a/src/main/agent-hooks/server/server-agent-presence.ts +++ b/src/main/agent-hooks/server/server-agent-presence.ts @@ -16,7 +16,7 @@ export abstract class AgentHookServerAgentPresence extends AgentHookServerLifecy checkAgentPresenceAfterHook(event: AgentHookEventPayload, row: AgentHookEventPayload): void { const sender = event.agentPresence?.process const owner = row.agentPresence - if (sender && owner && !owner.ended && !isSameAgentProcess(sender, owner.process)) { + if (sender && owner?.process && !owner.ended && !isSameAgentProcess(sender, owner.process)) { void this.checkAgentPresence(row.paneKey) } } @@ -24,8 +24,8 @@ export abstract class AgentHookServerAgentPresence extends AgentHookServerLifecy checkAgentPresence(paneKey: string): Promise { const resolved = this.resolvePaneKeyAlias(paneKey) const row = this.state.lastStatusByPaneKey.get(resolved) - // Why: an ended owner already published its exit; whatever runs in the pane now is not it. - if (!row?.agentPresence || row.agentPresence.ended) { + // Why: an ended owner already published its exit, and an owner no hook identified cannot be checked. + if (!row?.agentPresence?.process || row.agentPresence.ended) { return Promise.resolve(null) } if (row.connectionId !== null) { diff --git a/src/relay/agent-hook-server.ts b/src/relay/agent-hook-server.ts index 0d43809294eb..126d0470abb0 100644 --- a/src/relay/agent-hook-server.ts +++ b/src/relay/agent-hook-server.ts @@ -320,7 +320,7 @@ export class RelayAgentHookServer { if ( options.checkPresence !== false && sender && - owner && + owner?.process && !owner.ended && !isSameAgentProcess(sender, owner.process) ) { diff --git a/src/relay/relay-agent-presence.ts b/src/relay/relay-agent-presence.ts index 102c92d9636b..f32278a1309d 100644 --- a/src/relay/relay-agent-presence.ts +++ b/src/relay/relay-agent-presence.ts @@ -9,7 +9,7 @@ export class RelayAgentPresence { current: () => AgentHookEventPayload | undefined, publish: (event: AgentHookEventPayload) => void ): Promise { - if (!row?.agentPresence || row.agentPresence.ended) { + if (!row?.agentPresence?.process || row.agentPresence.ended) { return Promise.resolve() } const existing = this.pending.get(row) diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index aae2ec9d585d..de8978d8990b 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -147,7 +147,7 @@ export function normalizeHookPayload( // Why: presence needs the agent's own process; without it the hook cannot speak for liveness. const agentProcess = source === 'claude' ? readAgentProcessIdentity(record.agentProcess) : undefined - const agentPresence = agentProcess ? { process: agentProcess } : undefined + const agentPresence = agentProcess ? { agent: source, process: agentProcess } : undefined const sessionEndReason = readString(hookPayloadRecord, 'reason') if ( eventName === 'SessionEnd' && diff --git a/src/shared/agent-hook-presence-transition.ts b/src/shared/agent-hook-presence-transition.ts index dd245b612a90..f48dca64f0f7 100644 --- a/src/shared/agent-hook-presence-transition.ts +++ b/src/shared/agent-hook-presence-transition.ts @@ -1,54 +1,44 @@ import type { AgentHookEventPayload } from './agent-hook-listener/listener-event' import { isSameAgentProcess } from './agent-process-presence' -function claimsAgent(event: AgentHookEventPayload): boolean { - const agentType = event.payload.agentType - return agentType !== undefined && agentType !== 'unknown' -} - -/** The pane's presence belongs to one agent process; only that process's evidence changes it. */ +/** A pane has one owning agent; only the owner's own proven process can end it. */ export function transitionHookPresence( incoming: AgentHookEventPayload, previous: AgentHookEventPayload | undefined ): AgentHookEventPayload | undefined { - if ( - previous && - claimsAgent(previous) && - claimsAgent(incoming) && - previous.payload.agentType !== incoming.payload.agentType - ) { - // Why: an agent of another type started mid-turn is nested; it may report status, not own the pane. - if ( - previous.payload.state === 'done' || - previous.providerSessionOnly || - !incoming.agentPresence - ) { - return incoming - } - return incoming.hookEventName === 'SessionEnd' || incoming.agentPresence.ended - ? undefined - : { ...incoming, agentPresence: undefined } - } - const owner = previous?.agentPresence - const next = incoming.agentPresence - if (!next) { - // Why: evidence without an identity can neither extend nor revive the owner's presence. - return owner && !owner.ended ? { ...incoming, agentPresence: owner } : incoming - } - const fromOwner = owner !== undefined && isSameAgentProcess(owner.process, next.process) + const recorded = previous?.agentPresence + const owner = recorded && !recorded.ended && !previous?.providerSessionOnly ? recorded : undefined + const sender = incoming.agentPresence?.process // Why: SessionEnd, or an exit the execution host already proved (relay-forwarded). - if (incoming.hookEventName === 'SessionEnd' || next.ended) { - if (!owner || !fromOwner || owner.ended) { - return undefined + const exit = incoming.hookEventName === 'SessionEnd' || incoming.agentPresence?.ended === true + if (owner) { + if (exit) { + const fromOwner = owner.process && sender && isSameAgentProcess(owner.process, sender) + return fromOwner + ? { + ...incoming, + payload: previous?.payload ?? incoming.payload, + agentPresence: { ...owner, ended: true } + } + : undefined } - return { ...incoming, payload: previous.payload, agentPresence: { ...owner, ended: true } } + // Why: nested agents inherit ORCA_PANE_KEY; their hooks update status, never ownership. + return incoming.agentPresence === owner ? incoming : { ...incoming, agentPresence: owner } + } + if (exit) { + return undefined } - if (fromOwner) { - return owner.ended ? undefined : { ...incoming, agentPresence: owner } + if ( + recorded?.ended && + recorded.process && + sender && + isSameAgentProcess(recorded.process, sender) + ) { + return undefined } - if (owner && !owner.ended) { - // Why: nested agents inherit ORCA_PANE_KEY; another process's hooks update status, not ownership. - return { ...incoming, agentPresence: owner } + const agent = incoming.payload.agentType + if (!agent || agent === 'unknown') { + return incoming.agentPresence ? { ...incoming, agentPresence: undefined } : incoming } - return incoming + return { ...incoming, agentPresence: { agent, ...(sender ? { process: sender } : {}) } } } diff --git a/src/shared/agent-process-presence.ts b/src/shared/agent-process-presence.ts index 38908827c819..cbde61981bfd 100644 --- a/src/shared/agent-process-presence.ts +++ b/src/shared/agent-process-presence.ts @@ -1,12 +1,16 @@ +import { AGENT_TYPE_MAX_LENGTH } from './agent-status-field-normalization' +import type { AgentType } from './agent-status-types' + export type AgentProcessIdentity = { pid: number platform: 'darwin' | 'linux' | 'win32' startTime: string } -/** The pane's agent process, owned from its first identified hook until it ends. */ +/** The agent that owns a pane, from its first hook until it ends; the process when a hook proved it. */ export type AgentProcessPresence = { - process: AgentProcessIdentity + agent: AgentType + process?: AgentProcessIdentity ended?: true } @@ -43,15 +47,20 @@ export function readAgentProcessIdentity(value: unknown): AgentProcessIdentity | } export function readAgentProcessPresence(value: unknown): AgentProcessPresence | undefined { - if (!value || typeof value !== 'object' || !('process' in value)) { - return undefined - } - const process = readAgentProcessIdentity(value.process) - if (!process) { + if ( + !value || + typeof value !== 'object' || + !('agent' in value) || + typeof value.agent !== 'string' || + !value.agent || + value.agent.length > AGENT_TYPE_MAX_LENGTH + ) { return undefined } + const process = 'process' in value ? readAgentProcessIdentity(value.process) : undefined return { - process, + agent: value.agent, + ...(process ? { process } : {}), ...('ended' in value && value.ended === true ? { ended: true as const } : {}) } } From 089e0852bb9d9306326d24381d25046abe7f9f89 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 18:31:05 -0700 Subject: [PATCH 07/11] test(agent-status): read the optional process id in the relay presence test --- src/relay/relay-agent-presence.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/relay/relay-agent-presence.test.ts b/src/relay/relay-agent-presence.test.ts index b87617f139e0..5920d46871f8 100644 --- a/src/relay/relay-agent-presence.test.ts +++ b/src/relay/relay-agent-presence.test.ts @@ -63,7 +63,7 @@ describe('relay process presence', () => { expect(response.status).toBe(204) } await post('SessionStart', 'a') - expect(forward.mock.lastCall?.[0].agentPresence?.process.pid).toBe(4001) + expect(forward.mock.lastCall?.[0].agentPresence?.process?.pid).toBe(4001) expect(probe).not.toHaveBeenCalled() const retryHost = retryHosts[0] const original = retryHost.state.lastStatusByPaneKey.get(paneKey) From 39b2660d4cb86215dd99545427282feefa421ca1 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 18:50:48 -0700 Subject: [PATCH 08/11] fix(agent-status): other agents' SessionEnd hooks settle their status again Only an admitted exit (Claude's process-ending SessionEnd, or a host-proved exit) is marked ended on the event, and ownership keys on that marker, not the hook name. Devin, Qoder, CodeBuddy and Copilot SessionEnd hooks are ordinary status updates again, locally and through the relay. --- .../agent-hooks/server-agent-presence.test.ts | 19 +++++++++++++++++++ src/shared/agent-hook-listener.ts | 2 +- src/shared/agent-hook-presence-transition.ts | 7 ++++--- 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/src/main/agent-hooks/server-agent-presence.test.ts b/src/main/agent-hooks/server-agent-presence.test.ts index aaacd841e5d2..d050e6b71189 100644 --- a/src/main/agent-hooks/server-agent-presence.test.ts +++ b/src/main/agent-hooks/server-agent-presence.test.ts @@ -197,6 +197,25 @@ describe('host-owned hook presence', () => { expect(await server.checkAgentPresence(PANE)).toBeNull() }) + it.each(['devin', 'qoder', 'codebuddy', 'copilot'])( + 'settles %s to done on its own SessionEnd hook', + async (agent) => { + const server = await createServer() + const post = async (payload: Record) => { + const response = await postHookEvent( + server, + buildBody({ session_id: `${agent}-a`, ...payload }), + `/hook/${agent}` + ) + expect(response.status).toBe(204) + } + await post({ hook_event_name: 'UserPromptSubmit', prompt: 'do the task' }) + expect(state(server)).toBe('working') + await post({ hook_event_name: 'SessionEnd', reason: 'prompt_input_exit' }) + expect(state(server)).toBe('done') + } + ) + it('checks each pane once after replaying its spooled hooks', async () => { const userDataPath = mkdtempSync(join(tmpdir(), 'orca-presence-spool-')) const first = new AgentHookServer() diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index de8978d8990b..41e43ad36275 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -173,7 +173,7 @@ export function normalizeHookPayload( connectionId: null, providerSession: providerSession ?? undefined, hookEventName: 'SessionEnd', - agentPresence, + agentPresence: { ...agentPresence, ended: true as const }, payload } } diff --git a/src/shared/agent-hook-presence-transition.ts b/src/shared/agent-hook-presence-transition.ts index f48dca64f0f7..241ec382efaf 100644 --- a/src/shared/agent-hook-presence-transition.ts +++ b/src/shared/agent-hook-presence-transition.ts @@ -9,8 +9,9 @@ export function transitionHookPresence( const recorded = previous?.agentPresence const owner = recorded && !recorded.ended && !previous?.providerSessionOnly ? recorded : undefined const sender = incoming.agentPresence?.process - // Why: SessionEnd, or an exit the execution host already proved (relay-forwarded). - const exit = incoming.hookEventName === 'SessionEnd' || incoming.agentPresence?.ended === true + // Why: only an admitted exit is marked ended (Claude's process-ending SessionEnd, or a host-proved + // exit); other agents' SessionEnd hooks are ordinary status updates. + const exit = incoming.agentPresence?.ended === true if (owner) { if (exit) { const fromOwner = owner.process && sender && isSameAgentProcess(owner.process, sender) @@ -36,7 +37,7 @@ export function transitionHookPresence( ) { return undefined } - const agent = incoming.payload.agentType + const agent = incoming.agentPresence?.agent ?? incoming.payload.agentType if (!agent || agent === 'unknown') { return incoming.agentPresence ? { ...incoming, agentPresence: undefined } : incoming } From 603af1c5aad7f14f086746c97010ae8957122acd Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 18:59:33 -0700 Subject: [PATCH 09/11] test(agent-status): cover the owner's own SessionEnd through the relay --- src/relay/relay-agent-presence.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/relay/relay-agent-presence.test.ts b/src/relay/relay-agent-presence.test.ts index 5920d46871f8..87fac06e8e82 100644 --- a/src/relay/relay-agent-presence.test.ts +++ b/src/relay/relay-agent-presence.test.ts @@ -84,6 +84,11 @@ describe('relay process presence', () => { await post('SessionStart', 'b') await post('SessionEnd', 'nested', 'other', 4002) expect(forward.mock.lastCall?.[0].agentPresence?.ended).toBeUndefined() + await post('SessionEnd', 'b', 'prompt_input_exit') + expect(forward.mock.lastCall?.[0].agentPresence?.ended).toBe(true) + expect(forward.mock.lastCall?.[0].providerSessionOnly).toBe(true) + await post('SessionStart', 'c', undefined, 4003) + expect(forward.mock.lastCall?.[0].agentPresence?.process?.pid).toBe(4003) probe.mockResolvedValue('exited') await server.checkAgentPresence(paneKey) expect(forward.mock.lastCall?.[0].agentPresence?.ended).toBe(true) From ed826bc58646f7e314ae9d1d91029064f51293b1 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 19:56:39 -0700 Subject: [PATCH 10/11] fix(agent-status): rows without a process identity keep today's command-finished cleanup The renderer's command-finished cleanup kept every row when its shell check could not answer, which left Codex, hookless-agent and old-relay rows over SSH showing done after a real exit. It now asks the host whether the pane's agent process can be checked: only a pane with an identified, running owner keeps its row on an unanswered check; every other pane drops it exactly as before. The drop stays armed while the host answers, so a new command still cancels it, and a missing or failing answer (web client, older host) keeps today's behaviour. --- .../agent-hooks/server-agent-presence.test.ts | 12 + .../server/server-agent-presence.ts | 8 + src/main/ipc/agent-hooks.ts | 4 + src/preload/api/agent-status-api.ts | 2 + src/preload/api/agent-status-bridge.ts | 2 + ...onnection-command-finished-cleanup.test.ts | 3 +- ...nnection-unanswered-command-finish.test.ts | 210 ++++++++++++++++++ .../pty-connection/pane-agent-identity.ts | 26 ++- .../web/preload-api/web-agent-status-api.ts | 1 + 9 files changed, 263 insertions(+), 5 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/pty-connection-unanswered-command-finish.test.ts diff --git a/src/main/agent-hooks/server-agent-presence.test.ts b/src/main/agent-hooks/server-agent-presence.test.ts index d050e6b71189..cd8da3aecb4c 100644 --- a/src/main/agent-hooks/server-agent-presence.test.ts +++ b/src/main/agent-hooks/server-agent-presence.test.ts @@ -216,6 +216,18 @@ describe('host-owned hook presence', () => { } ) + it('reports a checkable agent process only for an identified, running owner', async () => { + const server = await createServer() + expect(server.hasVerifiableAgentProcess(PANE)).toBe(false) + await hook(server, 'SessionStart', 'unidentified', undefined, null) + expect(server.hasVerifiableAgentProcess(PANE)).toBe(false) + const identified = await createServer() + await hook(identified, 'SessionStart') + expect(identified.hasVerifiableAgentProcess(PANE)).toBe(true) + await hook(identified, 'SessionEnd', 'session-a', 'prompt_input_exit') + expect(identified.hasVerifiableAgentProcess(PANE)).toBe(false) + }) + it('checks each pane once after replaying its spooled hooks', async () => { const userDataPath = mkdtempSync(join(tmpdir(), 'orca-presence-spool-')) const first = new AgentHookServer() diff --git a/src/main/agent-hooks/server/server-agent-presence.ts b/src/main/agent-hooks/server/server-agent-presence.ts index a955e8fcfef3..ec09832ccb85 100644 --- a/src/main/agent-hooks/server/server-agent-presence.ts +++ b/src/main/agent-hooks/server/server-agent-presence.ts @@ -21,6 +21,14 @@ export abstract class AgentHookServerAgentPresence extends AgentHookServerLifecy } } + /** Whether this pane's owner carries a process identity that its execution host can check. */ + hasVerifiableAgentProcess(paneKey: string): boolean { + const presence = this.state.lastStatusByPaneKey.get( + this.resolvePaneKeyAlias(paneKey) + )?.agentPresence + return presence?.process !== undefined && !presence.ended + } + checkAgentPresence(paneKey: string): Promise { const resolved = this.resolvePaneKeyAlias(paneKey) const row = this.state.lastStatusByPaneKey.get(resolved) diff --git a/src/main/ipc/agent-hooks.ts b/src/main/ipc/agent-hooks.ts index f460be06b5fe..e6fecfc06ee0 100644 --- a/src/main/ipc/agent-hooks.ts +++ b/src/main/ipc/agent-hooks.ts @@ -36,6 +36,7 @@ export function registerAgentHookHandlers( ipcMain.removeHandler('agentStatus:getSnapshot') ipcMain.removeHandler('agentStatus:inferInterrupt') ipcMain.removeHandler('agentStatus:inferQuestionAnswered') + ipcMain.removeHandler('agentStatus:hasVerifiableAgentProcess') ipcMain.removeHandler('agentStatus:getMigrationUnsupportedSnapshot') registerAgentStatusRowTeardownIpcHandlers() registerAgentPaneAuthorityIpcHandlers({ @@ -69,6 +70,9 @@ export function registerAgentHookHandlers( } return agentHookServer.inferQuestionAnswered(request as AgentQuestionAnsweredInferenceRequest) }) + ipcMain.handle('agentStatus:hasVerifiableAgentProcess', (_event, paneKey: unknown): boolean => + typeof paneKey === 'string' ? agentHookServer.hasVerifiableAgentProcess(paneKey) : false + ) ipcMain.handle( 'agentStatus:getMigrationUnsupportedSnapshot', (): MigrationUnsupportedPtyEntry[] => getMigrationUnsupportedPtySnapshot() diff --git a/src/preload/api/agent-status-api.ts b/src/preload/api/agent-status-api.ts index 909f0fd20fba..119a3c55f6c8 100644 --- a/src/preload/api/agent-status-api.ts +++ b/src/preload/api/agent-status-api.ts @@ -18,6 +18,8 @@ export type AgentStatusApi = { inferInterrupt: (request: AgentInterruptInferenceRequest) => Promise /** Guarded clear for an answered AskUserQuestion wait — the CLI emits no hook at answer time, so the renderer reports the submit keystroke. */ inferQuestionAnswered: (request: AgentQuestionAnsweredInferenceRequest) => Promise + /** Whether the host can check this pane's agent process; without that, silence keeps today's cleanup. */ + hasVerifiableAgentProcess?: (paneKey: string) => Promise /** Listen for PTYs on a legacy numeric pane key that have registry-backed UUID pane proof. */ onMigrationUnsupported: (callback: (entry: MigrationUnsupportedPtyEntry) => void) => () => void onMigrationUnsupportedClear: (callback: (data: { ptyId: string }) => void) => () => void diff --git a/src/preload/api/agent-status-bridge.ts b/src/preload/api/agent-status-bridge.ts index 5b3566a87113..1a056252634e 100644 --- a/src/preload/api/agent-status-bridge.ts +++ b/src/preload/api/agent-status-bridge.ts @@ -28,6 +28,8 @@ export const agentStatusApi = { ipcRenderer.invoke('agentStatus:getSnapshot'), inferInterrupt: (request: AgentInterruptInferenceRequest): Promise => ipcRenderer.invoke('agentStatus:inferInterrupt', request), + hasVerifiableAgentProcess: (paneKey: string): Promise => + ipcRenderer.invoke('agentStatus:hasVerifiableAgentProcess', paneKey), inferQuestionAnswered: (request: AgentQuestionAnsweredInferenceRequest): Promise => ipcRenderer.invoke('agentStatus:inferQuestionAnswered', request), onMigrationUnsupported: ( diff --git a/src/renderer/src/components/terminal-pane/pty-connection-command-finished-cleanup.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-command-finished-cleanup.test.ts index 62b890237110..c118ec323207 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-command-finished-cleanup.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-command-finished-cleanup.test.ts @@ -694,8 +694,9 @@ describe('connectPanePty', () => { dataCallbackRef.current?.('\x1b]133;D;0\x07') await vi.advanceTimersByTimeAsync(350 + 1200 + 6000) + await flushAsyncTicks() - expect(mockStoreState.clearAgentLaunchConfig).not.toHaveBeenCalled() + expect(mockStoreState.clearAgentLaunchConfig).toHaveBeenCalledExactlyOnceWith(paneKey) expect(mockStoreState.paneForegroundAgentByPaneKey[paneKey]).toEqual({ agent: null, shellForeground: false diff --git a/src/renderer/src/components/terminal-pane/pty-connection-unanswered-command-finish.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-unanswered-command-finish.test.ts new file mode 100644 index 000000000000..e79d09e9857b --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection-unanswered-command-finish.test.ts @@ -0,0 +1,210 @@ +import type * as React from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { makePaneKey } from '../../../../shared/stable-pane-id' +import { flushAsyncTicks } from './pty-connection-test-async' +import { + LEAF_1, + createMockTransport, + createPane, + createManager, + type ConnectCallbacks, + type MockTransport +} from './pty-connection-test-pane-fixtures' +import type { StoreState } from './pty-connection-test-store-state' +import { buildPaneConnectionDeps } from './pty-connection-test-deps' +import { createInitialStoreState } from './pty-connection-test-store-fixtures' +import { + installTerminalTestGlobals, + restoreTerminalTestGlobals +} from './pty-connection-test-environment' + +const { + resetAndRefreshAllTerminalWebglAtlases, + scheduleTerminalWebglAtlasRecovery, + scheduleRuntimeGraphSync, + shouldSeedCacheTimerOnInitialTitle, + toastInfo, + notifyCodexPaneBoundForStaleSweep +} = vi.hoisted(() => ({ + resetAndRefreshAllTerminalWebglAtlases: vi.fn(), + scheduleTerminalWebglAtlasRecovery: vi.fn(), + scheduleRuntimeGraphSync: vi.fn(), + shouldSeedCacheTimerOnInitialTitle: vi.fn(() => false), + toastInfo: vi.fn(), + notifyCodexPaneBoundForStaleSweep: vi.fn() +})) + +let mockStoreState: StoreState +let transportFactoryQueue: MockTransport[] = [] +let createdTransportOptions: Record[] = [] +let storeSubscribers: ((state: StoreState) => void)[] = [] + +vi.mock('@/runtime/sync-runtime-graph', () => ({ + scheduleRuntimeGraphSync +})) + +vi.mock('@/lib/pane-manager/pane-manager-registry', async (importOriginal) => ({ + ...(await importOriginal>()), + resetAndRefreshAllTerminalWebglAtlases +})) + +vi.mock('./terminal-webgl-atlas-recovery', () => ({ + scheduleTerminalWebglAtlasRecovery +})) + +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => mockStoreState, + subscribe: (listener: (state: StoreState) => void) => { + storeSubscribers.push(listener) + return () => { + storeSubscribers = storeSubscribers.filter((candidate) => candidate !== listener) + } + } + } +})) + +vi.mock('@/lib/agent-status', async (importOriginal) => { + const { buildAgentStatusModuleMock } = await import('./pty-connection-test-environment') + return buildAgentStatusModuleMock(await importOriginal>()) +}) + +vi.mock('./cache-timer-seeding', () => ({ + shouldSeedCacheTimerOnInitialTitle +})) + +vi.mock('sonner', () => ({ + toast: { + info: toastInfo + } +})) + +vi.mock('@/lib/codex-stale-pane-sweep', () => ({ + notifyCodexPaneBoundForStaleSweep +})) + +// Why: the working→idle test invokes the real useNotificationDispatch hook outside React, so useCallback must pass through (safe suite-wide: no test here renders React). +vi.mock('react', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useCallback: unknown>(fn: T): T => fn + } +}) + +vi.mock('./pty-transport', () => ({ + createIpcPtyTransport: vi.fn((options: Record) => { + createdTransportOptions.push(options) + const nextTransport = transportFactoryQueue.shift() + if (!nextTransport) { + throw new Error('No mock transport queued') + } + return nextTransport + }) +})) + +vi.mock('./remote-runtime-pty-transport', () => ({ + createRemoteRuntimePtyTransport: vi.fn( + (_environmentId: string, options: Record) => { + createdTransportOptions.push(options) + const nextTransport = transportFactoryQueue.shift() + if (!nextTransport) { + throw new Error('No mock transport queued') + } + return nextTransport + } + ) +})) + +// Why: stub only getEagerPtyBufferHandle so tests can simulate a live eager buffer (adopt path) without standing up the real IPC dispatcher. +vi.mock('./pty-dispatcher', async (importOriginal) => { + const actual = await importOriginal>() + return { + ...actual, + getEagerPtyBufferHandle: vi.fn(() => undefined) + } +}) + +function createDeps(overrides: Record = {}) { + return buildPaneConnectionDeps(() => mockStoreState, overrides) +} + +describe('command-finished cleanup when the shell check cannot answer', () => { + beforeEach(() => { + vi.resetModules() + vi.clearAllMocks() + transportFactoryQueue = [] + createdTransportOptions = [] + storeSubscribers = [] + mockStoreState = createInitialStoreState(() => mockStoreState) + installTerminalTestGlobals() + }) + + afterEach(async () => { + await restoreTerminalTestGlobals() + }) + + it.each([ + ['clears', 'a row without a process identity', false], + ['keeps', 'a row whose agent process the host can check', true] + ])( + '%s %s when an SSH pane finishes and the shell check cannot answer', + async (_verb, _label, verifiable) => { + vi.useFakeTimers() + const { connectPanePty } = await import('./pty-connection') + vi.mocked(window.api.pty.confirmForegroundProcess).mockResolvedValue(null) + const hasVerifiableAgentProcess = vi.fn(async () => verifiable) + Object.assign(window.api.agentStatus, { hasVerifiableAgentProcess }) + const dataCallbackRef: { current: ((data: string) => void) | null } = { current: null } + const ptyId = 'ssh:conn@@pty-codex-exit' + const tabId = 'tab-ssh-codex-exit' + const paneKey = makePaneKey(tabId, LEAF_1) + const transport = createMockTransport(ptyId) + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + dataCallbackRef.current = callbacks.onData ?? null + return { id: ptyId } + } + ) + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + agentStatusByPaneKey: { + [paneKey]: { + paneKey, + state: 'done', + prompt: 'remote task', + updatedAt: 1_000, + stateStartedAt: 900, + agentType: 'codex', + terminalTitle: 'Codex', + stateHistory: [] + } + } + } + + connectPanePty( + createPane(1) as never, + createManager(1) as never, + createDeps({ tabId, isVisibleRef: { current: false } }) as never + ) + await vi.advanceTimersByTimeAsync(20) + await flushAsyncTicks() + mockStoreState.agentLaunchConfigByPaneKey[paneKey] = { + launchConfig: { agentArgs: '', agentEnv: {} }, + identity: { agentType: 'codex' } + } + + dataCallbackRef.current?.('\x1b]133;D;0\x07') + await vi.advanceTimersByTimeAsync(350 + 1200 + 6000) + await flushAsyncTicks() + + expect(hasVerifiableAgentProcess).toHaveBeenCalledWith(paneKey) + if (verifiable) { + expect(mockStoreState.dropAgentStatus).not.toHaveBeenCalled() + } else { + expect(mockStoreState.dropAgentStatus).toHaveBeenCalledWith(paneKey) + } + } + ) +}) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/pane-agent-identity.ts b/src/renderer/src/components/terminal-pane/pty-connection/pane-agent-identity.ts index 932f23b53322..8cca9a3926c5 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/pane-agent-identity.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/pane-agent-identity.ts @@ -120,12 +120,30 @@ export function installPaneAgentIdentity(session: ConnectPanePtySession): void { ): void => { const dropStatus = session.deferredCommandFinishedStatusDrop const reconcile = session.deferredConfirmedShellReconcile - session.deferredCommandFinishedStatusDrop = null session.deferredConfirmedShellReconcile = null - if (options.confirmedShell) { - dropStatus?.() - reconcile?.() + if (options.confirmedShell || !dropStatus) { + session.deferredCommandFinishedStatusDrop = null + if (options.confirmedShell) { + dropStatus?.() + reconcile?.() + } + return + } + // Why: only a pane whose agent process the host can check keeps its row on an unanswered read; + // every other pane keeps today's cleanup until the renderer reads the owner record (step 2). + // The drop stays armed while main answers, so a new command start still cancels it. + const dropUnlessVerifiable = (verifiable: boolean): void => { + if (session.deferredCommandFinishedStatusDrop !== dropStatus) { + return + } + session.deferredCommandFinishedStatusDrop = null + if (!verifiable) { + dropStatus() + } } + void Promise.resolve(window.api?.agentStatus?.hasVerifiableAgentProcess?.(session.cacheKey)) + .then((verifiable) => dropUnlessVerifiable(verifiable === true)) + .catch(() => dropUnlessVerifiable(false)) } const isRemotePtyId = (id: string): boolean => Boolean(isRemoteExecutionHostPtyId(id) || parseAppSshPtyId(id)) diff --git a/src/renderer/src/web/preload-api/web-agent-status-api.ts b/src/renderer/src/web/preload-api/web-agent-status-api.ts index d7c9740018c8..d4b2c0db1630 100644 --- a/src/renderer/src/web/preload-api/web-agent-status-api.ts +++ b/src/renderer/src/web/preload-api/web-agent-status-api.ts @@ -9,6 +9,7 @@ export function createWebAgentStatusApi(): Partial { getSnapshot: () => Promise.resolve([]), inferInterrupt: () => Promise.resolve(false), inferQuestionAnswered: () => Promise.resolve(false), + hasVerifiableAgentProcess: () => Promise.resolve(false), onMigrationUnsupported: () => noopUnsubscribe, onMigrationUnsupportedClear: () => noopUnsubscribe, onLegacyWorkerTerminalRecovery: () => noopUnsubscribe, From 721de1cdbcc55c314725b6deee897036a56e1f13 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 20:02:16 -0700 Subject: [PATCH 11/11] fix(agent-status): panes without an identified owner keep today's exit confirmation The title-driven exit confirmation applied 'silence is never an exit' to every pane. It now applies only when the pane has an identified owner whose process cannot be checked right now; a pane with no process identity (Codex, hookless agents, Windows, old relays, sessions started before the update, or an owner that already ended) confirms exits exactly as before. --- ...ntime-serialize-agent-prompt-submission.ts | 27 ++++++++---- .../terminal-side-effect-facts.spec.ts | 42 ++++++++++++------- 2 files changed, 44 insertions(+), 25 deletions(-) diff --git a/src/main/runtime/orca-runtime-serialize-agent-prompt-submission.ts b/src/main/runtime/orca-runtime-serialize-agent-prompt-submission.ts index f8503753ba71..99a3a56943d8 100644 --- a/src/main/runtime/orca-runtime-serialize-agent-prompt-submission.ts +++ b/src/main/runtime/orca-runtime-serialize-agent-prompt-submission.ts @@ -97,9 +97,10 @@ export class OrcaRuntimeWithSerializeAgentPromptSubmission extends OrcaRuntimeWi if (this.ptysById.get(ptyId) !== current || current?.incarnationId !== incarnation) { return } - // Why: an unanswered process check falls back to the foreground read, which itself never exits on silence. + // Why: without an identified owner (null) the foreground read keeps today's rules; with one + // whose process cannot be checked right now, silence from that read is never an exit. if (verdict === null || verdict === 'unverifiable') { - this.confirmLegacyPtyAgentExit(ptyId, recoverCompletedHook) + this.confirmLegacyPtyAgentExit(ptyId, recoverCompletedHook, verdict === 'unverifiable') } else if (verdict === 'exited' && !recoverCompletedHook) { this.recordTerminalSideEffectFact(ptyId, { kind: 'agent-exited' }) } else if (verdict === 'live') { @@ -139,7 +140,11 @@ export class OrcaRuntimeWithSerializeAgentPromptSubmission extends OrcaRuntimeWi } } - private confirmLegacyPtyAgentExit(ptyId: string, recoverCompletedHook: boolean): void { + private confirmLegacyPtyAgentExit( + ptyId: string, + recoverCompletedHook: boolean, + keepOnSilence: boolean + ): void { const pty = this.ptysById.get(ptyId) const handle = this.handleByPtyId.get(ptyId) if ( @@ -153,7 +158,11 @@ export class OrcaRuntimeWithSerializeAgentPromptSubmission extends OrcaRuntimeWi const titleObservedAt = pty?.lastOscTitleAt ?? null const foregroundRead = this.readPtyForegroundProcessFromController(ptyId, titleObservedAt ?? 0) if (!pty?.connected || !foregroundRead) { - this.ptyTitleTrackersByPtyId.get(ptyId)?.tracker.restoreLastAgentExit() + if (keepOnSilence) { + this.ptyTitleTrackersByPtyId.get(ptyId)?.tracker.restoreLastAgentExit() + } else if (!recoverCompletedHook) { + this.recordTerminalSideEffectFact(ptyId, { kind: 'agent-exited' }) + } return } void foregroundRead.then((result) => { @@ -193,14 +202,14 @@ export class OrcaRuntimeWithSerializeAgentPromptSubmission extends OrcaRuntimeWi this.restoreDisprovedAgentExit(ptyId, confirmedStatus) return } - // Why: only an answered read naming a non-agent is an exit; silence is never one. - if ( - !recoverCompletedHook && + const answered = result.controller === this.ptyController && result.available && typeof result.process === 'string' - ) { - this.recordTerminalSideEffectFact(ptyId, { kind: 'agent-exited' }) + if (!keepOnSilence || answered) { + if (!recoverCompletedHook) { + this.recordTerminalSideEffectFact(ptyId, { kind: 'agent-exited' }) + } } else { this.ptyTitleTrackersByPtyId.get(ptyId)?.tracker.restoreLastAgentExit() } diff --git a/src/main/runtime/orca-runtime-tests/terminal-side-effect-facts.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-side-effect-facts.spec.ts index 28dfe38b6888..a65181043d10 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-side-effect-facts.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-side-effect-facts.spec.ts @@ -516,24 +516,34 @@ describe('terminal side-effect fact channel', () => { } ) - it('treats synchronous foreground read failures as unavailable', async () => { - const { runtime, batches } = createSideEffectRuntime() - syncSinglePty(runtime) - runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;Codex ready\x07') - const getForegroundProcess = vi.fn(() => { - throw new TypeError('getForegroundProcess is unavailable') - }) - runtime.setPtyController({ - write: () => true, - kill: () => true, - getForegroundProcess - }) + it.each([ + { presence: null, exited: true }, + { presence: 'unverifiable' as const, exited: false } + ])( + 'treats synchronous foreground read failures as unavailable (hook presence $presence)', + async ({ presence, exited }) => { + // Why: only a pane with an identified owner keeps its agent on silence; others keep today's exit. + const { runtime, batches } = createSideEffectRuntime(async () => presence) + syncSinglePty(runtime) + runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;Codex ready\x07') + const getForegroundProcess = vi.fn(() => { + throw new TypeError('getForegroundProcess is unavailable') + }) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess + }) - runtime.onPtyData('pty-1', '\x1b]0;bichir\x07', 100) + runtime.onPtyData('pty-1', '\x1b]0;bichir\x07', 100) - await vi.waitFor(() => expect(getForegroundProcess).toHaveBeenCalledOnce()) - expect(batches.flatMap((batch) => batch.facts)).not.toContainEqual({ kind: 'agent-exited' }) - }) + await vi.waitFor(() => expect(getForegroundProcess).toHaveBeenCalled()) + await new Promise((resolve) => setTimeout(resolve, 50)) + expect( + batches.flatMap((batch) => batch.facts).some((fact) => fact.kind === 'agent-exited') + ).toBe(exited) + } + ) it('aligns a restored session and pre-response bytes to the provider sequence', async () => { const { runtime } = createSideEffectRuntime()