diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index 2d41bdc44121..d536515655e1 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-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..6cf965ae261f --- /dev/null +++ b/src/main/agent-hooks/hook-process-capture.test.ts @@ -0,0 +1,50 @@ +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').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') + 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', + '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') + ], + 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..0e68c51fed1b --- /dev/null +++ b/src/main/agent-hooks/hook-process-capture.ts @@ -0,0 +1,24 @@ +/** 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=', + // 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=', + ' 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', + // 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/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..cd8da3aecb4c --- /dev/null +++ b/src/main/agent-hooks/server-agent-presence.test.ts @@ -0,0 +1,346 @@ +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' + +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') +}) + +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 +} + +async function hook( + server: AgentHookServer, + event: string, + session = 'session-a', + reason?: string, + pid: number | null = 4001 +): Promise { + const agentProcess = + pid === null + ? 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, + ...(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) +} + +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 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') + 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') + expect(state(server)).toBe('working') + await hook(server, 'SessionEnd', 'outer', 'other') + expect(visible(server)).toBe(false) + }) + + it('never ends a pane from a SessionEnd without a process identity', async () => { + const server = await createServer() + 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') + }) + + it('lets an unidentified agent keep reporting after an identified nested agent ends', async () => { + const server = await createServer() + 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() + }) + + 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: { + agent: 'claude', + process: { pid: 4002, platform: 'linux', startTime: 'boot:1' }, + ended: true + }, + payload: { state: 'done', prompt: '', agentType: 'claude' } + }, + 'ssh-1' + ) + 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.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('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() + 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') + 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) + expect(await server.checkAgentPresence(PANE)).toBeNull() + }) + + it('does not apply a delayed process exit to a relaunched agent', async () => { + const server = await createServer() + await hook(server, 'SessionStart') + let finish: (value: 'exited') => void = () => {} + probe.mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve + }) + ) + const pending = server.checkAgentPresence(PANE) + 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('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: { + agent: 'claude', + 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) + }) + + 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') + await hook(server, 'UserPromptSubmit') + 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, '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.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/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..ec09832ccb85 --- /dev/null +++ b/src/main/agent-hooks/server/server-agent-presence.ts @@ -0,0 +1,71 @@ +import type { AgentHookEventPayload } from '../../../shared/agent-hook-listener/listener-event' +import { + isSameAgentProcess, + 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 + >() + + /** 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?.process && !owner.ended && !isSameAgentProcess(sender, owner.process)) { + void this.checkAgentPresence(row.paneKey) + } + } + + /** 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) + // 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) { + 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..28eac6e56dc1 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,7 @@ export abstract class AgentHookServerIngestRemote extends AgentHookServerIngestS launchToken?: string hasExplicitPrompt?: boolean promptInteractionKey?: string + agentPresence?: unknown hookEventName?: string source?: unknown providerPromptId?: unknown @@ -156,6 +158,7 @@ export abstract class AgentHookServerIngestRemote extends AgentHookServerIngestS ) if ( envelope.providerSessionOnly === true && + !readAgentProcessPresence(envelope.agentPresence)?.ended && !isValidPiProviderSessionOnly(providerSession, normalizedPayload.agentType) ) { return @@ -256,6 +259,7 @@ export abstract class AgentHookServerIngestRemote extends AgentHookServerIngestS }) const event: AgentHookEventPayload & { authorityRestartId?: string } = { paneKey, + agentPresence: readAgentProcessPresence(envelope.agentPresence), 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 816058e55fdd..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 } @@ -131,6 +139,7 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv this.recordCurrentAuthorityObservation(event) const enriched = this.applyNormalizedStatus(event, normalized.onAccepted) if (enriched) { + this.checkAgentPresenceAfterHook(event, enriched) this.scheduleAssistantMessageRetry(source, aliasedBody, enriched) this.scheduleTranscriptPoll(source, aliasedBody, enriched) } 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..49bb8ade3ac9 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,17 @@ export abstract class AgentHookServerState { entry: EnrichedAgentHookEventPayload | null | undefined ): EnrichedAgentHookEventPayload | null protected abstract hasLiveClaimsForPaneKey(paneKey: string): boolean + abstract checkAgentPresence(paneKey: string): Promise + abstract checkAgentPresenceAfterHook( + event: AgentHookEventPayload, + row: AgentHookEventPayload + ): void + + 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..bb40c187134f 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) diff --git a/src/main/agent-hooks/spool.test.ts b/src/main/agent-hooks/spool.test.ts index 24fcef6be5d3..23917887234c 100644 --- a/src/main/agent-hooks/spool.test.ts +++ b/src/main/agent-hooks/spool.test.ts @@ -31,7 +31,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('waits for a newline before replaying a complete-looking final record', () => { diff --git a/src/main/claude/hook-script.ts b/src/main/claude/hook-script.ts index 475d79273b44..b2e98ba229fc 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/. */ @@ -78,6 +79,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/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/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..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 { @@ -68,6 +64,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 +143,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..99a3a56943d8 100644 --- a/src/main/runtime/orca-runtime-serialize-agent-prompt-submission.ts +++ b/src/main/runtime/orca-runtime-serialize-agent-prompt-submission.ts @@ -70,7 +70,81 @@ 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 + } + // 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, verdict === 'unverifiable') + } 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, + keepOnSilence: boolean + ): void { const pty = this.ptysById.get(ptyId) const handle = this.handleByPtyId.get(ptyId) if ( @@ -84,7 +158,9 @@ export class OrcaRuntimeWithSerializeAgentPromptSubmission extends OrcaRuntimeWi const titleObservedAt = pty?.lastOscTitleAt ?? null const foregroundRead = this.readPtyForegroundProcessFromController(ptyId, titleObservedAt ?? 0) if (!pty?.connected || !foregroundRead) { - if (!recoverCompletedHook) { + if (keepOnSilence) { + this.ptyTitleTrackersByPtyId.get(ptyId)?.tracker.restoreLastAgentExit() + } else if (!recoverCompletedHook) { this.recordTerminalSideEffectFact(ptyId, { kind: 'agent-exited' }) } return @@ -123,34 +199,19 @@ 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 } - if (!recoverCompletedHook) { - this.recordTerminalSideEffectFact(ptyId, { kind: 'agent-exited' }) + const answered = + result.controller === this.ptyController && + result.available && + typeof result.process === 'string' + 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-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-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 79feca3268c3..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 @@ -474,27 +474,77 @@ describe('terminal side-effect fact channel', () => { ) }) - it('treats synchronous foreground read failures as unavailable', async () => { - const { runtime, batches } = createSideEffectRuntime() + 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') - 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;~/repo\x07', 100) - await vi.waitFor(() => - expect(batches.flatMap((batch) => batch.facts)).toContainEqual({ kind: 'agent-exited' }) + 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 ) - expect(getForegroundProcess).toHaveBeenCalledOnce() }) + 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.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) + + 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() 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-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/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/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/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/relay/agent-hook-envelope-build.ts b/src/relay/agent-hook-envelope-build.ts index 6b1b45432d8b..ba2779c684a1 100644 --- a/src/relay/agent-hook-envelope-build.ts +++ b/src/relay/agent-hook-envelope-build.ts @@ -16,6 +16,7 @@ export function buildRelayHookEnvelope( ): AgentHookRelayEnvelope { return { source, + agentPresence: event.agentPresence, 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..e852f66cf69b --- /dev/null +++ b/src/relay/agent-hook-request.ts @@ -0,0 +1,86 @@ +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 + ) => AgentHookEventPayload | undefined + 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) + 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() + } 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-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/agent-hook-server.ts b/src/relay/agent-hook-server.ts index e75b11a24505..126d0470abb0 100644 --- a/src/relay/agent-hook-server.ts +++ b/src/relay/agent-hook-server.ts @@ -1,3 +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' @@ -17,16 +21,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 +67,6 @@ export type RelayHookServerOptions = { export type RelayHookServerStartOptions = { publishEndpoint?: boolean } - export class RelayAgentHookServer { private server: ReturnType | null = null private port = 0 @@ -92,6 +90,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) { @@ -107,7 +106,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 }) } }) } @@ -215,6 +214,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 +222,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,74 +263,40 @@ 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 { + options: { isReplay?: boolean; checkPresence?: boolean } = {} + ): AgentHookEventPayload | undefined { + const transitioned = transitionHookPresence( + incoming, + this.state.lastStatusByPaneKey.get(incoming.paneKey) + ) + if (!transitioned) { + return undefined + } + 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 // 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) @@ -329,11 +309,25 @@ export class RelayAgentHookServer { this.clearPaneState(paneKey) ) ) { - return + return undefined } this.lastEnvelopeMetaByPaneKey.delete(event.paneKey) this.lastEnvelopeMetaByPaneKey.set(event.paneKey, { source, env, version }) this.forward(buildRelayHookEnvelope(event, source, env, version, options)) + 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?.process && + !owner.ended && + !isSameAgentProcess(sender, owner.process) + ) { + 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/relay/pty-handler.ts b/src/relay/pty-handler.ts index b6714a956b2a..53d666a12f58 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -1,5 +1,7 @@ /* oxlint-disable max-lines */ import { resolveSynchronizedOutputSafeSplit } from '../shared/terminal-synchronized-output-scan' +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' import type { IPty } from 'node-pty' @@ -715,6 +717,12 @@ export class PtyHandler { return this.graceTimeMs } + 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 @@ -1019,7 +1027,25 @@ export class PtyHandler { } }) } + const recheckAgentPresence = (): void => { + if (managed.paneKey) { + this.agentPresenceTrigger?.(managed.paneKey) + } + } + let lastTitleGateKey: string | null = null + const presenceTriggers = createTerminalTitleTracker({ + 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) => { + presenceTriggers.handleChunk(data) const startup = managed.startupCommand if (startup?.waitForShellReady && startup.outputScanState && !startup.delivered) { const scanned = scanShellStartupOutput(startup.outputScanState, data) @@ -1041,6 +1067,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..87fac06e8e82 --- /dev/null +++ b/src/relay/relay-agent-presence.test.ts @@ -0,0 +1,101 @@ +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' + +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') +}) + +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, 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 }, + body: JSON.stringify({ + 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?.process?.pid).toBe(4001) + expect(probe).not.toHaveBeenCalled() + 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') + 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) + 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..f32278a1309d --- /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?.process || 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 ff7503b3012c..75b96f026842 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 @@ -697,6 +697,7 @@ describe('connectPanePty', () => { dataCallbackRef.current?.('\x1b]133;D;0\x07') await vi.advanceTimersByTimeAsync(350 + 1200 + 6000) + await flushAsyncTicks() expect(mockStoreState.clearAgentLaunchConfig).toHaveBeenCalledExactlyOnceWith(paneKey) expect(mockStoreState.paneForegroundAgentByPaneKey[paneKey]).toEqual({ 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 399930ebfa06..789c51f7b7ab 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 - dropStatus?.() - if (options.confirmedShell) { - 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, 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-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..41e43ad36275 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' @@ -24,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, @@ -136,6 +144,40 @@ 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 ? { agent: source, process: agentProcess } : undefined + const sessionEndReason = readString(hookPayloadRecord, 'reason') + if ( + eventName === 'SessionEnd' && + agentPresence && + !readString(hookPayloadRecord, 'agent_id') && + // 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 ?? + normalizeAgentStatusPayload({ state: 'done', prompt: '', agentType: source }) + if (!payload) { + return null + } + return { + paneKey, + source, + launchToken, + tabId, + worktreeId, + connectionId: null, + providerSession: providerSession ?? undefined, + hookEventName: 'SessionEnd', + agentPresence: { ...agentPresence, ended: true as const }, + payload + } + } + const extractedPrompt = extractPromptText(hookPayloadRecord) const promptText = extractedPrompt.text const dispatched = normalizeProviderEvent({ @@ -168,6 +210,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..a954313cf158 100644 --- a/src/shared/agent-hook-listener/listener-event.ts +++ b/src/shared/agent-hook-listener/listener-event.ts @@ -1,9 +1,11 @@ +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 /** 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..241ec382efaf --- /dev/null +++ b/src/shared/agent-hook-presence-transition.ts @@ -0,0 +1,45 @@ +import type { AgentHookEventPayload } from './agent-hook-listener/listener-event' +import { isSameAgentProcess } from './agent-process-presence' + +/** 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 { + const recorded = previous?.agentPresence + const owner = recorded && !recorded.ended && !previous?.providerSessionOnly ? recorded : undefined + const sender = incoming.agentPresence?.process + // 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) + return fromOwner + ? { + ...incoming, + payload: previous?.payload ?? incoming.payload, + agentPresence: { ...owner, ended: true } + } + : undefined + } + // 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 ( + recorded?.ended && + recorded.process && + sender && + isSameAgentProcess(recorded.process, sender) + ) { + return undefined + } + const agent = incoming.agentPresence?.agent ?? incoming.payload.agentType + if (!agent || agent === 'unknown') { + return incoming.agentPresence ? { ...incoming, agentPresence: undefined } : incoming + } + return { ...incoming, agentPresence: { agent, ...(sender ? { process: sender } : {}) } } +} diff --git a/src/shared/agent-hook-relay.ts b/src/shared/agent-hook-relay.ts index bb2dca99f729..4a29fca868bf 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,7 @@ export const REMOTE_AGENT_HOOK_ENV = 'remote' as const export type AgentHookRelayEnvelope = { source: AgentHookSource paneKey: string + agentPresence?: AgentProcessPresence /** 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 388c28f4c2dc..9c0d11f2d64a 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..ce29f18f094e --- /dev/null +++ b/src/shared/agent-process-presence-platform.test.ts @@ -0,0 +1,54 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { readAgentProcess } from './agent-process-presence-probe' + +const run = vi.hoisted(() => vi.fn()) +const read = vi.hoisted(() => vi.fn()) +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('leaves Windows unanswered until a Windows hook captures an identity', async () => { + platform('win32') + expect(await readAgentProcess(4242)).toEqual({ verdict: 'unverifiable' }) + expect(run).not.toHaveBeenCalled() + expect(read).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, + 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 new file mode 100644 index 000000000000..07e055969a1f --- /dev/null +++ b/src/shared/agent-process-presence-probe.test.ts @@ -0,0 +1,37 @@ +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: 'live', startTime: 'boot:100', zombie: false, stopped: true }, 'unverifiable'], + [{ 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..22fac38b7dbd --- /dev/null +++ b/src/shared/agent-process-presence-probe.ts @@ -0,0 +1,96 @@ +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; stopped?: 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', + stopped: fields[0] === 'T' || fields[0] === 't' + } + } + if (process.platform === 'darwin') { + const result = await runProcess({ + program: '/bin/ps', + args: ['-p', String(pid), '-o', 'stat=,lstart='], + // Why: lstart is local time; the hook capture pins the same zone so the strings compare. + env: { ...process.env, TZ: 'UTC0', 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'), + stopped: match[1].startsWith('T') + } + } + } + try { + process.kill(pid, 0) + } catch (error) { + if (isMissing(error)) { + return { verdict: 'exited' } + } + } + } + } catch { + return { verdict: 'unverifiable' } + } + // Why: no Windows hook captures an identity yet, so there is nothing to compare against. + 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 + } + if (observed.zombie || observed.startTime !== identity.startTime) { + return 'exited' + } + // Why: a suspended (Ctrl-Z) agent still exists but is not running in its terminal. + return observed.stopped ? 'unverifiable' : 'live' +} diff --git a/src/shared/agent-process-presence.ts b/src/shared/agent-process-presence.ts new file mode 100644 index 000000000000..cbde61981bfd --- /dev/null +++ b/src/shared/agent-process-presence.ts @@ -0,0 +1,70 @@ +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 agent that owns a pane, from its first hook until it ends; the process when a hook proved it. */ +export type AgentProcessPresence = { + agent: AgentType + process?: AgentProcessIdentity + ended?: 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' || + !('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 { + agent: value.agent, + ...(process ? { 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 +}