fix(desktop): gate terminal stdin on shell readiness to prevent preset command loss
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:
| Tool | What it does at startup | Typical delay |
|---|---|---|
| direnv / devenv | Loads .envrc, evaluates Nix expressions, exports env vars | 1–5s |
| nvm | Detects .nvmrc, switches Node version, rehashes PATH | 100–500ms |
| conda | Activates environment, modifies PATH and prompt | 200–800ms |
| oh-my-zsh | Loads plugins (git, node, npm, etc.), compiles completions | 200–1000ms |
| asdf / mise | Detects .tool-versions, shims PATH entries | 100–300ms |
| starship / powerlevel10k | Renders 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 133sequences from precmd/PROMPT_COMMAND - iTerm2 —
OSC 1337andOSC 133for shell integration - Warp —
OSC 9001proprietary 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:
precmdfunction in.zlogin— placed last inprecmd_functionsso it fires after direnv and all other hooks - bash:
PROMPT_COMMANDfunction in rcfile — appended (not prepended) so it runs after existing hooks. Handles both scalar and arrayPROMPT_COMMAND(Bash 5.1+). Guards against emptyPROMPT_COMMAND. - fish:
--on-event fish_prompthandler, self-removing viafunctions -e
2. Session (session.ts) owns shell readiness with a state machine:
- States:
pending | ready | timed_out | unsupported write()buffers user commands inpreReadyStdinQueuewhilepending, 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
| File | Change |
|---|---|
apps/desktop/src/main/terminal-host/pty-subprocess-ipc.ts | SHELL_READY_MARKER constant with TSDoc |
apps/desktop/src/main/lib/agent-setup/shell-wrappers.ts | One-shot marker hooks for zsh, bash, fish |
apps/desktop/src/main/terminal-host/session.ts | Shell readiness state machine, write gating, marker stripping, DA response dropping |
apps/desktop/src/main/terminal-host/session-shell-ready.test.ts | 19 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 typecheckpasses - 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
ShellReadyStateenum +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.