SLOPSHOPPER

agentbar

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

newbandguardprocesstimeragents
★ 9v1.56.0MITupdated 2026-10-16michalstrnadel/AgentBar/Scripts/mods/claude
A shopper browsing a rack in a slop shop
README

AgentBar mod for Claude Code

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".

What it does

  • Writes one file per session, ~/.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.
  • Optionally (off unless ~/.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>.

What it never does

  • Answer anything. Every hook returns exactly what Claude Code (and every hook beneath it) returned; a failure in the mod's own bookkeeping is caught and changes nothing. It never writes answers.d/, never approves, denies or asks.
  • Reach the network, read your environment beyond HOME and AGENTBAR_HOME, or run any command but /usr/bin/open agentbar://focus?…, and that one only when you press Jump.

What it can do, as Claude Code reads it

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.

Trying it

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)
Source 1 files
hooks/register.js 646 lines
1// 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