fix(desktop): gate terminal stdin on shell readiness to prevent preset command loss

RO
8mo ago
+524-7

Summary

  • Fix terminal preset commands (e.g. claude) being lost when the shell is still initializing (direnv, devenv, nvm, conda, oh-my-zsh, etc.)
  • Session-owned shell readiness state machine gates write() — buffering stdin while the shell is pending, flushing in FIFO order once ready
  • createOrAttach() returns immediately — terminal attaches and renders as soon as PTY spawns, no blocking
  • Byte-level marker state machine prevents any marker bytes from leaking to xterm, even across chunk boundaries
  • Terminal protocol responses (DA, DSR) dropped during shell init to prevent escape fragments appearing as typed text

Problem

When a preset launches a terminal and sends a command, the command gets lost because the shell hasn't finished initializing. The PTY Spawned IPC frame fires immediately after pty.spawn() — before the shell has sourced RC files or run hook functions.

This affects any project where shell initialization takes non-trivial time:

ToolWhat it does at startupTypical delay
direnv / devenvLoads .envrc, evaluates Nix expressions, exports env vars1–5s
nvmDetects .nvmrc, switches Node version, rehashes PATH100–500ms
condaActivates environment, modifies PATH and prompt200–800ms
oh-my-zshLoads plugins (git, node, npm, etc.), compiles completions200–1000ms
asdf / miseDetects .tool-versions, shims PATH entries100–300ms
starship / powerlevel10kRenders prompt (git status, language versions)50–500ms

Architecture

Approach: Session-owned stdin gating with OSC shell-ready marker

This follows the same pattern used by major terminal emulators for shell integration:

  • VS Code — OSC 133 sequences from precmd/PROMPT_COMMAND
  • iTerm2 — OSC 1337 and OSC 133 for shell integration
  • Warp — OSC 9001 proprietary sequences from shell hooks

We use OSC 777 (private-use range, won't conflict with existing protocols).

How it works

1. Shell wrappers (shell-wrappers.ts) inject a one-shot marker hook at the end of shell init:

  • zsh: precmd function in .zlogin — placed last in precmd_functions so it fires after direnv and all other hooks
  • bash: PROMPT_COMMAND function in rcfile — appended (not prepended) so it runs after existing hooks. Handles both scalar and array PROMPT_COMMAND (Bash 5.1+). Guards against empty PROMPT_COMMAND.
  • fish: --on-event fish_prompt handler, self-removing via functions -e

2. Session (session.ts) owns shell readiness with a state machine:

  • States: pending | ready | timed_out | unsupported
  • write() buffers user commands in preReadyStdinQueue while pending, passes through otherwise
  • Terminal protocol responses (escape sequences starting with \x1b) are dropped during init — they are stale DA/DSR responses from shell startup queries that would appear as typed text (e.g. ?62;4;9;22c)
  • Headless emulator responses (DA queries) also dropped during init — the renderer handles queries once a client attaches
  • resolveShellReady() transitions state, clears timeout, flushes queue in FIFO order
  • Marker detected via byte-level state machine that withholds potential marker bytes from output until confirmed — zero leakage to xterm even across PTY data frame boundaries
  • Timeout (15s) started in spawn(), not in terminal-host
  • Shells without markers (sh, ksh) start as unsupported — writes pass through immediately, no timeout

3. TerminalHost (terminal-host.ts) — unchanged:

  • createOrAttach() returns immediately after PTY spawn — no blocking wait
  • Terminal attaches and renders right away (user sees direnv loading in real time)
  • All stdin gating is handled by Session internally

4. Renderer (launch-command.ts) — no changes needed

Edge cases handled

  • Unsupported shell (sh, ksh): Starts as unsupported, no buffering, no timeout
  • Very slow init (>15s): Timeout fires, flushes queue, degrades to pre-fix behavior
  • Re-attach to existing session: Only new sessions have pending state
  • Session exits/killed before ready: handleSubprocessExit → resolveShellReady('timed_out') → queue flushed
  • Marker split across PTY frames: Byte-level state machine holds partial matches
  • False marker starts: Held bytes flushed as regular output on mismatch
  • DA responses from xterm: Dropped during pending (stale startup query responses)
  • DA responses from headless emulator: Dropped during pending (renderer takes over)
  • Empty PROMPT_COMMAND: Guarded in bash scalar branch
  • Bash 5.1+ array PROMPT_COMMAND: Detected via declare -p, both forms handled

Files changed

FileChange
apps/desktop/src/main/terminal-host/pty-subprocess-ipc.tsSHELL_READY_MARKER constant with TSDoc
apps/desktop/src/main/lib/agent-setup/shell-wrappers.tsOne-shot marker hooks for zsh, bash, fish
apps/desktop/src/main/terminal-host/session.tsShell readiness state machine, write gating, marker stripping, DA response dropping
apps/desktop/src/main/terminal-host/session-shell-ready.test.ts19 focused unit tests

Test coverage (19 tests)

  • Write buffering: buffer + flush after marker, unsupported shell pass-through, DA response dropping, timeout flush
  • Marker detection: single frame, split across frames, start/end of frame, false marker starts
  • Kill/exit: subprocess exit before marker, session killed during init
  • Shell matrix: zsh/bash/fish buffered; sh/ksh/dash pass-through

Test plan

  • 19 unit tests passing
  • bun run typecheck passes
  • Open workspace with .envrc + direnv — terminal renders immediately, preset command runs after prompt
  • Test without direnv — command executes promptly
  • Test re-attaching to existing terminal — works instantly

PR History

Initial implementation

Blocked createOrAttach() with waitForShellReady() — terminal wouldn't render until shell was ready (up to 15s). Used a carry-buffer approach for marker detection.

Review feedback from @kitenite

Identified that blocking createOrAttach() was the wrong boundary — the terminal should attach immediately, and only stdin delivery should wait. Recommended:

  • Move readiness ownership from TerminalHost into Session
  • Buffer writes in Session, not block session creation
  • Use a state machine instead of a promise for readiness
  • Fix bash PROMPT_COMMAND ordering (was prepending, should append)
  • Replace carry-buffer with a proper byte-level state machine

Refactor to address feedback

Rewrote the implementation:

  • Removed all blocking from createOrAttach() — returns immediately
  • Session now owns readiness via ShellReadyState enum + preReadyStdinQueue
  • write() gates on readiness; resolveShellReady() flushes queue
  • Byte-level marker state machine prevents any marker bytes from reaching xterm
  • Bash hook appended (not prepended) with empty guard and Bash 5.1+ array support
  • Timeout started in spawn() where it belongs

DA response fix

Discovered that headless emulator DA responses and renderer xterm protocol responses were reaching the shell during init, causing ?62;4;9;22c to appear as typed text. Fixed by:

  • Dropping emulator responses during pending state (renderer handles queries once attached)
  • Dropping all escape sequence writes (\x1b-prefixed) during pending state — they are stale responses to shell startup queries

Test suite

Added 19 focused unit tests covering all cases requested in review: write buffering, marker detection (including cross-frame splits), kill/exit before readiness, unsupported shell fallback, and DA response dropping.