Files
orca/src/shared/startup-command-submission.ts
T
Jinjing 2f52f0d665 fix(pty): deliver multiline agent-launch prompts via bracketed paste (#7487)
* 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.

* Fix multiline detection for CRLF-terminated startup commands

Strip the entire CRLF terminator (or lone CR/LF) from startup commands
before checking if the body contains newline characters.

Previously, slicing off only the last character of a CRLF-terminated
command left a trailing CR in the body. This caused a single-line
command to be incorrectly categorized as multiline and wrapped in
bracketed paste.
2026-07-05 21:49:04 -07:00

45 lines
2.2 KiB
TypeScript

/**
* 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 {
// Strip a full CRLF (or lone CR/LF) terminator so a single-line command ending
// in \r\n isn't misread as multiline by the \r/\n body check below.
const trailingTerminator = /\r\n$|\r$|\n$/.exec(command)?.[0] ?? ''
const endsWithSubmit = trailingTerminator.length > 0
const body = endsWithSubmit ? command.slice(0, -trailingTerminator.length) : command
if (bracketedPasteSafe && (body.includes('\n') || body.includes('\r'))) {
return `${BRACKETED_PASTE_START}${body}${BRACKETED_PASTE_END}${submit}`
}
return endsWithSubmit ? command : `${command}${submit}`
}