SLOPSHOPPER

chain-inspector

Shows the live hook chain of every event mods hook, in order, with each mod's ms

newpanespinnercommandtimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · chain-inspector
│ ┃ chain ✕ › fix the failing auth test and add an audit log call │ ┃ chain inspector · 0 mods traced · tracing on │ ┃ no traced events yet ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /chain │ ⎿ chain-inspector: chain inspector opened · 0 mods traced │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · chain
chain inspector · 0 mods traced · tracing on no traced events yet
README

chain-inspector

See every mod's hook chain, in order, with each mod's ms. When a mod ends a shared chain and silently hides the mods beneath it, the inspector names it.

Install

/plugin marketplace add blukraken/witchbottle-mods
/plugin install chain-inspector@witchbottle

Restart Claude Code. The inspector sees only mods loaded after it, so move chain-inspector@witchbottle to the top of enabledPlugins in ~/.claude/settings.json.

Commands

CommandEffect
/chainOpen the pane
/chain offStop tracing
/chain onStart tracing again

Reading the pane

PromptHint  mod-a 0.4ms › mod-b 0.2 › engine + builtins 1.0

Each row is one event, showing the chain of its latest run in order, with each mod's average ms. A healthy chain ends at engine. A row that stops at a mod means that mod answered without passing the event on that run, which can be harmless once; the trouble rows below say when it keeps happening.

You seeIt meansWhat to do
! mod may have ended key · suspectedThe mod ended a shared chain three times in a rowRestart with --debug to confirm it
✗ mod ended key · confirmedThe debug log shows the mod ending the chainMods beneath it on that event are not running; report it to the mod's author or disable the mod
~ mod slow on key · Xms vs Yms meanAfter 10 runs, the mod took over twice its average and at least 20 msWatch whether it repeats
… load before the inspector and are not traced in the headerPlugins loaded before the inspector, which it cannot see pastMove the inspector to the top of enabledPlugins; plugins with only skills or commands hide nothing
debug log over 4 MiB · log readings frozenThe debug log grew too large to read, so nothing new is confirmedStart a new session for a fresh log

A mod ending a chain on its own command, tool or pane is normal and never flagged.

Run Claude Code with --debug to confirm chain-enders, split out builtin modules, and list every command hook by cost.

MIT licence.

Source 7 files
hooks/register.tsx 575 lines
1import { atom, read, update } from "claude-code";
2import type { Register } from "claude-code";
3import { LOG, parseLog, parsedNames } from "./logparse";
4import { selfCheckOf } from "./selfcheck";
5import {
6  LIMITS,
7  TROUBLE,
8  addPlugin,
9  addRecord,
10  emptyBuffer,
11  emptyRegistry,
12  hintTextOf,
13  isEngineCall,
14  isTraced,
15  keyOf,
16  makeRecord,
17  sameTrouble,
18  slowdownsOf,
19  snapshotOf,
20  trackSuspect,
21} from "./trace";
22import type {
23  LogStatus,
24  ParsedLog,
25  Slowdown,
26  Suspect,
27  TraceRecord,
28  TroubleItem,
29} from "./types/index";
30import { ROW_PREFIX, pane } from "./views/pane";
31
32// Thin entry. The engine follows `$` only into functions declared in this
33// file, so every `$` call and every hook sits here and the logic modules take
34// plain data. The star hook writes to the `buffer` atom, which no render
35// reads. A one-second timer, armed only while the pane is open, copies it to
36// the `snapshot` atom the pane draws from.
37
38const registryAtom = atom(
39  { plugin: "chain-inspector", key: "registry" } as const,
40  emptyRegistry(),
41);
42const bufferAtom = atom(
43  { plugin: "chain-inspector", key: "buffer" } as const,
44  emptyBuffer(),
45);
46const snapshotAtom = atom(
47  { plugin: "chain-inspector", key: "snapshot" } as const,
48  null,
49);
50const tracingAtom = atom(
51  { plugin: "chain-inspector", key: "tracing" } as const,
52  true,
53);
54const paneOpenAtom = atom(
55  { plugin: "chain-inspector", key: "paneOpen" } as const,
56  false,
57);
58const selectedAtom = atom(
59  { plugin: "chain-inspector", key: "selected" } as const,
60  null,
61);
62
63const troubleAtom = atom(
64  { plugin: "chain-inspector", key: "trouble" } as const,
65  [],
66);
67const hintTailAtom = atom(
68  { plugin: "chain-inspector", key: "hintTail" } as const,
69  null,
70);
71
72const logStatusAtom = atom(
73  { plugin: "chain-inspector", key: "logStatus" } as const,
74  "none" as LogStatus,
75);
76
77const USAGE = "usage: /chain [open|on|off]";
78
79// What the last read of a session's debug log gave. Module memory, keyed by
80// session id, so a read inside `LOG.minGapMs` of the last one reuses it.
81type LogCache = {
82  readAt: number | null;
83  log: ParsedLog | null;
84  frozen: boolean;
85  pending: Promise<void> | null;
86};
87const logCache = new Map<string, LogCache>();
88
89// One read of the log file into `slot`.
90async function fetchLog($: any, id: string, slot: LogCache): Promise<void> {
91  let dir = await $.env.get("CLAUDE_CONFIG_DIR");
92  if (!dir) {
93    const home = (await $.env.get("HOME")) ?? (await $.env.get("USERPROFILE"));
94    if (!home) return;
95    dir = `${home}/.claude`;
96  }
97  const path = `${dir}/debug/${id}.txt`;
98  let size = 0;
99  try {
100    size = Number((await $.fs.stat(path)).size) || 0;
101  } catch {
102    // No stat answer. The read below settles whether the log exists.
103  }
104  if (size > LOG.maxBytes) {
105    slot.frozen = true;
106    await update($, logStatusAtom, () => "frozen" as LogStatus);
107    return;
108  }
109  try {
110    slot.log = parseLog(String(await $.fs.read(path)));
111  } catch {
112    // No log file, which is what a run without `--debug` leaves.
113  }
114  await update(
115    $,
116    logStatusAtom,
117    () => (slot.log ? "reading" : "none") as LogStatus,
118  );
119}
120
121// After each completed read, checks every open suspect against the fresh
122// cache, so a suspect raised inside the reader's gap is confirmed by the next
123// read. A suspect that draws its own component is left to the pane. Never
124// throws.
125async function recheckSuspects($: any, slot: LogCache): Promise<void> {
126  try {
127    if (slot.frozen || !slot.log) return;
128    const log = slot.log;
129    const buffer = await read($, bufferAtom);
130    const open = Object.values(buffer.suspects).filter(
131      (s) =>
132        parsedNames(log, s.plugin, s.event) &&
133        !(
134          s.event === "ui.render" &&
135          (buffer.ownedEnders ?? []).includes(s.plugin)
136        ),
137    );
138    if (open.length === 0) return;
139    const at = Number(await $.clock.now());
140    const found: TroubleItem[] = open.map((s) => ({
141      kind: "ender",
142      plugin: s.plugin,
143      key: s.key,
144      event: s.event,
145      at,
146    }));
147    let fresh: TroubleItem[] = [];
148    await update($, troubleAtom, (t) => {
149      fresh = found.filter((f) => !t.some((x) => sameTrouble(x, f)));
150      return fresh.length > 0 ? [...t, ...fresh] : t;
151    });
152    const newest = fresh[fresh.length - 1];
153    if (newest) await announce($, newest);
154  } catch (err) {
155    $.ui.log(
156      `chain-inspector: suspect re-check failed (${String((err as any)?.message ?? err)})`,
157    );
158  }
159}
160
161// Reads the debug log into the cache, at most once per `LOG.minGapMs`. Stats it
162// first; past `LOG.maxBytes` it freezes readings for the session and keeps what
163// it has. A missing log (no `--debug`) leaves the cache empty. Never throws.
164async function readLog($: any): Promise<LogCache | null> {
165  try {
166    const id = String(await $.session.id());
167    let entry = logCache.get(id);
168    if (!entry) {
169      entry = { readAt: null, log: null, frozen: false, pending: null };
170      logCache.set(id, entry);
171    }
172    const slot = entry;
173    if (slot.frozen) return slot;
174    if (slot.pending) {
175      await slot.pending;
176      return slot;
177    }
178    const now = Number(await $.clock.now());
179    if (slot.readAt !== null && now - slot.readAt < LOG.minGapMs) return slot;
180    slot.readAt = now;
181    slot.pending = fetchLog($, id, slot);
182    try {
183      await slot.pending;
184    } finally {
185      slot.pending = null;
186    }
187    await recheckSuspects($, slot);
188    return slot;
189  } catch {
190    return null;
191  }
192}
193
194// enabledPlugins in key order, or undefined when settings give none.
195async function enabledPlugins($: any): Promise<unknown> {
196  try {
197    const settings = await $.settings.read();
198    return settings?.enabledPlugins;
199  } catch {
200    return undefined;
201  }
202}
203
204// Copies the buffer to the snapshot the pane reads. `fresh` reads the
205// settings again for the self-check names; a timed flush reuses the last.
206// Disarms first, so a failure below never leaves the timer stuck.
207async function flush($: any, fresh: boolean): Promise<void> {
208  try {
209    const buffer = await update($, bufferAtom, (b) => ({ ...b, armed: false }));
210    if (!(await read($, paneOpenAtom))) return;
211    const registry = await read($, registryAtom);
212    const previous = await read($, snapshotAtom);
213    const selfCheck =
214      fresh || !previous
215        ? selfCheckOf(
216            await enabledPlugins($),
217            registry.plugins,
218            buffer.maxAbove,
219          )
220        : { ...previous.selfCheck, above: buffer.maxAbove };
221    const at = Number(await $.clock.now());
222    const log = fresh
223      ? ((await readLog($))?.log ?? null)
224      : (logCache.get(String(await $.session.id()))?.log ?? null);
225    await update($, snapshotAtom, (s) =>
226      snapshotOf(buffer, selfCheck, at, (s?.seq ?? 0) + 1, log),
227    );
228  } catch (err) {
229    $.ui.log(
230      `chain-inspector: refresh failed (${String((err as any)?.message ?? err)})`,
231    );
232  }
233}
234
235// Writes one record to the buffer and, with the pane open and no timer
236// running, arms the one-second flush. A slowdown, or a suspect whose debug-log
237// check is due, schedules `raise` on a zero-delay timer so the log read and the
238// open stay off the dispatch's awaited path.
239async function store($: any, record: TraceRecord): Promise<void> {
240  const isOpen = await read($, paneOpenAtom);
241  let arm = false;
242  let slows: Slowdown[] = [];
243  let due = null as Suspect | null;
244  await update($, bufferAtom, (b) => {
245    slows = slowdownsOf(b, record);
246    const added = trackSuspect(addRecord(b, record), record);
247    const suspect = added.suspects[record.key];
248    due =
249      suspect &&
250      (suspect.checkedAt === null ||
251        record.at - suspect.checkedAt >= TROUBLE.logRecheckMs)
252        ? suspect
253        : null;
254    arm = isOpen && !b.armed;
255    return arm ? { ...added, armed: true } : added;
256  });
257  if (arm) void $.clock.after(LIMITS.refreshMs, () => flush($, false));
258  if (slows.length > 0 || due)
259    void $.clock.after(0, () => raise($, record, slows, due));
260}
261
262// True when the engine's debug log for this session says `plugin` answered
263// `event` without next(). The log exists only under `--debug`, so a missing or
264// unreadable file answers false, and so does a frozen reader.
265async function confirmedByLog(
266  $: any,
267  plugin: string,
268  event: string,
269): Promise<boolean> {
270  const entry = await readLog($);
271  if (!entry || entry.frozen || !entry.log) return false;
272  return parsedNames(entry.log, plugin, event);
273}
274
275// With the pane open, reads the log again (inside the reader's rate limit) and
276// redraws the snapshot. Runs on a zero-delay timer after `turn.complete`.
277async function refreshLog($: any): Promise<void> {
278  try {
279    if (!(await read($, paneOpenAtom))) return;
280    await readLog($);
281    await flush($, false);
282  } catch (err) {
283    $.ui.log(
284      `chain-inspector: log refresh failed (${String((err as any)?.message ?? err)})`,
285    );
286  }
287}
288
289// Turns a slowdown or a due suspect into trouble. A confirmed ender and a
290// reported slowdown stay for the session. New trouble opens the pane.
291async function raise(
292  $: any,
293  record: TraceRecord,
294  slows: readonly Slowdown[],
295  due: Suspect | null,
296): Promise<void> {
297  try {
298    const at = Number(await $.clock.now());
299    const found: TroubleItem[] = slows.map((s) => ({
300      kind: "slow",
301      plugin: s.plugin,
302      key: s.key,
303      event: s.event,
304      at,
305      sample: s.sample,
306      mean: s.mean,
307    }));
308    if (due) {
309      const known = (await read($, troubleAtom)).some(
310        (t) =>
311          t.kind === "ender" && t.key === due.key && t.plugin === due.plugin,
312      );
313      if (!known) {
314        // The log names `ui.render` but not the component, so a mod that has
315        // ended an owned component's chain cannot be told apart in it.
316        const drawsOwn =
317          due.event === "ui.render" &&
318          ((await read($, bufferAtom)).ownedEnders ?? []).includes(due.plugin);
319        const named =
320          !drawsOwn && (await confirmedByLog($, due.plugin, due.event));
321        await update($, bufferAtom, (b) => {
322          const now = b.suspects[due.key];
323          return now && now.plugin === due.plugin
324            ? {
325                ...b,
326                suspects: {
327                  ...b.suspects,
328                  [due.key]: { ...now, checkedAt: at },
329                },
330              }
331            : b;
332        });
333        if (named)
334          found.push({
335            kind: "ender",
336            plugin: due.plugin,
337            key: due.key,
338            event: due.event,
339            at,
340          });
341      }
342    }
343    if (found.length === 0) return;
344    let fresh: TroubleItem[] = [];
345    await update($, troubleAtom, (t) => {
346      fresh = found.filter((f) => !t.some((x) => sameTrouble(x, f)));
347      return fresh.length > 0 ? [...t, ...fresh] : t;
348    });
349    const newest = fresh[fresh.length - 1];
350    if (newest) await announce($, newest);
351  } catch (err) {
352    $.ui.log(
353      `chain-inspector: trouble check failed for ${record.key} (${String((err as any)?.message ?? err)})`,
354    );
355  }
356}
357
358// Opens the pane without being asked. When the engine cannot place it, the PromptHint line carries a note until the owner opens the pane.
359async function announce($: any, item: TroubleItem): Promise<void> {
360  let placed = false;
361  let asked = false;
362  try {
363    const opened = await $.ui.open({
364      id: "chain-inspector",
365      title: "chain",
366      closeOnEscape: true,
367    });
368    asked = true;
369    placed = opened.isPlaced === true;
370  } catch {
371    // No surface is attached (a headless run), so only the hint can speak.
372  }
373  if (asked) {
374    await update($, paneOpenAtom, () => true);
375    await flush($, true);
376  }
377  await update($, hintTailAtom, () => (placed ? null : hintTextOf(item)));
378}
379
380// A render dispatch is pure, and the engine refuses a state write made while
381// one runs. A `ui.render` record is therefore written from a zero-delay timer,
382// which runs after the dispatch has returned.
383async function noteTrace(
384  $: any,
385  event: string,
386  key: string,
387  trace: readonly any[],
388  failed: boolean,
389): Promise<void> {
390  // The engine lists no link of the inspector's own beneath its star hook, so
391  // a dispatch the inspector answers itself (`/chain`) has an empty trace and
392  // nothing to show.
393  if (trace.length === 0 && !failed) return;
394  const at = Number(await $.clock.now());
395  const record = makeRecord(event, key, at, trace, failed);
396  const later = () =>
397    void $.clock.after(0, async () => {
398      try {
399        await store($, record);
400      } catch (err) {
401        $.ui.log(
402          `chain-inspector: record lost (${String((err as any)?.message ?? err)})`,
403        );
404      }
405    });
406  if (event === "ui.render") return later();
407  try {
408    await store($, record);
409  } catch {
410    later();
411  }
412}
413
414export const register: Register = (on) => {
415  logCache.clear();
416
417  // The one star hook. An untraced dispatch goes straight to `next(e)`. A
418  // traced one runs `next(e)`, reads `next.trace`, then returns the result
419  // unchanged, or rethrows the rejection after recording it.
420  on("*", async ($, e, next) => {
421    const event = next.event;
422    if (isEngineCall(event)) return next(e);
423    let key: string = event;
424    if (next.is("ui.render", e)) {
425      if (e.requestId === "chain-inspector") return next(e);
426      key = keyOf(event, { component: e.component, requestId: e.requestId });
427    } else if (next.is("tool.call", e)) {
428      key = keyOf(event, { tool: e.tool });
429    }
430    const registry = await read($, registryAtom);
431    if (!isTraced(registry.traced, event)) return next(e);
432    if (!(await read($, tracingAtom))) return next(e);
433
434    let result: unknown;
435    let failure: unknown;
436    let failed = false;
437    try {
438      result = await next(e);
439    } catch (err) {
440      failed = true;
441      failure = err;
442    }
443    try {
444      await noteTrace($, event, key, next.trace, failed);
445    } catch {
446      // Recording never changes what the dispatch returns.
447    }
448    if (failed) throw failure;
449    return result;
450  });
451
452  // Judges every mod loaded after this one and never refuses it.
453  on("plugin.register", async ($, e, next) => {
454    try {
455      const record = {
456        name: e.name,
457        tier: e.tier,
458        provenance: e.provenance,
459        events: [...e.uses.events],
460      };
461      await update($, registryAtom, (r) => addPlugin(r, record));
462    } catch {
463      // A failed note leaves the mod unlisted and still loads it.
464    }
465    return next(e);
466  });
467
468  on("session.start", async ($, e, next) => {
469    try {
470      await $.command.register({
471        name: "chain",
472        description: "hook chain inspector",
473        argumentHint: "[open|on|off]",
474      });
475    } catch (err) {
476      $.ui.log(
477        `chain-inspector: /chain not registered (${String((err as any)?.message ?? err)})`,
478      );
479    }
480    return next(e);
481  });
482
483  // A finished turn refreshes the log readings while the pane is open. The
484  // read runs on a timer, off the dispatch's path.
485  on("turn.complete", async ($, e, next) => {
486    void $.clock.after(0, () => refreshLog($));
487    return next(e);
488  });
489
490  on("command.run", { command: "chain" }, async ($, e) => {
491    try {
492      const arg = (e.args ?? "").trim().split(/\s+/)[0] ?? "";
493      switch (arg) {
494        case "":
495        case "open": {
496          const opened = await $.ui.open({
497            id: "chain-inspector",
498            title: "chain",
499            closeOnEscape: true,
500          });
501          await update($, paneOpenAtom, () => true);
502          await flush($, true);
503          const registry = await read($, registryAtom);
504          if (!opened.isPlaced)
505            return { text: "chain inspector waits for a wider terminal" };
506          await update($, hintTailAtom, () => null);
507          return {
508            text: `chain inspector opened · ${registry.plugins.length} mods traced`,
509          };
510        }
511        case "off":
512          await update($, tracingAtom, () => false);
513          return { text: "chain inspector: tracing off" };
514        case "on":
515          await update($, tracingAtom, () => true);
516          return { text: "chain inspector: tracing on" };
517        default:
518          return { text: USAGE };
519      }
520    } catch (err) {
521      return {
522        text: `chain inspector: ${String((err as any)?.message ?? err)}`,
523      };
524    }
525  });
526
527  // Escape, as the person's close, steps back from a row's detail first and
528  // closes the pane on the next press. Any other close ends the pane.
529  on("ui.close", { id: "chain-inspector" }, async ($, e, next) => {
530    const detail = (await read($, selectedAtom)) !== null;
531    if (e.origin.kind === "person" && detail) {
532      await update($, selectedAtom, () => null);
533      return { value: undefined };
534    }
535    await update($, paneOpenAtom, () => false);
536    await update($, selectedAtom, () => null);
537    return next(e);
538  });
539
540  // Pressing a row opens its detail, and pressing it again returns.
541  on(
542    "ui.press",
543    { requestId: "chain-inspector", element: /^row-/ },
544    async ($, e, next) => {
545      const key = e.element.slice(ROW_PREFIX.length);
546      await update($, selectedAtom, (now) => (now === key ? null : key));
547      return next(e);
548    },
549  );
550
551  on(
552    "ui.render",
553    { component: "Pane", requestId: "chain-inspector" },
554    async ($, e) =>
555      pane({
556        table: $.ui.resolve(e),
557        columns: e.props.bodyColumns,
558        snapshot: await read($, snapshotAtom),
559        pluginCount: (await read($, registryAtom)).plugins.length,
560        tracing: await read($, tracingAtom),
561        selected: await read($, selectedAtom),
562        trouble: await read($, troubleAtom),
563        logStatus: await read($, logStatusAtom),
564      }),
565  );
566
567  // Adds the trouble note to the engine's own hint line and leaves the rest of
568  // the line as it is. Reads only.
569  on("ui.render", { component: "PromptHint" }, async ($, e, next) => {
570    const tail = await read($, hintTailAtom);
571    if (!tail) return next(e);
572    return next({ ...e, props: { ...e.props, tail } });
573  });
574};
575
hooks/logparse.ts 153 lines
1import type { BuiltinSample, CommandHook, ParsedLog } from "./types/index";
2
3// Pure. Turns the engine's debug log into the three results the pane uses: the
4// enders it names, the builtin timings and the command hooks. No IO.
5
6// Tuning knobs of the log reader.
7export const LOG = Object.freeze({
8  // Reads of the debug log are at least this far apart.
9  minGapMs: 5000,
10  // A log over this many bytes freezes readings for the session.
11  maxBytes: 4 * 1024 * 1024,
12});
13
14export const FROZEN_LINE = "debug log over 4 MiB · log readings frozen";
15
16const BUILTIN_PREFIX = "cc-plugin-";
17
18const ENDER = /(?:^|\s)(\S+) \([^)]*\) answered (\S+) without next\(\)/;
19const BUILTIN = /hooks module (\S+)@builtin (\S+) settled in (\d+(?:\.\d+)?)ms/;
20const HOOK =
21  /Hooks: (\S+) \[(.*?)\](?: \(plugin ([^)]+)\))? finished with status (-?\d+) \((\d+(?:\.\d+)?)ms\)/;
22
23// Extensions that mark a token as a script.
24const SCRIPT_EXT = /\.(py|sh|ps1|mjs|js|cmd|bat|ts)$/i;
25
26// A file name with nothing else in it.
27const PLAIN_NAME = /^[A-Za-z0-9._-]+$/;
28
29// Shown when the command holds no plain script file name.
30export const GENERIC_SCRIPT = "command";
31
32const lastSegment = (path: string) => path.split(/[\\/]/).pop() ?? path;
33
34const plainScript = (path: string): string | null => {
35  const name = lastSegment(path);
36  return PLAIN_NAME.test(name) && SCRIPT_EXT.test(name) ? name : null;
37};
38
39// The script's file name in a hook command, shown only when it is a plain file
40// name with a script extension. Anything else gives `command`, so the rest of
41// the command, which can hold paths and arguments, never reaches the screen. A
42// `run-hook` wrapper gives way to the script it runs when that is plain too.
43export function scriptNameOf(command: string): string {
44  const tokens = [...command.matchAll(/"([^"]*)"|(\S+)/g)].map(
45    (m) => m[1] ?? m[2] ?? "",
46  );
47  const first = tokens.find((t) => SCRIPT_EXT.test(t));
48  if (first === undefined) return GENERIC_SCRIPT;
49  const name = plainScript(first);
50  if (name !== null && /^run-hook\./i.test(name)) {
51    const after = tokens[tokens.indexOf(first) + 1];
52    const target = after === undefined ? null : plainScript(after);
53    if (target !== null) return target;
54  }
55  return name ?? GENERIC_SCRIPT;
56}
57
58const STAMP = /^(\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d+)?Z)/;
59
60// `foundry@personal` -> `foundry`.
61const pluginName = (name: string) => name.split("@")[0] ?? name;
62
63// Stamp step (1 ms) plus rounding of the two logged ms values (0.1 ms).
64const NEST_TOLERANCE_MS = 1.1;
65
66export function parseLog(text: string): ParsedLog {
67  const enders = new Set<string>();
68  // The latest dispatch of each event, builtins in settle order (innermost
69  // first).
70  const dispatches = new Map<string, BuiltinSample[]>();
71  // Start time (timestamp minus ms) of the latest line of each event.
72  const starts = new Map<string, number>();
73  const hooks = new Map<string, CommandHook>();
74  for (const line of text.split("\n")) {
75    if (line.includes("answered")) {
76      const m = ENDER.exec(line);
77      if (m) enders.add(`${m[1]} ${m[2]}`);
78    }
79    if (line.includes("@builtin ")) {
80      const m = BUILTIN.exec(line);
81      if (m) {
82        const name = (m[1] as string).startsWith(BUILTIN_PREFIX)
83          ? (m[1] as string).slice(BUILTIN_PREFIX.length)
84          : (m[1] as string);
85        const event = m[2] as string;
86        const ms = Number(m[3]);
87        const group = dispatches.get(event);
88        const prev = group?.[group.length - 1];
89        const stamp = STAMP.exec(line);
90        const start =
91          (stamp ? Date.parse(stamp[1] as string) : Number.NaN) - ms;
92        const prevStart = starts.get(event);
93        // A line nests around the group before it when it started within
94        // 1.1 ms after that group's latest start (1 ms stamp step plus 0.1 ms
95        // for the two rounded ms values) and no builtin of its name is in the
96        // group. A line with no readable stamp nests on the name rule alone.
97        const nested =
98          group !== undefined &&
99          prev !== undefined &&
100          !group.some((b) => b.name === name) &&
101          (prevStart !== undefined &&
102          Number.isFinite(start) &&
103          Number.isFinite(prevStart)
104            ? start <= prevStart + NEST_TOLERANCE_MS
105            : true);
106        starts.set(event, start);
107        const sample: BuiltinSample = {
108          name,
109          event,
110          ms,
111          ownMs: Math.max(0, ms - (nested && prev ? prev.ms : 0)),
112        };
113        if (nested && group) group.push(sample);
114        else dispatches.set(event, [sample]);
115      }
116    }
117    if (line.includes("Hooks: ")) {
118      const m = HOOK.exec(line);
119      if (m) {
120        const [event, ...rest] = (m[1] as string).split(":");
121        const matcher = rest.length > 0 ? rest.join(":") : null;
122        const source = m[3] ? pluginName(m[3]) : "settings";
123        const script = scriptNameOf(m[2] as string);
124        const key = `${m[1]}|${source}|${script}`;
125        hooks.set(key, {
126          event: event as string,
127          matcher,
128          source,
129          script,
130          ms: Number(m[5]),
131          runs: (hooks.get(key)?.runs ?? 0) + 1,
132          status: Number(m[4]),
133        });
134      }
135    }
136  }
137  return {
138    enders: [...enders],
139    builtins: [...dispatches.values()].flat(),
140    hooks: [...hooks.values()].sort((a, b) => b.ms - a.ms),
141  };
142}
143
144// True when the parsed log has a line saying `plugin` answered `event` without
145// calling next().
146export function parsedNames(
147  log: ParsedLog,
148  plugin: string,
149  event: string,
150): boolean {
151  return log.enders.includes(`${plugin} ${event}`);
152}
153
hooks/selfcheck.ts 79 lines
1import type { PluginRecord, SelfCheck } from "./types/index";
2
3// Pure. Works out what sits above the inspector, where it cannot see. Two
4// signals: the links above it in a trace, and the enabled plugins keyed ahead
5// of it that it never judged.
6
7export const SELF_NAME = "chain-inspector";
8
9// A trace lists the links beneath the caller, so its first index minus one is
10// the caller's own place, which equals the number of links above it.
11export function linksAbove(links: readonly { index: number }[]): number {
12  const first = links[0];
13  if (!first) return 0;
14  return Math.max(0, first.index - 1);
15}
16
17// `name@marketplace` to `name`.
18export function bareName(key: string): string {
19  const at = key.lastIndexOf("@");
20  return at > 0 ? key.slice(0, at) : key;
21}
22
23// The enabled plugins keyed before the inspector that it never judged, in key
24// order. Null when `enabled` is not an object, so the names are unknown.
25export function namesBefore(
26  enabled: unknown,
27  judged: readonly PluginRecord[],
28): string[] | null {
29  if (typeof enabled !== "object" || enabled === null || Array.isArray(enabled))
30    return null;
31  const keys = Object.keys(enabled);
32  const self = keys.findIndex((k) => bareName(k) === SELF_NAME);
33  if (self < 0) return [];
34  const seen = new Set<string>();
35  for (const p of judged) {
36    seen.add(p.name);
37    seen.add(p.provenance);
38  }
39  const names: string[] = [];
40  for (const key of keys.slice(0, self)) {
41    if ((enabled as Record<string, unknown>)[key] === false) continue;
42    if (seen.has(key) || seen.has(bareName(key))) continue;
43    names.push(bareName(key));
44  }
45  return names;
46}
47
48export function selfCheckOf(
49  enabled: unknown,
50  judged: readonly PluginRecord[],
51  above: number,
52): SelfCheck {
53  const names = namesBefore(enabled, judged);
54  return { above, names: names ?? [], namesKnown: names !== null };
55}
56
57const plural = (n: number, one: string, many: string) => (n === 1 ? one : many);
58
59// The pane header's line, or null when nothing sits above the inspector.
60export function selfCheckLine(sc: SelfCheck): string | null {
61  const n = sc.names.length;
62  if (n > 0) {
63    const verb = plural(n, "loads", "load");
64    const be = plural(n, "is", "are");
65    const head = `${n} ${plural(n, "mod", "mods")} ${verb} before the inspector and ${be} not traced: ${sc.names.join(", ")}`;
66    return sc.above > 0
67      ? `${head} · ${sc.above} ${plural(sc.above, "link", "links")} above`
68      : head;
69  }
70  if (sc.above > 0) {
71    const links = plural(sc.above, "link", "links");
72    const verb = plural(sc.above, "sits", "sit");
73    const be = plural(sc.above, "is", "are");
74    const tail = sc.namesKnown ? "" : " (names unavailable)";
75    return `${sc.above} ${links} ${verb} above the inspector and ${be} not traced${tail}`;
76  }
77  return null;
78}
79
hooks/trace.ts 488 lines
1import { linksAbove } from "./selfcheck";
2import type {
3  Aggregate,
4  BuiltinSample,
5  LinkRecord,
6  ParsedLog,
7  PluginRecord,
8  Registry,
9  Slowdown,
10  Snapshot,
11  SnapshotRow,
12  TraceBuffer,
13  TraceRecord,
14  TroubleItem,
15} from "./types/index";
16
17// Pure. The record shape, the ring buffer, the per-link aggregates, the event
18// keys and the traced-event test. No IO and no state.
19
20// Tuning knobs. Everything the star hook and the pane tune on sits here.
21export const LIMITS = Object.freeze({
22  // Records kept per key.
23  perKey: 20,
24  // Keys kept. The least recently dispatched key goes first.
25  maxKeys: 64,
26  // Records the detail view lists.
27  detail: 5,
28  // The pane redraws at most this often.
29  refreshMs: 1000,
30});
31
32// Tuning knobs of the trouble rules.
33export const TROUBLE = Object.freeze({
34  // Short records in a row, all ending at one mod, that make it a suspect.
35  shortRun: 3,
36  // Timed samples a mod needs on a key before a slowdown can alarm.
37  slowSamples: 10,
38  // A new sample over this many times the earlier mean is a slowdown.
39  slowFactor: 2,
40  // A sample under this many ms is no trouble, however much it grew. Matches
41  // the ms at which the pane turns a link warn.
42  slowMinMs: 20,
43  // A suspect's debug-log check repeats no sooner than this.
44  logRecheckMs: 5000,
45});
46
47// `ui.render` components where one mod ending the chain starves the others.
48// Every other component is drawn by its owner, so a mod that ends its chain
49// is drawing it, by design.
50export const SHARED_COMPONENTS = Object.freeze([
51  "AbovePrompt",
52  "PromptHint",
53  "SessionMode",
54  "Spinner",
55]);
56
57export const PANE_ID = "chain-inspector";
58
59// Events the star hook never traces, so it passes them with no state read.
60// `engine.create` runs before `$` exists. `state.*` is how the inspector keeps
61// its own records. The rest are engine calls that fire in bursts of hundreds
62// at startup: a headless "ok" turn dispatched 300 `ui.resolve`, 276
63// `tool.describe`, 238 `command.describe` and 140 `env.get` (W-0012 C4, and a
64// probe above the inspector on 2026-10-13). A state read per dispatch queued
65// behind that burst and cost the inspector 30 ms to 110 ms on each.
66export const ENGINE_CALLS = Object.freeze({
67  exact: Object.freeze([
68    "engine.create",
69    "ui.resolve",
70    "ui.invalidate",
71    "ui.status",
72    "tool.describe",
73    "command.describe",
74    "agent.offer",
75    "prompt.section",
76    "prompt.attachment",
77    "session.append",
78    // The inspector's own debug-log read, so it never records itself.
79    "fs.read",
80    "fs.stat",
81    "session.id",
82  ]),
83  prefix: Object.freeze(["state.", "clock.", "store.", "env."]),
84});
85
86// Streaming events hand back a stream, not a promise, so they pass untraced.
87export const STREAMING = Object.freeze(["turn.step", "process.spawn"]);
88
89// Traced whether or not a mod says it hooks it.
90export const ALWAYS_TRACED = Object.freeze(["ui.render"]);
91
92export function isEngineCall(event: string): boolean {
93  return (
94    ENGINE_CALLS.exact.includes(event) ||
95    ENGINE_CALLS.prefix.some((p) => event.startsWith(p)) ||
96    STREAMING.includes(event)
97  );
98}
99
100// A pattern a mod hooks, as `uses.events` writes it. The bare `*` and the
101// negations name nearly every event, so they widen nothing here.
102export function isTracePattern(pattern: string): boolean {
103  return pattern !== "*" && !pattern.startsWith("!");
104}
105
106function matches(pattern: string, event: string): boolean {
107  if (!pattern.includes("*")) return pattern === event;
108  const source = pattern
109    .split("*")
110    .map((part) => part.replace(/[.+?^${}()|[\]\\]/g, "\\$&"))
111    .join(".*");
112  return new RegExp(`^${source}$`).test(event);
113}
114
115export function isTraced(patterns: readonly string[], event: string): boolean {
116  if (isEngineCall(event)) return false;
117  return (
118    ALWAYS_TRACED.includes(event) || patterns.some((p) => matches(p, event))
119  );
120}
121
122export function emptyRegistry(): Registry {
123  return { plugins: [], traced: [] };
124}
125
126// Stores one judged mod. A mod seen twice replaces its earlier entry.
127export function addPlugin(registry: Registry, plugin: PluginRecord): Registry {
128  const plugins = [
129    ...registry.plugins.filter(
130      (p) => !(p.name === plugin.name && p.provenance === plugin.provenance),
131    ),
132    plugin,
133  ];
134  const traced: string[] = [];
135  for (const p of plugins)
136    for (const pattern of p.events)
137      if (isTracePattern(pattern) && !traced.includes(pattern))
138        traced.push(pattern);
139  return { plugins, traced };
140}
141
142export function emptyBuffer(): TraceBuffer {
143  return {
144    total: 0,
145    armed: false,
146    maxAbove: 0,
147    order: [],
148    records: {},
149    aggregates: {},
150    suspects: {},
151    ownedEnders: [],
152  };
153}
154
155// The row key of a dispatch. Only a pane keeps its requestId: the others name
156// one message or tool use each, which would grow the keys without bound.
157export function keyOf(
158  event: string,
159  detail: { component?: string; requestId?: string; tool?: string },
160): string {
161  if (event === "ui.render") {
162    const component = detail.component ?? "ui.render";
163    return component === "Pane" && detail.requestId
164      ? `Pane:${detail.requestId}`
165      : component;
166  }
167  if (event === "tool.call" && detail.tool) return `tool.call:${detail.tool}`;
168  return event;
169}
170
171type TraceEntryLike = {
172  index?: unknown;
173  plugin?: unknown;
174  tier?: unknown;
175  outcome?: unknown;
176  ms?: unknown;
177};
178
179const finite = (n: unknown): number =>
180  typeof n === "number" && Number.isFinite(n) ? n : 0;
181
182// Keeps the five fields of each entry and nothing else. `received` and
183// `returned` can hold whole prompts, so they are never read.
184export function linksOf(trace: readonly TraceEntryLike[]): LinkRecord[] {
185  return trace.map((t) => ({
186    index: finite(t.index),
187    plugin: String(t.plugin ?? ""),
188    tier: String(t.tier ?? ""),
189    outcome: String(t.outcome ?? ""),
190    ms: finite(t.ms),
191  }));
192}
193
194export function makeRecord(
195  event: string,
196  key: string,
197  at: number,
198  trace: readonly TraceEntryLike[],
199  rejected: boolean,
200): TraceRecord {
201  const links = linksOf(trace);
202  const last = links[links.length - 1];
203  return {
204    event,
205    key,
206    at,
207    links,
208    endsAtEngine: last !== undefined && last.plugin === "engine",
209    rejected,
210  };
211}
212
213// A plugin's cost on one dispatch: the sum over its links.
214function costs(links: readonly LinkRecord[]): Map<string, number> {
215  const byPlugin = new Map<string, number>();
216  for (const l of links)
217    byPlugin.set(l.plugin, (byPlugin.get(l.plugin) ?? 0) + l.ms);
218  return byPlugin;
219}
220
221function fold(agg: Aggregate | undefined, ms: number): Aggregate {
222  const count = (agg?.count ?? 0) + 1;
223  const mean = (agg?.meanMs ?? 0) + (ms - (agg?.meanMs ?? 0)) / count;
224  return { count, meanMs: mean, maxMs: Math.max(agg?.maxMs ?? 0, ms) };
225}
226
227// Adds one record: the ring buffer keeps the last `perKey`, the aggregates
228// fold in each plugin's cost, and the least recently dispatched key goes once
229// there are more than `maxKeys`. Returns a new buffer.
230export function addRecord(
231  buffer: TraceBuffer,
232  record: TraceRecord,
233): TraceBuffer {
234  const key = record.key;
235  const records = {
236    ...buffer.records,
237    [key]: [...(buffer.records[key] ?? []), record].slice(-LIMITS.perKey),
238  };
239  const aggregates = { ...buffer.aggregates };
240  const perPlugin = { ...(aggregates[key] ?? {}) };
241  for (const [plugin, ms] of costs(record.links))
242    perPlugin[plugin] = fold(perPlugin[plugin], ms);
243  aggregates[key] = perPlugin;
244  const suspects = { ...buffer.suspects };
245  const order = [...buffer.order.filter((k) => k !== key), key];
246  while (order.length > LIMITS.maxKeys) {
247    const gone = order.shift() as string;
248    delete records[gone];
249    delete aggregates[gone];
250    delete suspects[gone];
251  }
252  return {
253    ...buffer,
254    total: buffer.total + 1,
255    maxAbove: Math.max(buffer.maxAbove, linksAbove(record.links)),
256    order,
257    records,
258    aggregates,
259    suspects,
260  };
261}
262
263// The chain of a record in order, one entry per plugin. A plugin with two
264// hooks on the event shows once, at its first place.
265export function chainOf(record: TraceRecord): string[] {
266  const seen: string[] = [];
267  for (const l of [...record.links].sort((a, b) => a.index - b.index))
268    if (!seen.includes(l.plugin)) seen.push(l.plugin);
269  return seen;
270}
271
272// Replaces the `engine` entry of a chain with the builtins seen on `event`,
273// each at its own time, then `engine` with its time minus the outermost
274// builtin's logged ms (the last one settled). A chain with no `engine` entry,
275// or an event with no builtin, comes back as it was.
276export function withBuiltins(
277  chain: SnapshotRow["chain"],
278  event: string,
279  builtins: readonly BuiltinSample[],
280): SnapshotRow["chain"] {
281  const mine = builtins.filter((b) => b.event === event);
282  if (mine.length === 0 || !chain.some((l) => l.plugin === "engine"))
283    return chain;
284  const spent = mine[mine.length - 1]?.ms ?? 0;
285  return chain.flatMap((link) =>
286    link.plugin === "engine"
287      ? [
288          ...mine.map((b) => ({
289            plugin: b.name,
290            tier: "builtin",
291            meanMs: b.ownMs,
292            maxMs: b.ownMs,
293            count: 1,
294            builtin: true,
295          })),
296          {
297            ...link,
298            meanMs: Math.max(0, link.meanMs - spent),
299            maxMs: Math.max(0, link.maxMs - spent),
300          },
301        ]
302      : [link],
303  );
304}
305
306// One row per key, most recently dispatched first. A row's chain is the
307// newest record's, each link carrying its running mean and max. `builtins` are
308// the log's builtin timings, folded out of the engine entry.
309export function rowsOf(
310  buffer: TraceBuffer,
311  builtins: readonly BuiltinSample[] = [],
312): SnapshotRow[] {
313  const rows: SnapshotRow[] = [];
314  for (const key of [...buffer.order].reverse()) {
315    const recent = [...(buffer.records[key] ?? [])].reverse();
316    const newest = recent[0];
317    if (!newest) continue;
318    const firstTier = new Map<string, string>();
319    for (const l of newest.links)
320      if (!firstTier.has(l.plugin)) firstTier.set(l.plugin, l.tier);
321    const agg = buffer.aggregates[key] ?? {};
322    rows.push({
323      key,
324      event: newest.event,
325      chain: withBuiltins(
326        chainOf(newest).map((plugin) => ({
327          plugin,
328          tier: firstTier.get(plugin) ?? "",
329          meanMs: agg[plugin]?.meanMs ?? 0,
330          maxMs: agg[plugin]?.maxMs ?? 0,
331          count: agg[plugin]?.count ?? 0,
332        })),
333        newest.event,
334        builtins,
335      ),
336      endsAtEngine: newest.endsAtEngine,
337      recent: recent.slice(0, LIMITS.detail),
338    });
339  }
340  return rows;
341}
342
343export function snapshotOf(
344  buffer: TraceBuffer,
345  selfCheck: Snapshot["selfCheck"],
346  at: number,
347  seq: number,
348  log: ParsedLog | null = null,
349): Snapshot {
350  return {
351    seq,
352    at,
353    rows: rowsOf(buffer, log?.builtins ?? []),
354    selfCheck,
355    suspects: Object.values(buffer.suspects),
356    ...(log ? { commandHooks: log.hooks } : {}),
357  };
358}
359
360// --- trouble -----------------------------------------------------------------
361
362// Events whose owning mod answers without next(), by design.
363const OWNED_EVENTS = Object.freeze([
364  "command.run",
365  "tool.call",
366  "ui.press",
367  "ui.open",
368  "ui.close",
369]);
370
371// True when ending this event's chain starves other mods. Every event is
372// shared except those in OWNED_EVENTS, which their owner answers. A
373// `ui.render` key is shared only for the components in SHARED_COMPONENTS.
374export function isSharedSurface(event: string, key: string): boolean {
375  if (OWNED_EVENTS.includes(event)) return false;
376  return event !== "ui.render" || SHARED_COMPONENTS.includes(key);
377}
378
379// Remembers a mod that ended the chain of an owned `ui.render` component.
380function noteOwnedEnd(buffer: TraceBuffer, record: TraceRecord): TraceBuffer {
381  const last = record.links[record.links.length - 1];
382  const known = buffer.ownedEnders ?? [];
383  if (
384    record.event !== "ui.render" ||
385    record.endsAtEngine ||
386    record.rejected ||
387    !last ||
388    known.includes(last.plugin)
389  )
390    return buffer;
391  return { ...buffer, ownedEnders: [...known, last.plugin] };
392}
393
394// The mod that ended the last `shortRun` records of a key, or null. A record
395// is short when its chain stops short of the engine; a rejected record never
396// counts.
397export function endingMod(records: readonly TraceRecord[]): string | null {
398  if (records.length < TROUBLE.shortRun) return null;
399  let who: string | null = null;
400  for (const r of records.slice(-TROUBLE.shortRun)) {
401    const last = r.links[r.links.length - 1];
402    if (r.endsAtEngine || r.rejected || !last) return null;
403    if (who !== null && who !== last.plugin) return null;
404    who = last.plugin;
405  }
406  return who;
407}
408
409// Updates the key's suspect after `record` joined the buffer: a run of short
410// records ending at one mod sets it, and any other newest history clears it.
411export function trackSuspect(
412  buffer: TraceBuffer,
413  record: TraceRecord,
414): TraceBuffer {
415  if (!isSharedSurface(record.event, record.key))
416    return noteOwnedEnd(buffer, record);
417  const key = record.key;
418  const who = endingMod(buffer.records[key] ?? []);
419  const prior = buffer.suspects[key];
420  if (who === null) {
421    if (!prior) return buffer;
422    const suspects = { ...buffer.suspects };
423    delete suspects[key];
424    return { ...buffer, suspects };
425  }
426  return {
427    ...buffer,
428    suspects: {
429      ...buffer.suspects,
430      [key]: {
431        key,
432        event: record.event,
433        plugin: who,
434        checkedAt: prior && prior.plugin === who ? prior.checkedAt : null,
435      },
436    },
437  };
438}
439
440// Plugins whose cost on `record` is over `slowFactor` times their mean on the
441// earlier records of the key. Reads the buffer as it stood before `record`
442// joined it. The engine's own entry is never a slowdown.
443export function slowdownsOf(
444  before: TraceBuffer,
445  record: TraceRecord,
446): Slowdown[] {
447  const found: Slowdown[] = [];
448  for (const [plugin, ms] of costs(record.links)) {
449    if (plugin === "engine") continue;
450    const agg = before.aggregates[record.key]?.[plugin];
451    if (!agg || agg.count < TROUBLE.slowSamples) continue;
452    if (ms < TROUBLE.slowMinMs || ms <= TROUBLE.slowFactor * agg.meanMs)
453      continue;
454    found.push({
455      plugin,
456      key: record.key,
457      event: record.event,
458      sample: ms,
459      mean: agg.meanMs,
460    });
461  }
462  return found;
463}
464
465const escapeRe = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
466
467// True when the engine's debug log has a line saying `plugin` answered `event`
468// without calling next().
469export function logNames(log: string, plugin: string, event: string): boolean {
470  const who = escapeRe(plugin);
471  const what = escapeRe(event);
472  return new RegExp(
473    `(^|\\s)${who} \\([^)]*\\) answered ${what} without next\\(\\)`,
474    "m",
475  ).test(log);
476}
477
478export function sameTrouble(a: TroubleItem, b: TroubleItem): boolean {
479  return a.kind === b.kind && a.key === b.key && a.plugin === b.plugin;
480}
481
482// The note on the hint line for one trouble item.
483export function hintTextOf(item: TroubleItem): string {
484  return item.kind === "ender"
485    ? `chain: ${item.plugin} ended ${item.key} · /chain`
486    : `chain: ${item.plugin} slow on ${item.key} · /chain`;
487}
488
hooks/views/pane.tsx 359 lines
1import { selfCheckLine } from "../selfcheck";
2import { FROZEN_LINE } from "../logparse";
3import type {
4  LogStatus,
5  Snapshot,
6  SnapshotRow,
7  TroubleItem,
8} from "../types/index";
9import { paint, type ToneName } from "./theme";
10
11// The chain inspector pane. Pure: state and props in, a tree out. Text takes
12// single-width glyphs only, so a string's length is its width in cells.
13
14export const ROW_PREFIX = "row-";
15
16// Mean ms at which a link turns warn, then bad.
17export const SLOW_MS = Object.freeze({ warn: 20, bad: 100 });
18
19export type Seg = {
20  text: string;
21  tone?: ToneName;
22  bold?: boolean;
23  dim?: boolean;
24  // The Button key when the segment is pressable.
25  press?: string;
26};
27
28export type Line = { key: string; segs: Seg[] };
29
30export type PaneInput = {
31  snapshot: Snapshot | null;
32  pluginCount: number;
33  tracing: boolean;
34  selected: string | null;
35  columns: number;
36  // Confirmed enders and slowdowns.
37  trouble: TroubleItem[];
38  // Left out, the pane draws as it does with no debug log read.
39  logStatus?: LogStatus;
40};
41
42// What the engine entry reads as when no debug log can split the builtins out.
43export const ENGINE_LABEL = "engine + builtins";
44
45export const SEPARATOR = " › ";
46
47export function fmtMs(ms: number): string {
48  if (ms >= 1000) return `${(ms / 1000).toFixed(1)}s`;
49  if (ms >= 10) return String(Math.round(ms));
50  return ms.toFixed(1);
51}
52
53function msTone(ms: number): ToneName {
54  if (ms >= SLOW_MS.bad) return "bad";
55  if (ms >= SLOW_MS.warn) return "warn";
56  return "muted";
57}
58
59const widthOf = (segs: Seg[]) => segs.reduce((n, s) => n + s.text.length, 0);
60
61// Cuts the end of a line so it fits, closing with an ellipsis.
62export function fit(segs: Seg[], columns: number): Seg[] {
63  if (widthOf(segs) <= columns) return segs;
64  const out: Seg[] = [];
65  let room = Math.max(0, columns - 1);
66  for (const s of segs) {
67    if (room <= 0) break;
68    const text = s.text.length <= room ? s.text : s.text.slice(0, room);
69    out.push({ ...s, text });
70    room -= text.length;
71  }
72  out.push({ text: "…", tone: "comment" });
73  return out;
74}
75
76// Breaks a sentence at spaces into lines of at most `columns` cells.
77export function wrap(text: string, columns: number): string[] {
78  const lines: string[] = [];
79  let line = "";
80  for (const word of text.split(" ")) {
81    const next = line === "" ? word : `${line} ${word}`;
82    if (next.length <= columns || line === "") line = next;
83    else {
84      lines.push(line);
85      line = word;
86    }
87  }
88  if (line !== "") lines.push(line);
89  return lines.map((l) => (l.length > columns ? l.slice(0, columns) : l));
90}
91
92// `event  mod 2.1ms › mod 0.4 › engine 1.0`. The first link carries the unit.
93export function rowSegs(row: SnapshotRow, engine = "engine"): Seg[] {
94  const segs: Seg[] = [
95    { text: row.key, tone: "accent", bold: true, press: ROW_PREFIX + row.key },
96    { text: "  " },
97  ];
98  row.chain.forEach((link, i) => {
99    if (i > 0) segs.push({ text: SEPARATOR, tone: "comment" });
100    const isEngine = link.plugin === "engine";
101    const dim = isEngine || link.builtin === true;
102    segs.push({
103      text: isEngine ? engine : link.plugin,
104      tone: dim ? "muted" : "fg",
105      dim,
106    });
107    segs.push({
108      text: ` ${fmtMs(link.meanMs)}${i === 0 ? "ms" : ""}`,
109      tone: msTone(link.meanMs),
110    });
111  });
112  return segs;
113}
114
115function ageLabel(snapshot: Snapshot, at: number): string {
116  const s = Math.max(0, Math.round((snapshot.at - at) / 1000));
117  return `${s}s ago`;
118}
119
120function header(input: PaneInput): Line[] {
121  const n = input.pluginCount;
122  const lines: Line[] = [
123    {
124      key: "title",
125      segs: fit(
126        [
127          { text: "chain inspector", tone: "accent", bold: true },
128          {
129            text: ` · ${n} ${n === 1 ? "mod" : "mods"} traced · `,
130            tone: "muted",
131          },
132          input.tracing
133            ? { text: "tracing on", tone: "ok" }
134            : { text: "tracing off", tone: "warn", bold: true },
135        ],
136        input.columns,
137      ),
138    },
139  ];
140  if (input.logStatus === "frozen")
141    lines.push({
142      key: "frozen",
143      segs: fit(
144        [{ text: FROZEN_LINE, tone: "muted", dim: true }],
145        input.columns,
146      ),
147    });
148  const check = input.snapshot ? selfCheckLine(input.snapshot.selfCheck) : null;
149  if (check)
150    wrap(check, input.columns).forEach((text, i) =>
151      lines.push({ key: `check-${i}`, segs: [{ text, tone: "warn" }] }),
152    );
153  return lines;
154}
155
156// The trouble section: a red row per confirmed ender, a warn row per suspected
157// ender and per slowdown. Empty when there is no trouble. A suspect already
158// confirmed shows once, as confirmed.
159export function troubleLines(input: PaneInput): Line[] {
160  const confirmed = input.trouble.filter((t) => t.kind === "ender");
161  const suspects = (input.snapshot?.suspects ?? []).filter(
162    (s) => !confirmed.some((t) => t.key === s.key && t.plugin === s.plugin),
163  );
164  const slow = input.trouble.filter((t) => t.kind === "slow");
165  if (confirmed.length + suspects.length + slow.length === 0) return [];
166  const row = (key: string, tone: ToneName, text: string): Line => ({
167    key,
168    segs: fit([{ text, tone, bold: tone === "bad" }], input.columns),
169  });
170  return [
171    row("trouble-head", "bad", "trouble"),
172    ...confirmed.map((t) =>
173      row(
174        `trouble-end-${t.key}-${t.plugin}`,
175        "bad",
176        `✗ ${t.plugin} ended ${t.key} · confirmed`,
177      ),
178    ),
179    ...suspects.map((s) =>
180      row(
181        `trouble-sus-${s.key}-${s.plugin}`,
182        "warn",
183        `! ${s.plugin} may have ended ${s.key} · suspected`,
184      ),
185    ),
186    ...slow.map((t) =>
187      row(
188        `trouble-slow-${t.key}-${t.plugin}`,
189        "warn",
190        `~ ${t.plugin} slow on ${t.key} · ${fmtMs(t.sample ?? 0)}ms vs ${fmtMs(t.mean ?? 0)}ms mean`,
191      ),
192    ),
193  ];
194}
195
196// The command hooks section: one row per hook, slowest first, from the debug
197// log. Empty when the log gave none. A non-zero status turns the row bad.
198export function commandHookLines(input: PaneInput): Line[] {
199  const hooks = input.snapshot?.commandHooks ?? [];
200  if (hooks.length === 0) return [];
201  return [
202    {
203      key: "hooks-head",
204      segs: fit(
205        [{ text: "command hooks", tone: "accent", bold: true }],
206        input.columns,
207      ),
208    },
209    ...hooks.map((h, i): Line => {
210      const failed = h.status !== 0;
211      return {
212        key: `hook-${i}`,
213        segs: fit(
214          [
215            {
216              text: h.matcher ? `${h.event}:${h.matcher}` : h.event,
217              tone: "fg",
218            },
219            { text: `  ${h.source}  `, tone: "muted" },
220            { text: h.script, tone: "fg" },
221            {
222              text: ` ${fmtMs(h.ms)}ms`,
223              tone: failed ? "bad" : msTone(h.ms),
224              bold: failed,
225            },
226            ...(failed
227              ? [{ text: ` status ${h.status}`, tone: "bad" as const }]
228              : []),
229            {
230              text: ` · ${h.runs} ${h.runs === 1 ? "run" : "runs"}`,
231              tone: "comment",
232            },
233          ],
234          input.columns,
235        ),
236      };
237    }),
238  ];
239}
240
241function detailLines(row: SnapshotRow, snapshot: Snapshot, columns: number) {
242  const lines: Line[] = [
243    {
244      key: "detail-head",
245      segs: fit(
246        [
247          {
248            text: `last ${row.recent.length} ${row.recent.length === 1 ? "record" : "records"} · press the row or Esc to go back`,
249            tone: "comment",
250          },
251        ],
252        columns,
253      ),
254    },
255  ];
256  const width = Math.max(0, ...row.chain.map((c) => c.plugin.length));
257  row.recent.forEach((rec, r) => {
258    lines.push({
259      key: `rec-${r}`,
260      segs: fit(
261        [
262          { text: ageLabel(snapshot, rec.at), tone: "muted" },
263          ...(rec.rejected
264            ? [{ text: " · rejected", tone: "bad" as const, bold: true }]
265            : []),
266        ],
267        columns,
268      ),
269    });
270    rec.links.forEach((l, i) => {
271      lines.push({
272        key: `rec-${r}-${i}`,
273        segs: fit(
274          [
275            { text: "  " },
276            { text: l.plugin.padEnd(width), tone: "fg" },
277            { text: ` ${fmtMs(l.ms).padStart(6)}ms `, tone: msTone(l.ms) },
278            { text: l.outcome, tone: "comment" },
279          ],
280          columns,
281        ),
282      });
283    });
284  });
285  return lines;
286}
287
288export function layoutPane(input: PaneInput): Line[] {
289  const lines = [...header(input), ...troubleLines(input)];
290  const engine = input.logStatus === "none" ? ENGINE_LABEL : "engine";
291  const snapshot = input.snapshot;
292  if (!snapshot || snapshot.rows.length === 0) {
293    lines.push({
294      key: "empty",
295      segs: fit(
296        [{ text: "no traced events yet", tone: "comment" }],
297        input.columns,
298      ),
299    });
300    lines.push(...commandHookLines(input));
301    return lines;
302  }
303  const picked = input.selected
304    ? snapshot.rows.find((r) => r.key === input.selected)
305    : undefined;
306  if (picked) {
307    lines.push({
308      key: ROW_PREFIX + picked.key,
309      segs: fit(rowSegs(picked, engine), input.columns),
310    });
311    lines.push(...detailLines(picked, snapshot, input.columns));
312    return lines;
313  }
314  for (const row of snapshot.rows)
315    lines.push({
316      key: ROW_PREFIX + row.key,
317      segs: fit(rowSegs(row, engine), input.columns),
318    });
319  lines.push(...commandHookLines(input));
320  return lines;
321}
322
323export type PaneProps = PaneInput & { table: any };
324
325function lineEl(table: any, line: Line) {
326  const { Box, Text, Button } = table;
327  return (
328    <Box key={line.key}>
329      {line.segs.map((s) =>
330        s.press ? (
331          <Button
332            key={s.press}
333            label={s.text}
334            plain
335            onPress={() => undefined}
336          />
337        ) : (
338          <Text
339            color={s.tone ? paint(s.tone) : undefined}
340            bold={s.bold ? true : undefined}
341            dimColor={s.dim ? true : undefined}
342          >
343            {s.text}
344          </Text>
345        ),
346      )}
347    </Box>
348  );
349}
350
351export function pane(input: PaneProps) {
352  const { Box } = input.table;
353  return (
354    <Box flexDirection="column">
355      {layoutPane(input).map((l) => lineEl(input.table, l))}
356    </Box>
357  );
358}
359
hooks/views/theme.ts 58 lines
1import type { PaintMode, Tone } from "../types/views-state";
2
3// Tone table from docs/reference/theme-tokens.md. A tone with a proven theme
4// key follows the owner's Claude theme in `theme` mode; the rest use hex.
5
6export const TONES = Object.freeze({
7  fg: { hex: "#c0caf5", ansi: "whiteBright" },
8  muted: { hex: "#9aa5ce", ansi: "white" },
9  comment: { hex: "#565f89", ansi: "blackBright" },
10  accent: { hex: "#7aa2f7", ansi: "blue", themeKey: "claude" },
11  info: { hex: "#7dcfff", ansi: "cyan", themeKey: "suggestion" },
12  ok: { hex: "#9ece6a", ansi: "green", themeKey: "success" },
13  warn: { hex: "#e0af68", ansi: "yellow", themeKey: "warning" },
14  owner: { hex: "#ff9e64", ansi: "yellowBright" },
15  bad: { hex: "#f7768e", ansi: "red", themeKey: "error" },
16  violet: { hex: "#bb9af7", ansi: "magenta" },
17  teal: { hex: "#73daca", ansi: "cyanBright" },
18  indigo: { hex: "#3d59a1", ansi: "blue" },
19  bg: { hex: "#1a1b26", ansi: "black" },
20  redRowBg: { hex: "#4a272f", ansi: "red" },
21} satisfies Record<string, Tone>);
22
23export type ToneName = keyof typeof TONES;
24
25export function paint(tone: ToneName, mode: PaintMode = "theme"): string {
26  const t: Tone = TONES[tone];
27  if (mode === "ansi") return t.ansi;
28  if (mode === "theme" && t.themeKey) return t.themeKey;
29  return t.hex;
30}
31
32// Glyph policy. Text takes single-width symbols. Raster takes blocks, box
33// drawing, braille and ASCII. The Text fallback for the desktop Code tab
34// draws the crest and so also takes the blocks.
35export const GLYPH_POLICY = Object.freeze({
36  text: Object.freeze([..."●○◆◇◐◉■✓✗×~!▸▾·…━─└⎿›‹≠⇡"]),
37  raster: Object.freeze([
38    [0x2580, 0x259f],
39    [0x2500, 0x257f],
40    [0x2800, 0x28ff],
41    [0x20, 0x7e],
42  ] as const),
43  banned: Object.freeze([..."⚠✔✅"]),
44});
45
46export function isTextGlyph(ch: string): boolean {
47  const cp = ch.codePointAt(0) ?? 0;
48  return (cp >= 0x20 && cp <= 0x7e) || GLYPH_POLICY.text.includes(ch);
49}
50
51export function isRasterGlyph(cp: number): boolean {
52  return GLYPH_POLICY.raster.some(([lo, hi]) => cp >= lo && cp <= hi);
53}
54
55export function isBlockGlyph(cp: number): boolean {
56  return cp >= 0x2580 && cp <= 0x259f;
57}
58
hooks/types/index.d.ts 187 lines
1// PluginState contract for chain-inspector. Every key holds plain JSON data.
2
3// One link of a recorded chain. `received` and `returned` are never kept.
4export type LinkRecord = {
5  index: number;
6  plugin: string;
7  tier: string;
8  outcome: string;
9  ms: number;
10};
11
12// One traced dispatch.
13export type TraceRecord = {
14  event: string;
15  key: string;
16  at: number;
17  links: LinkRecord[];
18  // True when the last link is the engine's own entry.
19  endsAtEngine: boolean;
20  // True when `next(e)` rejected.
21  rejected: boolean;
22};
23
24export type Aggregate = {
25  count: number;
26  meanMs: number;
27  maxMs: number;
28};
29
30// A mod suspected of ending a shared chain: its last records on the key were
31// short and ended at it. `checkedAt` is when the debug log was last read for it.
32export type Suspect = {
33  key: string;
34  event: string;
35  plugin: string;
36  checkedAt: number | null;
37};
38
39// A plugin's new sample on a key that ran over its earlier mean.
40export type Slowdown = {
41  plugin: string;
42  key: string;
43  event: string;
44  sample: number;
45  mean: number;
46};
47
48// Confirmed trouble kept for the session: an ender the debug log names, or a
49// slowdown. `sample` and `mean` are set for a slowdown.
50export type TroubleItem = {
51  kind: "ender" | "slow";
52  plugin: string;
53  key: string;
54  event: string;
55  at: number;
56  sample?: number;
57  mean?: number;
58};
59
60export type TraceBuffer = {
61  // Records added since the session began.
62  total: number;
63  // True while a snapshot flush is scheduled.
64  armed: boolean;
65  // The most links seen above the inspector in any trace.
66  maxAbove: number;
67  // Keys, least recently dispatched first.
68  order: string[];
69  records: Record<string, TraceRecord[]>;
70  aggregates: Record<string, Record<string, Aggregate>>;
71  // Suspected enders by key. A full record on the key removes its entry.
72  suspects: Record<string, Suspect>;
73  // Mods that ended the chain of a component they draw (an owned `ui.render`
74  // component) this session. The debug log cannot tell such a draw from a
75  // shared-surface end, so these mods are never confirmed from it.
76  ownedEnders: string[];
77};
78
79// One mod `plugin.register` judged.
80export type PluginRecord = {
81  name: string;
82  tier: string;
83  provenance: string;
84  events: string[];
85};
86
87export type Registry = {
88  plugins: PluginRecord[];
89  // Event patterns the star hook traces, from every judged mod's `uses`.
90  traced: string[];
91};
92
93export type SnapshotLink = {
94  plugin: string;
95  tier: string;
96  meanMs: number;
97  maxMs: number;
98  count: number;
99  // True for a builtin plugin's link, read from the debug log.
100  builtin?: boolean;
101};
102
103// One builtin plugin's settled time on one event, from the debug log. The
104// samples of an event are its latest dispatch, innermost builtin first.
105export type BuiltinSample = {
106  name: string;
107  event: string;
108  // As logged, with everything beneath the builtin included.
109  ms: number;
110  // `ms` minus the `ms` of the builtin settled just before it, floored at 0.
111  ownMs: number;
112};
113
114// One command hook, from the debug log's `Hooks:` lines. The command itself is
115// never kept; `script` is the script's file name only.
116export type CommandHook = {
117  event: string;
118  matcher: string | null;
119  // `settings`, or the plugin's name without its marketplace.
120  source: string;
121  script: string;
122  // Latest run.
123  ms: number;
124  runs: number;
125  // Exit status of the latest run.
126  status: number;
127};
128
129// What one pass over the debug log yields.
130export type ParsedLog = {
131  // `<plugin> <event>` for each `answered <event> without next()` line.
132  enders: string[];
133  builtins: BuiltinSample[];
134  hooks: CommandHook[];
135};
136
137// `none` when there is no debug log, `reading` while it is read, `frozen` once
138// it passes the size limit.
139export type LogStatus = "none" | "reading" | "frozen";
140
141export type SnapshotRow = {
142  key: string;
143  event: string;
144  chain: SnapshotLink[];
145  endsAtEngine: boolean;
146  // Newest first.
147  recent: TraceRecord[];
148};
149
150export type SelfCheck = {
151  // Links sitting above the inspector in the widest trace seen.
152  above: number;
153  // Enabled plugins keyed before the inspector that it never judged.
154  names: string[];
155  // False when settings gave no enabledPlugins, so `names` is unknown.
156  namesKnown: boolean;
157};
158
159export type Snapshot = {
160  // Counts snapshot writes.
161  seq: number;
162  at: number;
163  rows: SnapshotRow[];
164  selfCheck: SelfCheck;
165  suspects: Suspect[];
166  // Command hooks from the debug log, slowest first. Absent without a log.
167  commandHooks?: CommandHook[];
168};
169
170declare module "claude-code" {
171  interface PluginState {
172    "chain-inspector": {
173      registry: Registry;
174      buffer: TraceBuffer;
175      snapshot: Snapshot | null;
176      tracing: boolean;
177      paneOpen: boolean;
178      selected: string | null;
179      // Confirmed enders and slowdowns, kept for the session.
180      trouble: TroubleItem[];
181      // The note the PromptHint line carries while an unasked pane waits.
182      hintTail: string | null;
183      logStatus: LogStatus;
184    };
185  }
186}
187