diff --git a/src/engine/crash-handler.test.ts b/src/engine/crash-handler.test.ts new file mode 100644 index 0000000..fde3fec --- /dev/null +++ b/src/engine/crash-handler.test.ts @@ -0,0 +1,85 @@ +import { describe, test, expect, beforeEach, afterEach } from "bun:test"; +import { mkdtempSync, writeFileSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + formatResumeHint, + writeBootMarker, + clearBootMarker, + detectBootCrashLoop, + fsyncSessionFile, + restoreTerminalAfterCrash, + installCrashHandler, + _resetCrashHandlerForTests, +} from "./crash-handler.js"; + +describe("crash-handler", () => { + const origHome = process.env["HOME"]; + + beforeEach(() => { + const dir = mkdtempSync(join(tmpdir(), "klaatai-crash-")); + process.env["HOME"] = dir; + _resetCrashHandlerForTests(); + clearBootMarker(); + }); + + afterEach(() => { + if (origHome !== undefined) process.env["HOME"] = origHome; + else delete process.env["HOME"]; + _resetCrashHandlerForTests(); + }); + + test("formatResumeHint includes session id and resume command", () => { + const hint = formatResumeHint("2026-07-25T12-00-00-abcd"); + expect(hint).toContain("2026-07-25T12-00-00-abcd"); + expect(hint).toContain("klaatai --resume"); + }); + + test("boot marker detects crash loop until cleared", () => { + expect(detectBootCrashLoop()).toBe(false); + writeBootMarker(); + expect(detectBootCrashLoop()).toBe(true); + clearBootMarker(); + expect(detectBootCrashLoop()).toBe(false); + }); + + test("fsyncSessionFile succeeds on an existing file", () => { + const file = join(process.env["HOME"]!, "session.jsonl"); + writeFileSync(file, '{"role":"user","content":"hi"}\n', "utf-8"); + expect(() => fsyncSessionFile(file)).not.toThrow(); + }); + + test("restoreTerminalAfterCrash is safe to call", () => { + expect(() => restoreTerminalAfterCrash()).not.toThrow(); + }); + + test("installCrashHandler prints resume hint and exits on uncaughtException", () => { + const stderr: string[] = []; + const origWrite = process.stderr.write.bind(process.stderr); + process.stderr.write = ((chunk: string | Uint8Array) => { + stderr.push(String(chunk)); + return true; + }) as typeof process.stderr.write; + + let exitCode: number | undefined; + const origExit = process.exit; + process.exit = ((code?: number) => { + exitCode = code; + throw new Error("exit"); + }) as typeof process.exit; + + installCrashHandler({ + getSessionId: () => "sess-123", + getSessionFile: () => join(process.env["HOME"]!, "sess.jsonl"), + }); + + expect(() => process.emit("uncaughtException", new Error("boom"))).toThrow("exit"); + expect(exitCode).toBe(1); + expect(stderr.join("")).toContain("sess-123"); + expect(stderr.join("")).toContain("klaatai --resume"); + + process.stderr.write = origWrite; + process.exit = origExit; + _resetCrashHandlerForTests(); + }); +}); diff --git a/src/engine/crash-handler.ts b/src/engine/crash-handler.ts new file mode 100644 index 0000000..e1bf272 --- /dev/null +++ b/src/engine/crash-handler.ts @@ -0,0 +1,122 @@ +/** + * Process-level crash handler for the TUI. + * + * On fatal errors: restore the terminal, fsync the session file, print a + * resume hint, and exit non-zero. A boot marker under ~/.klaatai/ detects + * crash loops during startup so the next run can skip plugins/MCP. + */ + +import { + existsSync, + mkdirSync, + openSync, + fsyncSync, + closeSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { + restoreTerminal, + disableMouse, + disableKitty, + disableBracketedPaste, +} from "./terminal.js"; + +const KLAATAI_DIR = join(homedir(), ".klaatai"); +const BOOT_MARKER = join(KLAATAI_DIR, ".boot-marker"); + +export interface CrashHandlerOptions { + getSessionId?: () => string | undefined; + getSessionFile?: () => string | undefined; + onCrash?: () => void; +} + +let installed = false; +let handling = false; + +/** Best-effort terminal teardown after a fatal error. */ +export function restoreTerminalAfterCrash(): void { + try { + disableBracketedPaste(); + disableKitty(); + disableMouse(); + restoreTerminal(); + } catch { + // Terminal may already be in a broken state — keep going. + } +} + +/** Fsync the session JSONL so the last append is durable on disk. */ +export function fsyncSessionFile(path?: string): void { + if (!path) return; + try { + const fd = openSync(path, "r"); + fsyncSync(fd); + closeSync(fd); + } catch { + // Session file may not exist yet — ignore. + } +} + +export function formatResumeHint(sessionId: string): string { + return `Session saved. Resume with:\n klaatai --resume ${sessionId}\n`; +} + +export function installCrashHandler(opts: CrashHandlerOptions = {}): void { + if (installed) return; + installed = true; + + const handle = (label: string, err: unknown) => { + if (handling) return; + handling = true; + + try { + opts.onCrash?.(); + } catch { + // onCrash must not block teardown. + } + + restoreTerminalAfterCrash(); + fsyncSessionFile(opts.getSessionFile?.()); + + const message = err instanceof Error ? err.message : String(err); + process.stderr.write(`\n\x1b[31mFatal error (${label}): ${message}\x1b[0m\n`); + + const sessionId = opts.getSessionId?.(); + if (sessionId) { + process.stderr.write(`\x1b[2m${formatResumeHint(sessionId)}\x1b[0m`); + } + + process.exit(1); + }; + + process.on("uncaughtException", (err) => handle("uncaughtException", err)); + process.on("unhandledRejection", (reason) => handle("unhandledRejection", reason)); +} + +/** Mark that a TUI boot is in progress (cleared once the REPL is ready). */ +export function writeBootMarker(): void { + mkdirSync(KLAATAI_DIR, { recursive: true }); + writeFileSync(BOOT_MARKER, new Date().toISOString(), "utf-8"); +} + +export function clearBootMarker(): void { + try { + if (existsSync(BOOT_MARKER)) unlinkSync(BOOT_MARKER); + } catch { + // Best effort. + } +} + +/** True when the previous run exited before clearing the boot marker. */ +export function detectBootCrashLoop(): boolean { + return existsSync(BOOT_MARKER); +} + +/** Reset handler state — for tests only. */ +export function _resetCrashHandlerForTests(): void { + installed = false; + handling = false; +} diff --git a/src/main.tsx b/src/main.tsx index b376faa..158376f 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -33,6 +33,10 @@ import { spawnSync as _openBrowser } from "node:child_process"; import { version as VERSION } from "../package.json"; import { loadProjectRules } from "./agent/system-prompt.js"; import { runAcpServer } from "./acp/agent.js"; +import { + detectBootCrashLoop, + writeBootMarker, +} from "./engine/crash-handler.js"; // ─── Helpers ───────────────────────────────────────────────────────────────── @@ -195,6 +199,14 @@ async function runSessionPicker(): Promise { * Full boot sequence: Splash → auth (if needed) → Welcome → REPL. */ async function boot(opts: { baseUrl?: string; dir?: string; resumeId?: string } = {}): Promise { + const safeMode = detectBootCrashLoop(); + if (safeMode) { + process.stderr.write( + "\x1b[33mWarning: previous run crashed during startup — starting in safe mode " + + "(plugins and MCP disabled).\x1b[0m\n", + ); + } + // ── Session picker (runs before TUI when `klaatcode -r` with no ID) ─────── if (opts.resumeId === "pick") { const picked = await runSessionPicker(); @@ -331,7 +343,12 @@ async function boot(opts: { baseUrl?: string; dir?: string; resumeId?: string } }; const client = new KlaatAIClient({ apiKey, baseUrl, onAuthExpired }); - const sessionResult = await runREPL(app, client, { ...config, baseUrl }, projectRoot, { theme, resumeId: opts.resumeId }); + writeBootMarker(); + const sessionResult = await runREPL(app, client, { ...config, baseUrl }, projectRoot, { + theme, + resumeId: opts.resumeId, + safeMode, + }); // REPL finished — clean up app.quit(); @@ -344,7 +361,7 @@ async function boot(opts: { baseUrl?: string; dir?: string; resumeId?: string } const cyan = "\x1b[36m"; const reset = "\x1b[0m"; process.stdout.write(`\n${dim}Session saved. Resume with:${reset}\n`); - process.stdout.write(` ${bold}${cyan}klaatcode --resume ${sessionResult.sessionId}${reset}\n\n`); + process.stdout.write(` ${bold}${cyan}klaatai --resume ${sessionResult.sessionId}${reset}\n\n`); } } diff --git a/src/screens/repl.ts b/src/screens/repl.ts index 185cfe8..477fc4a 100644 --- a/src/screens/repl.ts +++ b/src/screens/repl.ts @@ -34,6 +34,10 @@ import { enableKitty, disableKitty, enableBracketedPaste, disableBracketedPaste, } from "../engine/index.js"; +import { + installCrashHandler, + clearBootMarker, +} from "../engine/crash-handler.js"; import { KlaatAIClient, type Message, @@ -247,9 +251,16 @@ export async function runREPL( client: KlaatAIClient, config: Config, projectRoot: string, - opts: { theme?: Theme; resumeId?: string } = {}, + opts: { theme?: Theme; resumeId?: string; safeMode?: boolean } = {}, ): Promise<{ sessionId: string }> { + let sessionId = ""; + let sessionFile = ""; + installCrashHandler({ + getSessionId: () => sessionId || undefined, + getSessionFile: () => sessionFile || undefined, + }); + // ─── Widgets ────────────────────────────────────────────────────────────── const field = new InputField(); const chatSV = new ScrollView(); @@ -555,21 +566,26 @@ export async function runREPL( chatLinesDirty = true; app.requestRender(); }); - const mcpConfig = loadMCPConfig(projectRoot, { - importMcpConfigs: config.compat?.importMcpConfigs !== false, - onLog: (msg: string) => { process.stderr.write(msg + "\n"); }, - }); - if (Object.keys(mcpConfig.servers).length > 0) { - mcpManager.connect(mcpConfig); + const safeMode = opts.safeMode === true; + if (!safeMode) { + const mcpConfig = loadMCPConfig(projectRoot, { + importMcpConfigs: config.compat?.importMcpConfigs !== false, + onLog: (msg: string) => { process.stderr.write(msg + "\n"); }, + }); + if (Object.keys(mcpConfig.servers).length > 0) { + mcpManager.connect(mcpConfig); + } } // ─── Plugins (user tools from ~/.klaatai/plugins + .klaatai/tools) ──────── const pluginRegistry = new PluginRegistry(); - void pluginRegistry.load(projectRoot).then(() => { - if (pluginRegistry.plugins.length > 0 || pluginRegistry.errors.length > 0) { - app.requestRender(); - } - }); + if (!safeMode) { + void pluginRegistry.load(projectRoot).then(() => { + if (pluginRegistry.plugins.length > 0 || pluginRegistry.errors.length > 0) { + app.requestRender(); + } + }); + } // ─── Lifetime usage fetch ───────────────────────────────────────────────── @@ -850,8 +866,8 @@ export async function runREPL( const _sessionTs = new Date().toISOString().slice(0, 19).replace(/:/g, "-"); const _sessionRnd = Math.random().toString(36).slice(2, 6); - const sessionId = `${_sessionTs}-${_sessionRnd}`; - const sessionFile = join(SESSION_DIR, `${sessionId}.jsonl`); + sessionId = `${_sessionTs}-${_sessionRnd}`; + sessionFile = join(SESSION_DIR, `${sessionId}.jsonl`); const ledger = new SessionLedger(join(SESSION_DIR, `${sessionId}.ledger.md`)); function appendSessionMsg(msg: ChatMessage): void { @@ -5487,6 +5503,15 @@ export async function runREPL( // Switch the app's render function to our full-screen REPL app.setRenderFn(render); + clearBootMarker(); + + if (safeMode) { + pushSystemMsg( + "Safe mode: previous startup crashed — plugins and MCP servers were skipped. " + + "Fix the underlying issue, then restart normally.", + "error", + ); + } // Auto-resume if --resume flag was passed (ID resolved by pre-boot picker or directly) if (opts.resumeId) {