From dac9d3a5d060ed61db539f0c1f96f77bba744f6c Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Mon, 10 Aug 2026 20:46:30 -0400 Subject: [PATCH 1/8] feat(terminal): per-workspace history store with device-query sanitization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds TerminalHistoryStore: a debounced, line-capped, per-workspace log of terminal output rooted at /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). --- main/services/terminal-history.ts | 363 ++++++++++++++++++++++++++++++ 1 file changed, 363 insertions(+) create mode 100644 main/services/terminal-history.ts diff --git a/main/services/terminal-history.ts b/main/services/terminal-history.ts new file mode 100644 index 0000000..dec22fb --- /dev/null +++ b/main/services/terminal-history.ts @@ -0,0 +1,363 @@ +// Per-workspace terminal output history with control-sequence sanitization. +// +// PTY data arrives as a raw byte stream that mixes visible text with device +// control sequences. Replaying that stream verbatim — on terminal reopen, or +// when the renderer re-hydrates from snapshot — replays device *queries* too, +// and the shell answers them by echoing junk at the prompt. This store strips +// query/reply traffic (CSI/DCS/OSC) before persisting or returning history, so +// what gets replayed is only what the user actually saw. +// +// The store is deliberately network-free and synchronous-safe: it debounces +// disk writes per workspace so a noisy `npm install` doesn't thrash, and every +// write is best-effort (a terminal must never block or fail on disk trouble). + +import * as fs from "fs/promises"; +import * as path from "path"; +import { createHash } from "node:crypto"; +import { ensureUserDataDir } from "./data-store.js"; +import type { TerminalHistoryStoreLike } from "./terminal.js"; + +export const MAX_HISTORY_LINES = 5_000; +const PERSIST_DEBOUNCE_MS = 40; + +export interface TerminalHistoryStoreOptions { + /** Directory holding one `.log` per workspace. */ + logsDir: string; + /** Override for tests; production resolves via ensureUserDataDir. */ + maxLines?: number; + /** Test seam for the debounce window. */ + debounceMs?: number; + /** Test seam: custom timers. */ + now?: () => number; + schedule?: (fn: () => void, ms: number) => () => void; +} + +/** + * Strip device-query and device-reply escape sequences from a chunk of PTY + * output, carrying any half-sequence across chunk boundaries via + * `pendingControlSequence`. Returns the sanitized visible text and the new + * pending prefix to feed into the next call. + * + * Stripped (so a replayed history cannot trigger a fresh shell reply): + * - CSI cursor-position reports (…R), device-status (…n), + * device-attributes (…c), DECRQM/DECRPM (…$p/…$y), XTVERSION (>q), + * Kitty keyboard (?u). + * - DCS DECRQSS ($q) and XTGETTCAP (+q) queries and their replies. + * - OSC foreground/background/color queries (10;? / 11;? / rgb:…). + * Benign sequences (SGR colors, cursor moves, DECSTR, etc.) are preserved. + * + * Ported from t3code's `sanitizeTerminalHistoryChunk` (Manager.ts:953). + */ +export function sanitizeTerminalHistoryChunk( + pendingControlSequence: string, + data: string, +): { visibleText: string; pendingControlSequence: string } { + const input = `${pendingControlSequence}${data}`; + let visibleText = ""; + let index = 0; + + const append = (value: string) => { + visibleText += value; + }; + + while (index < input.length) { + const codePoint = input.charCodeAt(index); + + // ESC (0x1b) introduces a multi-byte escape sequence. + if (codePoint === 0x1b) { + const nextCodePoint = input.charCodeAt(index + 1); + if (Number.isNaN(nextCodePoint)) { + return { visibleText, pendingControlSequence: input.slice(index) }; + } + + // CSI: ESC [ …final-byte (0x40..0x7e). + if (nextCodePoint === 0x5b) { + let cursor = index + 2; + while (cursor < input.length) { + if (isCsiFinalByte(input.charCodeAt(cursor))) { + const sequence = input.slice(index, cursor + 1); + const body = input.slice(index + 2, cursor); + if (!shouldStripCsiSequence(body, input[cursor] ?? "")) { + append(sequence); + } + index = cursor + 1; + break; + } + cursor += 1; + } + if (cursor >= input.length) { + return { visibleText, pendingControlSequence: input.slice(index) }; + } + continue; + } + + // String-terminated sequences: OSC (]), DCS (P), SOS (^), PM (^), APC (_). + if ( + nextCodePoint === 0x5d || + nextCodePoint === 0x50 || + nextCodePoint === 0x5e || + nextCodePoint === 0x5f + ) { + const terminatorIndex = findStringTerminatorIndex(input, index + 2); + if (terminatorIndex === null) { + return { visibleText, pendingControlSequence: input.slice(index) }; + } + const sequence = input.slice(index, terminatorIndex); + const content = stripStringTerminator(input.slice(index + 2, terminatorIndex)); + const strip = + (nextCodePoint === 0x5d && shouldStripOscSequence(content)) || + (nextCodePoint === 0x50 && shouldStripDcsSequence(content)); + if (!strip) { + append(sequence); + } + index = terminatorIndex; + continue; + } + + // ESC + intermediate (0x20..0x2f) + final (0x30..0x7e): e.g. ESC ! p. + const escapeSequenceEndIndex = findEscapeSequenceEndIndex(input, index + 1); + if (escapeSequenceEndIndex === null) { + return { visibleText, pendingControlSequence: input.slice(index) }; + } + append(input.slice(index, escapeSequenceEndIndex)); + index = escapeSequenceEndIndex; + continue; + } + + // C1 CSI (0x9b) — the single-byte form of ESC [. + if (codePoint === 0x9b) { + let cursor = index + 1; + while (cursor < input.length) { + if (isCsiFinalByte(input.charCodeAt(cursor))) { + const sequence = input.slice(index, cursor + 1); + const body = input.slice(index + 1, cursor); + if (!shouldStripCsiSequence(body, input[cursor] ?? "")) { + append(sequence); + } + index = cursor + 1; + break; + } + cursor += 1; + } + if (cursor >= input.length) { + return { visibleText, pendingControlSequence: input.slice(index) }; + } + continue; + } + + // C1 OSC/DCS/SOS/PM/APC single-byte forms. + if (codePoint === 0x9d || codePoint === 0x90 || codePoint === 0x9e || codePoint === 0x9f) { + const terminatorIndex = findStringTerminatorIndex(input, index + 1); + if (terminatorIndex === null) { + return { visibleText, pendingControlSequence: input.slice(index) }; + } + const sequence = input.slice(index, terminatorIndex); + const content = stripStringTerminator(input.slice(index + 1, terminatorIndex)); + const strip = + (codePoint === 0x9d && shouldStripOscSequence(content)) || + (codePoint === 0x90 && shouldStripDcsSequence(content)); + if (!strip) { + append(sequence); + } + index = terminatorIndex; + continue; + } + + append(input[index] ?? ""); + index += 1; + } + + return { visibleText, pendingControlSequence: "" }; +} + +function isCsiFinalByte(codePoint: number): boolean { + return codePoint >= 0x40 && codePoint <= 0x7e; +} + +function shouldStripCsiSequence(body: string, finalByte: string): boolean { + // Device-status report (CSI … n). + if (finalByte === "n") return true; + // Cursor-position report (CSI row ; col R). + if (finalByte === "R" && /^[0-9;?]*$/.test(body)) return true; + // Device-attributes report (CSI … c). + if (finalByte === "c" && /^[>0-9;?]*$/.test(body)) return true; + // DECRQM mode queries (…$p) and DECRPM replies (…$y). The `$` guard keeps + // setters like DECSTR (!p) and DECSCL ("p) intact. + if ((finalByte === "p" || finalByte === "y") && /^[0-9;?]*\$$/.test(body)) return true; + // XTVERSION query (>q). DECSCUSR (space-intermediate q) stays. + if (finalByte === "q" && /^>[0-9;]*$/.test(body)) return true; + // Kitty keyboard protocol query/reply (?u). Restore-cursor (bare u) stays. + if (finalByte === "u" && body.startsWith("?")) return true; + return false; +} + +// DECRQSS ($q) and XTGETTCAP (+q) queries plus their replies ([01]$r / [01]+r): +// pure request/response traffic with no visual value. +function shouldStripDcsSequence(content: string): boolean { + return /^[01]?[$+][qr]/.test(content); +} + +// OSC 10/11/12 foreground/background/cursor color queries and rgb: replies. +function shouldStripOscSequence(content: string): boolean { + return /^(10|11|12);(?:\?|rgb:)/.test(content); +} + +function stripStringTerminator(value: string): string { + if (value.endsWith("\u001b\\")) return value.slice(0, -2); + const last = value.length > 0 ? value[value.length - 1] : ""; + if (last === "\u0007" || last === "\u009c") return value.slice(0, -1); + return value; +} + +function findStringTerminatorIndex(input: string, start: number): number | null { + for (let index = start; index < input.length; index += 1) { + const codePoint = input.charCodeAt(index); + // BEL (0x07) or ST (0x9c) terminate. + if (codePoint === 0x07 || codePoint === 0x9c) return index + 1; + // ESC \ (0x1b 0x5c) terminates. + if (codePoint === 0x1b && input.charCodeAt(index + 1) === 0x5c) return index + 2; + } + return null; +} + +function isEscapeIntermediateByte(codePoint: number): boolean { + return codePoint >= 0x20 && codePoint <= 0x2f; +} + +function isEscapeFinalByte(codePoint: number): boolean { + return codePoint >= 0x30 && codePoint <= 0x7e; +} + +function findEscapeSequenceEndIndex(input: string, start: number): number | null { + let cursor = start; + while (cursor < input.length && isEscapeIntermediateByte(input.charCodeAt(cursor))) { + cursor += 1; + } + if (cursor >= input.length) return null; + return isEscapeFinalByte(input.charCodeAt(cursor)) ? cursor + 1 : start + 1; +} + +/** + * Keep only the most recent `maxLines` lines so a long-running terminal does + * not grow without bound. A trailing newline is preserved if present. + */ +export function capHistory(history: string, maxLines: number): string { + if (history.length === 0) return history; + const hasTrailingNewline = history.endsWith("\n"); + const lines = history.split("\n"); + if (hasTrailingNewline) lines.pop(); + if (lines.length <= maxLines) return history; + const capped = lines.slice(lines.length - maxLines).join("\n"); + return hasTrailingNewline ? `${capped}\n` : capped; +} + +function safeWorkspaceId(workspaceId: string): string { + return createHash("sha256").update(workspaceId).digest("hex"); +} + +interface PendingWorkspaceState { + /** Sanitized history accumulated since the last disk write. */ + history: string; + /** Carried half-sequence across chunk boundaries. */ + pendingControlSequence: string; + /** Pending debounced write canceller. */ + cancel: () => void; + /** True when a write is scheduled but has not fired yet. */ + writeScheduled: boolean; +} + +export class TerminalHistoryStore implements TerminalHistoryStoreLike { + private readonly maxLines: number; + private readonly debounceMs: number; + private readonly schedule: (fn: () => void, ms: number) => () => void; + private readonly pending = new Map(); + + constructor(private readonly options: TerminalHistoryStoreOptions) { + this.maxLines = options.maxLines ?? MAX_HISTORY_LINES; + this.debounceMs = options.debounceMs ?? PERSIST_DEBOUNCE_MS; + this.schedule = options.schedule ?? ((fn, ms) => { + const handle = setTimeout(fn, ms); + return () => clearTimeout(handle); + }); + } + + /** Create the default store rooted at `/terminal-history`. */ + static async create(): Promise { + const logsDir = await ensureUserDataDir("terminal-history"); + return new TerminalHistoryStore({ logsDir }); + } + + async read(workspaceId: string): Promise { + const state = this.pending.get(workspaceId); + if (state) return state.history; + try { + const file = path.join(this.options.logsDir, `${safeWorkspaceId(workspaceId)}.log`); + const raw = await fs.readFile(file, "utf8"); + return capHistory(raw, this.maxLines); + } catch { + return ""; + } + } + + append(workspaceId: string, data: string): void { + let state = this.pending.get(workspaceId); + if (!state) { + state = { + history: "", + pendingControlSequence: "", + cancel: () => {}, + writeScheduled: false, + }; + this.pending.set(workspaceId, state); + } + const sanitized = sanitizeTerminalHistoryChunk(state.pendingControlSequence, data); + state.pendingControlSequence = sanitized.pendingControlSequence; + if (sanitized.visibleText.length > 0) { + state.history = capHistory(`${state.history}${sanitized.visibleText}`, this.maxLines); + } + this.scheduleDebouncedWrite(workspaceId); + } + + async flush(workspaceId: string): Promise { + const state = this.pending.get(workspaceId); + if (!state?.writeScheduled) return; + state.cancel(); + state.writeScheduled = false; + await this.persist(workspaceId); + } + + async clear(workspaceId: string): Promise { + const state = this.pending.get(workspaceId); + if (state) { + state.cancel(); + this.pending.delete(workspaceId); + } + try { + await fs.unlink(path.join(this.options.logsDir, `${safeWorkspaceId(workspaceId)}.log`)); + } catch { + // Already absent or unreadable — clearing is idempotent. + } + } + + private scheduleDebouncedWrite(workspaceId: string): void { + const state = this.pending.get(workspaceId); + if (!state || state.writeScheduled) return; + state.writeScheduled = true; + state.cancel = this.schedule(() => { + void this.persist(workspaceId); + }, this.debounceMs); + } + + private async persist(workspaceId: string): Promise { + const state = this.pending.get(workspaceId); + if (!state) return; + try { + const file = path.join(this.options.logsDir, `${safeWorkspaceId(workspaceId)}.log`); + await fs.writeFile(file, state.history, "utf8"); + } catch { + // A terminal must never fail or block on history-disk trouble. + } finally { + if (state) state.writeScheduled = false; + } + } +} From b26f879215595953e2755c34edae851ad33b5cb8 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Mon, 10 Aug 2026 20:46:47 -0400 Subject: [PATCH 2/8] feat(terminal): shell-candidate retry loop + persisted history wiring 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. --- main/services/terminal.ts | 171 ++++++++++++++++++++++++++++++++++---- 1 file changed, 154 insertions(+), 17 deletions(-) diff --git a/main/services/terminal.ts b/main/services/terminal.ts index 8f3482d..2c51f73 100644 --- a/main/services/terminal.ts +++ b/main/services/terminal.ts @@ -21,6 +21,14 @@ export interface TerminalSessionInfo { id: string; workspaceId: string; cwd: string; + /** The shell that actually launched this session (e.g. `/bin/zsh`). */ + resolvedShell: string; + /** + * True when the preferred shell was skipped and a fallback launched the + * session. The renderer surfaces a one-time toast so the user knows their + * `$SHELL` was unavailable. + */ + preferredShellSkipped: boolean; } export interface TerminalSnapshot { @@ -36,6 +44,7 @@ interface TerminalSession extends TerminalSessionInfo { removeOwnerInvalidation: () => void; buffer: string; sequence: number; + historyWorkspaceId: string; } export interface TerminalServiceOptions { @@ -51,6 +60,23 @@ export interface TerminalServiceOptions { * the chmod/verify path can be exercised without touching node_modules. */ spawnHelperPaths?: () => Promise; + /** + * Optional persisted-history store. When present, prior output is restored on + * open and new output is debounced-to-disk so a terminal survives close and + * app restart. Defaults to none (in-memory only) for tests and legacy paths. + */ + historyStore?: TerminalHistoryStoreLike; +} + +/** + * The history-store surface `TerminalService` depends on. The real + * implementation lives in `terminal-history.ts`; this structural interface keeps + * the service testable without the filesystem and without a circular import. + */ +export interface TerminalHistoryStoreLike { + read(workspaceId: string): Promise; + append(workspaceId: string, data: string): void; + flush(workspaceId: string): Promise; } function terminalId(): string { @@ -88,14 +114,96 @@ function isExecutable(filePath: string): boolean { } } -async function resolveShell(candidates: string[]): Promise { - for (const candidate of candidates) { - if (isExecutable(candidate)) return candidate; +/** + * Whether a shell spawn failure is worth retrying against the next candidate. + * Walks the error + `cause` chain collecting messages (a wrapping layer like + * node-pty often buries the real string on `cause`) and matches the substrings + * that mean "this shell is missing or not launchable": `posix_spawnp failed`, + * `ENOENT`, `not found`, `file not found`, `no such file`. Genuine errors + * (e.g. `EINVAL`, out of fds) are NOT retryable and must surface immediately. + * + * Ported from t3code's `isRetryableShellSpawnError` (Manager.ts:567). + */ +function isRetryableShellSpawnError(error: unknown): boolean { + const queue: unknown[] = [error]; + const seen = new Set(); + const messages: string[] = []; + while (queue.length > 0) { + const current = queue.shift(); + if (!current || seen.has(current)) continue; + seen.add(current); + if (typeof current === "string") { + messages.push(current); + } else if (current instanceof Error) { + messages.push(current.message); + const cause = (current as { cause?: unknown }).cause; + if (cause) queue.push(cause); + } else if (typeof current === "object" && current !== null) { + const value = current as { message?: unknown; cause?: unknown }; + if (typeof value.message === "string") messages.push(value.message); + if (value.cause) queue.push(value.cause); + } + } + const message = messages.join(" ").toLowerCase(); + return ( + message.includes("posix_spawnp failed") || + message.includes("enoent") || + message.includes("not found") || + message.includes("file not found") || + message.includes("no such file") + ); +} + +interface ShellSpawnOptions { + cwd: string; + env: Record; + cols?: number; + rows?: number; + name?: string; +} + +/** + * Try each shell candidate in order, retrying the next on a retryable spawn + * failure (missing binary, `posix_spawnp failed`). Returns the launched pty and + * the shell that won. A non-retryable error rethrows immediately so it isn't + * masked by the fallback chain. If every candidate fails to launch, throws a + * descriptive error listing every attempted shell and the last cause. + * + * Ported from t3code's `trySpawn` (Manager.ts:1830). + */ +async function trySpawnShell( + candidates: string[], + spawnPty: typeof spawn, + options: ShellSpawnOptions, +): Promise<{ pty: IPty; shell: string; preferredShellSkipped: boolean }> { + let lastError: unknown = null; + for (let index = 0; index < candidates.length; index += 1) { + const shell = candidates[index]; + if (!shell) continue; + try { + const pty = spawnPty(shell, [], { + name: options.name ?? "xterm-256color", + cols: options.cols ?? 120, + rows: options.rows ?? 30, + cwd: options.cwd, + env: options.env, + }); + return { pty, shell, preferredShellSkipped: index > 0 }; + } catch (error) { + lastError = error; + if (!isRetryableShellSpawnError(error)) throw error; + // Retryable: try the next candidate. + } } + const attempted = candidates.filter(Boolean).map((shell) => JSON.stringify(shell)).join(", "); + const causeMessage = + lastError instanceof Error + ? lastError.message + : typeof lastError === "string" + ? lastError + : "unknown error"; throw new Error( - `No executable shell found on this Mac (checked ${candidates - .map((candidate) => JSON.stringify(candidate)) - .join(", ")}). Set $SHELL to an installed shell, or reinstall macOS.`, + `Could not launch any shell (tried ${attempted}). Last failure: ${causeMessage}. Set $SHELL to an installed shell or reinstall macOS.`, ); } @@ -136,14 +244,33 @@ export class TerminalService { ); } const id = terminalId(); - const shell = await resolveShell((this.options.shellCandidates ?? defaultShellCandidates)()); - const pty = (this.options.spawnPty ?? spawn)(shell, [], { - name: "xterm-256color", - cols: 120, - rows: 30, - cwd, - env: { ...process.env, TERM: "xterm-256color" } as Record, - }); + const candidates = (this.options.shellCandidates ?? defaultShellCandidates)(); + // Verify each candidate exists+is executable before spawning, so the retry + // loop below only fights launch failures (not obvious "no such file" ones). + const executableCandidates = candidates.filter(isExecutable); + if (executableCandidates.length === 0) { + throw new Error( + `No executable shell found on this Mac (checked ${candidates + .map((candidate) => JSON.stringify(candidate)) + .join(", ")}). Set $SHELL to an installed shell, or reinstall macOS.`, + ); + } + const { pty, shell: resolvedShell, preferredShellSkipped } = await trySpawnShell( + executableCandidates, + this.options.spawnPty ?? spawn, + { + cwd, + env: { ...process.env, TERM: "xterm-256color" } as Record, + }, + ); + if (ownerInvalidated()) { + this.terminatePty(pty); + throw new Error("The workspace changed before the terminal could start."); + } + // Restore the sanitized prior-session output so the terminal reopens with + // its history. The renderer writes this buffer to xterm on hydrate, so no + // renderer change is required for the seed. + const restoredHistory = await this.options.historyStore?.read(workspaceId); if (ownerInvalidated()) { this.terminatePty(pty); throw new Error("The workspace changed before the terminal could start."); @@ -152,13 +279,16 @@ export class TerminalService { id, workspaceId, cwd, + resolvedShell, + preferredShellSkipped, pty, ownerWebContentsId: owner.id, ownerDocumentId: owner.documentId, owner, removeOwnerInvalidation: () => {}, - buffer: "", - sequence: 0, + buffer: restoredHistory ?? "", + sequence: restoredHistory ? 1 : 0, + historyWorkspaceId: workspaceId, }; this.sessions.set(id, session); const removeOwnerInvalidation = owner.onInvalidated(() => { @@ -180,6 +310,8 @@ export class TerminalService { if (!current) return; current.buffer = `${current.buffer}${data}`.slice(-MAX_BUFFER_CHARS); current.sequence += 1; + // Persist new output (the store sanitizes and debounces the disk write). + this.options.historyStore?.append(workspaceId, data); try { owner.send("terminal:data", { sessionId: id, sequence: current.sequence, data }); } catch { @@ -191,6 +323,8 @@ export class TerminalService { if (current !== session) return; this.sessions.delete(id); current.removeOwnerInvalidation(); + // Flush the final chunk before the session goes away. + void this.options.historyStore?.flush(workspaceId); try { owner.send("terminal:exit", { sessionId: id, exitCode, signal }); } catch { @@ -198,7 +332,7 @@ export class TerminalService { } }); - return { id, workspaceId, cwd }; + return { id, workspaceId, cwd, resolvedShell, preferredShellSkipped }; } snapshot(id: string, owner: RendererDocumentOwner): TerminalSnapshot { @@ -261,6 +395,9 @@ export class TerminalService { this.sessions.delete(id); session.removeOwnerInvalidation(); this.terminatePty(session.pty); + // Best-effort flush so the last output chunk is on disk before the session + // is torn down (window close, workspace switch, quit). Never block on it. + void this.options.historyStore?.flush(session.historyWorkspaceId); try { session.owner.send("terminal:exit", { sessionId: id, From f5380f705b82639e032319431bd7c570222dbf84 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Mon, 10 Aug 2026 20:46:53 -0400 Subject: [PATCH 3/8] test(terminal): cover history store round-trip, sanitization, debounce, clear --- main/services/terminal-history.test.ts | 169 +++++++++++++++++++++++++ 1 file changed, 169 insertions(+) create mode 100644 main/services/terminal-history.test.ts diff --git a/main/services/terminal-history.test.ts b/main/services/terminal-history.test.ts new file mode 100644 index 0000000..db79009 --- /dev/null +++ b/main/services/terminal-history.test.ts @@ -0,0 +1,169 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createHash } from "node:crypto"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { + TerminalHistoryStore, + capHistory, + sanitizeTerminalHistoryChunk, +} from "./terminal-history.js"; + +function safeId(workspaceId: string): string { + return createHash("sha256").update(workspaceId).digest("hex"); +} + +// Device-query / reply sequences that MUST be stripped from replayed history. +// CSI cursor-position report (CSI 6 n). +const CSI_CPR = "\u001b[6n"; +// CSI device-attributes query (CSI c). +const CSI_DA = "\u001b[c"; +// CSI device-status report (CSI 5 n). +const CSI_DSR = "\u001b[5n"; + +// Benign sequences that MUST survive sanitization. +// SGR red (CSI 3 1 m). +const SGR_RED = "\u001b[31m"; +// SGR reset (CSI 0 m). +const SGR_RESET = "\u001b[0m"; + +test("sanitizer strips CSI cursor-position, device-attributes, and device-status queries", () => { + const input = `hello ${CSI_CPR}${CSI_DA}world${CSI_DSR}!`; + const { visibleText, pendingControlSequence } = sanitizeTerminalHistoryChunk("", input); + assert.equal(pendingControlSequence, ""); + assert.equal(visibleText, "hello world!"); +}); + +test("sanitizer preserves benign SGR color sequences", () => { + const input = `${SGR_RED}error${SGR_RESET}`; + const { visibleText } = sanitizeTerminalHistoryChunk("", input); + assert.equal(visibleText, input); +}); + +test("sanitizer carries an incomplete escape sequence across chunk boundaries", () => { + // Split right in the middle of a CSI sequence: "\x1b[6" then "n". + const first = sanitizeTerminalHistoryChunk("", "a\u001b[6"); + assert.equal(first.visibleText, "a"); + assert.equal(first.pendingControlSequence, "\u001b[6"); + + const second = sanitizeTerminalHistoryChunk(first.pendingControlSequence, "nb"); + assert.equal(second.pendingControlSequence, ""); + // The full CSI 6 n was recognized and stripped; "b" is the only new visible text. + assert.equal(second.visibleText, "b"); +}); + +test("sanitizer strips OSC color queries (10;?) and rgb: replies", () => { + // OSC 10 ; ? ST — a foreground-color query. ST is ESC \. + const oscQuery = "\u001b]10;?\u001b\\"; + const { visibleText } = sanitizeTerminalHistoryChunk("", `x${oscQuery}y`); + assert.equal(visibleText, "xy"); +}); + +test("sanitizer strips DCS DECRQSS ($q) queries", () => { + // DCS $ q m ST — a DECRQSS query for SGR. + const dcsQuery = "\u001bP$qm\u001b\\"; + const { visibleText } = sanitizeTerminalHistoryChunk("", `pre${dcsQuery}post`); + assert.equal(visibleText, "prepost"); +}); + +test("capHistory keeps only the most recent N lines", () => { + const lines = Array.from({ length: 10 }, (_, i) => `line${i}`).join("\n"); + const capped = capHistory(lines, 3); + assert.equal(capped, "line7\nline8\nline9"); +}); + +test("capHistory preserves a trailing newline", () => { + const lines = Array.from({ length: 10 }, (_, i) => `line${i}`).join("\n") + "\n"; + const capped = capHistory(lines, 2); + assert.equal(capped, "line8\nline9\n"); +}); + +test("TerminalHistoryStore round-trips appended chunks after flush", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "pty-history-")); + const store = new TerminalHistoryStore({ logsDir: dir, debounceMs: 0 }); + try { + store.append("ws-1", "hello "); + store.append("ws-1", "world"); + await store.flush("ws-1"); + const read = await store.read("ws-1"); + assert.equal(read, "hello world"); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("TerminalHistoryStore persists sanitized output to disk", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "pty-history-sanitize-")); + const store = new TerminalHistoryStore({ logsDir: dir, debounceMs: 0 }); + try { + store.append("ws-1", `visible ${CSI_CPR}text`); + await store.flush("ws-1"); + const file = path.join(dir, `${safeId("ws-1")}.log`); + const raw = await readFile(file, "utf8"); + // The device query must not be in the persisted file. + assert.ok(!raw.includes(CSI_CPR)); + assert.equal(raw, "visible text"); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("TerminalHistoryStore isolates histories per workspace", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "pty-history-iso-")); + const store = new TerminalHistoryStore({ logsDir: dir, debounceMs: 0 }); + try { + store.append("ws-1", "alpha"); + store.append("ws-2", "beta"); + await store.flush("ws-1"); + await store.flush("ws-2"); + assert.equal(await store.read("ws-1"), "alpha"); + assert.equal(await store.read("ws-2"), "beta"); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("TerminalHistoryStore.clear removes the persisted log", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "pty-history-clear-")); + const store = new TerminalHistoryStore({ logsDir: dir, debounceMs: 0 }); + try { + store.append("ws-1", "data"); + await store.flush("ws-1"); + assert.equal(await store.read("ws-1"), "data"); + await store.clear("ws-1"); + assert.equal(await store.read("ws-1"), ""); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("TerminalHistoryStore coalesces rapid appends into one debounced write", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "pty-history-coalesce-")); + // Use a fake scheduler so the test is deterministic and fast. + let scheduledCount = 0; + let fire: () => void = () => {}; + const store = new TerminalHistoryStore({ + logsDir: dir, + debounceMs: 100, + schedule: (fn) => { + scheduledCount += 1; + fire = fn; + return () => { + fire = () => {}; + }; + }, + }); + try { + // Many rapid appends should schedule the write exactly once. + for (let i = 0; i < 50; i += 1) store.append("ws-1", "x"); + assert.equal(scheduledCount, 1); + // Fire the coalesced write. + fire(); + // Allow the writeFile to settle. + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(await store.read("ws-1"), "x".repeat(50)); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); From 5f517e152af6ad8052ec04e15e9ab043a8e19fe6 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Mon, 10 Aug 2026 20:46:53 -0400 Subject: [PATCH 4/8] test(terminal): cover shell retry/fallback/non-retryable and history seeding --- main/services/terminal.test.ts | 100 +++++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) diff --git a/main/services/terminal.test.ts b/main/services/terminal.test.ts index acbdc81..58ecfc2 100644 --- a/main/services/terminal.test.ts +++ b/main/services/terminal.test.ts @@ -246,3 +246,103 @@ test("a spawn-helper that is already executable is left untouched", async () => await rm(dir, { recursive: true, force: true }); }); + +test("shell retry loop falls back when the preferred shell fails to spawn", async () => { + const owner = ownerState(); + let spawnedShell = ""; + // The first candidate throws the exact retryable error; the second wins. + const service = new TerminalService({ + prepareSpawnHelper: async () => undefined, + // Both candidates must be "executable" so they enter the spawn loop; the + // spawn itself is what throws here. + shellCandidates: () => ["/bin/zsh", "/bin/sh"], + spawnPty: ((file: string) => { + if (file === "/bin/zsh") throw new Error("posix_spawnp failed."); + spawnedShell = file; + return fakePty().pty; + }) as typeof spawn, + }); + + const session = await service.create("workspace-1", "/tmp", owner.owner); + assert.equal(spawnedShell, "/bin/sh"); + assert.equal(session.resolvedShell, "/bin/sh"); + assert.equal(session.preferredShellSkipped, true); +}); + +test("a non-retryable spawn error surfaces immediately instead of falling back", async () => { + const owner = ownerState(); + let attempts = 0; + const service = new TerminalService({ + prepareSpawnHelper: async () => undefined, + shellCandidates: () => ["/bin/zsh", "/bin/sh"], + spawnPty: (() => { + attempts += 1; + // EINVAL is NOT a "missing shell" error — it must not be masked. + const error = new Error("EINVAL"); + (error as Error & { code?: string }).code = "EINVAL"; + throw error; + }) as typeof spawn, + }); + + await assert.rejects(service.create("workspace-1", "/tmp", owner.owner), /EINVAL/u); + // Only the first candidate was tried; no fallback. + assert.equal(attempts, 1); +}); + +test("all shells failing to spawn throws a descriptive error listing attempts", async () => { + const owner = ownerState(); + const service = new TerminalService({ + prepareSpawnHelper: async () => undefined, + shellCandidates: () => ["/bin/zsh", "/bin/bash"], + spawnPty: (() => { + throw new Error("posix_spawnp failed."); + }) as typeof spawn, + }); + + await assert.rejects( + service.create("workspace-1", "/tmp", owner.owner), + /Could not launch any shell.*\/bin\/zsh.*\/bin\/bash/u, + ); +}); + +test("the first candidate succeeding reports preferredShellSkipped false", async () => { + const owner = ownerState(); + let spawnedShell = ""; + const service = new TerminalService({ + prepareSpawnHelper: async () => undefined, + shellCandidates: () => ["/bin/zsh", "/bin/sh"], + spawnPty: ((file: string) => { + spawnedShell = file; + return fakePty().pty; + }) as typeof spawn, + }); + + const session = await service.create("workspace-1", "/tmp", owner.owner); + assert.equal(spawnedShell, "/bin/zsh"); + assert.equal(session.resolvedShell, "/bin/zsh"); + assert.equal(session.preferredShellSkipped, false); +}); + +test("persisted history seeds a reopened terminal buffer", async () => { + const owner = ownerState(); + let history = "prior output\n"; + // A minimal in-memory history store stub. + const historyStore = { + read: async () => history, + append: (_ws: string, data: string) => { + history += data; + }, + flush: async () => undefined, + }; + const service = new TerminalService({ + prepareSpawnHelper: async () => undefined, + spawnPty: (() => fakePty().pty) as typeof spawn, + historyStore, + }); + + const session = await service.create("workspace-1", "/tmp", owner.owner); + // The restored history is available via snapshot, so the renderer can + // re-hydrate xterm with the prior session's output. + const snapshot = service.snapshot(session.id, owner.owner); + assert.equal(snapshot.buffer, "prior output\n"); +}); From 6f5870657c3afb32393382cca541260053561424 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Mon, 10 Aug 2026 20:46:53 -0400 Subject: [PATCH 5/8] feat(ipc): add resolvedShell and preferredShellSkipped to TerminalSession --- renderer/lib/ipc.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/renderer/lib/ipc.ts b/renderer/lib/ipc.ts index 8725764..53e8b78 100644 --- a/renderer/lib/ipc.ts +++ b/renderer/lib/ipc.ts @@ -455,6 +455,10 @@ export interface TerminalSession { id: string; workspaceId: string; cwd: string; + /** The shell that actually launched this session (e.g. `/bin/zsh`). */ + resolvedShell: string; + /** True when the preferred shell was skipped and a fallback launched it. */ + preferredShellSkipped: boolean; } export interface TerminalSnapshot { From da5dbd30ce70ba7ac90663185d0216d013cf5b7c Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Mon, 10 Aug 2026 20:46:53 -0400 Subject: [PATCH 6/8] feat(terminal-drawer): toast when a fallback shell launched the terminal --- renderer/components/terminal-drawer.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/renderer/components/terminal-drawer.tsx b/renderer/components/terminal-drawer.tsx index d2ea006..312e51a 100644 --- a/renderer/components/terminal-drawer.tsx +++ b/renderer/components/terminal-drawer.tsx @@ -180,6 +180,13 @@ export function WorkspaceTerminalProvider({ children }: { children: React.ReactN } try { const session = await terminalApi.create(active.id); + // Surface once when the preferred shell was unavailable and a fallback + // launched the terminal — the user should know their $SHELL is broken. + if (session.preferredShellSkipped) { + toast.info( + `Used ${session.resolvedShell} because $SHELL was unavailable. Check your shell preference if this is unexpected.`, + ); + } setSessions((previous) => [...previous, session]); setActiveId(session.id); setOpen(true); From 15ad2d25c6329f76b233fc04e66dfefca6e177a2 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Mon, 10 Aug 2026 20:46:53 -0400 Subject: [PATCH 7/8] chore(test): register terminal + terminal-history tests in test/test:coverage --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 02c977c..c94a257 100644 --- a/package.json +++ b/package.json @@ -69,8 +69,8 @@ "test:assistant-automations": "tsx --test main/handlers/scheduled-tasks-parse.test.ts main/services/assistant/automation-runtime-contract.test.ts main/services/assistant/mcp-tool.test.ts main/services/assistant/project-tool.test.ts main/services/assistant/system-prompt.test.ts main/services/assistant/tool-loop-guard.test.ts main/services/mcp-selection.test.ts main/services/scheduled-settings-core.test.ts main/services/schedule-guard.test.ts main/services/schedule-store.test.ts main/services/schedule-tool.test.ts renderer/components/assistant/assistant-ui.test.tsx renderer/lib/scheduled-mcp-access-contract.test.ts renderer/shared/assistant.test.ts", "test:onboarding": "tsx --test main/services/onboarding-reset-core.test.ts main/services/onboarding-reset-lifecycle.test.ts renderer/components/onboarding-flow.test.tsx renderer/lib/onboarding-state.test.ts", "test:compaction": "tsx --test main/services/pi-compaction-core.test.ts", - "test": "tsx --test main/handlers/assistant-parse.test.ts main/services/assistant/system-prompt.test.ts main/services/chat-generation-start.test.ts main/services/chat-title-policy.test.ts main/services/chat-title-routing.test.ts main/services/chat-store-core.test.ts main/services/codex-provider.test.ts main/services/coding-tools.test.ts main/services/config-store-core.test.ts main/services/computer-use/computer-use-foundation.test.ts main/services/computer-use/computer-use-tool.test.ts main/services/computer-use/generation-gate.test.ts main/services/computer-use/safety.test.ts main/services/computer-use/settings-core.test.ts main/services/computer-use/status-core.test.ts main/services/data-store.test.ts main/services/data-store.resilience.test.ts main/services/aiden-config-dir.test.ts main/services/portable-config-core.test.ts main/services/portable-config-core.roundtrip.test.ts main/services/portable-config-watch-core.test.ts main/services/secret-map-core.test.ts main/services/dev-log.test.ts main/services/dictation-coordinator.test.ts main/services/dictation-paste.test.ts main/services/foundation-models-connection.test.ts main/services/foundation-models-connection-core.test.ts main/services/gemini-context-cache.test.ts main/services/generation-bound-connection-cache.test.ts main/services/generation-context.test.ts main/services/generation-messages.test.ts main/services/generation-runtime.test.ts main/services/external-editors.test.ts main/services/git.test.ts main/services/model-runtime-core.test.ts main/services/models.test.ts main/services/mcp-oauth-operation.test.ts main/services/mcp-oauth-session.test.ts main/services/mcp-presets.test.ts main/services/pi-credential-store-core.test.ts main/services/pi-provider-contract.test.ts main/services/profile-share-core.test.ts main/services/profile-share-files.test.ts main/services/profile.test.ts main/services/provider-auth-flow-core.test.ts main/services/provider-auth-owner.test.ts main/services/provider-key-policy.test.ts main/services/provider-list-core.test.ts main/services/quit-barrier.test.ts main/services/scratch-workspace.test.ts main/services/skills-discovery.test.ts main/services/tool-approval.test.ts main/services/local-runtime-status.test.ts main/services/usage-store-core.test.ts main/services/workspace-files.test.ts main/windows/pill-window-security.test.ts renderer/components/assistant/use-assistant-chat.test.ts renderer/components/assistant/assistant-ui.test.tsx renderer/components/activity-feed.test.tsx renderer/components/environment-subagents-contract.test.ts renderer/components/subagents-panel.test.tsx renderer/components/chat-sidebar.test.tsx renderer/components/composer.test.tsx renderer/main/chat-transition.test.tsx renderer/components/usage/profile-share-card.test.tsx renderer/lib/accessibility-refresh.test.ts renderer/lib/agent-activity.test.ts renderer/lib/assistant-dock.test.ts renderer/lib/assistant-motion-contract.test.ts renderer/lib/dialog-motion-contract.test.ts renderer/lib/chat-deletion-cache.test.ts renderer/lib/chat-terminal-sync.test.ts renderer/lib/ipc-stream.test.ts renderer/lib/chat-title-reveal.test.ts renderer/lib/codex-auth-session.test.ts renderer/lib/codex-provider-cache.test.ts renderer/lib/composer-placeholder.test.ts renderer/lib/computer-use-notice.test.ts renderer/lib/dictation-operation-gate.test.ts renderer/lib/editor-preference.test.ts renderer/lib/environment-panel-layout.test.ts renderer/lib/subagent-view-state.test.ts renderer/lib/truncate-path.test.ts renderer/lib/mcp-preset-state.test.ts renderer/lib/model-display.test.ts renderer/lib/model-picker-data.test.ts renderer/lib/profile-share-data.test.ts renderer/lib/sidebar-chat-shortcuts.test.ts renderer/lib/usage-profile-data.test.ts renderer/shared/appearance.test.ts renderer/shared/provider-deployment.test.ts main/handlers/ipc-contract.test.ts main/handlers/chat.parse.test.ts main/handlers/voice-codec.test.ts main/handlers/phase2-parse.test.ts scripts/apple-developer-tools.test.mjs scripts/check-macos-release.test.mjs scripts/computer-use-packaged-acceptance.test.mjs scripts/configure-electron-fuses.test.mjs scripts/model-snapshot-core.test.mjs scripts/prepare-macos-dev-runtime.test.mjs scripts/prepare-macos-package-output.test.mjs scripts/run-macos-distribution.test.mjs scripts/sign-macos.test.mjs scripts/hide-dmg-support-files.test.mjs scripts/update-model-capabilities.test.mjs scripts/vendor-cua-driver.test.mjs scripts/verify-macos-package.test.mjs && npm run test:worktree-remover:native && npm run test:computer-use:native", - "test:coverage": "tsx --test --experimental-test-coverage main/handlers/assistant-parse.test.ts main/services/assistant/system-prompt.test.ts main/services/chat-generation-start.test.ts main/services/chat-title-policy.test.ts main/services/chat-title-routing.test.ts main/services/chat-store-core.test.ts main/services/codex-provider.test.ts main/services/coding-tools.test.ts main/services/config-store-core.test.ts main/services/computer-use/computer-use-foundation.test.ts main/services/computer-use/computer-use-tool.test.ts main/services/computer-use/generation-gate.test.ts main/services/computer-use/safety.test.ts main/services/computer-use/settings-core.test.ts main/services/computer-use/status-core.test.ts main/services/data-store.test.ts main/services/data-store.resilience.test.ts main/services/aiden-config-dir.test.ts main/services/portable-config-core.test.ts main/services/portable-config-core.roundtrip.test.ts main/services/portable-config-watch-core.test.ts main/services/dev-log.test.ts main/services/dictation-coordinator.test.ts main/services/dictation-paste.test.ts main/services/foundation-models-connection.test.ts main/services/foundation-models-connection-core.test.ts main/services/generation-bound-connection-cache.test.ts main/services/generation-context.test.ts main/services/generation-messages.test.ts main/services/generation-runtime.test.ts main/services/external-editors.test.ts main/services/git.test.ts main/services/model-runtime-core.test.ts main/services/pi-compaction-core.test.ts main/services/models.test.ts main/services/mcp-oauth-operation.test.ts main/services/mcp-oauth-session.test.ts main/services/mcp-presets.test.ts main/services/pi-credential-store-core.test.ts main/services/pi-provider-contract.test.ts main/services/profile-share-core.test.ts main/services/profile-share-files.test.ts main/services/profile.test.ts main/services/provider-auth-flow-core.test.ts main/services/provider-auth-owner.test.ts main/services/provider-key-policy.test.ts main/services/provider-list-core.test.ts main/services/quit-barrier.test.ts main/services/scratch-workspace.test.ts main/services/skills-discovery.test.ts main/services/tool-approval.test.ts main/services/local-runtime-status.test.ts main/services/usage-store-core.test.ts main/services/workspace-files.test.ts main/windows/pill-window-security.test.ts renderer/components/assistant/use-assistant-chat.test.ts renderer/components/assistant/assistant-ui.test.tsx renderer/components/environment-subagents-contract.test.ts renderer/components/subagents-panel.test.tsx renderer/components/chat-sidebar.test.tsx renderer/components/composer.test.tsx renderer/main/chat-transition.test.tsx renderer/components/usage/profile-share-card.test.tsx renderer/lib/accessibility-refresh.test.ts renderer/lib/agent-activity.test.ts renderer/lib/assistant-dock.test.ts renderer/lib/assistant-motion-contract.test.ts renderer/lib/dialog-motion-contract.test.ts renderer/lib/chat-deletion-cache.test.ts renderer/lib/chat-terminal-sync.test.ts renderer/lib/ipc-stream.test.ts renderer/lib/chat-title-reveal.test.ts renderer/lib/codex-auth-session.test.ts renderer/lib/codex-auth-view-state.test.ts renderer/lib/codex-provider-cache.test.ts renderer/lib/composer-placeholder.test.ts renderer/lib/computer-use-notice.test.ts renderer/lib/dictation-operation-gate.test.ts renderer/lib/editor-preference.test.ts renderer/lib/environment-panel-layout.test.ts renderer/lib/subagent-view-state.test.ts renderer/lib/truncate-path.test.ts renderer/lib/mcp-preset-state.test.ts renderer/lib/model-display.test.ts renderer/lib/profile-share-data.test.ts renderer/lib/sidebar-chat-shortcuts.test.ts renderer/lib/usage-profile-data.test.ts renderer/shared/appearance.test.ts renderer/shared/provider-deployment.test.ts main/handlers/ipc-contract.test.ts main/handlers/chat.parse.test.ts main/handlers/voice-codec.test.ts main/handlers/phase2-parse.test.ts scripts/apple-developer-tools.test.mjs scripts/check-macos-release.test.mjs scripts/computer-use-packaged-acceptance.test.mjs scripts/configure-electron-fuses.test.mjs scripts/model-snapshot-core.test.mjs scripts/prepare-macos-dev-runtime.test.mjs scripts/prepare-macos-package-output.test.mjs scripts/run-macos-distribution.test.mjs scripts/sign-macos.test.mjs scripts/update-model-capabilities.test.mjs scripts/vendor-cua-driver.test.mjs scripts/verify-macos-package.test.mjs", + "test": "tsx --test main/handlers/assistant-parse.test.ts main/services/assistant/system-prompt.test.ts main/services/chat-generation-start.test.ts main/services/chat-title-policy.test.ts main/services/chat-title-routing.test.ts main/services/chat-store-core.test.ts main/services/codex-provider.test.ts main/services/coding-tools.test.ts main/services/config-store-core.test.ts main/services/computer-use/computer-use-foundation.test.ts main/services/computer-use/computer-use-tool.test.ts main/services/computer-use/generation-gate.test.ts main/services/computer-use/safety.test.ts main/services/computer-use/settings-core.test.ts main/services/computer-use/status-core.test.ts main/services/data-store.test.ts main/services/data-store.resilience.test.ts main/services/terminal.test.ts main/services/terminal-history.test.ts main/services/aiden-config-dir.test.ts main/services/portable-config-core.test.ts main/services/portable-config-core.roundtrip.test.ts main/services/portable-config-watch-core.test.ts main/services/secret-map-core.test.ts main/services/dev-log.test.ts main/services/dictation-coordinator.test.ts main/services/dictation-paste.test.ts main/services/foundation-models-connection.test.ts main/services/foundation-models-connection-core.test.ts main/services/gemini-context-cache.test.ts main/services/generation-bound-connection-cache.test.ts main/services/generation-context.test.ts main/services/generation-messages.test.ts main/services/generation-runtime.test.ts main/services/external-editors.test.ts main/services/git.test.ts main/services/model-runtime-core.test.ts main/services/models.test.ts main/services/mcp-oauth-operation.test.ts main/services/mcp-oauth-session.test.ts main/services/mcp-presets.test.ts main/services/pi-credential-store-core.test.ts main/services/pi-provider-contract.test.ts main/services/profile-share-core.test.ts main/services/profile-share-files.test.ts main/services/profile.test.ts main/services/provider-auth-flow-core.test.ts main/services/provider-auth-owner.test.ts main/services/provider-key-policy.test.ts main/services/provider-list-core.test.ts main/services/quit-barrier.test.ts main/services/scratch-workspace.test.ts main/services/skills-discovery.test.ts main/services/tool-approval.test.ts main/services/local-runtime-status.test.ts main/services/usage-store-core.test.ts main/services/workspace-files.test.ts main/windows/pill-window-security.test.ts renderer/components/assistant/use-assistant-chat.test.ts renderer/components/assistant/assistant-ui.test.tsx renderer/components/activity-feed.test.tsx renderer/components/environment-subagents-contract.test.ts renderer/components/subagents-panel.test.tsx renderer/components/chat-sidebar.test.tsx renderer/components/composer.test.tsx renderer/main/chat-transition.test.tsx renderer/components/usage/profile-share-card.test.tsx renderer/lib/accessibility-refresh.test.ts renderer/lib/agent-activity.test.ts renderer/lib/assistant-dock.test.ts renderer/lib/assistant-motion-contract.test.ts renderer/lib/dialog-motion-contract.test.ts renderer/lib/chat-deletion-cache.test.ts renderer/lib/chat-terminal-sync.test.ts renderer/lib/ipc-stream.test.ts renderer/lib/chat-title-reveal.test.ts renderer/lib/codex-auth-session.test.ts renderer/lib/codex-provider-cache.test.ts renderer/lib/composer-placeholder.test.ts renderer/lib/computer-use-notice.test.ts renderer/lib/dictation-operation-gate.test.ts renderer/lib/editor-preference.test.ts renderer/lib/environment-panel-layout.test.ts renderer/lib/subagent-view-state.test.ts renderer/lib/truncate-path.test.ts renderer/lib/mcp-preset-state.test.ts renderer/lib/model-display.test.ts renderer/lib/model-picker-data.test.ts renderer/lib/profile-share-data.test.ts renderer/lib/sidebar-chat-shortcuts.test.ts renderer/lib/usage-profile-data.test.ts renderer/shared/appearance.test.ts renderer/shared/provider-deployment.test.ts main/handlers/ipc-contract.test.ts main/handlers/chat.parse.test.ts main/handlers/voice-codec.test.ts main/handlers/phase2-parse.test.ts scripts/apple-developer-tools.test.mjs scripts/check-macos-release.test.mjs scripts/computer-use-packaged-acceptance.test.mjs scripts/configure-electron-fuses.test.mjs scripts/model-snapshot-core.test.mjs scripts/prepare-macos-dev-runtime.test.mjs scripts/prepare-macos-package-output.test.mjs scripts/run-macos-distribution.test.mjs scripts/sign-macos.test.mjs scripts/hide-dmg-support-files.test.mjs scripts/update-model-capabilities.test.mjs scripts/vendor-cua-driver.test.mjs scripts/verify-macos-package.test.mjs && npm run test:worktree-remover:native && npm run test:computer-use:native", + "test:coverage": "tsx --test --experimental-test-coverage main/handlers/assistant-parse.test.ts main/services/assistant/system-prompt.test.ts main/services/chat-generation-start.test.ts main/services/chat-title-policy.test.ts main/services/chat-title-routing.test.ts main/services/chat-store-core.test.ts main/services/codex-provider.test.ts main/services/coding-tools.test.ts main/services/config-store-core.test.ts main/services/computer-use/computer-use-foundation.test.ts main/services/computer-use/computer-use-tool.test.ts main/services/computer-use/generation-gate.test.ts main/services/computer-use/safety.test.ts main/services/computer-use/settings-core.test.ts main/services/computer-use/status-core.test.ts main/services/data-store.test.ts main/services/data-store.resilience.test.ts main/services/terminal.test.ts main/services/terminal-history.test.ts main/services/aiden-config-dir.test.ts main/services/portable-config-core.test.ts main/services/portable-config-core.roundtrip.test.ts main/services/portable-config-watch-core.test.ts main/services/dev-log.test.ts main/services/dictation-coordinator.test.ts main/services/dictation-paste.test.ts main/services/foundation-models-connection.test.ts main/services/foundation-models-connection-core.test.ts main/services/generation-bound-connection-cache.test.ts main/services/generation-context.test.ts main/services/generation-messages.test.ts main/services/generation-runtime.test.ts main/services/external-editors.test.ts main/services/git.test.ts main/services/model-runtime-core.test.ts main/services/pi-compaction-core.test.ts main/services/models.test.ts main/services/mcp-oauth-operation.test.ts main/services/mcp-oauth-session.test.ts main/services/mcp-presets.test.ts main/services/pi-credential-store-core.test.ts main/services/pi-provider-contract.test.ts main/services/profile-share-core.test.ts main/services/profile-share-files.test.ts main/services/profile.test.ts main/services/provider-auth-flow-core.test.ts main/services/provider-auth-owner.test.ts main/services/provider-key-policy.test.ts main/services/provider-list-core.test.ts main/services/quit-barrier.test.ts main/services/scratch-workspace.test.ts main/services/skills-discovery.test.ts main/services/tool-approval.test.ts main/services/local-runtime-status.test.ts main/services/usage-store-core.test.ts main/services/workspace-files.test.ts main/windows/pill-window-security.test.ts renderer/components/assistant/use-assistant-chat.test.ts renderer/components/assistant/assistant-ui.test.tsx renderer/components/environment-subagents-contract.test.ts renderer/components/subagents-panel.test.tsx renderer/components/chat-sidebar.test.tsx renderer/components/composer.test.tsx renderer/main/chat-transition.test.tsx renderer/components/usage/profile-share-card.test.tsx renderer/lib/accessibility-refresh.test.ts renderer/lib/agent-activity.test.ts renderer/lib/assistant-dock.test.ts renderer/lib/assistant-motion-contract.test.ts renderer/lib/dialog-motion-contract.test.ts renderer/lib/chat-deletion-cache.test.ts renderer/lib/chat-terminal-sync.test.ts renderer/lib/ipc-stream.test.ts renderer/lib/chat-title-reveal.test.ts renderer/lib/codex-auth-session.test.ts renderer/lib/codex-auth-view-state.test.ts renderer/lib/codex-provider-cache.test.ts renderer/lib/composer-placeholder.test.ts renderer/lib/computer-use-notice.test.ts renderer/lib/dictation-operation-gate.test.ts renderer/lib/editor-preference.test.ts renderer/lib/environment-panel-layout.test.ts renderer/lib/subagent-view-state.test.ts renderer/lib/truncate-path.test.ts renderer/lib/mcp-preset-state.test.ts renderer/lib/model-display.test.ts renderer/lib/profile-share-data.test.ts renderer/lib/sidebar-chat-shortcuts.test.ts renderer/lib/usage-profile-data.test.ts renderer/shared/appearance.test.ts renderer/shared/provider-deployment.test.ts main/handlers/ipc-contract.test.ts main/handlers/chat.parse.test.ts main/handlers/voice-codec.test.ts main/handlers/phase2-parse.test.ts scripts/apple-developer-tools.test.mjs scripts/check-macos-release.test.mjs scripts/computer-use-packaged-acceptance.test.mjs scripts/configure-electron-fuses.test.mjs scripts/model-snapshot-core.test.mjs scripts/prepare-macos-dev-runtime.test.mjs scripts/prepare-macos-package-output.test.mjs scripts/run-macos-distribution.test.mjs scripts/sign-macos.test.mjs scripts/update-model-capabilities.test.mjs scripts/vendor-cua-driver.test.mjs scripts/verify-macos-package.test.mjs", "test:computer-use": "tsx --test main/services/computer-use/computer-use-foundation.test.ts main/services/computer-use/computer-use-tool.test.ts main/services/computer-use/generation-gate.test.ts main/services/computer-use/safety.test.ts main/services/computer-use/settings-core.test.ts main/services/computer-use/status-core.test.ts main/services/data-store.test.ts main/services/generation-messages.test.ts main/services/generation-runtime.test.ts main/services/quit-barrier.test.ts main/services/tool-approval.test.ts scripts/check-macos-release.test.mjs scripts/computer-use-packaged-acceptance.test.mjs scripts/configure-electron-fuses.test.mjs scripts/prepare-macos-package-output.test.mjs scripts/run-macos-distribution.test.mjs scripts/sign-macos.test.mjs scripts/vendor-cua-driver.test.mjs scripts/verify-macos-package.test.mjs && npm run test:computer-use:native", "test:computer-use:packaged": "node scripts/computer-use-packaged-acceptance.mjs", "test:computer-use:native": "cd native/computer-use-broker && CARGO_TARGET_DIR=../../build/computer-use-broker-test cargo fmt -- --check && CARGO_TARGET_DIR=../../build/computer-use-broker-test cargo test --locked && CARGO_TARGET_DIR=../../build/computer-use-broker-test cargo clippy --locked --all-targets -- -D warnings", From 432ea212aa7b57c68ec3593d4b0d1e8c7659d983 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Mon, 10 Aug 2026 20:51:09 -0400 Subject: [PATCH 8/8] test(subagents): update terminal spawn source assertion for trySpawnShell refactor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- main/services/subagents/subagent-phase3-contract.test.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/main/services/subagents/subagent-phase3-contract.test.ts b/main/services/subagents/subagent-phase3-contract.test.ts index 0f0b86c..b6ff3da 100644 --- a/main/services/subagents/subagent-phase3-contract.test.ts +++ b/main/services/subagents/subagent-phase3-contract.test.ts @@ -560,10 +560,7 @@ test("managed worktree deletion and terminal creation share workspace mutation a "if (ownerInvalidated())", revalidate, ); - const spawn = terminalService.indexOf( - "const pty = (this.options.spawnPty ?? spawn)(", - finalAbortCheck, - ); + const spawn = terminalService.indexOf("const { pty,", finalAbortCheck); const postSpawnCheck = terminalService.indexOf( "if (ownerInvalidated())", spawn,