Enhancements for Claude Code auto mode

Enhancements for Claude Code auto mode.
Records each tool call's permission verdict. A function-hooks mod, so it loads only where CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1. It depends on the mod-events plugin, which writes its events.
tool.check: keeps the engine's verdict (allow, ask, deny), the deciding rule or hook, and the loop's agentId.tool.call: times the call and emits a tool.verdict event with the verdict. Tools that wait on the person (AskUserQuestion, the plan-mode tools) are marked interactive.session.start: emits a session.start event.turn.step: emits a server.tool event for each tool the API ran inside a request.The observability:session index ingests the events as mod_events and reads them through the classifier_verdicts view. The engine does not expose the permission mode to tool.check, so the records cannot tell an auto-mode classifier ask from a dialog ask.
bun scripts/mod-test.ts auto-modemod/register.ts 115 lines1import type { EngineInterface, On, ToolCallResult } from "claude-code";
2
3type ModEventsInput = Parameters<EngineInterface["modEvents"]["emit"]>[0];
4
5const MOD = "auto-mode";
6
7/**
8 * Tools whose run waits on the person by design, so their wall time measures the person.
9 */
10const INTERACTIVE = new Set(["AskUserQuestion", "EnterPlanMode", "ExitPlanMode"]);
11
12interface Verdict {
13 decision: string;
14 reason: string | null;
15 rule: string | null;
16 hook: string | null;
17 checkedAt: number;
18}
19
20function keyOf(agentId: string | undefined, toolUseId: string): string {
21 return `${agentId ?? ""}/${toolUseId}`;
22}
23
24function outcomeOf(result: ToolCallResult): "ok" | "error" | "deny" {
25 if (result.deny !== undefined) return "deny";
26 if (result.isError) return "error";
27 return "ok";
28}
29
30/**
31 * Records each tool call's permission verdict and each server tool the API ran.
32 * An `ask` goes to the mode's decider, which the mod API does not expose, so
33 * a verdict's `ms` spans the decider plus the tool. `check_ms` is the time to the verdict.
34 */
35export function register(on: On): void {
36 const verdicts = new Map<string, Verdict>();
37
38 on("session.start", ($, e, next) => {
39 void $.modEvents.emit({ mod: MOD, event: "session.start" });
40 return next(e);
41 });
42
43 // Both hooks only observe, so a failure lets the call go on as the engine settled it.
44 on("tool.check", async ($, e, next) => {
45 const verdict = await next(e);
46 if (e.tool_use_id !== undefined) {
47 verdicts.set(keyOf(e.agentId, e.tool_use_id), {
48 decision: verdict.decision,
49 reason: verdict.reason ?? null,
50 rule: verdict.rule ?? null,
51 hook: verdict.hook ?? null,
52 checkedAt: await $.clock.now(),
53 });
54 }
55 return verdict;
56 }).catch(($, e, next) => next(e));
57
58 on("tool.call", async ($, e, next) => {
59 const key = keyOf(e.agentId, e.tool_use_id);
60 const startedAt = await $.clock.now();
61 const settled: { result: ToolCallResult } | { thrown: unknown } = await next(e).then(
62 (result: ToolCallResult) => ({ result }),
63 (thrown: unknown) => ({ thrown }),
64 );
65 const verdict = verdicts.get(key);
66 verdicts.delete(key);
67 const finishedAt = await $.clock.now();
68 const outcome = "result" in settled ? outcomeOf(settled.result) : "throw";
69 void $.modEvents.emit({
70 mod: MOD,
71 event: "tool.verdict",
72 ok: outcome === "ok",
73 ms: finishedAt - startedAt,
74 detail: {
75 tool_use_id: e.tool_use_id,
76 agent_id: e.agentId ?? null,
77 tool: e.tool,
78 interactive: INTERACTIVE.has(e.tool),
79 decision: verdict?.decision ?? null,
80 rule: verdict?.rule ?? null,
81 hook: verdict?.hook ?? null,
82 reason: verdict?.reason ?? null,
83 started_at: startedAt,
84 check_ms: verdict === undefined ? null : verdict.checkedAt - startedAt,
85 outcome,
86 },
87 });
88 if ("thrown" in settled) throw settled.thrown;
89 return settled.result;
90 }).catch(($, e, next) => next(e));
91
92 on("turn.step", async function* ($, e, next) {
93 const response = yield* next(e);
94 const events: ModEventsInput[] = [];
95 for (const use of response.serverToolUses ?? []) {
96 const event: ModEventsInput = {
97 mod: MOD,
98 event: "server.tool",
99 detail: {
100 tool_use_id: use.id,
101 agent_id: e.agentId ?? null,
102 tool: use.name,
103 turn_id: response.turnId,
104 step: response.index,
105 started_at: use.startedAt,
106 },
107 };
108 if (use.endedAt !== undefined) event.ms = use.endedAt - use.startedAt;
109 events.push(event);
110 }
111 await Promise.all(events.map((event) => $.modEvents.emit(event)));
112 return response;
113 });
114}
115