mirror of
https://github.com/stablyai/orca.git
synced 2026-10-24 00:04:01 +00:00
Merge #24257 head 2603d2cda1 (crash guard: paste only when the launched agent is proven in front)
This commit is contained in:
@@ -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,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 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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user