fix(agent-launch): paste a launch prompt only when the launched agent is proven in front

A launch pasted its prompt unless a shell was proven in the terminal's
foreground, so any read that could not prove one let the prompt through. After
an agent exited at startup, its shell turned bracketed paste on at the next
prompt, readiness fired on it, and the prompt was typed into the shell:

- macOS: a pane runs its shell under login, so the process-group fence's root
  was never the shell's group and never proved it; the cached foreground name
  could also still name the exited process.
- Windows Git Bash and WSL: the shell-alone-in-its-job check never answers.

Now one fresh read of the terminal's foreground decides: agent, shell or
unknown. Only 'agent' lets a write through (paste, Enter, second Enter, reused
panes too); 'shell' still drops a ready signal. A Windows host never proves the
agent, so there the launch line carries the prompt at any size, as on main.
This commit is contained in:
Brennan Benson
2026-10-02 01:26:36 -07:00
parent 39419d2cbd
commit d96b1e75f2
17 changed files with 750 additions and 398 deletions
@@ -1,5 +1,8 @@
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', () => {
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()
})
})
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'
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 setPlatform = (value: NodeJS.Platform): void => {
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)
})
describe('macOS and Linux: a fresh foreground read', () => {
describe('macOS and Linux: one fresh foreground read decides', () => {
beforeEach(() => setPlatform('darwin'))
it.each([
// For its first 5 s the daemon's cached read names the launch agent while zsh is in front.
['claude', 'zsh', true],
['claude', 'claude', false],
['zsh', 'zsh', true],
['-zsh', '-zsh', true],
['bash', 'bash', true],
[null, 'zsh', true],
[null, null, false]
])('cached %s, scan %s: %s', async (foregroundProcess, scanned, shellInFront) => {
['claude', 'zsh', 'shell'],
// A stub agent that crashed: the cached read can still name it after zsh took the terminal.
['python3', 'zsh', 'shell'],
['claude', 'claude', 'agent'],
// A native Claude names itself by its version.
['zsh', '2.1.285', 'agent'],
['zsh', 'node', 'agent'],
['-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 proof = vi.fn()
const { runtime } = await createTranscriptPane({
paneTitle: 'Claude Code',
foregroundProcess,
confirmedForegroundProcess: scanned,
onForegroundScan: scan,
shellForegroundProven: false,
onShellForegroundProof: proof,
launchAgent: 'claude',
data: ''
})
await expect(runtime.isLaunchShellInFront(TRANSCRIPT_PANE_PTY_ID, 'claude')).resolves.toBe(
shellInFront
)
await expect(
runtime.readLaunchedAgentForeground(TRANSCRIPT_PANE_PTY_ID, 'claude')
).resolves.toBe(found)
expect(scan).toHaveBeenCalledOnce()
expect(proof).not.toHaveBeenCalled()
})
// The terminal daemon answers the shell-foreground check from its recovery state, which an
// 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()
it('proves nothing on a controller without a fresh scan', async () => {
const { runtime } = await createTranscriptPane({
paneTitle: 'copilot',
foregroundProcess: 'copilot',
confirmedForegroundProcess: 'zsh',
shellForegroundProven: false,
onShellForegroundProof: proof,
paneTitle: 'Claude Code',
foregroundProcess: 'claude',
launchAgent: 'claude',
data: ''
})
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',
data: ''
})
await expect(runtime.isLaunchShellInFront(TRANSCRIPT_PANE_PTY_ID, 'copilot')).resolves.toBe(
true
)
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
)
await expect(
runtime.readLaunchedAgentForeground(TRANSCRIPT_PANE_PTY_ID, 'copilot')
).resolves.toBe('shell')
})
})
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'))
// 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([
['powershell.exe', false, false],
['powershell.exe', true, true],
['claude', true, true],
['claude', false, false]
])('cached %s, shell check %s: %s', async (foregroundProcess, proven, shellInFront) => {
['powershell.exe', false, 'unknown'],
['powershell.exe', true, 'shell'],
['claude', true, 'shell'],
['claude', false, 'unknown'],
['node', false, 'unknown']
] as const)('cached %s, shell check %s: %s', async (foregroundProcess, proven, found) => {
const scan = vi.fn()
const { runtime } = await createTranscriptPane({
paneTitle: 'Claude Code',
foregroundProcess,
confirmedForegroundProcess: 'powershell.exe',
confirmedForegroundProcess: 'claude',
onForegroundScan: scan,
shellForegroundProven: proven,
launchAgent: 'claude',
data: ''
})
await expect(runtime.isLaunchShellInFront(TRANSCRIPT_PANE_PTY_ID, 'claude')).resolves.toBe(
shellInFront
)
await expect(
runtime.readLaunchedAgentForeground(TRANSCRIPT_PANE_PTY_ID, 'claude')
).resolves.toBe(found)
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.
it.each([
['claude', false],
['bash', true]
])(
'SSH: takes the relay’s own read %s (shell %s), without a scan or check',
async (relayRead, shell) => {
['claude', 'agent'],
['bash', 'shell']
] as const)(
'SSH: takes the relay’s own read %s (%s), without a scan or check',
async (relayRead, found) => {
setPlatform('darwin')
const scan = vi.fn()
const proof = vi.fn()
const { runtime } = await createTranscriptPane({
paneTitle: 'Claude Code',
foregroundProcess: relayRead,
confirmedForegroundProcess: shell ? 'claude' : 'zsh',
confirmedForegroundProcess: found === 'shell' ? 'claude' : 'zsh',
onForegroundScan: scan,
shellForegroundProven: !shell,
shellForegroundProven: found !== 'shell',
onShellForegroundProof: proof,
connectionId: 'ssh-1',
launchAgent: 'claude',
data: ''
})
await expect(runtime.isLaunchShellInFront(TRANSCRIPT_PANE_PTY_ID, 'claude')).resolves.toBe(
shell
)
await expect(
runtime.readLaunchedAgentForeground(TRANSCRIPT_PANE_PTY_ID, 'claude')
).resolves.toBe(found)
expect(scan).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).
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({
paneTitle: 'Copilot',
foregroundProcess: 'powershell.exe',
foregroundProcess: 'node',
connectionId: 'ssh-1',
remoteWindowsHost: true,
launchAgent: 'copilot',
data: ''
})
await expect(runtime.isLaunchShellInFront(TRANSCRIPT_PANE_PTY_ID, 'copilot')).resolves.toBe(
false
)
await expect(
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
* 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
* 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`
* 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,192 @@
/**
* 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 = {
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
],
[
'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, 'claude')
const writes: string[] = []
const runtime = {
waitForFreshWorkerComposer: async (_handle: string, agent: 'claude', 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: '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 type { LaunchedAgentForeground } from './launched-agent-foreground'
import {
createLaunchedAgentWriteGuard,
type LaunchedAgentWriteGuardRuntime
} from './launched-agent-write-guard'
function guardHarness(shellInFront: boolean[]) {
const answers = [...shellInFront]
function guardHarness(foregrounds: LaunchedAgentForeground[]) {
const answers = [...foregrounds]
let listener: ((data: string) => void) | null = null
const unsubscribe = vi.fn(() => {
listener = null
})
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) => {
listener = next
return unsubscribe
@@ -22,23 +25,35 @@ function guardHarness(shellInFront: boolean[]) {
}
describe('the check before each write of a launch prompt', () => {
it('reuses a read that found no shell for the writes after it', async () => {
const { runtime, guard } = guardHarness([false])
it('reuses a read that found the agent for the writes after it', async () => {
const { runtime, guard } = guardHarness(['agent'])
await guard.beforeWrite('pty-1')
// The agent's own output after the paste says nothing about a shell.
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([
['a shell turning bracketed paste on', '$ \x1b[?2004h'],
['a shell integration prompt mark', '\x1b]133;D;1\x07\x1b]133;A\x07$ '],
['a mark split across two chunks', ['\x1b[?20', '04h']]
] 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')
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')
expect(runtime.isLaunchShellInFront).toHaveBeenCalledTimes(2)
expect(runtime.readLaunchedAgentForeground).toHaveBeenCalledTimes(2)
})
it('catches a shell that returns while the first read is still running', async () => {
const { runtime, guard, emit } = guardHarness([])
vi.mocked(runtime.isLaunchShellInFront)
vi.mocked(runtime.readLaunchedAgentForeground)
.mockImplementationOnce(async () => {
emit('\x1b[?2004h')
return false
return 'agent'
})
.mockResolvedValueOnce(true)
.mockResolvedValueOnce('shell')
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 () => {
const refused = guardHarness([true])
const refused = guardHarness(['shell'])
await expect(refused.guard.beforeWrite('pty-1')).rejects.toThrow('agent_not_in_foreground')
expect(refused.unsubscribe).toHaveBeenCalledTimes(1)
const cleared = guardHarness([false])
const cleared = guardHarness(['agent'])
await cleared.guard.beforeWrite('pty-1')
cleared.guard.dispose()
expect(cleared.unsubscribe).toHaveBeenCalledTimes(1)
@@ -1,5 +1,6 @@
import type { TuiAgent } from '../../shared/tui-agent'
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. */
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<
OrcaRuntimeService,
'isLaunchShellInFront' | 'subscribeToTerminalData'
'readLaunchedAgentForeground' | 'subscribeToTerminalData'
>
/**
* 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
* 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.
* A write needs a fresh read that finds the agent in front; a shell, or a host that cannot tell,
* refuses it, since a ready signal alone can come from a shell back at its prompt. Once a read finds
* 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(
runtime: LaunchedAgentWriteGuardRuntime,
@@ -41,14 +43,14 @@ export function createLaunchedAgentWriteGuard(
watch.shellMayHaveReturned = true
}
})
let shellInFront: boolean
let foreground: LaunchedAgentForeground
try {
shellInFront = await runtime.isLaunchShellInFront(ptyId, agent)
foreground = await runtime.readLaunchedAgentForeground(ptyId, agent)
} catch (error) {
watch.unsubscribe()
throw error
}
if (shellInFront) {
if (foreground !== 'agent') {
watch.unsubscribe()
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 { readFreshComposerHold } from './launched-agent-composer-readiness'
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 {
async activateManagedWorktree(
@@ -208,7 +211,8 @@ export class OrcaRuntimeWithActivateManagedWorktree extends OrcaRuntimeWithListM
timeoutMs,
requireComposerMarker,
signal: stop.signal,
isShellInFront: (ownerPtyId) => this.isLaunchShellInFront(ownerPtyId, agent),
isShellInFront: async (ownerPtyId) =>
(await this.readLaunchedAgentForeground(ownerPtyId, agent)) === 'shell',
accept: (readyPtyId) => {
const pty = this.ptysById.get(readyPtyId)
const hold = pty
@@ -234,12 +238,17 @@ export class OrcaRuntimeWithActivateManagedWorktree extends OrcaRuntimeWithListM
return this.buildTuiIdleProbeResult(handle, null)
}
/** Whether a shell, not the agent a launch started, is proven to be in the terminal's foreground. */
isLaunchShellInFront(ptyId: string, agent: TuiAgent): Promise<boolean> {
/** What holds the terminal a launch started its agent in, read fresh from the execution host. */
readLaunchedAgentForeground(ptyId: string, agent: TuiAgent): Promise<LaunchedAgentForeground> {
const pty = this.ptysById.get(ptyId)
return isShellInFrontOfLaunchedAgent(
const remote = !!pty?.connectionId
return readLaunchedAgentForeground(
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,
agent
)
@@ -21,7 +21,10 @@ import { resolveLocalProjectRuntimeForWorktreeId } from '../local-project-runtim
import { resolveBareAgentLaunchCommand } from './runtime-agent-launch-resolution'
import { agentPromptRidesLaunchCommand } from '../../shared/tui-agent-startup'
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 { agentStartedTelemetry } from '../agent-launch/agent-started-telemetry'
import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
@@ -273,13 +276,16 @@ export class OrcaRuntimeWithResolveWorktreeRemovalTarget extends OrcaRuntimeWith
sessionOptions: this.toAgentSessionOptions(opts.launchPreferences)
}),
opts.startupPrompt ?? '',
nameLocalTypedLineShell({
isRemote,
...(opts.shellOverride ? { shellOverride: opts.shellOverride } : {}),
...(settings.terminalDefaultShell
? { defaultShellSetting: settings.terminalDefaultShell }
: {})
})
{
shellName: nameLocalTypedLineShell({
isRemote,
...(opts.shellOverride ? { shellOverride: opts.shellOverride } : {}),
...(settings.terminalDefaultShell
? { defaultShellSetting: settings.terminalDefaultShell }
: {})
}),
provesAgentInFront: launchHostProvesAgentInFront({ isRemote, launchPlatform: platform })
}
)
if (!startupPlan) {
// Why: an explicit agent that yields no plan would otherwise spawn a bare
@@ -26,8 +26,8 @@ function runtimeStub(overrides: {
send?: SendFn
/** Whether the agent's composer signal fires within its budget; else the idle evidence decides. */
composerSignal?: boolean
/** Whether a shell is proven in the terminal's foreground. */
shellInFront?: boolean
/** What a fresh read finds in the terminal's foreground; default: the agent. */
foreground?: 'agent' | 'shell' | 'unknown'
}) {
const queued = [...(overrides.waits ?? [])]
const waitForTerminal = vi.fn(
@@ -40,7 +40,7 @@ function runtimeStub(overrides: {
}
return COMPOSER_READY
})
const isLaunchShellInFront = vi.fn(async () => overrides.shellInFront ?? false)
const readLaunchedAgentForeground = vi.fn(async () => overrides.foreground ?? 'agent')
const subscribeToTerminalData = vi.fn(() => () => {})
const sendTerminalAgentPrompt = vi.fn<SendFn>(
overrides.send ?? (async () => ({ handle: 'term_1', accepted: true, bytesWritten: 12 }))
@@ -49,13 +49,13 @@ function runtimeStub(overrides: {
waitForTerminal,
waitForFreshWorkerComposer,
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.
runtime: {
waitForTerminal,
waitForFreshWorkerComposer,
sendTerminalAgentPrompt,
isLaunchShellInFront,
readLaunchedAgentForeground,
subscribeToTerminalData
} as unknown as Parameters<typeof deliverTerminalAgentLaunchPrompt>[0]['runtime']
}
@@ -228,12 +228,13 @@ describe('writing a launch prompt into a terminal agent', () => {
})
it.each([
['refuses the write when the shell is back in front', true, false],
// An unread foreground is no proof the agent exited; the composer was just seen ready.
['writes unless a shell is proven in front', false, true]
])('%s', async (_label, shellInFront, delivered) => {
['writes when a fresh read finds the agent in front', 'agent', true],
['refuses the write when the shell is back in front', 'shell', false],
// A ready signal can be a shell's own prompt, so a read that cannot tell proves nothing.
['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.
const stub = runtimeStub({ composerSignal: true, shellInFront })
const stub = runtimeStub({ composerSignal: true, foreground })
stub.sendTerminalAgentPrompt.mockImplementation(async (handle, _text, options) => {
const { beforeWrite } = options
if (typeof beforeWrite === 'function') {
@@ -251,7 +252,7 @@ describe('writing a launch prompt into a terminal agent', () => {
text: 'do the thing'
})
).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 () => {
@@ -275,7 +276,7 @@ describe('writing a launch prompt into a terminal agent', () => {
text: 'do the thing'
})
).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 () => {
@@ -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.
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() {
@@ -107,9 +107,9 @@ export async function deliverTerminalAgentLaunchPrompt(args: {
if (args.text.trim().length === 0) {
return false
}
// Before the paste and again before Enter: an agent that exited in between leaves its shell in
// the foreground, which must never receive the prompt or its Enter.
const guard = args.freshLaunch ? createLaunchedAgentWriteGuard(args.runtime, args.agent) : null
// Before the paste and again before Enter, for a reused pane too: a ready signal can come from a
// shell whose agent exited, so only a read that finds the agent in front lets the text through.
const guard = createLaunchedAgentWriteGuard(args.runtime, args.agent)
try {
const wait = await waitThroughBlockingPrompts(
args.runtime,
@@ -130,7 +130,7 @@ export async function deliverTerminalAgentLaunchPrompt(args: {
inputKind: 'launch',
// A fresh launch's composer was just seen ready; a reused pane's state is only inferred.
composerReady: args.freshLaunch,
...(guard ? { beforeWrite: guard.beforeWrite } : {}),
beforeWrite: guard.beforeWrite,
// 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
// strictly and a slow first turn throws.
@@ -151,6 +151,6 @@ export async function deliverTerminalAgentLaunchPrompt(args: {
)
return false
} 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 { buildAgentDraftLaunchPlan, buildAgentStartupPlan } from '../../shared/tui-agent-startup'
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 {
detectInstalledAgentsWithShellPathHydration,
detectRemoteAgents
@@ -153,16 +156,18 @@ export function buildWorktreeStartupForAgent(
const prompt = environment.prompt ?? ''
let startupPlan: ReturnType<typeof buildAgentStartupPlan>
if (environment.onPromptCarry) {
const offered = planStartupWithPromptCandidate(
planInputs,
prompt,
nameLocalTypedLineShell({
const offered = planStartupWithPromptCandidate(planInputs, prompt, {
shellName: nameLocalTypedLineShell({
isRemote: repoIsRemote(repo),
...(settings.terminalDefaultShell
? { defaultShellSetting: settings.terminalDefaultShell }
: {})
}),
provesAgentInFront: launchHostProvesAgentInFront({
isRemote: repoIsRemote(repo),
launchPlatform: environment.getLaunchPlatform()
})
)
})
startupPlan = offered.plan
if (startupPlan && prompt.trim()) {
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(
agent: TuiAgent,
prompt: string,
extra: { cmdOverride?: string; shellName?: string | undefined } = {}
extra: {
cmdOverride?: string
shellName?: string | undefined
platform?: NodeJS.Platform
provesAgentInFront?: boolean
} = {}
) {
return planStartupWithPromptCandidate(
{
agent,
cmdOverrides: extra.cmdOverride ? { [agent]: extra.cmdOverride } : {},
platform: 'darwin'
platform: extra.platform ?? 'darwin'
},
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', () => {
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.
+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
* 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
* 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
/** The shell the line is typed into, when the host can name it before the spawn. */
shellName?: string
/** Whether the host can prove the launched agent holds its terminal before it pastes. */
hostProvesAgentInFront: boolean
}): boolean {
const { withPrompt } = args
if (!withPrompt || withPrompt.followupPrompt !== null) {
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
// 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') {
@@ -93,7 +101,7 @@ type StartupPlanInputs = Omit<
export function planStartupWithPromptCandidate(
inputs: StartupPlanInputs,
prompt: string,
shellName?: string
host: { shellName?: string; provesAgentInFront: boolean }
): { plan: AgentStartupPlan | null; promptCarried: boolean } {
if (prompt.trim()) {
const withPrompt = buildAgentStartupPlan({ ...inputs, prompt, allowEmptyPromptLaunch: true })
@@ -101,7 +109,8 @@ export function planStartupWithPromptCandidate(
startupLineCarriesPrompt({
agent: inputs.agent,
withPrompt,
...(shellName ? { shellName } : {})
...(host.shellName ? { shellName: host.shellName } : {}),
hostProvesAgentInFront: host.provesAgentInFront
})
) {
return { plan: withPrompt, promptCarried: true }