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

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.
/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.
| Command | Effect |
|---|---|
/chain | Open the pane |
/chain off | Stop tracing |
/chain on | Start tracing again |
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 see | It means | What to do |
|---|---|---|
! mod may have ended key · suspected | The mod ended a shared chain three times in a row | Restart with --debug to confirm it |
✗ mod ended key · confirmed | The debug log shows the mod ending the chain | Mods 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 mean | After 10 runs, the mod took over twice its average and at least 20 ms | Watch whether it repeats |
… load before the inspector and are not traced in the header | Plugins loaded before the inspector, which it cannot see past | Move the inspector to the top of enabledPlugins; plugins with only skills or commands hide nothing |
debug log over 4 MiB · log readings frozen | The debug log grew too large to read, so nothing new is confirmed | Start 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.
hooks/register.tsx 575 lines1import { 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};
575hooks/logparse.ts 153 lines1import 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}
153hooks/selfcheck.ts 79 lines1import 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}
79hooks/trace.ts 488 lines1import { 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}
488hooks/views/pane.tsx 359 lines1import { 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}
359hooks/views/theme.ts 58 lines1import 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}
58hooks/types/index.d.ts 187 lines1// 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