feat(terminal): shell fallback retry + persisted sanitized history - #21
Conversation
…ation Adds TerminalHistoryStore: a debounced, line-capped, per-workspace log of terminal output rooted at <userData>/terminal-history. Output is sanitized before persisting so a replayed snapshot cannot trigger fresh shell replies — CSI cursor-position reports, device-attributes/status queries, DECRQM/PM, XTVERSION, Kitty keyboard, DCS DECRQSS/XTGETTCAP, and OSC color queries are stripped while benign SGR/cursor sequences survive. Partial sequences split across chunks are carried via a pending prefix. Ported from t3code's sanitizeTerminalHistoryChunk (Manager.ts:953).
Shell fallback: the spawn path now walks an executable candidate list ($SHELL -> /bin/zsh -> /bin/bash -> /bin/sh) and retries the next on a retryable failure (posix_spawnp failed, ENOENT, not found). A broken $SHELL self-heals instead of throwing. Non-retryable errors (EINVAL, out of fds) surface immediately. The session result gains resolvedShell and preferredShellSkipped so the renderer can tell the user which shell launched. History wiring: TerminalService now accepts an optional historyStore. On open the prior sanitized output seeds the buffer (the renderer re-hydrates xterm from snapshot, so no renderer change is needed for the seed); each PTY data event appends to the store; terminate/exit flush the final chunk.
…hell refactor The phase3 contract test asserts the terminal.ts source orders revalidate → abort-check → spawn → abort-check. The spawn call changed shape (single spawn → trySpawnShell destructure) in the shell-fallback PR; update the assertion to match while preserving the ordering invariant it protects.
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes — 7 commits across the shell-fallback retry loop, persisted sanitized history store, IPC contract additions, renderer toast, and test registration.
- Shell-candidate retry loop —
trySpawnShellwalks$SHELL→/bin/zsh→/bin/bash→/bin/sh, retries on retryable spawn errors, and surfaces non-retryable errors without masking.isRetryableShellSpawnErrorwalks the full error +causechain with cycle detection. The pre-filter viaisExecutableavoids wasting spawn attempts on obviously-missing binaries. - Persisted sanitized history —
TerminalHistoryStoreprovides debounced per-workspace file persistence under<userData>/terminal-history/with SHA-256 hashed filenames.sanitizeTerminalHistoryChunkstrips device-query/reply sequences (CSI CPR/DSR/DA/DECRQM/XTVERSION/Kitty keyboard, DCS DECRQSS/XTGETTCAP, OSC color queries) while preserving benign SGR/cursor sequences, with chunk-boundary carry for incomplete sequences. - Buffer seeding on open —
create()seeds the session buffer from the history store so xterm rehydrates with prior output via the existing snapshot path. Thesequencestarts at 1 for restored sessions so the renderer'slastSequenceguard correctly treats the seed as already-consumed. - Renderer toast — a one-time
toast.infowhenpreferredShellSkippedis true, telling the user which fallback shell was used. - Test coverage — 17 new tests (12 for the history store + 5 for the retry loop), all with tight exact-value assertions. Both test files registered in
npm testandnpm run test:coverage.
The design decisions are sound: the TerminalHistoryStoreLike interface decouples the service from the filesystem for testability, persist is best-effort (a terminal must never block on disk trouble), and pending half-sequences are intentionally not persisted (incomplete control sequences have no visual value).
DeepSeek Pro | 𝕏
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes — 1 new commit since the prior review at 15ad2d2.
- Source-reading test assertion update —
subagent-phase3-contract.test.ts:563now searches for"const { pty,"instead of"const pty = (this.options.spawnPty ?? spawn)(", reflecting thecreate()refactor that destructurestrySpawnShell's return value. The ordering assertion (abort check → spawn → post-spawn check) is preserved.
DeepSeek Pro | 𝕏

Summary
Two improvements adapted from studying t3code's terminal, building directly on the
posix_spawnpfix in #20.1. Shell-candidate fallback with retry-on-spawn-failure
A broken
$SHELL(or a shell that vanishes after a Homebrew uninstall) no longer breaks terminal creation. The spawn path now walks an executable candidate list —$SHELL→/bin/zsh→/bin/bash→/bin/sh— and retries the next candidate on a retryable failure (posix_spawnp failed,ENOENT,not found). Genuine errors (EINVAL, out of fds) surface immediately and are never masked.When the preferred shell is skipped and a fallback launches the terminal, the session reports
preferredShellSkipped: true+resolvedShell, and the renderer shows a one-time toast: "Used /bin/zsh because $SHELL was unavailable."Ported from t3code's
trySpawn/isRetryableShellSpawnError(Manager.ts:567,1830).2. Per-workspace persisted + sanitized history
Terminal output now survives close/reopen and app restart:
terminal-history.tsstore — one debounced (40ms), line-capped (5000) log per workspace under<userData>/terminal-history/. File names aresha256(workspaceId)so worktree paths with separators are safe.…R), DSR (…n), DA (…c), DECRQM/PM ($p/$y), XTVERSION (>q), Kitty keyboard (?u), DCS DECRQSS/XTGETTCAP, OSC color queries. Benign SGR/cursor sequences survive. This also fixes a latent bug in the existingsnapshot()reconnect flow, which replayed raw (un-sanitized) output.create()reads the prior history and seeds the session buffer; the renderer's existing xterm-hydrate-from-snapshot shows it with no renderer change needed.Ported from t3code's
sanitizeTerminalHistoryChunk(Manager.ts:953).Files
main/services/terminal-history.ts— new store + sanitizermain/services/terminal-history.test.ts— new (12 tests)main/services/terminal.ts— retry loop, history wiring,resolvedShell/preferredShellSkippedmain/services/terminal.test.ts— 5 new tests (retry, non-retryable, all-fail, happy path, history seeding)renderer/lib/ipc.ts—TerminalSessiongains two fieldsrenderer/components/terminal-drawer.tsx— fallback toastpackage.json— register both test files intest+test:coverage(they weren't in CI before)Verification
tsc --noEmitcleannpm run lintcleanScope (deliberately out of scope)
No architecture change — Aiden's PTY lives in the main process so t3code's server/contract model doesn't map; we adapt the logic, not the architecture. In-place restart, subprocess-aware labels, clickable links, and a reconnect stream are the medium-effort items from the comparison and are natural follow-ups.