mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
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>
67 lines
3.0 KiB
TypeScript
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}`
|
|
}
|