diff --git a/README.md b/README.md index bf341ac..aa64370 100644 --- a/README.md +++ b/README.md @@ -315,15 +315,17 @@ Run shell commands on agent lifecycle events. Hooks receive a JSON payload on st ```json // .klaatai/hooks.json { + "session_start": ["echo \"session $KLAATAI_SESSION_ID started\" >> /tmp/klaatai.log"], "after_message": ["afplay /System/Library/Sounds/Glass.aiff"], "before_tool": [ { "matcher": "run_command", "command": "./scripts/guard-shell.sh" } ], - "after_tool": ["notify-send \"$KLAATAI_TOOL_NAME done\""] + "after_tool": ["notify-send \"$KLAATAI_TOOL_NAME done\""], + "session_end": ["echo \"session ended\" >> /tmp/klaatai.log"] } ``` -Events: `before_message` · `after_message` · `before_tool` · `after_tool` +Events: `session_start` · `session_end` · `before_message` · `after_message` · `before_tool` · `after_tool` ### Project Rules diff --git a/src/screens/repl.ts b/src/screens/repl.ts index 3c0a701..abf6253 100644 --- a/src/screens/repl.ts +++ b/src/screens/repl.ts @@ -78,6 +78,7 @@ import { renderSessionMarkdown, resolveExportPath, } from "./export-session.js"; +import { createSessionLifecycle } from "./session-lifecycle.js"; import { TIER_COSTS, VALID_TIERS, TIER_CONTEXT_WINDOW, COMPACT_TRIGGER_RATIO, @@ -1298,7 +1299,7 @@ export async function runREPL( // STDIN and, for before_tool, can BLOCK the call: exit code 2 (stderr = // reason) or stdout {"decision":"block","reason":"…"}. - type HookEvent = "before_tool" | "after_tool" | "before_message" | "after_message"; + type HookEvent = "before_tool" | "after_tool" | "before_message" | "after_message" | "session_start" | "session_end"; type HookEntry = string | { command: string; matcher?: string; timeout?: number }; type HooksConfig = Partial>; @@ -2044,7 +2045,7 @@ export async function runREPL( " /review [ref] — AI code review of current diff (default: git diff HEAD)", " /commit — generate a git commit message with AI and confirm before committing", " /skill [name] — invoke a saved prompt skill; /skill list; /skill new ", - " /hooks — list configured lifecycle hooks (before/after tool & message)", + " /hooks — list configured lifecycle hooks (session/message/tool)", " /why — explain last routing decision", " /tier [name] — lock a Klaatu routing tier (nano/fast/code/reason/heavy); no arg = picker; /tier smart = auto", " /model — pick the model: Klaatu or a custom third-party API", @@ -2950,18 +2951,23 @@ export async function runREPL( // ── /hooks — list configured hooks ─────────────────────────────── if (slash === "/hooks") { const hooks = loadHooks(); - const events: HookEvent[] = ["before_message", "after_message", "before_tool", "after_tool"]; + const events: HookEvent[] = [ + "session_start", "session_end", + "before_message", "after_message", "before_tool", "after_tool", + ]; const hasAny = events.some(e => (hooks[e]?.length ?? 0) > 0); if (!hasAny) { pushSystemMsg( "No hooks configured.\n\n" + "Create `.klaatai/hooks.json` or `~/.klaatai/hooks.json`:\n\n" + "```json\n{\n" + + ' "session_start": ["echo \\"session $KLAATAI_SESSION_ID started\\" >> /tmp/klaatai.log"],\n' + ' "after_message": ["afplay /System/Library/Sounds/Glass.aiff"],\n' + ' "before_tool": ["echo \\"Tool: $KLAATAI_TOOL_NAME\\" >> /tmp/klaatai.log"],\n' + - ' "after_tool": ["echo \\"Done: $KLAATAI_TOOL_NAME\\" >> /tmp/klaatai.log"]\n' + + ' "after_tool": ["echo \\"Done: $KLAATAI_TOOL_NAME\\" >> /tmp/klaatai.log"],\n' + + ' "session_end": ["echo \\"session ended\\" >> /tmp/klaatai.log"]\n' + "}\n```\n\n" + - "**Events:** `before_message` · `after_message` · `before_tool` · `after_tool`\n" + + "**Events:** `session_start` · `session_end` · `before_message` · `after_message` · `before_tool` · `after_tool`\n" + "**Env vars:** `KLAATAI_EVENT` · `KLAATAI_TOOL_NAME` · `KLAATAI_TOOL_ARGS` · `KLAATAI_PROJECT_ROOT` · `KLAATAI_SESSION_ID`", ); } else { @@ -4695,9 +4701,16 @@ export async function runREPL( let _quitting = false; let _resolveQuit: (() => void) | null = null; + // Guards session_start / session_end so multiple quit triggers + // (/exit, Ctrl+D, Ctrl+C) cannot double-fire session_end. + const sessionLife = createSessionLifecycle((event) => { + runHooks(event); + }); + function quit(): void { if (_quitting) return; _quitting = true; + sessionLife.end(); clearInterval(tipTimer); for (const u of unsubscribers) u(); mcpManager.disconnectAll(); @@ -5502,6 +5515,9 @@ export async function runREPL( } } + // Fire once after boot (config/auth/MCP loaded) and before the first prompt. + sessionLife.start(); + // Wait until quit() is called await new Promise((resolve) => { _resolveQuit = resolve; diff --git a/src/screens/session-lifecycle.test.ts b/src/screens/session-lifecycle.test.ts new file mode 100644 index 0000000..887d157 --- /dev/null +++ b/src/screens/session-lifecycle.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from "bun:test"; +import { createSessionLifecycle, type SessionLifecycleEvent } from "./session-lifecycle"; + +describe("createSessionLifecycle", () => { + test("session_start fires exactly once on start()", () => { + const events: SessionLifecycleEvent[] = []; + const life = createSessionLifecycle((e) => events.push(e)); + + life.start(); + life.start(); + life.start(); + + expect(events).toEqual(["session_start"]); + expect(life.started).toBe(true); + expect(life.ended).toBe(false); + }); + + test("session_end fires exactly once on end() — covers /exit, Ctrl+D, Ctrl+C all calling quit()", () => { + const events: SessionLifecycleEvent[] = []; + const life = createSessionLifecycle((e) => events.push(e)); + + life.start(); + // Simulate multiple quit triggers (Ctrl+C then /exit, etc.) + life.end(); + life.end(); + life.end(); + + expect(events).toEqual(["session_start", "session_end"]); + expect(life.ended).toBe(true); + }); + + test("session_end without a prior start still fires once", () => { + const events: SessionLifecycleEvent[] = []; + const life = createSessionLifecycle((e) => events.push(e)); + + life.end(); + life.end(); + + expect(events).toEqual(["session_end"]); + }); + + test("normal boot → quit sequence", () => { + const events: SessionLifecycleEvent[] = []; + const life = createSessionLifecycle((e) => events.push(e)); + + // boot + life.start(); + // graceful quit + life.end(); + + expect(events).toEqual(["session_start", "session_end"]); + }); +}); diff --git a/src/screens/session-lifecycle.ts b/src/screens/session-lifecycle.ts new file mode 100644 index 0000000..006b776 --- /dev/null +++ b/src/screens/session-lifecycle.ts @@ -0,0 +1,42 @@ +/** + * Session lifecycle hook guard — ensures session_start / session_end + * fire at most once per session, independent of how many times quit is + * requested (/exit, Ctrl+D, Ctrl+C all funnel through end()). + */ + +export type SessionLifecycleEvent = "session_start" | "session_end"; + +export interface SessionLifecycle { + /** Fire session_start exactly once. Subsequent calls are no-ops. */ + start(): void; + /** Fire session_end exactly once. Subsequent calls are no-ops. */ + end(): void; + readonly started: boolean; + readonly ended: boolean; +} + +/** + * @param fire callback that runs the configured hooks for the event + * (e.g. `(e) => runHooks(e)`). Injected so unit tests can + * assert call counts without spawning shells or booting the TUI. + */ +export function createSessionLifecycle( + fire: (event: SessionLifecycleEvent) => void, +): SessionLifecycle { + let started = false; + let ended = false; + return { + start() { + if (started) return; + started = true; + fire("session_start"); + }, + end() { + if (ended) return; + ended = true; + fire("session_end"); + }, + get started() { return started; }, + get ended() { return ended; }, + }; +}