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…

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.
Parts marked (OTEL) need OpenTelemetry export on (CLAUDE_CODE_ENABLE_TELEMETRY=1). Without it they are missing and the rest works.
#N opens the full split: model, tools, agents, hooks, compaction, permission prompts and you.╭ ├ ╰). 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).↗ 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.● lines under the log.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.
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.
/plugin install agent-log --marketplace dboeter/cc-plugins
Answer y to add the marketplace, then pick a scope.
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.
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.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.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.
hooks/agent-log.tsx 2135 lines1import 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 markhooks/scrub.ts 29 lines1// 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)
29types/index.d.ts 9 lines1// 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