fix(agent-launch): keep a short-lined multi-line prompt on a local zsh launch line, as main did

#24257 moved every multi-line or over-512-byte prompt off the typed launch line and pasted it after readiness. The phone's AI buttons and review notes, whose multi-line prompts main typed whole into zsh, then reached Claude 0.5-3 s later and their RPC reply waited for the paste.

The host now names the shell a local macOS or Linux line is typed into, the way the spawn picks it, and a multi-line prompt rides a zsh line when every line is at most 512 bytes and the whole line at most 8 KB. A real-zsh test types such a line through Orca's own ready barrier and startup write, including when a slow user config makes the write land early. Elsewhere the measured unsafe cases keep the paste: bash 3.2 runs multi-line lines piecemeal, fish drops an early multi-line write, and any shell loses a line over 1 KB written early.
This commit is contained in:
Brennan Benson
2026-09-30 23:21:15 -07:00
parent d2680896f7
commit e463aea99c
10 changed files with 374 additions and 14 deletions
@@ -0,0 +1,148 @@
/**
* Real-zsh proof for the typed launch lines `startup-line-prompt-carry.ts` lets a prompt ride: a
* multi-line line whose every line fits the per-line budget, up to the whole-line budget, reaches
* zsh intact through Orca's own wrapper, ready marker and startup write.
*
* Why a slow user config too: the write is released 1.5 s after spawn even without the marker, and
* then lands while the terminal is still line-buffered, where macOS keeps at most MAX_CANON bytes of
* one line. The per-line budget is what survives that; a single 1.1 KB line did not.
*/
import { spawnSync } from 'node:child_process'
import { existsSync, mkdtempSync, readFileSync, 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 {
createShellStartupOutputScanState,
scanShellStartupOutput
} from '../shell-startup-output-scanner'
import { selectShellStartupFeatures } from '../shell-startup-features'
import { isBracketedPasteSafeShell } from '../../shared/startup-command-submission'
import {
TYPED_STARTUP_LINE_PROMPT_BUDGET_BYTES,
ZSH_MULTI_LINE_STARTUP_LINE_BUDGET_BYTES
} from '../../shared/startup-line-prompt-carry'
import {
restoreUserDataPathAfterEach,
setTestUserDataPath
} from './local-pty-shell-ready-test-harness'
const ZSH_PATH =
process.platform === 'win32'
? ''
: (spawnSync('sh', ['-c', 'command -v zsh'], { encoding: 'utf8' }).stdout ?? '').trim()
/** Lines of prompt text, each `perLine` bytes. */
function promptLines(lineCount: number, perLine: number): string {
const words = 'Fix the failing checks on this branch, then explain what changed and why.'
return Array.from({ length: lineCount }, (_unused, index) => {
let line = `L${index}:`
while (line.length < perLine) {
line += ` ${words}`
}
return line.slice(0, perLine)
}).join('\n')
}
let home = ''
beforeEach(() => {
home = mkdtempSync(join(tmpdir(), 'orca-typed-line-home-'))
setTestUserDataPath(mkdtempSync(join(tmpdir(), 'orca-typed-line-ud-')))
})
afterEach(() => {
rmSync(home, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
})
restoreUserDataPathAfterEach()
/** Types `printf '%s' '<text>' > out` the way a local pane types a launch line; returns what ran. */
async function typeIntoZsh(text: string, userConfigSeconds: number): Promise<string | null> {
if (userConfigSeconds > 0) {
writeFileSync(join(home, '.zshrc'), `sleep ${userConfigSeconds}\n`)
}
const out = join(home, 'out.txt')
const done = join(home, 'done.txt')
const { ensureShellReadyWrappers } = await import('./local-pty-shell-ready-wrapper-generation')
const { getShellLaunchConfig } = await import('./local-pty-shell-ready')
const { writeStartupCommandWhenShellReady, STARTUP_COMMAND_READY_MAX_WAIT_MS } =
await import('./local-pty-shell-ready-startup-command')
ensureShellReadyWrappers()
const env: Record<string, string> = { PATH: '/usr/bin:/bin', HOME: home }
const launch = getShellLaunchConfig(
ZSH_PATH,
selectShellStartupFeatures({
shellPath: ZSH_PATH,
env,
hasStartupCommand: true,
waitsForShellReady: true,
emitsStartupIdentity: false
})
)
expect(launch.supportsReadyMarker).toBe(true)
const proc = pty.spawn(ZSH_PATH, launch.args ?? [], {
name: 'xterm-256color',
cols: 200,
rows: 40,
cwd: home,
// Why ORCA_ORIG_ZDOTDIR: the user's config is read from the sandbox home, not the real one.
env: { ...env, ...launch.env, ORCA_ORIG_ZDOTDIR: home, TERM: 'xterm-256color' }
})
const scan = createShellStartupOutputScanState()
let resolveReady: ((signal: { postMarkerBytesObserved: boolean }) => void) | null = null
const ready = new Promise<{ postMarkerBytesObserved: boolean }>((resolve) => {
resolveReady = resolve
})
// The provider's cap (local-pty-shell-readiness-session.ts): a later marker releases the write.
const cap = setTimeout(() => {
resolveReady?.({ postMarkerBytesObserved: false })
resolveReady = null
}, STARTUP_COMMAND_READY_MAX_WAIT_MS)
proc.onData((data) => {
if (resolveReady && scanShellStartupOutput(scan, data).ready) {
resolveReady({ postMarkerBytesObserved: true })
resolveReady = null
}
})
writeStartupCommandWhenShellReady(
ready,
proc,
`printf '%s' '${text}' > '${out}'; printf ok > '${done}'`,
() => {},
{
bracketedPasteSafe: isBracketedPasteSafeShell({
shellName: 'zsh',
waitsForShellReady: launch.supportsReadyMarker === true
})
}
)
const deadline = Date.now() + 20_000 + userConfigSeconds * 1000
while (!existsSync(done) && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 50))
}
clearTimeout(cap)
const ran = existsSync(out) ? readFileSync(out, 'utf8') : null
const exited = new Promise<void>((resolve) => proc.onExit(() => resolve()))
proc.kill()
await Promise.race([exited, new Promise((resolve) => setTimeout(resolve, 2000))])
return ran
}
describe.skipIf(!ZSH_PATH)('a multi-line launch line typed into zsh', () => {
// Lines at the per-line budget, as many as the whole-line budget leaves room for.
const perLine = TYPED_STARTUP_LINE_PROMPT_BUDGET_BYTES - 1
const text = promptLines(
Math.floor((ZSH_MULTI_LINE_STARTUP_LINE_BUDGET_BYTES - 200) / (perLine + 1)),
perLine
)
it('arrives whole through the ready barrier', async () => {
expect(await typeIntoZsh(text, 0)).toBe(text)
}, 60_000)
it('arrives whole when the user config outlasts the barrier and the write lands early', async () => {
expect(await typeIntoZsh(text, 3)).toBe(text)
}, 60_000)
})
@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest'
import { 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', () => {
const base = { isRemote: false, platform: 'darwin' as const, envShell: '/bin/bash' }
expect(
nameLocalTypedLineShell({
...base,
shellOverride: '/opt/homebrew/bin/fish',
defaultShellSetting: '/bin/zsh'
})
).toBe('fish')
expect(nameLocalTypedLineShell({ ...base, defaultShellSetting: ' /bin/zsh ' })).toBe('zsh')
expect(nameLocalTypedLineShell(base)).toBe('bash')
expect(nameLocalTypedLineShell({ ...base, envShell: '' })).toBe('zsh')
})
it('names none for a remote host, whose relay picks its own login shell', () => {
expect(
nameLocalTypedLineShell({ isRemote: true, platform: 'darwin', envShell: '/bin/zsh' })
).toBeUndefined()
})
it('names none on Windows, where the pane may be cmd, PowerShell, Git Bash or WSL', () => {
expect(
nameLocalTypedLineShell({ isRemote: false, platform: 'win32', envShell: '/bin/zsh' })
).toBeUndefined()
})
})
@@ -0,0 +1,27 @@
import { basename } from 'node:path'
/**
* The shell a local pane's startup line gets typed into, named before the spawn the way the spawn
* picks it: the request's shell, the default-shell setting, then `SHELL`
* (`ipc/pty/runtime/spawn-preflight.ts`, then the local or daemon launch plan).
*
* Undefined where the host cannot name it: a remote host, whose relay picks its own login shell,
* and Windows, whose pane may be cmd, PowerShell, Git Bash or a WSL distro.
*/
export function nameLocalTypedLineShell(args: {
isRemote: boolean
shellOverride?: string
defaultShellSetting?: string
platform?: NodeJS.Platform
envShell?: string
}): string | undefined {
if (args.isRemote || (args.platform ?? process.platform) === 'win32') {
return undefined
}
const shellPath =
args.shellOverride?.trim() ||
args.defaultShellSetting?.trim() ||
(args.envShell ?? process.env.SHELL) ||
'/bin/zsh'
return basename(shellPath).toLowerCase()
}
@@ -17,7 +17,10 @@ vi.mock('electron', () => ({
app: { getPath: vi.fn(() => '/tmp') }
}))
function runtimeWithAgentLaunch(): {
// The shell the host names for a local line; bash takes no multi-line line, zsh does.
function runtimeWithAgentLaunch(
options: { terminalDefaultShell?: string; connectionId?: string } = {}
): {
runtime: OrcaRuntimeService
spawn: ReturnType<typeof vi.fn>
} {
@@ -27,11 +30,13 @@ function runtimeWithAgentLaunch(): {
store: { getSettings: () => Record<string, unknown> }
resolveTerminalWorkspaceLaunchScope: (selector: string) => Promise<unknown>
}
internal.store = { getSettings: () => ({}) }
internal.store = {
getSettings: () => ({ terminalDefaultShell: options.terminalDefaultShell ?? '/bin/bash' })
}
vi.spyOn(internal, 'resolveTerminalWorkspaceLaunchScope').mockResolvedValue({
id: 'wt-1',
path: '/repo/app',
connectionId: null,
connectionId: options.connectionId ?? null,
repo: null,
folderWorkspace: null
})
@@ -83,6 +88,37 @@ describe('a terminal create that is handed a launch prompt', () => {
expect(onStartupPromptCarry).toHaveBeenCalledWith(false)
})
it('carries a short-lined multi-line prompt on a local zsh line, so the agent starts with it', async () => {
const { runtime, spawn } = runtimeWithAgentLaunch({ terminalDefaultShell: '/bin/zsh' })
const onStartupPromptCarry = vi.fn()
await runtime.createTerminal('id:wt-1', {
startupAgent: 'claude',
startupPrompt: 'summarize the diff\nthen list the risks',
onStartupPromptCarry
})
expect(spawnedCommand(spawn)).toContain('summarize the diff\nthen list the risks')
expect(onStartupPromptCarry).toHaveBeenCalledWith(true)
})
it('starts clean on a remote host, whose shell this host cannot name', async () => {
const { runtime, spawn } = runtimeWithAgentLaunch({
terminalDefaultShell: '/bin/zsh',
connectionId: 'ssh-1'
})
const onStartupPromptCarry = vi.fn()
await runtime.createTerminal('id:wt-1', {
startupAgent: 'claude',
startupPrompt: 'summarize the diff\nthen list the risks',
onStartupPromptCarry
})
expect(spawnedCommand(spawn)).not.toContain('summarize')
expect(onStartupPromptCarry).toHaveBeenCalledWith(false)
})
it('starts Hermes clean instead of refusing when its env budget cannot hold the prompt', async () => {
const { runtime, spawn } = runtimeWithAgentLaunch()
const onStartupPromptCarry = vi.fn()
@@ -21,6 +21,7 @@ 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 { resolveAgentStartupPlanInputs } from '../../shared/agent-startup-plan-inputs'
import { agentStartedTelemetry } from '../agent-launch/agent-started-telemetry'
@@ -232,7 +233,14 @@ export class OrcaRuntimeWithResolveWorktreeRemovalTarget extends OrcaRuntimeWith
windowsShellOverride: opts.shellOverride,
sessionOptions: this.toAgentSessionOptions(opts.launchPreferences)
}),
opts.startupPrompt ?? ''
opts.startupPrompt ?? '',
nameLocalTypedLineShell({
isRemote,
...(opts.shellOverride ? { shellOverride: opts.shellOverride } : {}),
...(settings.terminalDefaultShell
? { defaultShellSetting: settings.terminalDefaultShell }
: {})
})
)
if (!startupPlan) {
// Why: an explicit agent that yields no plan would otherwise spawn a bare
@@ -86,6 +86,8 @@ export type RuntimeStore = {
agentDefaultArgs?: GlobalSettings['agentDefaultArgs']
agentDefaultEnv?: GlobalSettings['agentDefaultEnv']
terminalWindowsShell?: GlobalSettings['terminalWindowsShell']
// Read by the launch-line carry rule to name the shell a local line is typed into.
terminalDefaultShell?: GlobalSettings['terminalDefaultShell']
floatingTerminalEnabled?: GlobalSettings['floatingTerminalEnabled']
agentStatusHooksEnabled?: GlobalSettings['agentStatusHooksEnabled']
terminalCopyTrimsGutter?: GlobalSettings['terminalCopyTrimsGutter']
@@ -109,10 +109,10 @@ describe('buildWorktreeStartupForAgent host resolution', () => {
})
describe('buildWorktreeStartupForAgent prompt carry', () => {
const build = (onPromptCarry?: (carried: boolean) => void) =>
const build = (onPromptCarry?: (carried: boolean) => void, terminalDefaultShell = '/bin/bash') =>
buildWorktreeStartupForAgent({
repo: makeRepo({}),
settings,
settings: Object.assign({}, settings, { terminalDefaultShell }),
agent: 'claude',
prompt: 'summarize the diff\nthen list the risks',
getLaunchPlatform: () => 'linux',
@@ -129,6 +129,14 @@ describe('buildWorktreeStartupForAgent prompt carry', () => {
expect(onPromptCarry).toHaveBeenCalledWith(false)
})
it('carries a short-lined multi-line prompt on a local zsh line, as main typed it', () => {
const onPromptCarry = vi.fn()
const result = build(onPromptCarry, '/bin/zsh')
expect(result.startup.command).toContain('summarize the diff\nthen list the risks')
expect(onPromptCarry).toHaveBeenCalledWith(true)
})
it('keeps folding the prompt for a caller that delivers nothing afterwards', () => {
// `orca worktree create --prompt` has no post-start paste of its own for an argv agent.
expect(build().startup.command).toContain('summarize the diff')
@@ -10,6 +10,7 @@ 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 {
detectInstalledAgentsWithShellPathHydration,
detectRemoteAgents
@@ -152,7 +153,16 @@ export function buildWorktreeStartupForAgent(
const prompt = environment.prompt ?? ''
let startupPlan: ReturnType<typeof buildAgentStartupPlan>
if (environment.onPromptCarry) {
const offered = planStartupWithPromptCandidate(planInputs, prompt)
const offered = planStartupWithPromptCandidate(
planInputs,
prompt,
nameLocalTypedLineShell({
isRemote: repoIsRemote(repo),
...(settings.terminalDefaultShell
? { defaultShellSetting: settings.terminalDefaultShell }
: {})
})
)
startupPlan = offered.plan
if (startupPlan && prompt.trim()) {
environment.onPromptCarry(offered.promptCarried)
+59 -3
View File
@@ -2,7 +2,8 @@ import { describe, expect, it } from 'vitest'
import {
hasControlByte,
planStartupWithPromptCandidate,
TYPED_STARTUP_LINE_PROMPT_BUDGET_BYTES
TYPED_STARTUP_LINE_PROMPT_BUDGET_BYTES,
ZSH_MULTI_LINE_STARTUP_LINE_BUDGET_BYTES
} from './startup-line-prompt-carry'
import type { TuiAgent } from './tui-agent'
import {
@@ -10,14 +11,26 @@ import {
RUNTIME_CAPABILITIES
} from './protocol-version'
function offer(agent: TuiAgent, prompt: string, extra: { cmdOverride?: string } = {}) {
function offer(
agent: TuiAgent,
prompt: string,
extra: { cmdOverride?: string; shellName?: string | undefined } = {}
) {
return planStartupWithPromptCandidate(
{
agent,
cmdOverrides: extra.cmdOverride ? { [agent]: extra.cmdOverride } : {},
platform: 'darwin'
},
prompt
prompt,
extra.shellName
)
}
/** `count` prompt lines of `bytes` each, as a multi-line source-control prompt is. */
function linesOf(count: number, bytes: number): string {
return Array.from({ length: count }, (_unused, index) => `${index}:`.padEnd(bytes, 'x')).join(
'\n'
)
}
@@ -119,6 +132,49 @@ describe('whether a launch prompt rides the typed startup line', () => {
})
})
// Pinned live by startup-line-typed-length.live-shell.test.ts; the refusals below were measured on
// macOS through the same write: a 1.1 KB single line lost to zsh's late write, every multi-line line
// lost to bash 3.2, and to fish whenever its config outlasted the ready barrier.
describe('a multi-line prompt typed into a shell the host names', () => {
it('rides a zsh line as main typed it when each line is short, so the agent starts with it', () => {
const prompt = linesOf(21, 100)
const { plan, promptCarried } = offer('claude', prompt, { shellName: 'zsh' })
expect(promptCarried).toBe(true)
expect(plan?.launchCommand).toContain(prompt)
})
it('keeps a zsh line off the launch command when any one line exceeds the per-line budget', () => {
const prompt = `${linesOf(3, 100)}\n${'y'.repeat(TYPED_STARTUP_LINE_PROMPT_BUDGET_BYTES + 1)}`
expect(offer('claude', prompt, { shellName: 'zsh' }).promptCarried).toBe(false)
})
it('keeps a zsh line off the launch command past the whole-line budget', () => {
const prompt = linesOf(Math.ceil(ZSH_MULTI_LINE_STARTUP_LINE_BUDGET_BYTES / 400) + 1, 400)
expect(offer('claude', prompt, { shellName: 'zsh' }).promptCarried).toBe(false)
})
it('keeps a long single line off a zsh launch command, which a late write truncates', () => {
const prompt = promptForClaudeLineOf(TYPED_STARTUP_LINE_PROMPT_BUDGET_BYTES + 1)
expect(offer('claude', prompt, { shellName: 'zsh' }).promptCarried).toBe(false)
})
it.each([['bash'], ['fish'], ['pwsh'], [undefined]])(
'never types a multi-line line into %s',
(shellName) => {
const { plan, promptCarried } = offer('claude', linesOf(3, 50), { shellName })
expect(promptCarried).toBe(false)
expect(plan?.launchCommand).not.toContain('0:')
}
)
it.each([
['TAB', 'see\tthis\nnext line'],
['CR', 'first\r\nsecond']
])('never types a %s-bearing multi-line prompt into zsh', (_label, prompt) => {
expect(offer('claude', prompt, { shellName: 'zsh' }).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.
+39 -4
View File
@@ -8,6 +8,11 @@
* byte as a key, a canonical-mode write truncates a line
* past MAX_CANON (1024 on macOS), and cmd caps a line at 8191. Decided here, where the line exists,
* so the answer is a fact about what was built rather than a prediction of it.
*
* `startup-line-typed-length.live-shell.test.ts` types lines through Orca's own ready barrier and
* submission into real shells, including one whose config outlasts the barrier's 1.5 s cap so the
* line lands while the terminal is still line-buffered. Only zsh read a multi-line line whole in
* both cases, and only while each of its lines stayed short; a long single line was lost there.
*/
import { TUI_AGENT_CONFIG } from './tui-agent-config'
@@ -15,9 +20,12 @@ import { buildAgentStartupPlan, type AgentStartupPlan } from './tui-agent-startu
import type { TuiAgent } from './tui-agent'
/** Half of macOS MAX_CANON: a single typed line this long survived every canonical-mode write
* measured, and 1 KiB did not. */
* measured, and 1 KiB did not. Also the cap on each line of a multi-line one. */
export const TYPED_STARTUP_LINE_PROMPT_BUDGET_BYTES = 512
/** A multi-line line typed into zsh: the largest measured intact behind a late write (8 KiB). */
export const ZSH_MULTI_LINE_STARTUP_LINE_BUDGET_BYTES = 8192
const encoder = new TextEncoder()
function typedLineBytes(line: string): number {
@@ -27,6 +35,8 @@ function typedLineBytes(line: string): number {
export function startupLineCarriesPrompt(args: {
agent: TuiAgent
withPrompt: AgentStartupPlan | null
/** The shell the line is typed into, when the host can name it before the spawn. */
shellName?: string
}): boolean {
const { withPrompt } = args
if (!withPrompt || withPrompt.followupPrompt !== null) {
@@ -38,7 +48,25 @@ export function startupLineCarriesPrompt(args: {
return true
}
const line = withPrompt.launchCommand
return !hasControlByte(line) && typedLineBytes(line) <= TYPED_STARTUP_LINE_PROMPT_BUDGET_BYTES
if (!hasControlByte(line)) {
return typedLineBytes(line) <= TYPED_STARTUP_LINE_PROMPT_BUDGET_BYTES
}
return args.shellName === 'zsh' && zshReadsMultiLineWhole(line)
}
/**
* zsh takes a multi-line line as one bracketed paste, and a write that beats its line editor still
* reaches it line by line, so each line must fit the terminal's line buffer. bash 3.2 has no
* bracketed paste, and fish drops a paste written before its reader is up.
*/
function zshReadsMultiLineWhole(line: string): boolean {
const lines = line.split('\n')
return (
lines.every(
(part) =>
!hasControlByte(part) && typedLineBytes(part) <= TYPED_STARTUP_LINE_PROMPT_BUDGET_BYTES
) && typedLineBytes(line) <= ZSH_MULTI_LINE_STARTUP_LINE_BUDGET_BYTES
)
}
/** Any C0 byte or DEL, not just CR/LF: no quoter escapes them, and a single-line command is written
@@ -64,11 +92,18 @@ type StartupPlanInputs = Omit<
*/
export function planStartupWithPromptCandidate(
inputs: StartupPlanInputs,
prompt: string
prompt: string,
shellName?: string
): { plan: AgentStartupPlan | null; promptCarried: boolean } {
if (prompt.trim()) {
const withPrompt = buildAgentStartupPlan({ ...inputs, prompt, allowEmptyPromptLaunch: true })
if (startupLineCarriesPrompt({ agent: inputs.agent, withPrompt })) {
if (
startupLineCarriesPrompt({
agent: inputs.agent,
withPrompt,
...(shellName ? { shellName } : {})
})
) {
return { plan: withPrompt, promptCarried: true }
}
}