Skip to content
Merged
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
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
26 changes: 21 additions & 5 deletions src/screens/repl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<Record<HookEvent, HookEntry[]>>;

Expand Down Expand Up @@ -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 <name>",
" /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",
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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<void>((resolve) => {
_resolveQuit = resolve;
Expand Down
53 changes: 53 additions & 0 deletions src/screens/session-lifecycle.test.ts
Original file line number Diff line number Diff line change
@@ -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"]);
});
});
42 changes: 42 additions & 0 deletions src/screens/session-lifecycle.ts
Original file line number Diff line number Diff line change
@@ -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; },
};
}
Loading