Files
orca/src/shared/startup-command-submission.ts
T
NeilandOrca 5ea7df1a5b fix(terminal): make DECSET 2031 subscriptions silent (#13904)
fish arms `CSI ?2031h` before painting each prompt and withdraws it when it
hands the tty to a child — a ~1ms window. Orca answered that subscribe with
`CSI ?997;Nn` across a 1-3ms renderer hop, so the reply landed after the
withdrawal and was read as stdin by the next child, corrupting `brew`/`npx`
`[y/N]` prompts.

The reply is not stale by Orca's own view when written (measured
staleReplies: 0), so no suppress-the-stale-reply scheme can close this — the
information needed to suppress does not exist yet. Nothing asked for the reply
either. The Contour spec says a terminal "should only send out the DSR when the
palette has been updated"; Ghostty (Termio.zig:729 — force=true reachable only
from the ?996n DSR), iTerm2 (VT100Terminal.m:995 — flag only) and xterm.js
(InputHandler.ts:2035 — flag only) all emit nothing on the DECSET. So stop
entering the race: record the subscription, answer nothing.

Of 17 real programs measured under a pty, only fish, tmux, claude and opencode
subscribe; none block on a reply, and answering produces one redundant palette
re-query and zero rendering difference. tmux is the only one that sends `?996n`,
which Orca still answers.

- Subscribes are record-only at all four emitters (live scan, hidden-gate fact,
  parked byte watcher, parked responder — the last is deleted, it only replied).
- `?996n` answers, the subscription registry, and the theme-flip push are
  unchanged. `paneLastThemeMode` is still seeded at subscribe so the next
  appearance re-apply is not read as a flip.
- Replay grammar carries `?2031l` alongside `?2031h`, so a late-attaching remote
  client no longer registers a subscription the TUI already retired.

Also closes fish-integration gaps found alongside: `unset` (which fish lacks)
becomes `set -e` on paths parsed by the client's login shell, `config.fish` is
parsed for agent-home detection, and bracketed-paste startup delivery is made
consistent across local/daemon/relay.

Regression test drives real fish 4.7.1 under node-pty and asserts on what the
child process reads; it fails against pre-fix code with the exact payload from
the issue. CI installs fish 4 and fails loudly rather than skipping.

Closes #9993

Co-authored-by: Orca <help@stably.ai>
2026-08-11 21:16:36 -07:00

67 lines
3.0 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/fish). Only wrap multiline payloads when true — a shell
* without bracketed paste would echo the ESC[200~ markers as literal garbage. */
bracketedPasteSafe: boolean
}
/**
* Whether a spawned POSIX shell will read a bracketed-paste payload as one
* multiline command.
*
* Why fish needs the ready barrier: bash readline and zsh zle interpret the
* ESC[200~ wrapper out of their buffered input, but fish consumes bytes during
* its startup terminal-query handshake, so a payload written before its reader
* is up lands as literal `200~` text and the command never runs (verified
* against fish 4.7). Waiting for the shell-ready barrier is what makes fish
* paste-safe, and it is what the daemon and relay backends already require.
*/
export function isBracketedPasteSafeShell(args: {
shellName: string
waitsForShellReady: boolean
}): boolean {
const name = args.shellName.toLowerCase()
if (name === 'bash' || name === 'zsh') {
return true
}
return name === 'fish' && args.waitsForShellReady
}
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}`
}