SLOPSHOPPER

agent-log

Why was that turn slow, and who allowed that call? A live pane that splits each turn's time across the model (first token, thinking), tools, hooks and waiting…

panebandguardcommandtoastprompt
v0.1.0MITupdated 2026-10-24dboeter/cc-plugins/plugins/agent-log
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agent-log
│ ┃ Agent log ✕ › fix the failing auth test and add an audit log ╭─────────────────╮ │ ┃ a: all• m: model h: harness u: outside │ agent-log │ │ ┃ l: level info o: unfold i: keys ^↑↓ tur ⏺ Read(src/auth.ts) │ agent log shown │ │ ┃ k: ↑ s: setup Opus 5.5 ⎿ Read 6 lines ╰─────────────────╯ │ ┃ ──────────────────────────────────────────── ⏺ Update(src/auth.ts) │ ┃ ─────────── ⎿ Added 2 lines, removed 1 line │ ┃ start ⏺ Bash(bun test) │ ┃ Session init in app ⎿ 3 pass, 1 fail │ ┃ │ ┃ #1 fix the failing auth… · other 42.0s42.0s ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ Read auth.ts <0.1s │ ┃ Search "refresh\(" in src <0.1s ✻ Worked for 42s · done 4:20 PM │ ┃ Edit auth.ts <0.1s │ ┃ Write audit.ts <0.1s › /agentlog │ ┃ Write cache.ts <0.1s │ ┃ Run the test suite ✗ <0.1s │ ┃ Show working tree status <0.1s │ ┃ Clean build output and push <0.1s │ ┃ Show environment file <0.1s │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Agent log
a: all• m: model h: harness u: outside l: level info o: unfold i: keys ^↑↓ turn j: ↓ k: ↑ s: setup Opus 5.5 ─────────────────────────────────────────────────────── start Session init in app #1 fix the failing auth… · other 42.0s42.0s Read auth.ts <0.1s Search "refresh\(" in src <0.1s Edit auth.ts <0.1s Write audit.ts <0.1s Write cache.ts <0.1s Run the test suite ✗ <0.1s Show working tree status <0.1s Clean build output and push <0.1s Show environment file <0.1s
README

agent-log

Why was that turn slow, and who allowed that call? A live pane that splits each turn's time across the model (time to first token, thinking), tools, hooks and waiting on you, read from Claude Code's own model stream. With OTEL on, also who decided each call: you, a hook or the classifier.

#1 · tools 17.2s · model 12.3s · 4 hidden ▸                                           39.6s
╭ context-mode context
│ Waiting for you to answer the permission prompt                                       4.2s
╰ Run the tests                                                                     ✗ 18.4s
╭ guard                                                                             ⊘ <0.1s
╰ Clean the build output                                                          ⊘ denied
╭ Edit agent-log.tsx                                                                    0.1s
╰ lint-feedback                                                                     ↩ 1.3s
  Read index.ts ↗ ~/other-project/src                                                <0.1s
╭ Reply: The test fails because the header grew a row.                                 6.1s
╰ Advisor                                                                               3.2s

agent-log is the interpreted view of a session. event-log is the raw record, every event with its full payload on disk. The two work as a pair.

What it shows

Parts marked (OTEL) need OpenTelemetry export on (CLAUDE_CODE_ENABLE_TELEMETRY=1). Without it they are missing and the rest works.

  • Turns: each prompt's header gives the turn's time and its two largest parts. #N opens the full split: model, tools, agents, hooks, compaction, permission prompts and you.
  • Model requests: one row per request of the main loop or a subagent, with time to first token, thinking time, tokens and the start of the reply. Price and request id (OTEL).
  • Tool calls: each call with its run time, and its hooks bracketed around it (╭ ├ ╰). The inspector says who decided it: a settings rule, or, (OTEL), you, a hook or the auto-mode classifier. A call that waited for that decision gets a Waiting for … row above it (OTEL).
  • Outside the project root: a call that reached another project is marked ↗ with the place, and the controls count them (↗ 2 other project). A routine place (~/.claude, /tmp) shows as a dim path without the ↗. The outside view keeps all of these calls.
  • The harness: hook runs and what they injected, plugins' model calls, compactions, model switches and MCP failures. Claude Code's own requests (titles, suggestions) and API errors (OTEL).
  • Running now: what streams or runs at this moment, as green ● lines under the log.
  • Setup page (s): what broke this session (each problem opens its row), hooks per event with their runs (OTEL for the run counts), plugins, model use per source, where the session is, and background work still running. Context, cost, rate limits and MCP servers are left to /context, /cost, /status and /mcp.

i opens the keys page, which lists what every mark and colour means. Each mark has its own glyph, so none relies on colour alone. In the controls, bright means active: the view shown (also marked •), the open page, the loop the log is filtered to. When the log starts mid-turn, that turn's #N header stays pinned on its first line.

Per-hook times

Claude Code times an event's hooks as one chain and merges their text, so without help the log can only say "these hooks ran, together, in 1.3s". Wrapping each settings hook in bin/hook-timer gives each hook its own row, its own time and the text it injected. It needs bash 5+ and jq. Copy it onto your PATH and put it in front of each hook command in settings.json:

{ "type": "command", "command": "hook-timer ~/.claude/hooks/guard.sh" }

The hook keeps its stdin, stdout, stderr and exit code. hook-timer appends one line per run to ~/.claude/hook-times/<session_id>.jsonl, which agent-log reads. Plugins' hooks can't be wrapped; they are named from their hooks.json, and their text by the name in its first line.

Install

/plugin install agent-log --marketplace dboeter/cc-plugins

Answer y to add the marketplace, then pick a scope.

Use

The pane never opens by itself. While it is closed, a turn that ended unusually gets one line above the prompt, between turns, until the next turn ends or you open the pane:

  • /agentlog #4 took 41.2s, 3× the usual: the turn ran over twice the median of your earlier turns (once there are three).
  • /agentlog #2: hooks 12.0s of 20.0s: hooks, or a slow permission decision, held the turn longest.

Turns under 10 seconds never get a line, and time spent waiting on you never counts as unusual. /agentlog shows or hides the pane, /agentlog all|model|harness|outside picks a view (agent still works for model), /agentlog level debug|info|notice|error sets the least important row shown, and /agentlog clear empties the log. No /agentlog enters the conversation: its line is dropped and its reply is a toast, so the model reads neither.

In the pane, a m h u switch views: all, what the model did, the harness, outside the root. l toggles the level between info and notice. o opens or closes every Agent fold. s opens the setup page and i the keys page. j and k (or ↓ and ↑) move a row cursor, and Enter opens its row. e jumps to the newest row, ctrl+↑↓ (^↑↓ turn) to the previous or next prompt, and b goes back. In a row's inspector, n and p step to the next and previous row; an Agent row's inspector has f to keep only that agent's rows. While an agent runs, a tree of the main loop and its agents sits at the top, and pressing one filters the log to it.

What it does on your machine

  • Storage: the rows of the 10 newest sessions in the plugin store. A session keeps at most 300 rows (the keep option), and fewer when its rows are large, since each session is held to a tenth of the store's 4 MiB. Every new session prunes the older ones. If the store refuses a write, the rows stay in memory, the setup badge shows "log not saved", and the write is retried. Secret-shaped text (tokens, keys, passwords, Authorization headers) is masked as *** on a best-effort basis, and text is cut short: 300 characters, 400 for a command. Your typed prompts and a slash command's arguments are not stored as such. A turn header's first few words of the prompt are kept in memory only, so a resumed session's headers have none. Two stored texts can still restate what you typed: a skill's expanded text (which can include the arguments after /name) and a compaction summary. Both are kept scrubbed, up to 300 characters.
  • OTEL: with export on, it reads the records Claude Code sends the collector: request prices and ids, who decided each permission, hook run counts, plugins loaded, MCP connections, API errors and Claude Code's own model requests. It reads them and sends nothing.
  • Reads: your settings and the hooks.json of enabled plugins (to name hooks), ~/.claude/hook-times/<session_id>.jsonl when hook-timer writes it, the main loop's model at session start, and the session's root and cwd at each tool call. It makes no model requests and no network calls.

Develop

claude --plugin-dir plugins/agent-log   # loads it from source
tsc -p plugins                          # type-checks every plugin against plugins/types/
claude plugin test plugins/agent-log    # tests
claude plugin validate plugins/agent-log

claude plugin test runs only *.test.ts. After changing bin/hook-timer, run plugins/agent-log/hooks/hook-timer.test.sh: it checks the same stdin, stdout, stderr, exit code and record as the version before the fork-lean rewrite, or as the commit you name.

hooks/scrub.ts is a copy of plugins/shared/scrub.ts: edit that file and run plugins/shared/sync.sh.

Source 3 files
hooks/agent-log.tsx 2135 lines
1import type { AgentInfo, ModelUsage, Register, Timer, TurnStepResult } from 'claude-code'
2import { scrub } from './scrub'
3
4// Deliberately single-file: every hook and the pane read and mutate the module-level state (entries, seen, owed, chain, the
5// view and expansion state), so a split would pass that state across modules. Sections, in order (search `// ===`):
6// ENTRIES AND TEXT, HOOK NAMES, ROWS, STATE AND STORE, HOOKS, PANE
7
8// === ENTRIES AND TEXT ===
9type Src = 'engine' | 'hook' | 'plugin' // who authored injected text: the engine's own framing, a settings hook, or a plugin chain
10// agent: the loop's agentId, absent on main. bytes: injected size (kept, not shown for now). ms: tool run time, minus its wait.
11// wait/waitFor: on a tool row, the time between tool.check's verdict and the engine's decision, and what it waited on
12// type: the engine's attachment kind or context-block name, kept beside the readable `name`. origin: the settings hook event that produced the text or, on a hook row, ran the hook (its trigger; `name` is then the hook's own name).
13// cmd: a Bash call's full scrubbed command, shown in the inspector while the row shows its description. pat: a Grep's or Glob's
14// pattern, scrubbed and cut to 80, which its row reads (`Search "secs\(" in hooks`)
15// check/rule: tool.check's verdict on a tool call and the settings rule that decided it, as written (`Bash(git push:*)`); no rule = a mode, hook or the tool's own check
16// via: who settled the call, as the collector's tool_decision names it (`config`, `hook`, `user_temporary`, `user_reject`); only with OTEL export on
17// peek: the first PEEK chars of the injected text, error, or deny/block reason, secret-scrubbed; never the person's typed prompt
18// turn: the number of your prompt the entry followed (`#N` = since your Nth prompt; 0 = before your first, drawn `start`); a subagent's entries carry the same number. Absent on entries from before turns were counted
19// status: a tool call's result as data, so views and folds never parse the `outcome` label; entries from before it derive one (stat).
20// exit/insight: a failed call's exit code and its output boiled down (insightOf)
21// child: on an Agent row, the id of the agent it spawned; that agent's rows fold under it (rootOf)
22// use: the tool_use_id of the call a row belongs to: on a tool row, and on its PreToolUse/PostToolUse hook rows and their text, which the log draws around it, linked
23// again: hook text the session already logged word for word (a plugin's tip on every call), or a cwd change back to where the session began: ranked info
24type Status = 'ok' | 'failed' | 'denied' | 'blocked' | 'feedback' | 'stopped' | 'aborted' | 'nomatch'
25// fork: a plugin's $.model.fork, logged under main; name is the plugin, outcome its token cost, peek its reply or why it has none
26// step: one model request (turn.step); name is the model the request named, peek the start of its reply. step: on a step row its
27// own key, on a tool row the request that asked for the call. effort as requested; ttft: ms to the first streamed thinking, text
28// or call; think: ms spent thinking; stop: the API's stop reason; tok: [uncached in, cache read, cache write, out]; asked: the
29// tools it called; server: the calls the API ran itself (the advisor) and their ms; thought: its thinking's first line;
30// cost/req: the collector's price and request id for it, when OTEL export is on
31// split: on main's turn row, where its time went as splitOf counts it, kept so evicting the turn's rows does not lose it
32// out: on a tool row, the place outside the session's project root the call reached (outsideOf), `~`-shortened; absent inside it
33// each: on a row of several hooks, each hook's own ms, by name, as hook-timer recorded it (pickRuns); hooks it does not wrap are absent
34// spoke: on such a row, the hooks whose text (context, a block, feedback) the row's outcome is; errs: the wrapped hooks that exited
35// neither 0 nor 2 (2 is a block) without saying anything. Each hook's own row (hookOf) takes its mark from them; a row stored before
36// they existed shows times only. says: on such a row, each hook's own text (peek), by the hook it is credited to (speaker,
37// hookName); exits: each wrapped hook's nonzero exit code. Both feed the inspector a hook's line opens (hookOf)
38// event: something the engine reports happening (eventOf, harnessOf): name is what (`model switch`, `API error`), detail and outcome
39// its particulars. side: a model request outside the turns: the engine's own (src engine, name its query source: a title, a
40// prompt suggestion, compaction) or a plugin's $.model.complete (src plugin, name the plugin); detail is the model
41type Entry = { t: number; turn?: number; kind: 'tool' | 'hook' | 'session' | 'prompt' | 'turn' | 'fork' | 'step' | 'event' | 'side'; name: string; detail: string; outcome: string; status?: Status; src?: Src; bytes?: number; agent?: string; ms?: number; type?: string; origin?: string; peek?: string; cmd?: string; check?: 'allow' | 'ask' | 'deny'; rule?: string; exit?: number; insight?: string; child?: string; use?: string; wait?: number; waitFor?: string; again?: boolean
42  pat?: string; via?: string; step?: string; effort?: string | number; ttft?: number; think?: number; stop?: string; tok?: number[]; asked?: string[]; server?: [string, number | null][]; thought?: string; cost?: number; req?: string; split?: number[]; out?: string; each?: Record<string, number>; spoke?: string[]; errs?: string[]; says?: Record<string, string>; exits?: Record<string, number> }
43
44const PANE = 'agent-log'
45// a block after the fact refuses nothing: PostToolUse's tool already ran and Stop's turn already ended, so the hook's text is
46// feedback the model reads next (lint-feedback's lint errors), not a refusal
47const FEEDBACK = new Set(['PostToolUse', 'PostToolUseFailure', 'PostToolBatch', 'Stop', 'SubagentStop'])
48const stat = (x: Entry): Status => x.status ?? (x.outcome === 'deny' ? 'denied' : x.outcome === 'error' ? 'failed' : x.outcome.startsWith('block') ? (FEEDBACK.has(x.origin ?? '') ? 'feedback' : 'blocked') : x.outcome === 'stop' ? 'stopped' : x.outcome === 'aborted' ? 'aborted' : 'ok')
49const BAD = new Set<Status>(['failed', 'denied', 'blocked', 'stopped']) // ranked error (lvl); drawn red, save REFUSED and a stop
50// refused before it ran: by a hook (your guard), a rule, the classifier, you, or the model. Drawn a cyan ⊘, not a red ✗: a safety
51// check doing its job, not something that broke
52const REFUSED = new Set<Status>(['denied', 'blocked'])
53const loud = (x: Entry) => BAD.has(stat(x)) || stat(x) === 'feedback' // a hook row that refused its call or answered it
54const num = (t: number) => t < 1000 ? String(t) : `${(t / 1000).toFixed(1)}k`
55const toks = (t: number) => `${num(t)} tok`
56// a loop's token cost. input_tokens is only the uncached tail: a fork re-sends main's whole transcript, mostly from the cache
57export const costOf = (u?: ModelUsage) => u ? `${toks(u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens)} in · ${toks(u.output_tokens)} out` : ''
58const tok = (n: number) => toks(Math.round(n / 4)) // ponytail: chars/4, no tokenizer in the plugin API; ±20% on prose
59const kb = (n: number) => `~${tok(n)}`
60// scrub: secret-shaped text as ***, from hooks/scrub.ts (a copy of plugins/shared/scrub.ts, as event-log's is); re-exported for the tests
61export { scrub }
62const SESSIONS = 10 // was 20 before step rows. Each session's key is held to BUDGET bytes, so 10 of them fit the store's 4 MiB cap
63const PEEK = 300 // chars of text kept per entry
64const peek = (s: unknown): string | undefined => typeof s === 'string' && s.trim() ? scrub(s.trim()).slice(0, PEEK) : undefined
65const noCd = (s: string) => s.replace(/^\s*cd\s+\S+\s*(&&|;)\s*/, '') // a Bash command minus its leading `cd <dir> &&` / `;`
66const first = (s?: string) => (s ?? '').split('\n').find(l => l.trim())?.trim() ?? ''
67const esc = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
68// the engine's framing in front of a hook's text (`PreToolUse:Bash hook error: [$HOME/…/guard.sh]: `), which hookName reads past
69const FRAME = /^\w+(:\S+)? hook [^:\n]*:\s*(\[[^\]\n]*\]:\s*)?/
70
71// A failed call's output boiled down before the PEEK cap: the program, its error count, the lines that say what broke.
72// tool.call sees the whole text, so this beats the raw first 300 chars, which are often a command echo or a progress bar
73const ANSI = /\x1b\[[0-9;?]*[A-Za-z]/g
74const NOISE = /^\s*(npm (ERR!|error)\s*$|npm (ERR!|error) (code|path|errno|syscall|signal|command|cwd|A complete log|Lifecycle)|at .*(node_modules|node:internal)|[\d.]+%|.*[█░▓▒]{3}|[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏])/i
75const ERRLINE = /(error|exception)\b|\b(fatal|fail(ed|ure)?|panic|denied|rejected|conflict|not found|no such file|cannot|unable)\b|TS\d{4}/i // no \b before error: KeyError, TypeError
76const QUIET = new Set(['grep', 'rg', 'diff', 'cmp', 'test', '[']) // exit 1 from these means "no match" or "differs", not broke
77const WRAP = new Set(['sudo', 'npx', 'bunx', 'exec', 'time', 'env', 'nice', 'timeout'])
78const progOf = (seg: string) => { // `FOO=1 timeout 30 npx tsc -p .` -> tsc; `npm run test` -> npm test
79  const w = seg.trim().split(/\s+/).filter(t => t && !/^\w+=/.test(t))
80  while (w.length > 1 && (WRAP.has(w[0]!) || /^\d+[smh]?$/.test(w[0]!))) w.shift()
81  const p = (w[0] ?? '').split('/').pop() ?? ''
82  return /^(npm|pnpm|yarn|bun)$/.test(p) && w[1] && !w[1].startsWith('-') ? `${p} ${w[1] === 'run' ? w[2] ?? 'run' : w[1]}` : p
83}
84function insightOf(cmd: string | undefined, raw: string): { exit?: number; quiet: boolean; text: string } {
85  const m = raw.match(/^Exit code (\d+)/m) // the Bash tool's error text leads with it
86  const exit = m ? Number(m[1]) : undefined
87  const lines = raw.replace(ANSI, '').split('\n').map(l => l.trimEnd()).filter(l => l.trim() && !/^Exit code \d+$/.test(l.trim()) && !NOISE.test(l))
88  const progs = cmd ? noCd(cmd).split(/&&|\|\||[;|\n]/).map(progOf).filter(Boolean) : []
89  const last = progs.at(-1) // a pipeline exits with its last command's code
90  const quiet = exit === 1 && !!last && QUIET.has(last) && !lines.some(l => ERRLINE.test(l))
91  const known = progs.find(p => /^(vue-tsc|tsc|vitest|jest|pytest|git|cargo|go|eslint)$|test$/.test(p)) ?? last ?? ''
92  let head = known
93  let pick: string[] = []
94  const ts = lines.filter(l => /error TS\d+/.test(l))
95  if (ts.length) {
96    const files = new Set(ts.map(l => l.split('(')[0]))
97    head = `${known || 'tsc'} · ${ts.length} error${ts.length > 1 ? 's' : ''} in ${files.size} file${files.size > 1 ? 's' : ''}`
98    pick = ts.map(l => l.replace(/^[^(]*\//, '').replace(/:\s*error (TS\d+):\s*/, '  $1 ')) // `src/x.ts(4,7): error TS2322: msg` -> `x.ts(4,7)  TS2322 msg`
99  } else if (/test|vitest|jest|pytest/.test(known)) pick = lines.filter(l => /^\s*(FAIL|FAILED|✗|×|●)\s|\b\d+ failed\b/.test(l))
100  else if (known === 'git') pick = lines.filter(l => /^(fatal|error):|\[rejected\]|^CONFLICT/.test(l))
101  if (!pick.length) pick = lines.filter(l => ERRLINE.test(l))
102  if (!pick.length) pick = lines.slice(-3) // errors land at the end more often than at the start
103  return { exit, quiet, text: scrub([head, ...pick.slice(0, 3).map(l => l.trim())].filter(Boolean).join('\n')).slice(0, PEEK) }
104}
105
106// === HOOK NAMES ===
107// A hook's name, from settings and the enabled plugins' hooks.json: the engine says which event ran but merges every hook on it,
108// so the name is inferred. Candidates are the event's hooks whose matcher fits the target. With no text, every candidate ran and
109// said nothing, so all are named. A settings hook names itself in its text's first line (hooks/CLAUDE.md), a plugin's hook maybe
110// anywhere; text is credited to the candidates it names that way. Text naming none is a plugin hook's, or with one candidate that
111// one's. Else the event's, as a guess
112type HookCfg = Record<string, { matcher: string; name: string; plugin?: boolean; cmd?: string }[]> // cmd: the command as written, so one script on several events or matchers counts once
113let hookCfg: HookCfg | undefined
114let hookFresh = false // hookCfg came from a settings read that worked and no ConfigChange has fired since: else read again
115let enabled: string[] = [] // the plugins settings enable (`name@marketplace`): the setup's plugin list where no plugin_loaded record came
116const scriptName = (h: any): string => {
117  const cmd = String(h?.command ?? '')
118  return cmd.match(/([\w.-]+)\.(sh|py|js|mjs|ts|rb|pl)\b/)?.[1] ?? cmd.split(/\s+/)[0]?.split('/').pop() ?? String(h?.type ?? 'hook')
119}
120async function hooksOf($: any): Promise<HookCfg> {
121  if (hookCfg && hookFresh) return hookCfg
122  const cfg: HookCfg = {}
123  const add = (hooks: any, plugin?: string) => {
124    for (const [ev, groups] of Object.entries<any>(hooks ?? {})) for (const g of Array.isArray(groups) ? groups : []) for (const h of g?.hooks ?? []) (cfg[ev] ??= []).push({ matcher: String(g?.matcher ?? ''), name: plugin ?? scriptName(h), plugin: !!plugin, cmd: String(h?.command ?? h?.prompt ?? h?.type ?? '') })
125  }
126  let s: any
127  let ok = false
128  try { s = await $.settings.read(); ok = true; add(s?.hooks); enabled = Object.entries(s?.enabledPlugins ?? {}).filter(([, on]) => on).map(([id]) => id) } catch {}
129  // a plugin's command hooks run in the same classic chain; named by the plugin (`context-mode`), not their scripts (`pretooluse`)
130  try {
131    const installed = JSON.parse(await $.fs.read(`${await $.env.get('HOME')}/.claude/plugins/installed_plugins.json`))?.plugins ?? {}
132    for (const [id, on] of Object.entries(s?.enabledPlugins ?? {})) if (on) for (const i of installed[id] ?? []) try { add(JSON.parse(await $.fs.read(`${i.installPath}/hooks/hooks.json`))?.hooks, id.split('@')[0]) } catch {}
133  } catch {}
134  hookFresh = ok
135  return hookCfg = cfg
136}
137const fits = (matcher: string, target?: string) => {
138  if (!matcher || matcher === '*' || target == null) return true
139  try { return new RegExp(`^(?:${matcher})$`).test(target) } catch { return true }
140}
141// hook-timer's runs (~/.claude/hooks/hook-timer, which settings.json puts in front of each hook command): `{name, event, use,
142// t0, t1, exit, say}` per run, one file per session; say is the first line of what the hook handed back. The engine times an
143// event's chain whole and merges its hooks' text, so these are what name a wrapped hook, time it and credit its text to it;
144// hooks it does not wrap (a plugin's) are still read from the settings and their text (hookName).
145// keep: the runs that count; reading goes newest first and stops at the first run that ended before `from`
146type Run = { name: string; ms: number; say?: string; exit?: number }
147export const pickRuns = (text: string, keep: (r: any) => boolean, from: number): Run[] => {
148  const out: Run[] = [], lines = text.split('\n')
149  for (let i = lines.length - 1; i >= 0; i--) {
150    let r: any
151    try { r = JSON.parse(lines[i]!) } catch { continue }
152    if (r.t1 < from - SKEW) break
153    if (keep(r)) out.unshift({ name: String(r.name), ms: r.t1 - r.t0, say: r.say ?? undefined, exit: r.exit ?? undefined })
154  }
155  return out
156}
157const SKEW = 100 // ms: the wrapper's clock read sits a process start inside the engine's
158const LATE = 30 * 60_000 // ms: how far back a hook's text can have been said: a PreToolUse hook's goes out with the request after its call
159const runsOf = async ($: any, keep: (r: any) => boolean, from: number) => {
160  try { return pickRuns(await $.fs.read(`${await $.env.get('HOME')}/.claude/hook-times/${await $.session.id()}.jsonl`), keep, from) } catch { return [] } // no file yet
161}
162const timed = (h: { cmd?: string }) => !!h.cmd?.includes('hook-timer')
163// whether hook-timer wraps any hook this event runs: else there is nothing to read
164const wraps = (cfg: HookCfg, event: string, target?: string) => (cfg[event] ?? []).some(h => timed(h) && fits(h.matcher, target))
165// the hook that handed back a text: the wrapped run whose first line it holds
166export const speaker = (runs: Run[], text: string) => [...runs].reverse().find(r => r.say && text.includes(r.say))?.name
167// a classic event's hooks, given its runs: each text (pieces) by its speaker, else by hookName; with nothing said, the wrapped
168// hooks that ran and the unwrapped ones that fit
169export const runNames = (cfg: HookCfg, event: string, target: string | undefined, runs: Run[], pieces: string[]) => {
170  if (pieces.length) return [...new Set(pieces.flatMap(p => (speaker(runs, p) ?? hookName(cfg, event, target, p)).split(', ')))].join(', ')
171  const other = (cfg[event] ?? []).filter(h => !timed(h) && fits(h.matcher, target)).map(h => h.name)
172  return [...new Set([...runs.map(r => r.name), ...other])].join(', ')
173}
174// the hooks that spoke, then the rest that ran in silence (runNames with nothing said)
175export const withSilent = (cfg: HookCfg, event: string, target: string | undefined, runs: Run[], spoke: string) =>
176  [...new Set([...spoke.split(', '), ...runNames(cfg, event, target, runs, []).split(', ')])].filter(Boolean).join(', ')
177// '' = no hook fits: the event ran its chain, but no hook of yours or a plugin's
178export const hookName =(cfg: HookCfg, event: string, target?: string, text?: string): string => {
179  const fit = (cfg[event] ?? []).filter(h => fits(h.matcher, target))
180  const names = (hs: typeof fit) => [...new Set(hs.map(h => h.name))]
181  if (!text?.trim()) return names(fit).join(', ')
182  const has = (s: string) => (n: string) => new RegExp(`(^|[^\\w-])${esc(n)}([^\\w-]|$)`).test(s) // whole name: `guard` must not match `contract-guard`
183  // the first line past the engine's framing (`SessionStart:clear hook success: `), its paths cut to file names: test-gate's
184  // `[…/hooks/guard/test-gate.sh]: test-gate: …` (pre-2026-10 layout) must not name guard. Lines below can quote other hooks (a file list, a summary)
185  const head = first(text.trim().replace(FRAME, '')).replace(/\S*\//g, '')
186  const own = names(fit.filter(h => !h.plugin)).filter(has(head))
187  if (own.length) return own.join(', ')
188  const plugins = names(fit.filter(h => h.plugin))
189  const said = plugins.filter(has(text))
190  if (said.length) return said.join(', ')
191  // `PostToolUse hook modified <file> after your edit`: the engine's own notice, framed as the event's hook
192  const plugin = head.startsWith(`${event} hook `) ? [] : plugins
193  return (plugin.length ? plugin : names(fit).length === 1 ? names(fit) : [`${event} hook`]).join(', ')
194}
195
196// === ROWS ===
197// readable names for the engine's attachment kinds; an unknown kind shows with its underscores turned to spaces
198const LABEL: Record<string, string> = {
199  hook_success: 'output', hook_additional_context: 'context', hook_system_message: 'message', hook_blocking_error: 'block', // the ⚑ icon says hook
200  skill_listing: 'skill list', deferred_tools_delta: 'tool list', deferred_tools_record: 'tool list', agent_listing_delta: 'agent list', mcp_instructions_delta: 'MCP instructions',
201  // claudeMd/instructions carry every CLAUDE.md, rules/*.md, @import and MEMORY.md together, not one file
202  claudeMd: 'memory files', instructions: 'memory files', nested_memory: 'nested CLAUDE.md', queued_command: 'queued prompt', edited_text_file: 'file changed on disk', file: 'mentioned file',
203  total_tokens_reminder: 'token budget', output_style: 'style reminder', output_style_instructions: 'output style', plan_mode: 'plan mode', plan_mode_exit: 'plan mode exit', auto_mode: 'auto mode',
204  todo_reminder: 'todo reminder', date: 'date', environment: 'environment', sandbox_instructions: 'sandbox rules', session_context: 'user context', // not the session-context hook: the engine's email and git status
205  plan_file_reference: 'plan file', invoked_skills: 'earlier skills',
206  currentDate: 'date', userEmail: 'email', attachedProject: 'project', // first-message block keys, camelCase
207}
208const label = (type: string) => LABEL[type] ?? type.replace(/_/g, ' ')
209// per-turn chatter that drowned the log (>half of all entries): ranked debug (lvl), so hidden at the default level
210const DEBUG_ATTACHMENTS = new Set(['total_tokens_reminder', 'output_style', 'silent_turn_reminder'])
211// first-message blocks the engine sends as attachments, not as themselves (read from 2.1.296): email and project inside
212// `session_context` (beside git status, which has no block), the date as `date`. The attachment row carries the size
213const SENT_AS_ATTACHMENT = new Set(['userEmail', 'attachedProject', 'currentDate'])
214const STATUS: Record<string, [string, string?]> = { running: ['●', 'green'], completed: ['✓'], failed: ['✗', 'red'], killed: ['⚠', 'yellow'] } // cell's colours; a killed agent ended early, ⚠ as an interrupt
215const TEAM = 'team' as const // pseudo node: header over main's direct teammate children
216const SLOW = 1000 // ms: a classic hook this slow logs even when it changed nothing
217// slow enough to flag ⚠. SessionStart is exempt: its hooks set the session up once and take seconds every time (context-mode's
218// 1.7-2.9 s), so a ◷ on every session's first rows would teach you to read past the ◷ on a slow ConfigChange
219// one hook of a row of several, as a row of its own for its line's mark and its inspector: its own time, exit and text, the row's
220// outcome only if it said it. A row stored before says existed gives the whole text to its one speaker, else none
221export const hookOf = (x: Entry, n: string): Entry => {
222  const spoke = x.spoke?.includes(n)
223  return { ...x, name: n, ms: x.each?.[n], exit: x.exits?.[n], outcome: spoke ? x.outcome : '', status: x.errs?.includes(n) ? 'failed' : spoke ? x.status : undefined,
224    peek: x.says ? x.says[n] : spoke && x.spoke!.length === 1 ? x.peek : undefined, each: undefined, spoke: undefined, errs: undefined, says: undefined, exits: undefined }
225}
226// a row of several hooks as the log draws it: one row per hook (hookOf). The same objects on every draw, so ids, the inspector and back hold
227const perHook = new WeakMap<Entry, Entry[]>()
228const splitHooks = (x: Entry): Entry[] => {
229  if (x.kind !== 'hook' || !x.name.includes(', ')) return [x]
230  if (!perHook.has(x)) perHook.set(x, x.name.split(', ').map(n => hookOf(x, n)))
231  return perHook.get(x)!
232}
233const slow = (x: Entry, ms = x.ms) => x.kind === 'hook' && ms != null && ms >= SLOW && x.origin !== 'SessionStart'
234const ago = (ms: number) => { const s = Math.floor(ms / 1000); return s < 60 ? `${s}s` : s < 3600 ? `${Math.floor(s / 60)}m${String(s % 60).padStart(2, '0')}s` : `${Math.floor(s / 3600)}h${String(Math.floor(s / 60) % 60).padStart(2, '0')}m` }
235
236let keep = 300
237let entries: Entry[] = []
238let turnNo = 0 // your prompts so far (0: the session's start, before your first): only your own submissions count, not notifications, peers, schedules or plugins
239let pending = 0 // prompts typed while a turn ran: counted when that turn ends, so its last rows keep their number
240// turn -> its prompt's first few words (wordsOf) for the turn header, in memory only: saved() never stores them, and a session
241// switch (load) drops them. queued: the words of the `pending` prompts, in order, numbered once they are counted
242const words = new Map<number, string>()
243let queued: string[] = []
244// a prompt's first line, scrubbed, cut to its first 4 words within 24 characters (`Implement the following…`); a slash
245// command its name alone (`/review`): its arguments are your own typing
246const wordsOf = (e: { text: string; command?: { name: string } }): string => {
247  if (e.command) return `/${e.command.name}`
248  const ws = scrub(first(e.text)).split(/\s+/).filter(Boolean)
249  let s = ''
250  for (const w of ws.slice(0, 4)) { const t = s ? `${s} ${w}` : w; if (t.length > 24) break; s = t }
251  if (!s && ws[0]) s = ws[0].slice(0, 23)
252  return s.length < ws.join(' ').length ? `${s}…` : s
253}
254const OWN = new Set(['composer', 'bridge', 'sdk']) // PromptOrigin kinds that are the person's own prompt
255let key = '' // this session's store key; one per session so concurrent sessions never clobber each other
256let inspect: Entry | undefined // the entry shown in full; set, the pane draws it in place of the log (a second pane cannot take the keys from this one)
257let page: 'setup' | 'keys' | undefined // the setup page or the keys page (what each mark and key means), drawn in place of the log as the inspector is
258let pageBack = false // the inspector opened from the setup page: back returns to the page, not the log
259// The session's setup as the hooks saw it, for the setup button and page, in memory only: the main loop's last model and effort
260// (asked at session.start, then its steps), the permission mode (every classic event carries it), rate limits (session.measure),
261// and from the collector, with OTEL export on: the plugins the engine loaded and the MCP servers it connected
262// (records at startup), and per hook event the settings hooks' runs, the silent ones included (hook_execution_complete). bg: the
263// background tasks and crons still in flight when main's last turn ended (Stop): TaskCreated/Completed rows log their lives
264type Setup = { model?: string; effort?: string | number; mode?: string; limits: { kind: string; percentUsed: number; resetsAt?: string }[]
265  bg?: { tasks: { type: string; status: string; description: string; command?: string }[]; crons: { schedule: string; recurring: boolean; prompt: string }[] }
266  asked?: boolean
267  plugins: Map<string, { version?: string; scope?: string; hooks?: boolean; mcp?: boolean }>; mcp: Map<string, { status: string }>
268  runs: Map<string, { n: number; ms: number; errors: number; cancelled: number }>; runsOf?: string } // runsOf: the session id the runs count for
269const setup: Setup = { limits: [], plugins: new Map(), mcp: new Map(), runs: new Map() }
270// what the API alone answers: asked once at session.start (a reload's included), and by the first draw where none ran
271async function refreshSetup($: any): Promise<void> {
272  setup.asked = true
273  try { setup.model ??= await $.session.model() } catch {}
274  try { await hooksOf($) } catch {}
275}
276let homeDir: string | undefined // HOME, kept once an answer came: the `~` of the places calls reach
277const homeOf = async ($: any): Promise<string | undefined> => homeDir ??= await $.env.get('HOME').catch(() => undefined)
278// a row's id, stable while its entry lives: an index into the log shifts each time a full log evicts its oldest row
279const ids = new WeakMap<Entry, number>()
280let seq = 0
281const idOf = (x: Entry) => ids.get(x) ?? (ids.set(x, ++seq), seq)
282let expanded = new Set<number>() // expanded groups, keyed by the id of their first entry; kept across views, levels and filters
283let backTo: Entry | undefined // the entry whose row opened the inspector: back scrolls to that row rather than to the end
284let showDone = false // finished agents drawn in the tree rather than counted
285// turns whose rows below the level are drawn in place, dim, by their header's `N hidden ▸`; kept across views and levels
286const openTurns = new Set<number>()
287let busy = false // main's turn is running: the header counts its time up
288// The pane scrolls its own rows under a header that never moves: the engine's window stays at the top (ui.scroll) and the log
289// draws rows win..win+avail. winMax is the last render's, for the scroll hook; backKey is the row back centres on
290let win = 0, winMax = 0
291let backKey: string | undefined
292// a row that opened or closed a fold, and its line in the window: the next draw keeps it on that line rather than pinning the end
293let anchor: { k: string; off: number } | undefined
294let follow = true // auto-scroll to the newest entry; off once the person scrolls up, on again at the bottom or via the jump button
295// The row cursor: sel is the key of the row it is on (a line's k), the focus ring on that row's Button so Enter opens it. j, k
296// or an arrow ask a move of `move` rows, which the next draw makes (the rows exist only there); winEnd: the last row the window
297// drew, where a cursor starts. nav: the inspector's previous and next rows, which j and k step to while it is open
298let sel: string | undefined
299let move = 0
300let winEnd = 0
301let nav: { prev?: Entry; next?: Entry } = {}
302let fresh = 0 // entries logged while not following: the jump button's "N new events"; the render zeroes it once following again
303let chain: Promise<void> = Promise.resolve() // all store mutations run one after another, so no write lands a stale snapshot
304let only: string | undefined // agent filter: an agent id, 'main', or undefined for every loop
305// A row reads: disclosure, action, tool (muted), then one right cell (fixed width, so the action truncates first): the time.
306// A flag rides in front of the time in its color, one meaning per glyph so none needs the colour: red ✗ = broke, cyan ⊘ =
307// refused, yellow ⚠ = ended early (interrupted, cut off, an agent killed), ■ = a hook stopped the session (on purpose, but it ends
308// the turn), ◷ = slow, blue ↩ = a hook's feedback, green ● = running (ran, the live lines), magenta ↗ = another project (line). Dim: + = a hook added context, · = it changed nothing, ∅ = found
309// nothing (grep/diff exit 1): routine, no colour. No time (a denied call never ran) -> the status word stands in for it.
310// Derived at draw time so stored entries relabel too. Injected text shows no size for now (`+N tok` was a chars/4 guess)
311const cell = (x: Entry, ms = x.ms): [string, string?] => {
312  const s = stat(x), time = ms == null ? undefined : secs(ms)
313  return REFUSED.has(s) ? [`⊘ ${time ?? s}`, 'cyan'] : s === 'stopped' ? [`■ ${time ?? s}`, 'yellow'] : BAD.has(s) ? [`✗ ${time ?? s}`, 'red'] : s === 'feedback' ? [`↩ ${time ?? s}`, 'blue'] : s === 'aborted' ? [`⚠ ${time ?? s}`, 'yellow'] : s === 'nomatch' ? [`∅ ${time ?? 'no match'}`]
314    : slow(x, ms) ? [`◷ ${time}`, 'yellow'] // over + and ·: slowness is what a slow hook's row has to say
315    : x.kind === 'hook' ? (x.outcome.startsWith('context') ? [`+ ${time ?? '—'}`] : [`· ${time ?? '—'}`])
316    : [time ?? (x.kind === 'tool' ? '—' : '')]
317}
318// a row's time, in seconds so a column reads in one unit: `<0.1s` under 100 ms (never `0.0s`), `18.4s` under a minute, then
319// `ago`'s `2m05s`, `1h05m`. At most 6 characters, so a flag and the time fit the row's cell (END). Unknown is —, never a guess.
320// The minute cut-off rounds first: 59.96 s reads `1m00s`, not `60.0s`
321const secs = (ms?: number) => ms == null ? '—' : ms < 100 ? '<0.1s' : ms < 59_950 ? `${(ms / 1000).toFixed(1)}s` : ago(Math.max(ms, 60_000))
322// the inspector's and the setup page's time, where one value is read on its own: milliseconds under a second (`40ms`), else secs
323const exact = (ms?: number) => ms != null && ms < 999.5 ? `${Math.round(ms)}ms` : secs(ms)
324const cap = (s: string) => s.charAt(0).toUpperCase() + s.slice(1)
325// `claude-opus-5-5[1m]` -> `Opus 5.5`, `claude-opus-4-20250514` -> `Opus 4`, `claude-3-5-sonnet-20241022` -> `Sonnet 3.5`;
326// an id of no known shape (a gateway's alias) as named. (?!\d): a date suffix is no minor version
327export const modelName = (id: string) => {
328  const m = id.match(/claude-([a-z]+)-(\d+)(?:-(\d{1,2}))?(?!\d)/)
329  if (m) return `${cap(m[1]!)} ${m[2]}${m[3] ? `.${m[3]}` : ''}`
330  const o = id.match(/claude-(\d+)(?:-(\d))?-([a-z]+)/)
331  return o ? `${cap(o[3]!)} ${o[1]}${o[2] ? `.${o[2]}` : ''}` : id
332}
333// a step's result: cut off at the output limit reads ⚠ like an interrupt; a refusal ⊘; a full context window or no response at all, ✗
334const stepStatus = (stop: string | null | undefined): Status => stop === 'max_tokens' ? 'aborted' : stop === 'refusal' ? 'denied' : stop === 'model_context_window_exceeded' || stop == null ? 'failed' : 'ok'
335const STOPPED: Record<string, string> = { max_tokens: 'Cut off at the output limit', refusal: 'Refused', model_context_window_exceeded: 'Context window full' }
336// the engine's own requests by query source; the frequent ones rank debug: a prompt suggestion after most turns, a running agent's progress summary
337const SIDE: Record<string, string> = { prompt_suggestion: 'prompt suggestion', agent_summary: 'agent progress summary', generate_session_title: 'session title', web_search_tool: 'web search', side_question: 'side question', compact: 'compaction' }
338const CHATTER = new Set(['prompt_suggestion', 'agent_summary', 'generate_session_title'])
339// an `ask` settled, by the collector's decision source
340const VIA: Record<string, string> = { user_temporary: 'you allowed it once', user_permanent: 'you allowed it from now on', user_reject: 'you rejected it', user_abort: 'you interrupted it', hook: 'a PermissionRequest hook decided', config: 'your settings decided', classifier: 'the auto-mode classifier decided' }
341// auto mode's classifier refusing a call: the error result the model reads instead of the call's output
342const CLASSIFIED = /^Permission for this action was denied by the Claude Code auto mode classifier/
343// the error results a refused call returns: a denied permission prompt (or one no one could answer, headless), you rejecting it,
344// the classifier, the guard on the session's working directories. Read from the text too: without OTEL export no decision record
345// comes, and a refusal's record can come late
346const DENIED = /^(Permission to use \S+ has been denied|The user doesn't want to proceed with this tool use|Permission for this action was denied|\S+ in '[^']*' was blocked\. For security)/
347// why it was refused: the first sentence (past it, advice to the model); the guard's, with the rule it applied but not the directories
348const deniedWhy = (s: string) => /was blocked\. For security/.test(s) ? s.replace(/:\s*'[\s\S]*$/, '') : s.split(/(?<=\.)\s/)[0]!
349const classifierWhy = (s: string) => { const m = /Reason: (?:\[([^\]\n]+)\]|([^.\n]+))/.exec(s); return `the auto-mode classifier refused it${m ? `: ${(m[1] ?? m[2])!.trim()}` : ''}` }
350// what an event row means, for its inspector
351const EVENT_WHAT: Record<string, string> = {
352  'model switch': 'The session\'s model changed. Unless the prompt cache was warm, the next request writes it again', 'turn failed': 'The API ended the turn with an error',
353  compacted: 'The conversation was summarized to free context; the time is from PreCompact to PostCompact', 'settings changed': 'A settings file changed while the session ran',
354  'directory added': 'A directory joined the session\'s working directories', 'worktree created': 'A git worktree was created', 'worktree removed': 'A git worktree was removed',
355  'MCP question': 'An MCP server asked you for input', 'MCP answer': 'Your answer to an MCP server\'s question', command: 'A slash command expanded into the prompt',
356  'task created': 'A task joined the team\'s list', 'task completed': 'A task on the team\'s list was completed', 'teammate idle': 'A teammate is waiting for work',
357  notification: 'Claude Code notified you', 'file changed': 'A file a hook watches changed', setup: 'Claude Code ran its setup', mode: 'The permission mode changed',
358  'MCP connected': 'An MCP server connected; the time is how long it took', 'MCP failed': 'An MCP server failed to connect', 'MCP disconnected': 'An MCP server closed its connection: at the session\'s end, or a server that stopped', 'API error': 'A model request failed; the engine may have retried it',
359}
360const QUIET_EVENTS = new Set(['notification']) // Claude Code telling you it waits: the call's wait line and the turn's end already say so
361// The collector's records that report what no hook sees: a permission mode change, an MCP server connecting or failing, a failed
362// API request, the engine's own model requests outside the turns. A record of main's or a subagent's request belongs to its step
363// (turn.step) and is not repeated; `sdk` is a headless session's main loop
364export const harnessOf = (event: string, a: Record<string, any>): Omit<Entry, 't'> | undefined => {
365  if (event === 'permission_mode_changed') return { kind: 'event', name: 'mode', detail: `${a.from_mode} → ${a.to_mode}`, outcome: String(a.trigger ?? '') }
366  if (event === 'mcp_server_connection') {
367    // `disconnected`: a server closing, at the session's end or mid-session; its duration is how long it was connected, not a wait
368    if (a.status === 'disconnected') return { kind: 'event', name: 'MCP disconnected', detail: String(a.server_name ?? ''), outcome: String(a.transport_type ?? ''), status: 'ok', peek: Number(a.duration_ms) ? `connected for ${exact(Number(a.duration_ms))}` : undefined }
369    const ok = a.status === 'connected'
370    return { kind: 'event', name: ok ? 'MCP connected' : 'MCP failed', detail: String(a.server_name ?? ''), outcome: String(a.transport_type ?? ''), status: ok ? 'ok' : 'failed', ms: Number(a.duration_ms) || undefined, peek: ok ? undefined : peek(String(a.error ?? a.status ?? '')) }
371  }
372  if (event === 'api_error') return { kind: 'event', name: 'API error', detail: [a.status_code, a.error].filter(v => v != null && v !== '').join(' '), outcome: a.attempt ? `attempt ${a.attempt}` : '', status: 'failed', ms: Number(a.duration_ms) || undefined, peek: peek(String(a.error ?? '')) }
373  const q = String(a.query_source ?? '').replace(/:.*/, '')
374  if (event === 'api_request' && q && q !== 'repl_main_thread' && q !== 'agent' && q !== 'sdk') return { kind: 'side', name: q, src: 'engine', detail: String(a.model ?? ''), outcome: '', status: 'ok', ms: Number(a.duration_ms) || undefined,
375    tok: [a.input_tokens, a.cache_read_tokens, a.cache_creation_tokens, a.output_tokens].map(n => Number(n) || 0), cost: Number(a.cost_usd) || undefined, req: typeof a.request_id === 'string' ? a.request_id : undefined }
376  return undefined
377}
378const counted = (ns: string[]) => [...new Set(ns)].map(n => { const k = ns.filter(m => m === n).length; return k > 1 ? `${n} ×${k}` : n }).join(', ') // `Read ×3, Bash`
379// `9.1k in (9.0k cached, 0.1k written) · 50 out`: in counts every input token, as costOf does
380const tokLine = ([i = 0, r = 0, w = 0, o = 0]: number[]) => `${num(i + r + w)} in (${num(r)} cached, ${num(w)} written) · ${num(o)} out`
381// items of these widths, two cells apart, into rows of at most `room`, in order: each row the items' indexes. One too wide for
382// any row gets a row of its own
383export const pack = (ws: number[], room: number): number[][] => {
384  const out: number[][] = []
385  let used = 0
386  ws.forEach((n, i) => { if (out.length && used + 2 + n <= room) { out[out.length - 1]!.push(i); used += 2 + n } else { out.push([i]); used = n } })
387  return out
388}
389const base = (p: string) => p.replace(/\/+$/, '').split('/').pop() || p
390// a path made absolute against cwd, ~ and $HOME expanded, . and .. folded; undefined where another variable, or ~ and no home, is left
391const absOf = (p: string, cwd: string, home?: string): string | undefined => {
392  let s = p.replace(/^(["'])(.*)\1$/, '$2')
393  const h = /^(~|\$HOME|\$\{HOME\})(?=\/|$)/.exec(s)
394  if (h) { if (!home) return undefined; s = home + s.slice(h[0].length) }
395  if (s.includes('$')) return undefined
396  const parts: string[] = []
397  for (const x of (s.startsWith('/') ? s : `${cwd}/${s}`).split('/')) if (x === '..') parts.pop(); else if (x && x !== '.') parts.push(x)
398  return '/' + parts.join('/')
399}
400const under = (p: string, dir: string) => { const d = dir.replace(/\/+$/, ''); return p === d || p.startsWith(d + '/') }
401const tilde = (p: string, home?: string) => home && under(p, home) ? '~' + p.slice(home.replace(/\/+$/, '').length) : p
402const CD = /^\s*cd\s+("[^"]*"|'[^']*'|\S+)\s*(?:&&|;|$)/ // a Bash command's leading `cd <dir>`, alone or before `&&` / `;`
403const PATHS = /(?:^|[\s"'=(])((?:~|\$HOME|\$\{HOME\})?\/[^\s"'|;&<>()`]+)/g // the absolute and home paths a command names
404// Where a call reached outside the session's project root, `~`-shortened: a file tool's folder (Grep's and Glob's path as given,
405// else the loop's cwd); for Bash its leading `cd`'s folder, else the cwd, else the first path its command names under your home.
406// From a command's words only paths under home count: /tmp, /usr and /dev are scratch and system files, not work elsewhere, and
407// a word need not be a path. undefined: inside the root, or not known (no path, a variable left in it)
408// A place outside the root that is routine, not another project: Claude Code's own ~/.claude (memory, plans, settings) and
409// scratch space (/tmp, /var/tmp, macOS /private/tmp). It draws dim with no ↗; any other place draws ↗ magenta and counts in the controls
410export const routine = (p: string) => /^~\/\.claude(\/|$)/.test(p) || /^\/(private\/)?(var\/)?tmp(\/|$)/.test(p)
411export const outsideOf = (tool: string, a: { path?: string; cmd?: string }, root: string, cwd: string, home?: string): string | undefined => {
412  const away = (p?: string) => p && !under(p, root) ? tilde(p, home) : undefined
413  if (/^(Read|Edit|Write|NotebookEdit)$/.test(tool)) { const p = a.path ? absOf(a.path, cwd, home) : undefined; return away(p && (p.replace(/\/[^/]*$/, '') || '/')) }
414  if (/^(Grep|Glob)$/.test(tool)) return away(a.path ? absOf(a.path, cwd, home) : cwd)
415  if (tool !== 'Bash' || !a.cmd) return undefined
416  const cd = CD.exec(a.cmd)?.[1], at = cd ? absOf(cd, cwd, home) : cwd
417  if (!at) return undefined
418  const there = away(at)
419  if (there || !home) return there
420  for (const m of a.cmd.matchAll(PATHS)) { const p = absOf(m[1]!, at, home); if (p && under(p, home) && !under(p, root)) return tilde(p, home) }
421  return undefined
422}
423// a cwd change or another mid-session edit of the environment block; the first message's block reads `# Environment`
424const envUpdate = (x: Entry) => x.type === 'environment' && (x.peek ?? '').startsWith('# Environment update')
425// engine notes about one file, named by it: the kind, and where its first line holds the path
426const ABOUT: Record<string, [string, RegExp]> = {
427  edited_text_file: ['changed', /^Note: (.+?) changed on disk/], compact_file_reference: ['not restored', /^Note: (.+?) was read before/],
428  file: ['mentioned', /"file_path":"([^"]+)"/], nested_memory: ['memory', /^Contents of (.+?):?$/],
429}
430// Engine context named at draw time from its type and stored text, so rows logged under an older name read the current one.
431// kind: what tallies count by (`changed ×3`); tail: what this row is about, with its own glue (` → agent-log`, `: x.md`).
432// A hook's or plugin's row keeps its stored name: the hook's, which its attachment kind (`output`) would hide
433export const ctxOf = (x: Entry): [string, string?] => {
434  if (x.kind !== 'prompt' || (x.src && x.src !== 'engine')) return [x.name]
435  const p = x.peek ?? '', l = first(p), t = x.type ?? ''
436  const about = ABOUT[t] && l.match(ABOUT[t][1])?.[1]
437  if (about) return [ABOUT[t]![0], `: ${t === 'nested_memory' ? about.split('/').slice(-2).join('/') : base(about)}`] // a CLAUDE.md is told apart by its folder
438  if (envUpdate(x)) { const d = p.match(/Primary working directory: (.+?)(?: \(was .*)?$/m)?.[1]; return d ? ['CWD', ` → ${base(d)}`] : ['env change'] } // no `)` needed: the PEEK cap can cut a long `(was …`
439  if (t === 'date' && l.startsWith('The date has changed')) return ['date change'] // else the date at session start
440  // a delta's first one is the full list; later ones add or remove
441  const gone = /no longer available|have disconnected/.test(l)
442  if (t === 'deferred_tools_delta' && gone) return ['tools removed']
443  if (t === 'agent_listing_delta') return [gone ? 'agents removed' : l.startsWith('New ') ? 'agents added' : 'agent list']
444  if (t === 'mcp_instructions_delta' && gone) return ['MCP disconnected']
445  return [Object.hasOwn(LABEL, t) ? LABEL[t]! : x.name] // no label: as logged, e.g. `skill code-review`
446}
447// a request's reply, its first line; '' when it wrote none. One that threw keeps its error in peek: no reply
448const replyOf = (x: Entry) => x.kind === 'step' && !(stat(x) === 'failed' && !x.stop) ? first(x.peek) : ''
449// what happened, action first in sentence case, its verb saying what the tool did; the inspector names the tool
450// bare: a hook's or its text's name alone, for its row on its call's bracket, where the event and tool are plain
451const action = (x: Entry, bare = false): string => {
452  const d = x.detail
453  if (x.kind === 'tool') switch (x.name) {
454    case 'Bash': { // noCd: entries stored before the strip hold the raw command
455      const s = noCd(d).replace(/^bash\b[:\s-]*/i, '')
456      // no description: the detail is the command itself, an identifier like a hook's name, so `wc -l` never reads `Wc -l`
457      return (x.cmd && noCd(x.cmd).replace(/\s+/g, ' ').startsWith(s) ? s : cap(s)) || 'Run a command'
458    }
459    case 'Read': case 'Edit': case 'Write': case 'NotebookEdit': return `${x.name} ${base(d)}`
460    // by the pattern where the row holds it; rows stored before it read their folder alone
461    case 'Grep': return x.pat ? `Search "${x.pat}"${d ? ` in ${base(d)}` : ''}` : `Search ${base(d) || 'files'}`
462    case 'Glob': return x.pat ? `Find ${x.pat}${d ? ` in ${base(d)}` : ''}` : `Find files in ${base(d) || '.'}`
463    case 'Skill': return `Run skill ${d}`
464    case 'WebFetch': return `Fetch ${d.replace(/^https?:\/\//, '')}`
465    case 'WebSearch': return `Search the web for ${d}`
466    case 'ToolSearch': return `Load tools: ${d}`
467    case 'SubagentHandback': return 'Hand back' // a subagent delivering its report to its parent
468    case 'Agent': return d || x.name // `type: task`: the type is an identifier, as named
469    default: return (x.name.startsWith('mcp__') ? d : cap(d)) || x.name // mcp `tool: arg` stays as named, an identifier
470  }
471  // A hook's row and its text's row read by name and event, never by what it said: that text stays in the inspector
472  if (x.kind === 'hook') { // one hook by name, `guard on PreToolUse Bash`; a row of several draws as one row per hook (splitHooks). name = origin: a row stored before no-hook events were skipped
473    const n = x.name.split(', ').length
474    const who = x.name === x.origin ? 'No hook' : n > 1 ? `${n} hooks` : x.name
475    return bare ? who : `${who} on ${x.origin ?? 'event'}${d ? ` ${d}` : ''}`
476  }
477  if (x.kind === 'prompt') {
478    if (x.src && x.src !== 'engine') return `${x.name} ${label(x.type ?? '')}${!bare && x.origin && !x.name.includes(x.origin) ? ` on ${x.origin}` : ''}` // `context-mode context on PreToolUse`, `PreToolUse hook context`; a hook or plugin name is an identifier: never recased
479    const [k, tail = ''] = ctxOf(x)
480    return cap(k) + tail
481  }
482  if (x.kind === 'session') return x.name === 'reload' ? 'Plugin reloaded' : `Session init${d ? ` in ${base(d)}` : ''}` // `init`: `session start` is the engine context it sent
483  if (x.kind === 'fork') return `Fork by ${x.name}: ${first(x.peek) || stat(x)}`
484  // a request reads by what it did: the first line of its reply, else of its thinking, else the calls it made, else why it
485  // stopped (replyOf). The rest of what it said or thought is the inspector's
486  if (x.kind === 'step') {
487    const said = replyOf(x)
488    return (said ? `Reply: ${said}` : '') || (x.thought ? `Thought: ${x.thought}` : '') || (x.asked?.length ? `Called ${counted(x.asked)}` : '') || STOPPED[x.stop ?? ''] || (stat(x) === 'aborted' ? 'Interrupted' : 'No response')
489  }
490  if (x.kind === 'event') return x.name === 'model switch' ? `Model: ${x.detail.split(' → ').map(modelName).join(' → ')}${x.outcome ? ` (${x.outcome})` : ''}`
491    : `${cap(x.name)}${x.detail ? `: ${x.detail}` : ''}${x.outcome ? ` (${x.outcome})` : ''}`
492  if (x.kind === 'side') return x.src === 'plugin' ? (first(x.peek) || stat(x) === 'ok' ? x.name : `${x.name}: ${stat(x)}`) /* its reply in the inspector */ : cap(SIDE[x.name] ?? x.name.replace(/_/g, ' '))
493  return 'Turn ended'
494}
495// What kind of effect a row had, not which tool: the action already names the tool (Read x, ExitWorktree, Search y), and the
496// inspector keeps the real name. Unknown tools read `Tool`. Engine-injected text reads `Context`: where the text went
497const KIND: [RegExp, string][] = [
498  [/^(Bash|PowerShell)$/, 'Shell'], [/^(Read|Grep|Glob|LS|LSP|NotebookRead)$/, 'Look'], [/^(Edit|Write|MultiEdit|NotebookEdit)$/, 'Change'],
499  [/^Web/, 'Web'], [/^(Agent|SendMessage|ListAgents|SubagentHandback|Task\w*)$/, 'Agent'], [/^Skill$/, 'Skill'], [/^mcp__/, 'MCP'],
500  [/^(EnterWorktree|ExitWorktree|ToolSearch|TodoWrite|EnterPlanMode|ExitPlanMode|AskUserQuestion|SendUserMessage|Monitor|Cron\w*|ScheduleWakeup|RemoteTrigger|ReadNotifications|PushNotification)$/, 'Control'],
501]
502const toolOf = (x: Entry) => x.kind === 'tool' ? KIND.find(([re]) => re.test(x.name))?.[1] ?? 'Tool' : x.kind === 'prompt' ? (x.src === 'engine' || !x.src ? 'Engine' : cap(x.src)) : x.kind === 'step' ? modelName(x.name) : x.kind === 'side' ? modelName(x.detail) : x.kind === 'event' ? (x.name.startsWith('MCP') ? 'MCP' : x.name === 'model switch' ? 'Model' : x.name === 'mode' ? 'Mode' : 'Engine') : cap(x.kind)
503// Engine context is out of the log for now, its view hidden: it is still logged (the replay counts need it, and the view can come
504// back), but only rows that report something happening show. A cwd change, a file changed on disk or not restored, a nested
505// CLAUDE.md, a date change, IDE diagnostics, a list losing tools, agents or an MCP server, or gaining agents
506const EVENT_CTX = new Set(['CWD', 'env change', 'changed', 'not restored', 'memory', 'date change', 'diagnostics', 'tools removed', 'agents added', 'agents removed', 'MCP disconnected'])
507const hiddenCtx = (x: Entry) => x.kind === 'prompt' && (!x.src || x.src === 'engine') && !EVENT_CTX.has(ctxOf(x)[0])
508// preset views: one predicate over stored entries, applied alongside the agent filter and the level
509const VIEWS = {
510  all: (x: Entry) => !hiddenCtx(x),
511  model: (x: Entry) => x.kind === 'tool' || x.kind === 'turn' || x.kind === 'fork' || x.kind === 'step', // what the model did: its requests, calls and turns, and what it answered a plugin's fork
512  harness: (x: Entry) => x.kind === 'hook' || x.kind === 'fork' || x.kind === 'event' || x.kind === 'side' || (x.kind === 'prompt' && !hiddenCtx(x)) || (x.kind === 'tool' && (x.rule != null || x.check === 'deny')), // what your setup and the engine did: hooks, the text they injected, plugins' forks and model calls, rule decisions, the engine's own requests and what it reported happening
513  context: (x: Entry) => x.bytes != null && x.kind !== 'hook', // hidden for now: no button, no command. Prompt blocks and attachments, everything with an injected size
514  // where the session went beyond its project root: the calls that reached outside it, the directories and worktrees it gained or lost, its cwd changes
515  outside: (x: Entry) => !!x.out || (x.kind === 'event' && /^(directory added|worktree created|worktree removed)$/.test(x.name)) || (x.kind === 'prompt' && ctxOf(x)[0] === 'CWD'),
516} satisfies Record<string, (x: Entry) => boolean>
517type View = keyof typeof VIEWS
518const SHOWN: View[] = ['all', 'model', 'harness', 'outside'] // the views the controls and /agentlog offer
519let view: View = 'all'
520// How much a row matters, derived at draw time like stat so stored entries rank too and the rules can change. The pane shows
521// rows at `level` or above. error: refused or broke. notice: changed or decided something (shell, edits, agents, web, a rule's
522// verdict, hook text, a slow hook, an aborted turn, a cwd change). info: routine (lookups, control tools, engine context). debug: chatter (SendUserMessage, ToolSearch's schema loads)
523const LEVELS = ['debug', 'info', 'notice', 'error'] as const
524let level = 1 // info: what the log showed before levels existed
525const lvl = (x: Entry): number => {
526  if (BAD.has(stat(x))) return 3
527  if (x.kind === 'tool') return /^(SendUserMessage|ToolSearch)$/.test(x.name) ? 0 : x.rule != null || /^(Shell|Change|Agent|Web|MCP|Skill|Tool)$/.test(toolOf(x)) ? 2 : 1
528  if (x.kind === 'hook') return stat(x) === 'feedback' || slow(x) ? 2 : 0 // a fast hook that changed nothing, or added context its attachment row already carries
529  if (x.kind === 'prompt') return DEBUG_ATTACHMENTS.has(x.type ?? '') ? 0 : !x.again && (envUpdate(x) || x.src === 'hook' || x.src === 'plugin') ? 2 : 1
530  if (x.kind === 'session') return x.name === 'reload' ? 0 : 2
531  // a request that wrote no text ranks debug: the calls it made follow as rows of their own, and its time stays in the turn header's split and in its calls' inspector
532  if (x.kind === 'step') return stat(x) === 'aborted' ? 2 : first(x.peek) ? 1 : 0
533  if (x.kind === 'event') return QUIET_EVENTS.has(x.name) || x.name === 'MCP connected' ? 0 : /^(MCP disconnected|file changed|command|task |teammate|setup)/.test(x.name) ? 1 : 2
534  if (x.kind === 'side') return x.src === 'plugin' || !CHATTER.has(x.name) ? (x.name === 'compact' ? 2 : 1) : 0
535  return stat(x) === 'aborted' ? 2 : 1
536}
537// a row that did something, drawn bright when its cell has no colour: a call at notice or above (shell, edits, agents, web, a
538// rule's verdict), a request that replied, a plugin's fork. The rest (lookups, hook runs, context, engine events) draws dim
539const bright = (x: Entry) => (x.kind === 'tool' && lvl(x) >= 2) || !!replyOf(x) || x.kind === 'fork'
540
541// === STATE AND STORE ===
542const started = new Map<string, number>() // agent id -> spawn time; AgentInfo has no timestamp. Teammates raise no agent.spawn, so they have none
543// agent id -> the name a row shows when $.agent.list() does not hold the loop: a spawned agent's type once the engine drops it, a
544// fork's plugin. Stored with the entries, so old turns keep their names. A fork has no agent.spawn and its SubagentStop's agent_type
545// is '' (probed on 2.1.294), so it is named `fork` there and by its plugin once that plugin's $.model.fork resolves (the model.fork hook)
546let names: Record<string, string> = {}
547// PostToolUse context to come, per call, in the order its hooks ran: its attachment names only the tool (`PostToolUse:Read hook …`),
548// and parallel calls of one tool run their hooks in turn, so the first waiting call of that tool is the one it belongs to.
549// A copy or a replay of an attachment takes no place: its call was served when it first came
550let postCtx: { use: string; tool: string; agent?: string; n: number }[] = []
551const takePost = (tool: string | undefined, agent: string | undefined): string | undefined => {
552  const w = postCtx.find(c => c.tool === tool && c.agent === agent)
553  if (w && !--w.n) postCtx = postCtx.filter(c => c !== w)
554  return w?.use
555}
556let stops: { id: string; t: number; text?: string }[] = [] // SubagentStops of loops no agent.spawn started, not yet tied to a fork
557// agent.spawn's Agent call -> the agent it started, until that call's tool.call takes it. Capped: a plugin's own $.agent.spawn
558// raises agent.spawn but no Agent tool.call, so its pair is never taken
559let spawned: { use: string; id: string }[] = []
560// the Agent row a loop's rows fold under: the top-most, so a grandchild's rows fold with its parent's. None for main, a plugin's
561// fork or spawn, and loops logged before rows stored their child. Depth-capped: a cycle in a damaged store must not hang the draw
562export const rootOf = (xs: Entry[]) => {
563  const spawn = new Map<string, Entry>()
564  for (const x of xs) if (x.child) spawn.set(x.child, x)
565  return (x: Entry): Entry | undefined => {
566    let top: Entry | undefined, a = x.agent
567    for (let n = 0; a && n < 8; n++) { const s = spawn.get(a); if (!s) break; top = s; a = s.agent }
568    return top
569  }
570}
571// Where a turn's time went, each instant counted once, by what held it, in this order where they overlap (a read-only call runs
572// while the model still writes; a call's hooks run inside the call): you answering a permission prompt, a permission decision
573// the engine took its time over (a headless `ask` sat 19 s before its `config` decision), hooks, a compaction, agents, other
574// tools, the model.
575// A wait on the model's message is the model's. Over main's rows alone: a foreground subagent's time sits inside its Agent call.
576// [from, to] is the turn; what none of them held is the engine's and plugins' own work between steps: other = to - from - the sum
577export const SPLIT = ['you', 'permission', 'hooks', 'compact', 'agents', 'tools', 'model'] as const
578export const ASKED = 'you to answer the permission prompt' // a wait's waitFor when the person decided it
579export const STREAMING = 'the model to finish its message' // a wait's waitFor when the call was decided as main's message ended
580export const splitOf = (xs: Entry[], from: number, to: number): number[] => {
581  const pts: [number, number, number][] = []
582  const span = (a: number, b: number, c: number) => { const p = Math.max(a, from), q = Math.min(b, to); if (q > p) pts.push([p, 1, c], [q, -1, c]) }
583  for (const x of xs) {
584    if (x.agent) continue
585    if (x.kind === 'tool' && x.wait && x.waitFor !== STREAMING) span(x.t, x.t + x.wait, x.waitFor === ASKED ? 0 : 1) // a call's t is its start, its wait before its run; a refused call has a wait and no run
586    if (x.ms == null) continue
587    if (x.kind === 'step') span(x.t, x.t + x.ms, 6)
588    else if (x.kind === 'tool') span(x.t + (x.wait ?? 0), x.t + (x.wait ?? 0) + x.ms, x.name === 'Agent' ? 4 : 5)
589    else if (x.kind === 'hook') span(x.t - x.ms, x.t, 2) // a hook row's t is its end
590    else if (x.kind === 'event' && x.name === 'compacted') span(x.t, x.t + x.ms, 3) // pushed at its start
591  }
592  pts.sort((p, q) => p[0] - q[0])
593  const on = SPLIT.map(() => 0), out = SPLIT.map(() => 0)
594  let at = from
595  for (const [t, d, c] of pts) { const top = on.findIndex(n => n > 0); if (top >= 0) out[top]! += t - at; at = t; on[c]! += d }
596  return out
597}
598// The band's note on a turn that ended unusually, or none: `#4 took 41.2s, 3× the usual` against the median of main's earlier
599// turns (3 or more of them), else `#4: hooks 12.0s of 20.0s` when hooks or a slow permission decision held the turn longest.
600// Turns under 10 s never note: a short turn is not worth a look. Waiting on you is left out: that is your time, not a fault
601export const unusualOf = (n: number, split: number[], ms: number, prior: number[]): string | undefined => {
602  if (ms < 10_000) return undefined
603  const ps = prior.filter(p => p > 0).sort((a, b) => a - b), mid = ps.length >= 3 ? ps[ps.length >> 1]! : undefined
604  if (mid && ms > 2 * mid) return `#${n} took ${secs(ms)}, ${Math.round(ms / mid)}× the usual`
605  const top = split.reduce((m, v, i) => v > split[m]! ? i : m, 0), other = ms - split.reduce((a, b) => a + b, 0)
606  const who = SPLIT[top]!
607  if ((who === 'hooks' || who === 'permission') && split[top]! > other && split[top]! >= 3000) return `#${n}: ${who} ${secs(split[top])} of ${secs(ms)}`
608  return undefined
609}
610// a spawned agent's name: the second and later of a type read `type #2`, `#3`, so two general-purpose agents tell apart in row
611// prefixes, the tree and the filter. counted: the loops already numbered, so not this one or a parallel spawn whose SubagentStart
612// named it before its agent.spawn resumed. One past the highest, not the count: saved() drops the names of loops evicted from the
613// log, and `#2` may outlive the bare first
614export const tagOf = (names: Record<string, string>, type: string, counted: (id: string) => boolean = () => true) => {
615  const ns = Object.entries(names).filter(([id, v]) => counted(id) && (v === type || v.startsWith(`${type} #`))).map(([, v]) => v === type ? 1 : Number(v.slice(type.length + 2)) || 1)
616  return ns.length ? `${type} #${Math.max(...ns) + 1}` : type
617}
618// a loop an Agent row started: in memory since its spawn, or in the log once a reload emptied started
619const linked = (id: string) => started.has(id) || entries.some(x => x.child === id)
620let memBytes: number | undefined // size of the claudeMd block just logged; the engine repeats it once as an `instructions` attachment
621let lastSig = '' // poll's change detector
622const checks = new Map<string, { check: Entry['check']; rule?: string; reason?: string }>() // tool_use_id -> tool.check verdict, taken by the tool.call that raised it
623const blockedUse = new Set<string>() // tool_use_ids a PreToolUse hook blocked: the call reads `isError`, not `deny`, so tool.call learns it here
624// tool_use_id -> when tool.check returned its verdict, and when the engine's collector record `tool_decision` settled it. The gap
625// is not the tool's: a call that starts while the model still streams its message is decided once that message ends (probed on
626// 2.1.296: an `ask` settled `source: config` 1.1 s after the check, its `tool_decision` 6 ms before main's `api_request` record,
627// with no classifier request). Only with OTEL export on does the engine raise collector records; without them no wait is logged
628const checkedAt = new Map<string, number>()
629const decided = new Map<string, { t: number; source?: string; decision?: string }>()
630let streamEnd = 0 // when main's last model request closed (collector `api_request`)
631const WAIT = 100 // ms: a shorter gap is the engine's own step from check to decision (1-2 ms when nothing streams), not a wait
632const preCtx = new Map<string, number>() // tool_use_id -> context entries its PreToolUse hooks returned, taken by the tool.call that raised them
633// tool_use_id -> the loop making the call, while it runs: classic.PreToolUse's envelope is the bare call (ToolCallEnvelope), which
634// names no loop, so its hook rows take the loop from here
635const loopOf = new Map<string, string | undefined>()
636// tool_use_id -> the step that asked for the call: its `tool` chunk streams before the engine runs the call, whose row takes it
637const stepOf = new Map<string, string>()
638// The collector's `api_request` records not yet paired with a step, and steps not yet paired with one: whichever comes second
639// pairs them by the four token counts, which the record repeats exactly. Either may land first. Records of the engine's side
640// requests (titles, prompt suggestions) pair with nothing and age out
641let bills: { t: number; sig: string; cost?: number; req?: string }[] = []
642let unbilled: { t: number; sig: string; key: string }[] = []
643// what streams or runs right now, for the pane's ● lines: a loop's model request (its phase, and the first line it wrote or thought
644// so far) or a tool call. Gone at its end, when its row is logged. In memory only: a reload starts it empty
645// asking: a call whose permission prompt is up (PermissionRequest), until its decision lands (the collector's tool_decision)
646// wrote: the request has streamed text, so its step row will rank info (lvl): until then it may end as a hidden debug row
647type Live = { t: number; loop?: string; model?: string; phase?: 'waiting' | 'thinking' | 'writing' | 'calling'; said?: string; tool?: string; row?: Omit<Entry, 't'>; asking?: boolean; wrote?: boolean }
648const liveNow = new Map<string, Live>()
649const compactAt = new Map<string, number>() // loop -> when its PreCompact fired: PostCompact's row takes the time
650// the collector's last hook_execution_complete: the settings hooks a classic event just ran, how many failed and how many the engine
651// cancelled (a timeout). It lands inside that event's next, so classic.* reads it right after
652// name: the collector's hook_name, `<event>:<target>` (`PostToolUse:Edit`). The first row that fits it takes it (classic.*), so one
653// record marks one row; two parallel calls of one tool both fit, and which takes it is a guess: the record names no call
654let lastRun: { t: number; event: string; name: string; errors: number; cancelled: number } | undefined
655let turnAt: number | undefined // when main's running turn began: your prompt's submit
656// a tool row -> PreToolUse context attachments still to come for it. The engine sends a PreToolUse hook's context with the request
657// after the call, so its attachment arrives once the call's row is logged; push files it just above that row, in call order
658const ahead = new WeakMap<Entry, number>()
659const composed = new Set<string>() // system prompt shapes logged this session; a set, since main and subagents render different prompts in turn. A hot reload empties it, so each logs once more then
660let timer: Timer | undefined // lives while the pane is open
661async function tick($: any): Promise<void> {
662  try {
663    if (!(await $.ui.panes()).some((p: any) => p.id === PANE)) { timer?.cancel(); timer = undefined; return } // closed by Esc / ctrl+x x, which bypasses command.run
664    const agents: AgentInfo[] = await $.agent.list()
665    const sig = agents.map(a => a.id + a.status).join()
666    // redraw on any status change (incl. kills and failures, which raise no turn.complete), and while something runs so its elapsed cell counts up
667    // started empties on a hot reload; an Agent row's child still counts up on its row
668    if (sig !== lastSig || busy || liveNow.size || agents.some(a => a.status === 'running' && linked(a.id))) { lastSig = sig; $.ui.invalidate('ui.render') }
669  } catch {}
670}
671
672// ponytail: 2s poll while pane open, replace if an agent-status event lands in the API
673const startPoll = ($: any) => { timer ??= $.clock.every(2000, () => { tick($) }); note = undefined } // the pane is open: the band's note is read
674// the band's line above the prompt while the pane is closed: the last turn's unusualOf, until the next turn ends or the pane opens
675let note: string | undefined
676
677// The engine caches prompt.attachment and prompt.context answers, and when the cache drops it asks again for every one
678// in the history, in order, on a loop's next request: for a fresh instance of this plugin (a resume, a hot reload of
679// it), and for a running one whenever the plugin chain changes (another plugin loaded, edited or removed: seen dumping
680// a session's whole history as new rows, its SessionStart text and every prompt's hook text again) or a plugin calls
681// $.ui.invalidate on either event. No per-ask replay flag exists, so `seen` counts how often each signature entered the
682// context this session, stored beside the entries. A fresh instance owes those counts back: until a loop's first tool
683// call or turn end, an ask that matches an owed copy is a replay and is skipped; one past the count is new and is logged.
684// A running instance knows a replay by its lead: per loop and event, the first ask of a kind a context carries once (`once`),
685// which comes back only when the history is asked again. It opens `rerun`, the counts owed back on that event, until the
686// same end. A lead that recurs for real (an environment update exactly as long as the first) hides that request's repeats.
687// What a loop asked before its lead (`pre`: a subagent's history can open with a hook tip) comes back ahead of it and logs
688// as new; once the lead shows, those asks are counted back out and their rows taken away (`unlog`)
689let seen: Record<string, number> = {}
690let owed: Record<string, number> | undefined // undefined: not joining a conversation with history
691let blind = false // a resume whose store is gone (pruned): nothing to match against, so the window logs nothing
692const closed = new Set<string>() // loops ('main' or an agent id) whose first request since the instance began is over
693const lead = new Map<string, string>() // `<loop>|att` or `<loop>|ctx` (prompt.context) -> the lead's signature
694// what a context carries once, from its start: a first-message block, a SessionStart or SubagentStart hook's text, the engine's
695// environment. Not a hook's tip or a reminder: they recur for real, and the first ask of a subagent's history can be one
696const once = (sig: string, via: 'att' | 'ctx') => { const [type, origin] = sig.split('|'); return via === 'ctx' || origin === 'SessionStart' || origin === 'SubagentStart' || type === 'environment' }
697// same keys -> the loop's counts a replay still owes back. All of the loop's: attachment types and block names never collide
698// (`instructions` is an attachment, `claudeMd` a block), so only its own event's asks can match
699const rerun = new Map<string, Record<string, number>>()
700const close = (loop: string) => { closed.add(loop); rerun.delete(`${loop}|att`); rerun.delete(`${loop}|ctx`) }
701const pre = new Map<string, string[]>() // same keys -> what the loop asked before its lead was set, in order
702const recent = new Map<string, { sig: string; t?: number }[]>() // same keys -> the loop's latest asks, as many as its `pre`. t: when one logged as new
703// an ask into its loop's lead, `pre` and `recent`. After the replay check, which reads them as they were before this ask.
704// `pre` stops at 20: a loop that never gets a lead must not grow it all session, and a longer prefix then never matches
705const track = (at: string, sig: string, via: 'att' | 'ctx', t?: number) => {
706  if (!lead.has(at)) { if (once(sig, via)) lead.set(at, sig); else if ((pre.get(at)?.length ?? 0) < 20) pre.set(at, [...pre.get(at) ?? [], sig]) }
707  const n = pre.get(at)?.length
708  if (n) recent.set(at, [...recent.get(at) ?? [], { sig, t }].slice(-n))
709}
710// A fork re-reads its parent's history on its first request: every attachment main already logged comes back under the fork's id
711// (seen: an engine fork between turns logging ~10k tok of environment, skill list and old hook output). Forks are Agent-tool spawns
712// with `fork` and the engine's own (memory, compaction), which raise no agent.spawn and carry ids no list names. While a fork's
713// first request lasts, an attachment matching one main logged is a copy, taken from the fork's own tally of main's counts.
714// A copy logs no row but sits in the fork's context: `took` keeps it for a replay of the fork to owe back, and it can be the
715// fork's lead. In memory, not in `seen`: `seen` is stored, and a session forks several times a turn.
716// A plain subagent starts fresh: its skill list and memory files are its own cost and still log
717const forks = new Set<string>()
718const inherited = new Map<string, Record<string, number>>() // fork id -> main's counts not yet matched
719const took = new Map<string, Record<string, number>>() // fork id -> the copies it matched, under its own signatures
720async function copied($: any, agent: string | undefined, type: string, origin: string | undefined, bytes: number): Promise<boolean> {
721  if (!agent || closed.has(agent)) return false
722  if (!forks.has(agent) && (started.has(agent) || ((await $.agent.list().catch(() => [])) as AgentInfo[]).some(a => a.id === agent))) return false
723  let left = inherited.get(agent)
724  if (!left) { left = {}; for (const [g, c] of Object.entries(seen)) if (g.split('|')[2] === '') left[g] = c; inherited.set(agent, left) }
725  const g = sigOf(type, origin, undefined, bytes)
726  if (!left[g]) return false
727  left[g]--
728  const own = sigOf(type, origin, agent, bytes), mine = took.get(agent) ?? {}
729  mine[own] = (mine[own] ?? 0) + 1; took.set(agent, mine)
730  track(`${agent}|att`, own, 'att')
731  return true
732}
733// type + author + loop + size: different texts of one kind and length (a date change) still count apart per copy,
734// since a replay asks each past copy once and anything past the count is new
735const sigOf = (type: string, origin: string | undefined, agent: string | undefined, bytes: number) => `${type}|${origin ?? ''}|${agent ?? ''}|${bytes}`
736// stores written before `seen` hold entries alone; seed from them (only logged rows, so noisy kinds start at zero)
737const seed = (xs: Entry[]) => { const s: Record<string, number> = {}; for (const x of xs) if (x.kind === 'prompt' && x.type && x.bytes != null) { const g = sigOf(x.type, x.origin, x.agent, x.bytes); s[g] = (s[g] ?? 0) + 1 } return s }
738// names only for loops the kept entries still show: a session forks several times a turn, and the store has a size cap
739// `seen` likewise drops a subagent's counts once no kept entry shows it: else they grow all session. Main's (agent part '') stay
740const shown = (id: string) => entries.some(x => x.agent === id)
741const saved = () => ({ entries, seen: Object.fromEntries(Object.entries(seen).filter(([g]) => { const a = g.split('|')[2]; return !a || shown(a) })), turn: turnNo, names: Object.fromEntries(Object.entries(names).filter(([id]) => shown(id))) })
742
743// session id changes on /clear and resume; a failed load is retried next call. Outside the chain, so a load during a session
744// switch cannot interleave with a push's: loadNow runs only inside mutate, which is why it checks the key again
745const load = async ($: any): Promise<void> => { if (key && `r:${await $.session.id()}` === key) return; return mutate(() => loadNow($)) }
746async function loadNow($: any): Promise<void> {
747  const id = await $.session.id()
748  const k = `r:${id}` // r: = redacted format (scrubbed, PEEK-capped text only); older s: keys hold unredacted text and pruneOthers() deletes them
749  if (k === key) return
750  if (dirty && key) { const tail = { key, value: saved() }; if (!await save($, tail.key, tail.value)) pendingTail = tail } // the old session's unflushed tail, before its key is dropped
751  dirty = false
752  const v = await $.store.get(k)
753  if (key) { stepOf.clear(); bills = []; unbilled = [] } // the old session's requests; not on the first load, which a step's push can trigger after its pairing was queued
754  if (setup.runsOf !== id) { setup.runs.clear(); setup.runsOf = id } // the old session's hook runs; ones the new session's records counted before this load stay
755  key = k
756  words.clear(); openTurns.clear(); sel = undefined // the old session's prompts (never stored, so a resumed session's turns have none), opened turns, the cursor
757  entries = Array.isArray(v) ? v : Array.isArray(v?.entries) ? v.entries : []
758  turnNo = typeof v?.turn === 'number' ? v.turn : Math.max(0, ...entries.map(x => x.turn ?? 0)) // stores from before turns hold none: start at 0
759  seen = v?.seen && !Array.isArray(v) ? v.seen : seed(entries)
760  names = { ...(v?.names && !Array.isArray(v) ? v.names : {}), ...names } // merged: a fresh instance can name a loop before its first load. Ids are unique, and saved() drops the ones no entry shows
761  owed = entries.length || Object.keys(seen).length ? { ...seen } : undefined // /clear's empty session owes nothing
762  blind = false; closed.clear(); lead.clear(); rerun.clear(); pre.clear(); recent.clear(); composed.clear(); checks.clear(); inherited.clear(); took.clear(); postCtx = [] // the old session's calls
763  // every new key prunes the others: a session's start, and /clear and resume, which add a key. Queued, not awaited: prompt.submit
764  // awaits this load before its next, and a prune reads every other session
765  mutate(() => pruneOthers($)).catch(() => {})
766}
767const ready = ($: any) => key ? Promise.resolve() : load($).catch(() => {}) // a fresh instance loads before the replay window is read
768
769function mutate(fn: () => Promise<void>): Promise<void> {
770  const run = chain.then(fn)
771  chain = run.catch(() => {})
772  return run
773}
774
775// The store is one JSON file for all sessions (~1 MB), so a write per entry made a burst (a resume re-asks
776// every past attachment) take seconds, and hooks awaiting it ran past their budget. Entries now land in
777// memory and flush at most once a second; a hot reload loses at most that last second.
778// A write the store refuses (its 4 MiB cap, shared by every session's key) keeps the rows dirty, shows "log not saved" (problemsOf),
779// prunes the other sessions and tries again in RETRY ms; pendingTail is a switched-away session's tail still to write
780let dirty = false
781let flushTimer: Timer | undefined
782let retryTimer: Timer | undefined
783let unsaved = false
784let pendingTail: { key: string; value: unknown } | undefined
785const RETRY = 5000
786async function save($: any, k: string, v: unknown): Promise<boolean> {
787  try { await $.store.set(k, v); return true } catch {}
788  unsaved = true
789  await pruneOthers($).catch(() => {})
790  retryTimer ??= $.clock.after(RETRY, () => { retryTimer = undefined; dirty = true; flush($).catch(() => {}) })
791  $.ui.invalidate('ui.render')
792  return false
793}
794// per session, in JSON text: SESSIONS of these and headroom fit the store's 4 MiB. Measured, as rows vary from ~200 bytes to over 2 KB
795const BUDGET = Math.floor((4 * 1024 * 1024 - 256 * 1024) / SESSIONS)
796const fit = () => {
797  let n = JSON.stringify(saved()).length
798  if (n <= BUDGET) return
799  const size = new Map(entries.map(x => [x, JSON.stringify(x).length + 1]))
800  while (n > BUDGET && entries.length) n -= size.get(evictOne())!
801  // the rows are gone and it is still over: `seen` alone, a session that logged thousands of signatures. Its rarest go first
802  if (n > BUDGET) for (const [g] of Object.entries(seen).sort((a, b) => a[1] - b[1])) { if (JSON.stringify(saved()).length <= BUDGET) break; delete seen[g] }
803}
804function flush($: any): Promise<void> {
805  flushTimer?.cancel(); flushTimer = undefined
806  return mutate(async () => {
807    if (pendingTail && await save($, pendingTail.key, pendingTail.value)) pendingTail = undefined
808    if (dirty && key) { dirty = false; fit(); if (!await save($, key, saved())) dirty = true }
809    if (unsaved && !pendingTail && !dirty) { unsaved = false; retryTimer?.cancel(); retryTimer = undefined; $.ui.invalidate('ui.render') } // all written
810  })
811}
812const touch = ($: any) => { dirty = true; flushTimer ??= $.clock.after(1000, () => { flushTimer = undefined; flush($).catch(() => {}) }) }
813
814// true when this ask re-reads history already counted (see `seen`); otherwise counts it. Call after ready()
815function replayed($: any, sig: string, agent?: string, via: 'att' | 'ctx' = 'att'): boolean {
816  const at = `${agent ?? 'main'}|${via}`, again = recounted($, sig, agent, at)
817  track(at, sig, via, again ? undefined : Date.now())
818  return again
819}
820function recounted($: any, sig: string, agent: string | undefined, at: string): boolean {
821  const loop = agent ?? 'main', back = lead.get(at) === sig
822  if (owed && !closed.has(loop)) {
823    if (blind) return true
824    if (owed[sig]) { owed[sig]--; return true }
825  }
826  // not gated on `closed`: a chain change before the loop's first tool call replays too
827  if (back && !rerun.has(at)) {
828    // the asks just before the lead were this replay's too, logged as new: count them back out and take their rows away
829    const p = pre.get(at) ?? [], r = recent.get(at) ?? []
830    const ahead = p.length > 0 && r.length === p.length && r.every((a, i) => a.sig === p[i])
831    if (ahead) for (const a of r) if (a.t != null) { if (!--seen[a.sig]!) delete seen[a.sig]; unlog($, a.sig, a.t) }
832    const debt: Record<string, number> = { ...took.get(loop) } // a fork's copies of main's history
833    for (const [g, n] of Object.entries(seen)) if (g.split('|')[2] === (agent ?? '')) debt[g] = (debt[g] ?? 0) + n
834    if (ahead) for (const g of p) if (debt[g]) debt[g]-- // asked again already
835    rerun.set(at, debt)
836  }
837  const debt = rerun.get(at)
838  if (debt?.[sig]) { debt[sig]--; return true } // counted when it first came
839  seen[sig] = (seen[sig] ?? 0) + 1
840  touch($)
841  return false
842}
843// takes away the row logged at t or later for an ask that turned out to be a replay's. The newest that matches: the replay's copy
844function unlog($: any, sig: string, t: number): void {
845  mutate(async () => {
846    const i = entries.findLastIndex(y => y.kind === 'prompt' && y.t >= t && sigOf(y.type ?? '', y.origin, y.agent, y.bytes ?? 0) === sig)
847    if (i < 0) return // counted but never logged (a skipped kind)
848    const gone = entries.splice(i, 1)[0]!
849    if (!gone.again && (gone.src === 'hook' || gone.src === 'plugin')) markAgain(entries, gone)
850    touch($)
851    $.ui.invalidate('ui.render')
852  }).catch(() => {})
853}
854
855// hook text logged before, word for word: every copy of x's text after the first in the log's order is `again`. Over the whole log,
856// not just x: x may have gone in above a copy logged earlier (push's `above`), which then is the repeat
857export const markAgain = (xs: Entry[], x: Entry) => {
858  let seen = false
859  for (const y of xs) if (y.kind === 'prompt' && y.name === x.name && y.bytes === x.bytes && first(y.peek) === first(x.peek)) { y.again = seen || undefined; seen = true }
860}
861
862// fire-and-forget: callers never await the log, so logging cannot cost a hook its budget
863// opt.ahead: on a tool row, its PreToolUse context attachments to come. opt.above: on such an attachment, the tool it goes with.
864// opt.after: on a PostToolUse attachment, the tool it followed (its newest call of that name in the loop, by start)
865// opt.start: on a tool row, when its call began
866function push($: any, e: Omit<Entry, 't'>, opt: { ahead?: number; above?: string; after?: string; start?: number } = {}): void {
867  const t = opt.start ?? Date.now() // when it happened, not when the chain gets to it: rows are placed by it
868  mutate(async () => {
869    await loadNow($)
870    const x: Entry = { t, turn: turnNo, ...e }
871    // A call's row is pushed once the call is over, after everything that happened while it ran: its own hooks (they run inside
872    // it), parallel calls that finished first, a foreground agent's whole run. So it goes in where it began: before the first
873    // row logged after its start, but never between another call and that call's hook rows, and in front of its own first row
874    // even when they share a millisecond. Its PreToolUse context, which comes after it, goes in behind that call's PreToolUse hook rows
875    const call = x.kind === 'tool' && x.use ? entries.findIndex(y => y.use === x.use) : -1
876    let at = opt.start != null ? entries.findLastIndex(y => y.t <= x.t) + 1 : -1
877    while (at > 0 && at < entries.length && entries[at]!.use && entries[at]!.use === entries[at - 1]!.use) at++
878    if (call >= 0 && (at < 0 || call < at)) at = call
879    const i = opt.above ? entries.findIndex(y => (ahead.get(y) ?? 0) > 0 && y.name === opt.above && y.agent === x.agent) : -1
880    if (call >= 0) x.turn = entries[call]!.turn // the turn its hooks ran in, the one it began in
881    if (at >= 0 && at < entries.length) entries.splice(at, 0, x)
882    else if (i >= 0) {
883      const y = entries[i]!; ahead.set(y, ahead.get(y)! - 1); x.t = y.t; x.turn = y.turn; x.use = y.use // the row's time and turn, so the log stays in time order
884      const j = y.use ? entries.findLastIndex(z => z.use === y.use && z.origin === 'PreToolUse') : -1
885      entries.splice(Math.max(i, j) + 1, 0, x)
886    } else {
887      if (opt.after && !x.use) x.use = entries.findLast(y => y.kind === 'tool' && y.name === opt.after && y.agent === x.agent)?.use // no hook seen: a reload lost the queue
888      entries.push(x)
889    }
890    if (opt.ahead) ahead.set(x, opt.ahead)
891    // the same hook text once more, or the engine putting the cwd back where the session began after a `cd`: nothing new, so info
892    if (x.kind === 'prompt' && (x.src === 'hook' || x.src === 'plugin')) markAgain(entries, x)
893    else if (envUpdate(x)) { const home = entries.findLast(y => y.kind === 'session' && y.name !== 'reload')?.detail; x.again = (!!home && ctxOf(x)[1] === ` → ${base(home)}`) || undefined }
894    // over `keep`, the oldest hidden or debug row goes first: chatter only fills slots the rest leaves free (evictOne)
895    while (entries.length > keep) evictOne()
896    touch($)
897    $.ui.invalidate('ui.render') // a hidden row still redraws: its turn's hidden count changed
898    if (lvl(x) < level) return // no "N new" for a row nobody sees
899    if (!follow) fresh++ // following, the redraw pins the window to the end
900  }).catch(() => {})
901}
902
903// over `keep` or the store budget (fit), the oldest hidden or debug row goes first, else the oldest; returns it
904// an evicted first copy of a hook text hands that role to the next copy, which then ranks notice again (markAgain)
905function evictOne(): Entry {
906  const d = entries.findIndex(y => hiddenCtx(y) || lvl(y) === 0)
907  const gone = entries.splice(Math.max(0, d), 1)[0]!
908  if (gone.kind === 'prompt' && !gone.again && (gone.src === 'hook' || gone.src === 'plugin')) markAgain(entries, gone)
909  return gone
910}
911
912// called from loadNow on each key change (session.start's first load among them), and when a write is refused
913async function pruneOthers($: any): Promise<void> {
914  // keep the SESSIONS - 1 other sessions whose newest entry is newest. Insertion order pruned live sessions: every hot reload
915  // prunes in every running session, and a long-lived session's key is the oldest. A session idle behind 9 busier ones still goes.
916  // And by bytes: keys written before BUDGET can be larger, so the oldest kept ones also go until this session's share is free
917  const others: string[] = (await $.store.keys()).filter((k: string) => k !== key)
918  const redacted = others.filter(k => k.startsWith('r:'))
919  const last = new Map<string, number>(), size = new Map<string, number>()
920  for (const k of redacted) { const v = await $.store.get(k); const es = Array.isArray(v) ? v : v?.entries; last.set(k, Array.isArray(es) ? es.at(-1)?.t ?? 0 : 0); size.set(k, JSON.stringify(v ?? null).length) }
921  const kept = [...redacted].sort((a, b) => last.get(b)! - last.get(a)!).slice(0, SESSIONS - 1)
922  let total = kept.reduce((n, k) => n + size.get(k)!, 0)
923  while (kept.length && total > 4 * 1024 * 1024 - 256 * 1024 - BUDGET) total -= size.get(kept.pop()!)!
924  for (const k of others.filter(k => !kept.includes(k))) await $.store.delete(k)
925}
926
927// What broke this session, for the controls' red badge and the setup page: settings hook runs that failed or were cancelled (the collector's counts, else the red
928// hook rows), API errors, turns the API failed, MCP servers that did not connect
929const problemsOf = (xs: Entry[]): string[] => {
930  const n = (f: (x: Entry) => boolean) => xs.filter(f).length
931  const many = (k: number, one: string, more = `${one}s`) => k ? `${k} ${k > 1 ? more : one}` : ''
932  const runs = [...setup.runs.values()].reduce((a, r) => a + r.errors + r.cancelled, 0)
933  return [
934    many(runs || n(x => x.kind === 'hook' && stat(x) === 'failed'), 'hook run failed', 'hook runs failed'),
935    many(n(x => x.kind === 'event' && x.name === 'API error'), 'API error'),
936    many(n(x => x.kind === 'event' && x.name === 'turn failed'), 'turn failed', 'turns failed'),
937    unsaved ? 'log not saved' : '',
938    many([...setup.mcp.values()].filter(m => m.status !== 'connected' && m.status !== 'disconnected').length || n(x => x.kind === 'event' && x.name === 'MCP failed'), 'MCP server down', 'MCP servers down'),
939  ].filter(Boolean)
940}
941
942// === HOOKS ===
943export const register: Register = (on, options) => {
944  keep = Math.min(300, Math.max(1, Number(options.keep) || 300)) // the most a session keeps, as plugin.json says; fit() keeps fewer when its rows are large (step rows run to 2 KB)
945
946  // what identifies a call: the scrubbed Bash command, a file target, or a short handle (agent type and task, skill, query, URL).
947  // Prompts, file contents and edit bodies stay out of the store
948  const RESERVED = new Set(['tool', 'tool_use_id', 'consent', 'agentId']) // tool.call keys that are not the tool's arguments
949  // `tool: arg`. The server is dropped: the row stays short, and the inspector keeps the full name. A handle key
950  // (url, query, path, command) beats the first string, which is often a mode flag (ctx_execute's language)
951  const HANDLES = ['url', 'query', 'queries', 'path', 'file_path', 'command']
952  const mcpDetail = (e: any): string => {
953    const tool = String(e.tool).split('__').slice(2).join('__')
954    const pick = (v: unknown) => [v].flat().filter(s => typeof s === 'string').join(', ') // queries is a string array
955    const arg = HANDLES.map(k => pick(e[k])).find(Boolean) ?? pick(Object.entries(e).find(([k, v]) => !RESERVED.has(k) && typeof v === 'string')?.[1])
956    return arg ? `${tool}: ${scrub(arg)}` : tool
957  }
958  const detailOf = (e: any): string =>
959    e.tool === 'Bash' ? scrub(e.description ?? '') || noCd(scrub(e.command ?? '')) // the model's own label; else the command minus a leading cd
960    : /^(Read|Edit|Write|NotebookEdit|Grep|Glob)$/.test(e.tool) ? e.file_path ?? e.notebook_path ?? e.path ?? ''
961    : e.tool === 'Agent' ? `${e.subagent_type ?? 'general-purpose'}: ${e.description ?? ''}`
962    : e.tool === 'Skill' ? `${e.skill ?? ''}${e.args ? ` ${scrub(e.args)}` : ''}`
963    : e.tool === 'WebFetch' ? scrub(e.url ?? '')
964    : /^(WebSearch|ToolSearch)$/.test(e.tool) ? scrub(e.query ?? '')
965    : String(e.tool).startsWith('mcp__') ? mcpDetail(e)
966    : ''
967
968  // the verdict and the rule behind it; tool.call reads it after its next, inside which this fires
969  on('tool.check', async ($, e, next) => {
970    const r = await next(e)
971    if (e.tool_use_id) { checks.set(e.tool_use_id, { check: r.decision, rule: r.rule, reason: r.reason }); checkedAt.set(e.tool_use_id, Date.now()) }
972    return r
973  })
974
975  // read-only: when a call was decided, and when main's model request closed, for the wait tool.call logs; each request's price
976  on('telemetry.log', { to: 'collector' }, async ($, e: any, next) => {
977    const a = e.attributes ?? {}
978    // a refusal no one waited on (a headless session's prompt) is recorded just after its call ended: its row is in, or queued ahead
979    // of this on the chain, so the decision is written onto it
980    if (e.event === 'tool_decision' && typeof a.tool_use_id === 'string' && !loopOf.has(a.tool_use_id)) mutate(async () => {
981      const y = entries.findLast(y => y.kind === 'tool' && y.use === a.tool_use_id)
982      decided.delete(a.tool_use_id)
983      if (!y || y.via) return
984      y.via = a.source
985      if (a.decision === 'reject') { y.status = 'denied'; y.outcome = 'deny'; y.insight = undefined; y.exit = undefined }
986      touch($)
987    }).catch(() => {})
988    else if (e.event === 'tool_decision' && typeof a.tool_use_id === 'string') { decided.set(a.tool_use_id, { t: Date.now(), source: a.source, decision: a.decision }); const l = liveNow.get(`t${a.tool_use_id}`); if (l?.asking) { l.asking = false; $.ui.invalidate('ui.render') } }
989    if (e.event === 'hook_execution_complete') {
990      const run = lastRun = { t: Date.now(), event: String(a.hook_event ?? ''), name: String(a.hook_name ?? ''), errors: Number(a.num_non_blocking_error) || 0, cancelled: Number(a.num_cancelled) || 0 }
991      // the count is this session's: /clear and resume start it over, here and at load(). A record can land before the new session's
992      // first load (its SessionStart hooks run first), so it asks the id itself. The plugins and MCP servers stay: the process loaded them once
993      try { const sid = await $.session.id(); if (sid !== setup.runsOf) { setup.runs.clear(); setup.runsOf = sid } } catch {}
994      const r = setup.runs.get(run.event) ?? { n: 0, ms: 0, errors: 0, cancelled: 0 }
995      setup.runs.set(run.event, { n: r.n + 1, ms: r.ms + (Number(a.total_duration_ms) || 0), errors: r.errors + run.errors, cancelled: r.cancelled + run.cancelled })
996    }
997    if (e.event === 'plugin_loaded' && a['plugin.name']) setup.plugins.set(String(a['plugin.name']), { version: a['plugin.version'], scope: a['plugin.scope'], hooks: a.has_hooks === true || a.has_hooks === 'true', mcp: a.has_mcp === true || a.has_mcp === 'true' })
998    // each server's last status, for the problems' `MCP server down`; the server list itself is /mcp's
999    if (e.event === 'mcp_server_connection' && a.server_name) setup.mcp.set(String(a.server_name), { status: String(a.status ?? '') })
1000    if (e.event === 'permission_mode_changed' && a.to_mode) setup.mode = String(a.to_mode)
1001    try { const x = harnessOf(e.event, a); if (x) push($, x, { start: x.ms != null ? Date.now() - x.ms : undefined }) } catch {}
1002    if (e.event === 'api_request' && String(a.query_source ?? '').startsWith('repl_main_thread')) streamEnd = Date.now()
1003    if (e.event === 'api_request') try {
1004      const sig = [a.input_tokens, a.cache_read_tokens, a.cache_creation_tokens, a.output_tokens].map(Number).join()
1005      const bill = { t: Date.now(), sig, cost: Number(a.cost_usd) || undefined, req: typeof a.request_id === 'string' ? a.request_id : undefined }
1006      const s = unbilled.find(u => u.sig === sig)
1007      if (!s) bills = [...bills.filter(b => bill.t - b.t < 60000).slice(-19), bill]
1008      else { // the step's row is in, or queued ahead of this on the chain
1009        unbilled = unbilled.filter(u => u !== s)
1010        mutate(async () => { const y = entries.findLast(y => y.kind === 'step' && y.step === s.key); if (y) { y.cost = bill.cost; y.req = bill.req; touch($) } }).catch(() => {})
1011      }
1012    } catch {}
1013    return next(e)
1014  })
1015
1016  on('tool.call', async ($, e, next) => {
1017    const began = Date.now() // before ready: a fresh instance's first load can take longer than the gap to a parallel call
1018    await ready($)
1019    close(e.agentId ?? 'main')
1020    const detail = String(detailOf(e)).replace(/\s+/g, ' ').slice(0, 400)
1021    const cmd = e.tool === 'Bash' && e.command ? scrub(e.command).slice(0, 400) : undefined // newlines kept so heredocs stay readable in the inspector; 400 = the old detail cap, keeping the store budget
1022    // Grep's and Glob's pattern: not in the plugin types (BuiltinToolInputs is undeclared), but in Claude Code's tool schemas.
1023    // Absent, the row reads its folder alone
1024    const p0 = (e as any).pattern, pat = /^(Grep|Glob)$/.test(e.tool) && typeof p0 === 'string' && p0 ? scrub(p0).replace(/\s+/g, ' ').slice(0, 80) : undefined
1025    const t0 = Date.now()
1026    const step = e.tool_use_id ? stepOf.get(e.tool_use_id) : undefined
1027    if (e.tool_use_id) { loopOf.set(e.tool_use_id, e.agentId); stepOf.delete(e.tool_use_id) }
1028    const now = `t${e.tool_use_id ?? t0}`
1029    liveNow.set(now, { t: began, loop: e.agentId, row: { kind: 'tool', name: e.tool, detail, outcome: '', cmd, pat, agent: e.agentId } })
1030    $.ui.invalidate('ui.render')
1031    // where the call runs: asked before it runs (a Bash `cd` moves the cwd) and in this hook (a subagent's own cwd), not awaited
1032    // ahead of it. Unanswered, the row is not tagged
1033    const place = Promise.all([$.session.root(), $.session.cwd(), homeOf($)]).catch(() => undefined)
1034    const r = await next(e).finally(() => { if (e.tool_use_id) loopOf.delete(e.tool_use_id); liveNow.delete(now) })
1035    const v = e.tool_use_id ? checks.get(e.tool_use_id) : undefined
1036    const pre = e.tool_use_id ? preCtx.get(e.tool_use_id) : undefined // classic.PreToolUse ran inside next
1037    const hookBlocked = !!e.tool_use_id && blockedUse.delete(e.tool_use_id)
1038    const at = e.tool_use_id ? checkedAt.get(e.tool_use_id) : undefined, d = e.tool_use_id ? decided.get(e.tool_use_id) : undefined
1039    if (e.tool_use_id) { checks.delete(e.tool_use_id); preCtx.delete(e.tool_use_id); checkedAt.delete(e.tool_use_id); decided.delete(e.tool_use_id) }
1040    const wait = at != null && d && d.t - at >= WAIT ? d.t - at : undefined
1041    // main's message closed between the check and the call's end: the decision waited for it. Not up to the decision: the engine
1042    // records the request a few ms after deciding. Main's next request starts only once its calls end, so none closes in between
1043    // by chance. Anything else is named by the decision's source alone: `user_temporary`, `user_reject` and their kin are the person
1044    // Auto mode's classifier answers as `config`, as a rule does, but a rule takes a millisecond and the classifier a model request:
1045    // a config decision that waited, in auto mode, is the classifier's. Its refusal names it in the error result
1046    const text = typeof r.text === 'string' ? r.text : '', refused = d?.decision === 'reject' || (!!r.isError && DENIED.test(text))
1047    const classified = CLASSIFIED.test(text) || (d?.source === 'config' && wait != null && setup.mode === 'auto')
1048    const waitFor = wait == null ? undefined : !e.agentId && streamEnd >= at! && streamEnd <= Date.now() ? STREAMING
1049      : d?.source?.startsWith('user') ? ASKED : classified ? 'the auto-mode classifier' : `the permission decision${d?.source ? ` (${d.source})` : ''}`
1050    const sp = e.tool === 'Agent' ? spawned.find(s => s.use === e.tool_use_id) : undefined // the spawn ran inside next
1051    if (sp) spawned = spawned.filter(s => s !== sp)
1052    // a rejected decision is a refusal, whether it reaches the call as a deny or as the error result the model reads
1053    const outcome = r.deny || refused ? 'deny' : r.isError ? 'error' : 'ok'
1054    const why = CLASSIFIED.test(text) ? scrub(classifierWhy(text)) : refused && DENIED.test(text) ? scrub(deniedWhy(text))
1055      : r.deny ? peek(r.deny) ?? peek(v?.reason) : r.isError ? peek(r.text) : undefined // only failures keep text: an ok result can be a whole file
1056    const ins = r.isError && !r.deny && !refused && !hookBlocked && text ? insightOf(cmd, text) : undefined // refused: it never ran, so no output to boil down
1057    const status: Status = r.deny || refused || hookBlocked ? 'denied' : ins?.quiet ? 'nomatch' : r.isError ? 'failed' : 'ok'
1058    const pl = await place, a = e as any
1059    const out = pl ? outsideOf(e.tool, { path: a.file_path ?? a.notebook_path ?? a.path, cmd }, ...pl) : undefined
1060    try { push($, { kind: 'tool', name: e.tool, detail, outcome, status, agent: e.agentId, ms: r.deny ? undefined : Date.now() - t0 - (wait ?? 0), wait, waitFor, peek: why, cmd, pat, check: v?.check, rule: v?.rule, exit: ins?.exit, insight: ins?.text || undefined, child: sp?.id, use: e.tool_use_id, step, via: classified ? 'classifier' : d?.source, out }, { ahead: pre, start: began }) } catch {} // logger never breaks the call
1061    return r
1062  })
1063
1064  // Each model request as it streams: when the first thinking, text or call arrived, how long it thought, the start of what it
1065  // wrote and thought, the calls it asked for. One `step` row at its end, placed at its start. Chunks pass on untouched, and a
1066  // chunk costs a kind check and a timestamp (the hook's budget counts its own code, not the stream it waits on). A stream closed
1067  // before its end is an interrupt (the event log holds steps that entered and never exited); one that threw, a failure
1068  on('turn.step', async function* ($, e, next) {
1069    const t0 = Date.now(), key = `${e.turnId.slice(0, 8)}:${e.index}`, me = `s${key}`
1070    const now: Live = { t: t0, loop: e.agentId, model: e.model, phase: 'waiting' }
1071    liveNow.set(me, now)
1072    $.ui.invalidate('ui.render')
1073    let ttft: number | undefined, think = 0, since: number | undefined, text = '', thought = '', r: TurnStepResult | undefined, err: unknown
1074    const asked: string[] = []
1075    // a redraw per phase, not per chunk: tick's poll counts the time up
1076    const phase = (p: NonNullable<Live['phase']>, said?: string) => { now.said = said; if (now.phase !== p) { now.phase = p; $.ui.invalidate('ui.render') } }
1077    const s = next(e)
1078    try {
1079      for await (const c of s) {
1080        if (c.kind !== 'engine' && c.kind !== 'stop') {
1081          const t = Date.now()
1082          ttft ??= t - t0
1083          if (c.kind === 'thinking') { since ??= t; if (thought.length < PEEK) thought += c.text; phase('thinking', first(thought)) }
1084          else {
1085            if (since != null) { think += t - since; since = undefined }
1086            if (c.kind === 'text') { if (text.length < PEEK) text += c.text; now.wrote = true; phase('writing', first(text)) }
1087            if (c.kind === 'tool') { asked.push(c.name); stepOf.set(c.id, key); if (stepOf.size > 200) stepOf.delete(stepOf.keys().next().value!); now.tool = c.name; phase('calling') }
1088          }
1089        }
1090        yield c
1091      }
1092      r = await s.result
1093    } catch (x) { err = x; throw x } finally {
1094      liveNow.delete(me)
1095      try {
1096        const end = Date.now()
1097        if (since != null) think += end - since
1098        const u = r?.usage
1099        const tok = u ? [u.input_tokens, u.cache_read_input_tokens, u.cache_creation_input_tokens, u.output_tokens] : undefined
1100        const sig = tok?.join(), b = sig ? bills.find(b => b.sig === sig) : undefined
1101        if (b) bills = bills.filter(x => x !== b); else if (sig) unbilled = [...unbilled.slice(-19), { t: end, sig, key }]
1102        const aborted = next.signal.aborted || (!r && !err) // closed early: the person interrupted, or the engine dropped the stream
1103        const status: Status = aborted ? 'aborted' : err ? 'failed' : stepStatus(r?.stopReason)
1104        const names = r ? r.toolUses.map(u => u.name) : asked
1105        const server = r?.serverToolUses?.map((u): [string, number | null] => [u.name, u.endedAt != null ? u.endedAt - u.startedAt : null]) // $.clock.now() ms: only their difference means anything here
1106        const said = r ? r.answer : text
1107        if (!e.agentId) { setup.model = e.model; setup.effort = e.effort }
1108        push($, { kind: 'step', name: e.model, detail: '', outcome: '', status, agent: e.agentId, ms: end - t0, step: key, effort: e.effort, ttft, think: think || undefined,
1109          stop: r?.stopReason ?? undefined, tok, asked: names.length ? names : undefined, server: server?.length ? server : undefined, cost: b?.cost, req: b?.req,
1110          thought: scrub(first(thought)).replace(/^#+\s*|\*\*|__/g, '').slice(0, 200) || undefined, // its first line: a summary's title, and the store's budget
1111          peek: peek(said) ?? (err ? peek(err instanceof Error ? err.message : String(err)) : undefined) }, { start: t0 })
1112      } catch {}
1113    }
1114    return r
1115  })
1116
1117  // What the engine reports happening: a row of its own, whether or not a hook is set on the event. Not repeated is what another row
1118  // already tells: a cwd change (the environment update's row), a CLAUDE.md loading (the memory row), a permission prompt (the call's
1119  // wait and its ● line), auto mode's denial (the call's denied row), and tool calls, prompts, agents and sessions (their own rows)
1120  const eventOf = (e: any, at: (p: string) => string): Omit<Entry, 't'> | undefined => {
1121    const agent: string | undefined = e.agent_id, ev = (name: string, detail?: string | null, outcome?: string | null, more: Partial<Entry> = {}): Omit<Entry, 't'> => ({ kind: 'event', name, detail: String(detail ?? ''), outcome: String(outcome ?? ''), agent, ...more })
1122    switch (e.hook_event_name) {
1123      case 'PostModelSwitch': return ev('model switch', `${e.from_model} → ${e.to_model}`, e.source, { peek: `${num(Number(e.context_tokens) || 0)} tokens of context; the prompt cache was ${e.prompt_cache_warm ? 'warm' : `cold, so the next request writes it again (~$${(Number(e.estimated_cache_write_usd) || 0).toFixed(2)})`}` })
1124      case 'StopFailure': return ev('turn failed', e.error, '', { status: 'failed', peek: peek(e.error_details) })
1125      case 'PostCompact': { const t = compactAt.get(agent ?? 'main'); compactAt.delete(agent ?? 'main'); return ev('compacted', e.trigger, '', { ms: t != null ? Date.now() - t : undefined, peek: peek(e.compact_summary) }) }
1126      case 'ConfigChange': hookFresh = false; return ev('settings changed', e.file_path ? at(e.file_path) : e.source) // the path says whose settings: no `(user_settings)` after it
1127      case 'DirectoryAdded': return ev('directory added', at(e.directory ?? ''), e.source)
1128      case 'WorktreeCreate': return ev('worktree created', e.name)
1129      case 'WorktreeRemove': return ev('worktree removed', at(e.worktree_path ?? ''))
1130      case 'Elicitation': return ev('MCP question', e.mcp_server_name, e.mode, { peek: peek(e.message) })
1131      case 'ElicitationResult': return ev('MCP answer', e.mcp_server_name, e.action)
1132      case 'UserPromptExpansion': return ev('command', `/${e.command_name}`, e.command_source) // not its args: your own typing
1133      case 'TaskCreated': return ev('task created', e.task_subject, e.teammate_name)
1134      case 'TaskCompleted': return ev('task completed', e.task_subject, e.teammate_name)
1135      case 'TeammateIdle': return ev('teammate idle', e.teammate_name, e.team_name)
1136      case 'Notification': return ev('notification', e.notification_type, '', { peek: peek(e.message) })
1137      case 'FileChanged': return ev('file changed', at(e.file_path ?? ''), e.event)
1138      case 'Setup': return ev('setup', e.trigger)
1139    }
1140    return undefined
1141  }
1142
1143  // every classic event; one that changed nothing (or only added context, logged again as its attachment) ranks debug unless slow
1144  on('classic.*', async ($, e: any, next) => {
1145    if (e.hook_event_name === 'SessionStart' && (e.source === 'resume' || e.source === 'fork')) {
1146      await mutate(() => load($)).catch(() => {}) // a /resume inside this process switches the key: match against the resumed session's counts
1147      if (!Object.keys(seen).length) { owed = {}; blind = true; closed.clear() } // store pruned: the history is unknown, so its replay is muted whole
1148    }
1149    if (/^Subagent(Start|Stop)$/.test(e.hook_event_name) && e.agent_id && !started.has(e.agent_id)) {
1150      names[e.agent_id] ??= e.agent_type || 'fork'
1151      // before next: the fork's $.model.fork resolves right after this event, and the model.fork hook looks for it then
1152      if (e.hook_event_name === 'SubagentStop' && !e.agent_type) stops = [...stops.slice(-19), { id: e.agent_id, t: Date.now(), text: e.last_assistant_message }]
1153    }
1154    if (!e.agent_id && e.permission_mode) setup.mode = e.permission_mode // main's; a subagent's may differ
1155    if (e.hook_event_name === 'Stop' && !e.agent_id) setup.bg = { tasks: e.background_tasks ?? [], crons: e.session_crons ?? [] }
1156    if (e.hook_event_name === 'PreCompact') compactAt.set(e.agent_id ?? 'main', Date.now())
1157    if (e.hook_event_name === 'PermissionRequest') { // the prompt is up: the call's ● line says so until the decision lands
1158      const l = [...liveNow.values()].find(l => l.row?.name === e.tool_name && (l.loop ?? '') === (e.agent_id ?? '') && !l.asking)
1159      if (l) { l.asking = true; $.ui.invalidate('ui.render') }
1160    }
1161    const t0 = Date.now()
1162    const r: any = await next(e)
1163    const ms = Date.now() - t0
1164    // $HOME shortened to ~ in the paths a row shows
1165    const home = e.file_path || e.directory || e.worktree_path ? await $.env.get('HOME').catch(() => undefined) : undefined
1166    const at = (p: string) => home && p.startsWith(home + '/') ? '~' + p.slice(home.length) : p
1167    try { const x = eventOf(e, at); if (x) push($, x, { start: x.ms != null ? Date.now() - x.ms : t0 }) } catch {}
1168    const blocked = r?.deny ?? r?.block // classic.PreToolUse answers deny, every other classic event block
1169    const ctx: string[] = r?.additionalContext ?? []
1170    const event = e.hook_event_name ?? 'PreToolUse' // classic.PreToolUse's envelope is tool.call's, which names no event
1171    if (event === 'PreToolUse' && e.tool_use_id && blocked) blockedUse.add(e.tool_use_id)
1172    if (event === 'PostToolUse' && e.tool_use_id && ctx.length) postCtx = [...postCtx.slice(-19), { use: e.tool_use_id, tool: e.tool_name, agent: e.agent_id, n: ctx.length }]
1173    if (event === 'PreToolUse' && e.tool_use_id && ctx.length) preCtx.set(e.tool_use_id, ctx.length) // one attachment per entry
1174    const outcome = blocked ? 'block' : ctx.length ? `context x${ctx.length} ${kb(ctx.join('').length)}` : r?.preventContinuation ? 'stop' : ''
1175    // InstructionsLoaded/FileChanged: which file, and why it loaded
1176    // ConfigChange's source (`user_settings`) is left out: its event row names the settings file
1177    const detail = String(e.tool_name ?? e.tool ?? (event === 'ConfigChange' ? undefined : e.source) ?? e.reason ?? [e.load_reason, e.file_path && at(e.file_path)].filter(Boolean).join(' ')).slice(0, 120) // no e.prompt: that is the person's prompt
1178    // settings hooks that exited with an error or that the engine cancelled (a timeout), as the collector counted them: a red row,
1179    // even when the rest of the event changed nothing. Only with OTEL export on
1180    // classic.PreToolUse's e is the tool.call envelope: `tool`, no `tool_name` or `hook_event_name`
1181    const target = e.tool ?? e.tool_name ?? e.source ?? e.trigger ?? e.reason
1182    const run = lastRun && lastRun.event === event && lastRun.t >= t0 && (!lastRun.name.includes(':') || fits(lastRun.name.slice(lastRun.name.indexOf(':') + 1), target)) ? lastRun : undefined
1183    if (run) lastRun = undefined
1184    const broke = run ? [run.cancelled && `${run.cancelled} cancelled`, run.errors && `${run.errors} failed`].filter(Boolean).join(', ') : ''
1185    const said = (typeof blocked === 'string' ? blocked : ctx.join('\n')) || broke // the block reason, else the context the hooks added, else what broke
1186    // a hook that broke beside others that added context is still a failure: red, and what broke leads the text. A block or stop keeps its own mark
1187    const failed = !!broke && !blocked && !r?.preventContinuation
1188    const cfg = await hooksOf($)
1189    // the wrapped hooks that ran on this event and call, within its span
1190    const runs = wraps(cfg, event, target) ? await runsOf($, r => r.event === event && (r.use ?? undefined) === e.tool_use_id && r.t0 >= t0 - SKEW && r.t1 <= Date.now() + SKEW, t0) : []
1191    // each context entry is one hook's: by the run that said it, else named in its own first line. Context leaves the silent hooks
1192    // in the name: they ran too, and the row counts them. A block keeps only its blocker, whom the call's ⊘ names (byOf)
1193    const spoke = runs.length ? runNames(cfg, event, target, runs, typeof blocked === 'string' ? [blocked] : ctx)
1194      : typeof blocked === 'string' || ctx.length < 2 ? hookName(cfg, event, target, said) : [...new Set(ctx.flatMap(c => hookName(cfg, event, target, c).split(', ')))].join(', ')
1195    const name = runs.length && !blocked && ctx.length ? withSilent(cfg, event, target, runs, spoke) : spoke
1196    if (!name && !outcome && !broke && ms < SLOW) return r // the event fired, but no hook is set on it: most events, most of the time
1197    // every classic event but PreToolUse names its loop (agent_id); PreToolUse's comes from the call it runs inside (loopOf)
1198    // several hooks: each wrapped one's own time; the engine times only the chain
1199    const each = name.includes(', ') && runs.length ? Object.fromEntries(runs.map(r => [r.name, r.ms])) : undefined
1200    // several hooks: which of them the outcome is, and which broke in silence, so each hook's line gets its own mark
hooks/scrub.ts 29 lines
1// Copied from plugins/shared/scrub.ts by plugins/shared/sync.sh: edit that file, then run the script.
2// The secret scrub agent-log and event-log share: one source, imported by both. A secret's value as ***, the text around it
3// kept. Best-effort, not a guarantee (ponytail). The rules are redact's (plugins/redact/hooks/redact.tsx RULES, minus its email
4// rule: paths and git output stay readable), plus three shapes it lacks: an Authorization header's value, curl's
5// `-u user:pass` and mysql's `-p<pass>`. redact keeps its own copy (with the email rule); keep the two in step. Repeats that
6// can fail are bounded, as redact's speed test pins
7const NAME = String.raw`\w{0,64}(?:token|secret|password|passwd|api[_-]?key|private[_-]?key|access[_-]?key|credential)\w{0,64}`
8const STOP = String.raw`\s'"\`,;&<>()[\]{}⟦⟧`
9const VAL = String.raw`[^${STOP}]+`
10const END = String.raw`(?![^${STOP}])`
11// an unquoted value that reads as code stays: a `$` reference, a call or index, a keyword or literal, a dotted path, the name itself
12const CODE = String.raw`(?!-?\$|[\w.]+[(\[]|(?:await|new|string|number|boolean|bool|str|int|bytes|any|unknown|undefined|none|nil|true|false|null|\d+|[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)+|\k<name>|-)${END})`
13// a rule masks its v (or v2) group and keeps pre, q and post; one with no groups masks its whole match
14const SCRUB: RegExp[] = [
15  /-----BEGIN [A-Z ]*PRIVATE KEY-----(?:[\s\S]{0,8192}?-----END [A-Z ]*PRIVATE KEY-----|(?:(?:\r?\n|\\n)[ \t]*(?:\d+\t)?[A-Za-z0-9+/=]+(?=[\r\n\\]|$))+)/g,
16  /\b(?:sk-[\w-]{20,}|gh[pousr]_\w{36,}|github_pat_\w{20,}|(?:AKIA|ASIA)[0-9A-Z]{16}|xox[abprs]-[\w-]{10,}|AIza[\w-]{35}|[sr]k_(?:live|test)_[0-9A-Za-z]{24,}|glpat-[\w-]{20,}|npm_[A-Za-z0-9]{36}|dckr_pat_[\w-]{20,}|SG\.[\w-]{20,}\.[\w-]{20,}|hf_[A-Za-z0-9]{30,}|ya29\.[\w-]{20,}|eyJ[\w-]{1,512}\.[\w-]+\.[\w-]+)/g,
17  /(?<pre>\bauthorization["']?[ \t]*[:=][ \t]*(?:(?:basic|bearer|token|digest)[ \t]+)?)(?<v>[^\s'"]+)/gi,
18  /\b(?<pre>bearer[ \t]+)(?<v>[\w.~+/-]{8,}=*)/gi,
19  /(?<pre>:\/\/[^:\/\s@]+:)(?!\$)(?<v>[^\s@\/]+)(?<post>@)/g,
20  /(?<pre>(?:^|\s)(?:-u|--user)(?:[ \t]+|=)["']?[^\s:'"]{1,64}:)(?<v>[^\s'"]+)/g,
21  /(?<pre>\b(?:mysql|mysqldump|mariadb)\b[^\n]{0,200}?[ \t]-p)(?<v>[^\s'"]+)/g,
22  new RegExp(String.raw`(?<pre>--(?:password|token|api-key|secret)[ \t]+)(?![$-])(?<v>${VAL})`, 'gi'),
23  new RegExp(String.raw`(?<pre>\b(?<name>${NAME})["']?[ \t]*[=:][ \t]*)(?:(?<q>["'])(?!\$)(?<v>(?:(?!\k<q>)[^\n])+)(?<post>\k<q>?)|${CODE}(?<v2>${VAL}))`, 'gi'),
24]
25export const scrub = (s: string) => SCRUB.reduce((acc, re) => acc.replace(re, (m: string, ...rest: unknown[]) => {
26  const g = rest.at(-1) as Record<string, string | undefined> | string
27  return typeof g === 'object' && g ? `${g.pre ?? ''}${g.q ?? ''}***${g.post ?? ''}` : '***'
28}), s)
29
types/index.d.ts 9 lines
1// started: a session.start already logged in this process. Held by the host, so a hot reload of agent-log.tsx still reads it
2export type Started = boolean // a contract must export a type, and `claude plugin validate` reads the key from the inline object below
3
4declare module 'claude-code' {
5  interface PluginState {
6    'agent-log': { started: Started }
7  }
8}
9