SLOPSHOPPER

xtrace

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

newpaneguardcommandtoastprompt
★ 1v0.1.0no licenseupdated 2026-10-12xebia/ai-assisted-se-claude-exercise/.claude/skills/xtrace
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · xtrace
│ ┃ xtrace ✕ › fix the failing auth test and add an audit log call │ ┃ What should /xtrace show? │ ┃ 1: [x] tools ⏺ Read(src/auth.ts) │ ┃ 2: [x] mcp ⎿ Read 6 lines │ ┃ 3: [x] skills ⏺ Update(src/auth.ts) │ ┃ 4: [x] hooks ⎿ Added 2 lines, removed 1 line │ ┃ 5: [x] agents ⏺ Bash(bun test) │ ┃ 6: [x] commands ⎿ 3 pass, 1 fail │ ┃ 7: [x] context │ ┃ 8: [x] model ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ a: [x] all ✻ Worked for 42s · done 4:20 PM │ ┃ s: [ ] whole session │ ┃ p: toggle live pane › /xtrace │ ┃ ⎿ xtrace: xtrace: pick what to show. │ ┃ [ show ] [ cancel ] │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · xtrace
What should /xtrace show? 1: [x] tools 2: [x] mcp 3: [x] skills 4: [x] hooks 5: [x] agents 6: [x] commands 7: [x] context 8: [x] model a: [x] all s: [ ] whole session p: toggle live pane [ show ] [ cancel ]
Pane · xtrace
tools 9 · mcp 0 · skills 0 · hooks 12 · agents 0 · commands… filter: try: outcome:!ok · kind:tools sort:-ms · tokens:>5k ──────────────────────────────────────────────────────── No rows in this turn yet. f: ⏮ p: ◀ n: ▶ l: ⏭ no turns yet
README

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] …).

Live pane

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.

Layout

  • 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 ..

Source 6 files
hooks/register.tsx 699 lines
1import { 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}
699
hooks/args.ts 79 lines
1import 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}
79
hooks/report.ts 506 lines
1import 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}
506
hooks/hookmatch.ts 68 lines
1type 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}
68
hooks/view.ts 257 lines
1import 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}
257
types/index.d.ts 69 lines
1export 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