Records every tool, MCP, skill, hook, subagent, command and injected-context access and reports it with /xtrace.

Records every external access Claude makes in this session and reports it.
/xtrace menu /xtrace all every category, last turn /xtrace tools mcp hooks chosen categories, last turn /xtrace tools session whole session /xtrace pane [on|off] live pane /xtrace tools mcp pane live pane, filtered to these kinds
/clear empties the record: counters, turns and rows start from zero; the filter bar and the pane preference stay, and an open pane is drawn afresh.
Categories: tools, mcp, skills, hooks, agents, commands, context, model.
Hooks rows show which commands are configured for the event in settings.json; plugin-shipped hooks and the per-command output are not visible to a mod.
Every tool call also fires PreToolUse, so with hooks recorded the 2000-row buffer fills about twice as fast as the tool count suggests.
A call auto mode denies reads denied, its target led by the category auto mode gave ([Auto-Mode Bypass] …).
One turn at a time: its prompt: line, its rows, and its totals (time, requests, context growth and the spend at API list price, ≈ because cache writes are priced at the 1-hour rate; subagents get their own Agents: block). A turn Claude Code started from a message rather than a prompt is named by it, e.g. [agent message from ace7b01].
Click a control, or take the pane's keys with ctrl+x tab (Esc gives them back). The cursor starts in the filter; Tab moves on to the buttons.
f p n l first, previous, next, latest turn u d page up, down through a turn's rows wheel scroll through a turn's rows h filter examples
On an older turn the pane stays there and shows ● live: N; l follows the latest again. When a turn has more rows than fit, ↑ N earlier rows says so; paging or scrolling back to the bottom follows the live turn.
Categories named with pane lead the filter bar as one kind: term, replacing any there; all pane drops it. The rest of the bar stays. The pane filters by its bar only; the categories picked for the report do not narrow it.
The filter takes column:value terms; every term must match:
outcome:!ok everything that did not succeed kind:tools sort:-ms tool calls, slowest first tokens:>5k rows that added more than 5k tokens kind:tools,mcp name:bash tool and MCP calls named like bash ms:100..900 cache 100 to 900 ms, "cache" in name or target
Columns: kind name target plugins outcome tokens ms. kind and outcome match exactly (comma lists OR), the other text columns by substring, ! negates. ms and tokens take >, <, >=, <=, =, a..b and k/m. A bare word looks in name and target. sort:col / sort:-col sorts; clicking a header cycles it and writes the sort: term for you. A term that does not parse is named under the bar and left out; the rest still filters.
hooks/register.tsx: recorders, the command, the menu and the live pane. The engine follows $ and the state atoms only within the hooks module's own file, so everything that touches $ lives here.hooks/args.ts, hooks/report.ts, hooks/hookmatch.ts: pure, unit-tested.hooks/view.ts: the pane's filter language, sorting, turn navigation and row window; pure, unit-tested.hooks/xtrace.test.ts: engine tests. A test cannot read plugin state, so they assert through the /xtrace report and the drawn pane.Check with claude plugin validate . and claude plugin test ..
hooks/register.tsx 699 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3import type { Category, Compaction, Row, Selection, Turn, View } from '../types'
4import { CATEGORIES, parseArgs, withNewCategories, wordsOf } from './args'
5import { attachHookContext, buildReport, counterSegments, denialCategory, firstWords, NAME_COLUMN, PANE_MARGIN, paletteFor, paneHeaderCells, paneHeaderWidths, paneRowCells, paneTotalRoom, paneTotalSegments, turnTotalLines, targetOf, tokensOf, tokenValues, usageText } from './report'
6import { commandsFor, enabledInstalls, pluginsFor } from './hookmatch'
7import type { PluginHooks } from './hookmatch'
8import { applyView, canNav, canPage, EXAMPLES, navLabel, navTarget, parseFilter, pageRows, PLACEHOLDER, rowWindow, scrollRows, scrollStep, sortMark, turnTitle, viewedTurn, withKinds, withSort } from './view'
9import type { Column, Nav, Page } from './view'
10
11// The engine follows `$` and the state atoms only within this file, never across
12// an import, so every helper that takes `$` and every atom is declared here.
13
14const ROW_CAP = 2000
15const TURN_CAP = 200
16
17const ROWS = atom({ plugin: 'xtrace', key: 'rows' } as const, [] as Row[])
18const TURNS = atom({ plugin: 'xtrace', key: 'turns' } as const, [] as Turn[])
19const COMPACTIONS = atom({ plugin: 'xtrace', key: 'compactions' } as const, [] as Compaction[])
20const SELECTION = atom(
21 { plugin: 'xtrace', key: 'selection' } as const,
22 { categories: [...CATEGORIES], scope: 'turn', known: [...CATEGORIES] } as Selection,
23)
24
25const VIEW = atom({ plugin: 'xtrace', key: 'view' } as const, { filter: '', help: false } as View)
26
27let counter = 0
28const mintId = (prefix: string) => `${prefix}-${Date.now().toString(36)}-${(counter++).toString(36)}`
29
30const MCP = /^mcp__([^_]+(?:_[^_]+)*)__(.+)$/
31
32function splitMcp(tool: string): { server: string; name: string } | undefined {
33 const m = MCP.exec(tool)
34 return m && m[1] !== undefined && m[2] !== undefined ? { server: m[1], name: m[2] } : undefined
35}
36
37function addRow($: EngineInterface, row: Row) {
38 return update($, ROWS, list => [...list, row].slice(-ROW_CAP))
39}
40
41function finishRow($: EngineInterface, id: string, patch: Partial<Row>) {
42 return update($, ROWS, list => list.map(r => (r.id === id ? { ...r, ...patch } : r)))
43}
44
45function finishRowWith($: EngineInterface, id: string, patch: (row: Row) => Partial<Row>) {
46 return update($, ROWS, list => list.map(r => (r.id === id ? { ...r, ...patch(r) } : r)))
47}
48
49function addTurn($: EngineInterface, turn: Turn) {
50 return update($, TURNS, list => [...list, turn].slice(-TURN_CAP))
51}
52
53// The engine's clock for display only: a report or a drawing never fails for want of it.
54async function nowOrUndefined($: EngineInterface): Promise<number | undefined> {
55 try { return await $.clock.now() } catch { return undefined }
56}
57
58function completeTurn($: EngineInterface, turnId: string, completedAt: number) {
59 return update($, TURNS, list => list.map(t => (t.turnId === turnId ? { ...t, isComplete: true, completedAt } : t)))
60}
61
62async function currentTurnId($: EngineInterface): Promise<string> {
63 const turns = await read($, TURNS)
64 return turns.at(-1)?.turnId ?? 'before-first-turn'
65}
66
67// Merged settings, read in session.start (which a hot reload fires again).
68let settings: unknown = undefined
69
70// The enabled plugins' hooks.json files, read in session.start after settings.
71let pluginHooks: PluginHooks[] = []
72
73async function loadPluginHooks($: EngineInterface, cwd: string | undefined): Promise<PluginHooks[]> {
74 const home = await $.env.get('HOME')
75 const configDir = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? (home !== undefined ? `${home}/.claude` : undefined)
76 if (configDir === undefined) return []
77 const installed = JSON.parse(await $.fs.read(`${configDir}/plugins/installed_plugins.json`))
78 const out: PluginHooks[] = []
79 for (const p of enabledInstalls(settings, installed, cwd)) {
80 try { out.push({ name: p.name, config: JSON.parse(await $.fs.read(`${p.installPath}/hooks/hooks.json`)) }) } catch {}
81 }
82 return out
83}
84
85// One helper for every classic event; each registration names its event literally.
86async function recordHook(event: string, $: EngineInterface, e: any, next: (e: any) => Promise<any>) {
87 const toolName: string | undefined = typeof e.tool_name === 'string' ? e.tool_name : typeof e.tool === 'string' ? e.tool : undefined
88 const id = mintId('hook')
89 let startedAt = 0
90 try {
91 startedAt = await $.clock.now()
92 await addRow($, {
93 id, turnId: await currentTurnId($), kind: 'hooks',
94 name: toolName ? `${event} ${toolName}` : event,
95 target: commandsFor(settings, event, toolName).join(', '),
96 plugins: pluginsFor(pluginHooks, event, toolName).join(', '),
97 outcome: 'running', startedAt,
98 })
99 } catch {}
100 const ran = await next(e)
101 try {
102 const r = (ran ?? {}) as Record<string, unknown>
103 const blocked = r.deny !== undefined || r.block !== undefined || r.preventContinuation === true || r.decision === 'block'
104 const context = Array.isArray(r.additionalContext) && r.additionalContext.length > 0
105 const ms = (await $.clock.now()) - startedAt
106 // Context appended while the hook ran has already marked the row; keep it.
107 await update($, ROWS, list => list.map(row => row.id !== id ? row : {
108 ...row, ms,
109 outcome: blocked ? 'blocked' : context || (row.chars ?? 0) > 0 ? 'context' : 'none',
110 }))
111 } catch {}
112 return ran
113}
114
115// The menu dialog and the live pane.
116
117const MENU = 'xtrace-menu'
118const PANE = 'xtrace'
119const PANE_PREF = 'pane'
120const NARROW = 'xtrace: widen the terminal to 144 columns to see the pane'
121
122function toggleCategory(s: Selection, c: Category): Selection {
123 return {
124 ...s,
125 categories: s.categories.includes(c)
126 ? s.categories.filter(x => x !== c)
127 : CATEGORIES.filter(x => x === c || s.categories.includes(x)),
128 }
129}
130
131async function isPanePreferred($: EngineInterface): Promise<boolean> {
132 try { return (await $.store.get(PANE_PREF)) === true } catch { return false }
133}
134
135async function openPane($: EngineInterface): Promise<void> {
136 const opened = await $.ui.open({ id: PANE, title: 'xtrace' })
137 if (!opened.isPlaced) $.ui.toast(NARROW)
138}
139
140async function setPane($: EngineInterface, mode: 'on' | 'off' | 'toggle'): Promise<'on' | 'off'> {
141 const want = mode === 'toggle' ? !(await isPanePreferred($)) : mode === 'on'
142 await $.store.set(PANE_PREF, want)
143 if (want) await openPane($)
144 else await $.ui.close({ id: PANE })
145 return want ? 'on' : 'off'
146}
147
148async function setFilter($: EngineInterface, filter: string): Promise<void> {
149 try { await update($, VIEW, v => ({ ...v, filter, rowStart: undefined })) } catch {}
150}
151
152async function sortBy($: EngineInterface, column: Column): Promise<void> {
153 try { await update($, VIEW, v => ({ ...v, filter: withSort(v.filter, column), rowStart: undefined })) } catch {}
154}
155
156// Read the turns at press time, so a press at an end (or a fast double press) is a no-op.
157async function navigate($: EngineInterface, to: Nav): Promise<void> {
158 try {
159 const turns = await read($, TURNS)
160 const { pinnedTurnId } = await read($, VIEW)
161 if (!canNav(turns, pinnedTurnId, to)) return
162 await update($, VIEW, v => ({ ...v, pinnedTurnId: navTarget(turns, v.pinnedTurnId, to), rowStart: undefined }))
163 } catch {}
164}
165
166// The row window as last drawn: a scroll or a page press moves it by what was on screen.
167// One pane, so the last drawing is the one on screen.
168let drawnWindow = { count: 0, room: 1, sorted: false, turnId: undefined as string | undefined }
169
170// A row start belongs to the turn it was set on: another turn starts from its default.
171const startFor = (v: View, turnId: string | undefined) => (v.rowTurnId === turnId ? v.rowStart : undefined)
172
173async function moveRows($: EngineInterface, by: number | Page): Promise<void> {
174 const { count, room, sorted, turnId } = drawnWindow
175 try {
176 await update($, VIEW, v => {
177 const start = startFor(v, turnId)
178 return { ...v, rowTurnId: turnId, rowStart: typeof by === 'number' ? scrollRows(count, room, start, sorted, by) : pageRows(count, room, start, sorted, by) }
179 })
180 } catch {}
181}
182
183// A one-pixel line as wide as its box; dashed for the subagents' rule.
184const ruleSvg = (color: string, isDashed: boolean) =>
185 `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="1" preserveAspectRatio="none">` +
186 `<line x1="0" y1="0.5" x2="100%" y2="0.5" stroke="${color}" stroke-width="1"${isDashed ? ' stroke-dasharray="4 3"' : ''}/></svg>`
187
188const PAGE: { dir: Page; hotkey: string; label: string }[] = [
189 { dir: 'up', hotkey: 'u', label: '▲' },
190 { dir: 'down', hotkey: 'd', label: '▼' },
191]
192
193const NAV: { to: Nav; hotkey: string; label: string }[] = [
194 { to: 'first', hotkey: 'f', label: '⏮' },
195 { to: 'prev', hotkey: 'p', label: '◀' },
196 { to: 'next', hotkey: 'n', label: '▶' },
197 { to: 'latest', hotkey: 'l', label: '⏭' },
198]
199
200export const register: Register = on => {
201 const runningAgents: string[] = [] // tool_use_ids of Agent calls in flight, newest last
202 const parentOfAgent = new Map<string, string>() // agentId -> Agent tool_use_id
203 const deniedCalls = new Set<string>() // tool_use_ids auto mode denied: their error result reads as denied
204
205 on('session.start', async ($, e, next) => {
206 try { settings = await $.settings.read() } catch { settings = undefined }
207 try { await update($, SELECTION, withNewCategories) } catch {}
208 try { pluginHooks = await loadPluginHooks($, e.cwd) } catch { pluginHooks = [] }
209 try {
210 await $.command.register({
211 name: 'xtrace',
212 description: 'Trace every tool, MCP, skill, hook, subagent, command and context access',
213 argumentHint: '[all | tools mcp skills hooks agents commands context model] [session | pane] | pane [on|off]',
214 })
215 } catch {}
216 try {
217 if (await isPanePreferred($)) await openPane($)
218 } catch {}
219 return next(e)
220 })
221
222 // /clear ends the session but keeps the process and this plugin's state:
223 // empty the record so the pane and the report start from nothing. The
224 // filter bar, the help flag, the menu's ticks and the pane preference stay.
225 on('session.end', async ($, e, next) => {
226 if (e.reason === 'clear') {
227 runningAgents.length = 0
228 parentOfAgent.clear()
229 deniedCalls.clear()
230 try { await update($, ROWS, () => []) } catch {}
231 try { await update($, TURNS, () => []) } catch {}
232 try { await update($, COMPACTIONS, () => []) } catch {}
233 try { await update($, VIEW, v => ({ ...v, pinnedTurnId: undefined, rowStart: undefined, rowTurnId: undefined })) } catch {}
234 try { if (await isPanePreferred($)) await openPane($) } catch {}
235 }
236 return next(e)
237 })
238
239 on('turn.start', async ($, e, next) => {
240 try {
241 await addTurn($, { turnId: e.turnId, text: firstWords(e.text), startedAt: await $.clock.now(), isComplete: false })
242 } catch {}
243 return next(e)
244 })
245
246 on('turn.complete', async ($, e, next) => {
247 try {
248 if (e.agentId === undefined) await completeTurn($, e.turnId, await $.clock.now())
249 } catch {}
250 return next(e)
251 })
252
253 on('tool.call', async ($, e, next) => {
254 const tool = String(e.tool)
255 const { tool: _t, tool_use_id, agentId, consent: _c, ...args } = e as Record<string, unknown> & { tool_use_id?: string; agentId?: string }
256 const id = typeof tool_use_id === 'string' ? tool_use_id : mintId('call')
257 const mcp = splitMcp(tool)
258 let startedAt = 0
259 try {
260 startedAt = await $.clock.now()
261 const agent = typeof agentId === 'string' ? agentId : undefined
262 let parentId: string | undefined
263 if (agent !== undefined) {
264 parentId = parentOfAgent.get(agent)
265 if (parentId === undefined) {
266 parentId = runningAgents.at(-1)
267 if (parentId !== undefined) parentOfAgent.set(agent, parentId)
268 }
269 }
270 const row: Row = {
271 id,
272 turnId: await currentTurnId($),
273 agentId: agent,
274 parentId,
275 kind: mcp ? 'mcp' : tool === 'Agent' ? 'agents' : 'tools',
276 name: mcp ? `${mcp.server} / ${mcp.name}` : tool === 'Agent' ? String(args.subagent_type ?? 'general-purpose') : tool,
277 target: tool === 'Agent' ? String(args.description ?? '') : targetOf(tool, args),
278 outcome: 'running',
279 startedAt,
280 }
281 await addRow($, row)
282 if (tool === 'Agent') runningAgents.push(id)
283 } catch {}
284
285 try {
286 const ran = await next(e)
287 try {
288 const ms = (await $.clock.now()) - startedAt
289 const outcome: Row['outcome'] = ran.deny !== undefined || deniedCalls.has(id) ? 'denied' : ran.isError === true ? 'error' : 'ok'
290 // What the model reads back: the result text, the deny text, and any reminders riding along.
291 const read = typeof ran.text === 'string' ? ran.text : typeof ran.deny === 'string' ? ran.deny : ''
292 const chars = read.length + (ran.context ?? []).reduce((n: number, c: string) => n + c.length, 0)
293 await finishRow($, id, { outcome, ms, chars })
294 } catch {}
295 return ran
296 } finally {
297 const at = runningAgents.indexOf(id)
298 if (at >= 0) runningAgents.splice(at, 1)
299 }
300 })
301
302 on('agent.spawn', async ($, e, next) => {
303 const ran = await next(e)
304 try {
305 await finishRow($, e.tool_use_id, { detail: ran.model ?? e.model ?? 'inherit' })
306 if (ran.agentId !== undefined) parentOfAgent.set(ran.agentId, e.tool_use_id)
307 } catch {}
308 return ran
309 })
310
311 // One row per model request, with the usage the API reported for it.
312 on('turn.step', async function* ($, e, next) {
313 const id = mintId('step')
314 let startedAt = 0
315 try {
316 startedAt = await $.clock.now()
317 const agent = typeof e.agentId === 'string' ? e.agentId : undefined
318 await addRow($, {
319 id, turnId: await currentTurnId($), agentId: agent,
320 parentId: agent !== undefined ? parentOfAgent.get(agent) ?? runningAgents.at(-1) : undefined,
321 kind: 'model', name: `step ${e.index}`, target: e.model, outcome: 'running', startedAt,
322 })
323 } catch {}
324 const r = yield* next(e)
325 try {
326 const u = r.usage
327 const usage = u ? { model: u.model, input: u.input_tokens, cacheRead: u.cache_read_input_tokens, cacheWrite: u.cache_creation_input_tokens, output: u.output_tokens } : undefined
328 await finishRow($, id, {
329 outcome: usage ? 'ok' : 'error', ms: (await $.clock.now()) - startedAt,
330 usage, target: usage ? usageText(usage) : e.model,
331 })
332 } catch {}
333 return r
334 })
335
336 on('skill.prompt', async ($, e, next) => {
337 const ran = await next(e)
338 try {
339 await addRow($, {
340 id: mintId('skill'), turnId: await currentTurnId($), kind: 'skills',
341 name: e.skill, target: `${ran.text.length} chars`, chars: ran.text.length, outcome: 'ok', startedAt: await $.clock.now(),
342 })
343 } catch {}
344 return ran
345 })
346
347 on('command.run', { command: 'xtrace' }, async ($, e) => {
348 const parsed = parseArgs(e.args)
349 if (parsed.mode === 'error') return { text: parsed.message }
350 if (parsed.mode === 'menu') {
351 const opened = await $.ui.open({ id: MENU, title: 'xtrace', focus: true, closeOnEscape: true, rows: 14 })
352 return { text: opened.isPlaced ? 'xtrace: pick what to show.' : `xtrace: ${opened.reason}` }
353 }
354 if (parsed.mode === 'pane') {
355 if (parsed.kinds === undefined) return { text: `xtrace: live pane ${await setPane($, parsed.pane)}.` }
356 const filter = withKinds((await read($, VIEW)).filter, parsed.kinds)
357 await setFilter($, filter)
358 await setPane($, 'on')
359 return { text: `xtrace: live pane on, filter "${filter}".` }
360 }
361 // The save is for the menu's ticks; a refused write (the engine refuses
362 // one while a ui.render hook draws, e.g. with the menu open) must not
363 // cost the report.
364 try { await update($, SELECTION, () => ({ ...parsed.selection, known: [...CATEGORIES] })) } catch {}
365 const [rows, turns, compactions] = await Promise.all([read($, ROWS), read($, TURNS), read($, COMPACTIONS)])
366 return { text: buildReport({ rows, turns, compactions, selection: parsed.selection, now: await nowOrUndefined($) }) }
367 })
368
369 on('command.run', async ($, e, next) => {
370 if (e.command === 'xtrace') return next(e)
371 const id = mintId('cmd')
372 let startedAt = 0
373 try {
374 startedAt = await $.clock.now()
375 await addRow($, { id, turnId: await currentTurnId($), kind: 'commands', name: e.command, target: e.args, outcome: 'running', startedAt })
376 } catch {}
377 try {
378 const ran = await next(e)
379 try { await finishRow($, id, { outcome: 'ok', ms: (await $.clock.now()) - startedAt }) } catch {}
380 return ran
381 } catch (err) {
382 try { await finishRow($, id, { outcome: 'error', ms: (await $.clock.now()) - startedAt }) } catch {}
383 throw err
384 }
385 })
386
387 on('session.append', async ($, e, next) => {
388 try {
389 if (e.door === 'attachment' || e.door === 'hook-context') {
390 const text = e.message.content.map(b => (b.type === 'text' ? b.text : '')).join('')
391 if (text.length === 0) return next(e) // an empty append says nothing
392 const turnId = await currentTurnId($)
393 if (e.origin.kind === 'hook') {
394 // A hook's context belongs on its hooks row; a row of its own only when there is none.
395 const event = e.origin.event
396 let merged = false
397 await update($, ROWS, list => {
398 const attached = attachHookContext(list, turnId, event, text.length)
399 merged = attached !== undefined
400 return attached ?? list
401 })
402 if (merged) return next(e)
403 }
404 const name = e.origin.kind === 'hook' ? `hook ${e.origin.event}` : `${e.door} ${e.message.name ?? ''}`.trim()
405 await addRow($, {
406 id: e.uuid, turnId, agentId: e.agentId, kind: 'context',
407 name, target: `${text.length} chars`, chars: text.length, outcome: 'ok', startedAt: await $.clock.now(),
408 })
409 }
410 } catch {}
411 return next(e)
412 })
413
414 on('session.compact', async ($, e, next) => {
415 const ran = await next(e)
416 try {
417 // Only a main-conversation compaction that happened (not a skip or a precompute).
418 if (e.agentId === undefined && ran.skip === undefined && e.trigger !== 'precompute') {
419 const turnId = await currentTurnId($)
420 const at = await $.clock.now()
421 await update($, COMPACTIONS, list => [...list, { at, turnId }].slice(-50))
422 }
423 } catch {}
424 return ran
425 })
426
427 on('classic.PreToolUse', ($, e, next) => recordHook('PreToolUse', $, e, next))
428 on('classic.PostToolUse', ($, e, next) => recordHook('PostToolUse', $, e, next))
429 on('classic.PostToolUseFailure', ($, e, next) => recordHook('PostToolUseFailure', $, e, next))
430 on('classic.UserPromptSubmit', ($, e, next) => recordHook('UserPromptSubmit', $, e, next))
431 on('classic.Stop', ($, e, next) => recordHook('Stop', $, e, next))
432 on('classic.SessionStart', ($, e, next) => recordHook('SessionStart', $, e, next))
433 on('classic.SessionEnd', ($, e, next) => recordHook('SessionEnd', $, e, next))
434 on('classic.SubagentStart', ($, e, next) => recordHook('SubagentStart', $, e, next))
435 on('classic.SubagentStop', ($, e, next) => recordHook('SubagentStop', $, e, next))
436 on('classic.PreCompact', ($, e, next) => recordHook('PreCompact', $, e, next))
437 on('classic.PostCompact', ($, e, next) => recordHook('PostCompact', $, e, next))
438 on('classic.Notification', ($, e, next) => recordHook('Notification', $, e, next))
439 on('classic.PermissionRequest', ($, e, next) => recordHook('PermissionRequest', $, e, next))
440 // Auto mode answers a call it denies as an ordinary error result; this event says
441 // which call it was and why. Before or after the call finished, its row reads denied.
442 on('classic.PermissionDenied', async ($, e, next) => {
443 try {
444 const id = typeof e.tool_use_id === 'string' ? e.tool_use_id : undefined
445 if (id !== undefined && !deniedCalls.has(id)) {
446 deniedCalls.add(id)
447 const why = typeof e.reason === 'string' ? denialCategory(e.reason) : undefined
448 await finishRowWith($, id, row => ({ outcome: row.outcome === 'running' ? row.outcome : 'denied', target: why !== undefined ? `[${why}] ${row.target}` : row.target }))
449 }
450 } catch {}
451 return recordHook('PermissionDenied', $, e, next)
452 })
453 // The pane is drawn to fit, so the engine never scrolls it: a wheel tick or a
454 // scroll key moves the row window instead, and the window stays undrawn.
455 // Rows that fit leave the scroll to the engine, so a pane too short for its fixed lines still scrolls.
456 on('ui.scroll', { requestId: PANE }, async ($, e, next) => {
457 if (drawnWindow.count <= drawnWindow.room) return next(e)
458 await moveRows($, scrollStep(e.by, e.bodyRows))
459 return {}
460 })
461
462 on('ui.close', async ($, e, next) => {
463 try {
464 // The person closed the pane with the engine's own key: remember it as off.
465 if (e.id === PANE && e.origin.kind === 'person') await $.store.set(PANE_PREF, false)
466 } catch {}
467 return next(e)
468 })
469
470 on('ui.render', { component: 'Pane', requestId: MENU }, async ($, e) => {
471 const { Box, Text, Button } = $.ui.resolve(e)
472 const s = await read($, SELECTION)
473 const isAll = CATEGORIES.every(c => s.categories.includes(c))
474 const canShow = s.categories.length > 0
475
476 return (
477 <Box flexDirection="column">
478 <Text bold>What should /xtrace show?</Text>
479 {CATEGORIES.map((c, i) => (
480 <Button
481 key={`cat-${c}`}
482 hotkey={String(i + 1)}
483 plain
484 label={`[${s.categories.includes(c) ? 'x' : ' '}] ${c}`}
485 onPress={() => update($, SELECTION, cur => toggleCategory(cur, c))}
486 />
487 ))}
488 <Text> </Text>
489 <Button key="all" hotkey="a" plain label={`[${isAll ? 'x' : ' '}] all`}
490 onPress={() => update($, SELECTION, cur => ({ ...cur, categories: isAll ? [] : [...CATEGORIES] }))} />
491 <Button key="scope" hotkey="s" plain label={`[${s.scope === 'session' ? 'x' : ' '}] whole session`}
492 onPress={() => update($, SELECTION, cur => ({ ...cur, scope: cur.scope === 'session' ? 'turn' : 'session' }))} />
493 <Button key="pane" hotkey="p" plain label="toggle live pane"
494 onPress={async () => { await setPane($, 'toggle') }} />
495 <Text> </Text>
496 <Box gap={2}>
497 <Button key="show" hotkey="0" variant="primary" label="show" dimColor={!canShow}
498 onPress={async () => {
499 if (!canShow) return
500 const cur = await read($, SELECTION)
501 await $.ui.close({ id: MENU })
502 await $.command.run({ command: 'xtrace', args: wordsOf(cur) })
503 }} />
504 <Button key="cancel" role="dismiss" label="cancel" onPress={() => $.ui.close({ id: MENU })} />
505 </Box>
506 </Box>
507 )
508 })
509
510 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
511 const elements = $.ui.resolve(e)
512 const { Box, Text, Button, Input } = elements
513 // Off the terminal (whose table draws an Svg as nothing), rules are lines, not text.
514 const Svg = e.surface !== 'terminal' && 'Svg' in elements ? elements.Svg : undefined
515 const rows: Row[] = await read($, ROWS)
516 const turns: Turn[] = await read($, TURNS)
517 const view: View = await read($, VIEW)
518 const pal = paletteFor((await $.config.list().catch(() => [])).find(row => row.key === 'theme')?.value)
519 const counters = counterSegments(CATEGORIES.map(c => [c, rows.filter(r => r.kind === c).length]), pal)
520 const { turn } = viewedTurn(turns, view.pinnedTurnId)
521 const turnId = turn?.turnId
522 const turnRows = rows.filter(r => r.turnId === turnId)
523 // An old turn with no rows left: the 2000-row buffer dropped them.
524 const isEvicted = turn !== undefined && turn.turnId !== turns.at(-1)?.turnId && turnRows.length === 0 && rows.length >= ROW_CAP
525 const tokens = tokensOf(turnRows)
526 const counts = new Map([...tokenValues(turnRows)].map(([id, v]) => [id, v.n]))
527 const parsed = parseFilter(view.filter)
528 const shown = applyView(turnRows, parsed, counts)
529 const isFiltered = view.filter.trim() !== ''
530 const now = await nowOrUndefined($)
531 const total = turn !== undefined && now !== undefined ? turnTotalLines(turn, rows, now) : undefined
532 const extra = (view.help ? EXAMPLES.length : 0) + (parsed.errors.length > 0 ? 1 : 0) + (isFiltered ? 1 : 0)
533 const agentsAt = total?.findIndex(line => line.startsWith('subagents:')) ?? -1
534 // The pane's own rows, not the terminal's: content taller than the pane scrolls,
535 // and a scrolled pane draws the filter's cursor on the wrong row.
536 const bodyRows = e.props.scroll.bodyRows > 0 ? e.props.scroll.bodyRows : (e.viewport?.rows ?? 24)
537 // Counters, filter bar, rule, [prompt, rule,] header, the rows, rule, the total's lines, rule, nav.
538 const base = Math.max(1, bodyRows - 7 - (turn !== undefined ? 2 : 0) - extra - (total?.length ?? 0) - (agentsAt > 0 ? 1 : 0))
539 const width = e.props.bodyColumns > 0 ? e.props.bodyColumns : 80
540 const ruleWidth = Math.max(1, Math.min(width, 120))
541 // A rule one line high across its width. The terminal draws it as text; a surface
542 // with proportional text (the desktop) would wrap `─` onto a second line, which
543 // its boxes do not clip, so there it is an SVG line filling the box.
544 const rule = (key: string, w = ruleWidth, char = '─') => (
545 <Box key={key} width={w} height={1} overflow="hidden" alignItems="center">
546 {Svg !== undefined
547 ? <Svg alt="" source={ruleSvg(pal.separator, char !== '─')} />
548 : <Text dimColor wrap="truncate-end">{char.repeat(w)}</Text>}
549 </Box>
550 )
551 const withPlugins = shown.some(r => r.kind === 'hooks')
552 const marks = parsed.sort !== undefined ? { [parsed.sort.column]: sortMark(parsed.sort, parsed.sort.column) } : {}
553 const header = paneHeaderCells(width, withPlugins, marks)
554 const columnWidths = paneHeaderWidths(width, withPlugins)
555 // Unsorted, the newest rows that fit; sorted, the first ones in order.
556 // A turn that overflows gives one line to the paging line: the earlier-rows hint and its controls.
557 const overflows = shown.length > base
558 const room = overflows ? Math.max(1, base - 1) : base
559 const sorted = parsed.sort !== undefined
560 const rowStart = startFor(view, turnId)
561 const win = rowWindow(shown.length, room, rowStart, sorted)
562 const visible = shown.slice(win.from, win.to)
563 drawnWindow = { count: shown.length, room, sorted, turnId }
564 // The paging line spans the rows (they end PANE_MARGIN short of the pane's edge);
565 // its controls, `u: ▲ d: ▼`, take 9 columns in the middle.
566 const pagingWidth = Math.max(9, width - PANE_MARGIN)
567 const pagingSide = Math.floor((pagingWidth - 9) / 2)
568
569 return (
570 // At least the pane's height, so the spacer below can push the footer to the bottom;
571 // an overfull tree still grows and scrolls.
572 <Box flexDirection="column" minHeight={bodyRows}>
573 <Text wrap="truncate-end">
574 {counters.map((s, i) => <Text key={`count-${i}`} color={s.color}>{s.text}</Text>)}
575 </Text>
576 <Box flexDirection="row" gap={2}>
577 {/* Our own label: the engine draws an Input's label as dim as its placeholder. */}
578 <Box flexDirection="row" gap={1}>
579 <Box key="filter-label"><Text color={pal.value}>filter:</Text></Box>
580 <Input key="filter" autoFocus placeholder={PLACEHOLDER} value={view.filter} submitLabel="keep"
581 onInput={value => setFilter($, value)} onSubmit={value => setFilter($, value)} />
582 </Box>
583 <Button key="help" hotkey="h" plain label="?"
584 onPress={async () => { try { await update($, VIEW, v => ({ ...v, help: !v.help })) } catch {} }} />
585 </Box>
586 {parsed.errors.length > 0 && <Box key="filter-error"><Text dimColor wrap="truncate-end">{parsed.errors.join(' · ')}</Text></Box>}
587 {view.help && EXAMPLES.map(x => (
588 <Text key={`example-${x.text}`} dimColor wrap="truncate-end">{` ${x.text.padEnd(28)} ${x.meaning}`}</Text>
589 ))}
590 {rule('rule-top')}
591 {turn !== undefined && (
592 <Box key="prompt">
593 <Text wrap="truncate-end">
594 <Text color={pal.label}>prompt: </Text>
595 <Text color={pal.value}>{turnTitle(turn.text)}</Text>
596 </Text>
597 </Box>
598 )}
599 {turn !== undefined && rule('rule-prompt')}
600 {isEvicted && <Box key="evicted"><Text dimColor>rows for this turn fell out of the 2000-row buffer</Text></Box>}
601 {!isEvicted && turnRows.length === 0 && <Text dimColor>No rows in this turn yet.</Text>}
602 {turnRows.length > 0 && (
603 // Each label in a box of its column's width, like the rows' cells, so they line up
604 // on a surface that draws proportional text too.
605 <Box flexDirection="row" gap={1}>
606 {header.map((c, i) => {
607 const label = c.text.trim()
608 const isLast = i === header.length - 1
609 const sort = <Button key={`sort-${c.column}`} plain dimColor label={label} onPress={() => sortBy($, c.column as Column)} />
610 return (
611 <Box key={`hcell-${c.column}`} width={columnWidths[i] ?? 1} flexShrink={0}
612 justifyContent={isLast ? 'flex-end' : 'flex-start'}>
613 {e.surface === 'terminal' ? sort : [
614 // The desktop draws a native button, padded: at rest the label is text, lined
615 // up with the cells below; under the pointer the button shows over it.
616 <Text key={`hlabel-${c.column}`} dimColor wrap="truncate-end">{label}</Text>,
617 // Unkeyed: the keyed cell is the hover scope, a keyed Box would be its own.
618 <Box position="absolute" top={0} {...(isLast ? { right: 0 } : { left: 0 })}
619 display="none" hover={{ display: 'flex' }}>
620 {sort}
621 </Box>,
622 ]}
623 </Box>
624 )
625 })}
626 </Box>
627 )}
628 {overflows && (
629 // Three parts: the hint at the left, the controls in the middle, the range and ↓ at
630 // the right edge of the rows (the ms column's end). Equal sides keep the middle centred.
631 <Box key="paging" flexDirection="row" width={pagingWidth}>
632 <Box width={pagingSide} justifyContent="flex-start">
633 {win.from > 0 && <Box key="hidden"><Text dimColor wrap="truncate-end">{`↑ ${win.from} ${sorted ? 'rows above' : 'earlier rows'}`}</Text></Box>}
634 </Box>
635 <Box width={pagingWidth - 2 * pagingSide} justifyContent="center" gap={1}>
636 {PAGE.map(b => (
637 <Button key={`page-${b.dir}`} hotkey={b.hotkey} plain label={b.label}
638 dimColor={!canPage(shown.length, room, rowStart, sorted, b.dir)} onPress={() => moveRows($, b.dir)} />
639 ))}
640 </Box>
641 <Box width={pagingSide} justifyContent="flex-end" gap={1}>
642 <Box key="page-label"><Text dimColor wrap="truncate-end">{`rows ${win.from + 1}–${win.to} of ${shown.length}`}</Text></Box>
643 <Text dimColor>↓</Text>
644 </Box>
645 </Box>
646 )}
647 {visible.map(r => (
648 <Box key={`row-${r.id}`} flexDirection="row" gap={1}>
649 {paneRowCells(r, width, withPlugins, tokens.get(r.id), pal).map((c, i) => (
650 <Box key={`${r.id}-${i}`} width={c.width} flexShrink={0} justifyContent={c.isRight ? 'flex-end' : 'flex-start'}>
651 <Text color={c.color} bold={c.bold} wrap="truncate-end">{c.text}</Text>
652 </Box>
653 ))}
654 </Box>
655 ))}
656 {/* The free space: the totals and the nav row stay at the bottom of the pane. */}
657 <Box key="footer-spacer" flexGrow={1} />
658 {turnRows.length > 0 && total !== undefined && rule('rule-total')}
659 {turnRows.length > 0 && total !== undefined && (
660 <Box flexDirection="column">
661 {total.flatMap((line, n) => [
662 // A short rule sets the subagents' block apart from the main loop's.
663 ...(n === agentsAt && n > 0 ? [rule('agents-rule', Math.min(24, ruleWidth), '╌')] : []),
664 // The lead (`Total:` or the indent) in a box of the kind column's width, so the
665 // figures start under the name column on any surface.
666 (() => {
667 const [lead, ...rest] = paneTotalSegments(line, width, n === 0, pal, e.surface === 'terminal')
668 return (
669 <Box key={`total-${n}`} flexDirection="row">
670 <Box key={`total-${n}-lead`} width={NAME_COLUMN} flexShrink={0}>
671 <Text color={lead?.color} bold={lead?.bold}>{lead?.text.trim() ?? ''}</Text>
672 </Box>
673 {/* The figures take the room up to the margin and are cut at its edge. */}
674 <Box key={`total-${n}-figures`} width={paneTotalRoom(width)} flexShrink={1}>
675 <Text wrap="truncate-end">
676 {rest.map((s, i) => <Text key={`total-${n}-${i}`} color={s.color} bold={s.bold}>{s.text}</Text>)}
677 </Text>
678 </Box>
679 </Box>
680 )
681 })(),
682 ])}
683 </Box>
684 )}
685 {turnRows.length > 0 && isFiltered && <Box key="shown"><Text dimColor>{`${shown.length} of ${turnRows.length} rows shown`}</Text></Box>}
686 {turnRows.length > 0 && total !== undefined && rule('rule-nav')}
687 <Box key="nav" flexDirection="row" gap={1} width={ruleWidth} justifyContent="center">
688 {NAV.map(b => {
689 const can = canNav(turns, view.pinnedTurnId, b.to)
690 return <Button key={`nav-${b.to}`} hotkey={b.hotkey} plain label={b.label} dimColor={!can}
691 onPress={() => navigate($, b.to)} />
692 })}
693 <Box key="nav-label"><Text dimColor wrap="truncate-end">{navLabel(turns, view.pinnedTurnId)}</Text></Box>
694 </Box>
695 </Box>
696 )
697 })
698}
699hooks/args.ts 79 lines1import type { Category, Selection } from '../types'
2
3export const CATEGORIES: readonly Category[] = [
4 'tools', 'mcp', 'skills', 'hooks', 'agents', 'commands', 'context', 'model',
5]
6
7export const USAGE =
8 'Usage: /xtrace [all | tools mcp skills hooks agents commands context model] [session] | /xtrace [categories] pane | /xtrace pane [on|off]'
9
10export type ParsedArgs =
11 | { mode: 'menu' }
12 | { mode: 'report'; selection: Selection }
13 | { mode: 'pane'; pane: 'on' | 'off' | 'toggle'; kinds?: Category[] | 'all' }
14 | { mode: 'error'; message: string; selection?: undefined }
15
16const isCategory = (word: string): word is Category =>
17 (CATEGORIES as readonly string[]).includes(word)
18
19const error = (message: string): ParsedArgs => ({ mode: 'error', message: `${message} ${USAGE}` })
20
21// `pane` anywhere: on, off or a toggle; categories with it open the pane filtered to them.
22function parsePane(words: string[]): ParsedArgs {
23 if (words.filter(w => w === 'pane').length > 1) return error('Name "pane" once.')
24 let pane: 'on' | 'off' | undefined
25 let all = false
26 const picked = new Set<Category>()
27 for (const word of words) {
28 if (word === 'pane') continue
29 if (word === 'on' || word === 'off') {
30 if (pane !== undefined) return error(`Unknown pane option "${word}".`)
31 pane = word
32 } else if (word === 'all') all = true
33 else if (word === 'session') return error('The pane shows one turn; "session" is for the report.')
34 else if (isCategory(word)) picked.add(word)
35 else return error(`Unknown word "${word}".`)
36 }
37 if (!all && picked.size === 0) return { mode: 'pane', pane: pane ?? 'toggle' }
38 if (pane === 'off') return error('"pane off" takes no categories.')
39 return { mode: 'pane', pane: 'on', kinds: all ? 'all' : CATEGORIES.filter(c => picked.has(c)) }
40}
41
42export function parseArgs(text: string): ParsedArgs {
43 const words = text.trim().toLowerCase().split(/\s+/).filter(Boolean)
44 if (words.length === 0) return { mode: 'menu' }
45 if (words.includes('pane')) return parsePane(words)
46
47 const picked = new Set<Category>()
48 let scope: Selection['scope'] = 'turn'
49 for (const word of words) {
50 if (word === 'all') CATEGORIES.forEach(c => picked.add(c))
51 else if (word === 'session') scope = 'session'
52 else if (isCategory(word)) picked.add(word)
53 else return error(`Unknown word "${word}".`)
54 }
55 if (picked.size === 0) return error('Name at least one category.')
56 return {
57 mode: 'report',
58 selection: { categories: CATEGORIES.filter(c => picked.has(c)), scope },
59 }
60}
61
62export function wordsOf(selection: Selection): string {
63 const isAll = CATEGORIES.every(c => selection.categories.includes(c))
64 const words = isAll ? ['all'] : CATEGORIES.filter(c => selection.categories.includes(c))
65 if (selection.scope === 'session') words.push('session')
66 return words.join(' ')
67}
68
69// The categories before `known` was stored: a selection without it knew these.
70const FIRST_CATEGORIES: readonly Category[] = ['tools', 'mcp', 'skills', 'hooks', 'agents', 'commands', 'context']
71
72// A category added after the selection was saved starts switched on; one the
73// person switched off stays off.
74export function withNewCategories(s: Selection): Selection {
75 const known = s.known ?? FIRST_CATEGORIES
76 const added = CATEGORIES.filter(c => !known.includes(c))
77 return { ...s, categories: CATEGORIES.filter(c => s.categories.includes(c) || added.includes(c)), known: [...CATEGORIES] }
78}
79hooks/report.ts 506 lines1import type { Category, Compaction, Row, Selection, Turn, Usage } from '../types'
2
3const TARGET_MAX = 80
4const FILE_TOOLS = new Set(['Read', 'Edit', 'Write', 'NotebookEdit'])
5const EDIT_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit'])
6const CHECK_RUNNERS = ['go test', 'npm test', 'pnpm test', 'yarn test', 'pytest', 'gradle', './gradlew', 'mvn', 'cargo test', 'make']
7
8const str = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined)
9
10export function trim(text: string, max = TARGET_MAX): string {
11 const one = text.replace(/\s+/g, ' ').trim()
12 return one.length <= max ? one : one.slice(0, max - 1) + '…'
13}
14
15// What auto mode's denial reason names: its leading `[Category]`, else the reason itself, shortened.
16export function denialCategory(reason: string): string | undefined {
17 const text = reason.trim()
18 if (text === '') return undefined
19 return /^\[([^\]]+)\]/.exec(text)?.[1] ?? firstWords(text, 7)
20}
21
22export function firstWords(text: string, n = 8): string {
23 const words = text.trim().split(/\s+/).filter(Boolean)
24 return words.length <= n ? words.join(' ') : words.slice(0, n).join(' ') + '…'
25}
26
27export function targetOf(tool: string, args: Record<string, unknown>): string {
28 const path = str(args.file_path)
29 if (FILE_TOOLS.has(tool) && path !== undefined) {
30 const offset = typeof args.offset === 'number' ? args.offset : undefined
31 const limit = typeof args.limit === 'number' ? args.limit : undefined
32 if (offset !== undefined && limit !== undefined) return trim(`${path} ${offset}–${offset + limit - 1}`)
33 return trim(path)
34 }
35 if (tool === 'Grep' || tool === 'Glob') {
36 const pattern = str(args.pattern) ?? ''
37 const where = str(args.path)
38 return trim(where ? `\`${pattern}\` in ${where}` : `\`${pattern}\``)
39 }
40 if (tool === 'Bash') return trim(str(args.command) ?? '')
41 if (tool === 'Agent') return trim(`${str(args.subagent_type) ?? 'general-purpose'}: ${str(args.description) ?? ''}`)
42 if (tool === 'Skill') return trim(str(args.skill) ?? '')
43 for (const value of Object.values(args)) {
44 const s = str(value)
45 if (s !== undefined) return trim(s)
46 }
47 return ''
48}
49
50// --- Tokens -----------------------------------------------------------------
51
52export function fmtTokens(n: number): string {
53 return n < 1000 ? String(Math.round(n)) : `${(n / 1000).toFixed(1)}k`
54}
55
56// Everything the request was answered over: uncached, read from cache, written to it.
57export const contextOf = (u: Usage): number => u.input + u.cacheRead + u.cacheWrite
58
59export function usageText(u: Usage): string {
60 return `${u.model} · in ${fmtTokens(u.input)} · cache ${fmtTokens(u.cacheRead)} · write ${fmtTokens(u.cacheWrite)} · out ${fmtTokens(u.output)}`
61}
62
63// What each row added to the context. Model rows: their request's output,
64// exact. Other rows with text: about chars / 4, then corrected per loop: the
65// growth between two requests (next context − this context − this output)
66// is split over the rows recorded between them by their share of the text.
67export type TokenValue = { n: number; exact: boolean }
68
69export function tokenValues(rows: Row[]): Map<string, TokenValue> {
70 const out = new Map<string, TokenValue>()
71 const groups = new Map<string, Row[]>()
72 for (const r of rows) {
73 if (r.kind === 'model' && r.usage !== undefined) out.set(r.id, { n: r.usage.output, exact: true }) // what it wrote; stays in the context
74 else if (r.chars !== undefined && r.chars > 0) out.set(r.id, { n: Math.ceil(r.chars / 4), exact: false })
75 const key = `${r.turnId}|${r.agentId ?? ''}`
76 groups.set(key, [...(groups.get(key) ?? []), r])
77 }
78 for (const group of groups.values()) {
79 const sorted = [...group].sort((a, b) => a.startedAt - b.startedAt)
80 const steps = sorted.filter(r => r.kind === 'model' && r.usage !== undefined)
81 for (let i = 0; i + 1 < steps.length; i++) {
82 const a = steps[i]
83 const b = steps[i + 1]
84 if (a?.usage === undefined || b?.usage === undefined) continue
85 const grown = contextOf(b.usage) - contextOf(a.usage) - a.usage.output
86 const between = sorted.filter(r => r.kind !== 'model' && r.startedAt > a.startedAt && r.startedAt < b.startedAt && (r.chars ?? 0) > 0)
87 const chars = between.reduce((sum, r) => sum + (r.chars ?? 0), 0)
88 if (grown <= 0 || chars === 0) continue
89 for (const r of between) out.set(r.id, { n: (grown * (r.chars ?? 0)) / chars, exact: false })
90 }
91 }
92 return out
93}
94
95// The same values as the pane and the report print them: `+310`, `~1.0k`.
96export function tokensOf(rows: Row[]): Map<string, string> {
97 return new Map([...tokenValues(rows)].map(([id, v]) => [id, `${v.exact ? '+' : '~'}${fmtTokens(v.n)}`]))
98}
99
100// --- Cost -------------------------------------------------------------------
101
102// First-party API list prices, $ per million tokens (claude-api skill, cached 2026-10-06):
103// input, output, cache read. A cache write is priced at the 1-hour rate, 2x input:
104// the usage a mod sees does not split 5-minute from 1-hour writes, and Claude Code
105// writes 1-hour entries, so the figure is an estimate (shown with ≈).
106type Price = { input: number; output: number; cacheRead: number }
107const PRICES: Record<string, Price> = {
108 'claude-fable-5-1': { input: 10, output: 50, cacheRead: 0.25 },
109 'claude-mythos-5-1': { input: 10, output: 50, cacheRead: 0.25 },
110 'claude-fable-5': { input: 10, output: 50, cacheRead: 1 },
111 'claude-mythos-5': { input: 10, output: 50, cacheRead: 1 },
112 'claude-opus-5-5': { input: 4, output: 20, cacheRead: 0.2 },
113 'claude-opus-5': { input: 5, output: 25, cacheRead: 0.5 },
114 'claude-opus-4-8': { input: 5, output: 25, cacheRead: 0.5 },
115 'claude-opus-4-7': { input: 5, output: 25, cacheRead: 0.5 },
116 'claude-opus-4-6': { input: 5, output: 25, cacheRead: 0.5 },
117 'claude-sonnet-5-5': { input: 2, output: 10, cacheRead: 0.2 },
118 'claude-sonnet-5': { input: 2, output: 10, cacheRead: 0.2 },
119 'claude-sonnet-4-6': { input: 3, output: 15, cacheRead: 0.3 },
120 'claude-haiku-5-5': { input: 0.1, output: 0.5, cacheRead: 0.01 },
121 'claude-haiku-4-5': { input: 1, output: 5, cacheRead: 0.1 },
122}
123// Haiku 5.5 is priced higher for prompts over 100K tokens.
124const HAIKU_LONG: Price = { input: 0.5, output: 2.5, cacheRead: 0.05 }
125const CACHE_WRITE = 2
126
127// The longest known name the id contains: `claude-opus-5-5[1m]`, `us.anthropic.claude-sonnet-5-5`.
128function priceOf(u: Usage): Price | undefined {
129 const name = Object.keys(PRICES).filter(k => u.model.includes(k)).sort((a, b) => b.length - a.length)[0]
130 if (name === undefined) return undefined
131 return name === 'claude-haiku-5-5' && contextOf(u) > 100_000 ? HAIKU_LONG : PRICES[name]
132}
133
134export function costOf(u: Usage): number | undefined {
135 const p = priceOf(u)
136 if (p === undefined) return undefined
137 return (u.input * p.input + u.cacheWrite * p.input * CACHE_WRITE + u.cacheRead * p.cacheRead + u.output * p.output) / 1e6
138}
139
140export const fmtCost = (usd: number): string => (usd < 0.01 ? '≈<$0.01' : `≈$${usd.toFixed(2)}`)
141
142// One line per loop for the report: requests, context growth, output, cache split.
143type LoopTotals = { requests: number; from: number; to: number; output: number; cacheRead: number; input: number; cacheWrite: number; cost: number; isPartial: boolean }
144
145// The main loop's (sub false) or the subagents' (sub true) model requests, summed.
146function loopTotals(rows: Row[], sub: boolean): LoopTotals | undefined {
147 const loop = rows
148 .filter(r => r.kind === 'model' && r.usage !== undefined && (r.agentId !== undefined) === sub)
149 .sort((a, b) => a.startedAt - b.startedAt)
150 const first = loop[0]?.usage
151 const last = loop.at(-1)?.usage
152 if (first === undefined || last === undefined) return undefined
153 const sum = (f: (u: Usage) => number) => loop.reduce((n, r) => n + (r.usage ? f(r.usage) : 0), 0)
154 return {
155 requests: loop.length, from: contextOf(first), to: contextOf(last),
156 output: sum(u => u.output), cacheRead: sum(u => u.cacheRead), input: sum(u => u.input), cacheWrite: sum(u => u.cacheWrite),
157 cost: sum(u => costOf(u) ?? 0), isPartial: loop.some(r => r.usage !== undefined && costOf(r.usage) === undefined),
158 }
159}
160
161// The spend follows the context: what the loop cost at list price, `+ ?` when a model has no price.
162const growthText = (t: LoopTotals) =>
163 `${t.requests} request(s) · context +${fmtTokens(t.to - t.from)} (${fmtTokens(t.from)} → ${fmtTokens(t.to)}) · ${spendText(t)}`
164const spendText = (t: LoopTotals) =>
165 t.isPartial && t.cost === 0 ? '≈$?' : `${fmtCost(t.cost)}${t.isPartial ? ' + ?' : ''}`
166const splitText = (t: LoopTotals) =>
167 `output ${fmtTokens(t.output)} · cache read ${fmtTokens(t.cacheRead)} · uncached ${fmtTokens(t.input)} · cache write ${fmtTokens(t.cacheWrite)}`
168
169function loopSummary(rows: Row[], sub: boolean): string | undefined {
170 const t = loopTotals(rows, sub)
171 return t === undefined ? undefined : `${growthText(t)} · ${splitText(t)}`
172}
173
174// One line per loop for the report: requests, context growth, output, cache split.
175export function tokenTotals(rows: Row[]): string[] {
176 const lines: string[] = []
177 const main = loopSummary(rows, false)
178 const sub = loopSummary(rows, true)
179 if (main !== undefined) lines.push(`Tokens: ${main}`)
180 if (sub !== undefined) lines.push(`Subagent tokens: ${sub}`)
181 return lines
182}
183
184export function fmtDuration(ms: number): string {
185 if (ms < 1000) return `${Math.round(ms)}ms`
186 if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`
187 const s = Math.round(ms / 1000)
188 return `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, '0')}s`
189}
190
191// One prompt's total, in lines short enough for a half-screen pane:
192// Total: <wall time> · <requests> · context <first> → <last> (+growth)
193// output … · cache read … · uncached … · cache write …
194// subagents: … (when subagents ran)
195// Wall time runs from the prompt to the answer, or to now while it runs.
196export function turnTotalLines(turn: Turn, rows: Row[], now: number): string[] {
197 const own = rows.filter(r => r.turnId === turn.turnId)
198 const lastEnd = Math.max(turn.startedAt, ...own.map(r => r.startedAt + (r.ms ?? 0)))
199 const end = turn.completedAt ?? (turn.isComplete ? lastEnd : now)
200 const time = `${fmtDuration(end - turn.startedAt)}${turn.isComplete ? '' : ' so far'}`
201 const main = loopTotals(own, false)
202 const sub = loopTotals(own, true)
203 const lines = [`Total: ${time} · ${main ? growthText(main) : 'no model requests recorded'}`]
204 if (main) lines.push(splitText(main))
205 if (sub) lines.push(`subagents: ${growthText(sub)}`, splitText(sub))
206 return lines
207}
208
209// The same on one line, for the report.
210export function turnTotal(turn: Turn, rows: Row[], now: number): string {
211 return turnTotalLines(turn, rows, now).join(' · ')
212}
213
214// Puts a hook's appended context on the latest hooks row of that event in the
215// turn (named `Event` or `Event Tool`). Undefined when there is no such row.
216export function attachHookContext(rows: Row[], turnId: string, event: string, chars: number): Row[] | undefined {
217 let at = -1
218 rows.forEach((r, i) => {
219 if (r.kind === 'hooks' && r.turnId === turnId && (r.name === event || r.name.startsWith(`${event} `))) at = i
220 })
221 if (at < 0) return undefined
222 return rows.map((r, i) => (i === at ? { ...r, chars: (r.chars ?? 0) + chars, outcome: 'context' as const } : r))
223}
224
225export function scopedTurns(turns: Turn[], scope: Selection['scope']): Turn[] {
226 if (scope === 'session') return turns
227 return turns.slice(-1)
228}
229
230const cell = (text: string | number | undefined): string => String(text ?? '').replace(/\|/g, '\\|')
231
232function orderRows(rows: Row[], withAgents: boolean): { row: Row; indent: boolean }[] {
233 const byParent = new Map<string, Row[]>()
234 const top: Row[] = []
235 for (const r of rows) {
236 if (withAgents && r.parentId !== undefined && rows.some(p => p.id === r.parentId)) {
237 const list = byParent.get(r.parentId) ?? []
238 list.push(r)
239 byParent.set(r.parentId, list)
240 } else top.push(r)
241 }
242 const out: { row: Row; indent: boolean }[] = []
243 for (const r of top) {
244 out.push({ row: r, indent: false })
245 for (const child of byParent.get(r.id) ?? []) out.push({ row: child, indent: true })
246 }
247 return out
248}
249
250function nameOf(row: Row, rows: Row[], withAgents: boolean, indent: boolean): string {
251 if (indent) return ` ${row.name}`
252 if (!withAgents && row.parentId !== undefined) {
253 const parent = rows.find(p => p.id === row.parentId)
254 if (parent) return `${row.name} (${parent.name})`
255 }
256 return row.name
257}
258
259export type ReportInput = { rows: Row[]; turns: Turn[]; compactions: Compaction[]; selection: Selection; now?: number }
260
261export function buildReport({ rows, turns, compactions, selection, now = Date.now() }: ReportInput): string {
262 const scope = scopedTurns(turns, selection.scope)
263 const turnIds = new Set(scope.map(t => t.turnId))
264 const picked = new Set<Category>(selection.categories)
265 const all = rows.filter(r => turnIds.has(r.turnId))
266 const shown = all.filter(r => picked.has(r.kind)).sort((a, b) => a.startedAt - b.startedAt)
267
268 if (shown.length === 0) {
269 return selection.scope === 'turn'
270 ? 'xtrace: nothing recorded for the last turn.'
271 : 'xtrace: nothing recorded for this session.'
272 }
273
274 const lines: string[] = []
275 lines.push(`xtrace: ${scope.length} turn(s), ${shown.length} rows, categories: ${selection.categories.join(', ')}`)
276 const compacted = compactions.filter(c => turnIds.has(c.turnId)).at(-1)
277 if (compacted !== undefined) {
278 const at = new Date(compacted.at).toISOString().slice(11, 19)
279 lines.push(`Memory starts after a compaction at ${at}; earlier turns are not recorded.`)
280 }
281
282 const withAgents = picked.has('agents')
283 const withPlugins = picked.has('hooks')
284 const tokens = tokensOf(all)
285 let n = 0
286 for (const turn of scope) {
287 const ofTurn = shown.filter(r => r.turnId === turn.turnId)
288 if (ofTurn.length === 0) continue
289 lines.push('', `"${firstWords(turn.text)}"`, '')
290 lines.push(withPlugins
291 ? '| # | Kind | Name | Target | Plugins | Outcome | ms | Tokens |'
292 : '| # | Kind | Name | Target | Outcome | ms | Tokens |')
293 lines.push(withPlugins ? '| --- | --- | --- | --- | --- | --- | --- | --- |' : '| --- | --- | --- | --- | --- | --- | --- |')
294 for (const { row, indent } of orderRows(ofTurn, withAgents)) {
295 n += 1
296 const plugins = withPlugins ? ` ${cell(row.plugins)} |` : ''
297 lines.push(`| ${n} | ${row.kind} | ${cell(nameOf(row, all, withAgents, indent))} | ${cell(row.target)} |${plugins} ${row.outcome} | ${cell(row.ms)} | ${cell(tokens.get(row.id))} |`)
298 }
299 lines.push('', turnTotal(turn, all, now))
300 }
301
302 lines.push('')
303 for (const c of selection.categories) {
304 lines.push(`${c}: ${shown.filter(r => r.kind === c).length}`)
305 }
306 const toolRows = shown.filter(r => r.kind === 'tools')
307 const files = new Set(toolRows.filter(r => FILE_TOOLS.has(r.name)).map(r => r.target.split(' ')[0]))
308 lines.push(`Distinct files: ${files.size}`)
309 const firstEdit = shown.findIndex(r => r.kind === 'tools' && EDIT_TOOLS.has(r.name))
310 if (firstEdit >= 0) lines.push(`Rows before the first edit: ${firstEdit}`)
311 if (scope.length > 1) lines.push(...tokenTotals(all)) // a single turn already has its total under the table
312 const checks = toolRows.filter(r => r.name === 'Bash' && CHECK_RUNNERS.some(c => r.target.startsWith(c))).map(r => r.target)
313 lines.push(checks.length > 0 ? `Checked: ${checks.join('; ')}` : 'Checked: nothing.')
314
315 return lines.join('\n')
316}
317
318// The live pane: fixed-width columns so rows line up; Target takes the room
319// the others leave. Kind, Name, Target, [Plugins,] Outcome, ms.
320const PANE_HEADER = ['kind', 'name', 'target', 'plugins', 'outcome', 'tokens', 'ms']
321
322function paneWidths(width: number, withPlugins: boolean): number[] {
323 const widths = withPlugins ? [8, 20, 0, 14, 7, 7, 6] : [8, 20, 0, 7, 7, 6] // 7 fits context, blocked, running
324 const used = widths.reduce((a, b) => a + b, 0) + widths.length - 1
325 widths[2] = Math.max(8, width - used)
326 return widths
327}
328
329// Columns kept free at the pane's right edge, so right-aligned ms does not touch the border.
330export const PANE_MARGIN = 2
331
332// Each cell fitted to its column: cut with an ellipsis, padded; ms right-aligned.
333function fitCells(cells: string[], paneWidth: number, withPlugins: boolean): string[] {
334 const widths = paneWidths(Math.max(1, paneWidth - PANE_MARGIN), withPlugins)
335 const last = cells.length - 1
336 return cells.map((c, i) => {
337 const w = widths[i] ?? 0
338 return i === last ? trim(c, w).padStart(w) : trim(c, w).padEnd(w)
339 })
340}
341
342function paneCells(cells: string[], paneWidth: number, withPlugins: boolean): string {
343 return fitCells(cells, paneWidth, withPlugins).join(' ').slice(0, Math.max(1, paneWidth - PANE_MARGIN)).trimEnd()
344}
345
346export function paneHeader(width: number, withPlugins: boolean): string {
347 return paneCells(withPlugins ? PANE_HEADER : PANE_HEADER.filter(h => h !== 'plugins'), width, withPlugins)
348}
349
350// The header's cells for the pane's sort buttons, fitted like the rows. A
351// sorted column ends in its mark; where the label fills the column the mark
352// takes its last place, so it is never cut off.
353export function paneHeaderCells(width: number, withPlugins: boolean, marks: Partial<Record<string, string>> = {}): { column: string; text: string }[] {
354 const labels = withPlugins ? PANE_HEADER : PANE_HEADER.filter(h => h !== 'plugins')
355 const widths = paneWidths(Math.max(1, width - PANE_MARGIN), withPlugins)
356 const marked = labels.map((label, i) => {
357 const mark = marks[label] ?? ''
358 const w = widths[i] ?? 0
359 return mark === '' || label.length + mark.length <= w ? label + mark : label.slice(0, Math.max(0, w - mark.length)) + mark
360 })
361 const fitted = fitCells(marked, width, withPlugins)
362 return labels.map((column, i) => ({ column, text: fitted[i] ?? '' }))
363}
364
365export function paneLine(row: Row, width: number, withPlugins: boolean, tokens = ''): string {
366 const ms = row.ms === undefined ? '' : String(row.ms)
367 const cells = withPlugins
368 ? [row.kind, row.name, row.target, row.plugins ?? '', row.outcome, tokens, ms]
369 : [row.kind, row.name, row.target, row.outcome, tokens, ms]
370 return paneCells(cells, width, withPlugins)
371}
372
373// --- Pane colors. Kind and name share one blue, strongest for the kinds that
374// usually cost the most tokens (model, then tools, context, hooks); everything
375// else is a grey step, so only token weight and failures carry warning colors.
376// Greys are hex, so there is one palette for dark themes and one for light.
377
378export type Segment = { text: string; color?: string; bold?: boolean; underline?: boolean }
379
380export type Palette = {
381 kind: Record<Row['kind'], string>
382 label: string; value: string; separator: string
383 target: string; plugins: string; outcome: string; tokens: string
384 ms: [string, string, string, string, string] // under 10, 100, 1k, 10k, and above
385}
386
387// Hex colors: exact in a truecolor terminal. Where Claude Code falls back to
388// 16 colors (no COLORTERM=truecolor) the blues collapse into white and cyan.
389const ramp = (model: string, tools: string, context: string, hooks: string): Palette['kind'] =>
390 ({ model, tools, mcp: tools, skills: tools, agents: tools, commands: tools, context, hooks })
391
392export const DARK: Palette = {
393 kind: ramp('#a3b2ff', '#8391d6', '#6872a6', '#5a628e'),
394 label: '#8a8a8a', value: '#b2b2b2', separator: '#585858',
395 target: '#767676', plugins: '#626262', outcome: '#6c6c6c', tokens: '#b2b2b2',
396 ms: ['#4e4e4e', '#626262', '#767676', '#949494', '#bcbcbc'],
397}
398
399export const LIGHT: Palette = {
400 kind: ramp('#3a4fd6', '#5a68b8', '#7d86b8', '#a3a8c6'),
401 label: '#767676', value: '#4e4e4e', separator: '#b2b2b2',
402 target: '#767676', plugins: '#9e9e9e', outcome: '#8a8a8a', tokens: '#4e4e4e',
403 ms: ['#c6c6c6', '#a8a8a8', '#8a8a8a', '#6c6c6c', '#4e4e4e'],
404}
405
406// The `theme` setting's value: `light`, `light-daltonized`, `light-ansi` are light.
407export const paletteFor = (theme: unknown): Palette => (typeof theme === 'string' && theme.startsWith('light') ? LIGHT : DARK)
408
409export function outcomeStyle(outcome: Row['outcome'], pal: Palette = DARK): Omit<Segment, 'text'> {
410 if (outcome === 'error' || outcome === 'denied' || outcome === 'blocked') return { color: 'error', bold: true }
411 if (outcome === 'running') return { color: 'warning' }
412 return { color: pal.outcome }
413}
414
415// `~2.0k`, `640`, `48.3k` back to a number.
416function tokenCount(text: string): number {
417 const m = /^[~+]?([\d.]+)(k?)$/.exec(text.trim())
418 return m ? Number(m[1]) * (m[2] === 'k' ? 1000 : 1) : 0
419}
420
421// Under 1k a quiet grey; from 1k warning, over 10k error. Model rows too:
422// their number is what the request wrote.
423export function tokenStyle(text: string, pal: Palette = DARK): Omit<Segment, 'text'> {
424 const n = tokenCount(text)
425 if (n > 10000) return { color: 'error', bold: true }
426 if (n >= 1000) return { color: 'warning', bold: true }
427 return { color: pal.tokens }
428}
429
430// One grey step per power of ten, stronger the longer it took.
431export function msStyle(ms: number | undefined, pal: Palette = DARK): Omit<Segment, 'text'> {
432 if (ms === undefined) return {}
433 const step = ms < 10 ? 0 : ms < 100 ? 1 : ms < 1000 ? 2 : ms < 10000 ? 3 : 4
434 return { color: pal.ms[step] }
435}
436
437export function paneSegments(row: Row, width: number, withPlugins: boolean, tokens = '', pal: Palette = DARK): Segment[] {
438 const ms = row.ms === undefined ? '' : String(row.ms)
439 const cells: [string, Omit<Segment, 'text'>][] = [
440 [row.kind, { color: pal.kind[row.kind], bold: true }],
441 [row.name, { color: pal.kind[row.kind] }],
442 [row.target, { color: pal.target }],
443 ...(withPlugins ? [[row.plugins ?? '', { color: pal.plugins }] as [string, Omit<Segment, 'text'>]] : []),
444 [row.outcome, outcomeStyle(row.outcome, pal)],
445 [tokens, tokenStyle(tokens, pal)],
446 [ms, msStyle(row.ms, pal)],
447 ]
448 const fitted = fitCells(cells.map(c => c[0]), width, withPlugins)
449 return fitted.flatMap((text, i) => [...(i > 0 ? [{ text: ' ' }] : []), { text, ...cells[i]?.[1] }])
450}
451
452// The pane's row as cells of their own, each with its column's width: a surface
453// that draws proportional text (the desktop) lines the columns up by the cell
454// boxes, never by padding. Text is cut to the column, never padded.
455export type Cell = Segment & { width: number; isRight: boolean }
456
457export function paneRowCells(row: Row, width: number, withPlugins: boolean, tokens = '', pal: Palette = DARK): Cell[] {
458 const widths = paneWidths(Math.max(1, width - PANE_MARGIN), withPlugins)
459 const segments = paneSegments(row, width, withPlugins, tokens, pal).filter((_, i) => i % 2 === 0)
460 return segments.map((s, i) => ({ ...s, text: s.text.trim(), width: widths[i] ?? 0, isRight: i === segments.length - 1 }))
461}
462
463export function paneHeaderWidths(width: number, withPlugins: boolean): number[] {
464 return paneWidths(Math.max(1, width - PANE_MARGIN), withPlugins)
465}
466
467// Words with a digit (and the arrow between two counts) are values, `·` a
468// separator, the rest labels. Neighbours of one kind merge into one segment.
469export function labelValueSegments(text: string, pal: Palette = DARK): Segment[] {
470 const out: Segment[] = []
471 for (const [i, word] of text.split(' ').entries()) {
472 const color = word === '·' ? pal.separator : /\d|^→$/.test(word) ? pal.value : pal.label
473 const prev = out.at(-1)
474 if (prev !== undefined && prev.color === color) prev.text += ` ${word}`
475 else out.push(...(i > 0 ? [{ text: ' ' }] : []), { text: word, color })
476 }
477 return out.filter(s => s.text !== '')
478}
479
480// The counters line above the table, in the footer's label and value colors.
481export function counterSegments(counts: [string, number][], pal: Palette = DARK): Segment[] {
482 return labelValueSegments(counts.map(([c, n]) => `${c} ${n}`).join(' · '), pal)
483}
484
485// Where the name column starts: the kind column and the space after it.
486export const NAME_COLUMN = paneWidths(80, false)[0]! + 1
487
488// One line of the pane's footer, fitted to the pane. Its figures start under
489// the name column: the first line puts `Total:` in front of them, the others
490// are indented to the same place.
491// The cells the footer's figures have: the pane less its margin and the lead.
492export const paneTotalRoom = (width: number) => Math.max(1, width - PANE_MARGIN - NAME_COLUMN)
493
494// `isCut` false leaves the cutting to the surface: proportional text (the desktop)
495// fits more characters than the room's cells, so a cut by count stops short.
496export function paneTotalSegments(line: string, width: number, isFirst: boolean, pal: Palette = DARK, isCut = true): Segment[] {
497 const room = isCut ? paneTotalRoom(width) : Infinity
498 // `Total:` heads the first line, `Agents:` the subagents' block; the rest are indented.
499 const head = isFirst && line.startsWith('Total:') ? 'Total:' : line.startsWith('subagents:') ? 'subagents:' : undefined
500 const lead: Segment = head !== undefined
501 ? { text: (head === 'Total:' ? 'Total:' : 'Agents:').padEnd(NAME_COLUMN), color: pal.value, bold: true }
502 : { text: ' '.repeat(NAME_COLUMN) }
503 const rest = head !== undefined ? line.slice(head.length).trim() : line
504 return [lead, ...labelValueSegments(trim(rest, room), pal)]
505}
506hooks/hookmatch.ts 68 lines1type HookEntry = { matcher?: unknown; hooks?: unknown }
2type HookCommand = { type?: unknown; command?: unknown }
3
4const INTERPRETERS = new Set(['uv', 'run', 'node', 'bun', 'python', 'python3', 'sh', 'bash', 'zsh', 'npx'])
5
6export function shortName(command: string): string {
7 const words = command.split(/\s+/).map(w => w.replace(/^["']|["']$/g, '')).filter(Boolean)
8 const word = words.find(w => !w.startsWith('-') && !INTERPRETERS.has(w)) ?? ''
9 return word.split('/').pop() ?? ''
10}
11
12function matches(matcher: unknown, toolName: string | undefined): boolean {
13 if (toolName === undefined) return true
14 if (typeof matcher !== 'string' || matcher === '' || matcher === '*') return true
15 try {
16 return new RegExp(`^(?:${matcher})$`).test(toolName)
17 } catch {
18 return false
19 }
20}
21
22export function commandsFor(settings: unknown, event: string, toolName?: string): string[] {
23 const hooks = (settings as { hooks?: unknown } | undefined)?.hooks
24 if (hooks === null || typeof hooks !== 'object') return []
25 const entries = (hooks as Record<string, unknown>)[event]
26 if (!Array.isArray(entries)) return []
27
28 const names: string[] = []
29 for (const entry of entries as HookEntry[]) {
30 if (!matches(entry.matcher, toolName)) continue
31 if (!Array.isArray(entry.hooks)) continue
32 for (const hook of entry.hooks as HookCommand[]) {
33 if (typeof hook.command === 'string') names.push(shortName(hook.command))
34 }
35 }
36 return names
37}
38
39// A plugin's hooks/hooks.json, which has the same `hooks` shape as settings.
40export type PluginHooks = { name: string; config: unknown }
41
42type Install = { scope?: unknown; projectPath?: unknown; installPath?: unknown }
43
44// The enabled plugins (settings.enabledPlugins) and where each is installed
45// (installed_plugins.json). A plugin installed for several projects takes the
46// install for `cwd`, else the user-scope one, else the first.
47export function enabledInstalls(settings: unknown, installed: unknown, cwd?: string): { name: string; installPath: string }[] {
48 const enabled = (settings as { enabledPlugins?: unknown } | undefined)?.enabledPlugins
49 const plugins = (installed as { plugins?: unknown } | undefined)?.plugins
50 if (enabled === null || typeof enabled !== 'object' || plugins === null || typeof plugins !== 'object') return []
51
52 const out: { name: string; installPath: string }[] = []
53 for (const [id, on] of Object.entries(enabled as Record<string, unknown>)) {
54 if (on !== true) continue
55 const installs = (plugins as Record<string, unknown>)[id]
56 if (!Array.isArray(installs)) continue
57 const list = installs as Install[]
58 const pick = list.find(i => cwd !== undefined && i.projectPath === cwd) ?? list.find(i => i.scope === 'user') ?? list[0]
59 if (typeof pick?.installPath === 'string') out.push({ name: id.split('@')[0] ?? id, installPath: pick.installPath })
60 }
61 return out
62}
63
64// The plugins with a command hook for this event and tool.
65export function pluginsFor(plugins: PluginHooks[], event: string, toolName?: string): string[] {
66 return plugins.filter(p => commandsFor(p.config, event, toolName).length > 0).map(p => p.name)
67}
68hooks/view.ts 257 lines1import type { Category, Row, Turn } from '../types'
2
3// The live pane's view: the filter bar's language and sorting (this task),
4// which turn is shown (the navigation helpers below). Pure, no `$`.
5
6export type Column = 'kind' | 'name' | 'target' | 'plugins' | 'outcome' | 'tokens' | 'ms'
7export const COLUMNS: readonly Column[] = ['kind', 'name', 'target', 'plugins', 'outcome', 'tokens', 'ms']
8export type Sort = { column: Column; desc: boolean }
9export type Match = (row: Row, tokens: number | undefined) => boolean
10export type ParsedFilter = { match: Match; sort?: Sort; errors: string[] }
11
12// One table for the placeholder, the cheat sheet and the tests, so the help
13// cannot drift from the parser.
14export const EXAMPLES: readonly { text: string; meaning: string }[] = [
15 { text: 'outcome:!ok', meaning: 'everything that did not succeed' },
16 { text: 'kind:tools sort:-ms', meaning: 'tool calls, slowest first' },
17 { text: 'tokens:>5k', meaning: 'rows that added more than 5k tokens' },
18 { text: 'kind:tools,mcp name:bash', meaning: 'tool and MCP calls named like bash' },
19 { text: 'ms:100..900 cache', meaning: '100 to 900 ms, "cache" in name or target' },
20]
21
22export const PLACEHOLDER = `try: ${EXAMPLES.slice(0, 3).map(x => x.text).join(' · ')}`
23
24const COLUMN_LIST = `columns: ${COLUMNS.join(' ')}`
25const isColumn = (s: string): s is Column => (COLUMNS as readonly string[]).includes(s)
26const isNumeric = (c: Column): c is 'ms' | 'tokens' => c === 'ms' || c === 'tokens'
27
28function cellText(row: Row, column: Column): string {
29 switch (column) {
30 case 'kind': return row.kind
31 case 'name': return row.name
32 case 'target': return row.target
33 case 'plugins': return row.plugins ?? ''
34 case 'outcome': return row.outcome
35 default: return ''
36 }
37}
38
39const cellNumber = (row: Row, column: 'ms' | 'tokens', tokens: number | undefined): number | undefined =>
40 column === 'ms' ? row.ms : tokens
41
42// `500`, `5k`, `1.5m`.
43function parseNumber(s: string): number | undefined {
44 const m = /^(\d+(?:\.\d+)?)([km]?)$/i.exec(s)
45 if (m === null) return undefined
46 const unit = (m[2] ?? '').toLowerCase()
47 return Number(m[1]) * (unit === 'k' ? 1e3 : unit === 'm' ? 1e6 : 1)
48}
49
50// `>500`, `<=50`, `=3`, `3`, `100..900` (inclusive).
51function numericTest(value: string): ((n: number) => boolean) | undefined {
52 const range = /^(.+)\.\.(.+)$/.exec(value)
53 if (range !== null) {
54 const lo = parseNumber(range[1] ?? '')
55 const hi = parseNumber(range[2] ?? '')
56 return lo === undefined || hi === undefined ? undefined : n => n >= lo && n <= hi
57 }
58 const m = /^(>=|<=|>|<|=)?(.*)$/.exec(value)
59 const x = parseNumber(m?.[2] ?? '')
60 if (x === undefined) return undefined
61 switch (m?.[1]) {
62 case '>': return n => n > x
63 case '<': return n => n < x
64 case '>=': return n => n >= x
65 case '<=': return n => n <= x
66 default: return n => n === x
67 }
68}
69
70// One `column:value` term as a match, or the words saying why it is not one.
71function termOf(column: Column, raw: string): Match | string {
72 if (raw === '') return `no value for "${column}:"`
73 if (isNumeric(column)) {
74 const test = numericTest(raw)
75 if (test === undefined) return `not a number in "${column}:${raw}" (try >500, <5k, 100..900)`
76 return (row, tokens) => {
77 const n = cellNumber(row, column, tokens)
78 return n !== undefined && test(n)
79 }
80 }
81 const negate = raw.startsWith('!')
82 const value = (negate ? raw.slice(1) : raw).toLowerCase()
83 if (value === '') return `no value after "!" in "${column}:${raw}"`
84 // kind and outcome are short closed sets: exact, a comma list ORs. The rest: substring.
85 const wanted = value.split(',').filter(Boolean)
86 if ((column === 'kind' || column === 'outcome') && wanted.length === 0) return `no value for "${column}:${raw}"`
87 const hit = column === 'kind' || column === 'outcome'
88 ? (row: Row) => wanted.includes(cellText(row, column).toLowerCase())
89 : (row: Row) => cellText(row, column).toLowerCase().includes(value)
90 return negate ? row => !hit(row) : row => hit(row)
91}
92
93export function parseFilter(text: string): ParsedFilter {
94 const matches: Match[] = []
95 const errors: string[] = []
96 let sort: Sort | undefined
97 for (const term of text.trim().split(/\s+/).filter(Boolean)) {
98 const colon = term.indexOf(':')
99 if (colon < 0) {
100 const word = term.toLowerCase()
101 matches.push(row => row.name.toLowerCase().includes(word) || row.target.toLowerCase().includes(word))
102 continue
103 }
104 const key = term.slice(0, colon).toLowerCase()
105 const value = term.slice(colon + 1)
106 if (key === 'sort') {
107 const desc = value.startsWith('-')
108 const column = (desc ? value.slice(1) : value).toLowerCase()
109 if (isColumn(column)) sort = { column, desc }
110 else errors.push(`unknown sort column "${column}"; ${COLUMN_LIST}`)
111 continue
112 }
113 if (key === '') { errors.push(`no column before ":" in "${term}"`); continue }
114 if (!isColumn(key)) { errors.push(`unknown column "${key}"; ${COLUMN_LIST}`); continue }
115 const m = termOf(key, value)
116 if (typeof m === 'string') errors.push(m)
117 else matches.push(m)
118 }
119 return { match: (row, tokens) => matches.every(m => m(row, tokens)), sort, errors }
120}
121
122function compare(a: Row, b: Row, sort: Sort, tokens: Map<string, number>): number {
123 if (isNumeric(sort.column)) {
124 const x = cellNumber(a, sort.column, tokens.get(a.id))
125 const y = cellNumber(b, sort.column, tokens.get(b.id))
126 if (x === undefined || y === undefined) return x === y ? 0 : x === undefined ? 1 : -1 // no value: last, either way
127 return sort.desc ? y - x : x - y
128 }
129 const x = cellText(a, sort.column).toLowerCase()
130 const y = cellText(b, sort.column).toLowerCase()
131 const c = x < y ? -1 : x > y ? 1 : 0
132 return sort.desc ? -c : c
133}
134
135// Filter, then sort; Array.prototype.sort is stable, so ties keep recording order.
136export function applyView(rows: Row[], parsed: ParsedFilter, tokens: Map<string, number>): Row[] {
137 const kept = rows.filter(r => parsed.match(r, tokens.get(r.id)))
138 const sort = parsed.sort
139 return sort === undefined ? kept : [...kept].sort((a, b) => compare(a, b, sort, tokens))
140}
141
142const isSortTerm = (t: string) => t.toLowerCase().startsWith('sort:')
143
144// A header click: that column ascending, then descending, then unsorted. The
145// sort term is rewritten where the last one stood, so the bar reads as typed.
146export function withSort(text: string, column: Column): string {
147 const terms = text.trim().split(/\s+/).filter(Boolean)
148 const at = terms.findLastIndex(isSortTerm)
149 const current = at >= 0 ? parseFilter(terms[at] ?? '').sort : undefined
150 const next = current?.column !== column ? `sort:${column}` : current.desc ? undefined : `sort:-${column}`
151 const kept = terms.filter(t => !isSortTerm(t))
152 if (next !== undefined) {
153 const place = at >= 0 ? terms.slice(0, at).filter(t => !isSortTerm(t)).length : kept.length
154 kept.splice(place, 0, next)
155 }
156 return kept.join(' ')
157}
158
159const isKindTerm = (t: string) => t.toLowerCase().startsWith('kind:')
160
161// `/xtrace tools mcp pane`: the kinds lead the bar, replacing any kind term; `all` drops it.
162export function withKinds(text: string, kinds: readonly Category[] | 'all'): string {
163 const kept = text.trim().split(/\s+/).filter(t => t !== '' && !isKindTerm(t))
164 return (kinds === 'all' ? kept : [`kind:${kinds.join(',')}`, ...kept]).join(' ')
165}
166
167export const sortMark = (sort: Sort | undefined, column: Column): string =>
168 sort?.column !== column ? '' : sort.desc ? '▼' : '▲'
169
170// --- Which turn the pane shows. A pin is a turnId, not an index: TURNS is
171// capped, so indexes shift as old turns drop. No pin follows the latest turn.
172
173export type Nav = 'first' | 'prev' | 'next' | 'latest'
174
175export function viewedTurn(turns: Turn[], pinnedTurnId?: string): { turn?: Turn; index: number; isLive: boolean } {
176 const isLive = pinnedTurnId === undefined
177 if (turns.length === 0) return { turn: undefined, index: -1, isLive }
178 if (isLive) return { turn: turns.at(-1), index: turns.length - 1, isLive }
179 const at = turns.findIndex(t => t.turnId === pinnedTurnId)
180 const index = at >= 0 ? at : 0 // evicted: the oldest turn kept
181 return { turn: turns[index], index, isLive }
182}
183
184function navIndex(turns: Turn[], pinnedTurnId: string | undefined, to: Nav): number {
185 const { index } = viewedTurn(turns, pinnedTurnId)
186 const last = turns.length - 1
187 switch (to) {
188 case 'first': return 0
189 case 'prev': return Math.max(0, index - 1)
190 case 'next': return Math.min(last, index + 1)
191 default: return last
192 }
193}
194
195export function canNav(turns: Turn[], pinnedTurnId: string | undefined, to: Nav): boolean {
196 return turns.length > 1 && navIndex(turns, pinnedTurnId, to) !== viewedTurn(turns, pinnedTurnId).index
197}
198
199// The pin after moving; onto the latest turn means following live again.
200export function navTarget(turns: Turn[], pinnedTurnId: string | undefined, to: Nav): string | undefined {
201 if (turns.length === 0) return undefined
202 const i = navIndex(turns, pinnedTurnId, to)
203 return i >= turns.length - 1 ? undefined : turns[i]?.turnId
204}
205
206// Position only; the turn's prompt has its own line above the header.
207export function navLabel(turns: Turn[], pinnedTurnId?: string): string {
208 const { turn, index, isLive } = viewedTurn(turns, pinnedTurnId)
209 if (turn === undefined) return 'no turns yet'
210 const live = !isLive && index < turns.length - 1 ? ` · ● live: ${turns.length}` : ''
211 return `turn ${index + 1}/${turns.length}${live}`
212}
213
214// A turn the person did not type (a subagent's hand-back, a task notification)
215// starts with a tag: name it by the tag, and by its `from` when it has one.
216export function turnTitle(text: string): string {
217 const trimmed = text.trim()
218 if (trimmed === '') return '[no prompt]'
219 const tag = /^<([a-z][\w-]*)([^>]*)>/i.exec(trimmed)
220 if (tag === null) return `"${trimmed}"`
221 const from = /\bfrom="([^"]*)"/.exec(tag[2] ?? '')?.[1]
222 const name = (tag[1] ?? '').replace(/-/g, ' ')
223 return from !== undefined ? `[${name} from ${from.slice(0, 7)}]` : `[${name}]`
224}
225
226// --- Which of a turn's shown rows fit the pane. A start is the first row in
227// view; none is the default: unsorted the newest rows (a live turn keeps
228// showing its latest), sorted the top.
229
230export type Page = 'up' | 'down'
231
232export function rowWindow(count: number, room: number, start: number | undefined, sorted: boolean): { from: number; to: number } {
233 const last = Math.max(0, count - room)
234 const from = start === undefined ? (sorted ? 0 : last) : Math.min(Math.max(0, start), last)
235 return { from, to: Math.min(count, from + room) }
236}
237
238// Move the window by `by` rows (a wheel tick, a scroll key); landing on the
239// default window returns undefined, so it follows the live turn again.
240export function scrollRows(count: number, room: number, start: number | undefined, sorted: boolean, by: number): number | undefined {
241 const last = Math.max(0, count - room)
242 const next = Math.min(Math.max(0, rowWindow(count, room, start, sorted).from + by), last)
243 return next === (sorted ? 0 : last) ? undefined : next
244}
245
246export const pageRows = (count: number, room: number, start: number | undefined, sorted: boolean, dir: Page): number | undefined =>
247 scrollRows(count, room, start, sorted, dir === 'up' ? -room : room)
248
249// A page key moves the engine's window by the pane's height; here it moves one row window.
250export const scrollStep = (by: number, bodyRows: number): number | Page =>
251 Math.abs(by) >= bodyRows ? (by < 0 ? 'up' : 'down') : by
252
253export function canPage(count: number, room: number, start: number | undefined, sorted: boolean, dir: Page): boolean {
254 const { from } = rowWindow(count, room, start, sorted)
255 return dir === 'up' ? from > 0 : from < Math.max(0, count - room)
256}
257types/index.d.ts 69 lines1export type Category =
2 | 'tools' | 'mcp' | 'skills' | 'hooks' | 'agents' | 'commands' | 'context' | 'model'
3
4export type Outcome =
5 | 'ok' | 'error' | 'denied' | 'running' // tools, mcp, agents, commands
6 | 'context' | 'blocked' | 'none' // hooks
7
8export type Row = {
9 id: string
10 turnId: string
11 agentId?: string
12 parentId?: string // the agents row this row ran under, when known
13 kind: Category
14 name: string
15 target: string
16 plugins?: string // hooks: enabled plugins with a hook for this event and tool
17 chars?: number // characters this row added to the conversation (tool result, hook or attachment text)
18 usage?: Usage // model: what the request cost, as the API reported it
19 detail?: string
20 outcome: Outcome
21 startedAt: number
22 ms?: number
23}
24
25export type Usage = {
26 model: string
27 input: number // uncached input
28 cacheRead: number
29 cacheWrite: number
30 output: number
31}
32
33export type Turn = {
34 turnId: string
35 text: string // the prompt, first eight words
36 startedAt: number
37 isComplete: boolean
38 completedAt?: number // when the main loop's turn.complete arrived
39}
40
41export type Compaction = { at: number; turnId: string }
42
43export type Selection = {
44 categories: Category[]
45 scope: 'turn' | 'session'
46 known?: Category[] // the categories that existed when it was saved; later ones start on
47}
48
49export type View = {
50 filter: string // the pane's filter bar, its sort term included
51 pinnedTurnId?: string // the turn shown; undefined follows the latest
52 rowStart?: number // first row in view when paged or scrolled; undefined: newest (sorted: top)
53 rowTurnId?: string // the turn rowStart belongs to; on any other turn it is ignored
54 help: boolean // the filter cheat sheet is shown
55}
56
57// The keys are spelled inline: `claude plugin validate` reads them from this literal.
58declare module 'claude-code' {
59 interface PluginState {
60 xtrace: {
61 rows: Row[]
62 turns: Turn[]
63 compactions: Compaction[]
64 selection: Selection
65 view: View
66 }
67 }
68}
69