fix(pty): deliver multiline agent-launch prompts via bracketed paste

Multiline agent-launch prompts (claude/codex/opencode argv injection) were
mangled when Orca typed the startup command into the interactive shell. The
command is single-quoted, but its literal embedded newlines survive quoting;
bash readline / zsh zle read every raw LF as accept-line (Enter), so the first
newline submits an unterminated single-quoted command, drops the shell into PS2
(>) continuation, and the rest executes piecemeal — backticks/$ evaluate, quotes
go unbalanced, and the agent never receives the intact prompt. Short single-line
prompts worked because they have no embedded newline.

Fix: when a startup command contains a newline, wrap the payload in
bracketed-paste markers (ESC[200~ … ESC[201~) before the trailing submit CR/LF
so the line editor inserts the multiline text literally and only the trailing
byte submits it. The single-line fast path is unchanged. Gated on the target
line editor having bracketed-paste mode active (Orca-wrapped bash/zsh) so shells
without it never echo the markers as garbage; Orca's bash rc wrappers now force
`enable-bracketed-paste on` (zsh has it on by default).

Applied consistently across every startup-command delivery path:
- src/main/providers/local-pty-shell-ready.ts (in-process / degraded local)
- src/main/daemon/terminal-host.ts (daemon host — primary local)
- src/relay/pty-handler.ts (SSH relay, remote host)
- src/renderer/src/lib/ssh-background-startup-delivery.ts (hidden SSH tab)
All share src/shared/startup-command-submission.ts. Windows cmd.exe and other
shells keep the current CR submit path (no regression); PSReadLine/POSIX
bash/zsh get the fix.
This commit is contained in:
Jinjing
2026-07-05 21:08:41 -07:00
parent 61bd98db6f
commit 9a8f2fd393
10 changed files with 212 additions and 15 deletions
+5
View File
@@ -95,6 +95,11 @@ elif [[ -f "$HOME/.bash_login" ]]; then
elif [[ -f "$HOME/.profile" ]]; then
source "$HOME/.profile"
fi
# Why: enable bracketed paste so Orca can deliver a multiline startup prompt as
# a single literal paste (ESC[200~…ESC[201~); without it, older readline builds
# treat each embedded newline as Enter and mangle the prompt into PS2
# continuation. Modern readline defaults this on; force it for the rest.
[[ $- == *i* ]] && bind 'set enable-bracketed-paste on' 2>/dev/null
__orca_restore_attribution_path() {
[[ -n "\${ORCA_ATTRIBUTION_SHIM_DIR:-}" ]] || return 0
case "$PATH" in
+10 -2
View File
@@ -2,6 +2,7 @@ import { Session, type SubprocessHandle } from './session'
import { normalizePtySize } from './daemon-pty-size'
import { resolveProcessCwd } from '../providers/process-cwd'
import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery'
import { buildStartupCommandSubmission } from '../../shared/startup-command-submission'
import type {
SessionInfo,
TakePendingOutputResult,
@@ -163,8 +164,15 @@ export class TerminalHost {
// the user would need to press Enter after Orca launches the agent or
// setup script. POSIX shells accept CR as Enter under ICRNL.
const submit = process.platform === 'win32' ? '\r' : '\n'
const endsWithSubmit = opts.command.endsWith('\r') || opts.command.endsWith('\n')
session.write(endsWithSubmit ? opts.command : `${opts.command}${submit}`)
// Why: multiline startup prompts are pasted literally via bracketed paste
// only for Orca-wrapped bash/zsh, which is exactly when the shell-ready
// barrier is supported; other shells keep the raw submit path.
session.write(
buildStartupCommandSubmission(opts.command, {
submit,
bracketedPasteSafe: opts.shellReadySupported ?? false
})
)
}
return {
+15 -3
View File
@@ -805,9 +805,21 @@ export class LocalPtyProvider implements IPtyProvider {
ptyDisposables.set(id, disposables)
if (args.command && !startupCommandDeliveredInShellArgs) {
writeStartupCommandWhenShellReady(shellReadyPromise, proc, args.command, (cleanup) => {
startupCommandCleanup = cleanup
})
// Why: only Orca-wrapped POSIX bash/zsh have bracketed-paste mode armed
// (bash via `bind`, zsh on by default), so multiline startup prompts can
// be pasted literally there; other shells keep the raw submit path.
const spawnedShellName = getSpawnedShellName(shellPath).toLowerCase()
const bracketedPasteSafe =
process.platform !== 'win32' && (spawnedShellName === 'bash' || spawnedShellName === 'zsh')
writeStartupCommandWhenShellReady(
shellReadyPromise,
proc,
args.command,
(cleanup) => {
startupCommandCleanup = cleanup
},
{ bracketedPasteSafe }
)
}
// Why: publish the OS pid so ipc/pty can register the PTY with the memory
@@ -165,6 +165,61 @@ describe('writeStartupCommandWhenShellReady', () => {
await Promise.resolve()
expect(proc._writes).toEqual(['codex\n'])
})
// Why: regression for the multiline agent-prompt bug. A startup command with
// embedded newlines must be wrapped in bracketed paste (ESC[200~ … ESC[201~)
// followed by a single submit byte, so bash readline / zsh zle insert the
// whole prompt literally instead of reading each LF as Enter and mangling it
// into PS2 continuation.
it('wraps a multiline startup command in bracketed paste when the shell supports it', async () => {
Object.defineProperty(process, 'platform', { value: 'darwin' })
const proc = createMockProc()
const ready = Promise.resolve()
const command = "claude '--dangerously-skip-permissions' 'line one\nline two'"
writeStartupCommandWhenShellReady(ready, proc, command, () => {}, {
bracketedPasteSafe: true
})
await ready
proc._emitData('\r\nuser@host % ')
vi.advanceTimersByTime(30)
await Promise.resolve()
expect(proc._writes).toEqual([`\x1b[200~${command}\x1b[201~\n`])
})
it('leaves a single-line command on the raw submit path even when bracketed paste is safe', async () => {
Object.defineProperty(process, 'platform', { value: 'darwin' })
const proc = createMockProc()
const ready = Promise.resolve()
writeStartupCommandWhenShellReady(ready, proc, 'claude', () => {}, {
bracketedPasteSafe: true
})
await ready
proc._emitData('\r\nuser@host % ')
vi.advanceTimersByTime(30)
await Promise.resolve()
expect(proc._writes).toEqual(['claude\n'])
})
it('does not bracket-wrap a multiline command when the shell lacks bracketed paste', async () => {
Object.defineProperty(process, 'platform', { value: 'darwin' })
const proc = createMockProc()
const ready = Promise.resolve()
const command = 'echo one\necho two'
// Default options: bracketedPasteSafe is false, so the raw path is kept to
// avoid echoing the ESC[200~ markers on shells without bracketed paste.
writeStartupCommandWhenShellReady(ready, proc, command, () => {})
await ready
proc._emitData('\r\nuser@host % ')
vi.advanceTimersByTime(30)
await Promise.resolve()
expect(proc._writes).toEqual([`${command}\n`])
})
})
describe('scanForShellReady', () => {
+20 -5
View File
@@ -21,6 +21,7 @@ import {
isPowerShellExecutableName
} from '../powershell-osc133-bootstrap'
import { getPosixOmpShellWrapper } from '../pty/omp-shell-wrapper'
import { buildStartupCommandSubmission } from '../../shared/startup-command-submission'
import {
getZshEnvTemplate,
getZshFinalZdotdirRestoreBlock,
@@ -118,6 +119,11 @@ elif [[ -f "$HOME/.bash_login" ]]; then
elif [[ -f "$HOME/.profile" ]]; then
source "$HOME/.profile"
fi
# Why: enable bracketed paste so Orca can deliver a multiline startup prompt as
# a single literal paste (ESC[200~…ESC[201~). Without it, older readline builds
# treat each embedded newline as Enter and mangle the prompt into PS2
# continuation. Modern readline defaults this on; force it for the rest.
[[ $- == *i* ]] && bind 'set enable-bracketed-paste on' 2>/dev/null
# Why: preserve bash's normal login-shell contract. Many users already source
# ~/.bashrc from ~/.bash_profile; forcing ~/.bashrc again here would duplicate
# PATH edits, hooks, and prompt init in Orca startup-command shells.
@@ -432,7 +438,11 @@ export function writeStartupCommandWhenShellReady(
readyPromise: Promise<void | ShellReadySignal>,
proc: pty.IPty,
startupCommand: string,
onExit: (cleanup: () => void) => void
onExit: (cleanup: () => void) => void,
// Why: only Orca-wrapped bash/zsh have bracketed-paste mode active, so
// multiline startup commands are wrapped in ESC[200~/ESC[201~ only there;
// other shells keep the raw submit path to avoid echoing the markers.
options: { bracketedPasteSafe?: boolean } = {}
): void {
let sent = false
let postReadyTimer: ReturnType<typeof setTimeout> | null = null
@@ -470,12 +480,17 @@ export function writeStartupCommandWhenShellReady(
// Enter under ICRNL, so CR works there too, but this code path is reached
// on Windows as well as POSIX via writeStartupCommandWhenShellReady.
const submit = process.platform === 'win32' ? '\r' : '\n'
const endsWithSubmit = startupCommand.endsWith('\r') || startupCommand.endsWith('\n')
const payload = endsWithSubmit ? startupCommand : `${startupCommand}${submit}`
// Why: startup commands are usually long, quoted agent launches. Writing
// them in one PTY call after the shell-ready barrier avoids the incremental
// paste behavior that still dropped characters in practice.
proc.write(payload)
// paste behavior that still dropped characters in practice. Multiline
// prompts are additionally wrapped in bracketed paste (see the helper) so
// embedded newlines are inserted literally instead of submitting early.
proc.write(
buildStartupCommandSubmission(startupCommand, {
submit,
bracketedPasteSafe: options.bracketedPasteSafe === true
})
)
}
const schedulePostReadyFlush = (): void => {
+9 -2
View File
@@ -15,6 +15,7 @@ import {
import { getRelayShellLaunchConfig } from './pty-shell-launch'
import { DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS } from '../shared/ssh-types'
import { shouldUseShellReadyStartupDelivery } from '../shared/codex-startup-delivery'
import { buildStartupCommandSubmission } from '../shared/startup-command-submission'
import { resolveSetupAgentSequenceLaunchCommand } from '../shared/setup-agent-sequencing'
import {
createShellReadyScanState,
@@ -323,8 +324,14 @@ export class PtyHandler {
}
}
const submit = process.platform === 'win32' ? '\r' : '\n'
const endsWithSubmit = startup.command.endsWith('\r') || startup.command.endsWith('\n')
const payload = endsWithSubmit ? startup.command : `${startup.command}${submit}`
// Why: a multiline startup prompt is pasted literally via bracketed paste
// only when the Orca shell-ready wrapper is active (waitForShellReady) —
// that is the bash/zsh overlay that arms bracketed-paste mode. Other remote
// shells keep the raw submit path so the ESC[200~ markers are not echoed.
const payload = buildStartupCommandSubmission(startup.command, {
submit,
bracketedPasteSafe: startup.waitForShellReady
})
managed.startupCommand = undefined
managed.pty.write(payload)
}
+5
View File
@@ -141,6 +141,11 @@ elif [[ -f "$HOME/.bash_login" ]]; then
elif [[ -f "$HOME/.profile" ]]; then
source "$HOME/.profile"
fi
# Why: enable bracketed paste so Orca can deliver a multiline startup prompt as
# a single literal paste (ESC[200~…ESC[201~); without it, older readline builds
# treat each embedded newline as Enter and mangle the prompt into PS2
# continuation. Modern readline defaults this on; force it for the rest.
[[ $- == *i* ]] && bind 'set enable-bracketed-paste on' 2>/dev/null
# Why: remote startup files can re-export user defaults after relay spawn.
[[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}"
[[ -n "\${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="\${ORCA_MIMOCODE_HOME}"
@@ -2,6 +2,7 @@ import {
createShellReadyMarkerScanState,
scanForShellReadyMarker
} from '@/components/terminal-pane/shell-ready-marker-scan'
import { buildStartupCommandSubmission } from '../../../shared/startup-command-submission'
const SSH_SHELL_READY_STARTUP_FALLBACK_MS = 1500
@@ -76,9 +77,17 @@ export function createSshBackgroundStartupDelivery(
pendingCommand = null
// Why: the SSH relay treats spawn.command as metadata for interactive
// PTYs; hidden automation tabs still submit the command themselves.
const submittedCommand =
command.endsWith('\r') || command.endsWith('\n') ? command : `${command}\r`
options.write(ptyId, submittedCommand)
// Why bracketed paste: multiline prompts are pasted literally only when we
// synchronized on the Orca shell-ready marker (waitForShellReady) — that
// is the bash/zsh overlay with bracketed-paste mode armed. Submit with CR
// since the relay drives a remote shell.
options.write(
ptyId,
buildStartupCommandSubmission(command, {
submit: '\r',
bracketedPasteSafe: options.waitForShellReady
})
)
}, 50)
}
@@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest'
import { buildStartupCommandSubmission } from './startup-command-submission'
describe('buildStartupCommandSubmission', () => {
it('appends the submit byte to a single-line command unchanged', () => {
expect(
buildStartupCommandSubmission('claude', { submit: '\n', bracketedPasteSafe: true })
).toBe('claude\n')
expect(
buildStartupCommandSubmission('claude', { submit: '\r', bracketedPasteSafe: true })
).toBe('claude\r')
})
it('preserves a caller-supplied trailing submit byte on single-line commands', () => {
expect(
buildStartupCommandSubmission('claude\n', { submit: '\r', bracketedPasteSafe: true })
).toBe('claude\n')
})
it('wraps a multiline command in bracketed paste with a trailing submit byte', () => {
const command = "claude 'first\nsecond'"
expect(buildStartupCommandSubmission(command, { submit: '\n', bracketedPasteSafe: true })).toBe(
`\x1b[200~${command}\x1b[201~\n`
)
})
it('strips a trailing submit byte before bracket-wrapping the multiline body', () => {
const body = "claude 'first\nsecond'"
expect(
buildStartupCommandSubmission(`${body}\n`, { submit: '\r', bracketedPasteSafe: true })
).toBe(`\x1b[200~${body}\x1b[201~\r`)
})
it('keeps the raw path for multiline commands when bracketed paste is unsafe', () => {
const command = 'echo one\necho two'
expect(
buildStartupCommandSubmission(command, { submit: '\n', bracketedPasteSafe: false })
).toBe(`${command}\n`)
})
})
+41
View File
@@ -0,0 +1,41 @@
/**
* Builds the exact bytes Orca writes into an interactive shell to deliver and
* submit a startup command (agent launch, setup script, etc.).
*
* Why bracketed paste: agent launch prompts are single-quoted, but their
* literal embedded newlines survive quoting. bash readline / zsh zle read every
* raw LF as accept-line (Enter), so the first newline inside a multiline prompt
* submits an unterminated single-quoted command and drops the shell into PS2
* continuation — the prompt is executed piecemeal and mangled. Wrapping the
* payload in bracketed-paste markers (ESC[200~ … ESC[201~) tells the line
* editor to insert the whole multiline text literally; only the trailing CR/LF
* written after the end marker submits it. Single-line commands keep the proven
* raw-write path unchanged so the fast path never regresses.
*/
// DEC 2004 bracketed-paste bracket sequences.
const BRACKETED_PASTE_START = '\x1b[200~'
const BRACKETED_PASTE_END = '\x1b[201~'
export type StartupCommandSubmissionOptions = {
/** Byte that submits the line: CR on Windows (PSReadLine/cmd.exe), LF on
* POSIX; SSH relays remote shells with CR. A caller-supplied trailing submit
* byte on `command` is preserved as-is. */
submit: string
/** Whether the target line editor has bracketed-paste mode active (Orca's
* wrapped bash/zsh). Only wrap multiline payloads when true — a shell without
* bracketed paste would echo the ESC[200~ markers as literal garbage. */
bracketedPasteSafe: boolean
}
export function buildStartupCommandSubmission(
command: string,
{ submit, bracketedPasteSafe }: StartupCommandSubmissionOptions
): string {
const endsWithSubmit = command.endsWith('\r') || command.endsWith('\n')
const body = endsWithSubmit ? command.slice(0, -1) : command
if (bracketedPasteSafe && (body.includes('\n') || body.includes('\r'))) {
return `${BRACKETED_PASTE_START}${body}${BRACKETED_PASTE_END}${submit}`
}
return endsWithSubmit ? command : `${command}${submit}`
}