diff --git a/src/main/daemon/shell-ready.ts b/src/main/daemon/shell-ready.ts index 7ad5a686bb7..ff60fa63a8e 100644 --- a/src/main/daemon/shell-ready.ts +++ b/src/main/daemon/shell-ready.ts @@ -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 diff --git a/src/main/daemon/terminal-host.ts b/src/main/daemon/terminal-host.ts index 41e1f96b1bb..2a54ce2c19b 100644 --- a/src/main/daemon/terminal-host.ts +++ b/src/main/daemon/terminal-host.ts @@ -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 { diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index 81f9cbcd3b2..5b0e5ea84da 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -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 diff --git a/src/main/providers/local-pty-shell-ready.test.ts b/src/main/providers/local-pty-shell-ready.test.ts index 71983b81abc..e736e236c9a 100644 --- a/src/main/providers/local-pty-shell-ready.test.ts +++ b/src/main/providers/local-pty-shell-ready.test.ts @@ -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', () => { diff --git a/src/main/providers/local-pty-shell-ready.ts b/src/main/providers/local-pty-shell-ready.ts index 0e1b7bdbb3f..978d3e94b2a 100644 --- a/src/main/providers/local-pty-shell-ready.ts +++ b/src/main/providers/local-pty-shell-ready.ts @@ -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, 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 | 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 => { diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index 126f761333b..3eaa0cc456d 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -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) } diff --git a/src/relay/pty-shell-launch.ts b/src/relay/pty-shell-launch.ts index 5197e3a021b..5f58b89702c 100644 --- a/src/relay/pty-shell-launch.ts +++ b/src/relay/pty-shell-launch.ts @@ -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}" diff --git a/src/renderer/src/lib/ssh-background-startup-delivery.ts b/src/renderer/src/lib/ssh-background-startup-delivery.ts index a2c8cc6f767..eb178fd5964 100644 --- a/src/renderer/src/lib/ssh-background-startup-delivery.ts +++ b/src/renderer/src/lib/ssh-background-startup-delivery.ts @@ -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) } diff --git a/src/shared/startup-command-submission.test.ts b/src/shared/startup-command-submission.test.ts new file mode 100644 index 00000000000..9da31225996 --- /dev/null +++ b/src/shared/startup-command-submission.test.ts @@ -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`) + }) +}) diff --git a/src/shared/startup-command-submission.ts b/src/shared/startup-command-submission.ts new file mode 100644 index 00000000000..103175909e6 --- /dev/null +++ b/src/shared/startup-command-submission.ts @@ -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}` +}