AgentBar's Claude Code mod. Observes only: writes what Claude Code decided without asking, and what it measures (context, rate limits, running subagents), to…

A Claude Code mod (2.1.287 or later) that lets AgentBar see what the hooks cannot: the calls Claude Code allowed or refused without asking you, and the figures it measures itself. Contract: docs/protocol.md, "mods.d".
~/.agentbar/mods.d/<session_id>.json (or under $AGENTBAR_HOME), at most once a second, and a last time with "ended": true when the session ends:context and rate_limits: Claude Code's own figures, a missing one left out, never zeroed;subagents: how many subagents are running;decisions: the newest 200 verdicts reached without a prompt (allow or deny), each naming who decided: a settings rule, the permission mode, or a hook. Read-only tools are left out, and only command, file_path, url and description are kept from a call's input.~/.agentbar/mods/config.json says {"band": true}) draws one line above the prompt when another session waits on you, with a Jump button that opens agentbar://focus?session=<id>.answers.d/, never approves, denies or asks.HOME and AGENTBAR_HOME, or run any command but /usr/bin/open agentbar://focus?…, and that one only when you press Jump.claude plugin validate --json --strict Scripts/mods/claude lists it:
./register.js hooks: session.start, session.measure, tool.check, tool.call, agent.spawn, turn.complete, session.end, ui.render{component=AbovePrompt}
./register.js calls: $.clock.after (via schedule), $.clock.every (via onStart, refreshBand), $.clock.now (via onCall, onCheck, pollWaiting, writeFile), $.env.get (via resolveRoot), $.fs.exists (via onStart), $.fs.list (via pollWaiting), $.fs.read (via pollWaiting, readConfig), $.fs.write (via writeFile), $.process.run (via jump), $.session.cwd (via ensureSession, onStart), $.session.id (via ensureSession, onStart), $.session.usage (via onStart), $.ui.invalidate (via setWaiting), $.ui.resolve (via drawBand)
./register.js env writes: nothing
./register.js env reads: AGENTBAR_HOME, HOME
Scripts/test/mod-test.sh holds those lists to an allow-list, so an edit that gains a capability fails CI rather than slipping through review.
claude --plugin-dir Scripts/mods/claude # this session only
bash Scripts/test/mod-test.sh # validate, allow-list, tests
AGENTBAR_LIVE_TESTS=1 bash Scripts/test/mod-test.sh # plus one real turn (about a cent)hooks/register.js 646 lines1// AgentBar's Claude Code mod: observe, write one file per session, answer nothing.
2//
3// Contract: docs/protocol.md, "mods.d". One file per session,
4// `<root>/mods.d/<session_id>.json`, holding what Claude Code measures (context,
5// rate limits, running subagents) and the verdicts it reached WITHOUT a prompt.
6//
7// Every hook here is observe-only. Each one calls `next` exactly once and returns
8// what `next` returned, untouched; the bookkeeping runs after, inside a try, and
9// each registration's `.catch` hands back `next(e)` — which, in a catch handler, is
10// replay-safe: when the hook had already called `next`, it resolves to that same
11// result and nothing beneath runs again. A bug in this file can lose a row of
12// bookkeeping; it can never change, delay or answer a tool call.
13//
14// What it never does: approve, deny or ask anything; write `answers.d/`; open a
15// window. The optional band (off unless `<root>/mods/config.json` says
16// `{"band": true}`) only *reads* `state.d/` and offers a Jump link.
17//
18// The host reads `on(...)` and `$.noun.method(...)` from this source to list what
19// the module hooks and calls (`claude plugin validate`), so every call on `$` is
20// spelled literally inside a top-level function whose parameter is `$`.
21// Scripts/test/mod-test.sh holds that list to an allow-list.
22
23export const VERSION = "1.56.0";
24
25// ---- Limits (docs/protocol.md, "mods.d") ------------------------------------
26
27const RING = 200; // newest verdicts kept
28const INPUT_KEYS = ["command", "file_path", "url", "description"];
29const INPUT_BYTES = 2048; // each kept input field, UTF-8
30const REASON_CHARS = 300;
31const WRITE_EVERY_MS = 1000; // debounce: at most one write a second, trailing write guaranteed
32const READ_ONLY = new Set([
33 "Read", "Glob", "Grep", "LS", "TodoWrite", "NotebookRead", "WebSearch", "ToolSearch", "BashOutput",
34]);
35
36// A call that has not reached tool.check this long after tool.call began is held
37// by something beneath this mod in tool.call (see onCallStart).
38const HOLD_MS = 3000;
39
40// Band
41const CONFIG_EVERY_MS = 30000;
42const POLL_EVERY_MS = 2000;
43const STALE_S = 600; // a state.d row older than this is not "waiting" any more
44const MAX_ROWS = 64; // state.d files read per poll
45// The waiting-on-the-person states of state.d (docs/protocol.md, "state.d").
46const WAITING = { permission: "needs your approval", question: "has a question" };
47// A Claude Code session whose own mod reports a call held before it runs
48// (mods.d `held`) is waiting on the person too, though its row says working.
49const HELD = "is holding a command for you";
50const WORKING = new Set(["thinking", "tool"]);
51const AGENT_NAMES = {
52 claude: "Claude", codex: "Codex", copilot: "Copilot", antigravity: "Antigravity",
53 cursor: "Cursor", gemini: "Gemini", qwen: "Qwen", opencode: "OpenCode", devin: "Devin",
54};
55
56// ---- Module state (a reload starts it over; session.start fires again) ------
57
58const S = {
59 root: null, // state root, resolved once per session.start
60 sessionId: null, // the state.d row name this session writes under
61 cwd: "",
62 context: null, // { percent?, tokens?, window? }
63 rateLimits: [], // [{ kind, percent_used, resets_at? }]
64 decisions: [], // ring, oldest first
65 seen: new Set(), // tool_use_ids already in the ring (bounded with it)
66 asked: new Set(), // tool_use_ids that reached the person: never a "hook deny"
67 asking: new Map(), // tool_use_id -> { at } while an ask is unsettled
68 pending: new Map(), // tool_use_id -> { tool, input, at, timer } until tool.check or the end
69 held: null, // { id, tool, input, since } while a call is held before it runs
70 running: new Map(), // subagent id -> { background }
71 finished: new Set(), // subagent ids whose turn.complete came before their spawn resolved
72 ended: new Set(), // session ids already written with ended:true
73 timer: null,
74 writing: false,
75 dirty: false,
76 band: false,
77 isMac: false,
78 waiting: [], // [{ id, agent, name, project, state, ts }]
79 waitingKey: "",
80 pollTimer: null,
81 configTimer: null,
82};
83
84// ---- Pure helpers -----------------------------------------------------------
85
86const safeId = (s) => String(s || "").replace(/[^A-Za-z0-9_.-]/g, "").slice(0, 64);
87
88// A lone surrogate makes Swift's JSONSerialization reject the whole file; strip
89// them on the way out, as the hooks do (Scripts/hooks/claude/update.js).
90const paired = (_k, v) => (typeof v === "string"
91 ? v.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, "")
92 : v);
93
94const capChars = (s, n) => {
95 const cut = String(s).slice(0, n);
96 const last = cut.charCodeAt(cut.length - 1);
97 return last >= 0xd800 && last <= 0xdbff ? cut.slice(0, -1) : cut;
98};
99
100const capBytes = (s, n) => {
101 let bytes = 0;
102 let out = "";
103 for (const ch of String(s)) {
104 const c = ch.codePointAt(0);
105 const w = c < 0x80 ? 1 : c < 0x800 ? 2 : c < 0x10000 ? 3 : 4;
106 if (bytes + w > n) break;
107 bytes += w;
108 out += ch;
109 }
110 return out;
111};
112
113export const stateRoot = (agentbarHome, home) => {
114 const v = typeof agentbarHome === "string" ? agentbarHome : "";
115 if (v.startsWith("/")) return v.replace(/\/+$/, "") || "/";
116 if (!home || !String(home).startsWith("/")) return null;
117 return String(home).replace(/\/+$/, "") + "/.agentbar";
118};
119
120export const pruneInput = (input) => {
121 const out = {};
122 if (!input || typeof input !== "object") return out;
123 for (const k of INPUT_KEYS) {
124 const v = input[k];
125 if (typeof v === "string") out[k] = capBytes(v, INPUT_BYTES);
126 }
127 return out;
128};
129
130// Who decided a verdict Claude Code reached without asking.
131//
132// `tool.check`'s `next(e)` resolves to core's verdict with whatever hooks beneath
133// this one did to it (we load @inline, ahead of marketplace mods, so they sit
134// beneath us). What core hands back, measured on 2.1.289:
135// settings rule {decision:"deny", rule:"Bash(echo:*)", reason:"Permission to use Bash with command echo hi has been denied."}
136// PreToolUse hook {decision:"allow", hook:"PreToolUse"} (no rule, no reason;
137// `hook` is not in the d.ts, and the hook's own reason is dropped)
138// mode / own check {decision:"allow"} (acceptEdits Write, `ls`, `uname -a`, Agent)
139// So:
140// rule present -> "rule"
141// `hook` present -> "hook"
142// neither, but a reason -> "hook" (a mod beneath us answered; core's own
143// mode and tool verdicts come back bare)
144// neither, no reason -> "mode" (the permission mode or the tool's own check)
145// A verdict core itself phrased with a reason and no rule (none seen yet) would be
146// filed as "hook"; the reason travels with the row, so a reader can see why.
147export const attribute = (r) => {
148 const rule = typeof r.rule === "string" ? r.rule : "";
149 const reason = typeof r.reason === "string" ? r.reason : "";
150 const hook = typeof r.hook === "string" ? r.hook : "";
151 if (rule) return { by: "rule", rule, reason };
152 if (hook || reason.trim()) return { by: "hook", rule: "", reason };
153 return { by: "mode", rule: "", reason: "" };
154};
155
156const finite = (n) => typeof n === "number" && Number.isFinite(n);
157
158export const contextOf = (c) => {
159 if (!c || typeof c !== "object") return null;
160 const out = {};
161 if (finite(c.percent)) out.percent = c.percent;
162 if (finite(c.tokens)) out.tokens = c.tokens;
163 if (finite(c.window) && c.window > 0) out.window = c.window;
164 return Object.keys(out).length ? out : null;
165};
166
167export const rateLimitsOf = (list) => {
168 if (!Array.isArray(list)) return [];
169 const out = [];
170 for (const l of list) {
171 if (!l || typeof l.kind !== "string" || !finite(l.percentUsed)) continue;
172 const row = { kind: l.kind, percent_used: l.percentUsed };
173 if (typeof l.resetsAt === "string" && l.resetsAt) row.resets_at = l.resetsAt;
174 out.push(row);
175 }
176 return out;
177};
178
179const remember = (set, id, cap) => {
180 set.add(id);
181 if (set.size > cap) set.delete(set.values().next().value);
182};
183
184const pushDecision = (row) => {
185 if (S.seen.has(row.id)) return false;
186 S.decisions.push(row);
187 S.seen.add(row.id);
188 while (S.decisions.length > RING) S.seen.delete(S.decisions.shift().id);
189 return true;
190};
191
192export const snapshot = (now) => {
193 const body = {
194 v: 1, agent: "claude", session_id: S.sessionId, ts: Math.floor(now / 1000), mod: VERSION,
195 cwd: S.cwd || "", ended: S.ended.has(S.sessionId),
196 };
197 if (S.context) body.context = S.context;
198 body.rate_limits = S.rateLimits;
199 body.subagents = S.running.size;
200 if (S.held) {
201 body.held = { tool: S.held.tool, input: S.held.input, since: Math.floor(S.held.since / 1000) };
202 }
203 body.decisions = S.decisions;
204 return JSON.stringify(body, paired);
205};
206
207const resetSession = () => {
208 S.sessionId = null;
209 S.context = null;
210 S.rateLimits = [];
211 S.decisions = [];
212 S.seen = new Set();
213 S.asked = new Set();
214 S.asking = new Map();
215 for (const p of S.pending.values()) if (p.timer) p.timer.cancel();
216 S.pending = new Map();
217 S.held = null;
218 S.running = new Map();
219 S.finished = new Set();
220 S.dirty = false;
221};
222
223// ---- Effects (each takes `$` by that name; see the header) ------------------
224
225async function resolveRoot($) {
226 const custom = await $.env.get("AGENTBAR_HOME");
227 const home = await $.env.get("HOME");
228 return stateRoot(custom, home);
229}
230
231async function ensureSession($) {
232 if (!S.root) S.root = await resolveRoot($);
233 if (!S.sessionId) S.sessionId = safeId(await $.session.id()) || null;
234 if (!S.cwd) S.cwd = String((await $.session.cwd()) || "");
235}
236
237async function writeFile($) {
238 await $.fs.write(`${S.root}/mods.d/${S.sessionId}.json`, snapshot(await $.clock.now()));
239}
240
241// The last word for a session: not behind the debounce's lock, so a write in
242// flight cannot hold it up or overtake it with what came after the reset.
243async function writeFinal($) {
244 await ensureSession($);
245 if (S.root && S.sessionId) await writeFile($);
246}
247
248async function writeNow($) {
249 if (S.writing) { S.dirty = true; return; }
250 S.writing = true;
251 try {
252 while (true) {
253 S.dirty = false;
254 await ensureSession($);
255 if (!S.root || !S.sessionId) return;
256 // Once ended:true is on disk, nothing but writeFinal touches that file again.
257 if (S.ended.has(S.sessionId)) return;
258 await writeFile($);
259 if (!S.dirty) return;
260 }
261 } finally {
262 S.writing = false;
263 }
264}
265
266// Debounced: the first change schedules a write a second out; changes inside
267// that second ride along. A change landing mid-write sets `dirty`, and
268// writeNow loops, so the last change always reaches the disk.
269function schedule($) {
270 if (S.sessionId && S.ended.has(S.sessionId)) return;
271 S.dirty = true;
272 if (S.timer) return;
273 S.timer = $.clock.after(WRITE_EVERY_MS, () => {
274 S.timer = null;
275 writeNow($).catch(() => {});
276 });
277}
278
279// ---- Band -------------------------------------------------------------------
280
281async function readConfig($) {
282 if (!S.root) return false;
283 try {
284 const text = await $.fs.read(`${S.root}/mods/config.json`);
285 const cfg = JSON.parse(text);
286 return !!(cfg && cfg.band === true);
287 } catch {
288 return false;
289 }
290}
291
292async function pollWaiting($) {
293 const dir = `${S.root}/state.d`;
294 let entries = [];
295 try { entries = await $.fs.list(dir); } catch { entries = []; }
296 const now = Math.floor((await $.clock.now()) / 1000);
297 const found = [];
298 let read = 0;
299 for (const ent of entries) {
300 if (read >= MAX_ROWS) break;
301 if (!ent || ent.kind !== "file" || !ent.name.endsWith(".json")) continue;
302 const id = ent.name.slice(0, -5);
303 if (id === S.sessionId) continue;
304 read += 1;
305 let row;
306 try { row = JSON.parse(await $.fs.read(`${dir}/${ent.name}`)); } catch { continue; }
307 if (!row || typeof row !== "object" || row.started === false) continue;
308 const ts = finite(row.ts) ? row.ts : 0;
309 let state = row.state;
310 if (!WAITING[state]) {
311 if (row.agent !== "claude" || !WORKING.has(state)) continue;
312 // Same rule as the app (SessionStore.isHeld): held, and the row not moved since.
313 let side;
314 try { side = JSON.parse(await $.fs.read(`${S.root}/mods.d/${ent.name}`)); } catch { continue; }
315 const since = side && side.held && finite(side.held.since) ? side.held.since : 0;
316 if (!since || ts > since) continue;
317 state = "held";
318 } else if (ts > 0 && now - ts > STALE_S) continue;
319 const agent = typeof row.agent === "string" ? row.agent : "";
320 const named = typeof row.agent_name === "string" ? row.agent_name.replace(/[\u0000-\u001f]/g, "").trim().slice(0, 24) : "";
321 found.push({
322 id, agent, state, ts,
323 name: AGENT_NAMES[agent] || named || agent || "An agent",
324 project: typeof row.project === "string" ? row.project : "",
325 });
326 }
327 found.sort((a, b) => a.ts - b.ts || (a.id < b.id ? -1 : 1));
328 return found;
329}
330
331function setWaiting($, list) {
332 const key = list.map((w) => `${w.id}:${w.state}`).join("|");
333 S.waiting = list;
334 if (key !== S.waitingKey) {
335 S.waitingKey = key;
336 $.ui.invalidate("ui.render");
337 }
338}
339
340async function tick($) {
341 if (!S.band || !S.root) return;
342 setWaiting($, await pollWaiting($));
343}
344
345async function refreshBand($) {
346 const on = await readConfig($);
347 if (on === S.band) return;
348 S.band = on;
349 if (on) {
350 S.pollTimer = $.clock.every(POLL_EVERY_MS, () => { tick($).catch(() => {}); });
351 await tick($);
352 } else {
353 if (S.pollTimer) S.pollTimer.cancel();
354 S.pollTimer = null;
355 setWaiting($, []);
356 }
357}
358
359export const bandLine = (waiting, columns) => {
360 const first = waiting[0];
361 let text = `◆ ${first.name} ${WAITING[first.state] || HELD}`;
362 if (first.project) text += ` · ${first.project}`;
363 if (waiting.length > 1) text += ` · and ${waiting.length - 1} more`;
364 const room = Math.max(8, (columns || 80) - 12); // leave room for the Jump button
365 return text.length > room ? capChars(text, room - 1) + "…" : text;
366};
367
368function jump($, id) {
369 return $.process.run(["/usr/bin/open", `agentbar://focus?session=${encodeURIComponent(id)}`], { timeoutMs: 5000 });
370}
371
372function drawBand($, e, below) {
373 const { Box, Text, Button } = $.ui.resolve(e);
374 const first = S.waiting[0];
375 const line = [
376 Text({ key: "line", color: "yellow", wrap: "truncate-end", children: bandLine(S.waiting, e.props.bodyColumns) }),
377 ];
378 if (S.isMac) {
379 line.push(Button({
380 key: "agentbar-jump", label: "Jump", hotkey: "1", plain: true,
381 onPress: () => { jump($, first.id).catch(() => {}); },
382 }));
383 }
384 const ours = Box({ key: "agentbar", flexDirection: "row", gap: 2, children: line });
385 if (!below) return ours;
386 return Box({ flexDirection: "column", children: [ours, below] });
387}
388
389// ---- Hooks ------------------------------------------------------------------
390
391async function onStart($, e) {
392 // A reload fires session.start again for the same session; anything else is new.
393 const id = safeId(await $.session.id()) || null;
394 if (id !== S.sessionId) resetSession();
395 S.root = await resolveRoot($);
396 S.sessionId = id;
397 S.cwd = String((e && e.cwd) || (await $.session.cwd()) || "");
398 S.ended.delete(id);
399 try {
400 S.isMac = await $.fs.exists("/System/Library/CoreServices/SystemVersion.plist");
401 } catch { S.isMac = false; }
402 try {
403 // At start Claude Code knows the window and little else: keep what it has,
404 // never a zero for what it does not.
405 const usage = await $.session.usage();
406 S.context = contextOf(usage && usage.context) || S.context;
407 const limits = rateLimitsOf(usage && usage.rateLimits);
408 if (limits.length) S.rateLimits = limits;
409 } catch { /* the next session.measure brings them */ }
410 schedule($);
411 if (S.configTimer) S.configTimer.cancel();
412 S.configTimer = $.clock.every(CONFIG_EVERY_MS, () => { refreshBand($).catch(() => {}); });
413 await refreshBand($);
414}
415
416function onMeasure($, e) {
417 const ctx = contextOf(e.context);
418 if (ctx) S.context = ctx;
419 const limits = rateLimitsOf(e.rateLimits);
420 // An empty list off a subscription is news; an absent one is not.
421 if (Array.isArray(e.rateLimits)) S.rateLimits = limits;
422 schedule($);
423}
424
425async function onCheck($, e, r) {
426 const id = e && e.tool_use_id;
427 if (!id || !r) return; // a query ($.tool.check), not a call
428 settlePending($, id);
429 if (r.decision === "ask") {
430 remember(S.asked, id, 512);
431 if (!READ_ONLY.has(e.tool)) await watchAsk($, id);
432 return;
433 }
434 if (r.decision !== "allow" && r.decision !== "deny") return;
435 if (READ_ONLY.has(e.tool)) return;
436 const who = attribute(r);
437 const added = pushDecision({
438 id, ts: Math.floor((await $.clock.now()) / 1000), tool: String(e.tool || ""),
439 input: pruneInput(e.input), verdict: r.decision, by: who.by, rule: who.rule,
440 reason: capChars(who.reason, REASON_CHARS),
441 });
442 if (added) schedule($);
443}
444
445// ---- Held before it runs ----------------------------------------------------
446//
447// tool.call's hooks run first, then PreToolUse, then tool.check, then the prompt
448// and the tool. A mod that holds a command until the person answers it in its own
449// pane (blast-radius does, for `rm -r` and a force push) sits in tool.call beneath
450// this one, so the call starts, tool.check does not come, and nothing anywhere
451// says the session is waiting on anybody — AgentBar showed it as working.
452// Measured on 2.1.289: call.start, then 35 s of nothing until the pane answered.
453// So a call with no tool.check HOLD_MS after it began is reported as `held`, and
454// cleared the moment tool.check arrives or the call ends. A PreToolUse hook that
455// takes longer than that reads as held too; the app words it as a wait before the
456// command runs, which it is.
457
458function onCallStart($, e) {
459 const id = e && e.tool_use_id;
460 if (!id || READ_ONLY.has(e.tool) || S.pending.has(id)) return;
461 const p = { tool: String(e.tool || ""), input: pruneInput(e), at: 0, timer: null };
462 S.pending.set(id, p);
463 $.clock.now().then((now) => { p.at = now; }).catch(() => {});
464 p.timer = $.clock.after(HOLD_MS, () => {
465 p.timer = null;
466 if (!S.pending.has(id)) return;
467 S.held = { id, tool: p.tool, input: p.input, since: p.at };
468 schedule($);
469 });
470}
471
472function settlePending($, id) {
473 const p = S.pending.get(id);
474 if (!p) return;
475 if (p.timer) p.timer.cancel();
476 S.pending.delete(id);
477 if (S.held && S.held.id === id) { S.held = null; schedule($); }
478}
479
480// ---- After an ask --------------------------------------------------------
481//
482// tool.check fires before the permission mode settles an ask, so on its own it
483// cannot see what auto mode's classifier decides. tool.call can: it wraps the
484// prompt and the tool, and resolves after both. What it cannot say is whether a
485// prompt reached the person — classic.PermissionRequest never fires for a mod on
486// 2.1.289, and no render component is the dialog. AgentBar's own permission hook
487// says so instead: the moment a prompt is due — app running or not, answered
488// anywhere — it writes the time to `mods.d/.prompted-<session>`. A call that ran,
489// after an ask, with no prompt marked since the ask, was settled by the mode:
490// measured, auto mode runs `mkdir` with check "ask", no prompt, and a result with
491// no isError. (Reading the session's state.d row for "permission" was tried
492// first and is not enough: the hook writes that only while an app is there to
493// answer, so a prompt answered in the terminal with the app closed read as auto.)
494// Without AgentBar's hooks there is no marker and no state.d row; the row is the
495// check that they are wired, and without it nothing is recorded — a prompt the
496// person answered must never read as auto mode. Only what ran is recorded: a
497// refusal comes back as an error result, the same shape as a command that ran
498// and failed, and a guess is worse than a gap.
499
500async function watchAsk($, id) {
501 S.asking.set(id, { at: await $.clock.now() });
502}
503
504async function markerTime($) {
505 try {
506 const t = Number(String(await $.fs.read(`${S.root}/mods.d/.prompted-${S.sessionId}`)).trim());
507 return Number.isFinite(t) ? t : 0;
508 } catch { return 0; }
509}
510
511async function rowThere($) {
512 try { await $.fs.read(`${S.root}/state.d/${S.sessionId}.json`); return true; } catch { return false; }
513}
514
515export const ranUnprompted = (r) => !!r && typeof r.deny !== "string" && r.result !== undefined && r.isError !== true;
516
517async function onCallAsked($, e, r) {
518 const id = e && e.tool_use_id;
519 const a = id ? S.asking.get(id) : null;
520 if (!a) return false;
521 S.asking.delete(id);
522 if (!ranUnprompted(r)) return true;
523 await ensureSession($);
524 if (!S.root || !S.sessionId || !(await rowThere($))) return true;
525 // A second of slack: the hook's clock and this one are the same machine's, but
526 // the ask is stamped after Claude Code has already started deciding.
527 if ((await markerTime($)) >= a.at - 1000) return true;
528 const added = pushDecision({
529 id, ts: Math.floor((await $.clock.now()) / 1000), tool: String(e.tool || ""),
530 input: pruneInput(e), verdict: "allow", by: "auto", rule: "", reason: "",
531 });
532 if (added) schedule($);
533 return true;
534}
535
536// A refusal that never reached tool.check: a hook beneath us in tool.call (a mod
537// holding a command, the person cancelling it there) answered `{ deny }`.
538async function onCall($, e, r) {
539 if (e && e.tool_use_id) settlePending($, e.tool_use_id);
540 if (await onCallAsked($, e, r)) return;
541 const id = e && e.tool_use_id;
542 if (!id || !r || typeof r.deny !== "string") return;
543 if (S.seen.has(id) || S.asked.has(id) || READ_ONLY.has(e.tool)) return;
544 const added = pushDecision({
545 id, ts: Math.floor((await $.clock.now()) / 1000), tool: String(e.tool || ""),
546 input: pruneInput(e), verdict: "deny", by: "hook", rule: "",
547 reason: capChars(r.deny, REASON_CHARS),
548 });
549 if (added) schedule($);
550}
551
552function onSpawn($, e, r) {
553 const agentId = r && typeof r.agentId === "string" ? r.agentId : "";
554 if (!agentId) return;
555 // Already finished before the spawn resolved: never counted, never leaked.
556 if (S.finished.delete(agentId)) return;
557 S.running.set(agentId, { background: !!(e && e.background) });
558 schedule($);
559}
560
561function onTurn($, e) {
562 const agentId = e && typeof e.agentId === "string" ? e.agentId : "";
563 if (agentId) {
564 if (S.running.delete(agentId)) schedule($);
565 else remember(S.finished, agentId, 64);
566 return;
567 }
568 // The main loop's turn ended: a foreground subagent cannot outlive it, so any
569 // still counted lost its turn.complete. Background ones may run on.
570 let changed = false;
571 for (const [id, a] of S.running) {
572 if (!a.background) { S.running.delete(id); changed = true; }
573 }
574 if (changed) schedule($);
575}
576
577async function onEnd($, e) {
578 if (S.timer) { S.timer.cancel(); S.timer = null; }
579 const id = safeId(e && e.sessionId) || S.sessionId;
580 if (id) S.sessionId = id;
581 S.running.clear();
582 if (S.sessionId) S.ended.add(S.sessionId);
583 await writeFinal($);
584 // /clear ends this session and carries on under a new id with no
585 // session.start; the next event resolves it afresh.
586 resetSession();
587 if (S.pollTimer) { S.pollTimer.cancel(); S.pollTimer = null; }
588 if (S.configTimer) { S.configTimer.cancel(); S.configTimer = null; }
589 S.band = false;
590 S.waiting = [];
591 S.waitingKey = "";
592}
593
594const passThrough = ($, e, next) => next(e);
595
596export function register(on) {
597 on("session.start", async ($, e, next) => {
598 const r = await next(e);
599 try { await onStart($, e); } catch { /* bookkeeping only */ }
600 return r;
601 }).catch(passThrough);
602
603 on("session.measure", async ($, e, next) => {
604 const r = await next(e);
605 try { onMeasure($, e); } catch { /* bookkeeping only */ }
606 return r;
607 }).catch(passThrough);
608
609 on("tool.check", async ($, e, next) => {
610 const r = await next(e);
611 try { await onCheck($, e, r); } catch { /* bookkeeping only */ }
612 return r;
613 }).catch(passThrough);
614
615 on("tool.call", async ($, e, next) => {
616 try { onCallStart($, e); } catch { /* bookkeeping only */ }
617 const r = await next(e);
618 try { await onCall($, e, r); } catch { /* bookkeeping only */ }
619 return r;
620 }).catch(passThrough);
621
622 on("agent.spawn", async ($, e, next) => {
623 const r = await next(e);
624 try { onSpawn($, e, r); } catch { /* bookkeeping only */ }
625 return r;
626 }).catch(passThrough);
627
628 on("turn.complete", async ($, e, next) => {
629 const r = await next(e);
630 try { onTurn($, e); } catch { /* bookkeeping only */ }
631 return r;
632 }).catch(passThrough);
633
634 on("session.end", async ($, e, next) => {
635 // Our write first: session.end has a 1.5 s budget, and the file is the point.
636 try { await onEnd($, e); } catch { /* bookkeeping only */ }
637 return next(e);
638 }).catch(passThrough);
639
640 on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
641 const below = await next(e);
642 if (!S.band || S.waiting.length === 0 || (e.props && e.props.hasSurvey)) return below;
643 try { return drawBand($, e, below); } catch { return below; }
644 }).catch(passThrough);
645}
646