From 5823808117ddcf5a6e5bcc9606daf9a7bf67ca32 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:18:58 -0700 Subject: [PATCH 01/31] fix(terminal): require positive evidence before publishing agent exit Preserve chat and display identity when process observations are unverifiable, while keeping input routing subject to fresh evidence. Co-authored-by: lurunzi --- ...e-get-unpersisted-tracked-title-for-pty.ts | 2 +- ...ntime-serialize-agent-prompt-submission.ts | 27 +++- .../terminal-side-effect-facts.spec.ts | 25 ++-- ...time-pty-foreground-agent-evidence.test.ts | 120 ++++++++++++++++++ .../runtime/runtime-pty-foreground-agent.ts | 62 ++++++++- ...ime-terminal-agent-status-evidence.test.ts | 47 +++++++ .../runtime-terminal-agent-status-query.ts | 25 ++-- .../terminal-agent-exit-evidence.test.ts | 86 +++++++++++++ .../pty-handler-resize-stale-pty.test.ts | 24 ++++ src/relay/pty-handler.ts | 18 +-- .../native-chat-leaf-routing.test.ts | 12 ++ .../native-chat/native-chat-leaf-routing.ts | 3 + .../agent-exit-observation.test.ts | 79 ++++++++++++ .../pane-foreground-agent-tracker.test.ts | 38 ++++-- .../pane-foreground-agent-tracker.ts | 36 +++--- .../pane-foreground-process-reader.ts | 4 +- .../parked-terminal-byte-watcher.ts | 3 +- .../parked-terminal-command-status.test.ts | 9 +- .../parked-terminal-command-status.ts | 15 +-- .../agent-idle-working-handlers.ts | 10 +- .../pty-connection/pane-agent-identity.ts | 19 ++- .../pty-connection/pty-input-recovery.ts | 2 +- .../pty-connection/terminal-keydown-fit.ts | 1 + .../terminal-pane/pty-output-processor.ts | 6 +- .../pty-output-title-observer.ts | 11 +- .../terminal-pane/pty-transport-types.ts | 1 + .../components/terminal-pane/pty-transport.ts | 4 +- .../remote-runtime-pty-transport.ts | 4 +- .../terminal-side-effect-facts-handler.ts | 7 +- .../terminal-title-tracker-parity.test.ts | 4 +- src/shared/agent-title-status.ts | 7 +- src/shared/foreground-agent-verdict.ts | 15 +++ src/shared/terminal-output-side-effects.ts | 8 +- src/shared/terminal-side-effect-facts.ts | 2 +- 34 files changed, 625 insertions(+), 111 deletions(-) create mode 100644 src/main/runtime/runtime-pty-foreground-agent-evidence.test.ts create mode 100644 src/main/runtime/runtime-terminal-agent-status-evidence.test.ts create mode 100644 src/main/runtime/terminal-agent-exit-evidence.test.ts create mode 100644 src/renderer/src/components/terminal-pane/agent-exit-observation.test.ts create mode 100644 src/shared/foreground-agent-verdict.ts 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..993e6d005278 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 @@ -140,7 +140,7 @@ export class OrcaRuntimeWithGetUnpersistedTrackedTitleForPty extends OrcaRuntime ...(meta?.staleWorkingTitleClear ? { staleWorkingTitleClear: true } : {}) }) }, - onAgentExited: () => { + onAgentExitCandidate: () => { this.confirmPtyAgentExit(ptyId) }, onCommandFinished: (exitCode: number | 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..bf299fcf4dac 100644 --- a/src/main/runtime/orca-runtime-serialize-agent-prompt-submission.ts +++ b/src/main/runtime/orca-runtime-serialize-agent-prompt-submission.ts @@ -1,4 +1,6 @@ // @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. +import { startSpan } from '../observability/tracer' +import { foregroundAgentVerdict } from '../../shared/foreground-agent-verdict' import { selectFreshExplicitAgentStatus } from './runtime-hook-agent-row-selection' import { OrcaRuntimeWithControllerKnowsPtyIsLive } from './orca-runtime-controller-knows-pty-is-live' import type { RuntimeTerminalAgentStatus } from '../../shared/runtime-types' @@ -84,9 +86,7 @@ export class OrcaRuntimeWithSerializeAgentPromptSubmission extends OrcaRuntimeWi const titleObservedAt = pty?.lastOscTitleAt ?? null const foregroundRead = this.readPtyForegroundProcessFromController(ptyId, titleObservedAt ?? 0) if (!pty?.connected || !foregroundRead) { - if (!recoverCompletedHook) { - this.recordTerminalSideEffectFact(ptyId, { kind: 'agent-exited' }) - } + this.ptyTitleTrackersByPtyId.get(ptyId)?.tracker.restoreLastAgentExit() return } void foregroundRead.then((result) => { @@ -149,8 +149,25 @@ export class OrcaRuntimeWithSerializeAgentPromptSubmission extends OrcaRuntimeWi } return } - if (!recoverCompletedHook) { - this.recordTerminalSideEffectFact(ptyId, { kind: 'agent-exited' }) + if ( + !recoverCompletedHook && + result.controller === this.ptyController && + result.available && + foregroundAgentVerdict(result.process) === 'exited' + ) { + startSpan('terminal.agent-exit-decision', { + attributes: { + reason: 'title-exit-candidate', + evidenceSource: 'host-foreground-confirmation', + verdict: 'exited' + } + }).end() + this.recordTerminalSideEffectFact(ptyId, { + kind: 'agent-exited', + evidence: 'foreground-shell' + }) + } else { + this.ptyTitleTrackersByPtyId.get(ptyId)?.tracker.restoreLastAgentExit() } }) } diff --git a/src/main/runtime/orca-runtime-tests/terminal-side-effect-facts.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-side-effect-facts.spec.ts index 79feca3268c3..f9cfa5a81b45 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 @@ -418,7 +418,8 @@ describe('terminal side-effect fact channel', () => { runtime.setPtyController({ write: () => true, kill: () => true, - getForegroundProcess + getForegroundProcess, + confirmForegroundProcess: getForegroundProcess }) runtime.onPtyData('pty-1', '\x1b]0;bichir\x07', 101) @@ -443,7 +444,10 @@ describe('terminal side-effect fact channel', () => { runtime.onPtyData('pty-1', '\x1b]0;other cwd\x07', 102) await vi.waitFor(() => - expect(batches.flatMap((batch) => batch.facts)).toContainEqual({ kind: 'agent-exited' }) + expect(batches.flatMap((batch) => batch.facts)).toContainEqual({ + kind: 'agent-exited', + evidence: 'foreground-shell' + }) ) expect(getForegroundProcess).toHaveBeenCalledTimes(2) }) @@ -459,7 +463,8 @@ describe('terminal side-effect fact channel', () => { runtime.setPtyController({ write: () => true, kill: () => true, - getForegroundProcess + getForegroundProcess, + confirmForegroundProcess: getForegroundProcess }) runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;Codex ready\x07') @@ -470,7 +475,10 @@ describe('terminal side-effect fact channel', () => { await vi.waitFor(() => expect(getForegroundProcess).toHaveBeenCalledTimes(2)) await vi.waitFor(() => - expect(batches.flatMap((batch) => batch.facts)).toContainEqual({ kind: 'agent-exited' }) + expect(batches.flatMap((batch) => batch.facts)).toContainEqual({ + kind: 'agent-exited', + evidence: 'foreground-shell' + }) ) }) @@ -484,15 +492,16 @@ describe('terminal side-effect fact channel', () => { runtime.setPtyController({ write: () => true, kill: () => true, - getForegroundProcess + getForegroundProcess, + confirmForegroundProcess: getForegroundProcess }) runtime.onPtyData('pty-1', '\x1b]0;bichir\x07', 100) - await vi.waitFor(() => - expect(batches.flatMap((batch) => batch.facts)).toContainEqual({ kind: 'agent-exited' }) + await vi.waitFor(() => expect(getForegroundProcess).toHaveBeenCalledOnce()) + expect(batches.flatMap((batch) => batch.facts)).not.toEqual( + expect.arrayContaining([expect.objectContaining({ kind: 'agent-exited' })]) ) - expect(getForegroundProcess).toHaveBeenCalledOnce() }) it('aligns a restored session and pre-response bytes to the provider sequence', async () => { diff --git a/src/main/runtime/runtime-pty-foreground-agent-evidence.test.ts b/src/main/runtime/runtime-pty-foreground-agent-evidence.test.ts new file mode 100644 index 000000000000..1ae2ac8d79f9 --- /dev/null +++ b/src/main/runtime/runtime-pty-foreground-agent-evidence.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it, vi } from 'vitest' +import { RuntimePtyForegroundAgent } from './runtime-pty-foreground-agent' +import type { RuntimePtyController } from './runtime-pty-controller-contract' +import type { RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' + +function setup(remote = false) { + const pty: Pick< + RuntimePtyWorktreeRecord, + 'connectionId' | 'incarnationId' | 'connected' | 'launchAgent' | 'foregroundAgent' + > = { + connectionId: remote ? 'ssh-host' : null, + incarnationId: 'generation-1', + connected: true, + launchAgent: null, + foregroundAgent: 'claude' + } + const confirm = vi.fn<() => Promise>().mockResolvedValue(null) + let controller: RuntimePtyController = { + write: () => true, + kill: () => true, + getForegroundProcess: async () => 'zsh', + confirmForegroundProcess: confirm + } + const touched = vi.fn() + const agent = new RuntimePtyForegroundAgent({ + getController: () => controller, + getPty: () => pty, + touchSnapshot: touched, + finishDelayedSnapshot: vi.fn() + }) + return { + pty, + agent, + confirm, + touched, + replace: (next: RuntimePtyController) => { + controller = next + } + } +} + +describe('foreground identity on unknown observations', () => { + it.each([null, '', 'node.exe', 'other-tool'])( + 'keeps the published Claude identity on %j', + async (process) => { + const h = setup() + h.confirm.mockResolvedValue(process) + await h.agent.refresh('pty-1') + expect(h.pty.foregroundAgent).toBe('claude') + expect(h.touched).not.toHaveBeenCalled() + h.confirm.mockResolvedValue('zsh') + await h.agent.refresh('pty-1') + expect(h.pty.foregroundAgent).toBeNull() + expect(h.touched).toHaveBeenCalledOnce() + } + ) + it('rejects a result for a replaced terminal incarnation', async () => { + const h = setup() + let answer: (value: string) => void = () => {} + h.confirm.mockImplementation( + () => + new Promise((resolve) => { + answer = resolve + }) + ) + const pending = h.agent.refresh('pty-1') + h.pty.incarnationId = 'generation-2' + answer('zsh') + await pending + expect(h.pty.foregroundAgent).toBe('claude') + }) + it.each(['missing', 'unverifiable', 'wrong-incarnation', 'stale', 'live'] as const)( + 'admits only fresh matching SSH evidence: %s', + async (kind) => { + const h = setup(true) + h.replace({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => 'zsh', + inspectProcess: async () => ({ + foregroundProcess: 'zsh', + hasChildProcesses: false, + ...(kind === 'missing' + ? {} + : { + foregroundProcessEvidence: + kind === 'unverifiable' + ? { + verdict: 'unverifiable' as const, + reason: 'inspection_failed', + authorityGeneration: 'host-1', + observationEpoch: 1, + capturedAgeMs: 0, + ptyId: 'pty-1', + ptyIncarnationId: 'generation-1' + } + : { + verdict: 'live' as const, + processName: 'zsh', + fence: { + platform: 'posix' as const, + shellPid: 10, + shellStartTime: '100', + tty: '/dev/pts/1', + foregroundPgid: 10 + }, + authorityGeneration: 'host-1', + observationEpoch: 1, + capturedAgeMs: kind === 'stale' ? 10000 : 0, + ptyId: 'pty-1', + ptyIncarnationId: kind === 'wrong-incarnation' ? 'old' : 'generation-1' + } + }) + }) + }) + await h.agent.refresh('pty-1') + expect(h.pty.foregroundAgent).toBe(kind === 'live' ? null : 'claude') + } + ) +}) diff --git a/src/main/runtime/runtime-pty-foreground-agent.ts b/src/main/runtime/runtime-pty-foreground-agent.ts index 252354c09536..1348ea7a88ee 100644 --- a/src/main/runtime/runtime-pty-foreground-agent.ts +++ b/src/main/runtime/runtime-pty-foreground-agent.ts @@ -1,3 +1,7 @@ +import { foregroundAgentVerdict } from '../../shared/foreground-agent-verdict' +import { admitRemoteForegroundEvidence } from '../../shared/remote-foreground-evidence-admission' +import { isClientOnlyUnverifiableInspection } from '../../shared/terminal-process-inspection' +import { parseAppSshPtyId } from '../../shared/ssh-pty-id' import { recognizeAgentProcess } from '../../shared/agent-process-recognition' import type { RuntimePtyController } from './runtime-pty-controller-contract' import type { @@ -7,9 +11,14 @@ import type { } from './runtime-terminal-contracts' import type { RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' +type ForegroundPty = Pick< + RuntimePtyWorktreeRecord, + 'connectionId' | 'incarnationId' | 'connected' | 'launchAgent' | 'foregroundAgent' +> + type Dependencies = { getController(): RuntimePtyController | null - getPty(ptyId: string): RuntimePtyWorktreeRecord | null + getPty(ptyId: string): ForegroundPty | null touchSnapshot(ptyId: string): void finishDelayedSnapshot(ptyId: string, changed: boolean): void } @@ -38,7 +47,7 @@ export class RuntimePtyForegroundAgent { const unavailable: PtyForegroundProcessRead = { controller, process: null, available: false } let processRead: Promise try { - processRead = Promise.resolve(controller.getForegroundProcess(ptyId)) + processRead = this.readProcess(controller, ptyId) } catch { const entry: PtyForegroundProcessReadEntry = { controller, @@ -50,8 +59,13 @@ export class RuntimePtyForegroundAgent { return entry.promise } let entry: PtyForegroundProcessReadEntry + const incarnationId = this.deps.getPty(ptyId)?.incarnationId const promise = processRead - .then((process) => ({ controller, process, available: true })) + .then((process) => + this.deps.getPty(ptyId)?.incarnationId === incarnationId + ? { controller, process, available: true } + : unavailable + ) .catch(() => unavailable) .finally(() => this.deleteRead(ptyId, entry)) entry = { controller, startedAfterTitleObservation: afterTitle, promise } @@ -123,11 +137,18 @@ export class RuntimePtyForegroundAgent { if (!controller || !pty?.connected || pty.launchAgent) { return false } + const incarnationId = pty.incarnationId const result = await this.read(ptyId, afterTitle) if (!result || result.controller !== this.deps.getController() || !result.available) { return false } - const agent = result.process ? (recognizeAgentProcess(result.process)?.agent ?? null) : null + if (this.deps.getPty(ptyId) !== pty || !pty.connected || pty.incarnationId !== incarnationId) { + return false + } + if (foregroundAgentVerdict(result.process) === 'unverifiable') { + return false + } + const agent = recognizeAgentProcess(result.process)?.agent ?? null if (pty.foregroundAgent === agent) { return false } @@ -136,6 +157,39 @@ export class RuntimePtyForegroundAgent { return true } + private async readProcess( + controller: RuntimePtyController, + ptyId: string + ): Promise { + const pty = this.deps.getPty(ptyId) + if (!pty?.connectionId) { + // Cached display names cannot certify that the agent returned to its shell. + return controller.confirmForegroundProcess + ? controller.confirmForegroundProcess(ptyId) + : controller + .getForegroundProcess(ptyId) + .then((name) => (recognizeAgentProcess(name) ? name : null)) + } + const incarnationId = pty.incarnationId + const started = performance.now() + const inspection = await controller.inspectProcess?.( + ptyId, + incarnationId ? { expectedIncarnationId: incarnationId } : {} + ) + if (!inspection || isClientOnlyUnverifiableInspection(inspection)) { + return null + } + const evidence = admitRemoteForegroundEvidence(inspection.foregroundProcessEvidence, { + expectedPtyId: parseAppSshPtyId(ptyId)?.relayPtyId ?? ptyId, + expectedIncarnationId: incarnationId, + requestStartedAtMonotonic: started, + receivedAtMonotonic: performance.now(), + lastAuthorityGeneration: null, + lastObservationEpoch: -1 + }) + return evidence?.verdict === 'live' ? evidence.processName : null + } + private deleteRead(ptyId: string, entry: PtyForegroundProcessReadEntry): void { if (this.reads.get(ptyId) === entry) { this.reads.delete(ptyId) diff --git a/src/main/runtime/runtime-terminal-agent-status-evidence.test.ts b/src/main/runtime/runtime-terminal-agent-status-evidence.test.ts new file mode 100644 index 000000000000..1925de2348c0 --- /dev/null +++ b/src/main/runtime/runtime-terminal-agent-status-evidence.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it, vi } from 'vitest' +import { RuntimeTerminalAgentStatusQuery } from './runtime-terminal-agent-status-query' +import type { RuntimePtyController } from './runtime-pty-controller-contract' + +describe('status survives an unconfirmed shell observation', () => { + it.each([null, '', 'node.exe', 'claude', 'zsh', 'throw'])( + 'confirms the foreground before retiring status: %j', + async (result) => { + const controller: RuntimePtyController = { + write: () => true, + kill: () => true, + getForegroundProcess: async () => 'zsh', + confirmForegroundProcess: async () => { + if (result === 'throw') { + throw new Error('offline') + } + return result + } + } + const query = new RuntimeTerminalAgentStatusQuery({ + getController: () => controller, + getLivePty: () => null, + getLiveLeaf: () => { + throw new Error('unused') + }, + getPrimaryLeaf: () => null, + getTabTitle: () => null, + getExplicitStatus: () => ({ status: 'idle', updatedAt: Date.now() }), + getLifecycleStatus: () => null, + isRunning: async () => true + }) + vi.spyOn(query, 'getPtyId').mockReturnValue('pty-1') + vi.spyOn(query, 'getSnapshot').mockReturnValue({ + waitText: '', + waitBlockedAt: null, + title: 'zsh', + titleStatus: null, + titleStatusIsLive: true + }) + expect(await query.getStatus('terminal')).toEqual({ + handle: 'terminal', + isRunningAgent: result !== 'zsh', + status: result === 'zsh' ? null : 'idle' + }) + } + ) +}) diff --git a/src/main/runtime/runtime-terminal-agent-status-query.ts b/src/main/runtime/runtime-terminal-agent-status-query.ts index 5a06ea3f688b..ae0034a32932 100644 --- a/src/main/runtime/runtime-terminal-agent-status-query.ts +++ b/src/main/runtime/runtime-terminal-agent-status-query.ts @@ -1,3 +1,4 @@ +import { foregroundAgentVerdict } from '../../shared/foreground-agent-verdict' import { detectAgentStatusFromTitle, isOpenCodeNativeTitle, @@ -5,14 +6,10 @@ import { isShellProcess, type AgentStatus } from '../../shared/agent-detection' -import { recognizeAgentProcess } from '../../shared/agent-process-recognition' import type { RuntimeTerminalAgentStatus } from '../../shared/runtime-types' import type { RuntimePtyController } from './runtime-pty-controller-contract' import type { RuntimeLeafRecord, RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' -import { - terminalTitleBlocksExplicitAgentStatus, - getLatestAgentCandidateTitleInfo -} from './runtime-worktree-status-projection' +import { getLatestAgentCandidateTitleInfo } from './runtime-worktree-status-projection' import { detectTerminalWaitBlockedReason } from './terminal-wait-detection' import { getTerminalState } from './terminal-wait-results' import { buildTerminalWaitText } from './terminal-wait-tail-state' @@ -96,9 +93,7 @@ export class RuntimeTerminalAgentStatusQuery { if (explicitStatus) { // Why: permission titles can linger after hooks report the agent resumed. // Fresh hook state is tighter, but current shell/management evidence wins. - const isRunningAgent = - !terminalTitleBlocksExplicitAgentStatus(terminal.title) && - !(await this.terminalHasShellForegroundProcess(handle, ptyId)) + const isRunningAgent = !(await this.terminalHasShellForegroundProcess(handle, ptyId)) this.assertTerminalAgentStatusPtyBinding(handle, ptyId) return { handle, @@ -241,19 +236,25 @@ export class RuntimeTerminalAgentStatusQuery { return false } const confirmationController = this.deps.getController() - if (!confirmationController?.confirmForegroundProcess) { - return true + if ( + confirmationController !== controller || + !confirmationController?.confirmForegroundProcess + ) { + return false } let confirmedProcess: string | null try { confirmedProcess = await confirmationController.confirmForegroundProcess(ptyId) } catch { this.assertTerminalAgentStatusPtyBinding(handle, ptyId) - return true + return false } this.assertTerminalAgentStatusPtyBinding(handle, ptyId) // Why: hook identity is generic; strong provider evidence only needs to // prove that some recognized agent still owns this exact PTY. - return recognizeAgentProcess(confirmedProcess) === null + return ( + this.deps.getController() === controller && + foregroundAgentVerdict(confirmedProcess) === 'exited' + ) } } diff --git a/src/main/runtime/terminal-agent-exit-evidence.test.ts b/src/main/runtime/terminal-agent-exit-evidence.test.ts new file mode 100644 index 000000000000..85fd3955eee5 --- /dev/null +++ b/src/main/runtime/terminal-agent-exit-evidence.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it, vi } from 'vitest' +import { createTerminalTitleTracker } from '../../shared/terminal-output-side-effects' +import type { TerminalSideEffectFact } from '../../shared/terminal-side-effect-facts' +import { OrcaRuntimeWithSerializeAgentPromptSubmission } from './orca-runtime-serialize-agent-prompt-submission' + +vi.mock('./orca-runtime-controller-knows-pty-is-live', () => ({ + OrcaRuntimeWithControllerKnowsPtyIsLive: class {} +})) + +class ExitHarness extends OrcaRuntimeWithSerializeAgentPromptSubmission { + confirm(): void { + this.confirmPtyAgentExit('pty-1') + } +} + +function setup() { + const runtime = new ExitHarness(null) + const facts: TerminalSideEffectFact[] = [] + const controller = {} + const pty = { connected: true, incarnationId: 'inc-1', lastOscTitleAt: 1, lastAgentStatus: null } + const tracker = createTerminalTitleTracker( + { onAgentExitCandidate: () => runtime.confirm() }, + { initialTitle: 'Claude ready' } + ) + const read = vi.fn().mockResolvedValue({ controller, process: null, available: true }) + Object.assign(runtime, { + ptysById: new Map([['pty-1', pty]]), + handleByPtyId: new Map(), + ptyController: controller, + ptyTitleTrackersByPtyId: new Map([['pty-1', { tracker }]]), + readPtyForegroundProcessFromController: read, + recordTerminalSideEffectFact: (_id: string, fact: TerminalSideEffectFact) => facts.push(fact), + getLeavesForPty: () => [] + }) + return { runtime, facts, controller, pty, tracker, read } +} + +describe('host-confirmed agent exit', () => { + it.each([null, '', 'node.exe', 'other-tool'])( + 'retains Claude after %j and observes a later shell', + async (process) => { + const h = setup() + h.read.mockResolvedValue({ controller: h.controller, process, available: true }) + h.tracker.handleChunk('\x1b]0;workspace\x07') + await Promise.resolve() + expect(h.facts).toEqual([]) + h.read.mockResolvedValue({ controller: h.controller, process: 'zsh', available: true }) + h.tracker.handleChunk('\x1b]0;next workspace\x07') + await Promise.resolve() + expect(h.facts).toEqual([{ kind: 'agent-exited', evidence: 'foreground-shell' }]) + h.tracker.dispose() + } + ) + it.each(['missing', 'unavailable', 'replaced'] as const)( + 'does not publish exit for a %s controller observation', + async (kind) => { + const h = setup() + h.read.mockReturnValue( + kind === 'missing' + ? null + : Promise.resolve({ + controller: kind === 'replaced' ? {} : h.controller, + process: 'zsh', + available: kind !== 'unavailable' + }) + ) + h.tracker.handleChunk('\x1b]0;workspace\x07') + await Promise.resolve() + expect(h.facts).toEqual([]) + h.read.mockResolvedValue({ controller: h.controller, process: 'zsh', available: true }) + h.tracker.handleChunk('\x1b]0;next workspace\x07') + await Promise.resolve() + expect(h.facts).toHaveLength(1) + h.tracker.dispose() + } + ) + it('ignores an observation after terminal incarnation replacement', async () => { + const h = setup() + h.read.mockResolvedValue({ controller: h.controller, process: 'zsh', available: true }) + h.tracker.handleChunk('\x1b]0;workspace\x07') + h.pty.incarnationId = 'inc-2' + await Promise.resolve() + expect(h.facts).toEqual([]) + h.tracker.dispose() + }) +}) diff --git a/src/relay/pty-handler-resize-stale-pty.test.ts b/src/relay/pty-handler-resize-stale-pty.test.ts index d3478bc6825f..3d9c61f4c10a 100644 --- a/src/relay/pty-handler-resize-stale-pty.test.ts +++ b/src/relay/pty-handler-resize-stale-pty.test.ts @@ -166,6 +166,30 @@ describe('PtyHandler.resize against a stale PTY handle', () => { expect(exits[0]?.params).toMatchObject({ id: PTY_1, code: 7 }) }) + it('does not turn a torn-down record into a host exit tombstone', async () => { + const exited = vi.fn() + handler.setExitListener(exited) + const records: unknown = Reflect.get(handler, 'ptys') + if (!(records instanceof Map)) { + throw new Error('missing managed PTYs') + } + const managed: unknown = records.get(PTY_1) + if (!managed || typeof managed !== 'object') { + throw new Error('missing PTY') + } + const incarnationId: unknown = Reflect.get(managed, 'incarnationId') + Reflect.set(managed, 'disposed', true) + await dispatcher.callRequest('pty.listProcesses', {}) + expect(exited).not.toHaveBeenCalled() + expect(dispatcher._notifications.filter((event) => event.method === 'pty.exit')).toEqual([]) + await expect( + dispatcher.callRequest('pty.inspectProcess', { + id: PTY_1, + expectedIncarnationId: incarnationId + }) + ).rejects.toThrow('terminal_gone') + }) + it('still resizes a live PTY, with the clamped geometry', () => { vi.spyOn(ptyShellUtils, 'isProcessAlive').mockReturnValue(true) diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index 286bfdb89a7d..5d7e2c54f211 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -2488,20 +2488,22 @@ export class PtyHandler { * silent (docs/reference/ssh-execution-boundary.md). */ private reapExitedPty(managed: ManagedPty, evidence: 'exited' | 'record-torn-down'): void { - managed.physicalExit?.markExited() this.releaseRelayIngress(managed) this.flushPtyOutput(managed.id) if (evidence === 'exited') { + managed.physicalExit?.markExited() this.publishReapedExit(managed) + this.notifyExitListener(managed) } - this.notifyExitListener(managed) this.agentSessionOwners.release(managed.id) - this.retiredIncarnations.set(managed.id, { - id: managed.id, - code: 0, - incarnationId: managed.incarnationId, - expiresAt: Date.now() + 5_000 - }) + if (evidence === 'exited') { + this.retiredIncarnations.set(managed.id, { + id: managed.id, + code: 0, + incarnationId: managed.incarnationId, + expiresAt: Date.now() + 5_000 + }) + } pruneRetiredPtyIncarnations(this.retiredIncarnations) disposeManagedPty(managed) this.removePty(managed.id) diff --git a/src/renderer/src/components/native-chat/native-chat-leaf-routing.test.ts b/src/renderer/src/components/native-chat/native-chat-leaf-routing.test.ts index 47a4d020a252..7cbcc3b3e88e 100644 --- a/src/renderer/src/components/native-chat/native-chat-leaf-routing.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-leaf-routing.test.ts @@ -209,6 +209,18 @@ describe('resolveNativeChatLeafRoute', () => { ).toEqual({ chatLeafId: 'agent-sibling', exitChat: false }) }) + it('keeps an unbound chat request through unavailable foreground evidence', () => { + expect( + resolveNativeChatLeafRoute({ + isChatViewMode: true, + chatLeafId: null, + activeLeafId: 'starting-agent', + chatLeafStillMounted: false, + activeLeafIsEligible: false + }) + ).toEqual({ chatLeafId: null, exitChat: false }) + }) + it('attaches a tab-level chat request to the eligible active leaf', () => { expect( resolveNativeChatLeafRoute({ diff --git a/src/renderer/src/components/native-chat/native-chat-leaf-routing.ts b/src/renderer/src/components/native-chat/native-chat-leaf-routing.ts index 176194952dcf..72d666e23dc5 100644 --- a/src/renderer/src/components/native-chat/native-chat-leaf-routing.ts +++ b/src/renderer/src/components/native-chat/native-chat-leaf-routing.ts @@ -115,6 +115,9 @@ export function resolveNativeChatLeafRoute(args: { if (args.activeLeafIsEligible && (!confirmedAgentExit || args.activeLeafId !== args.chatLeafId)) { return { chatLeafId: args.activeLeafId, exitChat: false } } + if (!args.chatLeafId && !confirmedAgentExit) { + return { chatLeafId: null, exitChat: false } + } // Why: removing the owning leaf or confirming its agent exited must not leave // the composer targeting a plain shell. Return the tab to terminal mode. return { chatLeafId: null, exitChat: true } diff --git a/src/renderer/src/components/terminal-pane/agent-exit-observation.test.ts b/src/renderer/src/components/terminal-pane/agent-exit-observation.test.ts new file mode 100644 index 000000000000..9427296dabb4 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/agent-exit-observation.test.ts @@ -0,0 +1,79 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createPtyOutputTitleObserver } from './pty-output-title-observer' +import { createPaneForegroundAgentTracker } from './pane-foreground-agent-tracker' +import { + _dispatchTerminalSideEffectBatchForTest, + _resetTerminalSideEffectFactConsumersForTest, + registerTerminalSideEffectFactConsumer +} from './terminal-side-effect-facts-handler' + +afterEach(() => { + vi.useRealTimers() + _resetTerminalSideEffectFactConsumersForTest() +}) + +describe('agent exit observation', () => { + it('keeps a title candidate alive until a host can confirm it', () => { + const candidate = vi.fn() + const observer = createPtyOutputTitleObserver({ + onTitleChange: vi.fn(), + onAgentExitCandidate: candidate, + initialAgentTitle: 'Claude ready' + }) + observer.processObservedTitles(['workspace'], 'none', false) + observer.processObservedTitles(['another workspace'], 'none', false) + expect(candidate).toHaveBeenCalledTimes(2) + observer.reset() + }) + it('treats an old host exit fact as a candidate, not a confirmed exit', () => { + const exited = vi.fn() + const candidate = vi.fn() + registerTerminalSideEffectFactConsumer({ + ptyId: 'pty-1', + callbacks: { onAgentExited: exited, onAgentExitCandidate: candidate } + }) + _dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-1', + seq: 1, + facts: [{ kind: 'agent-exited' }] + }) + expect(exited).not.toHaveBeenCalled() + expect(candidate).toHaveBeenCalledOnce() + _dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-1', + seq: 2, + facts: [{ kind: 'agent-exited', evidence: 'foreground-shell' }] + }) + expect(exited).toHaveBeenCalledOnce() + }) + it.each([null, '', 'node.exe', 'unknown-tool'])( + 'retains identity after ambiguous command completion %j', + async (process) => { + vi.useFakeTimers() + const publish = vi.fn() + const shell = vi.fn() + const read = vi.fn().mockResolvedValue('claude') + const tracker = createPaneForegroundAgentTracker({ + getPtyId: () => 'pty-1', + isTrackablePtyId: () => true, + readForegroundProcess: read, + confirmForegroundProcess: read, + publish, + onConfirmedShellForeground: shell + }) + tracker.onVisiblePtyBound(true) + await vi.advanceTimersByTimeAsync(8000) + publish.mockClear() + read.mockResolvedValue(process) + tracker.onCommandFinished() + await vi.advanceTimersByTimeAsync(8000) + expect(publish).not.toHaveBeenCalled() + expect(shell).not.toHaveBeenCalled() + read.mockResolvedValue('zsh') + tracker.onCommandFinished() + await vi.advanceTimersByTimeAsync(8000) + expect(shell).toHaveBeenCalledOnce() + tracker.dispose() + } + ) +}) diff --git a/src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.test.ts b/src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.test.ts index 50bbb993bce6..f7da36fbcb4f 100644 --- a/src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.test.ts +++ b/src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.test.ts @@ -13,6 +13,7 @@ const SECOND_WRAPPER_RETRY_MS = 6000 describe('createPaneForegroundAgentTracker', () => { const readForegroundProcess = vi.fn<(ptyId: string) => Promise>() const confirmForegroundProcess = vi.fn<(ptyId: string) => Promise>() + const revokeRouting = vi.fn() const publish = vi.fn<(entry: PaneForegroundAgentEntry) => void>() const onConfirmedShellForeground = vi.fn<() => void>() const onCommandFinishedUnavailable = vi.fn<() => void>() @@ -28,6 +29,7 @@ describe('createPaneForegroundAgentTracker', () => { readForegroundProcess, confirmForegroundProcess, publish, + revokeRouting, hasKnownAgentIdentity, onConfirmedShellForeground, onCommandFinishedUnavailable, @@ -45,6 +47,7 @@ describe('createPaneForegroundAgentTracker', () => { confirmForegroundProcess.mockReset() confirmForegroundProcess.mockImplementation((id) => readForegroundProcess(id)) publish.mockReset() + revokeRouting.mockReset() onConfirmedShellForeground.mockReset() onCommandFinishedUnavailable.mockReset() onVisibleForegroundSettled.mockReset() @@ -259,7 +262,8 @@ describe('createPaneForegroundAgentTracker', () => { tracker.onCommandStarted('droid') - expect(publish).toHaveBeenCalledExactlyOnceWith({ agent: null, shellForeground: false }) + expect(publish).not.toHaveBeenCalled() + expect(revokeRouting).toHaveBeenCalledOnce() }) it('revokes stale routing immediately while a known pane confirms a new command', () => { @@ -267,7 +271,8 @@ describe('createPaneForegroundAgentTracker', () => { tracker.onCommandStarted() - expect(publish).toHaveBeenCalledExactlyOnceWith({ agent: null, shellForeground: false }) + expect(publish).not.toHaveBeenCalled() + expect(revokeRouting).toHaveBeenCalledOnce() expect(confirmForegroundProcess).not.toHaveBeenCalled() }) @@ -410,7 +415,7 @@ describe('createPaneForegroundAgentTracker', () => { expect(readForegroundProcess).not.toHaveBeenCalled() }) - it('trusts a rapid ordinary command finish without forcing a fresh scan', async () => { + it('confirms a rapid finish before discarding unidentified agent evidence', async () => { readForegroundProcess.mockResolvedValue('claude') const tracker = makeTracker() @@ -418,11 +423,15 @@ describe('createPaneForegroundAgentTracker', () => { tracker.onCommandFinished() await flushSettleRead(COMMAND_SETTLE_MS) - expect(readForegroundProcess).not.toHaveBeenCalled() - expect(publish).toHaveBeenLastCalledWith({ agent: null, shellForeground: true }) + expect(readForegroundProcess).toHaveBeenCalledExactlyOnceWith('pty-1') + expect(publish).toHaveBeenLastCalledWith({ + agent: 'claude', + shellForeground: false, + routingTrusted: true + }) }) - it('keeps duplicate ordinary 133;D pairs on the no-scan shell path', async () => { + it('confirms duplicate finish markers while identity is still being read', async () => { readForegroundProcess.mockResolvedValue('grok') const tracker = makeTracker() @@ -433,8 +442,12 @@ describe('createPaneForegroundAgentTracker', () => { tracker.onCommandFinished() await flushSettleRead(COMMAND_SETTLE_MS) - expect(readForegroundProcess).not.toHaveBeenCalled() - expect(publish).toHaveBeenLastCalledWith({ agent: null, shellForeground: true }) + expect(readForegroundProcess).toHaveBeenCalledExactlyOnceWith('pty-1') + expect(publish).toHaveBeenLastCalledWith({ + agent: 'grok', + shellForeground: false, + routingTrusted: true + }) }) it('still marks shell without a read for a duplicate D pair on an idle pane', async () => { @@ -460,7 +473,7 @@ describe('createPaneForegroundAgentTracker', () => { tracker.onCommandFinished() await flushSettleRead(COMMAND_SETTLE_MS) - expect(readForegroundProcess).not.toHaveBeenCalled() + expect(readForegroundProcess).toHaveBeenCalledExactlyOnceWith('pty-1') expect(publish).toHaveBeenLastCalledWith({ agent: null, shellForeground: true }) }) @@ -556,6 +569,7 @@ describe('createPaneForegroundAgentTracker', () => { readForegroundProcess, confirmForegroundProcess, publish, + revokeRouting, hasKnownAgentIdentity: () => true }) @@ -642,7 +656,7 @@ describe('createPaneForegroundAgentTracker', () => { }) }) - it('retires known identity when the bounded command-finish ladder remains unavailable', async () => { + it('retains known identity when the bounded command-finish ladder remains unavailable', async () => { readForegroundProcess.mockResolvedValue(null) const tracker = makeTracker(() => true) @@ -655,7 +669,7 @@ describe('createPaneForegroundAgentTracker', () => { await flushSettleRead(SECOND_WRAPPER_RETRY_MS) expect(readForegroundProcess).toHaveBeenCalledTimes(3) - expect(publish).toHaveBeenCalledExactlyOnceWith({ agent: null, shellForeground: false }) + expect(publish).not.toHaveBeenCalled() expect(onConfirmedShellForeground).not.toHaveBeenCalled() expect(onCommandFinishedUnavailable).toHaveBeenCalledTimes(1) }) @@ -712,6 +726,7 @@ describe('createPaneForegroundAgentTracker', () => { readForegroundProcess: remoteRead, confirmForegroundProcess: remoteRead, publish, + revokeRouting, onCommandFinishedUnavailable, onConfirmedShellForeground, onVisibleForegroundSettled @@ -759,6 +774,7 @@ describe('createPaneForegroundAgentTracker', () => { tracker.onCommandStarted() publish.mockReset() + revokeRouting.mockReset() tracker.dispose() await flushSettleRead(COMMAND_SETTLE_MS + WRAPPER_RESOLVE_RETRY_MS) diff --git a/src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.ts b/src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.ts index d831226267d0..3bf5db9e7e60 100644 --- a/src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.ts +++ b/src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.ts @@ -2,7 +2,7 @@ import { isAgentForegroundWrapperProcess, recognizeAgentProcess } from '../../../../shared/agent-process-recognition' -import { isShellProcess } from '../../../../shared/shell-process-detection' +import { foregroundAgentVerdict } from '../../../../shared/foreground-agent-verdict' import type { TuiAgent } from '../../../../shared/tui-agent' import type { PaneForegroundAgentEntry } from '@/store/slices/pane-foreground-agent' import type { RuntimeTerminalProcessInspection } from '@/runtime/runtime-terminal-inspection' @@ -33,6 +33,7 @@ type PaneForegroundAgentTrackerDeps = { isRemotePtyId?: (ptyId: string) => boolean getExpectedIncarnationId?: () => string | null publish: (entry: PaneForegroundAgentEntry) => void + revokeRouting?: () => void /** True when the pane is otherwise known to run an agent (launchAgent, live * hook status). Lets a restored agent pane confirm — rather than trust — a * 133;D before any command-start read has recorded its own evidence. */ @@ -198,19 +199,19 @@ export function createPaneForegroundAgentTracker(deps: PaneForegroundAgentTracke return } if (reason === 'command') { - if (remoteEvidenceVerdict !== null && remoteEvidenceVerdict !== 'live') { - hasAgentExpectation = false + if (!processName?.trim() || remoteEvidenceVerdict === 'unverifiable') { return } hasAgentExpectation = false - deps.publish({ agent: null, shellForeground: false }) + if (!hasForegroundAgentEvidence && !hasKnownAgentEvidence) { + deps.publish({ agent: null, shellForeground: false }) + } return } if (reason === 'visible-pty') { if ( (hasForegroundAgentEvidence || hasKnownAgentEvidence) && - processName !== null && - isShellProcess(processName) + foregroundAgentVerdict(processName) === 'exited' ) { hasForegroundAgentEvidence = false hasKnownAgentEvidence = false @@ -229,16 +230,11 @@ export function createPaneForegroundAgentTracker(deps: PaneForegroundAgentTracke deps.onCommandFinishedUnavailable?.() return } - // Why: client-only unverifiable inspection is not confirmed shell evidence; retire - // stale routing after the bounded D ladder without asserting shell truth. - hasForegroundAgentEvidence = false - hasKnownAgentEvidence = false - hasAgentExpectation = false - deps.publish({ agent: null, shellForeground: false }) deps.onCommandFinishedUnavailable?.() return } - if ((hasForegroundAgentEvidence || hasKnownAgentEvidence) && !isShellProcess(processName)) { + if (foregroundAgentVerdict(processName) !== 'exited') { + deps.onCommandFinishedUnavailable?.() // Why: this read may have replaced a cancelled visible-pty confirmation. // It publishes nothing, so without settling here the capability it was // asked to revalidate would be retained with no read left to clear it. @@ -298,6 +294,7 @@ export function createPaneForegroundAgentTracker(deps: PaneForegroundAgentTracke releaseRetainedCapability(hadReadBeforeCommandStart) return } + deps.revokeRouting?.() const alreadyHasKnownIdentity = deps.hasKnownAgentIdentity?.() === true hasAgentExpectation = expectedAgent !== null if (alreadyHasKnownIdentity) { @@ -307,7 +304,11 @@ export function createPaneForegroundAgentTracker(deps: PaneForegroundAgentTracke // Launch/hook identity remains only an expectation until fresh evidence. // Remote marker bytes are turn boundaries only; do not mutate a remote // identity from an OSC stream before the host evidence read completes. - if (deps.isRemotePtyId?.(ptyId) !== true) { + if ( + deps.isRemotePtyId?.(ptyId) !== true && + !hasForegroundAgentEvidence && + !hasKnownAgentEvidence + ) { deps.publish({ agent: null, shellForeground: false }) } scheduleRead(COMMAND_SETTLE_MS, 0, 'command') @@ -336,7 +337,12 @@ export function createPaneForegroundAgentTracker(deps: PaneForegroundAgentTracke // Why: trust the 133;D and mark shell without an RPC only when nothing hints // at an agent — no prior agent evidence, no launch/hook identity, and no // identity read racing this finish. - if (!hasForegroundAgentEvidence && !hasKnownAgentEvidence && !hasAgentExpectation) { + if ( + !hasForegroundAgentEvidence && + !hasKnownAgentEvidence && + !hasAgentExpectation && + !hadReadBeforeCommandFinish + ) { if (deps.isRemotePtyId?.(ptyId) !== true) { deps.publish({ agent: null, shellForeground: true }) } diff --git a/src/renderer/src/components/terminal-pane/pane-foreground-process-reader.ts b/src/renderer/src/components/terminal-pane/pane-foreground-process-reader.ts index fac54efc967e..29476c140c02 100644 --- a/src/renderer/src/components/terminal-pane/pane-foreground-process-reader.ts +++ b/src/renderer/src/components/terminal-pane/pane-foreground-process-reader.ts @@ -46,8 +46,8 @@ export function createPaneForegroundProcessReader(deps: { // adapter that lost its provider while the pane stayed mounted. remoteEvidenceVerdict = 'unverifiable' } else if (typeof inspection === 'string' || inspection === null) { - processName = inspection - remoteEvidenceVerdict = remote ? 'unverifiable' : null + processName = remote ? null : inspection + remoteEvidenceVerdict = remote || !inspection?.trim() ? 'unverifiable' : null } else if (remote) { const admitted = admitRemoteForegroundEvidence(inspection.foregroundProcessEvidence, { expectedPtyId: diff --git a/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.ts b/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.ts index f3fa629df7ad..c79023f48735 100644 --- a/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.ts +++ b/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.ts @@ -179,7 +179,8 @@ export function startParkedTerminalByteWatcher( } }, onAgentExited: (): void => { - // Why: title reverting to a plain shell means the agent session ended; clear the countdown so it doesn't survive in the sidebar while parked. + commandStatusPolicy.onAgentExited() + // Only a host-confirmed exit retires the parked session. useAppStore.getState().setCacheTimerStartedAt(paneKey, null) } } diff --git a/src/renderer/src/components/terminal-pane/parked-terminal-command-status.test.ts b/src/renderer/src/components/terminal-pane/parked-terminal-command-status.test.ts index 3a3343003417..06390a88b4d4 100644 --- a/src/renderer/src/components/terminal-pane/parked-terminal-command-status.test.ts +++ b/src/renderer/src/components/terminal-pane/parked-terminal-command-status.test.ts @@ -369,7 +369,7 @@ describe('createParkedTerminalCommandStatusPolicy', () => { expect(dispatchTerminalCommandFinishedEvent).toHaveBeenCalledWith(WORKTREE_ID, 0) }) - it('drops a same-turn status row on command finished for SSH PTYs only', async () => { + it('preserves local and SSH status until a confirmed agent exit', async () => { mockStoreState.agentStatusByPaneKey[PANE_KEY] = makeStatusEntry() const local = await createPolicy(PTY_ID_LOCAL) local.onCommandFinished(0) @@ -380,15 +380,18 @@ describe('createParkedTerminalCommandStatusPolicy', () => { const ssh = await createPolicy(PTY_ID_SSH) ssh.onCommandFinished(0) + expect(mockStoreState.dropAgentStatus).not.toHaveBeenCalled() + ssh.onAgentExited() expect(mockStoreState.dropAgentStatus).toHaveBeenCalledWith(PANE_KEY) ssh.dispose() }) - it('clears the launch registry on SSH command finished when no status row exists', async () => { + it('clears the launch registry only on a confirmed SSH exit when no status row exists', async () => { const ssh = await createPolicy(PTY_ID_SSH) ssh.onCommandFinished(0) - + expect(mockStoreState.clearAgentLaunchConfig).not.toHaveBeenCalled() + ssh.onAgentExited() expect(mockStoreState.clearAgentLaunchConfig).toHaveBeenCalledWith(PANE_KEY) expect(mockStoreState.dropAgentStatus).not.toHaveBeenCalled() ssh.dispose() diff --git a/src/renderer/src/components/terminal-pane/parked-terminal-command-status.ts b/src/renderer/src/components/terminal-pane/parked-terminal-command-status.ts index 6c2f20b48910..6bab0d33a35b 100644 --- a/src/renderer/src/components/terminal-pane/parked-terminal-command-status.ts +++ b/src/renderer/src/components/terminal-pane/parked-terminal-command-status.ts @@ -6,7 +6,6 @@ */ import type { AgentStatusEntry } from '../../../../shared/agent-status-types' import { resolvePaneAgentOwner } from '../../../../shared/pane-agent-owner' -import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id' import { dispatchTerminalCommandFinishedEvent } from '@/hooks/terminal-command-finished-event' import { resolveLiveAgentStatusConnectionRouting } from '@/lib/agent-status-connection-ownership' import { getConnectionIdFromState } from '@/lib/connection-owner-resolution' @@ -21,6 +20,7 @@ import { canCommandCodeOutputOwnPane } from './command-code-output-ownership' export type ParkedTerminalCommandStatusPolicy = { onCommandFinished: (bestEffortExitCode: number | null) => void + onAgentExited: () => void onCommandCodeWorking: (prompt: string) => void onCommandCodeDone: (prompt: string) => void dispose: () => void @@ -151,6 +151,11 @@ export function createParkedTerminalCommandStatusPolicy(options: { ) return { + onAgentExited: (): void => { + if (!disposed) { + dropCommandFinishedStatusIfSameTurn(useAppStore.getState().agentStatusByPaneKey[paneKey]) + } + }, onCommandFinished: (bestEffortExitCode: number | null): void => { if (disposed) { return @@ -158,14 +163,6 @@ export function createParkedTerminalCommandStatusPolicy(options: { // Why: the finished command may have moved HEAD or the index (an agent running // `git checkout` in a parked worktree); nudge git UI now instead of waiting for a poll. dispatchTerminalCommandFinishedEvent(worktreeId, bestEffortExitCode) - // Why: drop the same-turn status row only for SSH PTYs — exact parity with the mounted - // path, whose foreground tracker refuses SSH ids and drops un-probed. Local PTYs need - // pty-connection's process-confirm ladder to tell a leaked nested-shell 133;D from a - // real agent exit, so their drop stays with the mounted pane. - if (parseAppSshPtyId(ptyId) === null) { - return - } - dropCommandFinishedStatusIfSameTurn(useAppStore.getState().agentStatusByPaneKey[paneKey]) }, // Port of pty-connection's seedCommandCodeOutputWorkingStatus (store-level only). diff --git a/src/renderer/src/components/terminal-pane/pty-connection/agent-idle-working-handlers.ts b/src/renderer/src/components/terminal-pane/pty-connection/agent-idle-working-handlers.ts index 3e379b2d4f81..38018546bc13 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/agent-idle-working-handlers.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/agent-idle-working-handlers.ts @@ -31,16 +31,16 @@ export function installAgentIdleWorkingHandlers(session: ConnectPanePtySession): session.scheduleTerminalBellNotification() } } + session.onAgentExitCandidate = (): void => { + session.paneForegroundAgentTracker?.onVisiblePtyBound(true) + } session.onAgentExited = (): void => { - // Why: eligibility can disappear transiently during reconnect, but a - // confirmed shell-title transition is authoritative for native-chat exit. + // Only host-confirmed shell evidence reaches this callback. session.deps.onAgentExitedRef.current(session.pane.leafId) session.clearSuppressedTitleSideEffects() session.clearCommandInferredPaneAgent() session.requestKnownWindowsShiftEnterReconfirmation() - // Why: when the terminal title reverts to a plain shell (e.g., "bash", "zsh"), - // the agent has exited. Clear any running cache timer so the sidebar doesn't - // show a stale countdown for a tab that no longer has an active Claude session. + // Confirmed exit retires the previous session's cache timer. session.deps.setCacheTimerStartedAt(session.cacheKey, null) session.clearTitleOnlyInterruptTimer() // Why: title reversion alone is not process death. The process/PTY tracker diff --git a/src/renderer/src/components/terminal-pane/pty-connection/pane-agent-identity.ts b/src/renderer/src/components/terminal-pane/pty-connection/pane-agent-identity.ts index 2c86932c499d..3e23536805c0 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 @@ -63,7 +63,8 @@ export function installPaneAgentIdentity(session: ConnectPanePtySession): void { return ( Boolean(state.paneForegroundAgentByPaneKey[session.cacheKey]?.agent) || session.paneHasLiveHookAgentIcon(state) || - isTuiAgent(registeredLaunchAgent) + isTuiAgent(registeredLaunchAgent) || + Boolean(session.commandInferredPaneAgent) ) } // Why: a plain `codex`/`grok` sets its OSC title and the shell never repaints @@ -122,8 +123,9 @@ export function installPaneAgentIdentity(session: ConnectPanePtySession): void { const reconcile = session.deferredConfirmedShellReconcile session.deferredCommandFinishedStatusDrop = null session.deferredConfirmedShellReconcile = null - dropStatus?.() if (options.confirmedShell) { + session.clearCommandInferredPaneAgentAfterPtySideEffects() + dropStatus?.() reconcile?.() } } @@ -167,8 +169,20 @@ export function installPaneAgentIdentity(session: ConnectPanePtySession): void { isRemotePtyId, getExpectedIncarnationId: () => session.remotePtyIncarnationId ?? null, publish: (entry) => useAppStore.getState().setPaneForegroundAgent(session.cacheKey, entry), + revokeRouting: () => { + const state = useAppStore.getState() + const foreground = state.paneForegroundAgentByPaneKey[session.cacheKey] + if (foreground) { + state.setPaneForegroundAgent(session.cacheKey, { + agent: foreground.agent, + shellForeground: false, + routingRevoked: true + }) + } + }, hasKnownAgentIdentity: session.paneHasKnownAgentIdentity, onConfirmedShellForeground: (reason) => { + session.onAgentExited?.() // Why: a confirmed local shell proves any hibernation record for this pane is stale; // otherwise the tab resolver can repaint the exited agent from sleeping occupancy. const state = useAppStore.getState() @@ -212,7 +226,6 @@ export function installPaneAgentIdentity(session: ConnectPanePtySession): void { // routing both through this handler keeps the drop/interrupt semantics // identical across authority modes. session.handleCommandFinished = (bestEffortExitCode: number | null): void => { - session.clearCommandInferredPaneAgentAfterPtySideEffects() session.visibleForegroundSamplePending = false const shouldDeferStatusDrop = session.paneForegroundAgentTracker.onCommandFinished() // Why: the finished command may have moved HEAD or the index (e.g. diff --git a/src/renderer/src/components/terminal-pane/pty-connection/pty-input-recovery.ts b/src/renderer/src/components/terminal-pane/pty-connection/pty-input-recovery.ts index 4533a02cd21d..dad3c41c26ff 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/pty-input-recovery.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/pty-input-recovery.ts @@ -128,7 +128,7 @@ export function installPtyInputRecovery(session: ConnectPanePtySession): void { onBell: session.onBell, onAgentBecameIdle: session.onAgentBecameIdle, onAgentBecameWorking: session.onAgentBecameWorking, - onAgentExited: session.onAgentExited + onAgentExitCandidate: session.onAgentExitCandidate }), // Why: local IPC terminals are now model-owned in main: OrcaRuntimeService // parses OSC 9999 before renderer delivery and forwards through the hook diff --git a/src/renderer/src/components/terminal-pane/pty-connection/terminal-keydown-fit.ts b/src/renderer/src/components/terminal-pane/pty-connection/terminal-keydown-fit.ts index f454b247e4f1..febdad7c5f0d 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/terminal-keydown-fit.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/terminal-keydown-fit.ts @@ -129,6 +129,7 @@ export function installTerminalKeydownFit(session: ConnectPanePtySession): void onAgentBecameIdle: session.onAgentBecameIdle, onAgentBecameWorking: session.onAgentBecameWorking, onAgentExited: session.onAgentExited, + onAgentExitCandidate: session.onAgentExitCandidate, onCommandFinished: session.handleCommandFinished, onPrLink: (link) => useAppStore diff --git a/src/renderer/src/components/terminal-pane/pty-output-processor.ts b/src/renderer/src/components/terminal-pane/pty-output-processor.ts index d80f8e1cb7fb..27cd8d17a123 100644 --- a/src/renderer/src/components/terminal-pane/pty-output-processor.ts +++ b/src/renderer/src/components/terminal-pane/pty-output-processor.ts @@ -23,7 +23,7 @@ export type PtyOutputProcessorOptions = Pick< | 'onBell' | 'onAgentBecameIdle' | 'onAgentBecameWorking' - | 'onAgentExited' + | 'onAgentExitCandidate' | 'onAgentStatus' > & { initialAgentTitle?: string @@ -66,7 +66,7 @@ export function createPtyOutputProcessor({ onBell, onAgentBecameIdle, onAgentBecameWorking, - onAgentExited, + onAgentExitCandidate, onAgentStatus, initialAgentTitle }: PtyOutputProcessorOptions) { @@ -77,7 +77,7 @@ export function createPtyOutputProcessor({ onTitleChange, onAgentBecameIdle, onAgentBecameWorking, - onAgentExited, + onAgentExitCandidate, initialAgentTitle }) const sideEffects = createPtyOutputSideEffectQueue({ diff --git a/src/renderer/src/components/terminal-pane/pty-output-title-observer.ts b/src/renderer/src/components/terminal-pane/pty-output-title-observer.ts index a635b2bdcddb..13e8c9e9640b 100644 --- a/src/renderer/src/components/terminal-pane/pty-output-title-observer.ts +++ b/src/renderer/src/components/terminal-pane/pty-output-title-observer.ts @@ -13,7 +13,7 @@ const STALE_TITLE_TIMEOUT = 3_000 type PtyOutputTitleObserverOptions = Pick< IpcPtyTransportOptions, - 'onTitleChange' | 'onAgentBecameIdle' | 'onAgentBecameWorking' | 'onAgentExited' + 'onTitleChange' | 'onAgentBecameIdle' | 'onAgentBecameWorking' | 'onAgentExitCandidate' > & { initialAgentTitle?: string } @@ -34,7 +34,7 @@ export function createPtyOutputTitleObserver({ onTitleChange, onAgentBecameIdle, onAgentBecameWorking, - onAgentExited, + onAgentExitCandidate, initialAgentTitle }: PtyOutputTitleObserverOptions): PtyOutputTitleObserver { let lastEmittedTitle: string | null = @@ -45,11 +45,14 @@ export function createPtyOutputTitleObserver({ ? initialAgentTitle : undefined const agentTracker = - onAgentBecameIdle || onAgentBecameWorking || onAgentExited + onAgentBecameIdle || onAgentBecameWorking || onAgentExitCandidate ? createAgentStatusTracker( (title) => onAgentBecameIdle?.(title), onAgentBecameWorking, - onAgentExited, + () => { + agentTracker?.restoreLastExit() + onAgentExitCandidate?.() + }, initialTrackerTitle ) : null diff --git a/src/renderer/src/components/terminal-pane/pty-transport-types.ts b/src/renderer/src/components/terminal-pane/pty-transport-types.ts index dce25fd547cc..d8180e9bb70e 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-types.ts @@ -290,5 +290,6 @@ export type IpcPtyTransportOptions = { onAgentBecameIdle?: (title: string) => void onAgentBecameWorking?: () => void onAgentExited?: () => void + onAgentExitCandidate?: () => void onAgentStatus?: (payload: ParsedAgentStatusPayload) => void } diff --git a/src/renderer/src/components/terminal-pane/pty-transport.ts b/src/renderer/src/components/terminal-pane/pty-transport.ts index fd26a7324b5d..287e76716a0f 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.ts @@ -40,7 +40,7 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra onBell, onAgentBecameIdle, onAgentBecameWorking, - onAgentExited, + onAgentExitCandidate, onAgentStatus } = opts let connected = false @@ -81,7 +81,7 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra } }, onAgentBecameWorking, - onAgentExited, + onAgentExitCandidate, onAgentStatus }) const handlers = createIpcPtySessionHandlers({ diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts index cb78db75e9e3..a343765aa0fe 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts @@ -169,7 +169,7 @@ export function createRemoteRuntimePtyTransport( onBell, onAgentBecameIdle, onAgentBecameWorking, - onAgentExited, + onAgentExitCandidate, onAgentStatus } = opts let connected = false @@ -414,7 +414,7 @@ export function createRemoteRuntimePtyTransport( onBell, onAgentBecameIdle, onAgentBecameWorking, - onAgentExited, + onAgentExitCandidate, onAgentStatus }) const shutdownDataHandler = ( diff --git a/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts b/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts index 527fbb46b95c..730ac689d737 100644 --- a/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts +++ b/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts @@ -77,6 +77,7 @@ export type TerminalSideEffectFactConsumerCallbacks = { onAgentBecameIdle?: (title: string, meta?: { staleWorkingTitleClear?: boolean }) => void onAgentBecameWorking?: () => void onAgentExited?: () => void + onAgentExitCandidate?: () => void /** OSC 133;D — same policy hook the byte-mode commandLifecycle drove * (stale agent-status row drop + interrupt-inference coordination). */ onCommandFinished?: (bestEffortExitCode: number | null) => void @@ -130,7 +131,11 @@ function applyLiveFact(entry: ConsumerEntry, fact: TerminalSideEffectFact, seq: ) return case 'agent-exited': - entry.callbacks.onAgentExited?.() + if (fact.evidence === 'foreground-shell') { + entry.callbacks.onAgentExited?.() + } else { + entry.callbacks.onAgentExitCandidate?.() + } return case 'command-finished': entry.callbacks.onCommandFinished?.(fact.exitCode) diff --git a/src/renderer/src/components/terminal-pane/terminal-title-tracker-parity.test.ts b/src/renderer/src/components/terminal-pane/terminal-title-tracker-parity.test.ts index dac6bbaf3268..a1738c5f9e7c 100644 --- a/src/renderer/src/components/terminal-pane/terminal-title-tracker-parity.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-title-tracker-parity.test.ts @@ -40,7 +40,7 @@ function createRendererPath(initialTitle?: string, deferDrain = false): TitleFac onTitleChange: (normalized, raw) => events.push({ kind: 'title', normalized, raw }), onAgentBecameWorking: () => events.push({ kind: 'became-working' }), onAgentBecameIdle: (title) => events.push({ kind: 'became-idle', title }), - onAgentExited: () => events.push({ kind: 'agent-exited' }), + onAgentExitCandidate: () => events.push({ kind: 'agent-exited' }), onBell: () => events.push({ kind: 'bell' }), initialAgentTitle: initialTitle }) @@ -70,7 +70,7 @@ function createMainPath(initialTitle?: string): TitleFactPath { onTitle: (normalized, raw) => events.push({ kind: 'title', normalized, raw }), onAgentBecameWorking: () => events.push({ kind: 'became-working' }), onAgentBecameIdle: (title) => events.push({ kind: 'became-idle', title }), - onAgentExited: () => events.push({ kind: 'agent-exited' }), + onAgentExitCandidate: () => events.push({ kind: 'agent-exited' }), onBell: () => events.push({ kind: 'bell' }) }, initialTitle !== undefined ? { initialTitle } : undefined diff --git a/src/shared/agent-title-status.ts b/src/shared/agent-title-status.ts index 7cd7a2c292cd..c6fc742945a1 100644 --- a/src/shared/agent-title-status.ts +++ b/src/shared/agent-title-status.ts @@ -70,7 +70,7 @@ export function clearWorkingIndicators(title: string): string { export function createAgentStatusTracker( onBecameIdle: (title: string) => void, onBecameWorking?: () => void, - onAgentExited?: () => void, + onAgentExitCandidate?: () => void, initialTitle?: string ): { handleTitle: (title: string) => void @@ -96,12 +96,11 @@ export function createAgentStatusTracker( if (lastStatus !== 'working' && newStatus === 'working') { onBecameWorking?.() } - // Why: reverting to a plain shell prompt after idle/permission means the - // agent exited; while working it can just be a transient internal title. + // A neutral title requests a process check; it never proves the agent exited. if (lastStatus !== null && lastStatus !== 'working' && newStatus === null) { restorableExitStatus = lastStatus lastStatus = null - onAgentExited?.() + onAgentExitCandidate?.() } if (newStatus !== null) { lastStatus = newStatus diff --git a/src/shared/foreground-agent-verdict.ts b/src/shared/foreground-agent-verdict.ts new file mode 100644 index 000000000000..ee2e55a6eb64 --- /dev/null +++ b/src/shared/foreground-agent-verdict.ts @@ -0,0 +1,15 @@ +import { recognizeAgentProcess } from './agent-process-recognition' +import { isShellProcess } from './shell-process-detection' + +/** A process name can disprove agent ownership only when it positively names a shell. */ +export function foregroundAgentVerdict( + processName: string | null | undefined +): 'live' | 'unverifiable' | 'exited' { + if (!processName?.trim()) { + return 'unverifiable' + } + if (recognizeAgentProcess(processName)) { + return 'live' + } + return isShellProcess(processName) ? 'exited' : 'unverifiable' +} diff --git a/src/shared/terminal-output-side-effects.ts b/src/shared/terminal-output-side-effects.ts index 20128c632349..7c35a1a96763 100644 --- a/src/shared/terminal-output-side-effects.ts +++ b/src/shared/terminal-output-side-effects.ts @@ -55,7 +55,7 @@ export type TerminalTitleTrackerCallbacks = { onTitle?: (normalizedTitle: string, rawTitle: string, meta?: TerminalTitleFactMeta) => void onAgentBecameIdle?: (title: string, meta?: TerminalTitleFactMeta) => void onAgentBecameWorking?: () => void - onAgentExited?: () => void + onAgentExitCandidate?: () => void /** Fired once per chunk containing a real BEL (OSC-aware, cross-chunk escape state), after the chunk's titles (renderer drain order). */ onBell?: () => void /** @@ -116,7 +116,7 @@ export function createTerminalTitleTracker( onTitle, onAgentBecameIdle, onAgentBecameWorking, - onAgentExited, + onAgentExitCandidate, onBell, onCommandFinished, onPrLink, @@ -143,7 +143,7 @@ export function createTerminalTitleTracker( ? options.initialTitle : undefined const agentTracker = - onAgentBecameIdle || onAgentBecameWorking || onAgentExited + onAgentBecameIdle || onAgentBecameWorking || onAgentExitCandidate ? createAgentStatusTracker( (title) => { onAgentBecameIdle?.( @@ -152,7 +152,7 @@ export function createTerminalTitleTracker( ) }, onAgentBecameWorking, - onAgentExited, + onAgentExitCandidate, initialAgentStatusTitle ) : null diff --git a/src/shared/terminal-side-effect-facts.ts b/src/shared/terminal-side-effect-facts.ts index 47c416aff5d2..78699e485ba3 100644 --- a/src/shared/terminal-side-effect-facts.ts +++ b/src/shared/terminal-side-effect-facts.ts @@ -18,7 +18,7 @@ export type TerminalSideEffectFact = | { kind: 'bell' } | { kind: 'agent-working' } | { kind: 'agent-idle'; title: string; staleWorkingTitleClear?: boolean } - | { kind: 'agent-exited' } + | { kind: 'agent-exited'; evidence?: 'foreground-shell' } /** OSC 133;D — foreground shell command exited (exit code best-effort). */ | { kind: 'command-finished'; exitCode: number | null } /** Carries the parsed link so the renderer store consumer never re-parses From f8b7e59d907eb98d923b5f2a41ff85e0ee53f3cf Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:58:19 -0700 Subject: [PATCH 02/31] fix(terminal): keep ordinary fast commands off the confirmed-exit path A rapid 133;C->133;D pair on a pane with no agent identity forced a foreground read, and a shell result then ran the confirmed-agent-exit routine (xterm mode reset, chat exit callback) after every short shell command. With no identity there is nothing to exit; typed agent commands are already covered by command-inferred identity. --- .../pane-foreground-agent-tracker.test.ts | 24 +++++++------------ .../pane-foreground-agent-tracker.ts | 7 +----- 2 files changed, 10 insertions(+), 21 deletions(-) diff --git a/src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.test.ts b/src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.test.ts index f7da36fbcb4f..f31a913540ed 100644 --- a/src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.test.ts +++ b/src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.test.ts @@ -415,7 +415,7 @@ describe('createPaneForegroundAgentTracker', () => { expect(readForegroundProcess).not.toHaveBeenCalled() }) - it('confirms a rapid finish before discarding unidentified agent evidence', async () => { + it('trusts a rapid ordinary command finish without forcing a fresh scan', async () => { readForegroundProcess.mockResolvedValue('claude') const tracker = makeTracker() @@ -423,15 +423,11 @@ describe('createPaneForegroundAgentTracker', () => { tracker.onCommandFinished() await flushSettleRead(COMMAND_SETTLE_MS) - expect(readForegroundProcess).toHaveBeenCalledExactlyOnceWith('pty-1') - expect(publish).toHaveBeenLastCalledWith({ - agent: 'claude', - shellForeground: false, - routingTrusted: true - }) + expect(readForegroundProcess).not.toHaveBeenCalled() + expect(publish).toHaveBeenLastCalledWith({ agent: null, shellForeground: true }) }) - it('confirms duplicate finish markers while identity is still being read', async () => { + it('keeps duplicate ordinary 133;D pairs on the no-scan shell path', async () => { readForegroundProcess.mockResolvedValue('grok') const tracker = makeTracker() @@ -442,12 +438,8 @@ describe('createPaneForegroundAgentTracker', () => { tracker.onCommandFinished() await flushSettleRead(COMMAND_SETTLE_MS) - expect(readForegroundProcess).toHaveBeenCalledExactlyOnceWith('pty-1') - expect(publish).toHaveBeenLastCalledWith({ - agent: 'grok', - shellForeground: false, - routingTrusted: true - }) + expect(readForegroundProcess).not.toHaveBeenCalled() + expect(publish).toHaveBeenLastCalledWith({ agent: null, shellForeground: true }) }) it('still marks shell without a read for a duplicate D pair on an idle pane', async () => { @@ -473,8 +465,10 @@ describe('createPaneForegroundAgentTracker', () => { tracker.onCommandFinished() await flushSettleRead(COMMAND_SETTLE_MS) - expect(readForegroundProcess).toHaveBeenCalledExactlyOnceWith('pty-1') + expect(readForegroundProcess).not.toHaveBeenCalled() expect(publish).toHaveBeenLastCalledWith({ agent: null, shellForeground: true }) + // Why: an ordinary fast command is not an agent exit; the exit routine resets xterm modes. + expect(onConfirmedShellForeground).not.toHaveBeenCalled() }) it('confirms the foreground before clearing a pane an agent has owned', async () => { diff --git a/src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.ts b/src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.ts index 3bf5db9e7e60..cebda377b387 100644 --- a/src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.ts +++ b/src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.ts @@ -337,12 +337,7 @@ export function createPaneForegroundAgentTracker(deps: PaneForegroundAgentTracke // Why: trust the 133;D and mark shell without an RPC only when nothing hints // at an agent — no prior agent evidence, no launch/hook identity, and no // identity read racing this finish. - if ( - !hasForegroundAgentEvidence && - !hasKnownAgentEvidence && - !hasAgentExpectation && - !hadReadBeforeCommandFinish - ) { + if (!hasForegroundAgentEvidence && !hasKnownAgentEvidence && !hasAgentExpectation) { if (deps.isRemotePtyId?.(ptyId) !== true) { deps.publish({ agent: null, shellForeground: true }) } From dcb2c6d86d76b557251b81eb32f6a0e805834398 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:58:45 -0700 Subject: [PATCH 03/31] perf(runtime): keep a live local agent on the cached foreground read Every local foreground refresh (each title status transition) had moved to the fresh confirmation read, which bypasses the cached-agent revalidation and forces a full process scan on Windows. A cached read that still recognizes the agent is live evidence; only a non-agent result needs the fresh read before it can certify an exit. --- .../runtime-pty-foreground-agent-evidence.test.ts | 14 ++++++++++++++ src/main/runtime/runtime-pty-foreground-agent.ts | 10 +++++----- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/src/main/runtime/runtime-pty-foreground-agent-evidence.test.ts b/src/main/runtime/runtime-pty-foreground-agent-evidence.test.ts index 1ae2ac8d79f9..419b55381304 100644 --- a/src/main/runtime/runtime-pty-foreground-agent-evidence.test.ts +++ b/src/main/runtime/runtime-pty-foreground-agent-evidence.test.ts @@ -54,6 +54,19 @@ describe('foreground identity on unknown observations', () => { expect(h.touched).toHaveBeenCalledOnce() } ) + it('keeps a live local agent on the cached read without a fresh scan', async () => { + const h = setup() + h.pty.foregroundAgent = null + h.replace({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => 'claude', + confirmForegroundProcess: h.confirm + }) + await h.agent.refresh('pty-1') + expect(h.pty.foregroundAgent).toBe('claude') + expect(h.confirm).not.toHaveBeenCalled() + }) it('rejects a result for a replaced terminal incarnation', async () => { const h = setup() let answer: (value: string) => void = () => {} @@ -64,6 +77,7 @@ describe('foreground identity on unknown observations', () => { }) ) const pending = h.agent.refresh('pty-1') + await vi.waitFor(() => expect(h.confirm).toHaveBeenCalled()) h.pty.incarnationId = 'generation-2' answer('zsh') await pending diff --git a/src/main/runtime/runtime-pty-foreground-agent.ts b/src/main/runtime/runtime-pty-foreground-agent.ts index 1348ea7a88ee..97df8755c392 100644 --- a/src/main/runtime/runtime-pty-foreground-agent.ts +++ b/src/main/runtime/runtime-pty-foreground-agent.ts @@ -163,12 +163,12 @@ export class RuntimePtyForegroundAgent { ): Promise { const pty = this.deps.getPty(ptyId) if (!pty?.connectionId) { + const cached = await controller.getForegroundProcess(ptyId) + if (recognizeAgentProcess(cached)) { + return cached + } // Cached display names cannot certify that the agent returned to its shell. - return controller.confirmForegroundProcess - ? controller.confirmForegroundProcess(ptyId) - : controller - .getForegroundProcess(ptyId) - .then((name) => (recognizeAgentProcess(name) ? name : null)) + return controller.confirmForegroundProcess ? controller.confirmForegroundProcess(ptyId) : null } const incarnationId = pty.incarnationId const started = performance.now() From 265d4d7fde2c8e1624e3c01970d0d5f1466474c0 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:59:22 -0700 Subject: [PATCH 04/31] fix(terminal): confirm title exit candidates only while a pane holds an agent The renderer title tracker re-arms after every candidate, so byte-parser panes (remote runtime, main-authority kill switch) and old-host facts paid a foreground read ladder on every neutral title, including plain shell prompts long after the agent was gone. Skip the read when no agent identity remains. Also updates the transport callback list to the renamed candidate option. --- ...nection-main-side-effect-authority.test.ts | 40 ++++++++++++++++++- .../agent-idle-working-handlers.ts | 5 ++- 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-main-side-effect-authority.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-main-side-effect-authority.test.ts index d1b618e03308..e3226206fa86 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-main-side-effect-authority.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-main-side-effect-authority.test.ts @@ -165,7 +165,7 @@ describe('connectPanePty', () => { 'onBell', 'onAgentBecameIdle', 'onAgentBecameWorking', - 'onAgentExited' + 'onAgentExitCandidate' ] as const function enableMainAuthority(): void { @@ -678,4 +678,42 @@ describe('connectPanePty', () => { expect(deps.markWorktreeUnread).toHaveBeenCalledTimes(1) }) }) + + it('confirms byte-parser exit candidates only while the pane holds an agent identity', async () => { + vi.useFakeTimers() + mockStoreState.settings = { ...mockStoreState.settings, terminalMainSideEffectAuthority: false } + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + connectPanePty( + createPane(1) as never, + createManager(1) as never, + createDeps({ isVisibleRef: { current: false } }) as never + ) + await vi.advanceTimersByTimeAsync(20) + await flushAsyncTicks() + const onAgentExitCandidate = createdTransportOptions[0]?.onAgentExitCandidate + if (typeof onAgentExitCandidate !== 'function') { + throw new Error('Expected onAgentExitCandidate to be registered') + } + const ptyId = transport.getPtyId() + const reads = (): number => + [window.api.pty.getForegroundProcess, window.api.pty.confirmForegroundProcess] + .flatMap((read) => vi.mocked(read).mock.calls) + .filter(([id]) => id === ptyId).length + const baseline = reads() + + // Why: the renderer title tracker re-arms on every neutral title, so a plain shell must not pay a read per prompt. + onAgentExitCandidate() + await vi.advanceTimersByTimeAsync(400) + expect(reads()).toBe(baseline) + + mockStoreState.paneForegroundAgentByPaneKey[makePaneKey('tab-1', LEAF_1)] = { + agent: 'claude', + shellForeground: false + } + onAgentExitCandidate() + await vi.advanceTimersByTimeAsync(400) + expect(reads()).toBeGreaterThan(baseline) + }) }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/agent-idle-working-handlers.ts b/src/renderer/src/components/terminal-pane/pty-connection/agent-idle-working-handlers.ts index 38018546bc13..0ec5572193b1 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/agent-idle-working-handlers.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/agent-idle-working-handlers.ts @@ -32,7 +32,10 @@ export function installAgentIdleWorkingHandlers(session: ConnectPanePtySession): } } session.onAgentExitCandidate = (): void => { - session.paneForegroundAgentTracker?.onVisiblePtyBound(true) + // Why: the title tracker re-arms on unknown; with no identity left there is nothing to confirm. + if (session.paneHasKnownAgentIdentity()) { + session.paneForegroundAgentTracker?.onVisiblePtyBound(true) + } } session.onAgentExited = (): void => { // Only host-confirmed shell evidence reaches this callback. From f066f98152d4c9520c4259ff9244661fc74da4c6 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:00:01 -0700 Subject: [PATCH 05/31] test(terminal): align pane suites with exit-candidate semantics These suites still asserted the old behaviour: a neutral title exiting the agent directly, an unavailable command-finish ladder retiring the launch identity, and command start clearing display identity. They now assert the candidate is confirmed before exit, unknown reads keep display identity, and agent-specific Shift+Enter routing stays revoked. --- .../parked-terminal-byte-watcher.test.ts | 2 +- ...onnection-command-finished-cleanup.test.ts | 28 ++++++++--------- ...-connection-task-complete-dispatch.test.ts | 30 +++++++++++++++---- 3 files changed, 38 insertions(+), 22 deletions(-) diff --git a/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts b/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts index 6442f5eb5f95..0fdbedf65473 100644 --- a/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts +++ b/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts @@ -636,7 +636,7 @@ describe('startParkedTerminalByteWatcher', () => { pending.push({ kind: 'title', normalizedTitle, rawTitle }), onAgentBecameWorking: () => pending.push({ kind: 'agent-working' }), onAgentBecameIdle: (title) => pending.push({ kind: 'agent-idle', title }), - onAgentExited: () => pending.push({ kind: 'agent-exited' }), + onAgentExitCandidate: () => pending.push({ kind: 'agent-exited' }), onBell: () => pending.push({ kind: 'bell' }) }) let seq = 0 diff --git a/src/renderer/src/components/terminal-pane/pty-connection-command-finished-cleanup.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-command-finished-cleanup.test.ts index 45a603f9e7fb..f17a1f0c0d1e 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 @@ -196,10 +196,12 @@ describe('connectPanePty', () => { sendTerminalInputThroughPane(pane, 'droid\r') dataCallbackRef.current?.('\x1b]133;C\x07') - expect(mockStoreState.paneForegroundAgentByPaneKey[paneKey]).toEqual({ - agent: null, - shellForeground: false - }) + // Display identity waits for the command read; only routing is revoked. + expect(mockStoreState.paneForegroundAgentByPaneKey[paneKey]).toEqual( + identitySource === 'process' + ? { agent: 'antigravity', shellForeground: false, routingRevoked: true } + : undefined + ) expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, paneKey)).toBe('alt-enter') } ) @@ -510,11 +512,10 @@ describe('connectPanePty', () => { await vi.advanceTimersByTimeAsync(350 + 1200 + 6000) await flushAsyncTicks() - // The unavailable branch really ran: it publishes an unproven foreground before settling. - expect(mockStoreState.paneForegroundAgentByPaneKey[paneKey]).toEqual({ - agent: null, - shellForeground: false - }) + // The whole unavailable ladder ran and left the launch identity in place. + expect(window.api.pty.confirmForegroundProcess).toHaveBeenCalledTimes(3) + expect(mockStoreState.paneForegroundAgentByPaneKey[paneKey]).toBeUndefined() + expect(mockStoreState.agentLaunchConfigByPaneKey[paneKey]?.identity.agentType).toBe('droid') expect(window.api.agentStatus.reconcileEndedProcess).not.toHaveBeenCalled() }) @@ -662,7 +663,7 @@ describe('connectPanePty', () => { expect(deps.paneKittyKeyboardModesRef.current.get(pane.id)?.flags).toBe(5) }) - it('retires stale routing after unavailable command-finish reads without asserting shell', async () => { + it('keeps launch identity display-only after unavailable command-finish reads', async () => { vi.useFakeTimers() const { connectPanePty } = await import('./pty-connection') vi.mocked(window.api.pty.confirmForegroundProcess).mockResolvedValue(null) @@ -695,11 +696,8 @@ describe('connectPanePty', () => { dataCallbackRef.current?.('\x1b]133;D;0\x07') await vi.advanceTimersByTimeAsync(350 + 1200 + 6000) - expect(mockStoreState.clearAgentLaunchConfig).toHaveBeenCalledExactlyOnceWith(paneKey) - expect(mockStoreState.paneForegroundAgentByPaneKey[paneKey]).toEqual({ - agent: null, - shellForeground: false - }) + expect(mockStoreState.clearAgentLaunchConfig).not.toHaveBeenCalled() + expect(mockStoreState.paneForegroundAgentByPaneKey[paneKey]).toBeUndefined() expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, paneKey)).toBe('alt-enter') }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-task-complete-dispatch.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-task-complete-dispatch.test.ts index 25b40d861831..edf63da1c0ed 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-task-complete-dispatch.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-task-complete-dispatch.test.ts @@ -703,7 +703,8 @@ describe('connectPanePty', () => { }) // Why: agent-row removal belongs to process/PTY lifecycle, not title reversion, so interrupts can't disappear the activity row. - it('clears the cache timer without removing agent status when the title tracker sees exit', async () => { + it('clears the cache timer without removing agent status once a title exit is confirmed', async () => { + vi.useFakeTimers() const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport() transportFactoryQueue.push(transport) @@ -711,17 +712,34 @@ describe('connectPanePty', () => { const pane = createPane(1) const manager = createManager(1) const deps = createDeps() + const paneKey = makePaneKey('tab-1', LEAF_1) connectPanePty(pane as never, manager as never, deps as never) + await vi.advanceTimersByTimeAsync(20) + await flushAsyncTicks() - const agentExitedHandler = createdTransportOptions[0]?.onAgentExited as (() => void) | undefined - if (!agentExitedHandler) { - throw new Error('Expected onAgentExited to be registered') + const exitCandidate = createdTransportOptions[0]?.onAgentExitCandidate + if (typeof exitCandidate !== 'function') { + throw new Error('Expected onAgentExitCandidate to be registered') } + mockStoreState.paneForegroundAgentByPaneKey[paneKey] = { + agent: 'claude', + shellForeground: false + } + vi.mocked(window.api.pty.getForegroundProcess).mockResolvedValue('claude') + + // A neutral title is only a candidate while the agent still owns the foreground. + exitCandidate() + await vi.advanceTimersByTimeAsync(400) + expect(deps.onAgentExitedRef.current).not.toHaveBeenCalled() + expect(deps.setCacheTimerStartedAt).not.toHaveBeenCalledWith(paneKey, null) - agentExitedHandler() + vi.mocked(window.api.pty.getForegroundProcess).mockResolvedValue('zsh') + exitCandidate() + // Why: an expected-agent visibility read retries a shell result through the wrapper ladder. + await vi.advanceTimersByTimeAsync(350 + 1200 + 6000) - expect(deps.setCacheTimerStartedAt).toHaveBeenCalledWith(makePaneKey('tab-1', LEAF_1), null) + expect(deps.setCacheTimerStartedAt).toHaveBeenCalledWith(paneKey, null) expect(deps.onAgentExitedRef.current).toHaveBeenCalledWith(LEAF_1) expect(mockStoreState.removeAgentStatus).not.toHaveBeenCalled() }) From b82dd67969c474d30ccdda1836294b0bb3bfc34e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:42:57 -0700 Subject: [PATCH 06/31] feat(ssh): report whether the shell owns the terminal in host foreground evidence Host foreground evidence named only recognized agents, so a shell back at its prompt read as live with no process name and no reader could tell an exited agent from an unreadable one. The shared builder used by the relay and the daemon now adds an optional shellForeground fact: the PTY root is a shell, it owns the terminal's foreground group, and no agent is recognized there. Old hosts omit the field and readers treat that as unable to certify. --- ...foreground-process-remote-evidence.test.ts | 49 +++++++++++++++++++ ...gent-foreground-process-remote-evidence.ts | 9 +++- src/shared/foreground-process-evidence.ts | 5 ++ 3 files changed, 62 insertions(+), 1 deletion(-) diff --git a/src/main/providers/agent-foreground-process-remote-evidence.test.ts b/src/main/providers/agent-foreground-process-remote-evidence.test.ts index 274a24271b79..3f6d52f50718 100644 --- a/src/main/providers/agent-foreground-process-remote-evidence.test.ts +++ b/src/main/providers/agent-foreground-process-remote-evidence.test.ts @@ -84,4 +84,53 @@ describe('host-stamped remote foreground resolver', () => { ) ).toMatchObject({ verdict: 'unverifiable', reason: 'windows_ssh_foreground_unavailable' }) }) + + describe('shell in foreground', () => { + const idleShell = (command: string, extra: ProcessTableRow[] = []): ProcessTableRow[] => [ + { + pid: 100, + ppid: 1, + pgid: 100, + tpgid: 100, + tty: '/dev/pts/2', + startTime: 'root-start', + stat: 'Ss+', + command + }, + ...extra + ] + const resolve = (rows: ProcessTableRow[]) => + resolveRemoteForegroundEvidence({ rootPid: 100, fallbackProcess: 'zsh' }, metadata, rows) + + it.each(['/bin/zsh', '-zsh', 'bash --login'])('marks %s at its prompt', (command) => { + expect(resolve(idleShell(command))).toMatchObject({ + verdict: 'live', + processName: null, + shellForeground: true + }) + }) + + it('does not mark a shell whose foreground group runs another program', () => { + expect(resolve(rowsFor(['vim notes.md']))).toMatchObject({ + verdict: 'live', + processName: null, + shellForeground: false + }) + }) + + it('does not mark a recognized agent', () => { + expect(resolve(rowsFor(['node /opt/claude']))).toMatchObject({ + verdict: 'live', + processName: 'claude', + shellForeground: false + }) + }) + + it('does not mark an agent that is itself the PTY root', () => { + expect(resolve(idleShell('claude --resume'))).toMatchObject({ + verdict: 'live', + shellForeground: false + }) + }) + }) }) diff --git a/src/main/providers/agent-foreground-process-remote-evidence.ts b/src/main/providers/agent-foreground-process-remote-evidence.ts index 90c5e794fbda..1bbdc45561e0 100644 --- a/src/main/providers/agent-foreground-process-remote-evidence.ts +++ b/src/main/providers/agent-foreground-process-remote-evidence.ts @@ -1,4 +1,6 @@ import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition' +import { getFirstCommandToken } from '../../shared/command-token-scanner' +import { isShellProcess } from '../../shared/shell-process-detection' import type { PosixFence, RemoteForegroundEvidence, @@ -119,7 +121,12 @@ export function resolveRemoteForegroundEvidenceFromRows( ...metadata, verdict: 'live', processName: candidate?.name.processName ?? null, - fence + fence, + // Why the root's own name: an agent exec'd as the PTY command also owns its foreground group. + shellForeground: + !candidate && + root.tpgid === root.pgid && + isShellProcess(getFirstCommandToken(root.command).replace(/^-/, '')) } } diff --git a/src/shared/foreground-process-evidence.ts b/src/shared/foreground-process-evidence.ts index 975fbe388ffc..06e10a4cca10 100644 --- a/src/shared/foreground-process-evidence.ts +++ b/src/shared/foreground-process-evidence.ts @@ -79,6 +79,10 @@ export type RemoteForegroundEvidence = verdict: 'live' processName: string | null fence: PosixFence | WindowsFence + /** Host-computed: the PTY root is a shell that owns the terminal's foreground group and no + * agent is recognized in it. Absent on hosts that predate the field, which then cannot + * certify an agent exit by read. */ + shellForeground?: boolean } & HostObservation) | ({ verdict: 'unverifiable'; reason: string } & HostObservation) | ({ verdict: 'exited'; reason: string } & HostObservation) @@ -169,6 +173,7 @@ export function isRemoteForegroundEvidence(value: unknown): value is RemoteForeg if (input.verdict === 'live') { return ( (input.processName === null || typeof input.processName === 'string') && + (input.shellForeground === undefined || typeof input.shellForeground === 'boolean') && (isPosixFence(input.fence) || isWindowsFence(input.fence)) ) } From 36ee85309d82bb6a912c68827045c33cc0ecbd10 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:43:49 -0700 Subject: [PATCH 07/31] fix(terminal): decide agent exit with one evaluator over host observations The exit rule only accepted a process name, and host evidence never names a shell, so an agent that really exited on SSH or a paired host could never be confirmed gone: the tab kept its agent icon, status row and Chat view. judgeForegroundAgent now takes a structured observation (a local process name, an admitted host record, a host without evidence, or no answer) and returns live / unverifiable / exited plus whether the host can ever certify an exit. Main's foreground record, main's exit confirmation, the status query and the renderer tracker all use it, so they cannot disagree on the same answer. Main makes one decision per read: the read that confirms an exit also clears pty.foregroundAgent, so mobile, CLI and prompt delivery match desktop Chat. Main also confirms an agent exit at the shell's own 133;D, which is the only exit signal when the shell sets no title. On hosts that can never show a shell (the WSL bridge, SSH to Windows, relays or hosts without evidence) that 133;D retires an agent the PTY held; a transient unknown read still keeps it. The status query again refuses a running agent under Claude's management title. --- ...-runtime-emit-daemon-pty-transient-fact.ts | 1 + ...e-get-unpersisted-tracked-title-for-pty.ts | 1 + ...ntime-serialize-agent-prompt-submission.ts | 83 +++++++--- ...time-pty-foreground-agent-evidence.test.ts | 116 ++++++++------ .../runtime/runtime-pty-foreground-agent.ts | 115 +++++++++---- ...ime-terminal-agent-status-evidence.test.ts | 41 +++++ .../runtime-terminal-agent-status-query.ts | 10 +- .../runtime/runtime-terminal-contracts.ts | 4 +- .../terminal-agent-exit-evidence.test.ts | 102 ++++++++++-- .../pane-foreground-agent-tracker.ts | 151 +++++++++--------- ...round-inspect-observation-identity.test.ts | 12 +- .../pane-foreground-process-reader.ts | 85 +++++----- src/shared/foreground-agent-verdict.test.ts | 102 ++++++++++++ src/shared/foreground-agent-verdict.ts | 104 ++++++++++-- src/shared/terminal-side-effect-facts.ts | 3 +- 15 files changed, 689 insertions(+), 241 deletions(-) create mode 100644 src/shared/foreground-agent-verdict.test.ts diff --git a/src/main/runtime/orca-runtime-emit-daemon-pty-transient-fact.ts b/src/main/runtime/orca-runtime-emit-daemon-pty-transient-fact.ts index cc112a78ade7..6289037f90bc 100644 --- a/src/main/runtime/orca-runtime-emit-daemon-pty-transient-fact.ts +++ b/src/main/runtime/orca-runtime-emit-daemon-pty-transient-fact.ts @@ -18,6 +18,7 @@ export class OrcaRuntimeWithEmitDaemonPtyTransientFact extends OrcaRuntimeWithSc this.recordTerminalSideEffectFact(ptyId, { kind: 'bell' }) return case 'command-finished': + this.confirmPtyAgentExitAtCommandFinished(ptyId) this.retirePtyAgentLaunchAuthority(ptyId) this.recordTerminalSideEffectFact(ptyId, { kind: 'command-finished', 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 993e6d005278..ecd6fc934519 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 @@ -144,6 +144,7 @@ export class OrcaRuntimeWithGetUnpersistedTrackedTitleForPty extends OrcaRuntime this.confirmPtyAgentExit(ptyId) }, onCommandFinished: (exitCode: number | null) => { + this.confirmPtyAgentExitAtCommandFinished(ptyId) this.retirePtyAgentLaunchAuthority(ptyId) this.recordTerminalSideEffectFact(ptyId, { kind: 'command-finished', exitCode }) }, 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 bf299fcf4dac..5ddf90088f1e 100644 --- a/src/main/runtime/orca-runtime-serialize-agent-prompt-submission.ts +++ b/src/main/runtime/orca-runtime-serialize-agent-prompt-submission.ts @@ -1,6 +1,5 @@ // @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. import { startSpan } from '../observability/tracer' -import { foregroundAgentVerdict } from '../../shared/foreground-agent-verdict' import { selectFreshExplicitAgentStatus } from './runtime-hook-agent-row-selection' import { OrcaRuntimeWithControllerKnowsPtyIsLive } from './orca-runtime-controller-knows-pty-is-live' import type { RuntimeTerminalAgentStatus } from '../../shared/runtime-types' @@ -69,7 +68,7 @@ export class OrcaRuntimeWithSerializeAgentPromptSubmission extends OrcaRuntimeWi ptyId: string, afterTitleObservation = 0 ): Promise | null { - return this.ptyForegroundAgent.read(ptyId, afterTitleObservation) + return this.ptyForegroundAgent.confirm(ptyId, afterTitleObservation) } protected confirmPtyAgentExit(ptyId: string, recoverCompletedHook = false): void { @@ -113,14 +112,11 @@ export class OrcaRuntimeWithSerializeAgentPromptSubmission extends OrcaRuntimeWi this.confirmPtyAgentExit(ptyId, true) return } - if ( - result.controller === this.ptyController && - result.available && - recognizeAgentProcess(result.process) !== null - ) { + const { verdict, processName } = result.judgement + if (result.controller === this.ptyController && verdict === 'live') { // Codex's final native spinner can arrive after its done hook, then clear to the cwd. const confirmedStatus = - recoverCompletedHook && recognizeAgentProcess(result.process)?.agent === 'codex' + recoverCompletedHook && recognizeAgentProcess(processName)?.agent === 'codex' ? 'idle' : undefined const restoredStatus = this.ptyTitleTrackersByPtyId @@ -152,26 +148,71 @@ export class OrcaRuntimeWithSerializeAgentPromptSubmission extends OrcaRuntimeWi if ( !recoverCompletedHook && result.controller === this.ptyController && - result.available && - foregroundAgentVerdict(result.process) === 'exited' + verdict === 'exited' ) { - startSpan('terminal.agent-exit-decision', { - attributes: { - reason: 'title-exit-candidate', - evidenceSource: 'host-foreground-confirmation', - verdict: 'exited' - } - }).end() - this.recordTerminalSideEffectFact(ptyId, { - kind: 'agent-exited', - evidence: 'foreground-shell' - }) + this.publishPtyAgentExit(ptyId, 'title-exit-candidate', 'foreground-shell') } else { this.ptyTitleTrackersByPtyId.get(ptyId)?.tracker.restoreLastAgentExit() } }) } + /** + * The shell's own 133;D ends an agent this PTY held once a read shows a shell, or at once on a + * host that can never show one. A PTY that held no agent pays no read. + */ + protected confirmPtyAgentExitAtCommandFinished(ptyId: string): void { + const pty = this.ptysById.get(ptyId) + // Why launchAgent counts: Orca's shell integration emits 133;D only after the command it ran. + if (!pty?.connected || (!pty.launchAgent && !pty.foregroundAgent && !pty.lastAgentStatus)) { + return + } + const incarnationId = pty.incarnationId + // Why +0.5: newer than every title seen so far and older than the next, so a read begun + // before this 133;D is never reused and a later title never reuses this one. + const afterBoundary = this.titleObservationSequence + 0.5 + void this.readPtyForegroundProcessFromController(ptyId, afterBoundary)?.then((result) => { + const current = this.ptysById.get(ptyId) + const { verdict, canCertifyExit } = result.judgement + if ( + current !== pty || + !current.connected || + current.incarnationId !== incarnationId || + result.controller !== this.ptyController || + verdict === 'live' || + (verdict === 'unverifiable' && canCertifyExit) + ) { + return + } + if (verdict === 'unverifiable') { + this.ptyForegroundAgent.markExited(ptyId) + } + // Why: a stale agent title would otherwise make every later 133;D look like an agent's. + current.lastAgentStatus = null + this.publishPtyAgentExit( + ptyId, + 'command-finished', + verdict === 'exited' ? 'foreground-shell' : 'command-finished' + ) + }) + } + + private publishPtyAgentExit( + ptyId: string, + reason: 'title-exit-candidate' | 'command-finished', + evidence: 'foreground-shell' | 'command-finished' + ): void { + startSpan('terminal.agent-exit-decision', { + attributes: { + reason, + evidenceSource: + evidence === 'foreground-shell' ? 'host-foreground-confirmation' : 'osc-133', + verdict: 'exited' + } + }).end() + this.recordTerminalSideEffectFact(ptyId, { kind: 'agent-exited', evidence }) + } + /** * Schedules an asynchronous query to check which agent process is currently * running in the foreground of a PTY. diff --git a/src/main/runtime/runtime-pty-foreground-agent-evidence.test.ts b/src/main/runtime/runtime-pty-foreground-agent-evidence.test.ts index 419b55381304..7b5bbc295463 100644 --- a/src/main/runtime/runtime-pty-foreground-agent-evidence.test.ts +++ b/src/main/runtime/runtime-pty-foreground-agent-evidence.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it, vi } from 'vitest' import { RuntimePtyForegroundAgent } from './runtime-pty-foreground-agent' import type { RuntimePtyController } from './runtime-pty-controller-contract' import type { RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' +import { resolveRemoteForegroundEvidence } from '../providers/agent-foreground-process-batch' +import type { ProcessTableRow } from '../../shared/process-table-snapshot' function setup(remote = false) { const pty: Pick< @@ -83,52 +85,72 @@ describe('foreground identity on unknown observations', () => { await pending expect(h.pty.foregroundAgent).toBe('claude') }) - it.each(['missing', 'unverifiable', 'wrong-incarnation', 'stale', 'live'] as const)( - 'admits only fresh matching SSH evidence: %s', - async (kind) => { - const h = setup(true) - h.replace({ - write: () => true, - kill: () => true, - getForegroundProcess: async () => 'zsh', - inspectProcess: async () => ({ - foregroundProcess: 'zsh', - hasChildProcesses: false, - ...(kind === 'missing' - ? {} - : { - foregroundProcessEvidence: - kind === 'unverifiable' - ? { - verdict: 'unverifiable' as const, - reason: 'inspection_failed', - authorityGeneration: 'host-1', - observationEpoch: 1, - capturedAgeMs: 0, - ptyId: 'pty-1', - ptyIncarnationId: 'generation-1' - } - : { - verdict: 'live' as const, - processName: 'zsh', - fence: { - platform: 'posix' as const, - shellPid: 10, - shellStartTime: '100', - tty: '/dev/pts/1', - foregroundPgid: 10 - }, - authorityGeneration: 'host-1', - observationEpoch: 1, - capturedAgeMs: kind === 'stale' ? 10000 : 0, - ptyId: 'pty-1', - ptyIncarnationId: kind === 'wrong-incarnation' ? 'old' : 'generation-1' - } - }) - }) + it.each([ + ['an old relay without evidence', 'missing', 'claude'], + ['stale evidence', 'stale', 'claude'], + ['evidence for another incarnation', 'wrong-incarnation', 'claude'], + ['an SSH-to-Windows host', 'windows', 'claude'], + ['another program in front', 'vim', 'claude'], + ['an old host that cannot mark the shell', 'old-host-shell', 'claude'], + ['the shell back at its prompt', 'shell', null] + ] as const)('on SSH, %s leaves foregroundAgent %j', async (_label, kind, expected) => { + const h = setup(true) + h.replace({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => 'zsh', + inspectProcess: async () => ({ + foregroundProcess: 'zsh', + hasChildProcesses: false, + ...(kind === 'missing' ? {} : { foregroundProcessEvidence: hostEvidence(kind) }) }) - await h.agent.refresh('pty-1') - expect(h.pty.foregroundAgent).toBe(kind === 'live' ? null : 'claude') - } - ) + }) + await h.agent.refresh('pty-1') + expect(h.pty.foregroundAgent).toBe(expected) + }) + it('clears foregroundAgent from the same read that confirms an exit', async () => { + const h = setup() + h.confirm.mockResolvedValue('zsh') + const result = await h.agent.confirm('pty-1') + expect(result?.judgement.verdict).toBe('exited') + expect(h.pty.foregroundAgent).toBeNull() + expect(h.touched).toHaveBeenCalledOnce() + }) }) + +/** Host evidence built by the same builder the relay and daemon use, from one process table. */ +function hostEvidence( + kind: 'stale' | 'wrong-incarnation' | 'windows' | 'vim' | 'old-host-shell' | 'shell' +): unknown { + const shell: ProcessTableRow = { + pid: 10, + ppid: 1, + pgid: 10, + tpgid: kind === 'vim' ? 11 : 10, + tty: '/dev/pts/1', + startTime: '100', + stat: 'Ss', + command: '-zsh' + } + const rows: ProcessTableRow[] = + kind === 'vim' + ? [shell, { ...shell, pid: 11, ppid: 10, pgid: 11, stat: 'S+', command: 'vim notes.md' }] + : [shell] + const evidence = resolveRemoteForegroundEvidence( + { rootPid: 10, fallbackProcess: 'zsh' }, + { + ptyId: 'pty-1', + ptyIncarnationId: kind === 'wrong-incarnation' ? 'old' : 'generation-1', + authorityGeneration: 'host-1', + observationEpoch: 1, + capturedAgeMs: kind === 'stale' ? 10000 : 0, + platform: kind === 'windows' ? 'win32' : 'linux' + }, + rows + ) + if (kind === 'old-host-shell' && evidence.verdict === 'live') { + const { shellForeground: _omitted, ...legacy } = evidence + return legacy + } + return evidence +} diff --git a/src/main/runtime/runtime-pty-foreground-agent.ts b/src/main/runtime/runtime-pty-foreground-agent.ts index 97df8755c392..9a91a73e57d3 100644 --- a/src/main/runtime/runtime-pty-foreground-agent.ts +++ b/src/main/runtime/runtime-pty-foreground-agent.ts @@ -1,6 +1,9 @@ -import { foregroundAgentVerdict } from '../../shared/foreground-agent-verdict' +import { + judgeForegroundAgent, + observeHostInspection, + type ForegroundAgentJudgement +} from '../../shared/foreground-agent-verdict' import { admitRemoteForegroundEvidence } from '../../shared/remote-foreground-evidence-admission' -import { isClientOnlyUnverifiableInspection } from '../../shared/terminal-process-inspection' import { parseAppSshPtyId } from '../../shared/ssh-pty-id' import { recognizeAgentProcess } from '../../shared/agent-process-recognition' import type { RuntimePtyController } from './runtime-pty-controller-contract' @@ -41,11 +44,18 @@ export class RuntimePtyForegroundAgent { } if (pending?.controller === controller) { return pending.promise.then( - () => this.read(ptyId, afterTitle) ?? { controller, process: null, available: false } + () => + this.read(ptyId, afterTitle) ?? { + controller, + judgement: judgeForegroundAgent({ kind: 'unavailable' }) + } ) } - const unavailable: PtyForegroundProcessRead = { controller, process: null, available: false } - let processRead: Promise + const unavailable: PtyForegroundProcessRead = { + controller, + judgement: judgeForegroundAgent({ kind: 'unavailable' }) + } + let processRead: Promise try { processRead = this.readProcess(controller, ptyId) } catch { @@ -61,9 +71,9 @@ export class RuntimePtyForegroundAgent { let entry: PtyForegroundProcessReadEntry const incarnationId = this.deps.getPty(ptyId)?.incarnationId const promise = processRead - .then((process) => + .then((judgement) => this.deps.getPty(ptyId)?.incarnationId === incarnationId - ? { controller, process, available: true } + ? { controller, judgement } : unavailable ) .catch(() => unavailable) @@ -104,6 +114,30 @@ export class RuntimePtyForegroundAgent { return entry.promise } + /** Exit confirmation and `pty.foregroundAgent` come from this one current-incarnation read. */ + confirm(ptyId: string, afterTitle = 0): Promise | null { + const pty = this.deps.getPty(ptyId) + const read = this.read(ptyId, afterTitle) + if (!pty || !read) { + return read + } + const incarnationId = pty.incarnationId + return read.then((result) => { + if (this.isCurrent(ptyId, pty, incarnationId, result)) { + this.apply(ptyId, pty, result.judgement) + } + return result + }) + } + + /** The shell's own 133;D retired an agent on a host that cannot prove a shell by read. */ + markExited(ptyId: string): void { + const pty = this.deps.getPty(ptyId) + if (pty) { + this.apply(ptyId, pty, { verdict: 'exited', processName: null, canCertifyExit: false }) + } + } + getPending(ptyId: string, afterTitle: number): Promise | undefined { return this.refreshes.has(ptyId) ? this.refresh(ptyId, afterTitle) : undefined } @@ -139,16 +173,33 @@ export class RuntimePtyForegroundAgent { } const incarnationId = pty.incarnationId const result = await this.read(ptyId, afterTitle) - if (!result || result.controller !== this.deps.getController() || !result.available) { - return false - } - if (this.deps.getPty(ptyId) !== pty || !pty.connected || pty.incarnationId !== incarnationId) { - return false - } - if (foregroundAgentVerdict(result.process) === 'unverifiable') { + return result && this.isCurrent(ptyId, pty, incarnationId, result) + ? this.apply(ptyId, pty, result.judgement) + : false + } + + private isCurrent( + ptyId: string, + pty: ForegroundPty, + incarnationId: string | null | undefined, + result: PtyForegroundProcessRead + ): boolean { + return ( + result.controller === this.deps.getController() && + this.deps.getPty(ptyId) === pty && + pty.connected && + pty.incarnationId === incarnationId + ) + } + + private apply(ptyId: string, pty: ForegroundPty, judgement: ForegroundAgentJudgement): boolean { + if (judgement.verdict === 'unverifiable') { return false } - const agent = recognizeAgentProcess(result.process)?.agent ?? null + const agent = + judgement.verdict === 'live' + ? (recognizeAgentProcess(judgement.processName)?.agent ?? null) + : null if (pty.foregroundAgent === agent) { return false } @@ -160,15 +211,19 @@ export class RuntimePtyForegroundAgent { private async readProcess( controller: RuntimePtyController, ptyId: string - ): Promise { + ): Promise { const pty = this.deps.getPty(ptyId) if (!pty?.connectionId) { const cached = await controller.getForegroundProcess(ptyId) if (recognizeAgentProcess(cached)) { - return cached + return judgeForegroundAgent({ kind: 'process-name', processName: cached }) } // Cached display names cannot certify that the agent returned to its shell. - return controller.confirmForegroundProcess ? controller.confirmForegroundProcess(ptyId) : null + return judgeForegroundAgent( + controller.confirmForegroundProcess + ? { kind: 'process-name', processName: await controller.confirmForegroundProcess(ptyId) } + : { kind: 'unavailable' } + ) } const incarnationId = pty.incarnationId const started = performance.now() @@ -176,18 +231,18 @@ export class RuntimePtyForegroundAgent { ptyId, incarnationId ? { expectedIncarnationId: incarnationId } : {} ) - if (!inspection || isClientOnlyUnverifiableInspection(inspection)) { - return null - } - const evidence = admitRemoteForegroundEvidence(inspection.foregroundProcessEvidence, { - expectedPtyId: parseAppSshPtyId(ptyId)?.relayPtyId ?? ptyId, - expectedIncarnationId: incarnationId, - requestStartedAtMonotonic: started, - receivedAtMonotonic: performance.now(), - lastAuthorityGeneration: null, - lastObservationEpoch: -1 - }) - return evidence?.verdict === 'live' ? evidence.processName : null + return judgeForegroundAgent( + observeHostInspection(inspection, (evidence) => + admitRemoteForegroundEvidence(evidence, { + expectedPtyId: parseAppSshPtyId(ptyId)?.relayPtyId ?? ptyId, + expectedIncarnationId: incarnationId, + requestStartedAtMonotonic: started, + receivedAtMonotonic: performance.now(), + lastAuthorityGeneration: null, + lastObservationEpoch: -1 + }) + ) + ) } private deleteRead(ptyId: string, entry: PtyForegroundProcessReadEntry): void { diff --git a/src/main/runtime/runtime-terminal-agent-status-evidence.test.ts b/src/main/runtime/runtime-terminal-agent-status-evidence.test.ts index 1925de2348c0..00b39fa58c22 100644 --- a/src/main/runtime/runtime-terminal-agent-status-evidence.test.ts +++ b/src/main/runtime/runtime-terminal-agent-status-evidence.test.ts @@ -2,6 +2,47 @@ import { describe, expect, it, vi } from 'vitest' import { RuntimeTerminalAgentStatusQuery } from './runtime-terminal-agent-status-query' import type { RuntimePtyController } from './runtime-pty-controller-contract' +function statusQuery(title: string, confirmForegroundProcess: () => Promise) { + const controller: RuntimePtyController = { + write: () => true, + kill: () => true, + getForegroundProcess: async () => 'zsh', + confirmForegroundProcess + } + const query = new RuntimeTerminalAgentStatusQuery({ + getController: () => controller, + getLivePty: () => null, + getLiveLeaf: () => { + throw new Error('unused') + }, + getPrimaryLeaf: () => null, + getTabTitle: () => null, + getExplicitStatus: () => ({ status: 'idle', updatedAt: Date.now() }), + getLifecycleStatus: () => null, + isRunning: async () => true + }) + vi.spyOn(query, 'getPtyId').mockReturnValue('pty-1') + vi.spyOn(query, 'getSnapshot').mockReturnValue({ + waitText: '', + waitBlockedAt: null, + title, + titleStatus: null, + titleStatusIsLive: true + }) + return query +} + +describe('Claude management title', () => { + it('reports no running agent under the claude agents screen despite a fresh hook', async () => { + const query = statusQuery('claude agents', async () => 'claude') + expect(await query.getStatus('terminal')).toEqual({ + handle: 'terminal', + isRunningAgent: false, + status: null + }) + }) +}) + describe('status survives an unconfirmed shell observation', () => { it.each([null, '', 'node.exe', 'claude', 'zsh', 'throw'])( 'confirms the foreground before retiring status: %j', diff --git a/src/main/runtime/runtime-terminal-agent-status-query.ts b/src/main/runtime/runtime-terminal-agent-status-query.ts index ae0034a32932..cc44a4c1cba6 100644 --- a/src/main/runtime/runtime-terminal-agent-status-query.ts +++ b/src/main/runtime/runtime-terminal-agent-status-query.ts @@ -1,6 +1,7 @@ -import { foregroundAgentVerdict } from '../../shared/foreground-agent-verdict' +import { judgeForegroundAgent } from '../../shared/foreground-agent-verdict' import { detectAgentStatusFromTitle, + isClaudeManagementTitle, isOpenCodeNativeTitle, isQuarterCircleSpinnerOnlyAgentTitle, isShellProcess, @@ -93,7 +94,9 @@ export class RuntimeTerminalAgentStatusQuery { if (explicitStatus) { // Why: permission titles can linger after hooks report the agent resumed. // Fresh hook state is tighter, but current shell/management evidence wins. - const isRunningAgent = !(await this.terminalHasShellForegroundProcess(handle, ptyId)) + const isRunningAgent = + !(terminal.title !== null && isClaudeManagementTitle(terminal.title)) && + !(await this.terminalHasShellForegroundProcess(handle, ptyId)) this.assertTerminalAgentStatusPtyBinding(handle, ptyId) return { handle, @@ -254,7 +257,8 @@ export class RuntimeTerminalAgentStatusQuery { // prove that some recognized agent still owns this exact PTY. return ( this.deps.getController() === controller && - foregroundAgentVerdict(confirmedProcess) === 'exited' + judgeForegroundAgent({ kind: 'process-name', processName: confirmedProcess }).verdict === + 'exited' ) } } diff --git a/src/main/runtime/runtime-terminal-contracts.ts b/src/main/runtime/runtime-terminal-contracts.ts index e08fffa273cf..cc99b2861fca 100644 --- a/src/main/runtime/runtime-terminal-contracts.ts +++ b/src/main/runtime/runtime-terminal-contracts.ts @@ -18,6 +18,7 @@ import type { RuntimeTerminalSend } from '../../shared/runtime-terminal-contract import type { RuntimeTerminalWriteOptions } from './runtime-terminal-writer' import type { TerminalInputKind } from '../../shared/terminal-input-kind' import type { RuntimePtyController } from './runtime-pty-controller-contract' +import type { ForegroundAgentJudgement } from '../../shared/foreground-agent-verdict' import type { RuntimeAgentRowSnapshot } from './runtime-worktree-agent-rows' import type { WorkerTerminalHostScope } from './orchestration/worker-terminal-process-liveness' @@ -111,8 +112,7 @@ export type PtyForegroundAgentRefresh = { export type PtyForegroundProcessRead = { controller: RuntimePtyController - process: string | null - available: boolean + judgement: ForegroundAgentJudgement } export type PtyForegroundProcessReadEntry = { diff --git a/src/main/runtime/terminal-agent-exit-evidence.test.ts b/src/main/runtime/terminal-agent-exit-evidence.test.ts index 85fd3955eee5..dba74f850790 100644 --- a/src/main/runtime/terminal-agent-exit-evidence.test.ts +++ b/src/main/runtime/terminal-agent-exit-evidence.test.ts @@ -2,6 +2,12 @@ import { describe, expect, it, vi } from 'vitest' import { createTerminalTitleTracker } from '../../shared/terminal-output-side-effects' import type { TerminalSideEffectFact } from '../../shared/terminal-side-effect-facts' import { OrcaRuntimeWithSerializeAgentPromptSubmission } from './orca-runtime-serialize-agent-prompt-submission' +import { judgeForegroundAgent } from '../../shared/foreground-agent-verdict' + +const readResult = (controller: object, processName: string | null) => ({ + controller, + judgement: judgeForegroundAgent({ kind: 'process-name', processName }) +}) vi.mock('./orca-runtime-controller-knows-pty-is-live', () => ({ OrcaRuntimeWithControllerKnowsPtyIsLive: class {} @@ -11,18 +17,36 @@ class ExitHarness extends OrcaRuntimeWithSerializeAgentPromptSubmission { confirm(): void { this.confirmPtyAgentExit('pty-1') } + commandFinished(): void { + this.confirmPtyAgentExitAtCommandFinished('pty-1') + } } function setup() { const runtime = new ExitHarness(null) const facts: TerminalSideEffectFact[] = [] const controller = {} - const pty = { connected: true, incarnationId: 'inc-1', lastOscTitleAt: 1, lastAgentStatus: null } + const pty: { + connected: boolean + incarnationId: string + lastOscTitleAt: number + lastAgentStatus: string | null + launchAgent: string | null + foregroundAgent: string | null + } = { + connected: true, + incarnationId: 'inc-1', + lastOscTitleAt: 1, + lastAgentStatus: null, + launchAgent: null, + foregroundAgent: null + } + const markExited = vi.fn() const tracker = createTerminalTitleTracker( { onAgentExitCandidate: () => runtime.confirm() }, { initialTitle: 'Claude ready' } ) - const read = vi.fn().mockResolvedValue({ controller, process: null, available: true }) + const read = vi.fn().mockResolvedValue(readResult(controller, null)) Object.assign(runtime, { ptysById: new Map([['pty-1', pty]]), handleByPtyId: new Map(), @@ -30,9 +54,11 @@ function setup() { ptyTitleTrackersByPtyId: new Map([['pty-1', { tracker }]]), readPtyForegroundProcessFromController: read, recordTerminalSideEffectFact: (_id: string, fact: TerminalSideEffectFact) => facts.push(fact), - getLeavesForPty: () => [] + getLeavesForPty: () => [], + titleObservationSequence: 1, + ptyForegroundAgent: { markExited } }) - return { runtime, facts, controller, pty, tracker, read } + return { runtime, facts, controller, pty, tracker, read, markExited } } describe('host-confirmed agent exit', () => { @@ -40,11 +66,11 @@ describe('host-confirmed agent exit', () => { 'retains Claude after %j and observes a later shell', async (process) => { const h = setup() - h.read.mockResolvedValue({ controller: h.controller, process, available: true }) + h.read.mockResolvedValue(readResult(h.controller, process)) h.tracker.handleChunk('\x1b]0;workspace\x07') await Promise.resolve() expect(h.facts).toEqual([]) - h.read.mockResolvedValue({ controller: h.controller, process: 'zsh', available: true }) + h.read.mockResolvedValue(readResult(h.controller, 'zsh')) h.tracker.handleChunk('\x1b]0;next workspace\x07') await Promise.resolve() expect(h.facts).toEqual([{ kind: 'agent-exited', evidence: 'foreground-shell' }]) @@ -58,16 +84,16 @@ describe('host-confirmed agent exit', () => { h.read.mockReturnValue( kind === 'missing' ? null - : Promise.resolve({ - controller: kind === 'replaced' ? {} : h.controller, - process: 'zsh', - available: kind !== 'unavailable' - }) + : Promise.resolve( + kind === 'unavailable' + ? { controller: h.controller, judgement: judgeForegroundAgent({ kind }) } + : readResult({}, 'zsh') + ) ) h.tracker.handleChunk('\x1b]0;workspace\x07') await Promise.resolve() expect(h.facts).toEqual([]) - h.read.mockResolvedValue({ controller: h.controller, process: 'zsh', available: true }) + h.read.mockResolvedValue(readResult(h.controller, 'zsh')) h.tracker.handleChunk('\x1b]0;next workspace\x07') await Promise.resolve() expect(h.facts).toHaveLength(1) @@ -76,7 +102,7 @@ describe('host-confirmed agent exit', () => { ) it('ignores an observation after terminal incarnation replacement', async () => { const h = setup() - h.read.mockResolvedValue({ controller: h.controller, process: 'zsh', available: true }) + h.read.mockResolvedValue(readResult(h.controller, 'zsh')) h.tracker.handleChunk('\x1b]0;workspace\x07') h.pty.incarnationId = 'inc-2' await Promise.resolve() @@ -84,3 +110,53 @@ describe('host-confirmed agent exit', () => { h.tracker.dispose() }) }) + +describe('agent exit at the shell 133;D', () => { + it('publishes one exit when a read shows the shell after an agent title', async () => { + const h = setup() + h.pty.lastAgentStatus = 'idle' + h.read.mockResolvedValue(readResult(h.controller, 'zsh')) + h.runtime.commandFinished() + await vi.waitFor(() => + expect(h.facts).toEqual([{ kind: 'agent-exited', evidence: 'foreground-shell' }]) + ) + expect(h.pty.lastAgentStatus).toBeNull() + // A later ordinary command pays no read and publishes nothing. + h.runtime.commandFinished() + await Promise.resolve() + expect(h.read).toHaveBeenCalledOnce() + expect(h.facts).toHaveLength(1) + h.tracker.dispose() + }) + it.each([ + ['a live agent (leaked nested-shell 133;D)', 'claude'], + ['another program', 'node'], + ['no answer', null] + ])('keeps the agent when the read shows %s', async (_label, process) => { + const h = setup() + h.pty.launchAgent = 'claude' + h.read.mockResolvedValue(readResult(h.controller, process)) + h.runtime.commandFinished() + await vi.waitFor(() => expect(h.read).toHaveBeenCalledOnce()) + await Promise.resolve() + expect(h.facts).toEqual([]) + h.tracker.dispose() + }) + it('retires the agent on the 133;D where no read can ever name the shell', async () => { + const h = setup() + h.pty.foregroundAgent = 'claude' + h.read.mockResolvedValue(readResult(h.controller, 'wsl.exe')) + h.runtime.commandFinished() + await vi.waitFor(() => + expect(h.facts).toEqual([{ kind: 'agent-exited', evidence: 'command-finished' }]) + ) + expect(h.markExited).toHaveBeenCalledWith('pty-1') + h.tracker.dispose() + }) + it('does not read for a 133;D on a pane that never held an agent', () => { + const h = setup() + h.runtime.commandFinished() + expect(h.read).not.toHaveBeenCalled() + h.tracker.dispose() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.ts b/src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.ts index cebda377b387..6553b77f49c9 100644 --- a/src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.ts +++ b/src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.ts @@ -2,7 +2,6 @@ import { isAgentForegroundWrapperProcess, recognizeAgentProcess } from '../../../../shared/agent-process-recognition' -import { foregroundAgentVerdict } from '../../../../shared/foreground-agent-verdict' import type { TuiAgent } from '../../../../shared/tui-agent' import type { PaneForegroundAgentEntry } from '@/store/slices/pane-foreground-agent' import type { RuntimeTerminalProcessInspection } from '@/runtime/runtime-terminal-inspection' @@ -39,9 +38,13 @@ type PaneForegroundAgentTrackerDeps = { * 133;D before any command-start read has recorded its own evidence. */ hasKnownAgentIdentity?: () => boolean /** Fired when a confirming read proves the foreground genuinely returned to a - * shell (agent exited). Lets callers clear a stale agent-named tab title that - * the shell never repaints. */ - onConfirmedShellForeground?: (reason: 'visible-pty' | 'command-finished') => void + * shell. Lets callers clear a stale agent-named tab title that the shell never + * repaints. `agentExited` is false when only a launch/hook expectation was + * retired: a slow boot also shows the shell, so that is never an agent exit. */ + onConfirmedShellForeground?: ( + reason: 'visible-pty' | 'command-finished', + agentExited: boolean + ) => void onCommandFinishedUnavailable?: () => void onVisibleForegroundSettled?: (outcome: 'agent' | 'shell' | 'inconclusive') => void } @@ -58,6 +61,8 @@ export function createPaneForegroundAgentTracker(deps: PaneForegroundAgentTracke /** True while any read is scheduled or running, whatever its authority. */ hasReadInFlight: () => boolean onVisiblePtyBound: (expectsAgent?: boolean) => boolean + /** A neutral title followed the agent's own status title; confirm whether it exited. */ + onAgentExitCandidate: () => boolean onCommandStarted: (expectedAgent?: TuiAgent | null) => void /** True when pane identity must remain visible until an async shell confirmation. */ onCommandFinished: () => boolean @@ -144,10 +149,11 @@ export function createPaneForegroundAgentTracker(deps: PaneForegroundAgentTracke hasForegroundAgentEvidence || hasKnownAgentEvidence || hasAgentExpectation - const { processName, remoteEvidenceVerdict, expectedIncarnationId, remote } = await readProcess( + const { judgement, expectedIncarnationId, remote } = await readProcess( ptyId, requiresRoutingConfirmation ) + const { processName, verdict } = judgement // Why: a pane key can be rebound while process inspection is pending; the // old PTY's identity must never publish into its replacement session. if ( @@ -159,12 +165,7 @@ export function createPaneForegroundAgentTracker(deps: PaneForegroundAgentTracke settleAbortedRead(generation) return } - const recognized = - remoteEvidenceVerdict === 'live' - ? recognizeAgentProcess(processName) - : remoteEvidenceVerdict !== null - ? null - : recognizeAgentProcess(processName) + const recognized = verdict === 'live' ? recognizeAgentProcess(processName) : null if (recognized) { hasForegroundAgentEvidence = true hasAgentExpectation = false @@ -185,8 +186,12 @@ export function createPaneForegroundAgentTracker(deps: PaneForegroundAgentTracke const retryDelay = WRAPPER_RESOLVE_RETRY_DELAYS_MS[retryIndex] const hasConfirmationExpectation = hasForegroundAgentEvidence || hasKnownAgentEvidence || hasAgentExpectation + // Why: a re-read cannot change a host's structural answer or its fenced shell fact. + const settledByHost = !judgement.canCertifyExit || (remote && verdict === 'exited') const shouldRetryExpectedIdentity = - hasConfirmationExpectation && (reason !== 'command-finished' || processName === null) + hasConfirmationExpectation && + !settledByHost && + (reason !== 'command-finished' || processName === null) const shouldRetry = retryDelay !== undefined && (shouldRetryExpectedIdentity || @@ -199,7 +204,7 @@ export function createPaneForegroundAgentTracker(deps: PaneForegroundAgentTracke return } if (reason === 'command') { - if (!processName?.trim() || remoteEvidenceVerdict === 'unverifiable') { + if (!processName?.trim()) { return } hasAgentExpectation = false @@ -209,51 +214,71 @@ export function createPaneForegroundAgentTracker(deps: PaneForegroundAgentTracke return } if (reason === 'visible-pty') { - if ( - (hasForegroundAgentEvidence || hasKnownAgentEvidence) && - foregroundAgentVerdict(processName) === 'exited' - ) { - hasForegroundAgentEvidence = false - hasKnownAgentEvidence = false - hasAgentExpectation = false - deps.publish({ agent: null, shellForeground: true }) - deps.onConfirmedShellForeground?.(reason) + if ((hasForegroundAgentEvidence || hasKnownAgentEvidence) && verdict === 'exited') { + confirmShellForeground(reason) deps.onVisibleForegroundSettled?.('shell') } else { deps.onVisibleForegroundSettled?.('inconclusive') } return } - if (reason === 'command-finished') { - if (processName === null) { - if (remoteEvidenceVerdict !== null && remoteEvidenceVerdict !== 'live') { - deps.onCommandFinishedUnavailable?.() - return - } - deps.onCommandFinishedUnavailable?.() - return - } - if (foregroundAgentVerdict(processName) !== 'exited') { - deps.onCommandFinishedUnavailable?.() - // Why: this read may have replaced a cancelled visible-pty confirmation. - // It publishes nothing, so without settling here the capability it was - // asked to revalidate would be retained with no read left to clear it. - deps.onVisibleForegroundSettled?.('inconclusive') - return - } - // Why: the 133;D fired AND the foreground shows no agent — together that is - // real prompt proof, so the agent truly exited. Reset the evidence so the - // pane's ordinary shell commands go back to the no-RPC finished path. - hasForegroundAgentEvidence = false - hasKnownAgentEvidence = false - hasAgentExpectation = false - deps.publish({ agent: null, shellForeground: true }) - // Why: confirmed exit — let callers clear a stale agent title the shell - // won't repaint (a plain `codex`/`grok` leaves its OSC title behind). - deps.onConfirmedShellForeground?.(reason) + // Why: 133;D is the shell's own mark; where no read can ever show a shell it retires the agent. + if (verdict === 'exited' || (!judgement.canCertifyExit && hasConfirmationExpectation)) { + confirmShellForeground(reason) + return + } + deps.onCommandFinishedUnavailable?.() + if (processName !== null) { + // Why: this read may have replaced a cancelled visible-pty confirmation. + // It publishes nothing, so without settling here the capability it was + // asked to revalidate would be retained with no read left to clear it. + deps.onVisibleForegroundSettled?.('inconclusive') } } + const confirmShellForeground = (reason: 'visible-pty' | 'command-finished'): void => { + // Why: an exit needs an agent seen in this pane, or the shell's own 133;D ending the command. + const agentExited = reason === 'command-finished' || hasForegroundAgentEvidence + // Why: reset the evidence so the pane's ordinary shell commands go back to + // the no-RPC finished path. + hasForegroundAgentEvidence = false + hasKnownAgentEvidence = false + hasAgentExpectation = false + deps.publish({ agent: null, shellForeground: true }) + // Why: confirmed exit — let callers clear a stale agent title the shell + // won't repaint (a plain `codex`/`grok` leaves its OSC title behind). + deps.onConfirmedShellForeground?.(reason, agentExited) + } + + const bindVisiblePty = (expectsAgent: boolean, agentObserved: boolean): boolean => { + // Why: command-start and command-finished reads own the exit decision; + // visibility recovery is lower-authority and must never cancel them. + if ( + scheduledReadReason === 'command' || + activeReadReason === 'command' || + scheduledReadReason === 'command-finished' || + activeReadReason === 'command-finished' + ) { + return false + } + const hadReadBeforeVisibleBind = hasPendingRead() + cancelPendingRead() + if (!trackablePtyId()) { + releaseRetainedCapability(hadReadBeforeVisibleBind) + return false + } + if (expectsAgent || deps.hasKnownAgentIdentity?.() === true) { + hasKnownAgentEvidence = true + } + if (agentObserved) { + hasForegroundAgentEvidence = true + } + // Why: restored/manual agent panes can become visible while Codex is + // already foreground, so no OSC 133 command-start event will seed the tab icon. + scheduleRead(VISIBLE_PTY_SETTLE_MS, 0, 'visible-pty') + return true + } + return { // Why: onVisiblePtyBound refuses to schedule while a higher-authority // command read owns the pane, so "it scheduled nothing" must not be read @@ -262,29 +287,11 @@ export function createPaneForegroundAgentTracker(deps: PaneForegroundAgentTracke return hasPendingRead() }, onVisiblePtyBound(expectsAgent = false) { - // Why: command-start and command-finished reads own the exit decision; - // visibility recovery is lower-authority and must never cancel them. - if ( - scheduledReadReason === 'command' || - activeReadReason === 'command' || - scheduledReadReason === 'command-finished' || - activeReadReason === 'command-finished' - ) { - return false - } - const hadReadBeforeVisibleBind = hasPendingRead() - cancelPendingRead() - if (!trackablePtyId()) { - releaseRetainedCapability(hadReadBeforeVisibleBind) - return false - } - if (expectsAgent || deps.hasKnownAgentIdentity?.() === true) { - hasKnownAgentEvidence = true - } - // Why: restored/manual agent panes can become visible while Codex is - // already foreground, so no OSC 133 command-start event will seed the tab icon. - scheduleRead(VISIBLE_PTY_SETTLE_MS, 0, 'visible-pty') - return true + return bindVisiblePty(expectsAgent, false) + }, + onAgentExitCandidate() { + // Why: the title tracker saw the agent's own status title, so the agent was observed. + return bindVisiblePty(true, true) }, onCommandStarted(expectedAgent = null) { const hadReadBeforeCommandStart = hasPendingRead() diff --git a/src/renderer/src/components/terminal-pane/pane-foreground-inspect-observation-identity.test.ts b/src/renderer/src/components/terminal-pane/pane-foreground-inspect-observation-identity.test.ts index 71719b11e4fc..9bff00ebba95 100644 --- a/src/renderer/src/components/terminal-pane/pane-foreground-inspect-observation-identity.test.ts +++ b/src/renderer/src/components/terminal-pane/pane-foreground-inspect-observation-identity.test.ts @@ -69,12 +69,12 @@ describe('pane foreground inspect observation identity', () => { // The superseded read's continuation commits its epoch first. settle(0) - expect((await superseded).remoteEvidenceVerdict).toBe('live') + expect((await superseded).judgement.verdict).toBe('live') settle(1) const result = await reissued - expect(result.remoteEvidenceVerdict).toBe('live') - expect(result.processName).toBe('claude') + expect(result.judgement.verdict).toBe('live') + expect(result.judgement.processName).toBe('claude') }) it('degrades the second overlapping read to `unverifiable` when one observation is shared', async () => { @@ -85,11 +85,11 @@ describe('pane foreground inspect observation identity', () => { await flush() settle(0) - expect((await superseded).remoteEvidenceVerdict).toBe('live') + expect((await superseded).judgement.verdict).toBe('live') settle(1) const result = await reissued - expect(result.remoteEvidenceVerdict).toBe('unverifiable') - expect(result.processName).toBeNull() + expect(result.judgement.verdict).toBe('unverifiable') + expect(result.judgement.processName).toBeNull() }) }) diff --git a/src/renderer/src/components/terminal-pane/pane-foreground-process-reader.ts b/src/renderer/src/components/terminal-pane/pane-foreground-process-reader.ts index 29476c140c02..8ebf3625cf75 100644 --- a/src/renderer/src/components/terminal-pane/pane-foreground-process-reader.ts +++ b/src/renderer/src/components/terminal-pane/pane-foreground-process-reader.ts @@ -3,6 +3,13 @@ import { getRemoteRuntimeTerminalHandle } from '@/runtime/runtime-terminal-strea import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id' import { admitRemoteForegroundEvidence } from '../../../../shared/remote-foreground-evidence-admission' import { isClientOnlyUnverifiableInspection } from '../../../../shared/terminal-process-inspection' +import type { RemoteForegroundEvidence } from '../../../../shared/foreground-process-evidence' +import { + judgeForegroundAgent, + observeHostInspection, + type ForegroundAgentJudgement, + type ForegroundAgentObservation +} from '../../../../shared/foreground-agent-verdict' type ForegroundReader = ( ptyId: string, @@ -20,9 +27,14 @@ export function createPaneForegroundProcessReader(deps: { let bindingKey: string | null = null const knownAuthorityGenerations = new Set() - return async (ptyId: string, requiresConfirmation: boolean) => { - let processName: string | null = null - let remoteEvidenceVerdict: 'live' | 'unverifiable' | 'exited' | null = null + return async ( + ptyId: string, + requiresConfirmation: boolean + ): Promise<{ + judgement: ForegroundAgentJudgement + expectedIncarnationId: string | null + remote: boolean + }> => { const expectedIncarnationId = deps.getExpectedIncarnationId?.() ?? null const options = expectedIncarnationId ? { expectedIncarnationId } : undefined const requestStartedAtMonotonic = performance.now() @@ -36,51 +48,50 @@ export function createPaneForegroundProcessReader(deps: { knownAuthorityGenerations.clear() } } + const admit = (evidence: unknown): RemoteForegroundEvidence | null => { + const admitted = admitRemoteForegroundEvidence(evidence, { + expectedPtyId: + parseAppSshPtyId(ptyId)?.relayPtyId ?? getRemoteRuntimeTerminalHandle(ptyId) ?? ptyId, + expectedIncarnationId, + requestStartedAtMonotonic, + receivedAtMonotonic: performance.now(), + lastAuthorityGeneration: authorityGeneration, + lastObservationEpoch: observationEpoch, + knownAuthorityGenerations + }) + if (admitted) { + authorityGeneration = admitted.authorityGeneration + observationEpoch = admitted.observationEpoch + knownAuthorityGenerations.add(admitted.authorityGeneration) + } + return admitted + } + let observation: ForegroundAgentObservation try { const reader = requiresConfirmation ? (deps.confirmForegroundProcess ?? deps.readForegroundProcess) : deps.readForegroundProcess const inspection = await (options ? reader(ptyId, options) : reader(ptyId)) - if (isClientOnlyUnverifiableInspection(inspection)) { + if (remote) { + observation = observeHostInspection(inspection, admit) + } else if (isClientOnlyUnverifiableInspection(inspection)) { // A client-only result is never shell evidence, even for a local // adapter that lost its provider while the pane stayed mounted. - remoteEvidenceVerdict = 'unverifiable' - } else if (typeof inspection === 'string' || inspection === null) { - processName = remote ? null : inspection - remoteEvidenceVerdict = remote || !inspection?.trim() ? 'unverifiable' : null - } else if (remote) { - const admitted = admitRemoteForegroundEvidence(inspection.foregroundProcessEvidence, { - expectedPtyId: - parseAppSshPtyId(ptyId)?.relayPtyId ?? getRemoteRuntimeTerminalHandle(ptyId) ?? ptyId, - expectedIncarnationId, - requestStartedAtMonotonic, - receivedAtMonotonic: performance.now(), - lastAuthorityGeneration: authorityGeneration, - lastObservationEpoch: observationEpoch, - knownAuthorityGenerations - }) - if (admitted) { - authorityGeneration = admitted.authorityGeneration - observationEpoch = admitted.observationEpoch - knownAuthorityGenerations.add(admitted.authorityGeneration) - } - remoteEvidenceVerdict = admitted?.verdict ?? 'unverifiable' - if (admitted?.verdict === 'live') { - processName = admitted.processName - } + observation = { kind: 'unavailable' } } else { - processName = inspection.foregroundProcess + observation = { + kind: 'process-name', + processName: + typeof inspection === 'string' || inspection === null + ? inspection + : inspection.foregroundProcess + } } } catch { // The reader adapter is deliberately conservative for injected/legacy - // providers: no answer is still an unverifiable remote verdict. - remoteEvidenceVerdict = 'unverifiable' - } - return { - processName, - remoteEvidenceVerdict, - expectedIncarnationId, - remote + // providers: no answer is still an unverifiable verdict. + observation = { kind: 'unavailable' } } + return { judgement: judgeForegroundAgent(observation), expectedIncarnationId, remote } } } diff --git a/src/shared/foreground-agent-verdict.test.ts b/src/shared/foreground-agent-verdict.test.ts new file mode 100644 index 000000000000..1d107a21306d --- /dev/null +++ b/src/shared/foreground-agent-verdict.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from 'vitest' +import { + isRemoteForegroundEvidence, + type RemoteForegroundEvidence +} from './foreground-process-evidence' +import { + judgeForegroundAgent, + observeHostInspection, + type ForegroundAgentObservation +} from './foreground-agent-verdict' + +const observation = { + authorityGeneration: 'host-1', + observationEpoch: 1, + capturedAgeMs: 0, + ptyId: 'pty-1', + ptyIncarnationId: 'inc-1' +} +const fence = { + platform: 'posix' as const, + shellPid: 10, + shellStartTime: '100', + tty: '/dev/pts/1', + foregroundPgid: 10 +} +const live = (fields: { processName: string | null; shellForeground?: boolean }) => + ({ ...observation, verdict: 'live', fence, ...fields }) satisfies RemoteForegroundEvidence +const unverifiable = (reason: string) => + ({ ...observation, verdict: 'unverifiable', reason }) satisfies RemoteForegroundEvidence + +describe('judgeForegroundAgent', () => { + it.each<[string, ForegroundAgentObservation, string, boolean]>([ + ['a local agent', { kind: 'process-name', processName: 'claude' }, 'live', true], + ['a local shell', { kind: 'process-name', processName: 'zsh' }, 'exited', true], + ['another local program', { kind: 'process-name', processName: 'vim' }, 'unverifiable', true], + ['an empty local read', { kind: 'process-name', processName: '' }, 'unverifiable', true], + ['the WSL bridge', { kind: 'process-name', processName: 'wsl.exe' }, 'unverifiable', false], + ['no answer', { kind: 'unavailable' }, 'unverifiable', true], + ['an old host', { kind: 'host-without-evidence' }, 'unverifiable', false], + ['an inadmissible record', { kind: 'host-evidence', evidence: null }, 'unverifiable', true], + [ + 'a host agent', + { kind: 'host-evidence', evidence: live({ processName: 'claude', shellForeground: false }) }, + 'live', + true + ], + [ + 'a host shell at its prompt', + { kind: 'host-evidence', evidence: live({ processName: null, shellForeground: true }) }, + 'exited', + true + ], + [ + 'another host program', + { kind: 'host-evidence', evidence: live({ processName: null, shellForeground: false }) }, + 'unverifiable', + true + ], + [ + 'an old host that cannot mark its shell', + { kind: 'host-evidence', evidence: live({ processName: null }) }, + 'unverifiable', + false + ], + [ + 'an SSH-to-Windows host', + { kind: 'host-evidence', evidence: unverifiable('windows_ssh_foreground_unavailable') }, + 'unverifiable', + false + ], + [ + 'a stale host capture', + { kind: 'host-evidence', evidence: unverifiable('process_table_unreadable') }, + 'unverifiable', + true + ] + ])('judges %s', (_label, input, verdict, canCertifyExit) => { + expect(judgeForegroundAgent(input)).toMatchObject({ verdict, canCertifyExit }) + }) +}) + +describe('observeHostInspection', () => { + const admit = (evidence: unknown) => (isRemoteForegroundEvidence(evidence) ? evidence : null) + it('treats a host without an evidence field as unable to certify', () => { + expect( + observeHostInspection({ foregroundProcess: 'zsh', hasChildProcesses: false }, admit) + ).toEqual({ kind: 'host-without-evidence' }) + }) + it('keeps a transport failure transient', () => { + expect( + observeHostInspection( + { + foregroundProcess: null, + hasChildProcesses: false, + verdict: 'unverifiable', + reason: 'timeout' + }, + admit + ) + ).toEqual({ kind: 'unavailable' }) + }) +}) diff --git a/src/shared/foreground-agent-verdict.ts b/src/shared/foreground-agent-verdict.ts index ee2e55a6eb64..d2e1367e7c6a 100644 --- a/src/shared/foreground-agent-verdict.ts +++ b/src/shared/foreground-agent-verdict.ts @@ -1,15 +1,101 @@ import { recognizeAgentProcess } from './agent-process-recognition' +import type { RemoteForegroundEvidence } from './foreground-process-evidence' import { isShellProcess } from './shell-process-detection' +import { + isClientOnlyUnverifiableInspection, + type TerminalProcessInspection +} from './terminal-process-inspection' -/** A process name can disprove agent ownership only when it positively names a shell. */ -export function foregroundAgentVerdict( - processName: string | null | undefined -): 'live' | 'unverifiable' | 'exited' { - if (!processName?.trim()) { - return 'unverifiable' +/** One observation of what owns a PTY's foreground, before any exit rule is applied. */ +export type ForegroundAgentObservation = + /** A fresh local or daemon read of the foreground process name. */ + | { kind: 'process-name'; processName: string | null | undefined } + /** An execution-host record already fenced to this PTY; null when none was admissible. */ + | { kind: 'host-evidence'; evidence: RemoteForegroundEvidence | null } + /** The host predates foreground evidence, so it can never name a shell. */ + | { kind: 'host-without-evidence' } + /** No answer: transport loss, timeout, or a failed read. */ + | { kind: 'unavailable' } + +export type ForegroundAgentJudgement = { + verdict: 'live' | 'unverifiable' | 'exited' + /** The observed process name; host records only ever name recognized agents. */ + processName: string | null + /** False when this host can never prove a shell, so only the shell's own 133;D can retire an agent. */ + canCertifyExit: boolean +} + +const WSL_BRIDGE_NAMES = new Set(['wsl', 'wsl.exe', 'wslhost', 'wslhost.exe']) + +function unverifiable(processName: string | null, canCertifyExit = true): ForegroundAgentJudgement { + return { verdict: 'unverifiable', processName, canCertifyExit } +} + +/** The single rule for foreground agent identity: only a positive shell fact is an exit. */ +export function judgeForegroundAgent( + observation: ForegroundAgentObservation +): ForegroundAgentJudgement { + switch (observation.kind) { + case 'unavailable': + return unverifiable(null) + case 'host-without-evidence': + return unverifiable(null, false) + case 'process-name': { + const processName = observation.processName?.trim() ? observation.processName : null + if (!processName) { + return unverifiable(null) + } + if (recognizeAgentProcess(processName)) { + return { verdict: 'live', processName, canCertifyExit: true } + } + if (isShellProcess(processName)) { + return { verdict: 'exited', processName, canCertifyExit: true } + } + // Why: the WSL bridge hides the distro's process tree, so no Windows read can name its shell. + const basename = processName.trim().toLowerCase().split(/[\\/]/).pop() ?? '' + return unverifiable(processName, !WSL_BRIDGE_NAMES.has(basename)) + } + case 'host-evidence': { + const { evidence } = observation + if (!evidence) { + return unverifiable(null) + } + if (evidence.verdict === 'exited') { + return { verdict: 'exited', processName: null, canCertifyExit: true } + } + if (evidence.verdict === 'unverifiable') { + return unverifiable(null, evidence.reason !== 'windows_ssh_foreground_unavailable') + } + if (recognizeAgentProcess(evidence.processName)) { + return { verdict: 'live', processName: evidence.processName, canCertifyExit: true } + } + if (evidence.shellForeground === true) { + return { verdict: 'exited', processName: null, canCertifyExit: true } + } + // Absent on hosts that predate the field; false means some other program is in front. + return unverifiable(null, evidence.shellForeground !== undefined) + } + } +} + +/** Classify an inspect-process answer; `admit` fences the host record to the current PTY binding. */ +export function observeHostInspection( + inspection: TerminalProcessInspection | string | null | undefined, + admit: (evidence: unknown) => RemoteForegroundEvidence | null +): ForegroundAgentObservation { + if (inspection === null || inspection === undefined) { + return { kind: 'unavailable' } + } + if (typeof inspection === 'string') { + return { kind: 'host-without-evidence' } + } + if (isClientOnlyUnverifiableInspection(inspection)) { + return inspection.reason === 'old_host' + ? { kind: 'host-without-evidence' } + : { kind: 'unavailable' } } - if (recognizeAgentProcess(processName)) { - return 'live' + if (inspection.foregroundProcessEvidence === undefined) { + return { kind: 'host-without-evidence' } } - return isShellProcess(processName) ? 'exited' : 'unverifiable' + return { kind: 'host-evidence', evidence: admit(inspection.foregroundProcessEvidence) } } diff --git a/src/shared/terminal-side-effect-facts.ts b/src/shared/terminal-side-effect-facts.ts index 78699e485ba3..3b5807059adb 100644 --- a/src/shared/terminal-side-effect-facts.ts +++ b/src/shared/terminal-side-effect-facts.ts @@ -18,7 +18,8 @@ export type TerminalSideEffectFact = | { kind: 'bell' } | { kind: 'agent-working' } | { kind: 'agent-idle'; title: string; staleWorkingTitleClear?: boolean } - | { kind: 'agent-exited'; evidence?: 'foreground-shell' } + /** `evidence` is telemetry only; every host exit fact is the host's decision. */ + | { kind: 'agent-exited'; evidence?: 'foreground-shell' | 'command-finished' } /** OSC 133;D — foreground shell command exited (exit code best-effort). */ | { kind: 'command-finished'; exitCode: number | null } /** Carries the parsed link so the renderer store consumer never re-parses From 932fe16c7e634eef9d6f92677f2a07684837db40 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:44:09 -0700 Subject: [PATCH 08/31] fix(terminal): keep Chat through slow agent startup with one exit publisher A visible-pane sample taken while a launched agent was still booting saw the shell and published an agent exit, flipping Chat back to Terminal. The renderer now separates retiring a launch/hook expectation from an agent exit: an exit needs an agent observed in this pane (a read or its own status title) or the shell's 133;D ending the command. The renderer publishes exits only when it is the byte parser; with main as the parser, main's fact is the one publisher. Host agent-exited facts are the host's decision, so an old host's unmarked fact is accepted instead of re-judged by a read that could never succeed. Untracked panes (WSL) clear the typed-command agent on 133;D again, and the parked byte parser no longer carries a dead exit callback. --- .../agent-exit-observation.test.ts | 172 ++++++++++++++++-- .../parked-terminal-byte-watcher.ts | 13 +- ...nnection-foreground-agent-sampling.test.ts | 29 +++ .../agent-idle-working-handlers.ts | 4 +- .../pty-connection/pane-agent-identity.ts | 9 +- .../pty-connection/terminal-keydown-fit.ts | 1 - .../terminal-side-effect-facts-handler.ts | 8 +- 7 files changed, 206 insertions(+), 30 deletions(-) diff --git a/src/renderer/src/components/terminal-pane/agent-exit-observation.test.ts b/src/renderer/src/components/terminal-pane/agent-exit-observation.test.ts index 9427296dabb4..d8813769fde2 100644 --- a/src/renderer/src/components/terminal-pane/agent-exit-observation.test.ts +++ b/src/renderer/src/components/terminal-pane/agent-exit-observation.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { createPtyOutputTitleObserver } from './pty-output-title-observer' import { createPaneForegroundAgentTracker } from './pane-foreground-agent-tracker' +import type { RemoteForegroundEvidence } from '../../../../shared/foreground-process-evidence' import { _dispatchTerminalSideEffectBatchForTest, _resetTerminalSideEffectFactConsumersForTest, @@ -25,25 +26,14 @@ describe('agent exit observation', () => { expect(candidate).toHaveBeenCalledTimes(2) observer.reset() }) - it('treats an old host exit fact as a candidate, not a confirmed exit', () => { + it('accepts an old host exit fact as that host decided it', () => { const exited = vi.fn() - const candidate = vi.fn() - registerTerminalSideEffectFactConsumer({ - ptyId: 'pty-1', - callbacks: { onAgentExited: exited, onAgentExitCandidate: candidate } - }) + registerTerminalSideEffectFactConsumer({ ptyId: 'pty-1', callbacks: { onAgentExited: exited } }) _dispatchTerminalSideEffectBatchForTest({ ptyId: 'pty-1', seq: 1, facts: [{ kind: 'agent-exited' }] }) - expect(exited).not.toHaveBeenCalled() - expect(candidate).toHaveBeenCalledOnce() - _dispatchTerminalSideEffectBatchForTest({ - ptyId: 'pty-1', - seq: 2, - facts: [{ kind: 'agent-exited', evidence: 'foreground-shell' }] - }) expect(exited).toHaveBeenCalledOnce() }) it.each([null, '', 'node.exe', 'unknown-tool'])( @@ -76,4 +66,160 @@ describe('agent exit observation', () => { tracker.dispose() } ) + + describe('startup and remote hosts', () => { + const SSH_PTY_ID = 'ssh:conn-1@@pty-1' + let epoch = 0 + + function remoteTracker(evidence: (rows: 'shell' | 'agent') => unknown) { + let foreground: 'shell' | 'agent' = 'agent' + const read = vi.fn(async () => ({ + foregroundProcess: null, + hasChildProcesses: false, + foregroundProcessEvidence: evidence(foreground) + })) + const shell = vi.fn() + const publish = vi.fn() + const tracker = createPaneForegroundAgentTracker({ + getPtyId: () => SSH_PTY_ID, + isTrackablePtyId: () => true, + isRemotePtyId: () => true, + getExpectedIncarnationId: () => 'inc-1', + readForegroundProcess: read, + confirmForegroundProcess: read, + publish, + hasKnownAgentIdentity: () => true, + onConfirmedShellForeground: shell + }) + return { + tracker, + read, + shell, + publish, + exitAgent: () => { + foreground = 'shell' + } + } + } + + /** The shapes `resolveRemoteForegroundEvidence` emits (its own tests pin them). */ + function posixEvidence( + foreground: 'shell' | 'agent', + host: 'posix' | 'windows' = 'posix' + ): RemoteForegroundEvidence { + epoch += 1 + const observation = { + ptyId: 'pty-1', + ptyIncarnationId: 'inc-1', + authorityGeneration: 'host-1', + observationEpoch: epoch, + capturedAgeMs: 0 + } + if (host === 'windows') { + return { + ...observation, + verdict: 'unverifiable', + reason: 'windows_ssh_foreground_unavailable' + } + } + return { + ...observation, + verdict: 'live', + processName: foreground === 'agent' ? 'claude' : null, + shellForeground: foreground === 'shell', + fence: { + platform: 'posix', + shellPid: 10, + shellStartTime: '100', + tty: '/dev/pts/1', + foregroundPgid: foreground === 'shell' ? 10 : 11, + ...(foreground === 'agent' ? { process: { pid: 11, startTime: '101' } } : {}) + } + } + } + + it('retires a launch expectation on a boot-time shell sample without calling it an exit', async () => { + vi.useFakeTimers() + const shell = vi.fn() + const read = vi.fn().mockResolvedValue('zsh') + const tracker = createPaneForegroundAgentTracker({ + getPtyId: () => 'pty-1', + isTrackablePtyId: () => true, + readForegroundProcess: read, + confirmForegroundProcess: read, + publish: vi.fn(), + onConfirmedShellForeground: shell + }) + tracker.onVisiblePtyBound(true) + await vi.advanceTimersByTimeAsync(8000) + expect(shell).toHaveBeenCalledExactlyOnceWith('visible-pty', false) + tracker.dispose() + }) + + it('calls a shell after an observed agent title an exit', async () => { + vi.useFakeTimers() + const shell = vi.fn() + const read = vi.fn().mockResolvedValue('zsh') + const tracker = createPaneForegroundAgentTracker({ + getPtyId: () => 'pty-1', + isTrackablePtyId: () => true, + readForegroundProcess: read, + confirmForegroundProcess: read, + publish: vi.fn(), + onConfirmedShellForeground: shell + }) + tracker.onAgentExitCandidate() + await vi.advanceTimersByTimeAsync(8000) + expect(shell).toHaveBeenCalledExactlyOnceWith('visible-pty', true) + tracker.dispose() + }) + + it('confirms an SSH exit from host shell evidence at the first read after 133;D', async () => { + vi.useFakeTimers() + const h = remoteTracker((foreground) => posixEvidence(foreground)) + h.tracker.onCommandFinished() + await vi.advanceTimersByTimeAsync(400) + expect(h.shell).not.toHaveBeenCalled() + h.exitAgent() + h.tracker.onCommandFinished() + await vi.advanceTimersByTimeAsync(400) + expect(h.shell).toHaveBeenCalledExactlyOnceWith('command-finished', true) + expect(h.read).toHaveBeenCalledTimes(2) + h.tracker.dispose() + }) + + it.each([ + [ + 'an old host without the shell field', + (foreground: 'shell' | 'agent') => { + const evidence = posixEvidence(foreground) + if (evidence.verdict !== 'live') { + return evidence + } + const { shellForeground: _omitted, ...legacy } = evidence + return legacy + } + ], + ['an SSH-to-Windows host', () => posixEvidence('shell', 'windows')] + ])('retires the agent at the 133;D on %s', async (_label, evidence) => { + vi.useFakeTimers() + const h = remoteTracker(evidence) + h.exitAgent() + h.tracker.onCommandFinished() + await vi.advanceTimersByTimeAsync(400) + expect(h.shell).toHaveBeenCalledExactlyOnceWith('command-finished', true) + expect(h.read).toHaveBeenCalledOnce() + h.tracker.dispose() + }) + + it('keeps the agent when the host could not be read at the 133;D', async () => { + vi.useFakeTimers() + const h = remoteTracker(() => undefined) + h.read.mockRejectedValue(new Error('transport lost')) + h.tracker.onCommandFinished() + await vi.advanceTimersByTimeAsync(8000) + expect(h.shell).not.toHaveBeenCalled() + h.tracker.dispose() + }) + }) }) diff --git a/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.ts b/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.ts index c79023f48735..3bf95c9118f5 100644 --- a/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.ts +++ b/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.ts @@ -177,15 +177,15 @@ export function startParkedTerminalByteWatcher( if (pendingBellNotification) { scheduleTerminalBellNotification() } - }, - onAgentExited: (): void => { - commandStatusPolicy.onAgentExited() - // Only a host-confirmed exit retires the parked session. - useAppStore.getState().setCacheTimerStartedAt(paneKey, null) } } + // Why facts only: a parked byte parser has no process read, so its neutral titles prove nothing. + const onHostConfirmedAgentExit = (): void => { + commandStatusPolicy.onAgentExited() + useAppStore.getState().setCacheTimerStartedAt(paneKey, null) + } - // Why: command-lifecycle signals drive store-level policy only (git nudge, SSH same-turn + // Why: command-lifecycle signals drive store-level policy only (git nudge, confirmed-exit // status drop, Command Code seed/settle); the pane-coupled parts stay with the mounted pane. const commandStatusPolicy = createParkedTerminalCommandStatusPolicy({ ptyId, @@ -240,6 +240,7 @@ export function startParkedTerminalByteWatcher( // Why: ordinary park already has a pane-owned title; the flag below requests a snapshot only when no pane did. callbacks: { ...sideEffectCallbacks, + onAgentExited: onHostConfirmedAgentExit, onCommandFinished: commandStatusPolicy.onCommandFinished, onCommandCodeWorking: commandStatusPolicy.onCommandCodeWorking, onCommandCodeDone: commandStatusPolicy.onCommandCodeDone, diff --git a/src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-sampling.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-sampling.test.ts index 50e1b4a0f8b9..642f7e516c7c 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-sampling.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-sampling.test.ts @@ -549,6 +549,35 @@ describe('connectPanePty', () => { expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, cacheKey)).toBe('alt-enter') }) + it.each([false, true])( + 'keeps Chat through a slow agent boot that a visible sample sees as a shell (main authority %s)', + async (mainAuthority) => { + vi.useFakeTimers() + mockStoreState.settings = { + ...mockStoreState.settings, + terminalMainSideEffectAuthority: mainAuthority + } + // The launched agent has not started yet, so every rung of the ladder sees the shell. + vi.mocked(window.api.pty.confirmForegroundProcess).mockResolvedValue('zsh') + const ptyId = `pty-slow-agent-boot-${mainAuthority}` + const tabId = `tab-${ptyId}` + mockStoreState.tabsByWorktree = { 'wt-1': [{ id: tabId, ptyId }] } + + const { deps } = await connectRestoredPaneForForegroundSampling({ + ptyId, + tabId, + launchAgent: 'claude' + }) + await vi.advanceTimersByTimeAsync( + VISIBLE_PTY_SETTLE_MS + WRAPPER_RESOLVE_RETRY_MS + SECOND_WRAPPER_RETRY_MS + ) + await flushAsyncTicks() + + expect(vi.mocked(window.api.pty.confirmForegroundProcess)).toHaveBeenCalled() + expect(deps.onAgentExitedRef.current).not.toHaveBeenCalled() + } + ) + it('fails closed when a warm reattach has no persisted launch identity', async () => { vi.useFakeTimers() const ptyId = 'pty-reattach-missing-launch-identity' diff --git a/src/renderer/src/components/terminal-pane/pty-connection/agent-idle-working-handlers.ts b/src/renderer/src/components/terminal-pane/pty-connection/agent-idle-working-handlers.ts index 0ec5572193b1..53b1a0a3a0e8 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/agent-idle-working-handlers.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/agent-idle-working-handlers.ts @@ -34,11 +34,11 @@ export function installAgentIdleWorkingHandlers(session: ConnectPanePtySession): session.onAgentExitCandidate = (): void => { // Why: the title tracker re-arms on unknown; with no identity left there is nothing to confirm. if (session.paneHasKnownAgentIdentity()) { - session.paneForegroundAgentTracker?.onVisiblePtyBound(true) + session.paneForegroundAgentTracker?.onAgentExitCandidate() } } session.onAgentExited = (): void => { - // Only host-confirmed shell evidence reaches this callback. + // Only a confirmed exit reaches this: the host's fact, or the renderer's own read in byte mode. session.deps.onAgentExitedRef.current(session.pane.leafId) session.clearSuppressedTitleSideEffects() session.clearCommandInferredPaneAgent() 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 3e23536805c0..25624b936557 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 @@ -181,8 +181,11 @@ export function installPaneAgentIdentity(session: ConnectPanePtySession): void { } }, hasKnownAgentIdentity: session.paneHasKnownAgentIdentity, - onConfirmedShellForeground: (reason) => { - session.onAgentExited?.() + onConfirmedShellForeground: (reason, agentExited) => { + // Why: with main as the parser its host fact is the one exit publisher; bytes make it ours. + if (agentExited && !session.mainSideEffectAuthority) { + session.onAgentExited?.() + } // Why: a confirmed local shell proves any hibernation record for this pane is stale; // otherwise the tab resolver can repaint the exited agent from sleeping occupancy. const state = useAppStore.getState() @@ -262,6 +265,8 @@ export function installPaneAgentIdentity(session: ConnectPanePtySession): void { } session.deferredCommandFinishedStatusDrop = null session.deferredConfirmedShellReconcile = null + // Why: no confirming read is owed (untracked WSL panes, or no agent identity), so the D stands. + session.clearCommandInferredPaneAgentAfterPtySideEffects() dropStatus() } session.sampleVisiblePaneForegroundAgent = (forceRoutingConfirmation = false): void => { diff --git a/src/renderer/src/components/terminal-pane/pty-connection/terminal-keydown-fit.ts b/src/renderer/src/components/terminal-pane/pty-connection/terminal-keydown-fit.ts index febdad7c5f0d..f454b247e4f1 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/terminal-keydown-fit.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/terminal-keydown-fit.ts @@ -129,7 +129,6 @@ export function installTerminalKeydownFit(session: ConnectPanePtySession): void onAgentBecameIdle: session.onAgentBecameIdle, onAgentBecameWorking: session.onAgentBecameWorking, onAgentExited: session.onAgentExited, - onAgentExitCandidate: session.onAgentExitCandidate, onCommandFinished: session.handleCommandFinished, onPrLink: (link) => useAppStore diff --git a/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts b/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts index 730ac689d737..ca65e6cb5548 100644 --- a/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts +++ b/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts @@ -77,7 +77,6 @@ export type TerminalSideEffectFactConsumerCallbacks = { onAgentBecameIdle?: (title: string, meta?: { staleWorkingTitleClear?: boolean }) => void onAgentBecameWorking?: () => void onAgentExited?: () => void - onAgentExitCandidate?: () => void /** OSC 133;D — same policy hook the byte-mode commandLifecycle drove * (stale agent-status row drop + interrupt-inference coordination). */ onCommandFinished?: (bestEffortExitCode: number | null) => void @@ -131,11 +130,8 @@ function applyLiveFact(entry: ConsumerEntry, fact: TerminalSideEffectFact, seq: ) return case 'agent-exited': - if (fact.evidence === 'foreground-shell') { - entry.callbacks.onAgentExited?.() - } else { - entry.callbacks.onAgentExitCandidate?.() - } + // Why: the execution host decided; re-judging an old host's fact here could never succeed. + entry.callbacks.onAgentExited?.() return case 'command-finished': entry.callbacks.onCommandFinished?.(fact.exitCode) From d8833079d04ef8e1cc2325df08ad70a3ebe39166 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:44:29 -0700 Subject: [PATCH 09/31] test(terminal): fix renderer typecheck errors in pane exit suites The launch-identity assertion read an optional identity without a guard, and the exit-candidate test called a transport mock whose type is not callable; read the bound PTY id from the mock's recorded result instead. --- .../pty-connection-command-finished-cleanup.test.ts | 2 +- .../pty-connection-main-side-effect-authority.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 f17a1f0c0d1e..2a0a174b17b9 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 @@ -515,7 +515,7 @@ describe('connectPanePty', () => { // The whole unavailable ladder ran and left the launch identity in place. expect(window.api.pty.confirmForegroundProcess).toHaveBeenCalledTimes(3) expect(mockStoreState.paneForegroundAgentByPaneKey[paneKey]).toBeUndefined() - expect(mockStoreState.agentLaunchConfigByPaneKey[paneKey]?.identity.agentType).toBe('droid') + expect(mockStoreState.agentLaunchConfigByPaneKey[paneKey]?.identity?.agentType).toBe('droid') expect(window.api.agentStatus.reconcileEndedProcess).not.toHaveBeenCalled() }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-main-side-effect-authority.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-main-side-effect-authority.test.ts index e3226206fa86..56a25220192b 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-main-side-effect-authority.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-main-side-effect-authority.test.ts @@ -696,7 +696,7 @@ describe('connectPanePty', () => { if (typeof onAgentExitCandidate !== 'function') { throw new Error('Expected onAgentExitCandidate to be registered') } - const ptyId = transport.getPtyId() + const ptyId: unknown = transport.getPtyId.mock.results.at(-1)?.value const reads = (): number => [window.api.pty.getForegroundProcess, window.api.pty.confirmForegroundProcess] .flatMap((read) => vi.mocked(read).mock.calls) From 2187da3a5226fe55e46596e0b7dce7b01f53c25d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:46:00 -0700 Subject: [PATCH 10/31] test(runtime): name SSH foreground cases by the identity they leave --- ...runtime-pty-foreground-agent-evidence.test.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/main/runtime/runtime-pty-foreground-agent-evidence.test.ts b/src/main/runtime/runtime-pty-foreground-agent-evidence.test.ts index 7b5bbc295463..ca620f45c414 100644 --- a/src/main/runtime/runtime-pty-foreground-agent-evidence.test.ts +++ b/src/main/runtime/runtime-pty-foreground-agent-evidence.test.ts @@ -86,14 +86,14 @@ describe('foreground identity on unknown observations', () => { expect(h.pty.foregroundAgent).toBe('claude') }) it.each([ - ['an old relay without evidence', 'missing', 'claude'], - ['stale evidence', 'stale', 'claude'], - ['evidence for another incarnation', 'wrong-incarnation', 'claude'], - ['an SSH-to-Windows host', 'windows', 'claude'], - ['another program in front', 'vim', 'claude'], - ['an old host that cannot mark the shell', 'old-host-shell', 'claude'], - ['the shell back at its prompt', 'shell', null] - ] as const)('on SSH, %s leaves foregroundAgent %j', async (_label, kind, expected) => { + ['an old relay without evidence', 'claude', 'missing'], + ['stale evidence', 'claude', 'stale'], + ['evidence for another incarnation', 'claude', 'wrong-incarnation'], + ['an SSH-to-Windows host', 'claude', 'windows'], + ['another program in front', 'claude', 'vim'], + ['an old host that cannot mark the shell', 'claude', 'old-host-shell'], + ['the shell back at its prompt', null, 'shell'] + ] as const)('on SSH, %s leaves foregroundAgent %j', async (_label, expected, kind) => { const h = setup(true) h.replace({ write: () => true, From bd75fbf354688a808da080f5e38755caccb9f66d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:55:50 -0700 Subject: [PATCH 11/31] fix(runtime): keep the agent send guard failing closed on unconfirmed evidence The terminal agent-status query is what assertTerminalAgentSendable asks before typing a prompt into a pane. The PR had made it report a running agent when a shell title or shell foreground could not be confirmed, which would let an orchestrated send type a prompt into a bare shell. Restore the base query: a shell or Claude management title, or an unconfirmed shell foreground, means no agent is running for sending. Display surfaces keep identity on unknown; this guard refuses to act without positive evidence. Also align the runtime specs with exit confirmation reading a fresh process only after the cached read fails to name an agent, and cover main confirming an exit at the shell's 133;D. These specs only run with the runtime aggregator, which fails to import on the base (missing codex provider-verdicts module). --- .../agent-status-and-waits-part-02.spec.ts | 2 + .../terminal-side-effect-facts.spec.ts | 40 ++++++++- ...ime-terminal-agent-status-evidence.test.ts | 88 ------------------- .../runtime-terminal-agent-status-query.ts | 25 +++--- 4 files changed, 50 insertions(+), 105 deletions(-) delete mode 100644 src/main/runtime/runtime-terminal-agent-status-evidence.test.ts diff --git a/src/main/runtime/orca-runtime-tests/agent-status-and-waits-part-02.spec.ts b/src/main/runtime/orca-runtime-tests/agent-status-and-waits-part-02.spec.ts index b7a784e62828..5cf7a7841592 100644 --- a/src/main/runtime/orca-runtime-tests/agent-status-and-waits-part-02.spec.ts +++ b/src/main/runtime/orca-runtime-tests/agent-status-and-waits-part-02.spec.ts @@ -459,6 +459,8 @@ describe('OrcaRuntimeService', () => { : 'Hooks need review. Press enter to confirm\n', Date.now() + 1000 ) + // The title transition's own identity refresh may confirm a non-agent cached name. + await vi.waitFor(() => expect(runtime.ptyForegroundProcessReads.size).toBe(0)) getForegroundProcess.mockClear() confirmForegroundProcess.mockClear() 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 f9cfa5a81b45..2a24d16ed98f 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 @@ -415,11 +415,13 @@ describe('terminal side-effect fact channel', () => { batches.length = 0 const getForegroundProcess = vi.fn().mockResolvedValueOnce('codex') + // Only a fresh read may certify the shell; the cached read above recognized the agent. + const confirmForegroundProcess = vi.fn().mockResolvedValue('zsh') runtime.setPtyController({ write: () => true, kill: () => true, getForegroundProcess, - confirmForegroundProcess: getForegroundProcess + confirmForegroundProcess }) runtime.onPtyData('pty-1', '\x1b]0;bichir\x07', 101) @@ -450,6 +452,7 @@ describe('terminal side-effect fact channel', () => { }) ) expect(getForegroundProcess).toHaveBeenCalledTimes(2) + expect(confirmForegroundProcess).toHaveBeenCalledOnce() }) it('does not confirm an agent exit from a foreground read predating its title', async () => { @@ -460,11 +463,12 @@ describe('terminal side-effect fact channel', () => { resolveStaleRead = resolve }) const getForegroundProcess = vi.fn().mockReturnValueOnce(staleRead).mockResolvedValueOnce('zsh') + const confirmForegroundProcess = vi.fn().mockResolvedValue('zsh') runtime.setPtyController({ write: () => true, kill: () => true, getForegroundProcess, - confirmForegroundProcess: getForegroundProcess + confirmForegroundProcess }) runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;Codex ready\x07') @@ -482,6 +486,38 @@ describe('terminal side-effect fact channel', () => { ) }) + it('confirms an agent exit at the shell 133;D when the shell sets no title', async () => { + const { runtime, batches } = createSideEffectRuntime() + syncSinglePty(runtime) + const getForegroundProcess = vi.fn().mockResolvedValue('zsh') + const confirmForegroundProcess = vi.fn().mockResolvedValue('zsh') + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess, + confirmForegroundProcess + }) + runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;Codex ready\x07') + batches.length = 0 + + runtime.onPtyData('pty-1', '\x1b]133;D;0\x07', 100) + + await vi.waitFor(() => + expect(batches.flatMap((batch) => batch.facts)).toContainEqual({ + kind: 'agent-exited', + evidence: 'foreground-shell' + }) + ) + const reads = confirmForegroundProcess.mock.calls.length + // The agent is retired, so an ordinary command's 133;D pays no read and publishes no exit. + runtime.onPtyData('pty-1', '\x1b]133;D;0\x07', 101) + await Promise.resolve() + expect(confirmForegroundProcess).toHaveBeenCalledTimes(reads) + expect( + batches.flatMap((batch) => batch.facts).filter((fact) => fact.kind === 'agent-exited') + ).toHaveLength(1) + }) + it('treats synchronous foreground read failures as unavailable', async () => { const { runtime, batches } = createSideEffectRuntime() syncSinglePty(runtime) diff --git a/src/main/runtime/runtime-terminal-agent-status-evidence.test.ts b/src/main/runtime/runtime-terminal-agent-status-evidence.test.ts deleted file mode 100644 index 00b39fa58c22..000000000000 --- a/src/main/runtime/runtime-terminal-agent-status-evidence.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { RuntimeTerminalAgentStatusQuery } from './runtime-terminal-agent-status-query' -import type { RuntimePtyController } from './runtime-pty-controller-contract' - -function statusQuery(title: string, confirmForegroundProcess: () => Promise) { - const controller: RuntimePtyController = { - write: () => true, - kill: () => true, - getForegroundProcess: async () => 'zsh', - confirmForegroundProcess - } - const query = new RuntimeTerminalAgentStatusQuery({ - getController: () => controller, - getLivePty: () => null, - getLiveLeaf: () => { - throw new Error('unused') - }, - getPrimaryLeaf: () => null, - getTabTitle: () => null, - getExplicitStatus: () => ({ status: 'idle', updatedAt: Date.now() }), - getLifecycleStatus: () => null, - isRunning: async () => true - }) - vi.spyOn(query, 'getPtyId').mockReturnValue('pty-1') - vi.spyOn(query, 'getSnapshot').mockReturnValue({ - waitText: '', - waitBlockedAt: null, - title, - titleStatus: null, - titleStatusIsLive: true - }) - return query -} - -describe('Claude management title', () => { - it('reports no running agent under the claude agents screen despite a fresh hook', async () => { - const query = statusQuery('claude agents', async () => 'claude') - expect(await query.getStatus('terminal')).toEqual({ - handle: 'terminal', - isRunningAgent: false, - status: null - }) - }) -}) - -describe('status survives an unconfirmed shell observation', () => { - it.each([null, '', 'node.exe', 'claude', 'zsh', 'throw'])( - 'confirms the foreground before retiring status: %j', - async (result) => { - const controller: RuntimePtyController = { - write: () => true, - kill: () => true, - getForegroundProcess: async () => 'zsh', - confirmForegroundProcess: async () => { - if (result === 'throw') { - throw new Error('offline') - } - return result - } - } - const query = new RuntimeTerminalAgentStatusQuery({ - getController: () => controller, - getLivePty: () => null, - getLiveLeaf: () => { - throw new Error('unused') - }, - getPrimaryLeaf: () => null, - getTabTitle: () => null, - getExplicitStatus: () => ({ status: 'idle', updatedAt: Date.now() }), - getLifecycleStatus: () => null, - isRunning: async () => true - }) - vi.spyOn(query, 'getPtyId').mockReturnValue('pty-1') - vi.spyOn(query, 'getSnapshot').mockReturnValue({ - waitText: '', - waitBlockedAt: null, - title: 'zsh', - titleStatus: null, - titleStatusIsLive: true - }) - expect(await query.getStatus('terminal')).toEqual({ - handle: 'terminal', - isRunningAgent: result !== 'zsh', - status: result === 'zsh' ? null : 'idle' - }) - } - ) -}) diff --git a/src/main/runtime/runtime-terminal-agent-status-query.ts b/src/main/runtime/runtime-terminal-agent-status-query.ts index cc44a4c1cba6..5a06ea3f688b 100644 --- a/src/main/runtime/runtime-terminal-agent-status-query.ts +++ b/src/main/runtime/runtime-terminal-agent-status-query.ts @@ -1,16 +1,18 @@ -import { judgeForegroundAgent } from '../../shared/foreground-agent-verdict' import { detectAgentStatusFromTitle, - isClaudeManagementTitle, isOpenCodeNativeTitle, isQuarterCircleSpinnerOnlyAgentTitle, isShellProcess, type AgentStatus } from '../../shared/agent-detection' +import { recognizeAgentProcess } from '../../shared/agent-process-recognition' import type { RuntimeTerminalAgentStatus } from '../../shared/runtime-types' import type { RuntimePtyController } from './runtime-pty-controller-contract' import type { RuntimeLeafRecord, RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' -import { getLatestAgentCandidateTitleInfo } from './runtime-worktree-status-projection' +import { + terminalTitleBlocksExplicitAgentStatus, + getLatestAgentCandidateTitleInfo +} from './runtime-worktree-status-projection' import { detectTerminalWaitBlockedReason } from './terminal-wait-detection' import { getTerminalState } from './terminal-wait-results' import { buildTerminalWaitText } from './terminal-wait-tail-state' @@ -95,7 +97,7 @@ export class RuntimeTerminalAgentStatusQuery { // Why: permission titles can linger after hooks report the agent resumed. // Fresh hook state is tighter, but current shell/management evidence wins. const isRunningAgent = - !(terminal.title !== null && isClaudeManagementTitle(terminal.title)) && + !terminalTitleBlocksExplicitAgentStatus(terminal.title) && !(await this.terminalHasShellForegroundProcess(handle, ptyId)) this.assertTerminalAgentStatusPtyBinding(handle, ptyId) return { @@ -239,26 +241,19 @@ export class RuntimeTerminalAgentStatusQuery { return false } const confirmationController = this.deps.getController() - if ( - confirmationController !== controller || - !confirmationController?.confirmForegroundProcess - ) { - return false + if (!confirmationController?.confirmForegroundProcess) { + return true } let confirmedProcess: string | null try { confirmedProcess = await confirmationController.confirmForegroundProcess(ptyId) } catch { this.assertTerminalAgentStatusPtyBinding(handle, ptyId) - return false + return true } this.assertTerminalAgentStatusPtyBinding(handle, ptyId) // Why: hook identity is generic; strong provider evidence only needs to // prove that some recognized agent still owns this exact PTY. - return ( - this.deps.getController() === controller && - judgeForegroundAgent({ kind: 'process-name', processName: confirmedProcess }).verdict === - 'exited' - ) + return recognizeAgentProcess(confirmedProcess) === null } } From e1dbbad7eaa08e2c59f09ffa45d5224ac6e02703 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:29:17 -0700 Subject: [PATCH 12/31] fix(ssh): let host evidence see a shell leading the foreground group, not only the PTY root macOS PTYs are rooted at login(1), which forks the shell into its own process group, so a paired macOS host never reported the shell at its prompt and a real agent exit there never cleared the icon, status or Chat. A nested shell back at its prompt had the same gap on SSH. The rule now checks the foreground group's leader and refuses when an agent sits above it. --- ...foreground-process-remote-evidence.test.ts | 68 +++++++++++++++++++ ...gent-foreground-process-remote-evidence.ts | 39 +++++++++-- src/shared/foreground-process-evidence.ts | 6 +- 3 files changed, 105 insertions(+), 8 deletions(-) diff --git a/src/main/providers/agent-foreground-process-remote-evidence.test.ts b/src/main/providers/agent-foreground-process-remote-evidence.test.ts index 3f6d52f50718..dcfbcea4f170 100644 --- a/src/main/providers/agent-foreground-process-remote-evidence.test.ts +++ b/src/main/providers/agent-foreground-process-remote-evidence.test.ts @@ -132,5 +132,73 @@ describe('host-stamped remote foreground resolver', () => { shellForeground: false }) }) + + // Shape captured from `ps` on macOS: login(1) is the PTY root and forks the shell into its own group. + const loginRooted = (extra: ProcessTableRow[] = [], tpgid = 101): ProcessTableRow[] => [ + { + pid: 100, + ppid: 1, + pgid: 100, + tpgid, + tty: 'ttys001', + startTime: 'root-start', + stat: 'Ss', + command: + '/usr/bin/login -flpq user /bin/bash --noprofile --norc -p -c export SHELL="$1"; shift; exec -l -- "$@" orca-tcc-login /bin/zsh /bin/zsh -l' + }, + { + pid: 101, + ppid: 100, + pgid: 101, + tpgid, + tty: 'ttys001', + startTime: 'shell-start', + stat: 'S+', + command: '-/bin/zsh -l' + }, + ...extra + ] + const child = (pid: number, ppid: number, command: string, tpgid: number): ProcessTableRow => ({ + pid, + ppid, + pgid: pid, + tpgid, + tty: 'ttys001', + startTime: `start-${pid}`, + stat: 'S+', + command + }) + + it('marks a shell at its prompt under a login(1) PTY root', () => { + expect(resolve(loginRooted())).toMatchObject({ + verdict: 'live', + processName: null, + shellForeground: true + }) + }) + + it('marks a nested shell back at its prompt after the agent it ran exits', () => { + expect(resolve(loginRooted([child(102, 101, 'bash', 102)], 102))).toMatchObject({ + verdict: 'live', + processName: null, + shellForeground: true + }) + }) + + it('does not mark an interactive shell an agent hosts', () => { + const rows = loginRooted( + [child(102, 101, 'node /opt/claude', 103), child(103, 102, 'bash', 103)], + 103 + ) + expect(resolve(rows)).toMatchObject({ verdict: 'live', shellForeground: false }) + }) + + it('does not mark a login(1) root whose shell runs another program', () => { + expect(resolve(loginRooted([child(102, 101, 'vim notes.md', 102)], 102))).toMatchObject({ + verdict: 'live', + processName: null, + shellForeground: false + }) + }) }) }) diff --git a/src/main/providers/agent-foreground-process-remote-evidence.ts b/src/main/providers/agent-foreground-process-remote-evidence.ts index 1bbdc45561e0..8b7d52433d4e 100644 --- a/src/main/providers/agent-foreground-process-remote-evidence.ts +++ b/src/main/providers/agent-foreground-process-remote-evidence.ts @@ -122,14 +122,43 @@ export function resolveRemoteForegroundEvidenceFromRows( verdict: 'live', processName: candidate?.name.processName ?? null, fence, - // Why the root's own name: an agent exec'd as the PTY command also owns its foreground group. - shellForeground: - !candidate && - root.tpgid === root.pgid && - isShellProcess(getFirstCommandToken(root.command).replace(/^-/, '')) + shellForeground: !candidate && shellLeadsForegroundGroup(index, root) } } +/** + * The terminal's foreground group leader is a shell with no agent above it up to the PTY root. + * Why the leader, not the root: macOS PTYs are rooted at login(1), and a nested shell leads its + * own group. Why the ancestor walk: an agent exec'd as the PTY command, or one hosting an + * interactive shell, is still the process the user is working in. + */ +function shellLeadsForegroundGroup(index: ProcessTableIndex, root: ProcessTableRow): boolean { + const leader = index.byPid.get(root.tpgid ?? -1) + if ( + !leader || + leader.pgid !== root.tpgid || + leader.tty !== root.tty || + !isShellProcess(getFirstCommandToken(leader.command).replace(/^-/, '')) + ) { + return false + } + let row = leader + for (let depth = 0; depth < index.rows.length; depth += 1) { + if (recognizeAgentProcessFromCommandLine(row.command)) { + return false + } + if (row.pid === root.pid) { + return true + } + const parent = index.byPid.get(row.ppid) + if (!parent) { + return false + } + row = parent + } + return false +} + function collectDescendantRows(index: ProcessTableIndex, rootPid: number): ProcessTableRow[] { const result: ProcessTableRow[] = [] const seen = new Set([rootPid]) diff --git a/src/shared/foreground-process-evidence.ts b/src/shared/foreground-process-evidence.ts index 06e10a4cca10..7182d06536e9 100644 --- a/src/shared/foreground-process-evidence.ts +++ b/src/shared/foreground-process-evidence.ts @@ -79,9 +79,9 @@ export type RemoteForegroundEvidence = verdict: 'live' processName: string | null fence: PosixFence | WindowsFence - /** Host-computed: the PTY root is a shell that owns the terminal's foreground group and no - * agent is recognized in it. Absent on hosts that predate the field, which then cannot - * certify an agent exit by read. */ + /** Host-computed: a shell leads the terminal's foreground group with no agent in or above + * it. Absent on hosts that predate the field, which then cannot certify an agent exit by + * read. */ shellForeground?: boolean } & HostObservation) | ({ verdict: 'unverifiable'; reason: string } & HostObservation) From f8c0b306f656945ce8f8f2d2acc06167e98278cf Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:29:44 -0700 Subject: [PATCH 13/31] fix(runtime): retire an agent on its own title where an SSH host can neither read nor mark commands An SSH relay on Windows cannot read the foreground and injects no OSC 133, so neither a read nor the shell's command-finished mark could ever retire an agent there, and a real exit left Chat and the cache timer up. The agent's own idle-to-neutral title is that host's exit signal again, as before this PR. --- ...ntime-serialize-agent-prompt-submission.ts | 25 +++++++++++--- .../terminal-agent-exit-evidence.test.ts | 34 +++++++++++++++++++ src/shared/terminal-side-effect-facts.ts | 2 +- 3 files changed, 56 insertions(+), 5 deletions(-) diff --git a/src/main/runtime/orca-runtime-serialize-agent-prompt-submission.ts b/src/main/runtime/orca-runtime-serialize-agent-prompt-submission.ts index 5ddf90088f1e..737496e9b615 100644 --- a/src/main/runtime/orca-runtime-serialize-agent-prompt-submission.ts +++ b/src/main/runtime/orca-runtime-serialize-agent-prompt-submission.ts @@ -145,12 +145,25 @@ export class OrcaRuntimeWithSerializeAgentPromptSubmission extends OrcaRuntimeWi } return } + // Why: an SSH relay on Windows neither reads the foreground nor marks commands (OSC 133), + // so there the agent's own idle-to-neutral title is the only exit signal (base behaviour). + const titleIsOnlyExitSignal = + verdict === 'unverifiable' && + !result.judgement.canCertifyExit && + Boolean(current.connectionId) if ( !recoverCompletedHook && result.controller === this.ptyController && - verdict === 'exited' + (verdict === 'exited' || titleIsOnlyExitSignal) ) { - this.publishPtyAgentExit(ptyId, 'title-exit-candidate', 'foreground-shell') + if (titleIsOnlyExitSignal) { + this.ptyForegroundAgent.markExited(ptyId) + } + this.publishPtyAgentExit( + ptyId, + 'title-exit-candidate', + titleIsOnlyExitSignal ? 'agent-title' : 'foreground-shell' + ) } else { this.ptyTitleTrackersByPtyId.get(ptyId)?.tracker.restoreLastAgentExit() } @@ -200,13 +213,17 @@ export class OrcaRuntimeWithSerializeAgentPromptSubmission extends OrcaRuntimeWi private publishPtyAgentExit( ptyId: string, reason: 'title-exit-candidate' | 'command-finished', - evidence: 'foreground-shell' | 'command-finished' + evidence: 'foreground-shell' | 'command-finished' | 'agent-title' ): void { startSpan('terminal.agent-exit-decision', { attributes: { reason, evidenceSource: - evidence === 'foreground-shell' ? 'host-foreground-confirmation' : 'osc-133', + evidence === 'foreground-shell' + ? 'host-foreground-confirmation' + : evidence === 'agent-title' + ? 'osc-title' + : 'osc-133', verdict: 'exited' } }).end() diff --git a/src/main/runtime/terminal-agent-exit-evidence.test.ts b/src/main/runtime/terminal-agent-exit-evidence.test.ts index dba74f850790..62e37a6ba7bc 100644 --- a/src/main/runtime/terminal-agent-exit-evidence.test.ts +++ b/src/main/runtime/terminal-agent-exit-evidence.test.ts @@ -33,6 +33,7 @@ function setup() { lastAgentStatus: string | null launchAgent: string | null foregroundAgent: string | null + connectionId?: string } = { connected: true, incarnationId: 'inc-1', @@ -100,6 +101,39 @@ describe('host-confirmed agent exit', () => { h.tracker.dispose() } ) + it('retires an agent on its own title where the SSH host can neither read nor mark commands', async () => { + const h = setup() + h.pty.connectionId = 'ssh-windows' + h.read.mockResolvedValue({ + controller: h.controller, + judgement: judgeForegroundAgent({ + kind: 'host-evidence', + evidence: { + authorityGeneration: 'host-1', + observationEpoch: 1, + capturedAgeMs: 0, + ptyId: 'pty-1', + ptyIncarnationId: 'inc-1', + verdict: 'unverifiable', + reason: 'windows_ssh_foreground_unavailable' + } + }) + }) + h.tracker.handleChunk('\x1b]0;workspace\x07') + await Promise.resolve() + expect(h.facts).toEqual([{ kind: 'agent-exited', evidence: 'agent-title' }]) + expect(h.markExited).toHaveBeenCalledWith('pty-1') + h.tracker.dispose() + }) + it('keeps a local WSL agent through a neutral title; its shell marks the exit', async () => { + const h = setup() + h.read.mockResolvedValue(readResult(h.controller, 'wsl.exe')) + h.tracker.handleChunk('\x1b]0;workspace\x07') + await Promise.resolve() + expect(h.facts).toEqual([]) + expect(h.markExited).not.toHaveBeenCalled() + h.tracker.dispose() + }) it('ignores an observation after terminal incarnation replacement', async () => { const h = setup() h.read.mockResolvedValue(readResult(h.controller, 'zsh')) diff --git a/src/shared/terminal-side-effect-facts.ts b/src/shared/terminal-side-effect-facts.ts index 3b5807059adb..bbf307fd8c07 100644 --- a/src/shared/terminal-side-effect-facts.ts +++ b/src/shared/terminal-side-effect-facts.ts @@ -19,7 +19,7 @@ export type TerminalSideEffectFact = | { kind: 'agent-working' } | { kind: 'agent-idle'; title: string; staleWorkingTitleClear?: boolean } /** `evidence` is telemetry only; every host exit fact is the host's decision. */ - | { kind: 'agent-exited'; evidence?: 'foreground-shell' | 'command-finished' } + | { kind: 'agent-exited'; evidence?: 'foreground-shell' | 'command-finished' | 'agent-title' } /** OSC 133;D — foreground shell command exited (exit code best-effort). */ | { kind: 'command-finished'; exitCode: number | null } /** Carries the parsed link so the renderer store consumer never re-parses From c8176e245ec13a03d2a6fbc2f8ecd5bb57677b77 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:29:47 -0700 Subject: [PATCH 14/31] refactor(terminal): drop the transport exit option nothing reads Transports now take only the exit candidate; a caller still passing the old option would be silently ignored. --- src/renderer/src/components/terminal-pane/pty-transport-types.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/renderer/src/components/terminal-pane/pty-transport-types.ts b/src/renderer/src/components/terminal-pane/pty-transport-types.ts index d8180e9bb70e..9918497105b5 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-types.ts @@ -289,7 +289,6 @@ export type IpcPtyTransportOptions = { onBell?: () => void onAgentBecameIdle?: (title: string) => void onAgentBecameWorking?: () => void - onAgentExited?: () => void onAgentExitCandidate?: () => void onAgentStatus?: (payload: ParsedAgentStatusPayload) => void } From 78e91821bd1257f213ecb259282600befdcdfd28 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:40:52 -0700 Subject: [PATCH 15/31] test(terminal): type the host inspection mocks with the real inspection types --- .../runtime/runtime-pty-foreground-agent-evidence.test.ts | 6 ++++-- .../terminal-pane/agent-exit-observation.test.ts | 7 +++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/main/runtime/runtime-pty-foreground-agent-evidence.test.ts b/src/main/runtime/runtime-pty-foreground-agent-evidence.test.ts index ca620f45c414..ccb2baea8e07 100644 --- a/src/main/runtime/runtime-pty-foreground-agent-evidence.test.ts +++ b/src/main/runtime/runtime-pty-foreground-agent-evidence.test.ts @@ -4,6 +4,8 @@ import type { RuntimePtyController } from './runtime-pty-controller-contract' import type { RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' import { resolveRemoteForegroundEvidence } from '../providers/agent-foreground-process-batch' import type { ProcessTableRow } from '../../shared/process-table-snapshot' +import type { RemoteForegroundEvidence } from '../../shared/foreground-process-evidence' +import type { TerminalProcessInspection } from '../../shared/terminal-process-inspection' function setup(remote = false) { const pty: Pick< @@ -99,7 +101,7 @@ describe('foreground identity on unknown observations', () => { write: () => true, kill: () => true, getForegroundProcess: async () => 'zsh', - inspectProcess: async () => ({ + inspectProcess: async (): Promise => ({ foregroundProcess: 'zsh', hasChildProcesses: false, ...(kind === 'missing' ? {} : { foregroundProcessEvidence: hostEvidence(kind) }) @@ -121,7 +123,7 @@ describe('foreground identity on unknown observations', () => { /** Host evidence built by the same builder the relay and daemon use, from one process table. */ function hostEvidence( kind: 'stale' | 'wrong-incarnation' | 'windows' | 'vim' | 'old-host-shell' | 'shell' -): unknown { +): RemoteForegroundEvidence { const shell: ProcessTableRow = { pid: 10, ppid: 1, diff --git a/src/renderer/src/components/terminal-pane/agent-exit-observation.test.ts b/src/renderer/src/components/terminal-pane/agent-exit-observation.test.ts index d8813769fde2..8f3a4969617e 100644 --- a/src/renderer/src/components/terminal-pane/agent-exit-observation.test.ts +++ b/src/renderer/src/components/terminal-pane/agent-exit-observation.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { createPtyOutputTitleObserver } from './pty-output-title-observer' import { createPaneForegroundAgentTracker } from './pane-foreground-agent-tracker' import type { RemoteForegroundEvidence } from '../../../../shared/foreground-process-evidence' +import type { TerminalProcessInspection } from '../../../../shared/terminal-process-inspection' import { _dispatchTerminalSideEffectBatchForTest, _resetTerminalSideEffectFactConsumersForTest, @@ -71,9 +72,11 @@ describe('agent exit observation', () => { const SSH_PTY_ID = 'ssh:conn-1@@pty-1' let epoch = 0 - function remoteTracker(evidence: (rows: 'shell' | 'agent') => unknown) { + function remoteTracker( + evidence: (rows: 'shell' | 'agent') => RemoteForegroundEvidence | undefined + ) { let foreground: 'shell' | 'agent' = 'agent' - const read = vi.fn(async () => ({ + const read = vi.fn(async (): Promise => ({ foregroundProcess: null, hasChildProcesses: false, foregroundProcessEvidence: evidence(foreground) From 2c4ecd64fc9280fe59bbb6967834b8dbb6307f4d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:41:11 -0700 Subject: [PATCH 16/31] fix(chat): end an unbound Chat request when the active pane's agent exits A tab left in Chat mode with no bound pane waited for any agent to become eligible, and handleConfirmedAgentExit ignored exits while no pane was bound, so a later unrelated agent run would enter Chat on its own. --- ...terminal-chat-ownership-lifecycle.test.tsx | 22 +++++++++++++++++++ .../use-terminal-pane-chat-state.ts | 10 +++++---- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/components/terminal-pane/terminal-chat-ownership-lifecycle.test.tsx b/src/renderer/src/components/terminal-pane/terminal-chat-ownership-lifecycle.test.tsx index 350c27667fa5..c076cd25aa45 100644 --- a/src/renderer/src/components/terminal-pane/terminal-chat-ownership-lifecycle.test.tsx +++ b/src/renderer/src/components/terminal-pane/terminal-chat-ownership-lifecycle.test.tsx @@ -229,6 +229,28 @@ describe('terminal chat ownership lifecycle', () => { expect(mocks.state.unifiedTabsByWorktree.wt[0].viewMode).toBe('terminal') }) + it('ends an unbound chat request when the active leaf confirms its agent exited', () => { + const titles = mocks.state.runtimePaneTitlesByTabId.tab + mocks.state.runtimePaneTitlesByTabId.tab = { 1: 'zsh', 2: 'zsh' } + try { + const fixture = makeFixture() + const hook = renderHook(() => useFixture(fixture, null)) + expect(hook.result.current.chatLeafId).toBeNull() + expect(mocks.state.unifiedTabsByWorktree.wt[0].viewMode).toBe('chat') + act(() => fixture.onAgentExitedRef.current(RIGHT)) + expect(mocks.state.unifiedTabsByWorktree.wt[0].viewMode).toBe('chat') + act(() => fixture.onAgentExitedRef.current(LEFT)) + expect(mocks.state.unifiedTabsByWorktree.wt[0].viewMode).toBe('terminal') + // A later agent in this pane must not auto-enter the abandoned Chat request. + mocks.state.runtimePaneTitlesByTabId.tab = titles + hook.rerender() + expect(hook.result.current.chatLeafId).toBeNull() + expect(mocks.state.unifiedTabsByWorktree.wt[0].viewMode).toBe('terminal') + } finally { + mocks.state.runtimePaneTitlesByTabId.tab = titles + } + }) + it('restores chat on the right with the left active and different numeric pane IDs', () => { const initialFixture = makeFixture() const original = renderHook(() => useFixture(initialFixture, RIGHT)) diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-chat-state.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-chat-state.ts index 06c3b979f633..b189a31c369f 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-chat-state.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-chat-state.ts @@ -181,17 +181,19 @@ export function useTerminalPaneChatState(controller: TerminalPaneTitleController ) const handleConfirmedAgentExit = useCallback( (leafId: string): void => { - if (leafId !== chatLeafId) { + const activeLeafId = managerRef.current?.getActivePane()?.leafId ?? null + // Why: an unbound Chat request waits on the active leaf; that leaf's confirmed exit ends it. + const ownerLeafId = chatLeafId ?? (leafId === activeLeafId ? leafId : null) + if (leafId !== ownerLeafId) { return } const panes = managerRef.current?.getPanes() ?? [] - const activeLeafId = managerRef.current?.getActivePane()?.leafId ?? null applyNativeChatLeafRoute( resolveNativeChatLeafRoute({ isChatViewMode, - chatLeafId, + chatLeafId: ownerLeafId, activeLeafId, - chatLeafStillMounted: panes.some((pane) => pane.leafId === chatLeafId), + chatLeafStillMounted: panes.some((pane) => pane.leafId === ownerLeafId), activeLeafIsEligible: isChatEligibleForLeaf(activeLeafId), chatLeafHasConfirmedAgentExit: true }) From b72187ecc9f6dbeb6125d51478279a474af8750d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:41:14 -0700 Subject: [PATCH 17/31] fix(terminal): retire an agent on its own title where a remote Windows host marks no commands Remote-runtime panes always parse bytes in the renderer, and a Windows host can neither read the foreground nor, under cmd.exe, mark commands, so a real exit never cleared the icon or Chat. Mirror main's SSH rule: on a remote host that can never certify, the agent's idle-to-neutral title retires it, as on base. --- .../agent-exit-observation.test.ts | 30 +++++++++++++++++++ .../pane-foreground-agent-tracker.ts | 16 ++++++++-- 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/components/terminal-pane/agent-exit-observation.test.ts b/src/renderer/src/components/terminal-pane/agent-exit-observation.test.ts index 8f3a4969617e..f86aefe49006 100644 --- a/src/renderer/src/components/terminal-pane/agent-exit-observation.test.ts +++ b/src/renderer/src/components/terminal-pane/agent-exit-observation.test.ts @@ -215,6 +215,36 @@ describe('agent exit observation', () => { h.tracker.dispose() }) + it('retires the agent on its own neutral title where a Windows host marks no commands', async () => { + vi.useFakeTimers() + const h = remoteTracker(() => posixEvidence('shell', 'windows')) + h.tracker.onAgentExitCandidate() + await vi.advanceTimersByTimeAsync(400) + expect(h.shell).toHaveBeenCalledExactlyOnceWith('visible-pty', true) + expect(h.read).toHaveBeenCalledOnce() + h.tracker.dispose() + }) + + it.each([ + ['a visible-pane sample on a Windows host', () => posixEvidence('shell', 'windows'), false], + ['a neutral title with the agent still in front', () => posixEvidence('agent'), true], + ['a neutral title the host could not answer', () => posixEvidence('shell', 'windows'), true] + ])('keeps the agent on %s', async (label, evidence, titleCandidate) => { + vi.useFakeTimers() + const h = remoteTracker(evidence) + if (label.includes('could not answer')) { + h.read.mockRejectedValue(new Error('transport lost')) + } + if (titleCandidate) { + h.tracker.onAgentExitCandidate() + } else { + h.tracker.onVisiblePtyBound(true) + } + await vi.advanceTimersByTimeAsync(8000) + expect(h.shell).not.toHaveBeenCalled() + h.tracker.dispose() + }) + it('keeps the agent when the host could not be read at the 133;D', async () => { vi.useFakeTimers() const h = remoteTracker(() => undefined) diff --git a/src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.ts b/src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.ts index 6553b77f49c9..b1f72b2560e9 100644 --- a/src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.ts +++ b/src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.ts @@ -81,6 +81,8 @@ export function createPaneForegroundAgentTracker(deps: PaneForegroundAgentTracke // cannot remove the identity that authorizes the bounded retry ladder. let hasKnownAgentEvidence = false let hasAgentExpectation = false + // True while the pending visible-pty read answers the agent's own idle-to-neutral title. + let agentTitleExitCandidate = false const readProcess = createPaneForegroundProcessReader(deps) const trackablePtyId = (): string | null => { @@ -96,6 +98,7 @@ export function createPaneForegroundAgentTracker(deps: PaneForegroundAgentTracke } scheduledReadReason = null activeReadReason = null + agentTitleExitCandidate = false } const scheduleRead = ( @@ -214,7 +217,14 @@ export function createPaneForegroundAgentTracker(deps: PaneForegroundAgentTracke return } if (reason === 'visible-pty') { - if ((hasForegroundAgentEvidence || hasKnownAgentEvidence) && verdict === 'exited') { + // Why: a remote host that can never show a shell leaves the agent's own title as its exit + // signal (base behaviour; main applies the same rule to SSH facts). + const titleIsOnlyExitSignal = + agentTitleExitCandidate && remote && verdict === 'unverifiable' && !judgement.canCertifyExit + if ( + (hasForegroundAgentEvidence || hasKnownAgentEvidence) && + (verdict === 'exited' || titleIsOnlyExitSignal) + ) { confirmShellForeground(reason) deps.onVisibleForegroundSettled?.('shell') } else { @@ -291,7 +301,9 @@ export function createPaneForegroundAgentTracker(deps: PaneForegroundAgentTracke }, onAgentExitCandidate() { // Why: the title tracker saw the agent's own status title, so the agent was observed. - return bindVisiblePty(true, true) + const scheduled = bindVisiblePty(true, true) + agentTitleExitCandidate = scheduled + return scheduled }, onCommandStarted(expectedAgent = null) { const hadReadBeforeCommandStart = hasPendingRead() From fba8019fa20fc6b4c2a68902c1668a0c9a6b94ad Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 00:23:00 -0700 Subject: [PATCH 18/31] fix(runtime): end a launched agent only at the 133;D that closes its own command A user shell integration that prints OSC 133;D at every prompt emits one at the first prompt, before Orca's startup command runs. Main counted the launch expectation as a held agent there, so a read that beat the agent's fork saw the shell and published an exit, and hosts that cannot read the foreground retired the agent outright. Chat then flipped to Terminal during startup. Main now records the shell's 133;C per incarnation. A 133;D counts toward an agent exit when it closes a command started in this incarnation of a PTY with a launch expectation, or when the agent was seen live by a read or its own title. --- .../orca-runtime-apply-tracked-pty-title.ts | 1 + ...e-get-unpersisted-tracked-title-for-pty.ts | 7 ++ ...ntime-serialize-agent-prompt-submission.ts | 5 +- .../terminal-side-effect-facts.spec.ts | 29 +++++++ .../runtime/runtime-terminal-state-records.ts | 2 + src/main/runtime/shell-command-agent-hold.ts | 32 ++++++++ .../terminal-agent-exit-evidence.test.ts | 80 +++++++++++++++++++ src/shared/terminal-output-side-effects.ts | 8 +- 8 files changed, 161 insertions(+), 3 deletions(-) create mode 100644 src/main/runtime/shell-command-agent-hold.ts diff --git a/src/main/runtime/orca-runtime-apply-tracked-pty-title.ts b/src/main/runtime/orca-runtime-apply-tracked-pty-title.ts index f4f8826e6e78..80a5ac67104e 100644 --- a/src/main/runtime/orca-runtime-apply-tracked-pty-title.ts +++ b/src/main/runtime/orca-runtime-apply-tracked-pty-title.ts @@ -192,6 +192,7 @@ export class OrcaRuntimeWithApplyTrackedPtyTitle extends OrcaRuntimeWithGetUnper pty.waitBlockedAt = null pty.tailWaitState = undefined pty.commandPaint = undefined + pty.runningShellCommand = undefined } for (const leaf of this.getLeavesForPty(ptyId)) { leaf.lastOscTitle = null 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 ecd6fc934519..5b658b28bcc8 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 @@ -4,6 +4,7 @@ import { getDecorativeAgentTitleSignature } from '../../shared/agent-decorative- import { shouldEmitTitleFactForFrame } from './decorative-title-fact-emission' import type { RuntimePtyTitleTrackerEntry } from './runtime-terminal-state-records' import { createTerminalTitleTracker } from '../../shared/terminal-output-side-effects' +import { noteShellCommandStarted } from './shell-command-agent-hold' import { detectAgentStatusFromTitle } from '../../shared/agent-detection' import type { TerminalGitHubPRLink } from '../../shared/terminal-github-pr-link-detector' @@ -143,6 +144,12 @@ export class OrcaRuntimeWithGetUnpersistedTrackedTitleForPty extends OrcaRuntime onAgentExitCandidate: () => { this.confirmPtyAgentExit(ptyId) }, + onCommandStarted: () => { + const pty = this.ptysById.get(ptyId) + if (pty) { + noteShellCommandStarted(pty) + } + }, onCommandFinished: (exitCode: number | null) => { this.confirmPtyAgentExitAtCommandFinished(ptyId) this.retirePtyAgentLaunchAuthority(ptyId) 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 737496e9b615..d21f8d5bf323 100644 --- a/src/main/runtime/orca-runtime-serialize-agent-prompt-submission.ts +++ b/src/main/runtime/orca-runtime-serialize-agent-prompt-submission.ts @@ -1,6 +1,7 @@ // @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. import { startSpan } from '../observability/tracer' import { selectFreshExplicitAgentStatus } from './runtime-hook-agent-row-selection' +import { takeShellCommandFinishedAgentHold } from './shell-command-agent-hold' import { OrcaRuntimeWithControllerKnowsPtyIsLive } from './orca-runtime-controller-knows-pty-is-live' import type { RuntimeTerminalAgentStatus } from '../../shared/runtime-types' import type { RuntimeTerminalAgentStatusSnapshot } from './runtime-terminal-agent-status-query' @@ -176,8 +177,8 @@ export class OrcaRuntimeWithSerializeAgentPromptSubmission extends OrcaRuntimeWi */ protected confirmPtyAgentExitAtCommandFinished(ptyId: string): void { const pty = this.ptysById.get(ptyId) - // Why launchAgent counts: Orca's shell integration emits 133;D only after the command it ran. - if (!pty?.connected || (!pty.launchAgent && !pty.foregroundAgent && !pty.lastAgentStatus)) { + const heldAgent = pty ? takeShellCommandFinishedAgentHold(pty) : false + if (!pty?.connected || !heldAgent) { return } const incarnationId = pty.incarnationId 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 2a24d16ed98f..09082d524de1 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 @@ -518,6 +518,35 @@ describe('terminal side-effect fact channel', () => { ).toHaveLength(1) }) + it.each([ + ['before its 133;C', '\x1b]133;D;0\x07\x1b]133;A\x07', 0], + ['that closes its 133;C', '\x1b]133;C\x07\x1b]133;D;0\x07', 1] + ])('counts a launched agent exit at a 133;D %s accordingly', async (_label, bytes, exits) => { + const { runtime, batches } = createSideEffectRuntime() + syncSinglePty(runtime) + const confirmForegroundProcess = vi.fn().mockResolvedValue('zsh') + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: vi.fn().mockResolvedValue('zsh'), + confirmForegroundProcess + }) + const ptys: unknown = Reflect.get(runtime, 'ptysById') + const pty: unknown = ptys instanceof Map ? ptys.get('pty-1') : undefined + if (!pty || typeof pty !== 'object') { + throw new Error('expected the synced PTY record') + } + // Stands in for a launch Orca started; the record's launch identity is not public. + Reflect.set(pty, 'launchAgent', 'claude') + // The first case is a user shell integration's first-prompt D, before Orca's startup command. + runtime.onPtyData('pty-1', bytes, 100) + // Why a macrotask: every mocked read resolves in microtasks, so an owed exit has landed by then. + await new Promise((resolve) => setTimeout(resolve, 0)) + expect( + batches.flatMap((batch) => batch.facts).filter((fact) => fact.kind === 'agent-exited') + ).toHaveLength(exits) + }) + it('treats synchronous foreground read failures as unavailable', async () => { const { runtime, batches } = createSideEffectRuntime() syncSinglePty(runtime) diff --git a/src/main/runtime/runtime-terminal-state-records.ts b/src/main/runtime/runtime-terminal-state-records.ts index d030b896da99..37e33c6e0ed7 100644 --- a/src/main/runtime/runtime-terminal-state-records.ts +++ b/src/main/runtime/runtime-terminal-state-records.ts @@ -90,6 +90,8 @@ export type RuntimePtyWorktreeRecord = RuntimeTerminalTailState & { lastOutputAt: number | null /** See terminal-command-paint.ts; absent until the pane's first output, and again after a gap or a new process. */ commandPaint?: TerminalCommandPaint + /** The shell's OSC 133;C command no 133;D has ended yet, and the incarnation it ran in. */ + runningShellCommand?: { incarnationId: PtyIncarnationId | null } } export type RuntimePtyTabCloseAuthority = { diff --git a/src/main/runtime/shell-command-agent-hold.ts b/src/main/runtime/shell-command-agent-hold.ts new file mode 100644 index 000000000000..b2b2a51eae50 --- /dev/null +++ b/src/main/runtime/shell-command-agent-hold.ts @@ -0,0 +1,32 @@ +import type { RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' + +type ShellCommandPty = Pick + +type AgentHoldPty = ShellCommandPty & + Pick< + RuntimePtyWorktreeRecord, + 'launchAgent' | 'foregroundAgent' | 'lastAgentStatus' | 'lastAgentStatusObservedLive' + > + +/** OSC 133;C: the next 133;D ends a command this incarnation started. */ +export function noteShellCommandStarted(pty: ShellCommandPty): void { + pty.runningShellCommand = { incarnationId: pty.incarnationId } +} + +/** + * OSC 133;D: whether it can end an agent this PTY held, meaning one seen live (by a read or its + * own title) or one launched by the command this D closes. Consumes the running command. + */ +export function takeShellCommandFinishedAgentHold(pty: AgentHoldPty): boolean { + // Why: a user shell integration can print 133;D at the first prompt, before the launch runs. + const endsLaunchedCommand = + Boolean(pty.launchAgent) && + pty.runningShellCommand !== undefined && + pty.runningShellCommand.incarnationId === pty.incarnationId + pty.runningShellCommand = undefined + return ( + endsLaunchedCommand || + Boolean(pty.foregroundAgent) || + (Boolean(pty.lastAgentStatus) && pty.lastAgentStatusObservedLive) + ) +} diff --git a/src/main/runtime/terminal-agent-exit-evidence.test.ts b/src/main/runtime/terminal-agent-exit-evidence.test.ts index 62e37a6ba7bc..a27e69ff4438 100644 --- a/src/main/runtime/terminal-agent-exit-evidence.test.ts +++ b/src/main/runtime/terminal-agent-exit-evidence.test.ts @@ -3,6 +3,7 @@ import { createTerminalTitleTracker } from '../../shared/terminal-output-side-ef import type { TerminalSideEffectFact } from '../../shared/terminal-side-effect-facts' import { OrcaRuntimeWithSerializeAgentPromptSubmission } from './orca-runtime-serialize-agent-prompt-submission' import { judgeForegroundAgent } from '../../shared/foreground-agent-verdict' +import { noteShellCommandStarted } from './shell-command-agent-hold' const readResult = (controller: object, processName: string | null) => ({ controller, @@ -17,6 +18,12 @@ class ExitHarness extends OrcaRuntimeWithSerializeAgentPromptSubmission { confirm(): void { this.confirmPtyAgentExit('pty-1') } + commandStarted(): void { + const pty = this.ptysById.get('pty-1') + if (pty) { + noteShellCommandStarted(pty) + } + } commandFinished(): void { this.confirmPtyAgentExitAtCommandFinished('pty-1') } @@ -31,6 +38,7 @@ function setup() { incarnationId: string lastOscTitleAt: number lastAgentStatus: string | null + lastAgentStatusObservedLive: boolean launchAgent: string | null foregroundAgent: string | null connectionId?: string @@ -39,6 +47,7 @@ function setup() { incarnationId: 'inc-1', lastOscTitleAt: 1, lastAgentStatus: null, + lastAgentStatusObservedLive: false, launchAgent: null, foregroundAgent: null } @@ -149,6 +158,7 @@ describe('agent exit at the shell 133;D', () => { it('publishes one exit when a read shows the shell after an agent title', async () => { const h = setup() h.pty.lastAgentStatus = 'idle' + h.pty.lastAgentStatusObservedLive = true h.read.mockResolvedValue(readResult(h.controller, 'zsh')) h.runtime.commandFinished() await vi.waitFor(() => @@ -169,6 +179,7 @@ describe('agent exit at the shell 133;D', () => { ])('keeps the agent when the read shows %s', async (_label, process) => { const h = setup() h.pty.launchAgent = 'claude' + h.runtime.commandStarted() h.read.mockResolvedValue(readResult(h.controller, process)) h.runtime.commandFinished() await vi.waitFor(() => expect(h.read).toHaveBeenCalledOnce()) @@ -187,6 +198,17 @@ describe('agent exit at the shell 133;D', () => { expect(h.markExited).toHaveBeenCalledWith('pty-1') h.tracker.dispose() }) + it('ends a launched agent at the 133;D that closes its command', async () => { + const h = setup() + h.pty.launchAgent = 'claude' + h.runtime.commandStarted() + h.read.mockResolvedValue(readResult(h.controller, 'zsh')) + h.runtime.commandFinished() + await vi.waitFor(() => + expect(h.facts).toEqual([{ kind: 'agent-exited', evidence: 'foreground-shell' }]) + ) + h.tracker.dispose() + }) it('does not read for a 133;D on a pane that never held an agent', () => { const h = setup() h.runtime.commandFinished() @@ -194,3 +216,61 @@ describe('agent exit at the shell 133;D', () => { h.tracker.dispose() }) }) + +describe('a 133;D printed before the launched agent starts', () => { + const shellAtPrompt = { verdict: 'exited', processName: null, canCertifyExit: true } as const + it.each([ + ['a local shell', (h: ReturnType) => readResult(h.controller, 'zsh')], + [ + 'an SSH host showing its shell', + (h: ReturnType) => { + h.pty.connectionId = 'ssh-1' + return { controller: h.controller, judgement: shellAtPrompt } + } + ], + [ + 'a host that cannot read the foreground', + (h: ReturnType) => { + h.pty.connectionId = 'ssh-old' + return { + controller: h.controller, + judgement: judgeForegroundAgent({ kind: 'host-without-evidence' }) + } + } + ] + ])('does not end the launch on %s', async (_label, answer) => { + const h = setup() + h.pty.launchAgent = 'claude' + h.read.mockResolvedValue(answer(h)) + // A user shell integration's first-prompt D, before Orca's startup command runs. + h.runtime.commandFinished() + await Promise.resolve() + await Promise.resolve() + expect(h.facts).toEqual([]) + expect(h.markExited).not.toHaveBeenCalled() + h.tracker.dispose() + }) + it('does not count a command started by an earlier incarnation', async () => { + const h = setup() + h.pty.launchAgent = 'claude' + h.runtime.commandStarted() + h.pty.incarnationId = 'inc-2' + h.read.mockResolvedValue(readResult(h.controller, 'zsh')) + h.runtime.commandFinished() + await Promise.resolve() + await Promise.resolve() + expect(h.facts).toEqual([]) + h.tracker.dispose() + }) + it('counts one 133;D per started command when a user integration doubles the marks', async () => { + const h = setup() + h.pty.launchAgent = 'claude' + h.runtime.commandStarted() + h.runtime.commandStarted() + h.read.mockResolvedValue(readResult(h.controller, 'claude')) + h.runtime.commandFinished() + h.runtime.commandFinished() + await vi.waitFor(() => expect(h.read).toHaveBeenCalledOnce()) + h.tracker.dispose() + }) +}) diff --git a/src/shared/terminal-output-side-effects.ts b/src/shared/terminal-output-side-effects.ts index 7c35a1a96763..53d18279c4e8 100644 --- a/src/shared/terminal-output-side-effects.ts +++ b/src/shared/terminal-output-side-effects.ts @@ -63,6 +63,8 @@ export type TerminalTitleTrackerCallbacks = { * mirrors renderer command-lifecycle semantics so the fact path drops stale agent rows like byte mode. */ onCommandFinished?: (bestEffortExitCode: number | null) => void + /** Fired per complete OSC 133;C, in byte order with 133;D; scanned only alongside onCommandFinished. */ + onCommandStarted?: () => void /** Fired once per newly observed GitHub PR URL (chunk-boundary-safe, deduplicated per tracker). */ onPrLink?: (link: TerminalGitHubPRLink) => void /** @@ -119,6 +121,7 @@ export function createTerminalTitleTracker( onAgentExitCandidate, onBell, onCommandFinished, + onCommandStarted, onPrLink, onMode2031Subscribe, onMode2031Unsubscribe @@ -126,7 +129,10 @@ export function createTerminalTitleTracker( let bellDetector = onBell ? createBellDetector() : null // Why: created only when a consumer exists so headless serve never pays the per-chunk 133/URL scans. const commandFinishedScanner = onCommandFinished - ? createOsc133CommandFinishedScanner(onCommandFinished) + ? createOsc133CommandFinishedScanner( + onCommandFinished, + onCommandStarted ? () => onCommandStarted() : undefined + ) : null let prLinkDetector = onPrLink ? createTerminalGitHubPRLinkDetector() : null let transientSideEffectScanningEnabled = true From f331d62acfc51c6ed8a76cb9ff63ad8d38e18448 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 00:23:19 -0700 Subject: [PATCH 19/31] fix(terminal): exit Chat on the pane's own confirmed shell after it saw the agent live Two gaps in the renderer's exit decision: - In byte mode a first-prompt 133;D from a user shell integration counted as an agent exit whenever the pane expected a launch, so Chat could flip to Terminal during startup. A 133;D now counts only when it closes a command the shell's own 133;C started, or when the agent was seen in this pane. - With main as the parser, if both of main's reads failed at a real exit nothing exited Chat, and composer sends then reached the bare shell. The pane's own read ladder now exits Chat when a read in this PTY saw the agent live and a later read confirms the shell. An expectation retired by a shell sample is still never an exit, and a repeat exit signal is a no-op downstream. --- .../agent-exit-observation.test.ts | 81 +++++++++++++++++-- .../pane-foreground-agent-tracker.ts | 46 ++++++++--- ...nnection-foreground-agent-sampling.test.ts | 47 +++++++++++ ...nection-main-side-effect-authority.test.ts | 38 +++++++++ .../pty-connection/pane-agent-identity.ts | 14 +++- 5 files changed, 205 insertions(+), 21 deletions(-) diff --git a/src/renderer/src/components/terminal-pane/agent-exit-observation.test.ts b/src/renderer/src/components/terminal-pane/agent-exit-observation.test.ts index f86aefe49006..ce7c58a46248 100644 --- a/src/renderer/src/components/terminal-pane/agent-exit-observation.test.ts +++ b/src/renderer/src/components/terminal-pane/agent-exit-observation.test.ts @@ -155,7 +155,7 @@ describe('agent exit observation', () => { }) tracker.onVisiblePtyBound(true) await vi.advanceTimersByTimeAsync(8000) - expect(shell).toHaveBeenCalledExactlyOnceWith('visible-pty', false) + expect(shell).toHaveBeenCalledExactlyOnceWith('visible-pty', 'none') tracker.dispose() }) @@ -173,7 +173,7 @@ describe('agent exit observation', () => { }) tracker.onAgentExitCandidate() await vi.advanceTimersByTimeAsync(8000) - expect(shell).toHaveBeenCalledExactlyOnceWith('visible-pty', true) + expect(shell).toHaveBeenCalledExactlyOnceWith('visible-pty', 'marked') tracker.dispose() }) @@ -186,7 +186,7 @@ describe('agent exit observation', () => { h.exitAgent() h.tracker.onCommandFinished() await vi.advanceTimersByTimeAsync(400) - expect(h.shell).toHaveBeenCalledExactlyOnceWith('command-finished', true) + expect(h.shell).toHaveBeenCalledExactlyOnceWith('command-finished', 'read-confirmed') expect(h.read).toHaveBeenCalledTimes(2) h.tracker.dispose() }) @@ -207,10 +207,11 @@ describe('agent exit observation', () => { ])('retires the agent at the 133;D on %s', async (_label, evidence) => { vi.useFakeTimers() const h = remoteTracker(evidence) + h.tracker.onCommandStarted(null, { shellMarked: true }) h.exitAgent() h.tracker.onCommandFinished() await vi.advanceTimersByTimeAsync(400) - expect(h.shell).toHaveBeenCalledExactlyOnceWith('command-finished', true) + expect(h.shell).toHaveBeenCalledExactlyOnceWith('command-finished', 'marked') expect(h.read).toHaveBeenCalledOnce() h.tracker.dispose() }) @@ -220,7 +221,7 @@ describe('agent exit observation', () => { const h = remoteTracker(() => posixEvidence('shell', 'windows')) h.tracker.onAgentExitCandidate() await vi.advanceTimersByTimeAsync(400) - expect(h.shell).toHaveBeenCalledExactlyOnceWith('visible-pty', true) + expect(h.shell).toHaveBeenCalledExactlyOnceWith('visible-pty', 'marked') expect(h.read).toHaveBeenCalledOnce() h.tracker.dispose() }) @@ -245,6 +246,76 @@ describe('agent exit observation', () => { h.tracker.dispose() }) + it.each([ + ['a local shell', () => ({ pty: 'pty-1', read: vi.fn().mockResolvedValue('zsh') })], + [ + 'a host that cannot read the foreground', + () => { + const h = remoteTracker(() => posixEvidence('shell', 'windows')) + h.tracker.dispose() + return { pty: SSH_PTY_ID, read: h.read } + } + ] + ])('does not call a 133;D before any command started an exit on %s', async (_label, make) => { + vi.useFakeTimers() + const { pty, read } = make() + const shell = vi.fn() + const tracker = createPaneForegroundAgentTracker({ + getPtyId: () => pty, + isTrackablePtyId: () => true, + isRemotePtyId: (id) => id === SSH_PTY_ID, + getExpectedIncarnationId: () => 'inc-1', + readForegroundProcess: read, + confirmForegroundProcess: read, + publish: vi.fn(), + hasKnownAgentIdentity: () => true, + onConfirmedShellForeground: shell + }) + // A user shell integration's first-prompt D, before the launched agent's command starts. + tracker.onCommandFinished() + await vi.advanceTimersByTimeAsync(8000) + expect(shell).toHaveBeenCalledExactlyOnceWith('command-finished', 'none') + tracker.onCommandStarted(null, { shellMarked: true }) + tracker.onCommandFinished() + await vi.advanceTimersByTimeAsync(8000) + expect(shell).toHaveBeenLastCalledWith('command-finished', 'marked') + tracker.dispose() + }) + + it('calls a shell read-confirmed only after a read saw the agent in this PTY', async () => { + vi.useFakeTimers() + let ptyId = 'pty-1' + const shell = vi.fn() + const read = vi.fn().mockResolvedValue('claude') + const tracker = createPaneForegroundAgentTracker({ + getPtyId: () => ptyId, + isTrackablePtyId: () => true, + readForegroundProcess: read, + confirmForegroundProcess: read, + publish: vi.fn(), + hasKnownAgentIdentity: () => true, + onConfirmedShellForeground: shell + }) + tracker.onVisiblePtyBound(true) + await vi.advanceTimersByTimeAsync(400) + read.mockResolvedValue('zsh') + tracker.onCommandFinished() + await vi.advanceTimersByTimeAsync(400) + expect(shell).toHaveBeenCalledExactlyOnceWith('command-finished', 'read-confirmed') + + // A replacement PTY in the same pane never inherits the earlier sighting. + read.mockResolvedValue('claude') + tracker.onVisiblePtyBound(true) + await vi.advanceTimersByTimeAsync(400) + ptyId = 'pty-2' + read.mockResolvedValue('zsh') + tracker.onCommandFinished() + await vi.advanceTimersByTimeAsync(8000) + expect(shell).toHaveBeenCalledTimes(2) + expect(shell.mock.calls.at(-1)?.[1]).not.toBe('read-confirmed') + tracker.dispose() + }) + it('keeps the agent when the host could not be read at the 133;D', async () => { vi.useFakeTimers() const h = remoteTracker(() => undefined) diff --git a/src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.ts b/src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.ts index b1f72b2560e9..46e47d818a38 100644 --- a/src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.ts +++ b/src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.ts @@ -13,6 +13,10 @@ const COMMAND_SETTLE_MS = 350 const VISIBLE_PTY_SETTLE_MS = 350 const WRAPPER_RESOLVE_RETRY_DELAYS_MS = [1200, 6000] as const type ForegroundReadReason = 'command' | 'visible-pty' | 'command-finished' +type ShellConfirmReason = Exclude +/** 'read-confirmed': a read in this PTY saw the agent live first. 'marked': its own title, or a + * 133;D closing a command the shell started, brackets the exit. 'none': only an expectation. */ +export type ConfirmedShellAgentExit = 'none' | 'marked' | 'read-confirmed' type PaneForegroundAgentTrackerDeps = { getPtyId: () => string | null @@ -39,11 +43,10 @@ type PaneForegroundAgentTrackerDeps = { hasKnownAgentIdentity?: () => boolean /** Fired when a confirming read proves the foreground genuinely returned to a * shell. Lets callers clear a stale agent-named tab title that the shell never - * repaints. `agentExited` is false when only a launch/hook expectation was - * retired: a slow boot also shows the shell, so that is never an agent exit. */ + * repaints. A slow boot also shows the shell, so a retired expectation is never an exit. */ onConfirmedShellForeground?: ( - reason: 'visible-pty' | 'command-finished', - agentExited: boolean + reason: ShellConfirmReason, + agentExit: ConfirmedShellAgentExit ) => void onCommandFinishedUnavailable?: () => void onVisibleForegroundSettled?: (outcome: 'agent' | 'shell' | 'inconclusive') => void @@ -63,7 +66,8 @@ export function createPaneForegroundAgentTracker(deps: PaneForegroundAgentTracke onVisiblePtyBound: (expectsAgent?: boolean) => boolean /** A neutral title followed the agent's own status title; confirm whether it exited. */ onAgentExitCandidate: () => boolean - onCommandStarted: (expectedAgent?: TuiAgent | null) => void + /** `shellMarked`: the shell's own 133;C, not an expectation seeded at spawn or typed input. */ + onCommandStarted: (expectedAgent?: TuiAgent | null, options?: { shellMarked?: boolean }) => void /** True when pane identity must remain visible until an async shell confirmation. */ onCommandFinished: () => boolean dispose: () => void @@ -83,6 +87,12 @@ export function createPaneForegroundAgentTracker(deps: PaneForegroundAgentTracke let hasAgentExpectation = false // True while the pending visible-pty read answers the agent's own idle-to-neutral title. let agentTitleExitCandidate = false + // Why: a user shell integration can print 133;D at the first prompt, before the launch runs. + // 'closed': the latest 133;D ended a command the shell's own 133;C started. + let shellCommandMark: 'running' | 'closed' | null = null + // The PTY (and host incarnation) where a read last saw the agent live. + let agentSeenLiveInPtyKey: string | null = null + const ptyKey = (id: string): string => `${id}\n${deps.getExpectedIncarnationId?.() ?? ''}` const readProcess = createPaneForegroundProcessReader(deps) const trackablePtyId = (): string | null => { @@ -171,6 +181,7 @@ export function createPaneForegroundAgentTracker(deps: PaneForegroundAgentTracke const recognized = verdict === 'live' ? recognizeAgentProcess(processName) : null if (recognized) { hasForegroundAgentEvidence = true + agentSeenLiveInPtyKey = ptyKey(ptyId) hasAgentExpectation = false deps.publish({ agent: recognized.agent, @@ -225,7 +236,7 @@ export function createPaneForegroundAgentTracker(deps: PaneForegroundAgentTracke (hasForegroundAgentEvidence || hasKnownAgentEvidence) && (verdict === 'exited' || titleIsOnlyExitSignal) ) { - confirmShellForeground(reason) + confirmShellForeground(reason, ptyId) deps.onVisibleForegroundSettled?.('shell') } else { deps.onVisibleForegroundSettled?.('inconclusive') @@ -234,7 +245,7 @@ export function createPaneForegroundAgentTracker(deps: PaneForegroundAgentTracke } // Why: 133;D is the shell's own mark; where no read can ever show a shell it retires the agent. if (verdict === 'exited' || (!judgement.canCertifyExit && hasConfirmationExpectation)) { - confirmShellForeground(reason) + confirmShellForeground(reason, ptyId) return } deps.onCommandFinishedUnavailable?.() @@ -246,9 +257,16 @@ export function createPaneForegroundAgentTracker(deps: PaneForegroundAgentTracke } } - const confirmShellForeground = (reason: 'visible-pty' | 'command-finished'): void => { - // Why: an exit needs an agent seen in this pane, or the shell's own 133;D ending the command. - const agentExited = reason === 'command-finished' || hasForegroundAgentEvidence + const confirmShellForeground = (reason: ShellConfirmReason, ptyId: string): void => { + // Why: an exit needs an agent seen in this pane, or the shell's own 133;D ending its command. + const agentExit: ConfirmedShellAgentExit = + agentSeenLiveInPtyKey === ptyKey(ptyId) + ? 'read-confirmed' + : hasForegroundAgentEvidence || + (reason === 'command-finished' && shellCommandMark === 'closed') + ? 'marked' + : 'none' + agentSeenLiveInPtyKey = null // Why: reset the evidence so the pane's ordinary shell commands go back to // the no-RPC finished path. hasForegroundAgentEvidence = false @@ -257,7 +275,7 @@ export function createPaneForegroundAgentTracker(deps: PaneForegroundAgentTracke deps.publish({ agent: null, shellForeground: true }) // Why: confirmed exit — let callers clear a stale agent title the shell // won't repaint (a plain `codex`/`grok` leaves its OSC title behind). - deps.onConfirmedShellForeground?.(reason, agentExited) + deps.onConfirmedShellForeground?.(reason, agentExit) } const bindVisiblePty = (expectsAgent: boolean, agentObserved: boolean): boolean => { @@ -305,9 +323,12 @@ export function createPaneForegroundAgentTracker(deps: PaneForegroundAgentTracke agentTitleExitCandidate = scheduled return scheduled }, - onCommandStarted(expectedAgent = null) { + onCommandStarted(expectedAgent = null, options = {}) { const hadReadBeforeCommandStart = hasPendingRead() cancelPendingRead() + shellCommandMark = options.shellMarked ? 'running' : shellCommandMark + // Why: a new command means an earlier sighting describes the previous one. + agentSeenLiveInPtyKey = null const ptyId = trackablePtyId() if (!ptyId) { releaseRetainedCapability(hadReadBeforeCommandStart) @@ -333,6 +354,7 @@ export function createPaneForegroundAgentTracker(deps: PaneForegroundAgentTracke scheduleRead(COMMAND_SETTLE_MS, 0, 'command') }, onCommandFinished() { + shellCommandMark = shellCommandMark === 'running' ? 'closed' : null if (deps.hasKnownAgentIdentity?.() === true) { hasKnownAgentEvidence = true } diff --git a/src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-sampling.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-sampling.test.ts index 642f7e516c7c..4ba2cb3df3da 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-sampling.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-sampling.test.ts @@ -578,6 +578,53 @@ describe('connectPanePty', () => { } ) + it.each([ + ['after its own read saw the agent', 'claude', true], + ['from a shell sample that never saw the agent', 'zsh', false] + ])( + 'with main as the parser, a renderer-confirmed shell %s (first read %s) exits Chat: %s', + async (_label, firstRead, exitsChat) => { + vi.useFakeTimers() + mockStoreState.settings = { + ...mockStoreState.settings, + terminalMainSideEffectAuthority: true + } + const confirm = vi.mocked(window.api.pty.confirmForegroundProcess) + confirm.mockResolvedValue(firstRead) + vi.mocked(window.api.pty.getForegroundProcess).mockResolvedValue(firstRead) + const ptyId = `pty-renderer-confirmed-exit-${firstRead}` + const tabId = `tab-${ptyId}` + mockStoreState.tabsByWorktree = { 'wt-1': [{ id: tabId, ptyId }] } + const { deps } = await connectRestoredPaneForForegroundSampling({ + ptyId, + tabId, + launchAgent: 'claude' + }) + const ladder = VISIBLE_PTY_SETTLE_MS + WRAPPER_RESOLVE_RETRY_MS + SECOND_WRAPPER_RETRY_MS + await vi.advanceTimersByTimeAsync(ladder) + await flushAsyncTicks() + expect(deps.onAgentExitedRef.current).not.toHaveBeenCalled() + + // Main's own reads at the exit failed, so this 133;D carries no agent-exited fact. + confirm.mockResolvedValue('zsh') + vi.mocked(window.api.pty.getForegroundProcess).mockResolvedValue('zsh') + const handler = await import('./terminal-side-effect-facts-handler') + handler._dispatchTerminalSideEffectBatchForTest({ + ptyId, + seq: 1, + facts: [{ kind: 'command-finished', exitCode: 0 }] + }) + await vi.advanceTimersByTimeAsync(ladder) + await flushAsyncTicks() + + if (exitsChat) { + expect(deps.onAgentExitedRef.current).toHaveBeenCalledWith(LEAF_1) + } else { + expect(deps.onAgentExitedRef.current).not.toHaveBeenCalled() + } + } + ) + it('fails closed when a warm reattach has no persisted launch identity', async () => { vi.useFakeTimers() const ptyId = 'pty-reattach-missing-launch-identity' diff --git a/src/renderer/src/components/terminal-pane/pty-connection-main-side-effect-authority.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-main-side-effect-authority.test.ts index 56a25220192b..f6bbd8f42831 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-main-side-effect-authority.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-main-side-effect-authority.test.ts @@ -716,4 +716,42 @@ describe('connectPanePty', () => { await vi.advanceTimersByTimeAsync(400) expect(reads()).toBeGreaterThan(baseline) }) + + it('exits Chat only at a 133;D that closes a command the shell started', async () => { + vi.useFakeTimers() + mockStoreState.settings = { ...mockStoreState.settings, terminalMainSideEffectAuthority: false } + const { connectPanePty } = await import('./pty-connection') + vi.mocked(window.api.pty.confirmForegroundProcess).mockResolvedValue('zsh') + const dataCallbackRef: { current: ((data: string) => void) | null } = { current: null } + const ptyId = 'pty-first-prompt-command-finished' + const transport = createMockTransport(ptyId) + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + dataCallbackRef.current = callbacks.onData ?? null + return { id: ptyId } + }) + transportFactoryQueue.push(transport) + const paneKey = makePaneKey('tab-1', LEAF_1) + const deps = createDeps({ isVisibleRef: { current: false } }) + + connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) + await vi.advanceTimersByTimeAsync(20) + await flushAsyncTicks() + const launch = { + launchConfig: { agentArgs: '', agentEnv: {} }, + identity: { agentType: 'claude' } + } + mockStoreState.agentLaunchConfigByPaneKey[paneKey] = launch + + // A user shell integration's first-prompt D, before the launched agent's command starts. + dataCallbackRef.current?.('\x1b]133;D;0\x07') + await vi.advanceTimersByTimeAsync(350) + await flushAsyncTicks() + expect(deps.onAgentExitedRef.current).not.toHaveBeenCalled() + + mockStoreState.agentLaunchConfigByPaneKey[paneKey] = launch + dataCallbackRef.current?.('\x1b]133;C\x07\x1b]133;D;0\x07') + await vi.advanceTimersByTimeAsync(350) + await flushAsyncTicks() + expect(deps.onAgentExitedRef.current).toHaveBeenCalledExactlyOnceWith(LEAF_1) + }) }) 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 25624b936557..4d91e42cdd9a 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 @@ -181,9 +181,13 @@ export function installPaneAgentIdentity(session: ConnectPanePtySession): void { } }, hasKnownAgentIdentity: session.paneHasKnownAgentIdentity, - onConfirmedShellForeground: (reason, agentExited) => { - // Why: with main as the parser its host fact is the one exit publisher; bytes make it ours. - if (agentExited && !session.mainSideEffectAuthority) { + onConfirmedShellForeground: (reason, agentExit) => { + // Why: main publishes marked exits from its own facts; our own read-confirmed exit also + // covers main's reads failing at that exit. A second exit signal is a no-op downstream. + if ( + agentExit === 'read-confirmed' || + (agentExit === 'marked' && !session.mainSideEffectAuthority) + ) { session.onAgentExited?.() } // Why: a confirmed local shell proves any hibernation record for this pane is stale; @@ -344,7 +348,9 @@ export function installPaneAgentIdentity(session: ConnectPanePtySession): void { session.visibleForegroundSampleSettled = false // Why: typed commands can be aliases, so they only widen the bounded // process-confirmation window; they never become routing evidence. - session.paneForegroundAgentTracker.onCommandStarted(session.commandInferredPaneAgent) + session.paneForegroundAgentTracker.onCommandStarted(session.commandInferredPaneAgent, { + shellMarked: true + }) }, onCommandFinished: session.handleCommandFinished }) From df76d77f10df6dc1514e0cbf85fbb8d45bcc952e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 00:51:43 -0700 Subject: [PATCH 20/31] fix(runtime): count only this incarnation's agent sighting at a 133;D A same-id respawn keeps the previous process's foregroundAgent, so the new shell's first-prompt 133;D read as an agent the PTY held and a boot-time shell read published agent-exited. Stamp each read that names the agent with the incarnation it saw and require a match. --- ...orca-runtime-get-pty-record-for-pane-key.ts | 1 + ...ntime-pty-foreground-agent-evidence.test.ts | 18 +++++++++++++++++- .../runtime/runtime-pty-foreground-agent.ts | 10 +++++++++- .../runtime/runtime-terminal-state-records.ts | 2 ++ src/main/runtime/shell-command-agent-hold.ts | 8 ++++++-- .../terminal-agent-exit-evidence.test.ts | 17 +++++++++++++++++ 6 files changed, 52 insertions(+), 4 deletions(-) diff --git a/src/main/runtime/orca-runtime-get-pty-record-for-pane-key.ts b/src/main/runtime/orca-runtime-get-pty-record-for-pane-key.ts index e26301e379a2..2561d1ec2177 100644 --- a/src/main/runtime/orca-runtime-get-pty-record-for-pane-key.ts +++ b/src/main/runtime/orca-runtime-get-pty-record-for-pane-key.ts @@ -141,6 +141,7 @@ export class OrcaRuntimeWithGetPtyRecordForPaneKey extends OrcaRuntimeWithPruneM return false } trackedPty.foregroundAgent = agent + trackedPty.foregroundAgentIncarnationId = trackedPty.incarnationId return true } catch { return false diff --git a/src/main/runtime/runtime-pty-foreground-agent-evidence.test.ts b/src/main/runtime/runtime-pty-foreground-agent-evidence.test.ts index ccb2baea8e07..4d845231852e 100644 --- a/src/main/runtime/runtime-pty-foreground-agent-evidence.test.ts +++ b/src/main/runtime/runtime-pty-foreground-agent-evidence.test.ts @@ -10,7 +10,12 @@ import type { TerminalProcessInspection } from '../../shared/terminal-process-in function setup(remote = false) { const pty: Pick< RuntimePtyWorktreeRecord, - 'connectionId' | 'incarnationId' | 'connected' | 'launchAgent' | 'foregroundAgent' + | 'connectionId' + | 'incarnationId' + | 'connected' + | 'launchAgent' + | 'foregroundAgent' + | 'foregroundAgentIncarnationId' > = { connectionId: remote ? 'ssh-host' : null, incarnationId: 'generation-1', @@ -110,6 +115,17 @@ describe('foreground identity on unknown observations', () => { await h.agent.refresh('pty-1') expect(h.pty.foregroundAgent).toBe(expected) }) + it('stamps each read that names the agent with the incarnation it saw', async () => { + const h = setup() + h.confirm.mockResolvedValue('claude') + await h.agent.refresh('pty-1') + expect(h.pty.foregroundAgentIncarnationId).toBe('generation-1') + // A same-id respawn running the same agent must not keep the predecessor's stamp. + h.pty.incarnationId = 'generation-2' + await h.agent.confirm('pty-1') + expect(h.pty.foregroundAgent).toBe('claude') + expect(h.pty.foregroundAgentIncarnationId).toBe('generation-2') + }) it('clears foregroundAgent from the same read that confirms an exit', async () => { const h = setup() h.confirm.mockResolvedValue('zsh') diff --git a/src/main/runtime/runtime-pty-foreground-agent.ts b/src/main/runtime/runtime-pty-foreground-agent.ts index 9a91a73e57d3..1f5e28b815f0 100644 --- a/src/main/runtime/runtime-pty-foreground-agent.ts +++ b/src/main/runtime/runtime-pty-foreground-agent.ts @@ -16,7 +16,12 @@ import type { RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' type ForegroundPty = Pick< RuntimePtyWorktreeRecord, - 'connectionId' | 'incarnationId' | 'connected' | 'launchAgent' | 'foregroundAgent' + | 'connectionId' + | 'incarnationId' + | 'connected' + | 'launchAgent' + | 'foregroundAgent' + | 'foregroundAgentIncarnationId' > type Dependencies = { @@ -200,6 +205,9 @@ export class RuntimePtyForegroundAgent { judgement.verdict === 'live' ? (recognizeAgentProcess(judgement.processName)?.agent ?? null) : null + if (agent) { + pty.foregroundAgentIncarnationId = pty.incarnationId + } if (pty.foregroundAgent === agent) { return false } diff --git a/src/main/runtime/runtime-terminal-state-records.ts b/src/main/runtime/runtime-terminal-state-records.ts index 37e33c6e0ed7..73aebbe0bac1 100644 --- a/src/main/runtime/runtime-terminal-state-records.ts +++ b/src/main/runtime/runtime-terminal-state-records.ts @@ -67,6 +67,8 @@ export type RuntimePtyWorktreeRecord = RuntimeTerminalTailState & { launchAgent: TuiAgent | null agentSessionOwners: AgentSessionOwnerBinding[] foregroundAgent: TuiAgent | null + /** The incarnation whose read last named `foregroundAgent`; a same-id respawn keeps the name. */ + foregroundAgentIncarnationId?: PtyIncarnationId | null connected: boolean disconnectedAt: number | null lastExitCode: number | null diff --git a/src/main/runtime/shell-command-agent-hold.ts b/src/main/runtime/shell-command-agent-hold.ts index b2b2a51eae50..323f64fbdf87 100644 --- a/src/main/runtime/shell-command-agent-hold.ts +++ b/src/main/runtime/shell-command-agent-hold.ts @@ -5,7 +5,11 @@ type ShellCommandPty = Pick /** OSC 133;C: the next 133;D ends a command this incarnation started. */ @@ -26,7 +30,7 @@ export function takeShellCommandFinishedAgentHold(pty: AgentHoldPty): boolean { pty.runningShellCommand = undefined return ( endsLaunchedCommand || - Boolean(pty.foregroundAgent) || + (Boolean(pty.foregroundAgent) && pty.foregroundAgentIncarnationId === pty.incarnationId) || (Boolean(pty.lastAgentStatus) && pty.lastAgentStatusObservedLive) ) } diff --git a/src/main/runtime/terminal-agent-exit-evidence.test.ts b/src/main/runtime/terminal-agent-exit-evidence.test.ts index a27e69ff4438..3170c8289f18 100644 --- a/src/main/runtime/terminal-agent-exit-evidence.test.ts +++ b/src/main/runtime/terminal-agent-exit-evidence.test.ts @@ -41,6 +41,7 @@ function setup() { lastAgentStatusObservedLive: boolean launchAgent: string | null foregroundAgent: string | null + foregroundAgentIncarnationId?: string | null connectionId?: string } = { connected: true, @@ -190,6 +191,7 @@ describe('agent exit at the shell 133;D', () => { it('retires the agent on the 133;D where no read can ever name the shell', async () => { const h = setup() h.pty.foregroundAgent = 'claude' + h.pty.foregroundAgentIncarnationId = 'inc-1' h.read.mockResolvedValue(readResult(h.controller, 'wsl.exe')) h.runtime.commandFinished() await vi.waitFor(() => @@ -262,6 +264,21 @@ describe('a 133;D printed before the launched agent starts', () => { expect(h.facts).toEqual([]) h.tracker.dispose() }) + it('does not count an agent a read saw in an earlier incarnation', async () => { + const h = setup() + h.pty.launchAgent = 'claude' + h.pty.foregroundAgent = 'claude' + h.pty.foregroundAgentIncarnationId = 'inc-1' + // A same-id respawn: the new shell prints its first-prompt D before the agent relaunches. + h.pty.incarnationId = 'inc-2' + h.read.mockResolvedValue(readResult(h.controller, 'zsh')) + h.runtime.commandFinished() + await Promise.resolve() + await Promise.resolve() + expect(h.read).not.toHaveBeenCalled() + expect(h.facts).toEqual([]) + h.tracker.dispose() + }) it('counts one 133;D per started command when a user integration doubles the marks', async () => { const h = setup() h.pty.launchAgent = 'claude' From 8f570648a4ea9e0c010b4ce05575bc77f7af5634 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 00:51:52 -0700 Subject: [PATCH 21/31] fix(terminal): mark an exit only from the replacement PTY's own evidence The pane tracker outlives a PTY replacement, and its agent-evidence flag and running-command mark carried into the next PTY. In byte mode that made the replacement shell's first-prompt 133;D a marked exit and left Chat during a wake or restart. Each mark now keeps the PTY key it was seen in; the marks move to their own module to keep the tracker under max-lines. --- .../agent-exit-observation.test.ts | 27 +++++++++- .../terminal-pane/pane-agent-exit-marks.ts | 53 +++++++++++++++++++ .../pane-foreground-agent-tracker.ts | 36 +++++-------- 3 files changed, 93 insertions(+), 23 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/pane-agent-exit-marks.ts diff --git a/src/renderer/src/components/terminal-pane/agent-exit-observation.test.ts b/src/renderer/src/components/terminal-pane/agent-exit-observation.test.ts index ce7c58a46248..e8c57edad892 100644 --- a/src/renderer/src/components/terminal-pane/agent-exit-observation.test.ts +++ b/src/renderer/src/components/terminal-pane/agent-exit-observation.test.ts @@ -312,7 +312,32 @@ describe('agent exit observation', () => { tracker.onCommandFinished() await vi.advanceTimersByTimeAsync(8000) expect(shell).toHaveBeenCalledTimes(2) - expect(shell.mock.calls.at(-1)?.[1]).not.toBe('read-confirmed') + // Nor does the earlier PTY's evidence make the new PTY's first-prompt D a marked exit. + expect(shell.mock.calls.at(-1)?.[1]).toBe('none') + tracker.dispose() + }) + + it('does not count a command the previous PTY started as closed by the next PTY', async () => { + vi.useFakeTimers() + let ptyId = 'pty-1' + const shell = vi.fn() + const read = vi.fn().mockResolvedValue('zsh') + const tracker = createPaneForegroundAgentTracker({ + getPtyId: () => ptyId, + isTrackablePtyId: () => true, + readForegroundProcess: read, + confirmForegroundProcess: read, + publish: vi.fn(), + hasKnownAgentIdentity: () => true, + onConfirmedShellForeground: shell + }) + // The agent's command started in pty-1, which then died with no 133;D. + tracker.onCommandStarted(null, { shellMarked: true }) + ptyId = 'pty-2' + // The replacement shell's first-prompt D, before the relaunched agent starts. + tracker.onCommandFinished() + await vi.advanceTimersByTimeAsync(8000) + expect(shell).toHaveBeenCalledExactlyOnceWith('command-finished', 'none') tracker.dispose() }) diff --git a/src/renderer/src/components/terminal-pane/pane-agent-exit-marks.ts b/src/renderer/src/components/terminal-pane/pane-agent-exit-marks.ts new file mode 100644 index 000000000000..62e4b78cf025 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pane-agent-exit-marks.ts @@ -0,0 +1,53 @@ +/** 'read-confirmed': a read in this PTY saw the agent live first. 'marked': its own title, or a + * 133;D closing a command the shell started, brackets the exit. 'none': only an expectation. */ +export type ConfirmedShellAgentExit = 'none' | 'marked' | 'read-confirmed' + +/** + * What lets a confirmed shell count as an agent exit. Each mark keeps the PTY key (id plus host + * incarnation) it was seen in, because the pane's tracker outlives a PTY replacement. + */ +export function createPaneAgentExitMarks(): { + agentSeenLive: (ptyKey: string) => void + agentTitleObserved: (ptyKey: string) => void + commandStarted: (shellMarkedPtyKey: string | null) => void + commandFinished: () => void + classifyConfirmedShell: ( + ptyKey: string, + reason: 'visible-pty' | 'command-finished' + ) => ConfirmedShellAgentExit +} { + let seenLive: string | null = null + let observed: string | null = null + // Why: a user shell integration can print 133;D at the first prompt, before the launch runs. + let runningCommand: string | null = null + let closedCommand: string | null = null + return { + agentSeenLive(ptyKey) { + seenLive = ptyKey + observed = ptyKey + }, + agentTitleObserved(ptyKey) { + observed = ptyKey + }, + commandStarted(shellMarkedPtyKey) { + // Why: a new command means an earlier sighting describes the previous one. + seenLive = null + runningCommand = shellMarkedPtyKey ?? runningCommand + }, + commandFinished() { + closedCommand = runningCommand + runningCommand = null + }, + classifyConfirmedShell(ptyKey, reason) { + const exit = + seenLive === ptyKey + ? 'read-confirmed' + : observed === ptyKey || (reason === 'command-finished' && closedCommand === ptyKey) + ? 'marked' + : 'none' + seenLive = null + observed = null + return exit + } + } +} diff --git a/src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.ts b/src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.ts index 46e47d818a38..fd04a32bb780 100644 --- a/src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.ts +++ b/src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.ts @@ -6,6 +6,9 @@ import type { TuiAgent } from '../../../../shared/tui-agent' import type { PaneForegroundAgentEntry } from '@/store/slices/pane-foreground-agent' import type { RuntimeTerminalProcessInspection } from '@/runtime/runtime-terminal-inspection' import { createPaneForegroundProcessReader } from './pane-foreground-process-reader' +import { createPaneAgentExitMarks, type ConfirmedShellAgentExit } from './pane-agent-exit-marks' + +export type { ConfirmedShellAgentExit } from './pane-agent-exit-marks' // Why: settle after exec, then place the final generic retry beyond sequential // 3s PowerShell and WMIC enrichment scans. @@ -14,9 +17,6 @@ const VISIBLE_PTY_SETTLE_MS = 350 const WRAPPER_RESOLVE_RETRY_DELAYS_MS = [1200, 6000] as const type ForegroundReadReason = 'command' | 'visible-pty' | 'command-finished' type ShellConfirmReason = Exclude -/** 'read-confirmed': a read in this PTY saw the agent live first. 'marked': its own title, or a - * 133;D closing a command the shell started, brackets the exit. 'none': only an expectation. */ -export type ConfirmedShellAgentExit = 'none' | 'marked' | 'read-confirmed' type PaneForegroundAgentTrackerDeps = { getPtyId: () => string | null @@ -87,11 +87,7 @@ export function createPaneForegroundAgentTracker(deps: PaneForegroundAgentTracke let hasAgentExpectation = false // True while the pending visible-pty read answers the agent's own idle-to-neutral title. let agentTitleExitCandidate = false - // Why: a user shell integration can print 133;D at the first prompt, before the launch runs. - // 'closed': the latest 133;D ended a command the shell's own 133;C started. - let shellCommandMark: 'running' | 'closed' | null = null - // The PTY (and host incarnation) where a read last saw the agent live. - let agentSeenLiveInPtyKey: string | null = null + const exitMarks = createPaneAgentExitMarks() const ptyKey = (id: string): string => `${id}\n${deps.getExpectedIncarnationId?.() ?? ''}` const readProcess = createPaneForegroundProcessReader(deps) @@ -181,7 +177,7 @@ export function createPaneForegroundAgentTracker(deps: PaneForegroundAgentTracke const recognized = verdict === 'live' ? recognizeAgentProcess(processName) : null if (recognized) { hasForegroundAgentEvidence = true - agentSeenLiveInPtyKey = ptyKey(ptyId) + exitMarks.agentSeenLive(ptyKey(ptyId)) hasAgentExpectation = false deps.publish({ agent: recognized.agent, @@ -259,14 +255,10 @@ export function createPaneForegroundAgentTracker(deps: PaneForegroundAgentTracke const confirmShellForeground = (reason: ShellConfirmReason, ptyId: string): void => { // Why: an exit needs an agent seen in this pane, or the shell's own 133;D ending its command. - const agentExit: ConfirmedShellAgentExit = - agentSeenLiveInPtyKey === ptyKey(ptyId) - ? 'read-confirmed' - : hasForegroundAgentEvidence || - (reason === 'command-finished' && shellCommandMark === 'closed') - ? 'marked' - : 'none' - agentSeenLiveInPtyKey = null + const agentExit: ConfirmedShellAgentExit = exitMarks.classifyConfirmedShell( + ptyKey(ptyId), + reason + ) // Why: reset the evidence so the pane's ordinary shell commands go back to // the no-RPC finished path. hasForegroundAgentEvidence = false @@ -291,7 +283,8 @@ export function createPaneForegroundAgentTracker(deps: PaneForegroundAgentTracke } const hadReadBeforeVisibleBind = hasPendingRead() cancelPendingRead() - if (!trackablePtyId()) { + const ptyId = trackablePtyId() + if (!ptyId) { releaseRetainedCapability(hadReadBeforeVisibleBind) return false } @@ -300,6 +293,7 @@ export function createPaneForegroundAgentTracker(deps: PaneForegroundAgentTracke } if (agentObserved) { hasForegroundAgentEvidence = true + exitMarks.agentTitleObserved(ptyKey(ptyId)) } // Why: restored/manual agent panes can become visible while Codex is // already foreground, so no OSC 133 command-start event will seed the tab icon. @@ -326,10 +320,8 @@ export function createPaneForegroundAgentTracker(deps: PaneForegroundAgentTracke onCommandStarted(expectedAgent = null, options = {}) { const hadReadBeforeCommandStart = hasPendingRead() cancelPendingRead() - shellCommandMark = options.shellMarked ? 'running' : shellCommandMark - // Why: a new command means an earlier sighting describes the previous one. - agentSeenLiveInPtyKey = null const ptyId = trackablePtyId() + exitMarks.commandStarted(options.shellMarked && ptyId ? ptyKey(ptyId) : null) if (!ptyId) { releaseRetainedCapability(hadReadBeforeCommandStart) return @@ -354,7 +346,7 @@ export function createPaneForegroundAgentTracker(deps: PaneForegroundAgentTracke scheduleRead(COMMAND_SETTLE_MS, 0, 'command') }, onCommandFinished() { - shellCommandMark = shellCommandMark === 'running' ? 'closed' : null + exitMarks.commandFinished() if (deps.hasKnownAgentIdentity?.() === true) { hasKnownAgentEvidence = true } From 361a689f5117119184f59bf140c3c18e836be367 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 01:31:59 -0700 Subject: [PATCH 22/31] fix(terminal): one blindness rule for title exits, and a bounded re-read of an unanswered one Main gated the agent's own neutral-title exit on SSH and the renderer on "any remote pane", so claude inside tmux or ssh, and WSL with a shell that marks no commands, never exited. Both now ask one shared predicate: a pane is blind when a read that answered can never show its shell (a host that cannot read, a program hosting the agent) or, where its shell marks no OSC 133 commands, names the WSL bridge or another program. An unanswered read is never blindness. When main's read after a title candidate cannot answer, it re-reads on the shared 1.2 s / 6 s ladder, then keeps the candidate only until the host is next reached (liveness or reattach), re-reading once. Exit, a live answer, a new incarnation, or disposal drop it. Publishing and the recheck move to their own runtime class to keep the split file under max-lines. --- .../runtime/agent-exit-candidate-recheck.ts | 75 +++++++++ .../orca-runtime-agent-exit-confirmation.ts | 96 ++++++++++++ ...untime-agent-prompt-request-correlation.ts | 4 +- .../orca-runtime-apply-tracked-pty-title.ts | 2 +- ...ntime-serialize-agent-prompt-submission.ts | 128 ++++++---------- .../runtime/runtime-terminal-state-records.ts | 4 +- src/main/runtime/shell-command-agent-hold.ts | 15 +- .../terminal-agent-exit-evidence.test.ts | 144 ++++++++++++++++-- .../agent-exit-observation.test.ts | 53 +++++++ .../terminal-pane/pane-agent-exit-marks.ts | 15 +- .../pane-foreground-agent-tracker.ts | 34 +++-- .../pty-connection/pane-agent-identity.ts | 1 + src/shared/foreground-agent-verdict.test.ts | 42 +++++ src/shared/foreground-agent-verdict.ts | 94 ++++++++++-- 14 files changed, 581 insertions(+), 126 deletions(-) create mode 100644 src/main/runtime/agent-exit-candidate-recheck.ts create mode 100644 src/main/runtime/orca-runtime-agent-exit-confirmation.ts diff --git a/src/main/runtime/agent-exit-candidate-recheck.ts b/src/main/runtime/agent-exit-candidate-recheck.ts new file mode 100644 index 000000000000..23dbb62a0bad --- /dev/null +++ b/src/main/runtime/agent-exit-candidate-recheck.ts @@ -0,0 +1,75 @@ +import { FOREGROUND_CONFIRM_RETRY_DELAYS_MS } from '../../shared/foreground-agent-verdict' + +type Candidate = { + incarnationId: string | null + titleObservedAt: number | null + timer: ReturnType | null +} + +export type AgentExitRecheck = { attempt: number; titleObservedAt: number | null } + +/** The ladder is spent: the candidate waits for the host to be reached again. */ +export const SPENT_AGENT_EXIT_RECHECK = FOREGROUND_CONFIRM_RETRY_DELAYS_MS.length +/** A reconnect's single re-read, which never re-arms. */ +const FINAL_AGENT_EXIT_RECHECK = SPENT_AGENT_EXIT_RECHECK + 1 + +/** + * An agent exit candidate (its own neutral title) whose confirming read could not answer. + * Re-read on the shared bounded ladder, then keep it only until the host is reached again once. + */ +export class AgentExitCandidateRechecks { + private readonly byPtyId = new Map() + + constructor(private readonly recheck: (ptyId: string, recheck: AgentExitRecheck) => void) {} + + /** False when the ladder is spent; the candidate then waits for one reconnect re-read. */ + scheduleNext( + ptyId: string, + incarnationId: string | null, + titleObservedAt: number | null, + attempt: number + ): boolean { + this.clear(ptyId) + const delay = FOREGROUND_CONFIRM_RETRY_DELAYS_MS[attempt] + if (attempt >= FINAL_AGENT_EXIT_RECHECK) { + return false + } + const candidate: Candidate = { incarnationId, titleObservedAt, timer: null } + this.byPtyId.set(ptyId, candidate) + if (delay === undefined) { + return false + } + candidate.timer = setTimeout(() => { + candidate.timer = null + if (this.byPtyId.get(ptyId) === candidate) { + this.byPtyId.delete(ptyId) + this.recheck(ptyId, { attempt: attempt + 1, titleObservedAt }) + } + }, delay) + candidate.timer.unref?.() + return true + } + + /** The host answered for this PTY again: re-read a candidate the ladder left undecided, once. */ + recheckAfterContact(ptyId: string, incarnationId: string | null): void { + const candidate = this.byPtyId.get(ptyId) + if (!candidate || candidate.timer !== null) { + return + } + this.byPtyId.delete(ptyId) + if (candidate.incarnationId === incarnationId) { + this.recheck(ptyId, { + attempt: FINAL_AGENT_EXIT_RECHECK, + titleObservedAt: candidate.titleObservedAt + }) + } + } + + clear(ptyId: string): void { + const candidate = this.byPtyId.get(ptyId) + if (candidate?.timer) { + clearTimeout(candidate.timer) + } + this.byPtyId.delete(ptyId) + } +} diff --git a/src/main/runtime/orca-runtime-agent-exit-confirmation.ts b/src/main/runtime/orca-runtime-agent-exit-confirmation.ts new file mode 100644 index 000000000000..996dea83966a --- /dev/null +++ b/src/main/runtime/orca-runtime-agent-exit-confirmation.ts @@ -0,0 +1,96 @@ +import { startSpan } from '../observability/tracer' +import { takeShellCommandFinishedAgentHold } from './shell-command-agent-hold' +import { AgentExitCandidateRechecks } from './agent-exit-candidate-recheck' +import { OrcaRuntimeWithSerializeAgentPromptSubmission } from './orca-runtime-serialize-agent-prompt-submission' + +/** Publishes agent exits and owns the bounded re-read of a title candidate no read could answer. */ +export class OrcaRuntimeWithAgentExitConfirmation extends OrcaRuntimeWithSerializeAgentPromptSubmission { + protected readonly agentExitRechecks = new AgentExitCandidateRechecks((ptyId, recheck) => + this.confirmPtyAgentExit(ptyId, false, recheck) + ) + + /** + * The shell's own 133;D ends an agent this PTY held once a read shows a shell, or at once on a + * host that can never show one. A PTY that held no agent pays no read. + */ + protected confirmPtyAgentExitAtCommandFinished(ptyId: string): void { + const pty = this.ptysById.get(ptyId) + const heldAgent = pty ? takeShellCommandFinishedAgentHold(pty) : false + if (!pty?.connected || !heldAgent) { + return + } + const incarnationId = pty.incarnationId + // Why +0.5: newer than every title seen so far and older than the next, so a read begun + // before this 133;D is never reused and a later title never reuses this one. + const afterBoundary = this.titleObservationSequence + 0.5 + void this.readPtyForegroundProcessFromController(ptyId, afterBoundary)?.then((result) => { + const current = this.ptysById.get(ptyId) + const { verdict, canCertifyExit } = result.judgement + if ( + current !== pty || + !current.connected || + current.incarnationId !== incarnationId || + result.controller !== this.ptyController || + verdict === 'live' || + (verdict === 'unverifiable' && canCertifyExit) + ) { + return + } + if (verdict === 'unverifiable') { + this.ptyForegroundAgent.markExited(ptyId) + } + // Why: a stale agent title would otherwise make every later 133;D look like an agent's. + current.lastAgentStatus = null + this.publishPtyAgentExit( + ptyId, + 'command-finished', + verdict === 'exited' ? 'foreground-shell' : 'command-finished' + ) + }) + } + + protected publishPtyAgentExit( + ptyId: string, + reason: 'title-exit-candidate' | 'command-finished', + evidence: 'foreground-shell' | 'command-finished' | 'agent-title' + ): void { + startSpan('terminal.agent-exit-decision', { + attributes: { + reason, + evidenceSource: + evidence === 'foreground-shell' + ? 'host-foreground-confirmation' + : evidence === 'agent-title' + ? 'osc-title' + : 'osc-133', + verdict: 'exited' + } + }).end() + this.agentExitRechecks.clear(ptyId) + this.recordTerminalSideEffectFact(ptyId, { kind: 'agent-exited', evidence }) + } + + markPtyLivenessLive(ptyId: string, observedNoLaterThan?: number): void { + super.markPtyLivenessLive(ptyId, observedNoLaterThan) + this.agentExitRechecks.recheckAfterContact( + ptyId, + this.ptysById.get(ptyId)?.incarnationId ?? null + ) + } + + registerPty( + ...args: Parameters + ): void { + super.registerPty(...args) + // Why: a reattach is renewed contact with the host that could not answer. + this.agentExitRechecks.recheckAfterContact( + args[0], + this.ptysById.get(args[0])?.incarnationId ?? null + ) + } + + protected disposePtyTitleTracker(ptyId: string): void { + super.disposePtyTitleTracker(ptyId) + this.agentExitRechecks.clear(ptyId) + } +} diff --git a/src/main/runtime/orca-runtime-agent-prompt-request-correlation.ts b/src/main/runtime/orca-runtime-agent-prompt-request-correlation.ts index 9606d33e7dbf..48ee656b98c3 100644 --- a/src/main/runtime/orca-runtime-agent-prompt-request-correlation.ts +++ b/src/main/runtime/orca-runtime-agent-prompt-request-correlation.ts @@ -1,4 +1,4 @@ -import { OrcaRuntimeWithSerializeAgentPromptSubmission } from './orca-runtime-serialize-agent-prompt-submission' +import { OrcaRuntimeWithAgentExitConfirmation } from './orca-runtime-agent-exit-confirmation' import type { RuntimeTerminalPromptDelivery } from '../../shared/runtime-types' import type { RuntimeLeafRecord, RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' import type { TerminalHandleRecord } from './runtime-terminal-contracts' @@ -9,7 +9,7 @@ import type { import { verifyAgentPromptSubmission } from './agent-prompt-submission-verification' import { AgentPromptRequestCorrelation } from './agent-prompt-request-correlation' -export class OrcaRuntimeWithAgentPromptRequestCorrelation extends OrcaRuntimeWithSerializeAgentPromptSubmission { +export class OrcaRuntimeWithAgentPromptRequestCorrelation extends OrcaRuntimeWithAgentExitConfirmation { private readonly agentPromptCorrelation = new AgentPromptRequestCorrelation() // Declared, not defined: both live further up the mixin chain, so this link cannot see them. declare protected getLivePtyForHandle: ( diff --git a/src/main/runtime/orca-runtime-apply-tracked-pty-title.ts b/src/main/runtime/orca-runtime-apply-tracked-pty-title.ts index 80a5ac67104e..54d9c8fdac7f 100644 --- a/src/main/runtime/orca-runtime-apply-tracked-pty-title.ts +++ b/src/main/runtime/orca-runtime-apply-tracked-pty-title.ts @@ -192,7 +192,7 @@ export class OrcaRuntimeWithApplyTrackedPtyTitle extends OrcaRuntimeWithGetUnper pty.waitBlockedAt = null pty.tailWaitState = undefined pty.commandPaint = undefined - pty.runningShellCommand = undefined + pty.shellCommandMarks = undefined } for (const leaf of this.getLeavesForPty(ptyId)) { leaf.lastOscTitle = 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 d21f8d5bf323..b0b8a9385fd5 100644 --- a/src/main/runtime/orca-runtime-serialize-agent-prompt-submission.ts +++ b/src/main/runtime/orca-runtime-serialize-agent-prompt-submission.ts @@ -1,7 +1,8 @@ // @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. -import { startSpan } from '../observability/tracer' import { selectFreshExplicitAgentStatus } from './runtime-hook-agent-row-selection' -import { takeShellCommandFinishedAgentHold } from './shell-command-agent-hold' +import { ptyMarksShellCommands } from './shell-command-agent-hold' +import { isAgentExitBlind } from '../../shared/foreground-agent-verdict' +import { SPENT_AGENT_EXIT_RECHECK, type AgentExitRecheck } from './agent-exit-candidate-recheck' import { OrcaRuntimeWithControllerKnowsPtyIsLive } from './orca-runtime-controller-knows-pty-is-live' import type { RuntimeTerminalAgentStatus } from '../../shared/runtime-types' import type { RuntimeTerminalAgentStatusSnapshot } from './runtime-terminal-agent-status-query' @@ -72,7 +73,11 @@ export class OrcaRuntimeWithSerializeAgentPromptSubmission extends OrcaRuntimeWi return this.ptyForegroundAgent.confirm(ptyId, afterTitleObservation) } - protected confirmPtyAgentExit(ptyId: string, recoverCompletedHook = false): void { + protected confirmPtyAgentExit( + ptyId: string, + recoverCompletedHook = false, + recheck?: AgentExitRecheck + ): void { const pty = this.ptysById.get(ptyId) const handle = this.handleByPtyId.get(ptyId) if ( @@ -83,22 +88,40 @@ export class OrcaRuntimeWithSerializeAgentPromptSubmission extends OrcaRuntimeWi } const incarnationId = pty?.incarnationId const generation = recoverCompletedHook ? this.getPtyLifecycleGeneration(ptyId) : null - const titleObservedAt = pty?.lastOscTitleAt ?? null - const foregroundRead = this.readPtyForegroundProcessFromController(ptyId, titleObservedAt ?? 0) - if (!pty?.connected || !foregroundRead) { + const titleObservedAt = recheck ? recheck.titleObservedAt : (pty?.lastOscTitleAt ?? null) + const foregroundRead = this.readPtyForegroundProcessFromController( + ptyId, + pty?.lastOscTitleAt ?? 0 + ) + // Why: no host to ask now; the next contact with it re-reads once. + const awaitHostContact = (): void => { + if (pty && !recoverCompletedHook) { + this.agentExitRechecks.scheduleNext( + ptyId, + pty.incarnationId, + titleObservedAt, + Math.max(recheck?.attempt ?? 0, SPENT_AGENT_EXIT_RECHECK) + ) + } this.ptyTitleTrackersByPtyId.get(ptyId)?.tracker.restoreLastAgentExit() + } + if (!pty?.connected || !foregroundRead) { + awaitHostContact() return } void foregroundRead.then((result) => { const current = this.ptysById.get(ptyId) if ( current !== pty || - !current.connected || current.incarnationId !== incarnationId || (recoverCompletedHook && this.getPtyLifecycleGeneration(ptyId) !== generation) ) { return } + if (!current.connected) { + awaitHostContact() + return + } if (current.lastOscTitleAt !== titleObservedAt && current.lastAgentStatus !== null) { return } @@ -114,6 +137,9 @@ export class OrcaRuntimeWithSerializeAgentPromptSubmission extends OrcaRuntimeWi return } const { verdict, processName } = result.judgement + if (!recoverCompletedHook) { + this.agentExitRechecks.clear(ptyId) + } if (result.controller === this.ptyController && verdict === 'live') { // Codex's final native spinner can arrive after its done hook, then clear to the cwd. const confirmedStatus = @@ -146,91 +172,37 @@ export class OrcaRuntimeWithSerializeAgentPromptSubmission extends OrcaRuntimeWi } return } - // Why: an SSH relay on Windows neither reads the foreground nor marks commands (OSC 133), - // so there the agent's own idle-to-neutral title is the only exit signal (base behaviour). - const titleIsOnlyExitSignal = - verdict === 'unverifiable' && - !result.judgement.canCertifyExit && - Boolean(current.connectionId) - if ( - !recoverCompletedHook && - result.controller === this.ptyController && - (verdict === 'exited' || titleIsOnlyExitSignal) - ) { - if (titleIsOnlyExitSignal) { + const decides = !recoverCompletedHook && result.controller === this.ptyController + // Why: where no read can show this pane's shell, the agent's own title is its exit (base). + const blind = isAgentExitBlind(result.judgement, ptyMarksShellCommands(current)) + if (decides && (verdict === 'exited' || blind)) { + if (blind) { this.ptyForegroundAgent.markExited(ptyId) } this.publishPtyAgentExit( ptyId, 'title-exit-candidate', - titleIsOnlyExitSignal ? 'agent-title' : 'foreground-shell' + blind ? 'agent-title' : 'foreground-shell' ) - } else { - this.ptyTitleTrackersByPtyId.get(ptyId)?.tracker.restoreLastAgentExit() + return } - }) - } - - /** - * The shell's own 133;D ends an agent this PTY held once a read shows a shell, or at once on a - * host that can never show one. A PTY that held no agent pays no read. - */ - protected confirmPtyAgentExitAtCommandFinished(ptyId: string): void { - const pty = this.ptysById.get(ptyId) - const heldAgent = pty ? takeShellCommandFinishedAgentHold(pty) : false - if (!pty?.connected || !heldAgent) { - return - } - const incarnationId = pty.incarnationId - // Why +0.5: newer than every title seen so far and older than the next, so a read begun - // before this 133;D is never reused and a later title never reuses this one. - const afterBoundary = this.titleObservationSequence + 0.5 - void this.readPtyForegroundProcessFromController(ptyId, afterBoundary)?.then((result) => { - const current = this.ptysById.get(ptyId) - const { verdict, canCertifyExit } = result.judgement + // Why: an unanswered read re-derives on the bounded ladder; the candidate stays until then. if ( - current !== pty || - !current.connected || - current.incarnationId !== incarnationId || - result.controller !== this.ptyController || - verdict === 'live' || - (verdict === 'unverifiable' && canCertifyExit) + decides && + result.judgement.blindness === undefined && + this.agentExitRechecks.scheduleNext( + ptyId, + incarnationId, + titleObservedAt, + recheck?.attempt ?? 0 + ) ) { return } - if (verdict === 'unverifiable') { - this.ptyForegroundAgent.markExited(ptyId) - } - // Why: a stale agent title would otherwise make every later 133;D look like an agent's. - current.lastAgentStatus = null - this.publishPtyAgentExit( - ptyId, - 'command-finished', - verdict === 'exited' ? 'foreground-shell' : 'command-finished' - ) + this.ptyTitleTrackersByPtyId.get(ptyId)?.tracker.restoreLastAgentExit() }) } - private publishPtyAgentExit( - ptyId: string, - reason: 'title-exit-candidate' | 'command-finished', - evidence: 'foreground-shell' | 'command-finished' | 'agent-title' - ): void { - startSpan('terminal.agent-exit-decision', { - attributes: { - reason, - evidenceSource: - evidence === 'foreground-shell' - ? 'host-foreground-confirmation' - : evidence === 'agent-title' - ? 'osc-title' - : 'osc-133', - verdict: 'exited' - } - }).end() - this.recordTerminalSideEffectFact(ptyId, { kind: 'agent-exited', evidence }) - } - /** * Schedules an asynchronous query to check which agent process is currently * running in the foreground of a PTY. diff --git a/src/main/runtime/runtime-terminal-state-records.ts b/src/main/runtime/runtime-terminal-state-records.ts index 73aebbe0bac1..aa02ddc743e4 100644 --- a/src/main/runtime/runtime-terminal-state-records.ts +++ b/src/main/runtime/runtime-terminal-state-records.ts @@ -92,8 +92,8 @@ export type RuntimePtyWorktreeRecord = RuntimeTerminalTailState & { lastOutputAt: number | null /** See terminal-command-paint.ts; absent until the pane's first output, and again after a gap or a new process. */ commandPaint?: TerminalCommandPaint - /** The shell's OSC 133;C command no 133;D has ended yet, and the incarnation it ran in. */ - runningShellCommand?: { incarnationId: PtyIncarnationId | null } + /** The incarnation whose shell printed OSC 133 marks, and whether a 133;C awaits its 133;D. */ + shellCommandMarks?: { incarnationId: PtyIncarnationId | null; commandRunning: boolean } } export type RuntimePtyTabCloseAuthority = { diff --git a/src/main/runtime/shell-command-agent-hold.ts b/src/main/runtime/shell-command-agent-hold.ts index 323f64fbdf87..3d0fc6eca1e2 100644 --- a/src/main/runtime/shell-command-agent-hold.ts +++ b/src/main/runtime/shell-command-agent-hold.ts @@ -1,6 +1,6 @@ import type { RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' -type ShellCommandPty = Pick +type ShellCommandPty = Pick type AgentHoldPty = ShellCommandPty & Pick< @@ -14,7 +14,12 @@ type AgentHoldPty = ShellCommandPty & /** OSC 133;C: the next 133;D ends a command this incarnation started. */ export function noteShellCommandStarted(pty: ShellCommandPty): void { - pty.runningShellCommand = { incarnationId: pty.incarnationId } + pty.shellCommandMarks = { incarnationId: pty.incarnationId, commandRunning: true } +} + +/** Whether this incarnation's shell has printed any OSC 133 mark, so its own 133;D can end an agent. */ +export function ptyMarksShellCommands(pty: ShellCommandPty): boolean { + return pty.shellCommandMarks?.incarnationId === pty.incarnationId } /** @@ -25,9 +30,9 @@ export function takeShellCommandFinishedAgentHold(pty: AgentHoldPty): boolean { // Why: a user shell integration can print 133;D at the first prompt, before the launch runs. const endsLaunchedCommand = Boolean(pty.launchAgent) && - pty.runningShellCommand !== undefined && - pty.runningShellCommand.incarnationId === pty.incarnationId - pty.runningShellCommand = undefined + ptyMarksShellCommands(pty) && + pty.shellCommandMarks?.commandRunning === true + pty.shellCommandMarks = { incarnationId: pty.incarnationId, commandRunning: false } return ( endsLaunchedCommand || (Boolean(pty.foregroundAgent) && pty.foregroundAgentIncarnationId === pty.incarnationId) || diff --git a/src/main/runtime/terminal-agent-exit-evidence.test.ts b/src/main/runtime/terminal-agent-exit-evidence.test.ts index 3170c8289f18..c931fd4962ff 100644 --- a/src/main/runtime/terminal-agent-exit-evidence.test.ts +++ b/src/main/runtime/terminal-agent-exit-evidence.test.ts @@ -1,7 +1,7 @@ -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { createTerminalTitleTracker } from '../../shared/terminal-output-side-effects' import type { TerminalSideEffectFact } from '../../shared/terminal-side-effect-facts' -import { OrcaRuntimeWithSerializeAgentPromptSubmission } from './orca-runtime-serialize-agent-prompt-submission' +import { OrcaRuntimeWithAgentExitConfirmation } from './orca-runtime-agent-exit-confirmation' import { judgeForegroundAgent } from '../../shared/foreground-agent-verdict' import { noteShellCommandStarted } from './shell-command-agent-hold' @@ -11,10 +11,17 @@ const readResult = (controller: object, processName: string | null) => ({ }) vi.mock('./orca-runtime-controller-knows-pty-is-live', () => ({ - OrcaRuntimeWithControllerKnowsPtyIsLive: class {} + OrcaRuntimeWithControllerKnowsPtyIsLive: class { + markPtyLivenessLive(): void {} + disposePtyTitleTracker(): void {} + } })) -class ExitHarness extends OrcaRuntimeWithSerializeAgentPromptSubmission { +afterEach(() => { + vi.useRealTimers() +}) + +class ExitHarness extends OrcaRuntimeWithAgentExitConfirmation { confirm(): void { this.confirmPtyAgentExit('pty-1') } @@ -27,6 +34,9 @@ class ExitHarness extends OrcaRuntimeWithSerializeAgentPromptSubmission { commandFinished(): void { this.confirmPtyAgentExitAtCommandFinished('pty-1') } + dispose(): void { + this.disposePtyTitleTracker('pty-1') + } } function setup() { @@ -43,6 +53,7 @@ function setup() { foregroundAgent: string | null foregroundAgentIncarnationId?: string | null connectionId?: string + shellCommandMarks?: { incarnationId: string | null; commandRunning: boolean } } = { connected: true, incarnationId: 'inc-1', @@ -50,7 +61,9 @@ function setup() { lastAgentStatus: null, lastAgentStatusObservedLive: false, launchAgent: null, - foregroundAgent: null + foregroundAgent: null, + // A shell with Orca's integration has marked commands before the agent ran. + shellCommandMarks: { incarnationId: 'inc-1', commandRunning: false } } const markExited = vi.fn() const tracker = createTerminalTitleTracker( @@ -66,6 +79,7 @@ function setup() { readPtyForegroundProcessFromController: read, recordTerminalSideEffectFact: (_id: string, fact: TerminalSideEffectFact) => facts.push(fact), getLeavesForPty: () => [], + resolvePtyTuiIdleWaiters: () => {}, titleObservationSequence: 1, ptyForegroundAgent: { markExited } }) @@ -76,14 +90,16 @@ describe('host-confirmed agent exit', () => { it.each([null, '', 'node.exe', 'other-tool'])( 'retains Claude after %j and observes a later shell', async (process) => { + vi.useFakeTimers() const h = setup() h.read.mockResolvedValue(readResult(h.controller, process)) h.tracker.handleChunk('\x1b]0;workspace\x07') - await Promise.resolve() + await vi.advanceTimersByTimeAsync(0) expect(h.facts).toEqual([]) + // The later shell arrives by a re-read or by the next neutral title, whichever is owed. h.read.mockResolvedValue(readResult(h.controller, 'zsh')) h.tracker.handleChunk('\x1b]0;next workspace\x07') - await Promise.resolve() + await vi.advanceTimersByTimeAsync(8000) expect(h.facts).toEqual([{ kind: 'agent-exited', evidence: 'foreground-shell' }]) h.tracker.dispose() } @@ -91,6 +107,7 @@ describe('host-confirmed agent exit', () => { it.each(['missing', 'unavailable', 'replaced'] as const)( 'does not publish exit for a %s controller observation', async (kind) => { + vi.useFakeTimers() const h = setup() h.read.mockReturnValue( kind === 'missing' @@ -102,11 +119,11 @@ describe('host-confirmed agent exit', () => { ) ) h.tracker.handleChunk('\x1b]0;workspace\x07') - await Promise.resolve() + await vi.advanceTimersByTimeAsync(0) expect(h.facts).toEqual([]) h.read.mockResolvedValue(readResult(h.controller, 'zsh')) h.tracker.handleChunk('\x1b]0;next workspace\x07') - await Promise.resolve() + await vi.advanceTimersByTimeAsync(8000) expect(h.facts).toHaveLength(1) h.tracker.dispose() } @@ -135,7 +152,7 @@ describe('host-confirmed agent exit', () => { expect(h.markExited).toHaveBeenCalledWith('pty-1') h.tracker.dispose() }) - it('keeps a local WSL agent through a neutral title; its shell marks the exit', async () => { + it('keeps a local WSL agent through a neutral title where its shell marks commands', async () => { const h = setup() h.read.mockResolvedValue(readResult(h.controller, 'wsl.exe')) h.tracker.handleChunk('\x1b]0;workspace\x07') @@ -144,6 +161,36 @@ describe('host-confirmed agent exit', () => { expect(h.markExited).not.toHaveBeenCalled() h.tracker.dispose() }) + it.each([ + ['a WSL shell that marks no commands', 'wsl.exe', false], + ['claude inside tmux', 'tmux', true], + ['claude over ssh', 'ssh', true], + ['another program, where the shell marks no commands', 'vim', false] + ])('retires the agent on its own title for %s', async (_label, process, marks) => { + const h = setup() + if (!marks) { + h.pty.shellCommandMarks = undefined + } + h.read.mockResolvedValue(readResult(h.controller, process)) + h.tracker.handleChunk('\x1b]0;workspace\x07') + await Promise.resolve() + expect(h.facts).toEqual([{ kind: 'agent-exited', evidence: 'agent-title' }]) + expect(h.markExited).toHaveBeenCalledWith('pty-1') + h.tracker.dispose() + }) + it('keeps the agent through a failed read on a pane that marks no commands', async () => { + vi.useFakeTimers() + const h = setup() + h.pty.shellCommandMarks = undefined + h.read.mockResolvedValue({ + controller: h.controller, + judgement: judgeForegroundAgent({ kind: 'unavailable' }) + }) + h.tracker.handleChunk('\x1b]0;workspace\x07') + await vi.advanceTimersByTimeAsync(8000) + expect(h.facts).toEqual([]) + h.tracker.dispose() + }) it('ignores an observation after terminal incarnation replacement', async () => { const h = setup() h.read.mockResolvedValue(readResult(h.controller, 'zsh')) @@ -291,3 +338,80 @@ describe('a 133;D printed before the launched agent starts', () => { h.tracker.dispose() }) }) + +describe('re-deriving an exit candidate the first read could not answer', () => { + const unanswered = (h: ReturnType) => ({ + controller: h.controller, + judgement: judgeForegroundAgent({ kind: 'unavailable' }) + }) + + it('leaves Chat when a read fails once and the re-read shows the shell', async () => { + vi.useFakeTimers() + const h = setup() + h.read.mockResolvedValueOnce(unanswered(h)) + h.read.mockResolvedValue(readResult(h.controller, 'zsh')) + h.tracker.handleChunk('\x1b]0;workspace\x07') + await vi.advanceTimersByTimeAsync(0) + expect(h.facts).toEqual([]) + await vi.advanceTimersByTimeAsync(1200) + expect(h.facts).toEqual([{ kind: 'agent-exited', evidence: 'foreground-shell' }]) + expect(h.read).toHaveBeenCalledTimes(2) + h.tracker.dispose() + }) + + it('stops after the bounded ladder, then re-reads once when the host is reached again', async () => { + vi.useFakeTimers() + const h = setup() + h.read.mockResolvedValue(unanswered(h)) + h.tracker.handleChunk('\x1b]0;workspace\x07') + await vi.advanceTimersByTimeAsync(60_000) + expect(h.read).toHaveBeenCalledTimes(3) + expect(h.facts).toEqual([]) + h.read.mockResolvedValue(readResult(h.controller, 'zsh')) + h.runtime.markPtyLivenessLive('pty-1') + await vi.advanceTimersByTimeAsync(0) + expect(h.facts).toEqual([{ kind: 'agent-exited', evidence: 'foreground-shell' }]) + h.runtime.markPtyLivenessLive('pty-1') + await vi.advanceTimersByTimeAsync(60_000) + expect(h.read).toHaveBeenCalledTimes(4) + h.tracker.dispose() + }) + + it('re-reads only once on contact, even when that read fails too', async () => { + vi.useFakeTimers() + const h = setup() + h.read.mockResolvedValue(unanswered(h)) + h.tracker.handleChunk('\x1b]0;workspace\x07') + await vi.advanceTimersByTimeAsync(60_000) + h.runtime.markPtyLivenessLive('pty-1') + await vi.advanceTimersByTimeAsync(60_000) + h.runtime.markPtyLivenessLive('pty-1') + await vi.advanceTimersByTimeAsync(60_000) + expect(h.read).toHaveBeenCalledTimes(4) + expect(h.facts).toEqual([]) + h.tracker.dispose() + }) + + it('ends the ladder at a live answer, and drops it when the PTY is disposed', async () => { + vi.useFakeTimers() + const live = setup() + live.read.mockResolvedValueOnce(unanswered(live)) + live.read.mockResolvedValue(readResult(live.controller, 'claude')) + live.tracker.handleChunk('\x1b]0;workspace\x07') + await vi.advanceTimersByTimeAsync(60_000) + expect(live.read).toHaveBeenCalledTimes(2) + live.runtime.markPtyLivenessLive('pty-1') + await vi.advanceTimersByTimeAsync(0) + expect(live.read).toHaveBeenCalledTimes(2) + live.tracker.dispose() + + const disposed = setup() + disposed.read.mockResolvedValue(unanswered(disposed)) + disposed.tracker.handleChunk('\x1b]0;workspace\x07') + await vi.advanceTimersByTimeAsync(0) + disposed.runtime.dispose() + await vi.advanceTimersByTimeAsync(60_000) + expect(disposed.read).toHaveBeenCalledOnce() + disposed.tracker.dispose() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/agent-exit-observation.test.ts b/src/renderer/src/components/terminal-pane/agent-exit-observation.test.ts index e8c57edad892..f70c6c64e887 100644 --- a/src/renderer/src/components/terminal-pane/agent-exit-observation.test.ts +++ b/src/renderer/src/components/terminal-pane/agent-exit-observation.test.ts @@ -159,6 +159,59 @@ describe('agent exit observation', () => { tracker.dispose() }) + it.each([ + ['claude inside tmux', 'tmux', false], + ['claude over ssh', 'ssh', false], + ['another program, where the shell marks no commands', 'vim', false], + ['another program, where the shell marks commands', 'vim', true] + ])('on its own neutral title with %s in front', async (_label, process, marks) => { + vi.useFakeTimers() + const shell = vi.fn() + const read = vi.fn().mockResolvedValue(process) + const tracker = createPaneForegroundAgentTracker({ + getPtyId: () => 'pty-1', + isTrackablePtyId: () => true, + readForegroundProcess: read, + confirmForegroundProcess: read, + publish: vi.fn(), + onConfirmedShellForeground: shell + }) + if (marks) { + tracker.onCommandFinished() + await vi.advanceTimersByTimeAsync(8000) + } + tracker.onAgentExitCandidate() + await vi.advanceTimersByTimeAsync(8000) + if (marks) { + // A shell that marks commands ends the agent at its own 133;D instead. + expect(shell).not.toHaveBeenCalled() + } else { + expect(shell).toHaveBeenCalledExactlyOnceWith('visible-pty', 'marked') + expect(read).toHaveBeenCalledOnce() + } + tracker.dispose() + }) + + it('leaves the marks judgement to main when main parses the bytes', async () => { + vi.useFakeTimers() + const shell = vi.fn() + const read = vi.fn().mockResolvedValue('vim') + const tracker = createPaneForegroundAgentTracker({ + getPtyId: () => 'pty-1', + isTrackablePtyId: () => true, + readForegroundProcess: read, + confirmForegroundProcess: read, + publish: vi.fn(), + seesShellCommandMarks: () => false, + onConfirmedShellForeground: shell + }) + // Main saw the launch's 133;C, which never reaches this pane, so vim in front is not blindness. + tracker.onAgentExitCandidate() + await vi.advanceTimersByTimeAsync(8000) + expect(shell).not.toHaveBeenCalled() + tracker.dispose() + }) + it('calls a shell after an observed agent title an exit', async () => { vi.useFakeTimers() const shell = vi.fn() diff --git a/src/renderer/src/components/terminal-pane/pane-agent-exit-marks.ts b/src/renderer/src/components/terminal-pane/pane-agent-exit-marks.ts index 62e4b78cf025..ac94a6b635e6 100644 --- a/src/renderer/src/components/terminal-pane/pane-agent-exit-marks.ts +++ b/src/renderer/src/components/terminal-pane/pane-agent-exit-marks.ts @@ -6,11 +6,12 @@ export type ConfirmedShellAgentExit = 'none' | 'marked' | 'read-confirmed' * What lets a confirmed shell count as an agent exit. Each mark keeps the PTY key (id plus host * incarnation) it was seen in, because the pane's tracker outlives a PTY replacement. */ -export function createPaneAgentExitMarks(): { +export function createPaneAgentExitMarks(seesShellCommandMarks?: () => boolean): { agentSeenLive: (ptyKey: string) => void agentTitleObserved: (ptyKey: string) => void commandStarted: (shellMarkedPtyKey: string | null) => void - commandFinished: () => void + commandFinished: (ptyKey: string | null) => void + marksCommands: (ptyKey: string) => boolean classifyConfirmedShell: ( ptyKey: string, reason: 'visible-pty' | 'command-finished' @@ -21,6 +22,8 @@ export function createPaneAgentExitMarks(): { // Why: a user shell integration can print 133;D at the first prompt, before the launch runs. let runningCommand: string | null = null let closedCommand: string | null = null + // The PTY key whose shell has printed an OSC 133 mark; see isAgentExitBlind. + let marked: string | null = null return { agentSeenLive(ptyKey) { seenLive = ptyKey @@ -33,10 +36,16 @@ export function createPaneAgentExitMarks(): { // Why: a new command means an earlier sighting describes the previous one. seenLive = null runningCommand = shellMarkedPtyKey ?? runningCommand + marked = shellMarkedPtyKey ?? marked }, - commandFinished() { + commandFinished(ptyKey) { closedCommand = runningCommand runningCommand = null + marked = ptyKey ?? marked + }, + marksCommands(ptyKey) { + // Why: a pane that cannot see its shell's marks never calls itself blind for lacking them. + return seesShellCommandMarks?.() === false || marked === ptyKey }, classifyConfirmedShell(ptyKey, reason) { const exit = diff --git a/src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.ts b/src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.ts index fd04a32bb780..b8549684691c 100644 --- a/src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.ts +++ b/src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.ts @@ -7,6 +7,10 @@ import type { PaneForegroundAgentEntry } from '@/store/slices/pane-foreground-ag import type { RuntimeTerminalProcessInspection } from '@/runtime/runtime-terminal-inspection' import { createPaneForegroundProcessReader } from './pane-foreground-process-reader' import { createPaneAgentExitMarks, type ConfirmedShellAgentExit } from './pane-agent-exit-marks' +import { + FOREGROUND_CONFIRM_RETRY_DELAYS_MS, + isAgentExitBlind +} from '../../../../shared/foreground-agent-verdict' export type { ConfirmedShellAgentExit } from './pane-agent-exit-marks' @@ -14,7 +18,6 @@ export type { ConfirmedShellAgentExit } from './pane-agent-exit-marks' // 3s PowerShell and WMIC enrichment scans. const COMMAND_SETTLE_MS = 350 const VISIBLE_PTY_SETTLE_MS = 350 -const WRAPPER_RESOLVE_RETRY_DELAYS_MS = [1200, 6000] as const type ForegroundReadReason = 'command' | 'visible-pty' | 'command-finished' type ShellConfirmReason = Exclude @@ -37,6 +40,8 @@ type PaneForegroundAgentTrackerDeps = { getExpectedIncarnationId?: () => string | null publish: (entry: PaneForegroundAgentEntry) => void revokeRouting?: () => void + /** False when main parses the bytes: the pane never sees its shell's 133;C, so main judges marks. */ + seesShellCommandMarks?: () => boolean /** True when the pane is otherwise known to run an agent (launchAgent, live * hook status). Lets a restored agent pane confirm — rather than trust — a * 133;D before any command-start read has recorded its own evidence. */ @@ -87,7 +92,7 @@ export function createPaneForegroundAgentTracker(deps: PaneForegroundAgentTracke let hasAgentExpectation = false // True while the pending visible-pty read answers the agent's own idle-to-neutral title. let agentTitleExitCandidate = false - const exitMarks = createPaneAgentExitMarks() + const exitMarks = createPaneAgentExitMarks(deps.seesShellCommandMarks) const ptyKey = (id: string): string => `${id}\n${deps.getExpectedIncarnationId?.() ?? ''}` const readProcess = createPaneForegroundProcessReader(deps) @@ -193,11 +198,15 @@ export function createPaneForegroundAgentTracker(deps: PaneForegroundAgentTracke // so a still-live generation means the command is running and the shell is // a nested one (sh/bash without integration); marking shell-foreground // would suppress live title identity. Only 133;D proves the prompt. - const retryDelay = WRAPPER_RESOLVE_RETRY_DELAYS_MS[retryIndex] + const retryDelay = FOREGROUND_CONFIRM_RETRY_DELAYS_MS[retryIndex] const hasConfirmationExpectation = hasForegroundAgentEvidence || hasKnownAgentEvidence || hasAgentExpectation + // Why: where no read can show this pane's shell, the agent's own title is its exit (base). + const titleIsExitSignal = + agentTitleExitCandidate && isAgentExitBlind(judgement, exitMarks.marksCommands(ptyKey(ptyId))) // Why: a re-read cannot change a host's structural answer or its fenced shell fact. - const settledByHost = !judgement.canCertifyExit || (remote && verdict === 'exited') + const settledByHost = + !judgement.canCertifyExit || (remote && verdict === 'exited') || titleIsExitSignal const shouldRetryExpectedIdentity = hasConfirmationExpectation && !settledByHost && @@ -224,13 +233,9 @@ export function createPaneForegroundAgentTracker(deps: PaneForegroundAgentTracke return } if (reason === 'visible-pty') { - // Why: a remote host that can never show a shell leaves the agent's own title as its exit - // signal (base behaviour; main applies the same rule to SSH facts). - const titleIsOnlyExitSignal = - agentTitleExitCandidate && remote && verdict === 'unverifiable' && !judgement.canCertifyExit if ( (hasForegroundAgentEvidence || hasKnownAgentEvidence) && - (verdict === 'exited' || titleIsOnlyExitSignal) + (verdict === 'exited' || titleIsExitSignal) ) { confirmShellForeground(reason, ptyId) deps.onVisibleForegroundSettled?.('shell') @@ -255,10 +260,7 @@ export function createPaneForegroundAgentTracker(deps: PaneForegroundAgentTracke const confirmShellForeground = (reason: ShellConfirmReason, ptyId: string): void => { // Why: an exit needs an agent seen in this pane, or the shell's own 133;D ending its command. - const agentExit: ConfirmedShellAgentExit = exitMarks.classifyConfirmedShell( - ptyKey(ptyId), - reason - ) + const agentExit = exitMarks.classifyConfirmedShell(ptyKey(ptyId), reason) // Why: reset the evidence so the pane's ordinary shell commands go back to // the no-RPC finished path. hasForegroundAgentEvidence = false @@ -346,7 +348,8 @@ export function createPaneForegroundAgentTracker(deps: PaneForegroundAgentTracke scheduleRead(COMMAND_SETTLE_MS, 0, 'command') }, onCommandFinished() { - exitMarks.commandFinished() + const ptyId = trackablePtyId() + exitMarks.commandFinished(ptyId ? ptyKey(ptyId) : null) if (deps.hasKnownAgentIdentity?.() === true) { hasKnownAgentEvidence = true } @@ -362,11 +365,10 @@ export function createPaneForegroundAgentTracker(deps: PaneForegroundAgentTracke // an idle reattached agent's icon (the "codex reattached at rest" bug). const hadReadBeforeCommandFinish = hasPendingRead() cancelPendingRead() - if (!trackablePtyId()) { + if (!ptyId) { releaseRetainedCapability(hadReadBeforeCommandFinish) return false } - const ptyId = trackablePtyId()! // Why: trust the 133;D and mark shell without an RPC only when nothing hints // at an agent — no prior agent evidence, no launch/hook identity, and no // identity read racing this finish. 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 4d91e42cdd9a..bcb19041390c 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 @@ -181,6 +181,7 @@ export function installPaneAgentIdentity(session: ConnectPanePtySession): void { } }, hasKnownAgentIdentity: session.paneHasKnownAgentIdentity, + seesShellCommandMarks: () => !session.mainSideEffectAuthority, onConfirmedShellForeground: (reason, agentExit) => { // Why: main publishes marked exits from its own facts; our own read-confirmed exit also // covers main's reads failing at that exit. A second exit signal is a no-op downstream. diff --git a/src/shared/foreground-agent-verdict.test.ts b/src/shared/foreground-agent-verdict.test.ts index 1d107a21306d..459da17e79c4 100644 --- a/src/shared/foreground-agent-verdict.test.ts +++ b/src/shared/foreground-agent-verdict.test.ts @@ -4,6 +4,7 @@ import { type RemoteForegroundEvidence } from './foreground-process-evidence' import { + isAgentExitBlind, judgeForegroundAgent, observeHostInspection, type ForegroundAgentObservation @@ -100,3 +101,44 @@ describe('observeHostInspection', () => { ).toEqual({ kind: 'unavailable' }) }) }) + +describe('the shared blindness rule', () => { + const processName = (name: string | null) => + judgeForegroundAgent({ kind: 'process-name', processName: name }) + const host = (evidence: RemoteForegroundEvidence | null) => + judgeForegroundAgent({ kind: 'host-evidence', evidence }) + it.each([ + [ + 'a host that can never read the foreground', + host(unverifiable('windows_ssh_foreground_unavailable')), + true, + true + ], + [ + 'an old host without the shell field', + judgeForegroundAgent({ kind: 'host-without-evidence' }), + true, + true + ], + ['tmux in front', processName('tmux'), true, true], + ['ssh.exe in front', processName('C:\\Windows\\System32\\OpenSSH\\ssh.exe'), true, true], + ['a remote multiplexer', host(unverifiable('multiplexer_boundary')), true, true], + ['the WSL bridge', processName('wsl.exe'), false, true], + ['another program', processName('vim'), false, true], + [ + 'another program on a remote host', + host(live({ processName: null, shellForeground: false })), + false, + true + ], + ['an agent wrapper that a re-read may resolve', processName('node'), false, false], + ['no answer', judgeForegroundAgent({ kind: 'unavailable' }), false, false], + ['an inadmissible host record', host(null), false, false], + ['a transient host reason', host(unverifiable('process_table_unreadable')), false, false], + ['a live agent', processName('claude'), false, false], + ['a shell', processName('zsh'), false, false] + ])('%s: blind with marks %s, without marks %s', (_label, judgement, withMarks, withoutMarks) => { + expect(isAgentExitBlind(judgement, true)).toBe(withMarks) + expect(isAgentExitBlind(judgement, false)).toBe(withoutMarks) + }) +}) diff --git a/src/shared/foreground-agent-verdict.ts b/src/shared/foreground-agent-verdict.ts index d2e1367e7c6a..befab19ae9a1 100644 --- a/src/shared/foreground-agent-verdict.ts +++ b/src/shared/foreground-agent-verdict.ts @@ -1,4 +1,4 @@ -import { recognizeAgentProcess } from './agent-process-recognition' +import { isAgentForegroundWrapperProcess, recognizeAgentProcess } from './agent-process-recognition' import type { RemoteForegroundEvidence } from './foreground-process-evidence' import { isShellProcess } from './shell-process-detection' import { @@ -23,12 +23,79 @@ export type ForegroundAgentJudgement = { processName: string | null /** False when this host can never prove a shell, so only the shell's own 133;D can retire an agent. */ canCertifyExit: boolean + /** Why an answered read can never show this pane's shell; absent when a re-read might. */ + blindness?: ForegroundBlindness } -const WSL_BRIDGE_NAMES = new Set(['wsl', 'wsl.exe', 'wslhost', 'wslhost.exe']) +/** 'bridge' and 'other-program' blind only a pane whose shell marks no commands (OSC 133). */ +type ForegroundBlindness = 'host-cannot-read' | 'agent-host' | 'bridge' | 'other-program' -function unverifiable(processName: string | null, canCertifyExit = true): ForegroundAgentJudgement { - return { verdict: 'unverifiable', processName, canCertifyExit } +/** Re-read delays after a first read that could not decide, shared by main and the renderer. */ +export const FOREGROUND_CONFIRM_RETRY_DELAYS_MS = [1200, 6000] as const + +const WSL_BRIDGE_NAMES = new Set(['wsl', 'wslhost']) +// Why: these run the agent out of the pane's process sight, and their inner shell marks never reach it. +const AGENT_HOST_NAMES = new Set([ + 'tmux', + 'screen', + 'ssh', + 'mosh', + 'mosh-client', + 'et', + 'docker', + 'podman' +]) + +function unverifiable( + processName: string | null, + canCertifyExit = true, + blindness?: ForegroundBlindness +): ForegroundAgentJudgement { + return { + verdict: 'unverifiable', + processName, + canCertifyExit, + ...(blindness ? { blindness } : {}) + } +} + +function blindnessForProgram(processName: string): ForegroundBlindness | undefined { + const basename = (processName.trim().toLowerCase().split(/[\\/]/).pop() ?? '').replace( + /\.exe$/, + '' + ) + if (WSL_BRIDGE_NAMES.has(basename)) { + return 'bridge' + } + if (AGENT_HOST_NAMES.has(basename)) { + return 'agent-host' + } + // A wrapper (node, python) may still resolve to the agent on a re-read. + return isAgentForegroundWrapperProcess(processName) ? undefined : 'other-program' +} + +/** + * The one blindness rule main and the renderer share: on a blind pane the agent's own + * idle-to-neutral title retires it; on a sighted pane the confirming read decides. An unanswered + * read is never blindness, because loss of contact is not evidence of exit. + */ +export function isAgentExitBlind( + judgement: ForegroundAgentJudgement, + paneMarksCommands: boolean +): boolean { + if (judgement.verdict !== 'unverifiable') { + return false + } + switch (judgement.blindness) { + case 'host-cannot-read': + case 'agent-host': + return true + case 'bridge': + case 'other-program': + return !paneMarksCommands + case undefined: + return false + } } /** The single rule for foreground agent identity: only a positive shell fact is an exit. */ @@ -39,7 +106,7 @@ export function judgeForegroundAgent( case 'unavailable': return unverifiable(null) case 'host-without-evidence': - return unverifiable(null, false) + return unverifiable(null, false, 'host-cannot-read') case 'process-name': { const processName = observation.processName?.trim() ? observation.processName : null if (!processName) { @@ -52,8 +119,8 @@ export function judgeForegroundAgent( return { verdict: 'exited', processName, canCertifyExit: true } } // Why: the WSL bridge hides the distro's process tree, so no Windows read can name its shell. - const basename = processName.trim().toLowerCase().split(/[\\/]/).pop() ?? '' - return unverifiable(processName, !WSL_BRIDGE_NAMES.has(basename)) + const blindness = blindnessForProgram(processName) + return unverifiable(processName, blindness !== 'bridge', blindness) } case 'host-evidence': { const { evidence } = observation @@ -64,7 +131,14 @@ export function judgeForegroundAgent( return { verdict: 'exited', processName: null, canCertifyExit: true } } if (evidence.verdict === 'unverifiable') { - return unverifiable(null, evidence.reason !== 'windows_ssh_foreground_unavailable') + if (evidence.reason === 'windows_ssh_foreground_unavailable') { + return unverifiable(null, false, 'host-cannot-read') + } + return unverifiable( + null, + true, + evidence.reason === 'multiplexer_boundary' ? 'agent-host' : undefined + ) } if (recognizeAgentProcess(evidence.processName)) { return { verdict: 'live', processName: evidence.processName, canCertifyExit: true } @@ -73,7 +147,9 @@ export function judgeForegroundAgent( return { verdict: 'exited', processName: null, canCertifyExit: true } } // Absent on hosts that predate the field; false means some other program is in front. - return unverifiable(null, evidence.shellForeground !== undefined) + return evidence.shellForeground === undefined + ? unverifiable(null, false, 'host-cannot-read') + : unverifiable(null, true, 'other-program') } } } From fe04db0c2ac79c985c48ed35f285f3fa0744a222 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 01:42:51 -0700 Subject: [PATCH 23/31] fix(chat): keep a pane Chat-eligible while its agent input routing is revoked Every 133;C now revokes agent-specific input routing while the agent is still held, and the Chat eligibility index dropped revoked panes. An unbound Chat request then could not bind until the next read republished the agent, so the composer showed "No live terminal" with Claude alive. Revocation withholds input only; eligibility now ends only at a confirmed exit or PTY exit, and Shift+Enter and Ctrl+Enter still refuse agent encodings on a revoked pane. --- ...terminal-chat-ownership-lifecycle.test.tsx | 27 +++++++++++++++++++ .../terminal-tab-agent-type-index.test.ts | 24 +++++++++++++---- .../terminal-tab-agent-type-index.ts | 3 ++- 3 files changed, 48 insertions(+), 6 deletions(-) diff --git a/src/renderer/src/components/terminal-pane/terminal-chat-ownership-lifecycle.test.tsx b/src/renderer/src/components/terminal-pane/terminal-chat-ownership-lifecycle.test.tsx index c076cd25aa45..bcb0504176ff 100644 --- a/src/renderer/src/components/terminal-pane/terminal-chat-ownership-lifecycle.test.tsx +++ b/src/renderer/src/components/terminal-pane/terminal-chat-ownership-lifecycle.test.tsx @@ -9,6 +9,7 @@ import { resolveNativeChatLeafRoute } from '../native-chat/native-chat-leaf-rout import { detachTerminalLayoutLeaf } from './terminal-layout-leaf-detach' import { parseWorkspaceSession } from '../../../../shared/workspace-session-schema' import type { RemotePaneLayoutPusher } from './remote-pane-layout-push' +import { useAppStore } from '../../store' const LEFT = '11111111-1111-4111-8111-111111111111' const RIGHT = '22222222-2222-4222-8222-222222222222' @@ -251,6 +252,32 @@ describe('terminal chat ownership lifecycle', () => { } }) + it('binds an unbound chat request to the active agent after a command start revokes its routing', () => { + const titles = mocks.state.runtimePaneTitlesByTabId.tab + const foreground = useAppStore.getState().paneForegroundAgentByPaneKey + mocks.state.runtimePaneTitlesByTabId.tab = { 1: 'zsh', 2: 'zsh' } + try { + const fixture = makeFixture() + const hook = renderHook(() => useFixture(fixture, null)) + expect(hook.result.current.chatLeafId).toBeNull() + // A 133;C while Claude is held keeps its identity but withholds agent-specific input. + act(() => { + useAppStore.setState({ + paneForegroundAgentByPaneKey: { + [`tab:${LEFT}`]: { agent: 'claude', shellForeground: false, routingRevoked: true } + } + }) + }) + hook.rerender() + expect(hook.result.current.isChatEligibleForLeaf(LEFT)).toBe(true) + expect(hook.result.current.chatLeafId).toBe(LEFT) + expect(mocks.state.unifiedTabsByWorktree.wt[0].viewMode).toBe('chat') + } finally { + mocks.state.runtimePaneTitlesByTabId.tab = titles + useAppStore.setState({ paneForegroundAgentByPaneKey: foreground }) + } + }) + it('restores chat on the right with the left active and different numeric pane IDs', () => { const initialFixture = makeFixture() const original = renderHook(() => useFixture(initialFixture, RIGHT)) diff --git a/src/renderer/src/components/terminal-pane/terminal-tab-agent-type-index.test.ts b/src/renderer/src/components/terminal-pane/terminal-tab-agent-type-index.test.ts index d1714b19ed8e..5312d2944e95 100644 --- a/src/renderer/src/components/terminal-pane/terminal-tab-agent-type-index.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-tab-agent-type-index.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it, vi } from 'vitest' import type { AgentStatusEntry } from '../../../../shared/agent-status-types' import { createTerminalTabAgentTypeSelector } from './terminal-tab-agent-type-index' +import { hasCtrlEnterCsiUAuthorityForPane } from './terminal-ctrl-enter' +import { resolveWindowsShiftEnterEncodingForPane } from './terminal-windows-shift-enter' function entry(agentType: AgentStatusEntry['agentType'], state = 'working'): AgentStatusEntry { return { agentType, state, updatedAt: 0 } as AgentStatusEntry @@ -91,10 +93,22 @@ describe('createTerminalTabAgentTypeSelector', () => { 'tab-1:leaf-a': { ...foreground['tab-1:leaf-a'], shellForeground: true } }) ).toEqual({}) - expect( - select({}, 'tab-1', { - 'tab-1:leaf-a': { ...foreground['tab-1:leaf-a'], routingRevoked: true } - }) - ).toEqual({}) + }) + + it('keeps a pane whose input routing was revoked, while that input stays withheld', () => { + const select = createTerminalTabAgentTypeSelector() + const trusted = { agent: 'droid' as const, shellForeground: false, routingTrusted: true } + // What a 133;C leaves while the agent is still held: identity kept, byte authority revoked. + const revoked = { agent: 'droid' as const, shellForeground: false, routingRevoked: true } + const pane = (entry: typeof trusted | typeof revoked) => ({ + paneForegroundAgentByPaneKey: { 'tab-1:leaf-a': entry }, + agentLaunchConfigByPaneKey: {} + }) + + expect(select({}, 'tab-1', { 'tab-1:leaf-a': revoked })).toEqual({ 'leaf-a': 'droid' }) + expect(hasCtrlEnterCsiUAuthorityForPane(pane(trusted), 'tab-1:leaf-a')).toBe(true) + expect(hasCtrlEnterCsiUAuthorityForPane(pane(revoked), 'tab-1:leaf-a')).toBe(false) + expect(resolveWindowsShiftEnterEncodingForPane(pane(trusted), 'tab-1:leaf-a')).toBe('csi-u') + expect(resolveWindowsShiftEnterEncodingForPane(pane(revoked), 'tab-1:leaf-a')).toBe('alt-enter') }) }) diff --git a/src/renderer/src/components/terminal-pane/terminal-tab-agent-type-index.ts b/src/renderer/src/components/terminal-pane/terminal-tab-agent-type-index.ts index f14e96763e71..edffe1cb81af 100644 --- a/src/renderer/src/components/terminal-pane/terminal-tab-agent-type-index.ts +++ b/src/renderer/src/components/terminal-pane/terminal-tab-agent-type-index.ts @@ -63,7 +63,8 @@ export function createTerminalTabAgentTypeSelector( } } for (const [paneKey, entry] of Object.entries(foreground)) { - if (!entry.agent || entry.shellForeground || entry.routingRevoked) { + // Why: routingRevoked withholds agent-specific input only; it is not evidence the agent left. + if (!entry.agent || entry.shellForeground) { continue } const separator = paneKey.indexOf(':') From 49a71b305193abf5580020daf67458f89aabe3b5 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 01:57:03 -0700 Subject: [PATCH 24/31] fix(runtime): keep an exit re-read to the process whose title raised it The bounded re-read of an unanswered agent-exit candidate kept only the PTY id. Two ways it could reach a same-id replacement process and publish agent-exited for it while that replacement's agent was still booting: - a queued re-read fired after the PTY's incarnation changed (a respawn whose old exit arrives late never disposes the title tracker); - a confirming read still out when the PTY exited resolved against the kept, disconnected record and re-armed a waiting candidate after dispose, which the next contact then re-read into the respawned process. Each candidate now records the incarnation and lifecycle generation it was raised in, a re-read for any other is dropped before it reads, and every title-candidate read (not only hook recovery) is dropped when the lifecycle generation moved while it was out. --- .../runtime/agent-exit-candidate-recheck.ts | 29 +++++------- .../orca-runtime-agent-exit-confirmation.ts | 10 +---- ...ntime-serialize-agent-prompt-submission.ts | 28 +++++++----- .../terminal-agent-exit-evidence.test.ts | 44 ++++++++++++++++++- 4 files changed, 73 insertions(+), 38 deletions(-) diff --git a/src/main/runtime/agent-exit-candidate-recheck.ts b/src/main/runtime/agent-exit-candidate-recheck.ts index 23dbb62a0bad..d7de52b821c2 100644 --- a/src/main/runtime/agent-exit-candidate-recheck.ts +++ b/src/main/runtime/agent-exit-candidate-recheck.ts @@ -1,12 +1,15 @@ import { FOREGROUND_CONFIRM_RETRY_DELAYS_MS } from '../../shared/foreground-agent-verdict' -type Candidate = { +/** The process whose neutral title raised the candidate, so a same-id replacement never answers it. */ +export type AgentExitCandidateOwner = { incarnationId: string | null + lifecycleGeneration: number titleObservedAt: number | null - timer: ReturnType | null } -export type AgentExitRecheck = { attempt: number; titleObservedAt: number | null } +type Candidate = { owner: AgentExitCandidateOwner; timer: ReturnType | null } + +export type AgentExitRecheck = AgentExitCandidateOwner & { attempt: number } /** The ladder is spent: the candidate waits for the host to be reached again. */ export const SPENT_AGENT_EXIT_RECHECK = FOREGROUND_CONFIRM_RETRY_DELAYS_MS.length @@ -23,18 +26,13 @@ export class AgentExitCandidateRechecks { constructor(private readonly recheck: (ptyId: string, recheck: AgentExitRecheck) => void) {} /** False when the ladder is spent; the candidate then waits for one reconnect re-read. */ - scheduleNext( - ptyId: string, - incarnationId: string | null, - titleObservedAt: number | null, - attempt: number - ): boolean { + scheduleNext(ptyId: string, owner: AgentExitCandidateOwner, attempt: number): boolean { this.clear(ptyId) const delay = FOREGROUND_CONFIRM_RETRY_DELAYS_MS[attempt] if (attempt >= FINAL_AGENT_EXIT_RECHECK) { return false } - const candidate: Candidate = { incarnationId, titleObservedAt, timer: null } + const candidate: Candidate = { owner, timer: null } this.byPtyId.set(ptyId, candidate) if (delay === undefined) { return false @@ -43,7 +41,7 @@ export class AgentExitCandidateRechecks { candidate.timer = null if (this.byPtyId.get(ptyId) === candidate) { this.byPtyId.delete(ptyId) - this.recheck(ptyId, { attempt: attempt + 1, titleObservedAt }) + this.recheck(ptyId, { ...owner, attempt: attempt + 1 }) } }, delay) candidate.timer.unref?.() @@ -51,18 +49,13 @@ export class AgentExitCandidateRechecks { } /** The host answered for this PTY again: re-read a candidate the ladder left undecided, once. */ - recheckAfterContact(ptyId: string, incarnationId: string | null): void { + recheckAfterContact(ptyId: string): void { const candidate = this.byPtyId.get(ptyId) if (!candidate || candidate.timer !== null) { return } this.byPtyId.delete(ptyId) - if (candidate.incarnationId === incarnationId) { - this.recheck(ptyId, { - attempt: FINAL_AGENT_EXIT_RECHECK, - titleObservedAt: candidate.titleObservedAt - }) - } + this.recheck(ptyId, { ...candidate.owner, attempt: FINAL_AGENT_EXIT_RECHECK }) } clear(ptyId: string): void { diff --git a/src/main/runtime/orca-runtime-agent-exit-confirmation.ts b/src/main/runtime/orca-runtime-agent-exit-confirmation.ts index 996dea83966a..682c28727848 100644 --- a/src/main/runtime/orca-runtime-agent-exit-confirmation.ts +++ b/src/main/runtime/orca-runtime-agent-exit-confirmation.ts @@ -72,10 +72,7 @@ export class OrcaRuntimeWithAgentExitConfirmation extends OrcaRuntimeWithSeriali markPtyLivenessLive(ptyId: string, observedNoLaterThan?: number): void { super.markPtyLivenessLive(ptyId, observedNoLaterThan) - this.agentExitRechecks.recheckAfterContact( - ptyId, - this.ptysById.get(ptyId)?.incarnationId ?? null - ) + this.agentExitRechecks.recheckAfterContact(ptyId) } registerPty( @@ -83,10 +80,7 @@ export class OrcaRuntimeWithAgentExitConfirmation extends OrcaRuntimeWithSeriali ): void { super.registerPty(...args) // Why: a reattach is renewed contact with the host that could not answer. - this.agentExitRechecks.recheckAfterContact( - args[0], - this.ptysById.get(args[0])?.incarnationId ?? null - ) + this.agentExitRechecks.recheckAfterContact(args[0]) } protected disposePtyTitleTracker(ptyId: string): void { 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 b0b8a9385fd5..39a96157d7d0 100644 --- a/src/main/runtime/orca-runtime-serialize-agent-prompt-submission.ts +++ b/src/main/runtime/orca-runtime-serialize-agent-prompt-submission.ts @@ -87,8 +87,21 @@ export class OrcaRuntimeWithSerializeAgentPromptSubmission extends OrcaRuntimeWi return } const incarnationId = pty?.incarnationId - const generation = recoverCompletedHook ? this.getPtyLifecycleGeneration(ptyId) : null + const generation = this.getPtyLifecycleGeneration(ptyId) + // Why: a candidate belongs to the process whose title raised it, never a same-id replacement. + if ( + recheck && + (recheck.incarnationId !== (incarnationId ?? null) || + recheck.lifecycleGeneration !== generation) + ) { + return + } const titleObservedAt = recheck ? recheck.titleObservedAt : (pty?.lastOscTitleAt ?? null) + const candidateOwner = { + incarnationId: incarnationId ?? null, + lifecycleGeneration: generation, + titleObservedAt + } const foregroundRead = this.readPtyForegroundProcessFromController( ptyId, pty?.lastOscTitleAt ?? 0 @@ -98,8 +111,7 @@ export class OrcaRuntimeWithSerializeAgentPromptSubmission extends OrcaRuntimeWi if (pty && !recoverCompletedHook) { this.agentExitRechecks.scheduleNext( ptyId, - pty.incarnationId, - titleObservedAt, + candidateOwner, Math.max(recheck?.attempt ?? 0, SPENT_AGENT_EXIT_RECHECK) ) } @@ -114,7 +126,8 @@ export class OrcaRuntimeWithSerializeAgentPromptSubmission extends OrcaRuntimeWi if ( current !== pty || current.incarnationId !== incarnationId || - (recoverCompletedHook && this.getPtyLifecycleGeneration(ptyId) !== generation) + // Why: an exit or provider reset ended the process this read was asked about. + this.getPtyLifecycleGeneration(ptyId) !== generation ) { return } @@ -190,12 +203,7 @@ export class OrcaRuntimeWithSerializeAgentPromptSubmission extends OrcaRuntimeWi if ( decides && result.judgement.blindness === undefined && - this.agentExitRechecks.scheduleNext( - ptyId, - incarnationId, - titleObservedAt, - recheck?.attempt ?? 0 - ) + this.agentExitRechecks.scheduleNext(ptyId, candidateOwner, recheck?.attempt ?? 0) ) { return } diff --git a/src/main/runtime/terminal-agent-exit-evidence.test.ts b/src/main/runtime/terminal-agent-exit-evidence.test.ts index c931fd4962ff..1dcf2794fed8 100644 --- a/src/main/runtime/terminal-agent-exit-evidence.test.ts +++ b/src/main/runtime/terminal-agent-exit-evidence.test.ts @@ -71,6 +71,7 @@ function setup() { { initialTitle: 'Claude ready' } ) const read = vi.fn().mockResolvedValue(readResult(controller, null)) + const lifecycle = { generation: 1 } Object.assign(runtime, { ptysById: new Map([['pty-1', pty]]), handleByPtyId: new Map(), @@ -81,9 +82,10 @@ function setup() { getLeavesForPty: () => [], resolvePtyTuiIdleWaiters: () => {}, titleObservationSequence: 1, - ptyForegroundAgent: { markExited } + ptyForegroundAgent: { markExited }, + getPtyLifecycleGeneration: () => lifecycle.generation }) - return { runtime, facts, controller, pty, tracker, read, markExited } + return { runtime, facts, controller, pty, tracker, read, markExited, lifecycle } } describe('host-confirmed agent exit', () => { @@ -392,6 +394,44 @@ describe('re-deriving an exit candidate the first read could not answer', () => h.tracker.dispose() }) + it('never re-reads an old candidate into a same-id replacement incarnation', async () => { + vi.useFakeTimers() + const h = setup() + h.read.mockResolvedValueOnce(unanswered(h)) + h.tracker.handleChunk('\x1b]0;workspace\x07') + await vi.advanceTimersByTimeAsync(0) + // The respawned agent's shell is still booting when the ladder would fire. + h.pty.incarnationId = 'inc-2' + h.read.mockResolvedValue(readResult(h.controller, 'zsh')) + await vi.advanceTimersByTimeAsync(60_000) + h.runtime.markPtyLivenessLive('pty-1') + await vi.advanceTimersByTimeAsync(0) + expect(h.facts).toEqual([]) + expect(h.read).toHaveBeenCalledOnce() + h.tracker.dispose() + }) + + it('drops a candidate whose read was still out when its process exited', async () => { + vi.useFakeTimers() + const h = setup() + let answer: (result: ReturnType) => void = () => {} + h.read.mockReturnValueOnce(new Promise((resolve) => (answer = resolve))) + h.tracker.handleChunk('\x1b]0;workspace\x07') + // The PTY exits and a same-id shell respawns before the host answers. + h.lifecycle.generation += 1 + h.pty.connected = false + h.runtime.dispose() + answer(unanswered(h)) + await vi.advanceTimersByTimeAsync(0) + h.pty.connected = true + h.read.mockResolvedValue(readResult(h.controller, 'zsh')) + h.runtime.markPtyLivenessLive('pty-1') + await vi.advanceTimersByTimeAsync(60_000) + expect(h.facts).toEqual([]) + expect(h.read).toHaveBeenCalledOnce() + h.tracker.dispose() + }) + it('ends the ladder at a live answer, and drops it when the PTY is disposed', async () => { vi.useFakeTimers() const live = setup() From 6a5c0d7121b83480759ad2d306306f904ea442d6 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 02:16:19 -0700 Subject: [PATCH 25/31] fix(terminal): count zellij as a program that hosts the agent zellij runs the agent in its own panes, out of the outer pane's process sight, exactly like tmux and screen, which the blindness rule already lists. Without it a Claude inside zellij on a pane that marks commands kept Chat and its icon until zellij itself exited. --- src/shared/foreground-agent-verdict.test.ts | 1 + src/shared/foreground-agent-verdict.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/src/shared/foreground-agent-verdict.test.ts b/src/shared/foreground-agent-verdict.test.ts index 459da17e79c4..297cacfdd57f 100644 --- a/src/shared/foreground-agent-verdict.test.ts +++ b/src/shared/foreground-agent-verdict.test.ts @@ -121,6 +121,7 @@ describe('the shared blindness rule', () => { true ], ['tmux in front', processName('tmux'), true, true], + ['zellij in front', processName('zellij'), true, true], ['ssh.exe in front', processName('C:\\Windows\\System32\\OpenSSH\\ssh.exe'), true, true], ['a remote multiplexer', host(unverifiable('multiplexer_boundary')), true, true], ['the WSL bridge', processName('wsl.exe'), false, true], diff --git a/src/shared/foreground-agent-verdict.ts b/src/shared/foreground-agent-verdict.ts index befab19ae9a1..08885a23307c 100644 --- a/src/shared/foreground-agent-verdict.ts +++ b/src/shared/foreground-agent-verdict.ts @@ -38,6 +38,7 @@ const WSL_BRIDGE_NAMES = new Set(['wsl', 'wslhost']) const AGENT_HOST_NAMES = new Set([ 'tmux', 'screen', + 'zellij', 'ssh', 'mosh', 'mosh-client', From 898813a9ed7ac4b3f4d30cf65b239b537e078e38 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 02:22:04 -0700 Subject: [PATCH 26/31] test(terminal): read private PTY records through typed access in exit tests The anti-slop lint rejects Reflect.get and broad object parameters; the repo's existing tests use typed bracket access for the same records. --- .../terminal-side-effect-facts.spec.ts | 7 +++---- .../runtime/terminal-agent-exit-evidence.test.ts | 13 ++++++++++--- src/relay/pty-handler-resize-stale-pty.test.ts | 12 ++++-------- 3 files changed, 17 insertions(+), 15 deletions(-) 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 09082d524de1..5a4074a387e0 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 @@ -531,13 +531,12 @@ describe('terminal side-effect fact channel', () => { getForegroundProcess: vi.fn().mockResolvedValue('zsh'), confirmForegroundProcess }) - const ptys: unknown = Reflect.get(runtime, 'ptysById') - const pty: unknown = ptys instanceof Map ? ptys.get('pty-1') : undefined - if (!pty || typeof pty !== 'object') { + const pty = runtime['ptysById'].get('pty-1') + if (!pty) { throw new Error('expected the synced PTY record') } // Stands in for a launch Orca started; the record's launch identity is not public. - Reflect.set(pty, 'launchAgent', 'claude') + pty.launchAgent = 'claude' // The first case is a user shell integration's first-prompt D, before Orca's startup command. runtime.onPtyData('pty-1', bytes, 100) // Why a macrotask: every mocked read resolves in microtasks, so an owed exit has landed by then. diff --git a/src/main/runtime/terminal-agent-exit-evidence.test.ts b/src/main/runtime/terminal-agent-exit-evidence.test.ts index 1dcf2794fed8..e38a6dd3f341 100644 --- a/src/main/runtime/terminal-agent-exit-evidence.test.ts +++ b/src/main/runtime/terminal-agent-exit-evidence.test.ts @@ -4,8 +4,15 @@ import type { TerminalSideEffectFact } from '../../shared/terminal-side-effect-f import { OrcaRuntimeWithAgentExitConfirmation } from './orca-runtime-agent-exit-confirmation' import { judgeForegroundAgent } from '../../shared/foreground-agent-verdict' import { noteShellCommandStarted } from './shell-command-agent-hold' +import type { RuntimePtyController } from './runtime-pty-controller-contract' -const readResult = (controller: object, processName: string | null) => ({ +const stubController = (): RuntimePtyController => ({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null +}) + +const readResult = (controller: RuntimePtyController, processName: string | null) => ({ controller, judgement: judgeForegroundAgent({ kind: 'process-name', processName }) }) @@ -42,7 +49,7 @@ class ExitHarness extends OrcaRuntimeWithAgentExitConfirmation { function setup() { const runtime = new ExitHarness(null) const facts: TerminalSideEffectFact[] = [] - const controller = {} + const controller = stubController() const pty: { connected: boolean incarnationId: string @@ -117,7 +124,7 @@ describe('host-confirmed agent exit', () => { : Promise.resolve( kind === 'unavailable' ? { controller: h.controller, judgement: judgeForegroundAgent({ kind }) } - : readResult({}, 'zsh') + : readResult(stubController(), 'zsh') ) ) h.tracker.handleChunk('\x1b]0;workspace\x07') diff --git a/src/relay/pty-handler-resize-stale-pty.test.ts b/src/relay/pty-handler-resize-stale-pty.test.ts index 3d9c61f4c10a..486cb2711133 100644 --- a/src/relay/pty-handler-resize-stale-pty.test.ts +++ b/src/relay/pty-handler-resize-stale-pty.test.ts @@ -169,16 +169,12 @@ describe('PtyHandler.resize against a stale PTY handle', () => { it('does not turn a torn-down record into a host exit tombstone', async () => { const exited = vi.fn() handler.setExitListener(exited) - const records: unknown = Reflect.get(handler, 'ptys') - if (!(records instanceof Map)) { - throw new Error('missing managed PTYs') - } - const managed: unknown = records.get(PTY_1) - if (!managed || typeof managed !== 'object') { + const managed = handler['ptys'].get(PTY_1) + if (!managed) { throw new Error('missing PTY') } - const incarnationId: unknown = Reflect.get(managed, 'incarnationId') - Reflect.set(managed, 'disposed', true) + const incarnationId = managed.incarnationId + managed.disposed = true await dispatcher.callRequest('pty.listProcesses', {}) expect(exited).not.toHaveBeenCalled() expect(dispatcher._notifications.filter((event) => event.method === 'pty.exit')).toEqual([]) From e0ede121788af16b97d5212858c09c050cd5231a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 02:45:40 -0700 Subject: [PATCH 27/31] fix(runtime): take the fresh foreground read for every exit decision A local PTY's cached foreground name can still say the agent after it exits (the Windows daemon tracker keeps it until an async scan; POSIX keeps it for its 1 s TTL). Exit confirmation reused that cached-first read, so a real exit read as live, restored the agent, and never re-read. cmd.exe panes and parked panes had nothing else to retire Chat, the icon or the status. Exit decisions (title candidate, shell 133;D, and the bounded re-read ladder) now take confirmForegroundProcess. Routine refreshes and the per-turn hook recovery, which never certifies an exit, keep the cached-first read. A pending cached read no longer answers an exit decision; a pending fresh read may still answer a refresh. SSH reads are host inspections and are unchanged. --- ...ntime-serialize-agent-prompt-submission.ts | 9 ++- .../terminal-side-effect-facts.spec.ts | 52 ++++++++++--- ...chestration-codex-completion-title.test.ts | 24 ++++++ ...time-pty-foreground-agent-evidence.test.ts | 73 +++++++++++++++++++ .../runtime/runtime-pty-foreground-agent.ts | 43 +++++++---- .../runtime/runtime-terminal-contracts.ts | 2 + 6 files changed, 177 insertions(+), 26 deletions(-) diff --git a/src/main/runtime/orca-runtime-serialize-agent-prompt-submission.ts b/src/main/runtime/orca-runtime-serialize-agent-prompt-submission.ts index 39a96157d7d0..f45ae7ba9286 100644 --- a/src/main/runtime/orca-runtime-serialize-agent-prompt-submission.ts +++ b/src/main/runtime/orca-runtime-serialize-agent-prompt-submission.ts @@ -68,9 +68,10 @@ export class OrcaRuntimeWithSerializeAgentPromptSubmission extends OrcaRuntimeWi protected readPtyForegroundProcessFromController( ptyId: string, - afterTitleObservation = 0 + afterTitleObservation = 0, + fresh = true ): Promise | null { - return this.ptyForegroundAgent.confirm(ptyId, afterTitleObservation) + return this.ptyForegroundAgent.confirm(ptyId, afterTitleObservation, fresh) } protected confirmPtyAgentExit( @@ -102,9 +103,11 @@ export class OrcaRuntimeWithSerializeAgentPromptSubmission extends OrcaRuntimeWi lifecycleGeneration: generation, titleObservedAt } + // Why cached: the per-turn hook recovery only restores `idle`; it never certifies an exit. const foregroundRead = this.readPtyForegroundProcessFromController( ptyId, - pty?.lastOscTitleAt ?? 0 + pty?.lastOscTitleAt ?? 0, + !recoverCompletedHook ) // Why: no host to ask now; the next contact with it re-reads once. const awaitHostContact = (): void => { 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 5a4074a387e0..53f5abdad079 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 @@ -414,18 +414,17 @@ describe('terminal side-effect fact channel', () => { runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;Codex ready\x07') batches.length = 0 - const getForegroundProcess = vi.fn().mockResolvedValueOnce('codex') - // Only a fresh read may certify the shell; the cached read above recognized the agent. - const confirmForegroundProcess = vi.fn().mockResolvedValue('zsh') + // Exit decisions take only the fresh read; the cached name may outlive the agent. + const confirmForegroundProcess = vi.fn().mockResolvedValueOnce('codex') runtime.setPtyController({ write: () => true, kill: () => true, - getForegroundProcess, + getForegroundProcess: vi.fn().mockResolvedValue('codex'), confirmForegroundProcess }) runtime.onPtyData('pty-1', '\x1b]0;bichir\x07', 101) - await vi.waitFor(() => expect(getForegroundProcess).toHaveBeenCalledOnce()) + await vi.waitFor(() => expect(confirmForegroundProcess).toHaveBeenCalledOnce()) await vi.waitFor(() => expect(batches.flatMap((batch) => batch.facts)).toEqual([ { kind: 'title', normalizedTitle: 'bichir', rawTitle: 'bichir' } @@ -442,7 +441,7 @@ describe('terminal side-effect fact channel', () => { ) await Promise.resolve() - getForegroundProcess.mockResolvedValueOnce('zsh') + confirmForegroundProcess.mockResolvedValueOnce('zsh') runtime.onPtyData('pty-1', '\x1b]0;other cwd\x07', 102) await vi.waitFor(() => @@ -451,10 +450,42 @@ describe('terminal side-effect fact channel', () => { evidence: 'foreground-shell' }) ) - expect(getForegroundProcess).toHaveBeenCalledTimes(2) - expect(confirmForegroundProcess).toHaveBeenCalledOnce() + expect(confirmForegroundProcess).toHaveBeenCalledTimes(2) }) + it.each(['cmd.exe', 'zsh'])( + 'publishes an exit on a fresh %s read while the cached name still says claude', + async (shell) => { + const { runtime, batches } = createSideEffectRuntime() + syncSinglePty(runtime) + const getForegroundProcess = vi.fn().mockResolvedValue('claude') + const confirmForegroundProcess = vi.fn().mockResolvedValue(shell) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess, + confirmForegroundProcess + }) + runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;✳ Claude Code\x07') + const pty = runtime['ptysById'].get('pty-1') + if (!pty) { + throw new Error('expected the synced PTY record') + } + pty.foregroundAgent = 'claude' + batches.length = 0 + + runtime.onPtyData('pty-1', `\x1b]0;${shell}\x07`, 100) + + await vi.waitFor(() => + expect(batches.flatMap((batch) => batch.facts)).toContainEqual({ + kind: 'agent-exited', + evidence: 'foreground-shell' + }) + ) + expect(pty.foregroundAgent).toBeNull() + } + ) + it('does not confirm an agent exit from a foreground read predating its title', async () => { const { runtime, batches } = createSideEffectRuntime() syncSinglePty(runtime) @@ -462,7 +493,7 @@ describe('terminal side-effect fact channel', () => { const staleRead = new Promise((resolve) => { resolveStaleRead = resolve }) - const getForegroundProcess = vi.fn().mockReturnValueOnce(staleRead).mockResolvedValueOnce('zsh') + const getForegroundProcess = vi.fn().mockReturnValueOnce(staleRead) const confirmForegroundProcess = vi.fn().mockResolvedValue('zsh') runtime.setPtyController({ write: () => true, @@ -474,10 +505,11 @@ describe('terminal side-effect fact channel', () => { runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;Codex ready\x07') runtime.onPtyData('pty-1', '\x1b]0;bichir\x07', 100) expect(getForegroundProcess).toHaveBeenCalledOnce() + expect(confirmForegroundProcess).not.toHaveBeenCalled() resolveStaleRead('codex') - await vi.waitFor(() => expect(getForegroundProcess).toHaveBeenCalledTimes(2)) + await vi.waitFor(() => expect(confirmForegroundProcess).toHaveBeenCalledOnce()) await vi.waitFor(() => expect(batches.flatMap((batch) => batch.facts)).toContainEqual({ kind: 'agent-exited', diff --git a/src/main/runtime/orchestration-codex-completion-title.test.ts b/src/main/runtime/orchestration-codex-completion-title.test.ts index 92f55d166ff4..1755140fb7fa 100644 --- a/src/main/runtime/orchestration-codex-completion-title.test.ts +++ b/src/main/runtime/orchestration-codex-completion-title.test.ts @@ -104,6 +104,30 @@ describe('Codex completion title mailbox delivery', () => { } ) + it('recovers a completed turn from the cached foreground read without a fresh scan', async () => { + vi.useFakeTimers() + const { db, runtime, write, run, getForegroundProcess, completeWithNativeTitles } = + completionFixture() + const confirmForegroundProcess = vi.fn(async () => 'zsh') + runtime.setPtyController({ + write, + writeWithSettlement: settledWriteStub(write), + kill: vi.fn(), + getForegroundProcess, + confirmForegroundProcess + }) + await runtime.listTerminals() + insertDirectRunMessage(db, run.id, 'Worker progress') + completeWithNativeTitles() + await vi.advanceTimersByTimeAsync(1500) + expect(write.mock.calls.map(([, data]) => data)).toEqual([ + expect.stringContaining('You have 1 orchestration message'), + '\r' + ]) + expect(confirmForegroundProcess).not.toHaveBeenCalled() + db.close() + }) + it.each([ { name: 'shell', process: 'zsh' }, { name: 'unverifiable foreground', process: null }, diff --git a/src/main/runtime/runtime-pty-foreground-agent-evidence.test.ts b/src/main/runtime/runtime-pty-foreground-agent-evidence.test.ts index 4d845231852e..8aaa16e11224 100644 --- a/src/main/runtime/runtime-pty-foreground-agent-evidence.test.ts +++ b/src/main/runtime/runtime-pty-foreground-agent-evidence.test.ts @@ -76,6 +76,79 @@ describe('foreground identity on unknown observations', () => { expect(h.pty.foregroundAgent).toBe('claude') expect(h.confirm).not.toHaveBeenCalled() }) + // The Windows daemon tracker keeps the last agent name until an async scan retires it. + it.each(['cmd.exe', 'zsh'])( + 'confirms an exit on a fresh %s read while the cached name still says claude', + async (shell) => { + const h = setup() + const getForegroundProcess = vi.fn(async () => 'claude') + h.confirm.mockResolvedValue(shell) + h.replace({ + write: () => true, + kill: () => true, + getForegroundProcess, + confirmForegroundProcess: h.confirm + }) + const result = await h.agent.confirm('pty-1') + expect(result?.judgement.verdict).toBe('exited') + expect(h.pty.foregroundAgent).toBeNull() + expect(getForegroundProcess).not.toHaveBeenCalled() + } + ) + it('does not let a pending cached refresh answer an exit confirmation', async () => { + const h = setup() + let answerCached: (value: string) => void = () => {} + const getForegroundProcess = vi.fn( + () => + new Promise((resolve) => { + answerCached = resolve + }) + ) + h.confirm.mockResolvedValue('cmd.exe') + h.replace({ + write: () => true, + kill: () => true, + getForegroundProcess, + confirmForegroundProcess: h.confirm + }) + const refreshed = h.agent.refresh('pty-1') + await vi.waitFor(() => expect(getForegroundProcess).toHaveBeenCalledOnce()) + const confirmed = h.agent.confirm('pty-1') + answerCached('claude') + await refreshed + expect((await confirmed)?.judgement.verdict).toBe('exited') + expect(h.pty.foregroundAgent).toBeNull() + expect(h.confirm).toHaveBeenCalledOnce() + }) + it('lets a refresh reuse a pending fresh exit read', async () => { + const h = setup() + const getForegroundProcess = vi.fn(async () => 'claude') + h.confirm.mockResolvedValue('cmd.exe') + h.replace({ + write: () => true, + kill: () => true, + getForegroundProcess, + confirmForegroundProcess: h.confirm + }) + const confirmed = h.agent.confirm('pty-1') + await h.agent.refresh('pty-1') + await confirmed + expect(h.confirm).toHaveBeenCalledOnce() + expect(getForegroundProcess).not.toHaveBeenCalled() + expect(h.pty.foregroundAgent).toBeNull() + }) + it('keeps the per-turn hook recovery on the cached read', async () => { + const h = setup() + h.replace({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => 'claude', + confirmForegroundProcess: h.confirm + }) + const result = await h.agent.confirm('pty-1', 0, false) + expect(result?.judgement.verdict).toBe('live') + expect(h.confirm).not.toHaveBeenCalled() + }) it('rejects a result for a replaced terminal incarnation', async () => { const h = setup() let answer: (value: string) => void = () => {} diff --git a/src/main/runtime/runtime-pty-foreground-agent.ts b/src/main/runtime/runtime-pty-foreground-agent.ts index 1f5e28b815f0..e62fdb5b69a8 100644 --- a/src/main/runtime/runtime-pty-foreground-agent.ts +++ b/src/main/runtime/runtime-pty-foreground-agent.ts @@ -38,19 +38,29 @@ export class RuntimePtyForegroundAgent { constructor(private readonly deps: Dependencies) {} - read(ptyId: string, afterTitle = 0): Promise | null { + private read( + ptyId: string, + afterTitle: number, + requireFresh: boolean + ): Promise | null { const controller = this.deps.getController() if (!controller) { return null } + // Every remote read is a host inspection, so only a local read can be cached. + const fresh = requireFresh || Boolean(this.deps.getPty(ptyId)?.connectionId) const pending = this.reads.get(ptyId) - if (pending?.controller === controller && pending.startedAfterTitleObservation >= afterTitle) { + if ( + pending?.controller === controller && + pending.startedAfterTitleObservation >= afterTitle && + (pending.fresh || !fresh) + ) { return pending.promise } if (pending?.controller === controller) { return pending.promise.then( () => - this.read(ptyId, afterTitle) ?? { + this.read(ptyId, afterTitle, requireFresh) ?? { controller, judgement: judgeForegroundAgent({ kind: 'unavailable' }) } @@ -62,11 +72,12 @@ export class RuntimePtyForegroundAgent { } let processRead: Promise try { - processRead = this.readProcess(controller, ptyId) + processRead = this.readProcess(controller, ptyId, fresh) } catch { const entry: PtyForegroundProcessReadEntry = { controller, startedAfterTitleObservation: afterTitle, + fresh, promise: Promise.resolve(unavailable) } entry.promise = entry.promise.finally(() => this.deleteRead(ptyId, entry)) @@ -83,7 +94,7 @@ export class RuntimePtyForegroundAgent { ) .catch(() => unavailable) .finally(() => this.deleteRead(ptyId, entry)) - entry = { controller, startedAfterTitleObservation: afterTitle, promise } + entry = { controller, startedAfterTitleObservation: afterTitle, fresh, promise } this.reads.set(ptyId, entry) return entry.promise } @@ -119,10 +130,13 @@ export class RuntimePtyForegroundAgent { return entry.promise } - /** Exit confirmation and `pty.foregroundAgent` come from this one current-incarnation read. */ - confirm(ptyId: string, afterTitle = 0): Promise | null { + /** + * Exit confirmation and `pty.foregroundAgent` come from this one current-incarnation read. + * `fresh`: an exit decision must not take a cached agent name that outlives the agent. + */ + confirm(ptyId: string, afterTitle = 0, fresh = true): Promise | null { const pty = this.deps.getPty(ptyId) - const read = this.read(ptyId, afterTitle) + const read = this.read(ptyId, afterTitle, fresh) if (!pty || !read) { return read } @@ -177,7 +191,7 @@ export class RuntimePtyForegroundAgent { return false } const incarnationId = pty.incarnationId - const result = await this.read(ptyId, afterTitle) + const result = await this.read(ptyId, afterTitle, false) return result && this.isCurrent(ptyId, pty, incarnationId, result) ? this.apply(ptyId, pty, result.judgement) : false @@ -218,13 +232,16 @@ export class RuntimePtyForegroundAgent { private async readProcess( controller: RuntimePtyController, - ptyId: string + ptyId: string, + fresh: boolean ): Promise { const pty = this.deps.getPty(ptyId) if (!pty?.connectionId) { - const cached = await controller.getForegroundProcess(ptyId) - if (recognizeAgentProcess(cached)) { - return judgeForegroundAgent({ kind: 'process-name', processName: cached }) + if (!fresh || !controller.confirmForegroundProcess) { + const cached = await controller.getForegroundProcess(ptyId) + if (recognizeAgentProcess(cached)) { + return judgeForegroundAgent({ kind: 'process-name', processName: cached }) + } } // Cached display names cannot certify that the agent returned to its shell. return judgeForegroundAgent( diff --git a/src/main/runtime/runtime-terminal-contracts.ts b/src/main/runtime/runtime-terminal-contracts.ts index cc99b2861fca..3ce96fadec51 100644 --- a/src/main/runtime/runtime-terminal-contracts.ts +++ b/src/main/runtime/runtime-terminal-contracts.ts @@ -118,6 +118,8 @@ export type PtyForegroundProcessRead = { export type PtyForegroundProcessReadEntry = { controller: RuntimePtyController startedAfterTitleObservation: number + /** Bypassed the cached foreground name, so it can answer an exit decision. */ + fresh: boolean promise: Promise } From 625dd90804d3f2940421e298bae19d4f329dc8cd Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 03:00:27 -0700 Subject: [PATCH 28/31] fix(runtime): let an exit check reuse a cached read that already read fresh Every title exit candidate is preceded by the same title's cached refresh. When that refresh's cached name was no agent it fell through to the fresh read, but the exit check still took a second fresh read behind it (a second forced process scan on Windows). Freshness is now recorded from the answer, and a waiting exit check re-judges the settled read before reading again. --- .../terminal-side-effect-facts.spec.ts | 39 ++++++++ ...time-pty-foreground-agent-evidence.test.ts | 25 ++++++ .../runtime/runtime-pty-foreground-agent.ts | 89 +++++++++++-------- .../runtime/runtime-terminal-contracts.ts | 2 +- 4 files changed, 118 insertions(+), 37 deletions(-) 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 53f5abdad079..1a8148512deb 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 @@ -486,6 +486,45 @@ describe('terminal side-effect fact channel', () => { } ) + it.each(['cmd.exe', 'zsh'])( + 'confirms an exit with one fresh read when the cached name already says %s', + async (shell) => { + const { runtime, batches } = createSideEffectRuntime() + syncSinglePty(runtime) + const getForegroundProcess = vi.fn().mockResolvedValue(shell) + const confirmForegroundProcess = vi.fn().mockResolvedValue(shell) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess, + confirmForegroundProcess + }) + runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;✳ Claude Code\x07') + const pty = runtime['ptysById'].get('pty-1') + if (!pty) { + throw new Error('expected the synced PTY record') + } + pty.foregroundAgent = 'claude' + await vi.waitFor(() => expect(getForegroundProcess).toHaveBeenCalled()) + await new Promise((resolve) => setTimeout(resolve, 0)) + getForegroundProcess.mockClear() + confirmForegroundProcess.mockClear() + batches.length = 0 + + runtime.onPtyData('pty-1', `\x1b]0;${shell}\x07`, 100) + + await vi.waitFor(() => + expect(batches.flatMap((batch) => batch.facts)).toContainEqual({ + kind: 'agent-exited', + evidence: 'foreground-shell' + }) + ) + // The title's own cached refresh already fell through to the fresh read. + expect(getForegroundProcess).toHaveBeenCalledOnce() + expect(confirmForegroundProcess).toHaveBeenCalledOnce() + } + ) + it('does not confirm an agent exit from a foreground read predating its title', async () => { const { runtime, batches } = createSideEffectRuntime() syncSinglePty(runtime) diff --git a/src/main/runtime/runtime-pty-foreground-agent-evidence.test.ts b/src/main/runtime/runtime-pty-foreground-agent-evidence.test.ts index 8aaa16e11224..8c37902cd1c8 100644 --- a/src/main/runtime/runtime-pty-foreground-agent-evidence.test.ts +++ b/src/main/runtime/runtime-pty-foreground-agent-evidence.test.ts @@ -120,6 +120,31 @@ describe('foreground identity on unknown observations', () => { expect(h.pty.foregroundAgent).toBeNull() expect(h.confirm).toHaveBeenCalledOnce() }) + it('lets an exit confirmation reuse a pending refresh that fell through to a fresh read', async () => { + const h = setup() + let answerCached: (value: string) => void = () => {} + const getForegroundProcess = vi.fn( + () => + new Promise((resolve) => { + answerCached = resolve + }) + ) + h.confirm.mockResolvedValue('cmd.exe') + h.replace({ + write: () => true, + kill: () => true, + getForegroundProcess, + confirmForegroundProcess: h.confirm + }) + const refreshed = h.agent.refresh('pty-1') + await vi.waitFor(() => expect(getForegroundProcess).toHaveBeenCalledOnce()) + const confirmed = h.agent.confirm('pty-1') + answerCached('cmd.exe') + await refreshed + expect((await confirmed)?.judgement.verdict).toBe('exited') + expect(h.pty.foregroundAgent).toBeNull() + expect(h.confirm).toHaveBeenCalledOnce() + }) it('lets a refresh reuse a pending fresh exit read', async () => { const h = setup() const getForegroundProcess = vi.fn(async () => 'claude') diff --git a/src/main/runtime/runtime-pty-foreground-agent.ts b/src/main/runtime/runtime-pty-foreground-agent.ts index e62fdb5b69a8..d04275b0c324 100644 --- a/src/main/runtime/runtime-pty-foreground-agent.ts +++ b/src/main/runtime/runtime-pty-foreground-agent.ts @@ -24,6 +24,8 @@ type ForegroundPty = Pick< | 'foregroundAgentIncarnationId' > +type ProcessRead = { judgement: ForegroundAgentJudgement; fresh: boolean } + type Dependencies = { getController(): RuntimePtyController | null getPty(ptyId: string): ForegroundPty | null @@ -50,27 +52,27 @@ export class RuntimePtyForegroundAgent { // Every remote read is a host inspection, so only a local read can be cached. const fresh = requireFresh || Boolean(this.deps.getPty(ptyId)?.connectionId) const pending = this.reads.get(ptyId) - if ( - pending?.controller === controller && - pending.startedAfterTitleObservation >= afterTitle && - (pending.fresh || !fresh) - ) { + const answers = (entry: PtyForegroundProcessReadEntry): boolean => + entry.startedAfterTitleObservation >= afterTitle && (entry.fresh || !fresh) + if (pending?.controller === controller && answers(pending)) { return pending.promise } if (pending?.controller === controller) { - return pending.promise.then( - () => - this.read(ptyId, afterTitle, requireFresh) ?? { - controller, - judgement: judgeForegroundAgent({ kind: 'unavailable' }) - } + // Why re-judge once settled: a cached read whose name was no agent fell through to a fresh one. + return pending.promise.then((settled) => + answers(pending) + ? settled + : (this.read(ptyId, afterTitle, requireFresh) ?? { + controller, + judgement: judgeForegroundAgent({ kind: 'unavailable' }) + }) ) } const unavailable: PtyForegroundProcessRead = { controller, judgement: judgeForegroundAgent({ kind: 'unavailable' }) } - let processRead: Promise + let processRead: Promise try { processRead = this.readProcess(controller, ptyId, fresh) } catch { @@ -87,11 +89,13 @@ export class RuntimePtyForegroundAgent { let entry: PtyForegroundProcessReadEntry const incarnationId = this.deps.getPty(ptyId)?.incarnationId const promise = processRead - .then((judgement) => - this.deps.getPty(ptyId)?.incarnationId === incarnationId - ? { controller, judgement } - : unavailable - ) + .then((read) => { + if (this.deps.getPty(ptyId)?.incarnationId !== incarnationId) { + return unavailable + } + entry.fresh = read.fresh + return { controller, judgement: read.judgement } + }) .catch(() => unavailable) .finally(() => this.deleteRead(ptyId, entry)) entry = { controller, startedAfterTitleObservation: afterTitle, fresh, promise } @@ -234,21 +238,31 @@ export class RuntimePtyForegroundAgent { controller: RuntimePtyController, ptyId: string, fresh: boolean - ): Promise { + ): Promise { const pty = this.deps.getPty(ptyId) if (!pty?.connectionId) { if (!fresh || !controller.confirmForegroundProcess) { const cached = await controller.getForegroundProcess(ptyId) if (recognizeAgentProcess(cached)) { - return judgeForegroundAgent({ kind: 'process-name', processName: cached }) + return { + judgement: judgeForegroundAgent({ kind: 'process-name', processName: cached }), + // With no fresh read to take, the cached name is the best this host answers. + fresh: !controller.confirmForegroundProcess + } } } // Cached display names cannot certify that the agent returned to its shell. - return judgeForegroundAgent( - controller.confirmForegroundProcess - ? { kind: 'process-name', processName: await controller.confirmForegroundProcess(ptyId) } - : { kind: 'unavailable' } - ) + return { + judgement: judgeForegroundAgent( + controller.confirmForegroundProcess + ? { + kind: 'process-name', + processName: await controller.confirmForegroundProcess(ptyId) + } + : { kind: 'unavailable' } + ), + fresh: true + } } const incarnationId = pty.incarnationId const started = performance.now() @@ -256,18 +270,21 @@ export class RuntimePtyForegroundAgent { ptyId, incarnationId ? { expectedIncarnationId: incarnationId } : {} ) - return judgeForegroundAgent( - observeHostInspection(inspection, (evidence) => - admitRemoteForegroundEvidence(evidence, { - expectedPtyId: parseAppSshPtyId(ptyId)?.relayPtyId ?? ptyId, - expectedIncarnationId: incarnationId, - requestStartedAtMonotonic: started, - receivedAtMonotonic: performance.now(), - lastAuthorityGeneration: null, - lastObservationEpoch: -1 - }) - ) - ) + return { + judgement: judgeForegroundAgent( + observeHostInspection(inspection, (evidence) => + admitRemoteForegroundEvidence(evidence, { + expectedPtyId: parseAppSshPtyId(ptyId)?.relayPtyId ?? ptyId, + expectedIncarnationId: incarnationId, + requestStartedAtMonotonic: started, + receivedAtMonotonic: performance.now(), + lastAuthorityGeneration: null, + lastObservationEpoch: -1 + }) + ) + ), + fresh: true + } } private deleteRead(ptyId: string, entry: PtyForegroundProcessReadEntry): void { diff --git a/src/main/runtime/runtime-terminal-contracts.ts b/src/main/runtime/runtime-terminal-contracts.ts index 3ce96fadec51..fc6009496bb0 100644 --- a/src/main/runtime/runtime-terminal-contracts.ts +++ b/src/main/runtime/runtime-terminal-contracts.ts @@ -118,7 +118,7 @@ export type PtyForegroundProcessRead = { export type PtyForegroundProcessReadEntry = { controller: RuntimePtyController startedAfterTitleObservation: number - /** Bypassed the cached foreground name, so it can answer an exit decision. */ + /** The answer does not rest on the cached foreground name, so it can answer an exit decision. */ fresh: boolean promise: Promise } From 3404e8150f04d27473e3982d23fc0c21afcb758d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 03:15:04 -0700 Subject: [PATCH 29/31] test(runtime): pin the 133;D exit to the fresh foreground read The shell's end-of-command mark is an exit decision, but no test covered it with a stale cached agent name, so passing it the cached read stayed green. --- .../terminal-side-effect-facts.spec.ts | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) 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 1a8148512deb..e8788e8120cb 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 @@ -589,6 +589,34 @@ describe('terminal side-effect fact channel', () => { ).toHaveLength(1) }) + it('confirms an exit at the shell 133;D on a fresh read while the cached name still says claude', async () => { + const { runtime, batches } = createSideEffectRuntime() + syncSinglePty(runtime) + const getForegroundProcess = vi.fn().mockResolvedValue('claude') + const confirmForegroundProcess = vi.fn().mockResolvedValue('zsh') + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess, + confirmForegroundProcess + }) + runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;✳ Claude Code\x07') + await new Promise((resolve) => setTimeout(resolve, 0)) + getForegroundProcess.mockClear() + batches.length = 0 + + runtime.onPtyData('pty-1', '\x1b]133;D;0\x07', 100) + + await vi.waitFor(() => + expect(batches.flatMap((batch) => batch.facts)).toContainEqual({ + kind: 'agent-exited', + evidence: 'foreground-shell' + }) + ) + expect(confirmForegroundProcess).toHaveBeenCalledOnce() + expect(getForegroundProcess).not.toHaveBeenCalled() + }) + it.each([ ['before its 133;C', '\x1b]133;D;0\x07\x1b]133;A\x07', 0], ['that closes its 133;C', '\x1b]133;C\x07\x1b]133;D;0\x07', 1] From b8f321d0af800868a617f46896ae2c7d9a4186c0 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 03:18:49 -0700 Subject: [PATCH 30/31] test(terminal): keep agent identity when every Windows fresh scan is unavailable The composed daemon/pane test asserted the old retire-on-unknown ladder; an unreadable process table is missing evidence, so the pane keeps its agent. --- tests/e2e/terminal-foreground-confirmation.unit.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/e2e/terminal-foreground-confirmation.unit.test.ts b/tests/e2e/terminal-foreground-confirmation.unit.test.ts index 04873836a341..5c149532bf7d 100644 --- a/tests/e2e/terminal-foreground-confirmation.unit.test.ts +++ b/tests/e2e/terminal-foreground-confirmation.unit.test.ts @@ -112,7 +112,7 @@ describe('daemon foreground confirmation composes with pane tracking', () => { expect(resolveForegroundMock).toHaveBeenCalledTimes(1) }) - it('fails closed without claiming shell when every fresh scan is unavailable', async () => { + it('keeps agent identity without claiming shell when every fresh scan is unavailable', async () => { spawnMock.mockReturnValue(mockWindowsPty()) resolveForegroundMock.mockResolvedValue({ available: false, @@ -124,7 +124,8 @@ describe('daemon foreground confirmation composes with pane tracking', () => { tracker.onCommandFinished() await vi.advanceTimersByTimeAsync(350 + 1_200 + 6_000) - expect(publish).toHaveBeenCalledExactlyOnceWith({ agent: null, shellForeground: false }) + // An unreadable process table is missing evidence, not an exit. + expect(publish).not.toHaveBeenCalled() expect(resolveForegroundMock).toHaveBeenCalledTimes(3) }) }) From 9820d77a95c4894ad9297a880fd1b866002afb89 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 11:01:20 -0700 Subject: [PATCH 31/31] fix(runtime): end an SSH agent on the reply's live shell name when host evidence is undecided A loaded relay misses the fenced evidence's ps budget or age gate, so every SSH exit read came back undecided and Chat never left after a real /exit. Fall back to the same inspect reply's live foreground name, only when the reply names this PTY incarnation and the name is a shell; anything else stays undecided. --- .../runtime/host-foreground-shell-name.ts | 40 +++++++++++++ ...terminal-side-effect-facts-part-03.spec.ts | 52 +++++++++++++++++ ...time-pty-foreground-agent-evidence.test.ts | 57 ++++++++++++++++++- .../runtime/runtime-pty-foreground-agent.ts | 27 +++++---- 4 files changed, 163 insertions(+), 13 deletions(-) create mode 100644 src/main/runtime/host-foreground-shell-name.ts diff --git a/src/main/runtime/host-foreground-shell-name.ts b/src/main/runtime/host-foreground-shell-name.ts new file mode 100644 index 000000000000..9f07b8859070 --- /dev/null +++ b/src/main/runtime/host-foreground-shell-name.ts @@ -0,0 +1,40 @@ +import type { ForegroundAgentJudgement } from '../../shared/foreground-agent-verdict' +import { isShellProcess } from '../../shared/shell-process-detection' +import { + isClientOnlyUnverifiableInspection, + type TerminalProcessInspection +} from '../../shared/terminal-process-inspection' + +/** + * A loaded host misses the fenced evidence's capture budget or age gate, which leaves an exit + * undecided forever. The same reply's live foreground name still proves a shell, but only when the + * reply names this PTY incarnation; any other name (e.g. native Claude's `2.1.258`) stays undecided. + */ +export function judgeWithForegroundShellName( + judgement: ForegroundAgentJudgement, + inspection: TerminalProcessInspection | string | null | undefined, + expectedPtyId: string, + incarnationId: string | null +): ForegroundAgentJudgement { + if ( + judgement.verdict !== 'unverifiable' || + judgement.blindness || + !incarnationId || + !inspection || + typeof inspection === 'string' || + isClientOnlyUnverifiableInspection(inspection) + ) { + return judgement + } + const evidence = inspection.foregroundProcessEvidence + const name = inspection.foregroundProcess + if ( + evidence?.ptyId !== expectedPtyId || + evidence.ptyIncarnationId !== incarnationId || + !name || + !isShellProcess(name) + ) { + return judgement + } + return { verdict: 'exited', processName: name, canCertifyExit: true } +} diff --git a/src/main/runtime/orca-runtime-tests/terminal-side-effect-facts-part-03.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-side-effect-facts-part-03.spec.ts index cc45d9900c82..c45fdb152133 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-side-effect-facts-part-03.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-side-effect-facts-part-03.spec.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import { TEST_WORKTREE_ID, syncSinglePty } from '../orca-runtime-test-fixtures.spec' import { createSideEffectRuntime } from '../orca-runtime-test-scenario-builders.spec' import '../orca-runtime-test-mocks.spec' +import type { TerminalProcessInspection } from '../../../shared/terminal-process-inspection' describe('terminal side-effect fact channel', () => { it('arms the stale-title timer for a seeded working title', async () => { @@ -125,4 +126,55 @@ describe('terminal side-effect fact channel', () => { // The seed must not stomp the leaf record (worktree ps status source) back to the renderer's stale title. expect([...leaves.values()][0]?.lastOscTitle).toBe('⠋ Claude working') }) + + it.each([ + ['zsh', true], + ['2.1.258', false] + ] as const)( + 'on SSH with an unreadable host process table, foreground %s publishes exit: %s', + async (foregroundProcess, exits) => { + const { runtime, batches } = createSideEffectRuntime() + syncSinglePty(runtime) + const inspectProcess = vi.fn(async (): Promise => ({ + foregroundProcess, + hasChildProcesses: false, + // What a loaded relay answers when its ps capture misses the evidence budget. + foregroundProcessEvidence: { + authorityGeneration: 'host-1', + observationEpoch: 1, + capturedAgeMs: 0, + ptyId: 'pty-1', + ptyIncarnationId: 'generation-1', + verdict: 'unverifiable', + reason: 'process_table_unreadable' + } + })) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: vi.fn().mockResolvedValue('claude'), + inspectProcess + }) + runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;✳ Claude Code\x07') + const pty = runtime['ptysById'].get('pty-1') + if (!pty) { + throw new Error('expected the synced PTY record') + } + pty.connectionId = 'target-1' + pty.incarnationId = 'generation-1' + pty.foregroundAgent = 'claude' + batches.length = 0 + + runtime.onPtyData('pty-1', '\x1b]0;plain\x07', 100) + + await vi.waitFor(() => expect(inspectProcess).toHaveBeenCalled()) + // Why a macrotask: every mocked read resolves in microtasks, so an owed exit has landed by then. + await new Promise((resolve) => setTimeout(resolve, 0)) + const exitFacts = batches + .flatMap((batch) => batch.facts) + .filter((fact) => fact.kind === 'agent-exited') + expect(exitFacts).toHaveLength(exits ? 1 : 0) + expect(pty.foregroundAgent).toBe(exits ? null : 'claude') + } + ) }) diff --git a/src/main/runtime/runtime-pty-foreground-agent-evidence.test.ts b/src/main/runtime/runtime-pty-foreground-agent-evidence.test.ts index 8c37902cd1c8..43a22d517b9a 100644 --- a/src/main/runtime/runtime-pty-foreground-agent-evidence.test.ts +++ b/src/main/runtime/runtime-pty-foreground-agent-evidence.test.ts @@ -192,7 +192,8 @@ describe('foreground identity on unknown observations', () => { }) it.each([ ['an old relay without evidence', 'claude', 'missing'], - ['stale evidence', 'claude', 'stale'], + // The reply's own live foreground name still proves the shell when the record is too old. + ['stale evidence with the shell in front', null, 'stale'], ['evidence for another incarnation', 'claude', 'wrong-incarnation'], ['an SSH-to-Windows host', 'claude', 'windows'], ['another program in front', 'claude', 'vim'], @@ -213,6 +214,60 @@ describe('foreground identity on unknown observations', () => { await h.agent.refresh('pty-1') expect(h.pty.foregroundAgent).toBe(expected) }) + it.each([ + ['zsh', 'exited', null], + ['2.1.258', 'unverifiable', 'claude'] + ] as const)( + 'on an SSH pane whose host process table is unreadable, foreground %s is %s', + async (foregroundProcess, verdict, expected) => { + const h = setup(true) + h.replace({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => 'claude', + inspectProcess: async (): Promise => ({ + foregroundProcess, + hasChildProcesses: false, + foregroundProcessEvidence: { + authorityGeneration: 'host-1', + observationEpoch: 1, + capturedAgeMs: 0, + ptyId: 'pty-1', + ptyIncarnationId: 'generation-1', + verdict: 'unverifiable', + reason: 'process_table_unreadable' + } + }) + }) + const result = await h.agent.confirm('ssh:target-1@@pty-1') + expect(result?.judgement.verdict).toBe(verdict) + expect(h.pty.foregroundAgent).toBe(expected) + } + ) + it('does not take the shell name from a reply naming another PTY incarnation', async () => { + const h = setup(true) + h.replace({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => 'claude', + inspectProcess: async (): Promise => ({ + foregroundProcess: 'zsh', + hasChildProcesses: false, + foregroundProcessEvidence: { + authorityGeneration: 'host-1', + observationEpoch: 1, + capturedAgeMs: 0, + ptyId: 'pty-1', + ptyIncarnationId: 'generation-0', + verdict: 'unverifiable', + reason: 'process_table_unreadable' + } + }) + }) + const result = await h.agent.confirm('ssh:target-1@@pty-1') + expect(result?.judgement.verdict).toBe('unverifiable') + expect(h.pty.foregroundAgent).toBe('claude') + }) it('stamps each read that names the agent with the incarnation it saw', async () => { const h = setup() h.confirm.mockResolvedValue('claude') diff --git a/src/main/runtime/runtime-pty-foreground-agent.ts b/src/main/runtime/runtime-pty-foreground-agent.ts index d04275b0c324..c2ce76a9455c 100644 --- a/src/main/runtime/runtime-pty-foreground-agent.ts +++ b/src/main/runtime/runtime-pty-foreground-agent.ts @@ -6,6 +6,7 @@ import { import { admitRemoteForegroundEvidence } from '../../shared/remote-foreground-evidence-admission' import { parseAppSshPtyId } from '../../shared/ssh-pty-id' import { recognizeAgentProcess } from '../../shared/agent-process-recognition' +import { judgeWithForegroundShellName } from './host-foreground-shell-name' import type { RuntimePtyController } from './runtime-pty-controller-contract' import type { PtyForegroundAgentRefresh, @@ -265,24 +266,26 @@ export class RuntimePtyForegroundAgent { } } const incarnationId = pty.incarnationId + const expectedPtyId = parseAppSshPtyId(ptyId)?.relayPtyId ?? ptyId const started = performance.now() const inspection = await controller.inspectProcess?.( ptyId, incarnationId ? { expectedIncarnationId: incarnationId } : {} ) + const judgement = judgeForegroundAgent( + observeHostInspection(inspection, (evidence) => + admitRemoteForegroundEvidence(evidence, { + expectedPtyId, + expectedIncarnationId: incarnationId, + requestStartedAtMonotonic: started, + receivedAtMonotonic: performance.now(), + lastAuthorityGeneration: null, + lastObservationEpoch: -1 + }) + ) + ) return { - judgement: judgeForegroundAgent( - observeHostInspection(inspection, (evidence) => - admitRemoteForegroundEvidence(evidence, { - expectedPtyId: parseAppSshPtyId(ptyId)?.relayPtyId ?? ptyId, - expectedIncarnationId: incarnationId, - requestStartedAtMonotonic: started, - receivedAtMonotonic: performance.now(), - lastAuthorityGeneration: null, - lastObservationEpoch: -1 - }) - ) - ), + judgement: judgeWithForegroundShellName(judgement, inspection, expectedPtyId, incarnationId), fresh: true } }