Merge #24257 head 2603d2cda1 (crash guard: paste only when the launched agent is proven in front)

This commit is contained in:
Brennan Benson
2026-10-02 01:30:05 -07:00
17 changed files with 767 additions and 398 deletions
@@ -1,5 +1,8 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { nameLocalTypedLineShell } from './agent-launch-typed-line-shell' import {
launchHostProvesAgentInFront,
nameLocalTypedLineShell
} from './agent-launch-typed-line-shell'
describe('naming the shell a local launch line is typed into', () => { describe('naming the shell a local launch line is typed into', () => {
it('takes the request’s shell first, then the setting, then SHELL, as the spawn does', () => { it('takes the request’s shell first, then the setting, then SHELL, as the spawn does', () => {
@@ -28,3 +31,17 @@ describe('naming the shell a local launch line is typed into', () => {
).toBeUndefined() ).toBeUndefined()
}) })
}) })
describe('whether a launch host can prove its agent is in front before a paste', () => {
it.each([
['a local macOS host', false, 'darwin', 'darwin', true],
['a local Linux host', false, 'linux', 'linux', true],
['a local Windows host', false, 'win32', 'win32', false],
// The pane runs in the distro, but the reads run on the Windows host.
['a local WSL pane', false, 'linux', 'win32', false],
['an SSH Linux host from Windows', true, 'linux', 'win32', true],
['an SSH Windows host', true, 'win32', 'darwin', false]
] as const)('%s: %s', (_label, isRemote, launchPlatform, hostPlatform, proves) => {
expect(launchHostProvesAgentInFront({ isRemote, launchPlatform, hostPlatform })).toBe(proves)
})
})
@@ -25,3 +25,18 @@ export function nameLocalTypedLineShell(args: {
'/bin/zsh' '/bin/zsh'
return basename(shellPath).toLowerCase() return basename(shellPath).toLowerCase()
} }
/**
* Whether the execution host can prove a launched agent holds its terminal before a paste
* (`launched-agent-foreground`). A Windows host cannot, and a local WSL pane runs on one; an SSH
* host is judged by its own platform.
*/
export function launchHostProvesAgentInFront(args: {
isRemote: boolean
launchPlatform: NodeJS.Platform
hostPlatform?: NodeJS.Platform
}): boolean {
return args.isRemote
? args.launchPlatform !== 'win32'
: (args.hostPlatform ?? process.platform) !== 'win32'
}
@@ -131,7 +131,7 @@ describe('a fresh orchestration worker start for Claude', () => {
}) })
}) })
describe('whether a shell is proven in front of a launched agent’s terminal', () => { describe('what holds a launched agent’s terminal', () => {
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')! const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')!
const setPlatform = (value: NodeJS.Platform): void => { const setPlatform = (value: NodeJS.Platform): void => {
Object.defineProperty(process, 'platform', { configurable: true, value }) Object.defineProperty(process, 'platform', { configurable: true, value })
@@ -140,315 +140,206 @@ describe('whether a shell is proven in front of a launched agent’s terminal',
Object.defineProperty(process, 'platform', originalPlatform) Object.defineProperty(process, 'platform', originalPlatform)
}) })
describe('macOS and Linux: a fresh foreground read', () => { describe('macOS and Linux: one fresh foreground read decides', () => {
beforeEach(() => setPlatform('darwin')) beforeEach(() => setPlatform('darwin'))
it.each([ it.each([
// For its first 5 s the daemon's cached read names the launch agent while zsh is in front. // For its first 5 s the daemon's cached read names the launch agent while zsh is in front.
['claude', 'zsh', true], ['claude', 'zsh', 'shell'],
['claude', 'claude', false], // A stub agent that crashed: the cached read can still name it after zsh took the terminal.
['zsh', 'zsh', true], ['python3', 'zsh', 'shell'],
['-zsh', '-zsh', true], ['claude', 'claude', 'agent'],
['bash', 'bash', true], // A native Claude names itself by its version.
[null, 'zsh', true], ['zsh', '2.1.285', 'agent'],
[null, null, false] ['zsh', 'node', 'agent'],
])('cached %s, scan %s: %s', async (foregroundProcess, scanned, shellInFront) => { ['-zsh', '-zsh', 'shell'],
['bash', 'bash', 'shell'],
[null, 'zsh', 'shell'],
['claude', null, 'unknown']
] as const)('cached %s, scan %s: %s', async (foregroundProcess, scanned, found) => {
const scan = vi.fn() const scan = vi.fn()
const proof = vi.fn()
const { runtime } = await createTranscriptPane({ const { runtime } = await createTranscriptPane({
paneTitle: 'Claude Code', paneTitle: 'Claude Code',
foregroundProcess, foregroundProcess,
confirmedForegroundProcess: scanned, confirmedForegroundProcess: scanned,
onForegroundScan: scan, onForegroundScan: scan,
shellForegroundProven: false,
onShellForegroundProof: proof,
launchAgent: 'claude', launchAgent: 'claude',
data: '' data: ''
}) })
await expect(runtime.isLaunchShellInFront(TRANSCRIPT_PANE_PTY_ID, 'claude')).resolves.toBe( await expect(
shellInFront runtime.readLaunchedAgentForeground(TRANSCRIPT_PANE_PTY_ID, 'claude')
) ).resolves.toBe(found)
expect(scan).toHaveBeenCalledOnce() expect(scan).toHaveBeenCalledOnce()
expect(proof).not.toHaveBeenCalled()
}) })
// The terminal daemon answers the shell-foreground check from its recovery state, which an it('proves nothing on a controller without a fresh scan', async () => {
// agent that exits without leaving a full-screen mode up never sets.
it('an agent that exited: the scan names zsh while the daemon’s shell check says no', async () => {
const proof = vi.fn()
const { runtime } = await createTranscriptPane({ const { runtime } = await createTranscriptPane({
paneTitle: 'copilot', paneTitle: 'Claude Code',
foregroundProcess: 'copilot', foregroundProcess: 'claude',
confirmedForegroundProcess: 'zsh', launchAgent: 'claude',
shellForegroundProven: false, data: ''
onShellForegroundProof: proof, })
await expect(
runtime.readLaunchedAgentForeground(TRANSCRIPT_PANE_PTY_ID, 'claude')
).resolves.toBe('unknown')
})
// Captured with `ps -o pid=,ppid=,pgid=,tpgid=,stat=,command=` on a pane spawned the way a macOS
// pane is, under `login`: zsh holds the terminal in its own group, never the root's.
it('reads a login-wrapped pane’s zsh at its prompt as the shell', async () => {
const rows: ProcessTableRow[] = [
{
pid: 60404,
ppid: 60394,
pgid: 60404,
tpgid: 60406,
stat: 'Ss',
tty: 'ttys004',
startTime: 'Fri Oct 2 01:20:11 2026',
command: '/usr/bin/login -flpq user /bin/bash --noprofile --norc -p -c'
},
{
pid: 60406,
ppid: 60404,
pgid: 60406,
tpgid: 60406,
stat: 'S+',
tty: 'ttys004',
startTime: 'Fri Oct 2 01:20:11 2026',
command: '-/bin/zsh -f'
}
]
const inspection = {
foregroundProcess: 'zsh',
hasChildProcesses: true,
foregroundProcessEvidence: resolveRemoteForegroundEvidence(
{ rootPid: 60404, fallbackProcess: 'zsh' },
{
ptyId: TRANSCRIPT_PANE_PTY_ID,
ptyIncarnationId: 'inc-1',
authorityGeneration: 'gen-1',
observationEpoch: 1,
capturedAgeMs: 0,
platform: 'darwin'
},
rows
)
}
// Presence precondition: the fence is live, and its foreground group is not the root's.
expect(inspection.foregroundProcessEvidence).toMatchObject({
verdict: 'live',
fence: { shellPid: 60404, foregroundPgid: 60406 }
})
const { runtime } = await createTranscriptPane({
paneTitle: 'Terminal',
foregroundProcess: 'python3',
processInspection: inspection,
confirmedForegroundProcess: '-zsh',
launchAgent: 'claude',
data: ''
})
await expect(
runtime.readLaunchedAgentForeground(TRANSCRIPT_PANE_PTY_ID, 'claude')
).resolves.toBe('shell')
})
// A wrapper that does not `exec` an agent the scan cannot recognize keeps its own name in front,
// which reads as a shell: the prompt stays undelivered rather than risk the user's shell.
it('reads a bash wrapper still in front of an unrecognized agent as a shell', async () => {
const { runtime } = await createTranscriptPane({
paneTitle: 'Terminal',
foregroundProcess: 'bash',
confirmedForegroundProcess: 'bash',
launchAgent: 'copilot', launchAgent: 'copilot',
data: '' data: ''
}) })
await expect(runtime.isLaunchShellInFront(TRANSCRIPT_PANE_PTY_ID, 'copilot')).resolves.toBe( await expect(
true runtime.readLaunchedAgentForeground(TRANSCRIPT_PANE_PTY_ID, 'copilot')
) ).resolves.toBe('shell')
expect(proof).not.toHaveBeenCalled()
})
it('takes the cached read on a controller without a scan', async () => {
const { runtime } = await createTranscriptPane({
paneTitle: 'Claude Code',
foregroundProcess: 'zsh',
launchAgent: 'claude',
data: ''
})
await expect(runtime.isLaunchShellInFront(TRANSCRIPT_PANE_PTY_ID, 'claude')).resolves.toBe(
true
)
}) })
}) })
describe('Windows: only the shell-foreground check', () => { // The scan names the pane's shell for an agent it cannot recognize (an npm agent as `node.exe`),
// and Git Bash and WSL keep other processes in the shell's job, so nothing proves the agent.
describe('Windows: only the shell-foreground check, and never the agent', () => {
beforeEach(() => setPlatform('win32')) beforeEach(() => setPlatform('win32'))
// The scan names the pane's shell for an agent it cannot recognize (an npm agent as
// `node.exe`); only the shell alone in the pane's job proves one.
it.each([ it.each([
['powershell.exe', false, false], ['powershell.exe', false, 'unknown'],
['powershell.exe', true, true], ['powershell.exe', true, 'shell'],
['claude', true, true], ['claude', true, 'shell'],
['claude', false, false] ['claude', false, 'unknown'],
])('cached %s, shell check %s: %s', async (foregroundProcess, proven, shellInFront) => { ['node', false, 'unknown']
] as const)('cached %s, shell check %s: %s', async (foregroundProcess, proven, found) => {
const scan = vi.fn() const scan = vi.fn()
const { runtime } = await createTranscriptPane({ const { runtime } = await createTranscriptPane({
paneTitle: 'Claude Code', paneTitle: 'Claude Code',
foregroundProcess, foregroundProcess,
confirmedForegroundProcess: 'powershell.exe', confirmedForegroundProcess: 'claude',
onForegroundScan: scan, onForegroundScan: scan,
shellForegroundProven: proven, shellForegroundProven: proven,
launchAgent: 'claude', launchAgent: 'claude',
data: '' data: ''
}) })
await expect(runtime.isLaunchShellInFront(TRANSCRIPT_PANE_PTY_ID, 'claude')).resolves.toBe( await expect(
shellInFront runtime.readLaunchedAgentForeground(TRANSCRIPT_PANE_PTY_ID, 'claude')
) ).resolves.toBe(found)
expect(scan).not.toHaveBeenCalled() expect(scan).not.toHaveBeenCalled()
}) })
it('proves nothing on a controller without a shell check', async () => {
const { runtime } = await createTranscriptPane({
paneTitle: 'Claude Code',
foregroundProcess: 'powershell.exe',
confirmedForegroundProcess: 'powershell.exe',
launchAgent: 'claude',
data: ''
})
await expect(runtime.isLaunchShellInFront(TRANSCRIPT_PANE_PTY_ID, 'claude')).resolves.toBe(
false
)
})
}) })
// The stubs always disagree with the relay's name, so asking either would flip the answer. // The stubs always disagree with the relay's name, so asking either would flip the answer.
it.each([ it.each([
['claude', false], ['claude', 'agent'],
['bash', true] ['bash', 'shell']
])( ] as const)(
'SSH: takes the relay’s own read %s (shell %s), without a scan or check', 'SSH: takes the relay’s own read %s (%s), without a scan or check',
async (relayRead, shell) => { async (relayRead, found) => {
setPlatform('darwin')
const scan = vi.fn() const scan = vi.fn()
const proof = vi.fn() const proof = vi.fn()
const { runtime } = await createTranscriptPane({ const { runtime } = await createTranscriptPane({
paneTitle: 'Claude Code', paneTitle: 'Claude Code',
foregroundProcess: relayRead, foregroundProcess: relayRead,
confirmedForegroundProcess: shell ? 'claude' : 'zsh', confirmedForegroundProcess: found === 'shell' ? 'claude' : 'zsh',
onForegroundScan: scan, onForegroundScan: scan,
shellForegroundProven: !shell, shellForegroundProven: found !== 'shell',
onShellForegroundProof: proof, onShellForegroundProof: proof,
connectionId: 'ssh-1', connectionId: 'ssh-1',
launchAgent: 'claude', launchAgent: 'claude',
data: '' data: ''
}) })
await expect(runtime.isLaunchShellInFront(TRANSCRIPT_PANE_PTY_ID, 'claude')).resolves.toBe( await expect(
shell runtime.readLaunchedAgentForeground(TRANSCRIPT_PANE_PTY_ID, 'claude')
) ).resolves.toBe(found)
expect(scan).not.toHaveBeenCalled() expect(scan).not.toHaveBeenCalled()
expect(proof).not.toHaveBeenCalled() expect(proof).not.toHaveBeenCalled()
} }
) )
describe('macOS and Linux: by the shell’s identity, from the host’s process inspection', () => {
beforeEach(() => setPlatform('darwin'))
// Captured with `ps -axo pid=,ppid=,pgid=,tpgid=,stat=,tty=,lstart=,command=` on a pty running
// `zsh -f -i` (macOS 26): first while it ran `bash -c 'sleep 4; :'`, then back at its prompt.
const zsh = (tpgid: number, stat: string): ProcessTableRow => ({
pid: 20894,
ppid: 20891,
pgid: 20894,
tpgid,
stat,
tty: 'ttys000',
startTime: 'Wed Sep 30 09:18:50 2026',
command: '/bin/zsh -f -i'
})
const wrapperRunning: ProcessTableRow[] = [
zsh(20996, 'Ss'),
{
pid: 20996,
ppid: 20894,
pgid: 20996,
tpgid: 20996,
stat: 'S+',
tty: 'ttys000',
startTime: 'Wed Sep 30 09:18:52 2026',
command: 'bash -c sleep 4; :'
},
{
pid: 20997,
ppid: 20996,
pgid: 20996,
tpgid: 20996,
stat: 'S+',
tty: 'ttys000',
startTime: 'Wed Sep 30 09:18:52 2026',
command: 'sleep 4'
}
]
const zshAtPrompt: ProcessTableRow[] = [zsh(20894, 'Ss+')]
const inspect = (rows: ProcessTableRow[]) => ({
foregroundProcess: 'zsh',
hasChildProcesses: rows.length > 1,
foregroundProcessEvidence: resolveRemoteForegroundEvidence(
{ rootPid: 20894, fallbackProcess: 'zsh' },
{
ptyId: TRANSCRIPT_PANE_PTY_ID,
ptyIncarnationId: 'inc-1',
authorityGeneration: 'gen-1',
observationEpoch: 1,
capturedAgeMs: 0,
platform: 'darwin'
},
rows
)
})
it.each([
// A wrapper script's bash runs as its own job: named like a shell, but not the pane's shell.
['a bash wrapper under zsh is not the shell', wrapperRunning, 'bash', false, false],
['zsh back at its prompt is the shell', zshAtPrompt, 'zsh', true, true]
] as const)('%s', async (_label, rows, foregroundName, shell, scanned) => {
const inspection = inspect([...rows])
// Presence precondition: the captured rows are a live observation, not an unreadable one.
expect(inspection.foregroundProcessEvidence).toMatchObject({ verdict: 'live' })
const scan = vi.fn()
const { runtime } = await createTranscriptPane({
paneTitle: 'Terminal',
foregroundProcess: foregroundName,
processInspection: inspection,
confirmedForegroundProcess: foregroundName,
onForegroundScan: scan,
launchAgent: 'copilot',
data: ''
})
await expect(runtime.isLaunchShellInFront(TRANSCRIPT_PANE_PTY_ID, 'copilot')).resolves.toBe(
shell
)
expect(scan).toHaveBeenCalledTimes(scanned ? 1 : 0)
})
it('SSH: takes the relay’s inspection the same way', async () => {
const { runtime } = await createTranscriptPane({
paneTitle: 'Terminal',
foregroundProcess: 'bash',
processInspection: inspect([...wrapperRunning]),
connectionId: 'ssh-1',
launchAgent: 'copilot',
data: ''
})
await expect(runtime.isLaunchShellInFront(TRANSCRIPT_PANE_PTY_ID, 'copilot')).resolves.toBe(
false
)
})
it('an agent in the shell’s own group (a shell without job control) is not the shell', async () => {
const { runtime } = await createTranscriptPane({
paneTitle: 'Copilot',
// node-pty names the group's leader, which is the shell itself here.
foregroundProcess: 'zsh',
processInspection: inspect([...zshAtPrompt]),
confirmedForegroundProcess: 'node',
launchAgent: 'copilot',
data: ''
})
await expect(runtime.isLaunchShellInFront(TRANSCRIPT_PANE_PTY_ID, 'copilot')).resolves.toBe(
false
)
})
it('an inspection that cannot observe proves nothing, and falls back to no name', async () => {
const scan = vi.fn()
const { runtime } = await createTranscriptPane({
paneTitle: 'Terminal',
foregroundProcess: 'zsh',
processInspection: inspect([]),
confirmedForegroundProcess: 'zsh',
onForegroundScan: scan,
launchAgent: 'claude',
data: ''
})
await expect(runtime.isLaunchShellInFront(TRANSCRIPT_PANE_PTY_ID, 'claude')).resolves.toBe(
false
)
expect(scan).not.toHaveBeenCalled()
})
})
// Why: a Windows relay names the pane's shell for an agent its scan cannot recognize (node.exe). // Why: a Windows relay names the pane's shell for an agent its scan cannot recognize (node.exe).
it('SSH to a Windows host: the relay’s shell name proves nothing', async () => { it('SSH to a Windows host: never the agent', async () => {
const { runtime } = await createTranscriptPane({ const { runtime } = await createTranscriptPane({
paneTitle: 'Copilot', paneTitle: 'Copilot',
foregroundProcess: 'powershell.exe', foregroundProcess: 'node',
connectionId: 'ssh-1', connectionId: 'ssh-1',
remoteWindowsHost: true, remoteWindowsHost: true,
launchAgent: 'copilot', launchAgent: 'copilot',
data: '' data: ''
}) })
await expect(runtime.isLaunchShellInFront(TRANSCRIPT_PANE_PTY_ID, 'copilot')).resolves.toBe( await expect(
false runtime.readLaunchedAgentForeground(TRANSCRIPT_PANE_PTY_ID, 'copilot')
) ).resolves.toBe('unknown')
}) })
// Why no read: a native Claude names itself by its version, which the cached read gives at once.
it.each([
['darwin', '2.1.285'],
['darwin', 'node'],
['win32', 'node']
] as const)(
'%s: takes the non-shell name %s, other than the agent’s own, at once',
async (platform, foregroundProcess) => {
setPlatform(platform)
const scan = vi.fn()
const proof = vi.fn()
const { runtime } = await createTranscriptPane({
paneTitle: 'Claude Code',
foregroundProcess,
confirmedForegroundProcess: 'zsh',
onForegroundScan: scan,
shellForegroundProven: true,
onShellForegroundProof: proof,
launchAgent: 'claude',
data: ''
})
await expect(runtime.isLaunchShellInFront(TRANSCRIPT_PANE_PTY_ID, 'claude')).resolves.toBe(
false
)
expect(scan).not.toHaveBeenCalled()
expect(proof).not.toHaveBeenCalled()
}
)
}) })
@@ -9,7 +9,8 @@
* a composer from a startup dialog drawn in the same mode, so it counts only while the pane shows no * a composer from a startup dialog drawn in the same mode, so it counts only while the pane shows no
* startup dialog and no Codex provisional header (`readFreshComposerHold`). Unlike the desktop's * startup dialog and no Codex provisional header (`readFreshComposerHold`). Unlike the desktop's
* paste it reads only output after the shell's last `?2004l`, and drops a signal while a shell is * paste it reads only output after the shell's last `?2004l`, and drops a signal while a shell is
* proven in front (`isLaunchShellInFront`). * proven in front (`readLaunchedAgentForeground`). A signal is never proof by itself: a shell back
* at its prompt turns bracketed paste on too, so the write still needs the agent found in front.
* *
* Where the desktop pasted blind once its budget ran out, the host falls back to the `tui-idle` * Where the desktop pasted blind once its budget ran out, the host falls back to the `tui-idle`
* evidence ranking (idle titles, known ready screens), which also reports a dialog left up. A few * evidence ranking (idle titles, known ready screens), which also reports a dialog left up. A few
@@ -0,0 +1,174 @@
/**
* A launched agent that exits at startup hands the terminal back to its shell, which turns bracketed
* paste on at its next prompt just as an agent's composer does. Real zsh with a slow user config,
* spawned the way a pane is (under `login` on macOS), read through the terminal daemon's own
* foreground tracker: the launch must find the shell and write nothing, and must still find a live
* agent.
*/
import { spawnSync } from 'node:child_process'
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import * as pty from 'node-pty'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { recognizeAgentProcess } from '../../shared/agent-process-recognition'
import { getStrictProcessTableSnapshotWithAge } from '../../shared/process-table-snapshot-reader'
import { createPtyForegroundProcessTracker } from '../daemon/pty-subprocess/foreground-process-tracker'
import { resolveRemoteForegroundEvidence } from '../providers/agent-foreground-process'
import {
prepareMacosTccLoginShell,
wrapShellSpawnForMacosTccAttribution
} from '../providers/macos-tcc-login-shell'
import { readLaunchedAgentForeground } from './launched-agent-foreground'
import { createLaunchedAgentWriteGuard } from './launched-agent-write-guard'
import { waitForWorktreeStartupDraft } from './runtime-worktree-startup-readiness'
const PTY_ID = 'pty-1'
const ZSH_PATH =
process.platform === 'win32'
? ''
: (spawnSync('sh', ['-c', 'command -v zsh'], { encoding: 'utf8' }).stdout ?? '').trim()
const describeWithZsh = ZSH_PATH ? describe : describe.skip
const STUBS = {
// Exits at startup, as an agent with a bad config or a missing dependency does.
crash: "#!/bin/sh\nprintf 'stub: crashing at startup\\r\\n'\nexit 1\n",
// Opens its composer the way an agent does, then stays up.
ready: "#!/bin/sh\nprintf '\\033[?2004hstub agent\\r\\n> '\nexec sleep 30\n"
} as const
let home = ''
let savedLoginOptOut: string | undefined
beforeEach(() => {
home = mkdtempSync(join(tmpdir(), 'orca-crash-guard-'))
// The production spawn: a pane on macOS runs under `login` unless the user opted out.
savedLoginOptOut = process.env.ORCA_DISABLE_MACOS_LOGIN_SHELL
delete process.env.ORCA_DISABLE_MACOS_LOGIN_SHELL
})
afterEach(() => {
if (savedLoginOptOut !== undefined) {
process.env.ORCA_DISABLE_MACOS_LOGIN_SHELL = savedLoginOptOut
}
rmSync(home, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
})
/** Launches the `claude` stub in a fresh zsh pane and runs the launch's own readiness wait. */
async function launchStub(stub: keyof typeof STUBS, readyTimeoutMs: number) {
const bin = join(home, 'bin')
mkdirSync(bin)
// Typed by absolute path: `login` resets HOME and PATH, so a bare name would find a real agent.
const stubPath = join(bin, 'claude')
writeFileSync(stubPath, STUBS[stub])
chmodSync(stubPath, 0o755)
writeFileSync(join(home, '.zshrc'), 'sleep 2\n')
const env = { PATH: '/usr/bin:/bin', HOME: home, ZDOTDIR: home, TERM: 'xterm-256color' }
await prepareMacosTccLoginShell()
const spawn = wrapShellSpawnForMacosTccAttribution(ZSH_PATH, ['-i'], env)
const proc = pty.spawn(spawn.file, spawn.args, { cols: 80, rows: 24, cwd: home, env })
let dead = false
proc.onExit(() => {
dead = true
})
const listeners = new Set<(data: string) => void>()
let output = ''
const tracker = createPtyForegroundProcessTracker({
process: proc,
shellPath: ZSH_PATH,
cwd: home,
sessionId: 'wt-crash-guard@@pane',
startupAgentRecognition: recognizeAgentProcess('claude'),
isDead: () => dead
})
proc.onData((data) => {
tracker.recordOutput(data)
output += data
for (const listener of listeners) {
listener(data)
}
})
// The daemon's answers, each from the same pieces the daemon uses.
const controller = {
getForegroundProcess: async () => tracker.getForegroundProcess(),
confirmForegroundProcess: () => tracker.confirmForegroundProcess(),
confirmShellForeground: () => tracker.confirmShellForeground(),
inspectProcess: async () => {
const snapshot = await getStrictProcessTableSnapshotWithAge()
return {
foregroundProcess: tracker.getForegroundProcess(),
hasChildProcesses: true,
foregroundProcessEvidence: resolveRemoteForegroundEvidence(
{ rootPid: proc.pid, fallbackProcess: tracker.getForegroundProcess() },
{
ptyId: PTY_ID,
ptyIncarnationId: 'inc-1',
authorityGeneration: 'gen-1',
observationEpoch: 1,
capturedAgeMs: snapshot.capturedAgeMs,
platform: process.platform
},
snapshot.rows
)
}
}
}
const readForeground = (ptyId: string) =>
readLaunchedAgentForeground(controller, { remote: false, windows: false }, ptyId, 'claude')
const subscribe = (_ptyId: string, listener: (data: string) => void) => {
listeners.add(listener)
return () => listeners.delete(listener)
}
const ready = waitForWorktreeStartupDraft(
{
getPtyId: () => PTY_ID,
getForegroundProcess: controller.getForegroundProcess,
subscribeToData: subscribe,
readRecentOutput: () => undefined,
write: () => {}
},
'term-1',
'claude',
{
timeoutMs: readyTimeoutMs,
isShellInFront: async (ptyId) => (await readForeground(ptyId)) === 'shell'
}
)
proc.write(`'${stubPath}'\r`)
const guard = createLaunchedAgentWriteGuard(
{ readLaunchedAgentForeground: readForeground, subscribeToTerminalData: subscribe },
'claude'
)
const close = async (): Promise<void> => {
guard.dispose()
proc.kill()
await new Promise((resolve) => setTimeout(resolve, 200))
}
return { ready, guard, close, output: () => output }
}
describeWithZsh('a launch prompt after the launched agent exits at startup', () => {
it('drops the shell’s prompt as a ready signal and refuses the write', async () => {
const launch = await launchStub('crash', 9_000)
try {
// The shell's prompt after the crash turned bracketed paste on and went quiet, yet no agent did.
await expect(launch.ready).resolves.toBeNull()
// Presence precondition: the stub, not anything else on the host, is what ran and exited.
expect(launch.output()).toContain('stub: crashing at startup')
await expect(launch.guard.beforeWrite(PTY_ID)).rejects.toThrow('agent_not_in_foreground')
} finally {
await launch.close()
}
}, 30_000)
it('still finds an agent that stays up, and lets the write through', async () => {
const launch = await launchStub('ready', 15_000)
try {
await expect(launch.ready).resolves.toBe(PTY_ID)
expect(launch.output()).toContain('stub agent')
await expect(launch.guard.beforeWrite(PTY_ID)).resolves.toBeUndefined()
} finally {
await launch.close()
}
}, 30_000)
})
@@ -0,0 +1,209 @@
/**
* An agent that exits at startup leaves its shell at a prompt that turns bracketed paste on, which
* is the same signal an agent's composer gives. Driven through the launch's own readiness wait,
* foreground read and write guard, with each host answering the way it does after the exit.
*/
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { deliverTerminalAgentLaunchPrompt } from './rpc/methods/agent-launch-terminal-prompt'
import { readLaunchedAgentForeground } from './launched-agent-foreground'
import { waitForWorktreeStartupDraft } from './runtime-worktree-startup-readiness'
const PTY_ID = 'pty-1'
const SHELL_HANDOFF = '\x1b[?2004l'
/** zsh's prompt, the launch line it runs, then its prompt again once that command exited. */
function zshRunsLaunchLineThatExits(): string[] {
const zsh = readFileSync(join(__dirname, '__fixtures__', 'zsh-prompt-runs-command.txt'), 'utf8')
const split = zsh.indexOf('\n', zsh.indexOf(SHELL_HANDOFF)) + 1
return [zsh.slice(0, split), 'stub: crashing at startup\r\n', zsh.slice(split)]
}
/** Git Bash and WSL's bash: readline turns bracketed paste on at each prompt. */
function bashRunsLaunchLineThatExits(): string[] {
return [
'\x1b[?2004hqa@host:~$ claude\r\n\x1b[?2004l\r',
'stub: crashing at startup\r\n',
'\x1b[?2004hqa@host:~$ '
]
}
type HostAnswers = {
agent?: 'claude' | 'grok'
host: { remote: boolean; windows: boolean }
cached: string | null
scanned: string | null
shellAlone: boolean
}
const HOSTS: [string, HostAnswers, () => string[]][] = [
// The shell keeps other processes in its job, so the shell-alone check never answers.
[
'Windows Git Bash',
{
host: { remote: false, windows: true },
cached: 'claude',
scanned: 'bash',
shellAlone: false
},
bashRunsLaunchLineThatExits
],
// The QA stub: a `grok` override that exits at once, which takes the composer-marker wait.
[
'Windows Git Bash, grok',
{
agent: 'grok',
host: { remote: false, windows: true },
cached: 'node',
scanned: 'bash',
shellAlone: false
},
bashRunsLaunchLineThatExits
],
[
'Windows WSL',
{ host: { remote: false, windows: true }, cached: 'claude', scanned: 'wsl', shellAlone: false },
bashRunsLaunchLineThatExits
],
// A pane under `login`, whose cached name is still the exited stub's.
[
'macOS zsh',
{
host: { remote: false, windows: false },
cached: 'python3',
scanned: 'zsh',
shellAlone: false
},
zshRunsLaunchLineThatExits
]
]
function launchedPane(answers: HostAnswers) {
const listeners = new Set<(data: string) => void>()
const subscribe = (_ptyId: string, listener: (data: string) => void) => {
listeners.add(listener)
return () => listeners.delete(listener)
}
const controller = {
getForegroundProcess: async () => answers.cached,
confirmForegroundProcess: async () => answers.scanned,
confirmShellForeground: async () => answers.shellAlone
}
const readForeground = (ptyId: string) =>
readLaunchedAgentForeground(controller, answers.host, ptyId, answers.agent ?? 'claude')
const writes: string[] = []
const runtime = {
waitForFreshWorkerComposer: async (
_handle: string,
agent: 'claude' | 'grok',
timeoutMs: number
) => {
const ptyId = await waitForWorktreeStartupDraft(
{
getPtyId: () => PTY_ID,
getForegroundProcess: controller.getForegroundProcess,
subscribeToData: subscribe,
readRecentOutput: () => undefined,
write: () => {}
},
'term-1',
agent,
{
timeoutMs,
requireComposerMarker: false,
isShellInFront: async (id) => (await readForeground(id)) === 'shell'
}
)
if (!ptyId) {
throw new Error('timeout')
}
return { handle: 'term-1', satisfied: true, status: 'running' }
},
// The idle evidence after the budget: the shell's prompt can look settled too.
waitForTerminal: vi.fn(async () => ({ handle: 'term-1', satisfied: true, status: 'idle' })),
readLaunchedAgentForeground: (ptyId: string) => readForeground(ptyId),
subscribeToTerminalData: subscribe,
sendTerminalAgentPrompt: vi.fn(
async (
handle: string,
text: string,
options: { beforeWrite?: (id: string) => Promise<void> }
) => {
await options.beforeWrite?.(PTY_ID)
writes.push(text)
return { handle, accepted: true, bytesWritten: text.length }
}
)
}
return {
runtime,
writes,
emit: (data: string) => {
for (const listener of listeners) {
listener(data)
}
}
}
}
describe('a launch prompt after the launched agent exits at startup', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.spyOn(console, 'warn').mockImplementation(() => {})
})
afterEach(() => {
vi.useRealTimers()
vi.restoreAllMocks()
})
it.each(HOSTS)(
'%s: types nothing and reports it undelivered',
async (_label, answers, transcript) => {
const pane = launchedPane(answers)
const delivery = deliverTerminalAgentLaunchPrompt({
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the deliverer reaches only the methods built above.
runtime: pane.runtime as unknown as Parameters<
typeof deliverTerminalAgentLaunchPrompt
>[0]['runtime'],
handle: 'term-1',
agent: answers.agent ?? 'claude',
freshLaunch: true,
text: 'QA prompt that must never run as a shell command'
})
for (const chunk of transcript()) {
pane.emit(chunk)
}
await vi.advanceTimersByTimeAsync(61_000)
await expect(delivery).resolves.toBe(false)
expect(pane.writes).toEqual([])
}
)
it('macOS zsh: still writes into an agent that stays up', async () => {
const pane = launchedPane({
host: { remote: false, windows: false },
cached: 'claude',
scanned: 'claude',
shellAlone: false
})
const delivery = deliverTerminalAgentLaunchPrompt({
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the deliverer reaches only the methods built above.
runtime: pane.runtime as unknown as Parameters<
typeof deliverTerminalAgentLaunchPrompt
>[0]['runtime'],
handle: 'term-1',
agent: 'claude',
freshLaunch: true,
text: 'QA prompt'
})
pane.emit(zshRunsLaunchLineThatExits()[0])
pane.emit('\x1b[?2004hClaude Code\r\n> ')
await vi.advanceTimersByTimeAsync(2_000)
await expect(delivery).resolves.toBe(true)
expect(pane.writes).toEqual(['QA prompt'])
})
})
@@ -0,0 +1,60 @@
import { isExpectedAgentProcess } from '../../shared/agent-process-recognition'
import { isShellProcess } from '../../shared/shell-process-detection'
import type { TuiAgent } from '../../shared/tui-agent'
import { TUI_AGENT_CONFIG } from '../../shared/tui-agent-config'
import type { RuntimePtyController } from './runtime-pty-controller-contract'
/**
* What holds a launched agent's terminal: the agent (anything but the pane's shell), the shell
* (the launch line has not run yet, or the agent exited), or `unknown` when the host cannot tell.
* Only `agent` lets a launch write its prompt; only `shell` drops a ready signal.
*/
export type LaunchedAgentForeground = 'agent' | 'shell' | 'unknown'
/** A login shell is reported as `-zsh`. */
function isLaunchShell(processName: string): boolean {
return isShellProcess(processName.replace(/^-/, ''))
}
/**
* One fresh read of the terminal's foreground process: on a local POSIX host the scan behind
* `confirmForegroundProcess`, and on an SSH host the relay's name, which it reads from the terminal
* when asked. Never the cached name a tab icon uses: it can still name a process that already
* exited. Not the process-group fence either: a macOS pane runs its shell under `login`, so the
* fence's root is never the shell's group.
*
* Windows has no foreground process group, and its scan names the pane's shell for an agent it
* cannot recognize, while Git Bash and WSL keep other processes in the shell's job, so nothing
* there proves the agent. A Windows host can still prove its shell alone (`confirmShellForeground`).
*/
export async function readLaunchedAgentForeground(
controller: Pick<
RuntimePtyController,
'getForegroundProcess' | 'confirmForegroundProcess' | 'confirmShellForeground'
> | null,
host: { remote: boolean; windows: boolean },
ptyId: string,
agent: TuiAgent
): Promise<LaunchedAgentForeground> {
if (!controller) {
return 'unknown'
}
try {
if (host.windows) {
// An SSH pane has no such check, so on a Windows relay nothing proves a shell.
return (await controller.confirmShellForeground?.(ptyId)) ? 'shell' : 'unknown'
}
const foreground = host.remote
? await controller.getForegroundProcess(ptyId)
: ((await controller.confirmForegroundProcess?.(ptyId)) ?? null)
if (!foreground) {
return 'unknown'
}
return isLaunchShell(foreground) &&
!isExpectedAgentProcess(foreground, TUI_AGENT_CONFIG[agent].expectedProcess)
? 'shell'
: 'agent'
} catch {
return 'unknown'
}
}
@@ -1,92 +0,0 @@
import {
isExpectedAgentProcess,
recognizeAgentProcess
} from '../../shared/agent-process-recognition'
import { isShellProcess } from '../../shared/shell-process-detection'
import type { TuiAgent } from '../../shared/tui-agent'
import { TUI_AGENT_CONFIG } from '../../shared/tui-agent-config'
import type { RuntimePtyController } from './runtime-pty-controller-contract'
/** A login shell is reported as `-zsh`. */
function isLaunchShell(processName: string): boolean {
return isShellProcess(processName.replace(/^-/, ''))
}
/**
* Whether the pane's own shell, rather than the agent a launch started, is proven to be in the
* terminal's foreground: the launch line has not run yet, or the agent exited. A read that fails or
* cannot tell proves nothing, so it never delays a launch.
*
* On macOS and Linux identity decides first: the host's process inspection reports the terminal's
* foreground process group, and the pane's shell can be in front only when that group is the
* shell's own. A wrapper script's bash, or a conda hook, runs as its own job, so it is not the
* pane's shell even though it is named like one. A fresh read must then also name a shell.
*
* Windows has no foreground process group, and its scan names the pane's shell for any agent it
* cannot recognize (an npm agent running as `node.exe`), so it takes only the host's
* shell-foreground check (the shell alone in the pane's job). A daemon pane answers that from its
* recovery state, which a plain exit leaves unset, and an SSH pane has none, so there it proves
* nothing.
*
* Why no check for a non-shell name other than the agent's own: for its first 5 s the daemon's
* cached read names the launch agent whenever a shell is in front (measured on a zsh pane: cached
* `copilot`, scan `zsh`), so any other non-shell name it gives is the process itself. macOS
* reports the native Claude by its version (`2.1.258`), and a runtime such as node can front an
* agent.
*/
export async function isShellInFrontOfLaunchedAgent(
controller: Pick<
RuntimePtyController,
| 'getForegroundProcess'
| 'inspectProcess'
| 'confirmForegroundProcess'
| 'confirmShellForeground'
> | null,
host: { remote: boolean; windows: boolean },
ptyId: string,
agent: TuiAgent
): Promise<boolean> {
if (!controller) {
return false
}
try {
const cached = await controller.getForegroundProcess(ptyId)
if (cached && !isLaunchShell(cached) && recognizeAgentProcess(cached)?.agent !== agent) {
return false
}
if (host.windows) {
// An SSH pane has no such check, so on a Windows relay nothing proves a shell.
return (await controller.confirmShellForeground?.(ptyId)) ?? false
}
const evidence = (await controller.inspectProcess?.(ptyId))?.foregroundProcessEvidence
if (
evidence &&
!(
evidence.verdict === 'live' &&
evidence.fence.platform === 'posix' &&
evidence.fence.foregroundPgid === evidence.fence.shellPid
)
) {
return false
}
// Why the name too: a shell without job control runs its commands in its own group.
// A host that predates the evidence (an older daemon or relay, or a pane outside the daemon)
// has only the name, which reads a wrapper script's bash as the shell. A relay's cached name is
// read live, and it has no scan.
const foreground =
!host.remote && controller.confirmForegroundProcess
? await controller.confirmForegroundProcess(ptyId)
: cached
return isShellName(foreground, agent)
} catch {
return false
}
}
function isShellName(processName: string | null, agent: TuiAgent): boolean {
return (
!!processName &&
isLaunchShell(processName) &&
!isExpectedAgentProcess(processName, TUI_AGENT_CONFIG[agent].expectedProcess)
)
}
@@ -1,17 +1,20 @@
import { describe, expect, it, vi } from 'vitest' import { describe, expect, it, vi } from 'vitest'
import type { LaunchedAgentForeground } from './launched-agent-foreground'
import { import {
createLaunchedAgentWriteGuard, createLaunchedAgentWriteGuard,
type LaunchedAgentWriteGuardRuntime type LaunchedAgentWriteGuardRuntime
} from './launched-agent-write-guard' } from './launched-agent-write-guard'
function guardHarness(shellInFront: boolean[]) { function guardHarness(foregrounds: LaunchedAgentForeground[]) {
const answers = [...shellInFront] const answers = [...foregrounds]
let listener: ((data: string) => void) | null = null let listener: ((data: string) => void) | null = null
const unsubscribe = vi.fn(() => { const unsubscribe = vi.fn(() => {
listener = null listener = null
}) })
const runtime: LaunchedAgentWriteGuardRuntime = { const runtime: LaunchedAgentWriteGuardRuntime = {
isLaunchShellInFront: vi.fn(async () => answers.shift() ?? false), readLaunchedAgentForeground: vi.fn(
async (): Promise<LaunchedAgentForeground> => answers.shift() ?? 'agent'
),
subscribeToTerminalData: vi.fn((_ptyId: string, next: (data: string) => void) => { subscribeToTerminalData: vi.fn((_ptyId: string, next: (data: string) => void) => {
listener = next listener = next
return unsubscribe return unsubscribe
@@ -22,23 +25,35 @@ function guardHarness(shellInFront: boolean[]) {
} }
describe('the check before each write of a launch prompt', () => { describe('the check before each write of a launch prompt', () => {
it('reuses a read that found no shell for the writes after it', async () => { it('reuses a read that found the agent for the writes after it', async () => {
const { runtime, guard } = guardHarness([false]) const { runtime, guard } = guardHarness(['agent'])
await guard.beforeWrite('pty-1') await guard.beforeWrite('pty-1')
// The agent's own output after the paste says nothing about a shell. // The agent's own output after the paste says nothing about a shell.
await guard.beforeWrite('pty-1') await guard.beforeWrite('pty-1')
await guard.beforeWrite('pty-1') await guard.beforeWrite('pty-1')
expect(runtime.isLaunchShellInFront).toHaveBeenCalledTimes(1) expect(runtime.readLaunchedAgentForeground).toHaveBeenCalledTimes(1)
}) })
// Why: a ready signal can come from a shell whose agent exited, so only a read that finds the
// agent may let the text through; an unanswered read is not that.
it.each(['shell', 'unknown'] as const)(
'refuses a write when the read finds %s',
async (found) => {
const { guard, unsubscribe } = guardHarness([found])
await expect(guard.beforeWrite('pty-1')).rejects.toThrow('agent_not_in_foreground')
expect(unsubscribe).toHaveBeenCalledTimes(1)
}
)
it.each([ it.each([
['a shell turning bracketed paste on', '$ \x1b[?2004h'], ['a shell turning bracketed paste on', '$ \x1b[?2004h'],
['a shell integration prompt mark', '\x1b]133;D;1\x07\x1b]133;A\x07$ '], ['a shell integration prompt mark', '\x1b]133;D;1\x07\x1b]133;A\x07$ '],
['a mark split across two chunks', ['\x1b[?20', '04h']] ['a mark split across two chunks', ['\x1b[?20', '04h']]
] as const)('reads again after %s, and refuses a shell', async (_label, output) => { ] as const)('reads again after %s, and refuses a shell', async (_label, output) => {
const { runtime, guard, emit } = guardHarness([false, true]) const { runtime, guard, emit } = guardHarness(['agent', 'shell'])
await guard.beforeWrite('pty-1') await guard.beforeWrite('pty-1')
for (const chunk of typeof output === 'string' ? [output] : output) { for (const chunk of typeof output === 'string' ? [output] : output) {
@@ -46,17 +61,17 @@ describe('the check before each write of a launch prompt', () => {
} }
await expect(guard.beforeWrite('pty-1')).rejects.toThrow('agent_not_in_foreground') await expect(guard.beforeWrite('pty-1')).rejects.toThrow('agent_not_in_foreground')
expect(runtime.isLaunchShellInFront).toHaveBeenCalledTimes(2) expect(runtime.readLaunchedAgentForeground).toHaveBeenCalledTimes(2)
}) })
it('catches a shell that returns while the first read is still running', async () => { it('catches a shell that returns while the first read is still running', async () => {
const { runtime, guard, emit } = guardHarness([]) const { runtime, guard, emit } = guardHarness([])
vi.mocked(runtime.isLaunchShellInFront) vi.mocked(runtime.readLaunchedAgentForeground)
.mockImplementationOnce(async () => { .mockImplementationOnce(async () => {
emit('\x1b[?2004h') emit('\x1b[?2004h')
return false return 'agent'
}) })
.mockResolvedValueOnce(true) .mockResolvedValueOnce('shell')
await guard.beforeWrite('pty-1') await guard.beforeWrite('pty-1')
@@ -64,11 +79,11 @@ describe('the check before each write of a launch prompt', () => {
}) })
it('stops watching when it refuses and when it is disposed', async () => { it('stops watching when it refuses and when it is disposed', async () => {
const refused = guardHarness([true]) const refused = guardHarness(['shell'])
await expect(refused.guard.beforeWrite('pty-1')).rejects.toThrow('agent_not_in_foreground') await expect(refused.guard.beforeWrite('pty-1')).rejects.toThrow('agent_not_in_foreground')
expect(refused.unsubscribe).toHaveBeenCalledTimes(1) expect(refused.unsubscribe).toHaveBeenCalledTimes(1)
const cleared = guardHarness([false]) const cleared = guardHarness(['agent'])
await cleared.guard.beforeWrite('pty-1') await cleared.guard.beforeWrite('pty-1')
cleared.guard.dispose() cleared.guard.dispose()
expect(cleared.unsubscribe).toHaveBeenCalledTimes(1) expect(cleared.unsubscribe).toHaveBeenCalledTimes(1)
@@ -1,5 +1,6 @@
import type { TuiAgent } from '../../shared/tui-agent' import type { TuiAgent } from '../../shared/tui-agent'
import type { OrcaRuntimeService } from './orca-runtime' import type { OrcaRuntimeService } from './orca-runtime'
import type { LaunchedAgentForeground } from './launched-agent-foreground'
/** A shell back at its prompt turns bracketed paste on, and Orca's shell integration marks it. */ /** A shell back at its prompt turns bracketed paste on, and Orca's shell integration marks it. */
const SHELL_RETURN_MARKERS = ['\x1b[?2004', '\x1b]133;'] as const const SHELL_RETURN_MARKERS = ['\x1b[?2004', '\x1b]133;'] as const
@@ -7,14 +8,15 @@ const MARKER_CARRY_CHARS = Math.max(...SHELL_RETURN_MARKERS.map((marker) => mark
export type LaunchedAgentWriteGuardRuntime = Pick< export type LaunchedAgentWriteGuardRuntime = Pick<
OrcaRuntimeService, OrcaRuntimeService,
'isLaunchShellInFront' | 'subscribeToTerminalData' 'readLaunchedAgentForeground' | 'subscribeToTerminalData'
> >
/** /**
* The check before each write of a launch prompt: the paste, its Enter, and Codex's second Enter. * The check before each write of a launch prompt: the paste, its Enter, and Codex's second Enter.
* A shell proven in front refuses the write. Once a read finds none, later writes reuse it until the * A write needs a fresh read that finds the agent in front; a shell, or a host that cannot tell,
* terminal shows a shell coming back to its prompt, so Enter follows the paste on the desktop's * refuses it, since a ready signal alone can come from a shell back at its prompt. Once a read finds
* timing instead of waiting out another process read. * the agent, later writes reuse it until the terminal shows a shell coming back to its prompt, so
* Enter follows the paste on the desktop's timing instead of waiting out another process read.
*/ */
export function createLaunchedAgentWriteGuard( export function createLaunchedAgentWriteGuard(
runtime: LaunchedAgentWriteGuardRuntime, runtime: LaunchedAgentWriteGuardRuntime,
@@ -41,14 +43,14 @@ export function createLaunchedAgentWriteGuard(
watch.shellMayHaveReturned = true watch.shellMayHaveReturned = true
} }
}) })
let shellInFront: boolean let foreground: LaunchedAgentForeground
try { try {
shellInFront = await runtime.isLaunchShellInFront(ptyId, agent) foreground = await runtime.readLaunchedAgentForeground(ptyId, agent)
} catch (error) { } catch (error) {
watch.unsubscribe() watch.unsubscribe()
throw error throw error
} }
if (shellInFront) { if (foreground !== 'agent') {
watch.unsubscribe() watch.unsubscribe()
throw new Error('agent_not_in_foreground') throw new Error('agent_not_in_foreground')
} }
@@ -33,7 +33,10 @@ import type { CreateWorktreeResult } from '../../shared/worktree/create-types'
import { provisionWorktreeTerminals } from './runtime-worktree-terminal-provisioning' import { provisionWorktreeTerminals } from './runtime-worktree-terminal-provisioning'
import { readFreshComposerHold } from './launched-agent-composer-readiness' import { readFreshComposerHold } from './launched-agent-composer-readiness'
import { buildTerminalWaitText } from './terminal-wait-tail-state' import { buildTerminalWaitText } from './terminal-wait-tail-state'
import { isShellInFrontOfLaunchedAgent } from './launched-agent-shell-foreground' import {
readLaunchedAgentForeground,
type LaunchedAgentForeground
} from './launched-agent-foreground'
export class OrcaRuntimeWithActivateManagedWorktree extends OrcaRuntimeWithListManagedWorktrees { export class OrcaRuntimeWithActivateManagedWorktree extends OrcaRuntimeWithListManagedWorktrees {
async activateManagedWorktree( async activateManagedWorktree(
@@ -208,7 +211,8 @@ export class OrcaRuntimeWithActivateManagedWorktree extends OrcaRuntimeWithListM
timeoutMs, timeoutMs,
requireComposerMarker, requireComposerMarker,
signal: stop.signal, signal: stop.signal,
isShellInFront: (ownerPtyId) => this.isLaunchShellInFront(ownerPtyId, agent), isShellInFront: async (ownerPtyId) =>
(await this.readLaunchedAgentForeground(ownerPtyId, agent)) === 'shell',
accept: (readyPtyId) => { accept: (readyPtyId) => {
const pty = this.ptysById.get(readyPtyId) const pty = this.ptysById.get(readyPtyId)
const hold = pty const hold = pty
@@ -234,12 +238,17 @@ export class OrcaRuntimeWithActivateManagedWorktree extends OrcaRuntimeWithListM
return this.buildTuiIdleProbeResult(handle, null) return this.buildTuiIdleProbeResult(handle, null)
} }
/** Whether a shell, not the agent a launch started, is proven to be in the terminal's foreground. */ /** What holds the terminal a launch started its agent in, read fresh from the execution host. */
isLaunchShellInFront(ptyId: string, agent: TuiAgent): Promise<boolean> { readLaunchedAgentForeground(ptyId: string, agent: TuiAgent): Promise<LaunchedAgentForeground> {
const pty = this.ptysById.get(ptyId) const pty = this.ptysById.get(ptyId)
return isShellInFrontOfLaunchedAgent( const remote = !!pty?.connectionId
return readLaunchedAgentForeground(
this.ptyController, this.ptyController,
{ remote: !!pty?.connectionId, windows: this.pathFlavorForPty(pty) === 'win32' }, // A local WSL pane still runs on a Windows host, whose process reads cannot see into it.
{
remote,
windows: remote ? this.pathFlavorForPty(pty) === 'win32' : process.platform === 'win32'
},
ptyId, ptyId,
agent agent
) )
@@ -21,7 +21,10 @@ import { resolveLocalProjectRuntimeForWorktreeId } from '../local-project-runtim
import { resolveBareAgentLaunchCommand } from './runtime-agent-launch-resolution' import { resolveBareAgentLaunchCommand } from './runtime-agent-launch-resolution'
import { agentPromptRidesLaunchCommand } from '../../shared/tui-agent-startup' import { agentPromptRidesLaunchCommand } from '../../shared/tui-agent-startup'
import { planStartupWithPromptCandidate } from '../../shared/startup-line-prompt-carry' import { planStartupWithPromptCandidate } from '../../shared/startup-line-prompt-carry'
import { nameLocalTypedLineShell } from './agent-launch-typed-line-shell' import {
launchHostProvesAgentInFront,
nameLocalTypedLineShell
} from './agent-launch-typed-line-shell'
import { resolveAgentStartupPlanInputs } from '../../shared/agent-startup-plan-inputs' import { resolveAgentStartupPlanInputs } from '../../shared/agent-startup-plan-inputs'
import { agentStartedTelemetry } from '../agent-launch/agent-started-telemetry' import { agentStartedTelemetry } from '../agent-launch/agent-started-telemetry'
import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
@@ -273,13 +276,16 @@ export class OrcaRuntimeWithResolveWorktreeRemovalTarget extends OrcaRuntimeWith
sessionOptions: this.toAgentSessionOptions(opts.launchPreferences) sessionOptions: this.toAgentSessionOptions(opts.launchPreferences)
}), }),
opts.startupPrompt ?? '', opts.startupPrompt ?? '',
nameLocalTypedLineShell({ {
isRemote, shellName: nameLocalTypedLineShell({
...(opts.shellOverride ? { shellOverride: opts.shellOverride } : {}), isRemote,
...(settings.terminalDefaultShell ...(opts.shellOverride ? { shellOverride: opts.shellOverride } : {}),
? { defaultShellSetting: settings.terminalDefaultShell } ...(settings.terminalDefaultShell
: {}) ? { defaultShellSetting: settings.terminalDefaultShell }
}) : {})
}),
provesAgentInFront: launchHostProvesAgentInFront({ isRemote, launchPlatform: platform })
}
) )
if (!startupPlan) { if (!startupPlan) {
// Why: an explicit agent that yields no plan would otherwise spawn a bare // Why: an explicit agent that yields no plan would otherwise spawn a bare
@@ -26,8 +26,8 @@ function runtimeStub(overrides: {
send?: SendFn send?: SendFn
/** Whether the agent's composer signal fires within its budget; else the idle evidence decides. */ /** Whether the agent's composer signal fires within its budget; else the idle evidence decides. */
composerSignal?: boolean composerSignal?: boolean
/** Whether a shell is proven in the terminal's foreground. */ /** What a fresh read finds in the terminal's foreground; default: the agent. */
shellInFront?: boolean foreground?: 'agent' | 'shell' | 'unknown'
}) { }) {
const queued = [...(overrides.waits ?? [])] const queued = [...(overrides.waits ?? [])]
const waitForTerminal = vi.fn( const waitForTerminal = vi.fn(
@@ -40,7 +40,7 @@ function runtimeStub(overrides: {
} }
return COMPOSER_READY return COMPOSER_READY
}) })
const isLaunchShellInFront = vi.fn(async () => overrides.shellInFront ?? false) const readLaunchedAgentForeground = vi.fn(async () => overrides.foreground ?? 'agent')
const subscribeToTerminalData = vi.fn(() => () => {}) const subscribeToTerminalData = vi.fn(() => () => {})
const sendTerminalAgentPrompt = vi.fn<SendFn>( const sendTerminalAgentPrompt = vi.fn<SendFn>(
overrides.send ?? (async () => ({ handle: 'term_1', accepted: true, bytesWritten: 12 })) overrides.send ?? (async () => ({ handle: 'term_1', accepted: true, bytesWritten: 12 }))
@@ -49,13 +49,13 @@ function runtimeStub(overrides: {
waitForTerminal, waitForTerminal,
waitForFreshWorkerComposer, waitForFreshWorkerComposer,
sendTerminalAgentPrompt, sendTerminalAgentPrompt,
isLaunchShellInFront, readLaunchedAgentForeground,
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the deliverer reaches exactly these five runtime methods; anything else would throw rather than read a wrong value. // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the deliverer reaches exactly these five runtime methods; anything else would throw rather than read a wrong value.
runtime: { runtime: {
waitForTerminal, waitForTerminal,
waitForFreshWorkerComposer, waitForFreshWorkerComposer,
sendTerminalAgentPrompt, sendTerminalAgentPrompt,
isLaunchShellInFront, readLaunchedAgentForeground,
subscribeToTerminalData subscribeToTerminalData
} as unknown as Parameters<typeof deliverTerminalAgentLaunchPrompt>[0]['runtime'] } as unknown as Parameters<typeof deliverTerminalAgentLaunchPrompt>[0]['runtime']
} }
@@ -228,12 +228,13 @@ describe('writing a launch prompt into a terminal agent', () => {
}) })
it.each([ it.each([
['refuses the write when the shell is back in front', true, false], ['writes when a fresh read finds the agent in front', 'agent', true],
// An unread foreground is no proof the agent exited; the composer was just seen ready. ['refuses the write when the shell is back in front', 'shell', false],
['writes unless a shell is proven in front', false, true] // A ready signal can be a shell's own prompt, so a read that cannot tell proves nothing.
])('%s', async (_label, shellInFront, delivered) => { ['refuses the write when the host cannot tell what is in front', 'unknown', false]
] as const)('%s', async (_label, foreground, delivered) => {
// An agent that exits at startup hands its shell back, which must never run the prompt. // An agent that exits at startup hands its shell back, which must never run the prompt.
const stub = runtimeStub({ composerSignal: true, shellInFront }) const stub = runtimeStub({ composerSignal: true, foreground })
stub.sendTerminalAgentPrompt.mockImplementation(async (handle, _text, options) => { stub.sendTerminalAgentPrompt.mockImplementation(async (handle, _text, options) => {
const { beforeWrite } = options const { beforeWrite } = options
if (typeof beforeWrite === 'function') { if (typeof beforeWrite === 'function') {
@@ -251,7 +252,7 @@ describe('writing a launch prompt into a terminal agent', () => {
text: 'do the thing' text: 'do the thing'
}) })
).resolves.toBe(delivered) ).resolves.toBe(delivered)
expect(stub.isLaunchShellInFront).toHaveBeenCalledWith('pty-1', 'claude') expect(stub.readLaunchedAgentForeground).toHaveBeenCalledWith('pty-1', 'claude')
}) })
it('checks the foreground once for the paste, its Enter and the second Enter', async () => { it('checks the foreground once for the paste, its Enter and the second Enter', async () => {
@@ -275,7 +276,7 @@ describe('writing a launch prompt into a terminal agent', () => {
text: 'do the thing' text: 'do the thing'
}) })
).resolves.toBe(true) ).resolves.toBe(true)
expect(stub.isLaunchShellInFront).toHaveBeenCalledTimes(1) expect(stub.readLaunchedAgentForeground).toHaveBeenCalledTimes(1)
}) })
it('writes as soon as the composer signal fires, without consulting the idle evidence', async () => { it('writes as soon as the composer signal fires, without consulting the idle evidence', async () => {
@@ -381,6 +382,27 @@ describe('writing a launch prompt into a terminal agent', () => {
// A reused pane's render state is only inferred, so its Enter still waits for the render. // A reused pane's render state is only inferred, so its Enter still waits for the render.
expect(stub.sendTerminalAgentPrompt.mock.calls[0]?.[2]?.composerReady).toBe(false) expect(stub.sendTerminalAgentPrompt.mock.calls[0]?.[2]?.composerReady).toBe(false)
}) })
it('writes nothing into a reused terminal whose agent is no longer in front', async () => {
const stub = runtimeStub({ foreground: 'shell' })
stub.sendTerminalAgentPrompt.mockImplementation(async (handle, _text, options) => {
const { beforeWrite } = options
if (typeof beforeWrite === 'function') {
await beforeWrite('pty-1')
}
return { handle, accepted: true, bytesWritten: 12 }
})
await expect(
deliverTerminalAgentLaunchPrompt({
runtime: stub.runtime,
handle: 'term_existing',
agent: 'claude',
freshLaunch: false,
text: 'do the thing'
})
).resolves.toBe(false)
})
}) })
function fakeClock() { function fakeClock() {
@@ -107,9 +107,9 @@ export async function deliverTerminalAgentLaunchPrompt(args: {
if (args.text.trim().length === 0) { if (args.text.trim().length === 0) {
return false return false
} }
// Before the paste and again before Enter: an agent that exited in between leaves its shell in // Before the paste and again before Enter, for a reused pane too: a ready signal can come from a
// the foreground, which must never receive the prompt or its Enter. // shell whose agent exited, so only a read that finds the agent in front lets the text through.
const guard = args.freshLaunch ? createLaunchedAgentWriteGuard(args.runtime, args.agent) : null const guard = createLaunchedAgentWriteGuard(args.runtime, args.agent)
try { try {
const wait = await waitThroughBlockingPrompts( const wait = await waitThroughBlockingPrompts(
args.runtime, args.runtime,
@@ -130,7 +130,7 @@ export async function deliverTerminalAgentLaunchPrompt(args: {
inputKind: 'launch', inputKind: 'launch',
// A fresh launch's composer was just seen ready; a reused pane's state is only inferred. // A fresh launch's composer was just seen ready; a reused pane's state is only inferred.
composerReady: args.freshLaunch, composerReady: args.freshLaunch,
...(guard ? { beforeWrite: guard.beforeWrite } : {}), beforeWrite: guard.beforeWrite,
// Paired: together these take the queued path, which settles an unobserved turn start into // Paired: together these take the queued path, which settles an unobserved turn start into
// an `input_accepted` receipt rather than raising it. Without the id the write is verified // an `input_accepted` receipt rather than raising it. Without the id the write is verified
// strictly and a slow first turn throws. // strictly and a slow first turn throws.
@@ -151,6 +151,6 @@ export async function deliverTerminalAgentLaunchPrompt(args: {
) )
return false return false
} finally { } finally {
guard?.dispose() guard.dispose()
} }
} }
@@ -10,7 +10,10 @@ import { isTuiAgentEnabled, pickTuiAgent } from '../../shared/tui-agent-selectio
import { resolveAgentStartupPlanInputs } from '../../shared/agent-startup-plan-inputs' import { resolveAgentStartupPlanInputs } from '../../shared/agent-startup-plan-inputs'
import { buildAgentDraftLaunchPlan, buildAgentStartupPlan } from '../../shared/tui-agent-startup' import { buildAgentDraftLaunchPlan, buildAgentStartupPlan } from '../../shared/tui-agent-startup'
import { planStartupWithPromptCandidate } from '../../shared/startup-line-prompt-carry' import { planStartupWithPromptCandidate } from '../../shared/startup-line-prompt-carry'
import { nameLocalTypedLineShell } from './agent-launch-typed-line-shell' import {
launchHostProvesAgentInFront,
nameLocalTypedLineShell
} from './agent-launch-typed-line-shell'
import { import {
detectInstalledAgentsWithShellPathHydration, detectInstalledAgentsWithShellPathHydration,
detectRemoteAgents detectRemoteAgents
@@ -153,16 +156,18 @@ export function buildWorktreeStartupForAgent(
const prompt = environment.prompt ?? '' const prompt = environment.prompt ?? ''
let startupPlan: ReturnType<typeof buildAgentStartupPlan> let startupPlan: ReturnType<typeof buildAgentStartupPlan>
if (environment.onPromptCarry) { if (environment.onPromptCarry) {
const offered = planStartupWithPromptCandidate( const offered = planStartupWithPromptCandidate(planInputs, prompt, {
planInputs, shellName: nameLocalTypedLineShell({
prompt,
nameLocalTypedLineShell({
isRemote: repoIsRemote(repo), isRemote: repoIsRemote(repo),
...(settings.terminalDefaultShell ...(settings.terminalDefaultShell
? { defaultShellSetting: settings.terminalDefaultShell } ? { defaultShellSetting: settings.terminalDefaultShell }
: {}) : {})
}),
provesAgentInFront: launchHostProvesAgentInFront({
isRemote: repoIsRemote(repo),
launchPlatform: environment.getLaunchPlatform()
}) })
) })
startupPlan = offered.plan startupPlan = offered.plan
if (startupPlan && prompt.trim()) { if (startupPlan && prompt.trim()) {
environment.onPromptCarry(offered.promptCarried) environment.onPromptCarry(offered.promptCarried)
+29 -3
View File
@@ -12,16 +12,24 @@ import { AGENT_LAUNCH_PROMPT_CARRY_RUNTIME_CAPABILITY } from './agent-launch-run
function offer( function offer(
agent: TuiAgent, agent: TuiAgent,
prompt: string, prompt: string,
extra: { cmdOverride?: string; shellName?: string | undefined } = {} extra: {
cmdOverride?: string
shellName?: string | undefined
platform?: NodeJS.Platform
provesAgentInFront?: boolean
} = {}
) { ) {
return planStartupWithPromptCandidate( return planStartupWithPromptCandidate(
{ {
agent, agent,
cmdOverrides: extra.cmdOverride ? { [agent]: extra.cmdOverride } : {}, cmdOverrides: extra.cmdOverride ? { [agent]: extra.cmdOverride } : {},
platform: 'darwin' platform: extra.platform ?? 'darwin'
}, },
prompt, prompt,
extra.shellName {
...(extra.shellName ? { shellName: extra.shellName } : {}),
provesAgentInFront: extra.provesAgentInFront ?? true
}
) )
} }
@@ -173,6 +181,24 @@ describe('a multi-line prompt typed into a shell the host names', () => {
}) })
}) })
// Why: a host that cannot prove the agent took the terminal could paste into the shell of one that
// exited, so there the line carries the prompt whatever its size, as it did before.
describe('on a host that cannot prove the launched agent is in front', () => {
it.each([
['a long single line', 'x'.repeat(TYPED_STARTUP_LINE_PROMPT_BUDGET_BYTES * 4)],
['a multi-line prompt', linesOf(5, 40)]
])('carries %s on the launch line', (_label, prompt) => {
const { plan, promptCarried } = offer('claude', prompt, {
platform: 'win32',
provesAgentInFront: false
})
expect(promptCarried).toBe(true)
expect(plan?.launchCommand).toContain(prompt.split('\n')[0])
// Control: the same prompt is pasted where the host can prove the agent.
expect(offer('claude', prompt, { platform: 'win32' }).promptCarried).toBe(false)
})
})
describe('the capability clients gate a prompted launch on', () => { describe('the capability clients gate a prompted launch on', () => {
it('is advertised by every host that applies the typed-line rule', () => { it('is advertised by every host that applies the typed-line rule', () => {
// An older host folds any prompt into the typed line, so its absence is the gate. // An older host folds any prompt into the typed line, so its absence is the gate.
+12 -3
View File
@@ -1,6 +1,7 @@
/** /**
* Whether a launch prompt rides the command line that gets TYPED into the user's shell, or the agent * Whether a launch prompt rides the command line that gets TYPED into the user's shell, or the agent
* starts clean and the prompt is pasted once it is ready. * starts clean and the prompt is pasted once it is ready. A paste needs a host that can prove the
* agent holds its terminal (`launched-agent-foreground`); elsewhere the line carries it, as on main.
* *
* Measured on the built line, not the raw prompt: quoting, the launcher, its arguments and session * Measured on the built line, not the raw prompt: quoting, the launcher, its arguments and session
* options all land on that line, and every failure a long or multi-line typed line has is a property * options all land on that line, and every failure a long or multi-line typed line has is a property
@@ -37,11 +38,18 @@ export function startupLineCarriesPrompt(args: {
withPrompt: AgentStartupPlan | null withPrompt: AgentStartupPlan | null
/** The shell the line is typed into, when the host can name it before the spawn. */ /** The shell the line is typed into, when the host can name it before the spawn. */
shellName?: string shellName?: string
/** Whether the host can prove the launched agent holds its terminal before it pastes. */
hostProvesAgentInFront: boolean
}): boolean { }): boolean {
const { withPrompt } = args const { withPrompt } = args
if (!withPrompt || withPrompt.followupPrompt !== null) { if (!withPrompt || withPrompt.followupPrompt !== null) {
return false return false
} }
// Where nothing can prove the agent took the terminal, a paste could land in the shell of an
// agent that exited, so the line carries the prompt at any size, as it always did.
if (!args.hostProvesAgentInFront) {
return true
}
// Hermes types a fixed line that reads the prompt from the spawn env, so the line never grows // Hermes types a fixed line that reads the prompt from the spawn env, so the line never grows
// with the text; its own env budget already returned null above when the text did not fit. // with the text; its own env budget already returned null above when the text did not fit.
if (TUI_AGENT_CONFIG[args.agent].promptInjectionMode === 'hermes-query') { if (TUI_AGENT_CONFIG[args.agent].promptInjectionMode === 'hermes-query') {
@@ -93,7 +101,7 @@ type StartupPlanInputs = Omit<
export function planStartupWithPromptCandidate( export function planStartupWithPromptCandidate(
inputs: StartupPlanInputs, inputs: StartupPlanInputs,
prompt: string, prompt: string,
shellName?: string host: { shellName?: string; provesAgentInFront: boolean }
): { plan: AgentStartupPlan | null; promptCarried: boolean } { ): { plan: AgentStartupPlan | null; promptCarried: boolean } {
if (prompt.trim()) { if (prompt.trim()) {
const withPrompt = buildAgentStartupPlan({ ...inputs, prompt, allowEmptyPromptLaunch: true }) const withPrompt = buildAgentStartupPlan({ ...inputs, prompt, allowEmptyPromptLaunch: true })
@@ -101,7 +109,8 @@ export function planStartupWithPromptCandidate(
startupLineCarriesPrompt({ startupLineCarriesPrompt({
agent: inputs.agent, agent: inputs.agent,
withPrompt, withPrompt,
...(shellName ? { shellName } : {}) ...(host.shellName ? { shellName: host.shellName } : {}),
hostProvesAgentInFront: host.provesAgentInFront
}) })
) { ) {
return { plan: withPrompt, promptCarried: true } return { plan: withPrompt, promptCarried: true }