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
37 changes: 31 additions & 6 deletions src/cli/ui/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1690,12 +1690,37 @@ function AppInner({
}, [session, loop, codeMode, syncPendingCount, log, pendingEdits, startupInfoHints, system]);

// Esc handles "abort the current turn" separately; Ctrl+C is the universal "I'm done" key.
const quitProcess = useQuit(transcriptRef);
const sessionStartTimeRef = useRef(Date.now());

const fireSessionEndHook = useCallback(async () => {
if (hookList.some((h) => h.event === "SessionEnd")) {
try {
await runHooks({
hooks: hookList,
payload: {
event: "SessionEnd",
cwd: currentRootDir,
sessionName: session ?? undefined,
sessionDurationMs: Date.now() - sessionStartTimeRef.current,
totalCostUsd: summary.totalCostUsd,
},
});
} catch {
// SessionEnd must not block exit
}
}
}, [hookList, currentRootDir, session, summary.totalCostUsd]);

const quitProcess = useQuit(transcriptRef, { beforeQuit: fireSessionEndHook });

const quitWithSessionEnd = useCallback(() => {
quitProcess();
}, [quitProcess]);

// Ctrl+D = standard TUI exit (matches the boot-banner hint). Always-on
// — no modal / picker should swallow it.
useKeystroke((ev) => {
if (ev.ctrl && ev.input === "d") quitProcess();
if (ev.ctrl && ev.input === "d") quitWithSessionEnd();
});

// Ctrl+R = verbose toggle. ReasoningCard / ToolCard skip elision while on.
Expand Down Expand Up @@ -1792,7 +1817,7 @@ function AppInner({
isLoopActive,
stopLoop,
loop,
quitProcess,
quitProcess: quitWithSessionEnd,
});
return;
}
Expand Down Expand Up @@ -1820,7 +1845,7 @@ function AppInner({
isLoopActive,
stopLoop,
loop,
quitProcess,
quitProcess: quitWithSessionEnd,
});
return;
}
Expand Down Expand Up @@ -3222,7 +3247,7 @@ function AppInner({
codeModeOn: !!codeMode,
isLoopActive,
stopLoop,
quitProcess,
quitProcess: quitWithSessionEnd,
pushHistory,
resetPendingModals,
text,
Expand Down Expand Up @@ -3627,7 +3652,7 @@ function AppInner({
codeShowEdit,
codeUndo,
currentRootDir,
quitProcess,
quitWithSessionEnd,
hookList,
loop,
latestVersion,
Expand Down
22 changes: 19 additions & 3 deletions src/cli/ui/hooks/useQuit.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,32 @@
import type { WriteStream } from "node:fs";
import { type MutableRefObject, useCallback, useEffect } from "react";
import { type MutableRefObject, useCallback, useEffect, useRef } from "react";
import { stopAndSaveCpuProfile } from "../../cpu-prof.js";

export interface UseQuitOptions {
beforeQuit?: () => Promise<void> | void;
}

/** Ctrl+C / SIGINT → flush transcript + (if profiling) save .cpuprofile, then `process.exit(0)`. We call `process.exit` directly rather than Ink's `exit()` because the singleton stdin reader keeps a `data` listener attached — `exit()` would unmount the React tree but leave the event loop alive and the terminal would hang. */
export function useQuit(transcriptRef: MutableRefObject<WriteStream | null>): () => void {
export function useQuit(
transcriptRef: MutableRefObject<WriteStream | null>,
opts?: UseQuitOptions,
): () => void {
const quittingRef = useRef(false);

const quitProcess = useCallback(() => {
if (quittingRef.current) return;
quittingRef.current = true;
transcriptRef.current?.end();
void (async () => {
try {
await opts?.beforeQuit?.();
} catch {
// beforeQuit must not block or crash the exit path
}
await stopAndSaveCpuProfile();
process.exit(0);
})();
}, [transcriptRef]);
}, [transcriptRef, opts?.beforeQuit]);

useEffect(() => {
process.on("SIGINT", quitProcess);
Expand Down
13 changes: 12 additions & 1 deletion src/core/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,18 @@ export interface CheckpointRestoredEvent extends EventBase {
export interface HookFiredEvent extends EventBase {
type: "hook.fired";
hookName: string;
phase: "PreToolUse" | "PostToolUse" | "UserPromptSubmit" | "Stop";
/** Must match HookEvent type in src/hooks.ts — keep in sync (V-TE-05). */
phase:
| "SessionStart"
| "TurnStart"
| "PreToolUse"
| "PostToolUse"
| "UserPromptSubmit"
| "PreModelCall"
| "PostModelCall"
| "TurnEnd"
| "Stop"
| "SessionEnd";
outcome: "ok" | "blocked" | "modified" | "error";
}

Expand Down
59 changes: 54 additions & 5 deletions src/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,25 +7,55 @@ import { join } from "node:path";
import { projectHooksTrusted } from "./config.js";
import { t } from "./i18n/index.js";

export type HookEvent = "PreToolUse" | "PostToolUse" | "UserPromptSubmit" | "Stop";

/** All four events as a const array — drives slash listing + validation. */
export type HookEvent =
| "SessionStart"
| "TurnStart"
| "PreToolUse"
| "PostToolUse"
| "UserPromptSubmit"
| "PreModelCall"
| "PostModelCall"
| "TurnEnd"
| "Stop"
| "SessionEnd";

/** All ten events as a const array — drives slash listing + validation. */
export const HOOK_EVENTS: readonly HookEvent[] = [
"SessionStart",
"TurnStart",
"PreToolUse",
"PostToolUse",
"UserPromptSubmit",
"PreModelCall",
"PostModelCall",
"TurnEnd",
"Stop",
"SessionEnd",
] as const;

/** Only the gating events can block the loop. */
const BLOCKING_EVENTS: ReadonlySet<HookEvent> = new Set(["PreToolUse", "UserPromptSubmit"]);
export const BLOCKING_EVENTS: ReadonlySet<HookEvent> = new Set([
"PreToolUse",
"UserPromptSubmit",
"TurnStart",
"PreModelCall",
"TurnEnd",
]);

/** Per-event default timeout. Tool/prompt hooks gate progress, so they're tight. */
const DEFAULT_TIMEOUTS_MS: Record<HookEvent, number> = {
SessionStart: 10_000,
TurnStart: 5_000,
PreToolUse: 5_000,
UserPromptSubmit: 5_000,
PostToolUse: 30_000,
UserPromptSubmit: 5_000,
PreModelCall: 10_000,
PostModelCall: 30_000,
// 10s is enough for a lightweight audit check; 30s × 3-strike = 90s worst-case
// wasted wait if a hook misbehaves (V-TE-04).
TurnEnd: 10_000,
Stop: 30_000,
SessionEnd: 10_000,
};

export type HookScope = "project" | "global";
Expand Down Expand Up @@ -176,6 +206,21 @@ export interface HookPayload {
lastAssistantText?: string;
last_assistant_message?: string;
turn?: number;

sessionName?: string;
sessionDurationMs?: number;
totalCostUsd?: number;

modelMessages?: ReadonlyArray<{ role: string; content: string }>;
model?: string;

modelResponse?: { role: string; content: string; reasoning_content?: string };
usage?: {
promptTokens: number;
completionTokens: number;
cacheHitTokens: number;
cacheMissTokens: number;
};
}

/** Test seam — same shape as Node's spawn but returns a Promise of the raw outcome bits. */
Expand Down Expand Up @@ -276,6 +321,10 @@ function defaultSpawner(input: HookSpawnInput): Promise<HookSpawnResult> {
});
});

// EPIPE when child closes stdin before parent finishes writing —
// safe to ignore (close handler fires with the correct exit code).
child.stdin.on("error", () => {});

try {
child.stdin.write(input.stdin);
child.stdin.end();
Expand Down
Loading