Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions src/engine/crash-handler.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
122 changes: 122 additions & 0 deletions src/engine/crash-handler.ts
Original file line number Diff line number Diff line change
@@ -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;
}
21 changes: 19 additions & 2 deletions src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 ─────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -195,6 +199,14 @@ async function runSessionPicker(): Promise<string | null> {
* Full boot sequence: Splash → auth (if needed) → Welcome → REPL.
*/
async function boot(opts: { baseUrl?: string; dir?: string; resumeId?: string } = {}): Promise<void> {
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();
Expand Down Expand Up @@ -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();
Expand All @@ -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`);
}
}

Expand Down
53 changes: 39 additions & 14 deletions src/screens/repl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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 ─────────────────────────────────────────────────

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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) {
Expand Down
Loading