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..dcfbcea4f170 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,121 @@ 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 + }) + }) + + // 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 90c5e794fbda..8b7d52433d4e 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,10 +121,44 @@ export function resolveRemoteForegroundEvidenceFromRows( ...metadata, verdict: 'live', processName: candidate?.name.processName ?? null, - fence + fence, + 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/main/runtime/agent-exit-candidate-recheck.ts b/src/main/runtime/agent-exit-candidate-recheck.ts new file mode 100644 index 000000000000..d7de52b821c2 --- /dev/null +++ b/src/main/runtime/agent-exit-candidate-recheck.ts @@ -0,0 +1,68 @@ +import { FOREGROUND_CONFIRM_RETRY_DELAYS_MS } from '../../shared/foreground-agent-verdict' + +/** 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 +} + +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 +/** 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, 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 = { owner, 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, { ...owner, attempt: attempt + 1 }) + } + }, 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): void { + const candidate = this.byPtyId.get(ptyId) + if (!candidate || candidate.timer !== null) { + return + } + this.byPtyId.delete(ptyId) + this.recheck(ptyId, { ...candidate.owner, attempt: FINAL_AGENT_EXIT_RECHECK }) + } + + 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/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-agent-exit-confirmation.ts b/src/main/runtime/orca-runtime-agent-exit-confirmation.ts new file mode 100644 index 000000000000..682c28727848 --- /dev/null +++ b/src/main/runtime/orca-runtime-agent-exit-confirmation.ts @@ -0,0 +1,90 @@ +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) + } + + 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]) + } + + 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 f4f8826e6e78..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,6 +192,7 @@ export class OrcaRuntimeWithApplyTrackedPtyTitle extends OrcaRuntimeWithGetUnper pty.waitBlockedAt = null pty.tailWaitState = undefined pty.commandPaint = undefined + pty.shellCommandMarks = undefined } for (const leaf of this.getLeavesForPty(ptyId)) { leaf.lastOscTitle = null 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-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/orca-runtime-get-unpersisted-tracked-title-for-pty.ts b/src/main/runtime/orca-runtime-get-unpersisted-tracked-title-for-pty.ts index 3ece144bc248..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' @@ -140,10 +141,17 @@ export class OrcaRuntimeWithGetUnpersistedTrackedTitleForPty extends OrcaRuntime ...(meta?.staleWorkingTitleClear ? { staleWorkingTitleClear: true } : {}) }) }, - onAgentExited: () => { + 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) 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 dd3afbfa23e6..f45ae7ba9286 100644 --- a/src/main/runtime/orca-runtime-serialize-agent-prompt-submission.ts +++ b/src/main/runtime/orca-runtime-serialize-agent-prompt-submission.ts @@ -1,5 +1,8 @@ // @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. import { selectFreshExplicitAgentStatus } from './runtime-hook-agent-row-selection' +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' @@ -65,12 +68,17 @@ export class OrcaRuntimeWithSerializeAgentPromptSubmission extends OrcaRuntimeWi protected readPtyForegroundProcessFromController( ptyId: string, - afterTitleObservation = 0 + afterTitleObservation = 0, + fresh = true ): Promise | null { - return this.ptyForegroundAgent.read(ptyId, afterTitleObservation) + return this.ptyForegroundAgent.confirm(ptyId, afterTitleObservation, fresh) } - 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 ( @@ -80,25 +88,56 @@ export class OrcaRuntimeWithSerializeAgentPromptSubmission extends OrcaRuntimeWi return } 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) { - if (!recoverCompletedHook) { - this.recordTerminalSideEffectFact(ptyId, { kind: 'agent-exited' }) + 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 + } + // Why cached: the per-turn hook recovery only restores `idle`; it never certifies an exit. + const foregroundRead = this.readPtyForegroundProcessFromController( + ptyId, + pty?.lastOscTitleAt ?? 0, + !recoverCompletedHook + ) + // Why: no host to ask now; the next contact with it re-reads once. + const awaitHostContact = (): void => { + if (pty && !recoverCompletedHook) { + this.agentExitRechecks.scheduleNext( + ptyId, + candidateOwner, + 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) + // Why: an exit or provider reset ended the process this read was asked about. + this.getPtyLifecycleGeneration(ptyId) !== generation ) { return } + if (!current.connected) { + awaitHostContact() + return + } if (current.lastOscTitleAt !== titleObservedAt && current.lastAgentStatus !== null) { return } @@ -113,14 +152,14 @@ 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 (!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 = - recoverCompletedHook && recognizeAgentProcess(result.process)?.agent === 'codex' + recoverCompletedHook && recognizeAgentProcess(processName)?.agent === 'codex' ? 'idle' : undefined const restoredStatus = this.ptyTitleTrackersByPtyId @@ -149,9 +188,29 @@ export class OrcaRuntimeWithSerializeAgentPromptSubmission extends OrcaRuntimeWi } return } - if (!recoverCompletedHook) { - this.recordTerminalSideEffectFact(ptyId, { kind: 'agent-exited' }) + 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', + blind ? 'agent-title' : 'foreground-shell' + ) + return + } + // Why: an unanswered read re-derives on the bounded ladder; the candidate stays until then. + if ( + decides && + result.judgement.blindness === undefined && + this.agentExitRechecks.scheduleNext(ptyId, candidateOwner, recheck?.attempt ?? 0) + ) { + return } + this.ptyTitleTrackersByPtyId.get(ptyId)?.tracker.restoreLastAgentExit() }) } 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-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/orca-runtime-tests/terminal-side-effect-facts.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-side-effect-facts.spec.ts index 79feca3268c3..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 @@ -414,15 +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') + // 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' } @@ -439,15 +441,90 @@ 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(() => - 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) + 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.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) @@ -455,25 +532,119 @@ 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, kill: () => true, - getForegroundProcess + getForegroundProcess, + confirmForegroundProcess }) 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' }) + expect(batches.flatMap((batch) => batch.facts)).toContainEqual({ + kind: 'agent-exited', + evidence: 'foreground-shell' + }) ) }) + 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('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] + ])('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 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. + 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) @@ -484,15 +655,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/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 new file mode 100644 index 000000000000..43a22d517b9a --- /dev/null +++ b/src/main/runtime/runtime-pty-foreground-agent-evidence.test.ts @@ -0,0 +1,327 @@ +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' +import type { RemoteForegroundEvidence } from '../../shared/foreground-process-evidence' +import type { TerminalProcessInspection } from '../../shared/terminal-process-inspection' + +function setup(remote = false) { + const pty: Pick< + RuntimePtyWorktreeRecord, + | 'connectionId' + | 'incarnationId' + | 'connected' + | 'launchAgent' + | 'foregroundAgent' + | 'foregroundAgentIncarnationId' + > = { + 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('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() + }) + // 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 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') + 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 = () => {} + h.confirm.mockImplementation( + () => + new Promise((resolve) => { + answer = resolve + }) + ) + const pending = h.agent.refresh('pty-1') + await vi.waitFor(() => expect(h.confirm).toHaveBeenCalled()) + h.pty.incarnationId = 'generation-2' + answer('zsh') + await pending + expect(h.pty.foregroundAgent).toBe('claude') + }) + it.each([ + ['an old relay without evidence', 'claude', 'missing'], + // 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'], + ['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, + kill: () => true, + getForegroundProcess: async () => 'zsh', + inspectProcess: async (): Promise => ({ + foregroundProcess: 'zsh', + hasChildProcesses: false, + ...(kind === 'missing' ? {} : { foregroundProcessEvidence: hostEvidence(kind) }) + }) + }) + 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') + 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') + 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' +): RemoteForegroundEvidence { + 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 252354c09536..c2ce76a9455c 100644 --- a/src/main/runtime/runtime-pty-foreground-agent.ts +++ b/src/main/runtime/runtime-pty-foreground-agent.ts @@ -1,4 +1,12 @@ +import { + judgeForegroundAgent, + observeHostInspection, + type ForegroundAgentJudgement +} from '../../shared/foreground-agent-verdict' +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, @@ -7,9 +15,21 @@ import type { } from './runtime-terminal-contracts' import type { RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' +type ForegroundPty = Pick< + RuntimePtyWorktreeRecord, + | 'connectionId' + | 'incarnationId' + | 'connected' + | 'launchAgent' + | 'foregroundAgent' + | 'foregroundAgentIncarnationId' +> + +type ProcessRead = { judgement: ForegroundAgentJudgement; fresh: boolean } + 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 } @@ -21,28 +41,46 @@ 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) { + 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) ?? { controller, process: null, available: false } + // 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, process: null, available: false } - let processRead: Promise + const unavailable: PtyForegroundProcessRead = { + controller, + judgement: judgeForegroundAgent({ kind: 'unavailable' }) + } + let processRead: Promise try { - processRead = Promise.resolve(controller.getForegroundProcess(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)) @@ -50,11 +88,18 @@ 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((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, promise } + entry = { controller, startedAfterTitleObservation: afterTitle, fresh, promise } this.reads.set(ptyId, entry) return entry.promise } @@ -90,6 +135,33 @@ export class RuntimePtyForegroundAgent { return entry.promise } + /** + * 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, fresh) + 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 } @@ -123,11 +195,38 @@ export class RuntimePtyForegroundAgent { if (!controller || !pty?.connected || pty.launchAgent) { return false } - const result = await this.read(ptyId, afterTitle) - if (!result || result.controller !== this.deps.getController() || !result.available) { + const incarnationId = pty.incarnationId + const result = await this.read(ptyId, afterTitle, false) + 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 = result.process ? (recognizeAgentProcess(result.process)?.agent ?? null) : null + const agent = + judgement.verdict === 'live' + ? (recognizeAgentProcess(judgement.processName)?.agent ?? null) + : null + if (agent) { + pty.foregroundAgentIncarnationId = pty.incarnationId + } if (pty.foregroundAgent === agent) { return false } @@ -136,6 +235,61 @@ export class RuntimePtyForegroundAgent { return true } + private async readProcess( + controller: RuntimePtyController, + ptyId: string, + fresh: boolean + ): Promise { + const pty = this.deps.getPty(ptyId) + if (!pty?.connectionId) { + if (!fresh || !controller.confirmForegroundProcess) { + const cached = await controller.getForegroundProcess(ptyId) + if (recognizeAgentProcess(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 { + judgement: judgeForegroundAgent( + controller.confirmForegroundProcess + ? { + kind: 'process-name', + processName: await controller.confirmForegroundProcess(ptyId) + } + : { kind: 'unavailable' } + ), + fresh: true + } + } + 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: judgeWithForegroundShellName(judgement, inspection, expectedPtyId, incarnationId), + fresh: true + } + } + 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-contracts.ts b/src/main/runtime/runtime-terminal-contracts.ts index e08fffa273cf..fc6009496bb0 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,13 +112,14 @@ export type PtyForegroundAgentRefresh = { export type PtyForegroundProcessRead = { controller: RuntimePtyController - process: string | null - available: boolean + judgement: ForegroundAgentJudgement } export type PtyForegroundProcessReadEntry = { controller: RuntimePtyController startedAfterTitleObservation: number + /** The answer does not rest on the cached foreground name, so it can answer an exit decision. */ + fresh: boolean promise: Promise } diff --git a/src/main/runtime/runtime-terminal-state-records.ts b/src/main/runtime/runtime-terminal-state-records.ts index d030b896da99..aa02ddc743e4 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 @@ -90,6 +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 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 new file mode 100644 index 000000000000..3d0fc6eca1e2 --- /dev/null +++ b/src/main/runtime/shell-command-agent-hold.ts @@ -0,0 +1,41 @@ +import type { RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' + +type ShellCommandPty = Pick + +type AgentHoldPty = ShellCommandPty & + Pick< + RuntimePtyWorktreeRecord, + | 'launchAgent' + | 'foregroundAgent' + | 'foregroundAgentIncarnationId' + | 'lastAgentStatus' + | 'lastAgentStatusObservedLive' + > + +/** OSC 133;C: the next 133;D ends a command this incarnation started. */ +export function noteShellCommandStarted(pty: ShellCommandPty): void { + 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 +} + +/** + * 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) && + ptyMarksShellCommands(pty) && + pty.shellCommandMarks?.commandRunning === true + pty.shellCommandMarks = { incarnationId: pty.incarnationId, commandRunning: false } + return ( + endsLaunchedCommand || + (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 new file mode 100644 index 000000000000..e38a6dd3f341 --- /dev/null +++ b/src/main/runtime/terminal-agent-exit-evidence.test.ts @@ -0,0 +1,464 @@ +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 { 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 stubController = (): RuntimePtyController => ({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null +}) + +const readResult = (controller: RuntimePtyController, processName: string | null) => ({ + controller, + judgement: judgeForegroundAgent({ kind: 'process-name', processName }) +}) + +vi.mock('./orca-runtime-controller-knows-pty-is-live', () => ({ + OrcaRuntimeWithControllerKnowsPtyIsLive: class { + markPtyLivenessLive(): void {} + disposePtyTitleTracker(): void {} + } +})) + +afterEach(() => { + vi.useRealTimers() +}) + +class ExitHarness extends OrcaRuntimeWithAgentExitConfirmation { + 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') + } + dispose(): void { + this.disposePtyTitleTracker('pty-1') + } +} + +function setup() { + const runtime = new ExitHarness(null) + const facts: TerminalSideEffectFact[] = [] + const controller = stubController() + const pty: { + connected: boolean + incarnationId: string + lastOscTitleAt: number + lastAgentStatus: string | null + lastAgentStatusObservedLive: boolean + launchAgent: string | null + foregroundAgent: string | null + foregroundAgentIncarnationId?: string | null + connectionId?: string + shellCommandMarks?: { incarnationId: string | null; commandRunning: boolean } + } = { + connected: true, + incarnationId: 'inc-1', + lastOscTitleAt: 1, + lastAgentStatus: null, + lastAgentStatusObservedLive: false, + launchAgent: 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( + { onAgentExitCandidate: () => runtime.confirm() }, + { 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(), + ptyController: controller, + ptyTitleTrackersByPtyId: new Map([['pty-1', { tracker }]]), + readPtyForegroundProcessFromController: read, + recordTerminalSideEffectFact: (_id: string, fact: TerminalSideEffectFact) => facts.push(fact), + getLeavesForPty: () => [], + resolvePtyTuiIdleWaiters: () => {}, + titleObservationSequence: 1, + ptyForegroundAgent: { markExited }, + getPtyLifecycleGeneration: () => lifecycle.generation + }) + return { runtime, facts, controller, pty, tracker, read, markExited, lifecycle } +} + +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 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 vi.advanceTimersByTimeAsync(8000) + 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) => { + vi.useFakeTimers() + const h = setup() + h.read.mockReturnValue( + kind === 'missing' + ? null + : Promise.resolve( + kind === 'unavailable' + ? { controller: h.controller, judgement: judgeForegroundAgent({ kind }) } + : readResult(stubController(), 'zsh') + ) + ) + h.tracker.handleChunk('\x1b]0;workspace\x07') + await vi.advanceTimersByTimeAsync(0) + expect(h.facts).toEqual([]) + h.read.mockResolvedValue(readResult(h.controller, 'zsh')) + h.tracker.handleChunk('\x1b]0;next workspace\x07') + await vi.advanceTimersByTimeAsync(8000) + expect(h.facts).toHaveLength(1) + 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 where its shell marks commands', 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.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')) + h.tracker.handleChunk('\x1b]0;workspace\x07') + h.pty.incarnationId = 'inc-2' + await Promise.resolve() + expect(h.facts).toEqual([]) + 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.pty.lastAgentStatusObservedLive = true + 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.runtime.commandStarted() + 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.pty.foregroundAgentIncarnationId = 'inc-1' + 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('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() + expect(h.read).not.toHaveBeenCalled() + 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('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' + 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() + }) +}) + +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('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() + 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/relay/pty-handler-resize-stale-pty.test.ts b/src/relay/pty-handler-resize-stale-pty.test.ts index d3478bc6825f..486cb2711133 100644 --- a/src/relay/pty-handler-resize-stale-pty.test.ts +++ b/src/relay/pty-handler-resize-stale-pty.test.ts @@ -166,6 +166,26 @@ 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 managed = handler['ptys'].get(PTY_1) + if (!managed) { + throw new Error('missing PTY') + } + 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([]) + 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..f70c6c64e887 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/agent-exit-observation.test.ts @@ -0,0 +1,407 @@ +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, + 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('accepts an old host exit fact as that host decided it', () => { + const exited = vi.fn() + registerTerminalSideEffectFactConsumer({ ptyId: 'pty-1', callbacks: { onAgentExited: exited } }) + _dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-1', + seq: 1, + facts: [{ kind: 'agent-exited' }] + }) + 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() + } + ) + + describe('startup and remote hosts', () => { + const SSH_PTY_ID = 'ssh:conn-1@@pty-1' + let epoch = 0 + + function remoteTracker( + evidence: (rows: 'shell' | 'agent') => RemoteForegroundEvidence | undefined + ) { + let foreground: 'shell' | 'agent' = 'agent' + const read = vi.fn(async (): Promise => ({ + 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', 'none') + 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() + 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', 'marked') + 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', 'read-confirmed') + 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.tracker.onCommandStarted(null, { shellMarked: true }) + h.exitAgent() + h.tracker.onCommandFinished() + await vi.advanceTimersByTimeAsync(400) + expect(h.shell).toHaveBeenCalledExactlyOnceWith('command-finished', 'marked') + expect(h.read).toHaveBeenCalledOnce() + 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', 'marked') + 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.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) + // 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() + }) + + 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/pane-agent-exit-marks.ts b/src/renderer/src/components/terminal-pane/pane-agent-exit-marks.ts new file mode 100644 index 000000000000..ac94a6b635e6 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pane-agent-exit-marks.ts @@ -0,0 +1,62 @@ +/** '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(seesShellCommandMarks?: () => boolean): { + agentSeenLive: (ptyKey: string) => void + agentTitleObserved: (ptyKey: string) => void + commandStarted: (shellMarkedPtyKey: string | null) => void + commandFinished: (ptyKey: string | null) => void + marksCommands: (ptyKey: string) => boolean + 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 + // The PTY key whose shell has printed an OSC 133 mark; see isAgentExitBlind. + let marked: 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 + marked = shellMarkedPtyKey ?? marked + }, + 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 = + 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.test.ts b/src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.test.ts index 50bbb993bce6..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 @@ -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() }) @@ -462,6 +467,8 @@ describe('createPaneForegroundAgentTracker', () => { 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 () => { @@ -556,6 +563,7 @@ describe('createPaneForegroundAgentTracker', () => { readForegroundProcess, confirmForegroundProcess, publish, + revokeRouting, hasKnownAgentIdentity: () => true }) @@ -642,7 +650,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 +663,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 +720,7 @@ describe('createPaneForegroundAgentTracker', () => { readForegroundProcess: remoteRead, confirmForegroundProcess: remoteRead, publish, + revokeRouting, onCommandFinishedUnavailable, onConfirmedShellForeground, onVisibleForegroundSettled @@ -759,6 +768,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..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 @@ -2,18 +2,24 @@ import { isAgentForegroundWrapperProcess, recognizeAgentProcess } from '../../../../shared/agent-process-recognition' -import { isShellProcess } from '../../../../shared/shell-process-detection' 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' +import { + FOREGROUND_CONFIRM_RETRY_DELAYS_MS, + isAgentExitBlind +} from '../../../../shared/foreground-agent-verdict' + +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. 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 type PaneForegroundAgentTrackerDeps = { getPtyId: () => string | null @@ -33,14 +39,20 @@ type PaneForegroundAgentTrackerDeps = { isRemotePtyId?: (ptyId: string) => boolean 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. */ 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. A slow boot also shows the shell, so a retired expectation is never an exit. */ + onConfirmedShellForeground?: ( + reason: ShellConfirmReason, + agentExit: ConfirmedShellAgentExit + ) => void onCommandFinishedUnavailable?: () => void onVisibleForegroundSettled?: (outcome: 'agent' | 'shell' | 'inconclusive') => void } @@ -57,7 +69,10 @@ export function createPaneForegroundAgentTracker(deps: PaneForegroundAgentTracke /** True while any read is scheduled or running, whatever its authority. */ hasReadInFlight: () => boolean onVisiblePtyBound: (expectsAgent?: boolean) => boolean - onCommandStarted: (expectedAgent?: TuiAgent | null) => void + /** A neutral title followed the agent's own status title; confirm whether it exited. */ + onAgentExitCandidate: () => boolean + /** `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 @@ -75,6 +90,10 @@ 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 exitMarks = createPaneAgentExitMarks(deps.seesShellCommandMarks) + const ptyKey = (id: string): string => `${id}\n${deps.getExpectedIncarnationId?.() ?? ''}` const readProcess = createPaneForegroundProcessReader(deps) const trackablePtyId = (): string | null => { @@ -90,6 +109,7 @@ export function createPaneForegroundAgentTracker(deps: PaneForegroundAgentTracke } scheduledReadReason = null activeReadReason = null + agentTitleExitCandidate = false } const scheduleRead = ( @@ -143,10 +163,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 ( @@ -158,14 +179,10 @@ 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 + exitMarks.agentSeenLive(ptyKey(ptyId)) hasAgentExpectation = false deps.publish({ agent: recognized.agent, @@ -181,11 +198,19 @@ 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') || titleIsExitSignal const shouldRetryExpectedIdentity = - hasConfirmationExpectation && (reason !== 'command-finished' || processName === null) + hasConfirmationExpectation && + !settledByHost && + (reason !== 'command-finished' || processName === null) const shouldRetry = retryDelay !== undefined && (shouldRetryExpectedIdentity || @@ -198,64 +223,84 @@ export function createPaneForegroundAgentTracker(deps: PaneForegroundAgentTracke return } if (reason === 'command') { - if (remoteEvidenceVerdict !== null && remoteEvidenceVerdict !== 'live') { - hasAgentExpectation = false + if (!processName?.trim()) { 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) + (verdict === 'exited' || titleIsExitSignal) ) { - hasForegroundAgentEvidence = false - hasKnownAgentEvidence = false - hasAgentExpectation = false - deps.publish({ agent: null, shellForeground: true }) - deps.onConfirmedShellForeground?.(reason) + confirmShellForeground(reason, ptyId) deps.onVisibleForegroundSettled?.('shell') } else { deps.onVisibleForegroundSettled?.('inconclusive') } return } - if (reason === 'command-finished') { - if (processName === null) { - if (remoteEvidenceVerdict !== null && remoteEvidenceVerdict !== 'live') { - 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)) { - // 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, ptyId) + 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: 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 = 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 + 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, agentExit) + } + + 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() + const ptyId = trackablePtyId() + if (!ptyId) { + releaseRetainedCapability(hadReadBeforeVisibleBind) + return false + } + if (expectsAgent || deps.hasKnownAgentIdentity?.() === true) { + hasKnownAgentEvidence = true + } + 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. + scheduleRead(VISIBLE_PTY_SETTLE_MS, 0, 'visible-pty') + return true } return { @@ -266,38 +311,24 @@ 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) }, - onCommandStarted(expectedAgent = null) { + onAgentExitCandidate() { + // Why: the title tracker saw the agent's own status title, so the agent was observed. + const scheduled = bindVisiblePty(true, true) + agentTitleExitCandidate = scheduled + return scheduled + }, + onCommandStarted(expectedAgent = null, options = {}) { const hadReadBeforeCommandStart = hasPendingRead() cancelPendingRead() const ptyId = trackablePtyId() + exitMarks.commandStarted(options.shellMarked && ptyId ? ptyKey(ptyId) : null) if (!ptyId) { releaseRetainedCapability(hadReadBeforeCommandStart) return } + deps.revokeRouting?.() const alreadyHasKnownIdentity = deps.hasKnownAgentIdentity?.() === true hasAgentExpectation = expectedAgent !== null if (alreadyHasKnownIdentity) { @@ -307,12 +338,18 @@ 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') }, onCommandFinished() { + const ptyId = trackablePtyId() + exitMarks.commandFinished(ptyId ? ptyKey(ptyId) : null) if (deps.hasKnownAgentIdentity?.() === true) { hasKnownAgentEvidence = true } @@ -328,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/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 fac54efc967e..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 = inspection - remoteEvidenceVerdict = remote ? '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/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/parked-terminal-byte-watcher.ts b/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.ts index f3fa629df7ad..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,14 +177,15 @@ export function startParkedTerminalByteWatcher( if (pendingBellNotification) { scheduleTerminalBellNotification() } - }, - 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. - 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, @@ -239,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/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-command-finished-cleanup.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-command-finished-cleanup.test.ts index 45a603f9e7fb..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 @@ -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-foreground-agent-sampling.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-sampling.test.ts index 50e1b4a0f8b9..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 @@ -549,6 +549,82 @@ 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.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 d1b618e03308..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 @@ -165,7 +165,7 @@ describe('connectPanePty', () => { 'onBell', 'onAgentBecameIdle', 'onAgentBecameWorking', - 'onAgentExited' + 'onAgentExitCandidate' ] as const function enableMainAuthority(): void { @@ -678,4 +678,80 @@ 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: 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) + .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) + }) + + 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-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() }) 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..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 @@ -31,16 +31,19 @@ export function installAgentIdleWorkingHandlers(session: ConnectPanePtySession): session.scheduleTerminalBellNotification() } } + 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?.onAgentExitCandidate() + } + } session.onAgentExited = (): void => { - // Why: eligibility can disappear transiently during reconnect, but a - // confirmed shell-title transition is authoritative for native-chat exit. + // 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() 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..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 @@ -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,28 @@ 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) => { + 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. + if ( + agentExit === 'read-confirmed' || + (agentExit === 'marked' && !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() @@ -212,7 +234,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. @@ -249,6 +270,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 => { @@ -326,7 +349,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 }) 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-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..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,6 +289,6 @@ export type IpcPtyTransportOptions = { onBell?: () => void 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-chat-ownership-lifecycle.test.tsx b/src/renderer/src/components/terminal-pane/terminal-chat-ownership-lifecycle.test.tsx index 350c27667fa5..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' @@ -229,6 +230,54 @@ 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('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-side-effect-facts-handler.ts b/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts index 527fbb46b95c..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 @@ -130,6 +130,7 @@ function applyLiveFact(entry: ConsumerEntry, fact: TerminalSideEffectFact, seq: ) return case 'agent-exited': + // Why: the execution host decided; re-judging an old host's fact here could never succeed. entry.callbacks.onAgentExited?.() return case 'command-finished': 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(':') 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/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 }) 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.test.ts b/src/shared/foreground-agent-verdict.test.ts new file mode 100644 index 000000000000..297cacfdd57f --- /dev/null +++ b/src/shared/foreground-agent-verdict.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from 'vitest' +import { + isRemoteForegroundEvidence, + type RemoteForegroundEvidence +} from './foreground-process-evidence' +import { + isAgentExitBlind, + 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' }) + }) +}) + +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], + ['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], + ['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 new file mode 100644 index 000000000000..08885a23307c --- /dev/null +++ b/src/shared/foreground-agent-verdict.ts @@ -0,0 +1,178 @@ +import { isAgentForegroundWrapperProcess, 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' + +/** 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 + /** Why an answered read can never show this pane's shell; absent when a re-read might. */ + blindness?: ForegroundBlindness +} + +/** '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' + +/** 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', + 'zellij', + '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. */ +export function judgeForegroundAgent( + observation: ForegroundAgentObservation +): ForegroundAgentJudgement { + switch (observation.kind) { + case 'unavailable': + return unverifiable(null) + case 'host-without-evidence': + return unverifiable(null, false, 'host-cannot-read') + 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 blindness = blindnessForProgram(processName) + return unverifiable(processName, blindness !== 'bridge', blindness) + } + 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') { + 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 } + } + 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 evidence.shellForeground === undefined + ? unverifiable(null, false, 'host-cannot-read') + : unverifiable(null, true, 'other-program') + } + } +} + +/** 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 (inspection.foregroundProcessEvidence === undefined) { + return { kind: 'host-without-evidence' } + } + return { kind: 'host-evidence', evidence: admit(inspection.foregroundProcessEvidence) } +} diff --git a/src/shared/foreground-process-evidence.ts b/src/shared/foreground-process-evidence.ts index 975fbe388ffc..7182d06536e9 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: 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) | ({ 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)) ) } diff --git a/src/shared/terminal-output-side-effects.ts b/src/shared/terminal-output-side-effects.ts index 20128c632349..53d18279c4e8 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 /** @@ -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 /** @@ -116,9 +118,10 @@ export function createTerminalTitleTracker( onTitle, onAgentBecameIdle, onAgentBecameWorking, - onAgentExited, + 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 @@ -143,7 +149,7 @@ export function createTerminalTitleTracker( ? options.initialTitle : undefined const agentTracker = - onAgentBecameIdle || onAgentBecameWorking || onAgentExited + onAgentBecameIdle || onAgentBecameWorking || onAgentExitCandidate ? createAgentStatusTracker( (title) => { onAgentBecameIdle?.( @@ -152,7 +158,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..bbf307fd8c07 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` is telemetry only; every host exit fact is the host's decision. */ + | { 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 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) }) })