Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
5823808
fix(terminal): require positive evidence before publishing agent exit
brennanb2025 Sep 29, 2026
f8b7e59
fix(terminal): keep ordinary fast commands off the confirmed-exit path
brennanb2025 Sep 29, 2026
dcb2c6d
perf(runtime): keep a live local agent on the cached foreground read
brennanb2025 Sep 29, 2026
265d4d7
fix(terminal): confirm title exit candidates only while a pane holds …
brennanb2025 Sep 29, 2026
f066f98
test(terminal): align pane suites with exit-candidate semantics
brennanb2025 Sep 29, 2026
b82dd67
feat(ssh): report whether the shell owns the terminal in host foregro…
brennanb2025 Sep 29, 2026
36ee853
fix(terminal): decide agent exit with one evaluator over host observa…
brennanb2025 Sep 29, 2026
932fe16
fix(terminal): keep Chat through slow agent startup with one exit pub…
brennanb2025 Sep 29, 2026
d883307
test(terminal): fix renderer typecheck errors in pane exit suites
brennanb2025 Sep 29, 2026
2187da3
test(runtime): name SSH foreground cases by the identity they leave
brennanb2025 Sep 29, 2026
bd75fbf
fix(runtime): keep the agent send guard failing closed on unconfirmed…
brennanb2025 Sep 29, 2026
e1dbbad
fix(ssh): let host evidence see a shell leading the foreground group,…
brennanb2025 Sep 29, 2026
f8c0b30
fix(runtime): retire an agent on its own title where an SSH host can …
brennanb2025 Sep 29, 2026
c8176e2
refactor(terminal): drop the transport exit option nothing reads
brennanb2025 Sep 29, 2026
f08fb4c
Merge remote-tracking branch 'origin/main' into brennanb2025/claude-c…
brennanb2025 Sep 29, 2026
78e9182
test(terminal): type the host inspection mocks with the real inspecti…
brennanb2025 Sep 29, 2026
2c4ecd6
fix(chat): end an unbound Chat request when the active pane's agent e…
brennanb2025 Sep 29, 2026
b72187e
fix(terminal): retire an agent on its own title where a remote Window…
brennanb2025 Sep 29, 2026
fba8019
fix(runtime): end a launched agent only at the 133;D that closes its …
brennanb2025 Sep 29, 2026
f331d62
fix(terminal): exit Chat on the pane's own confirmed shell after it s…
brennanb2025 Sep 29, 2026
df76d77
fix(runtime): count only this incarnation's agent sighting at a 133;D
brennanb2025 Sep 29, 2026
8f57064
fix(terminal): mark an exit only from the replacement PTY's own evidence
brennanb2025 Sep 29, 2026
361a689
fix(terminal): one blindness rule for title exits, and a bounded re-r…
brennanb2025 Sep 29, 2026
fe04db0
fix(chat): keep a pane Chat-eligible while its agent input routing is…
brennanb2025 Sep 29, 2026
5d9c211
Merge remote-tracking branch 'origin/main' into brennanb2025/claude-c…
brennanb2025 Sep 29, 2026
49a71b3
fix(runtime): keep an exit re-read to the process whose title raised it
brennanb2025 Sep 29, 2026
6a5c0d7
fix(terminal): count zellij as a program that hosts the agent
brennanb2025 Sep 29, 2026
898813a
test(terminal): read private PTY records through typed access in exit…
brennanb2025 Sep 29, 2026
e0ede12
fix(runtime): take the fresh foreground read for every exit decision
brennanb2025 Sep 29, 2026
625dd90
fix(runtime): let an exit check reuse a cached read that already read…
brennanb2025 Sep 29, 2026
3404e81
test(runtime): pin the 133;D exit to the fresh foreground read
brennanb2025 Sep 29, 2026
b8f321d
test(terminal): keep agent identity when every Windows fresh scan is …
brennanb2025 Sep 29, 2026
9820d77
fix(runtime): end an SSH agent on the reply's live shell name when ho…
brennanb2025 Sep 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 117 additions & 0 deletions src/main/providers/agent-foreground-process-remote-evidence.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
})
})
})
})
38 changes: 37 additions & 1 deletion src/main/providers/agent-foreground-process-remote-evidence.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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<number>([rootPid])
Expand Down
68 changes: 68 additions & 0 deletions src/main/runtime/agent-exit-candidate-recheck.ts
Original file line number Diff line number Diff line change
@@ -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<typeof setTimeout> | 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<string, Candidate>()

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)
}
}
40 changes: 40 additions & 0 deletions src/main/runtime/host-foreground-shell-name.ts
Original file line number Diff line number Diff line change
@@ -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 }
}
90 changes: 90 additions & 0 deletions src/main/runtime/orca-runtime-agent-exit-confirmation.ts
Original file line number Diff line number Diff line change
@@ -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<OrcaRuntimeWithSerializeAgentPromptSubmission['registerPty']>
): 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)
}
}
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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: (
Expand Down
1 change: 1 addition & 0 deletions src/main/runtime/orca-runtime-apply-tracked-pty-title.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading