SLOPSHOPPER

trace

A /trace pane with two tabs: the skills that loaded this session (when, from where, who invoked them, how large) and the web pages Claude fetched or saw in…

newpaneguardcommandtoaststatus
v0.1.0MITupdated 2026-10-05Hula-Hoop-AI/supermods/plugins/trace
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · trace
│ ┃ Trace ✕ › fix the failing auth test and add an audit log call │ ┃ [ Skills ] [ Sources ] │ ┃ 0 skill loads · 0 skills [ Clear ] ⏺ Read(src/auth.ts) │ ┃ No skills loaded yet. ⎿ Read 6 lines │ ┃ updated 1:23:20 AM ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /trace │ ⎿ trace: Trace pane opened on Skills: 0 skill loads this session. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Trace
[ Skills ] [ Sources ] 0 skill loads · 0 skills [ Clear ] No skills loaded yet. updated 1:23:20 AM
README

trace

What it does

Adds /trace [skills|sources]: one pane with two tabs that record what this session pulled in. Both tabs record all the time; the tab only picks which one you are looking at.

Skills lists every skill that loaded, newest first: when, on which turn, who invoked it, where it came from, how large its instructions were.

[ Skills ] [ Sources ]
5 skill loads · 3 skills [ Clear ]
superpowers:using-superpowers ×3  commit ×1  deploy ×1
22:31:40 t9 deploy                        model denied: not allowed here "prod"
22:31:02 t9 commit                        model userSettings ~1.2k tok forked
22:28:29 t6 superpowers:using-superpowers user plugin:superpowers "Ignore these instructions…"
updated 22:31:40
  • model: the model called the Skill tool. user: you typed /<skill>. other: the skill's prompt was expanded with neither, e.g. preloaded into a subagent.
  • forked marks a skill that ran in a subagent; a failed Skill call shows why in red.

Sources lists every web page Claude consulted, deduplicated by URL, and turns them into citations on demand.

[ Skills ] [ Sources ]
2 pages fetched (1 failed) · 1 search result seen · 1 search · this session [ Insert citations ] [ Clear ]
fetched 2× docs.python.org Python docs      copy https://docs.python.org/3
fetched 1× httpbin.org     /status/404 failed copy https://httpbin.org/status/404
seen    1× realpython.com  Real Python      copy https://realpython.com/guide
search  “python docs”      → 2 results
updated 22:40:12
  • fetched: pages Claude asked for with WebFetch (or one of your extra fetch tools), with how many times; failed when no fetch of it succeeded (an error, a denied call, or an HTTP status of 400 or more).
  • seen: pages that came back from WebSearch but were never fetched.
  • search: each query, newest first, with its result count.
  • Insert citations puts the sources at the cursor in your prompt box, as a markdown list or numbered references. Failed fetches are never cited. Clear empties the ledger shown.

URLs are deduplicated after dropping the #fragment, tracking parameters (utm_*, fbclid, gclid, msclkid and similar) and a trailing slash.

/trace skills and /trace sources open the pane on that tab; /trace alone reopens the tab you last looked at. Where no pane can show (a narrow terminal, a headless host), /trace says why and answers in text: the skill-load count, or the summary and full list of sources.

The mod only observes: every hook passes its event on unchanged.

Install

/plugin marketplace add Hula-Hoop-AI/supermods
/plugin install {m}@supermods

Configuration

All options are rows in /config; a change applies at once.

OptionDefaultEffect
skills_max_entries200How many skill loads the list keeps (1–5000); the oldest drop off. Per-skill counts and the total cover the whole session regardless.
skills_show_argstrueRecord and show the arguments of each load (truncated to 80 characters). Off, arguments are never stored.
skills_status_linefalseShow skills: N in the status line under the prompt.
sources_citation_formatmarkdownmarkdown writes - Title; numbered writes [1] Title. url.
sources_cite_search_resultsfalseAlso cite pages that only appeared in search results. Off, only successfully fetched pages are cited.
sources_extra_toolsemptyComma-separated names of other tools that fetch a URL (for example mcp__fetch__fetch). Each is recorded like WebFetch; a page title is taken from a title field or an HTML <title> in its result.
sources_url_argurlThe argument that holds the URL in the extra tools' calls.
sources_project_historyfalseAlso keep every session's sources for this project in the plugin's store, and add a Show project history button. Insert and Clear then act on the view shown.

What it touches

From claude plugin validate --strict:

  • Events:
  • tool.call for the Skill tool: records a model invocation, its arguments, and whether it ran inline, forked or failed.
  • tool.call for WebFetch, WebSearch and your sources_extra_tools: awaits the call and records its URL or query and results.
  • command.run: answers /trace; any other command is recorded only when the model's skill listing names it as a skill.
  • skill.prompt: adds the size of the instructions the model reads.
  • session.start: registers /trace and restores the status line after a reload.
  • ui.render for its own pane.
  • Calls: $.command.register, $.session.usage (the /context skill listing, estimated locally with no request, and when the session began), $.session.turns, $.session.root (the project key for history), $.state (trace.tab, trace.skills, trace.ledger, trace.view), $.store.get / set / delete (only with sources_project_history on), $.prompt.fill, $.ui.open, $.ui.resolve, $.ui.status, $.ui.copy, $.ui.invalidate, $.ui.toast.
  • Data: skill names and arguments; URLs, page titles and search queries. Kept in session state; with sources_project_history, sources also go to the plugin's store, a JSON file under your Claude Code config directory, keyed by project path.
  • No files, processes, network or model calls of its own. Nothing is sent anywhere.

Limitations

  • A skill's size can be missing. Claude Code's built-in security plugin can withhold skill.prompt from user-installed mods (observed on Team organizations). There the Skills tab still records every model and typed load, without the ~N tok size, and other loads are not seen. The size is approximate: characters divided by 4.
  • A skill hidden from the model's listing (for example disable-model-invocation) is not recorded when typed, since the listing is how the mod tells skills from plain commands.
  • Turn is the number of prompts you have sent, slash commands included.
  • WebFetch has no page title. Its result is Claude's summary, not the page, so a fetched page is titled only when it also appeared in a search result; otherwise it shows its path.
  • Sources are only those that pass through tool calls. Pages a Bash curl or a subagent's own MCP server reads outside tool.call are not seen.
  • Caps: 500 sources per session and 300 per project (least recently seen dropped), the last 100 searches. Rows that do not fit the pane are counted in an "…and N more" line.
  • /clear empties both tabs, as it does all session state. Project history stays.
Source 7 files
hooks/register.tsx 123 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderSurface } from 'claude-code'
3
4import type { Ledger, Tab, View } from '../types'
5import { asLedger, EMPTY as NO_SOURCES } from './recorders/ledger'
6import { EMPTY as NO_SKILLS, recordSkills, skillsModel, skillsReport, statusText } from './recorders/skills'
7import {
8  citations, historyKey, recordSources, shownView, sourcesModel, sourcesReport,
9} from './recorders/sources'
10import type { TabModel } from './recorders/tab'
11import { tabPane } from './tab-pane'
12
13const PANE = 'trace'
14const COMMAND = 'trace'
15const TITLE = 'Trace'
16const TABS: { id: Tab; title: string }[] = [
17  { id: 'skills', title: 'Skills' },
18  { id: 'sources', title: 'Sources' },
19]
20const tab = atom({ plugin: 'trace', key: 'tab' } as const, 'skills' as Tab)
21// The recorders' atoms, named again: the validator reads a state reference only in the file that uses it.
22const trace = atom({ plugin: 'trace', key: 'skills' } as const, NO_SKILLS)
23const ledger = atom({ plugin: 'trace', key: 'ledger' } as const, NO_SOURCES)
24const view = atom({ plugin: 'trace', key: 'view' } as const, 'session' as View)
25
26const tabOf = (id: string) => TABS.find(t => t.id === id)
27
28// Every function that takes `$` lives in this file: the validator follows `$` no further.
29async function copy($: EngineInterface, text: string, surface: RenderSurface) {
30  const r = await $.ui.copy({ text, surface })
31  $.ui.toast(r.isCopied ? `Copied ${text}` : `Could not copy (${r.reason})`)
32}
33
34async function clearSkills($: EngineInterface) {
35  await update($, trace, () => NO_SKILLS)
36  $.ui.status(undefined)
37}
38
39async function loadHistory($: EngineInterface) {
40  return asLedger(await $.store.get(historyKey(await $.session.root())))
41}
42
43async function insertCitations($: EngineInterface, shown: Ledger) {
44  const cited = citations(shown)
45  if ('none' in cited) return $.ui.toast(cited.none)
46  const { isFilled } = await $.prompt.fill({ text: cited.text, mode: 'insert' })
47  if (!isFilled) $.ui.toast("trace: the prompt box didn't take the citations")
48}
49
50async function clearSources($: EngineInterface, current: View) {
51  if (current === 'project') {
52    await $.store.delete(historyKey(await $.session.root()))
53    $.ui.invalidate('ui.render')
54  } else {
55    await update($, ledger, () => NO_SOURCES)
56  }
57}
58
59async function skillsTab($: EngineInterface, since: number) {
60  return skillsModel(await read($, trace), since, () => void clearSkills($))
61}
62
63async function sourcesTab($: EngineInterface, since: number) {
64  const session = await read($, ledger) // read in every view, so a new source redraws the pane
65  const current = shownView(await read($, view))
66  const shown = current === 'project' ? await loadHistory($) : session
67  return sourcesModel(shown, current, since, {
68    insert: () => void insertCitations($, shown),
69    clear: () => void clearSources($, current),
70    toggleView: () => void update($, view, v => (v === 'project' ? 'session' : 'project')),
71  })
72}
73
74async function report($: EngineInterface, active: Tab, isPlaced: boolean) {
75  return active === 'skills' ? skillsReport(await read($, trace)) : sourcesReport(await read($, ledger), isPlaced)
76}
77
78export const register: Register = (on, options) => {
79  recordSkills(on, options)
80  recordSources(on, options)
81
82  on('session.start', async ($, e, next) => {
83    await $.command.register({
84      name: COMMAND,
85      description: 'Show the skills that loaded and the web sources Claude consulted this session',
86      argumentHint: `[${TABS.map(t => t.id).join('|')}]`,
87      immediate: true,
88    })
89    // After a reload (a settings change), match the status line to the setting.
90    $.ui.status(statusText(await read($, trace)))
91    return next(e)
92  })
93
94  on('command.run', { command: COMMAND }, async ($, e) => {
95    const arg = e.args.trim().toLowerCase()
96    const asked = tabOf(arg)
97    if (arg && !asked) return { text: `/${COMMAND}: no "${arg}" tab. Use ${TABS.map(t => t.id).join(' or ')}.` }
98    const active = asked ? await update($, tab, () => asked.id) : await read($, tab)
99    const opened = await $.ui.open({ id: PANE, title: TITLE })
100    return {
101      text: opened.isPlaced
102        ? `${TITLE} pane opened on ${tabOf(active)?.title}: ${await report($, active, true)}`
103        : `The ${TITLE} pane is open, but this surface is not showing it: ${opened.reason}\n${await report($, active, false)}`,
104    }
105  })
106
107  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
108    const active = await read($, tab)
109    const since = await $.session.usage().then(u => u.startedAt, () => Date.now())
110    const model: TabModel = active === 'skills' ? await skillsTab($, since) : await sourcesTab($, since)
111    return tabPane($.ui.resolve(e), e.viewport, {
112      ...model,
113      tabs: TABS,
114      activeTab: active,
115      onTab: id => {
116        const to = tabOf(id)
117        if (to) void update($, tab, () => to.id)
118      },
119      onCopy: (text, surface) => void copy($, text, surface),
120    })
121  })
122}
123
hooks/recorders/ledger.ts 181 lines
1import type { Ledger, Source } from '../../types'
2
3// Query parameters that only track the click, never change the page.
4const TRACKING_PARAMS = new Set([
5  'fbclid', 'gclid', 'dclid', 'gbraid', 'wbraid', 'msclkid', 'yclid', 'twclid', 'igshid',
6  'mc_cid', 'mc_eid', '_hsenc', '_hsmi', 'mkt_tok', 'ref_src', 'ref_url', 'spm',
7])
8const MAX_QUERIES_PER_SOURCE = 5
9const MAX_SEARCHES = 100
10const MAX_TITLE = 200
11
12export const EMPTY: Ledger = { sources: [], searches: [] }
13
14export type Observation =
15  | { kind: 'fetch'; url: string; ok: boolean; title?: string }
16  | { kind: 'search'; query: string; ok: boolean; hits: { url: string; title?: string }[] }
17
18export type CitationFormat = 'markdown' | 'numbered'
19
20/** Drops the fragment, tracking params (utm_* and friends) and a trailing slash. */
21export function normalizeUrl(raw: string): string {
22  let u: URL
23  try {
24    u = new URL(raw.trim())
25  } catch {
26    return raw.trim()
27  }
28  u.hash = ''
29  const tracking: string[] = []
30  u.searchParams.forEach((_, key) => {
31    const k = key.toLowerCase()
32    if (k.startsWith('utm_') || TRACKING_PARAMS.has(k)) tracking.push(key)
33  })
34  // Deleting re-encodes the query, so touch it only when there is something to drop.
35  for (const key of tracking) u.searchParams.delete(key)
36  if (u.pathname.length > 1 && u.pathname.endsWith('/')) u.pathname = u.pathname.slice(0, -1)
37  return u.toString()
38}
39
40export function domainOf(url: string): string {
41  try {
42    return new URL(url).hostname.replace(/^www\./, '')
43  } catch {
44    return url
45  }
46}
47
48const isObject = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null
49const str = (v: unknown) => (typeof v === 'string' && v.trim() ? v.trim() : undefined)
50const clip = (s: string | undefined) => (s && s.length > MAX_TITLE ? s.slice(0, MAX_TITLE - 1) + '…' : s)
51
52/** Whether a tool.call outcome succeeded: not denied, not an error, no HTTP error code. */
53function succeeded(out: unknown): boolean {
54  if (!isObject(out) || out.deny !== undefined || out.isError === true) return false
55  const code = isObject(out.result) ? out.result.code : undefined
56  return typeof code !== 'number' || code < 400
57}
58
59/** A page title from a tool's own result: a `title` field, or an HTML <title>. */
60function titleFrom(out: unknown): string | undefined {
61  if (!isObject(out)) return undefined
62  if (isObject(out.result) && str(out.result.title)) return clip(str(out.result.title))
63  const text = typeof out.text === 'string' ? out.text : typeof out.result === 'string' ? out.result : ''
64  const m = /<title[^>]*>([^<]{1,500})<\/title>/i.exec(text)
65  return clip(str(m?.[1]))
66}
67
68/**
69 * What one tool call tells us: `args` is the call's input, `out` what `next(e)` resolved to.
70 * WebFetch and WebSearch are read by their built-in shapes; any other tool is a fetch of the
71 * URL in `args[urlArg]`. Undefined when the call names no URL or query.
72 */
73export function observe(
74  tool: string,
75  args: Record<string, unknown>,
76  out: unknown,
77  urlArg: string,
78): Observation | undefined {
79  const ok = succeeded(out)
80  if (tool === 'WebSearch') {
81    const query = str(args.query)
82    if (!query) return undefined
83    const hits: { url: string; title?: string }[] = []
84    const results = isObject(out) && isObject(out.result) ? out.result.results : undefined
85    for (const block of Array.isArray(results) ? results : []) {
86      if (!isObject(block) || !Array.isArray(block.content)) continue
87      for (const hit of block.content) {
88        const url = isObject(hit) ? str(hit.url) : undefined
89        if (url) hits.push({ url, title: clip(str(isObject(hit) ? hit.title : undefined)) })
90      }
91    }
92    return { kind: 'search', query, ok, hits }
93  }
94  const url = str(args[tool === 'WebFetch' ? 'url' : urlArg])
95  if (!url) return undefined
96  return { kind: 'fetch', url, ok, title: tool === 'WebFetch' ? undefined : titleFrom(out) }
97}
98
99function touch(sources: Source[], url: string, turn: number, at: number): Source {
100  const key = normalizeUrl(url)
101  let s = sources.find(x => x.url === key)
102  if (!s) {
103    s = { url: key, domain: domainOf(key), fetches: 0, failures: 0, seen: 0, queries: [], firstTurn: turn, lastTurn: turn, lastAt: at }
104    sources.push(s)
105  }
106  s.lastTurn = turn
107  s.lastAt = at
108  return s
109}
110
111/** The ledger with one observation added; at most `maxSources`, least recently seen dropped. */
112export function record(ledger: Ledger, obs: Observation, turn: number, at: number, maxSources: number): Ledger {
113  const sources = ledger.sources.map(s => ({ ...s, queries: [...s.queries] }))
114  let searches = ledger.searches
115  if (obs.kind === 'fetch') {
116    const s = touch(sources, obs.url, turn, at)
117    s.fetches++
118    if (!obs.ok) s.failures++
119    if (obs.title) s.title = obs.title
120  } else {
121    for (const hit of obs.hits) {
122      const s = touch(sources, hit.url, turn, at)
123      s.seen++
124      if (hit.title && !s.title) s.title = hit.title
125      if (!s.queries.includes(obs.query)) s.queries = [...s.queries, obs.query].slice(-MAX_QUERIES_PER_SOURCE)
126    }
127    searches = [...searches, { query: obs.query, results: obs.hits.length, ok: obs.ok, turn, at }].slice(-MAX_SEARCHES)
128  }
129  if (sources.length > maxSources) {
130    const keep = new Set([...sources].sort((a, b) => b.lastAt - a.lastAt).slice(0, maxSources))
131    return { sources: sources.filter(s => keep.has(s)), searches }
132  }
133  return { sources, searches }
134}
135
136export const isFetched = (s: Source) => s.fetches > 0
137export const isFailed = (s: Source) => s.fetches > 0 && s.failures >= s.fetches
138
139/** Sources worth citing: fetched at least once successfully, plus search hits when asked. */
140export function citable(ledger: Ledger, includeSearchResults: boolean): Source[] {
141  return ledger.sources.filter(s => (isFetched(s) ? !isFailed(s) : includeSearchResults))
142}
143
144export function label(s: Source): string {
145  if (s.title) return s.title
146  try {
147    const u = new URL(s.url)
148    return u.pathname === '/' ? s.domain : s.domain + u.pathname
149  } catch {
150    return s.url
151  }
152}
153
154export const plural = (n: number, word: string, many = `${word}s`) => `${n} ${n === 1 ? word : many}`
155
156const isLedger = (v: unknown): v is Ledger =>
157  isObject(v) && Array.isArray(v.sources) && Array.isArray(v.searches)
158
159/** A stored value as a ledger; anything else (nothing saved, another shape) is the empty one. */
160export const asLedger = (v: unknown): Ledger => (isLedger(v) ? v : EMPTY)
161
162/** One line: pages fetched (failed), search results seen, searches. */
163export function summary(l: Ledger): string {
164  const fetched = l.sources.filter(isFetched)
165  const failed = fetched.filter(isFailed).length
166  return [
167    plural(fetched.length, 'page') + ' fetched' + (failed ? ` (${failed} failed)` : ''),
168    plural(l.sources.length - fetched.length, 'search result') + ' seen',
169    plural(l.searches.length, 'search', 'searches'),
170  ].join(' · ')
171}
172
173export function formatCitations(sources: Source[], format: CitationFormat): string {
174  const lines = sources.map((s, i) =>
175    format === 'numbered'
176      ? `[${i + 1}] ${label(s)}. ${s.url}`
177      : `- [${label(s).replace(/([[\]])/g, '\\$1')}](${s.url.replace(/\)/g, '%29')})`,
178  )
179  return `Sources:\n${lines.join('\n')}\n`
180}
181
hooks/recorders/skills.ts 207 lines
1import { atom, update } from 'claude-code'
2import type { EngineInterface, Register, ToolCallResult } from 'claude-code'
3
4import type { InvokedBy, Trace, TraceEntry } from '../../types'
5import { plural } from './ledger'
6import type { TabModel } from './tab'
7
8const ARGS_MAX = 80
9const DEFAULT_MAX_ENTRIES = 200
10const MAX_ENTRIES_CAP = 5000
11const COUNTS_SHOWN = 8
12export const EMPTY: Trace = { entries: [], counts: {}, total: 0 }
13const BY_COLOR: Record<InvokedBy, string> = { model: 'cyan', user: 'yellow', other: 'gray' }
14
15const trace = atom({ plugin: 'trace', key: 'skills' } as const, EMPTY)
16
17// A skill load in flight: a Skill tool call or a typed /skill, already recorded as `seq`.
18// The engine expands the skill's prompt (`skill.prompt`) inside it, which adds the size.
19type Pending = { skill: string; seq: number; sized: boolean }
20const pending: Pending[] = []
21
22let maxEntries = DEFAULT_MAX_ENTRIES
23let showArgs = true
24let statusLine = false
25
26const bareName = (s: string) => s.replace(/^\//, '').trim()
27
28// `superpowers:brainstorming` and `brainstorming` name the same skill.
29const sameSkill = (a: string, b: string) => {
30  const x = bareName(a)
31  const y = bareName(b)
32  return x === y || x.endsWith(`:${y}`) || y.endsWith(`:${x}`)
33}
34
35const truncate = (s: string, n: number) => (s.length > n ? `${s.slice(0, n - 1)}…` : s)
36
37function append(t: Trace, entry: TraceEntry, max: number): Trace {
38  return {
39    entries: [...t.entries, entry].slice(-max),
40    counts: { ...t.counts, [entry.skill]: (t.counts[entry.skill] ?? 0) + 1 },
41    total: t.total + 1,
42  }
43}
44
45function topCounts(counts: Record<string, number>, n: number): [string, number][] {
46  return Object.entries(counts)
47    .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
48    .slice(0, n)
49}
50
51const clock = (ms: number) => {
52  const d = new Date(ms)
53  return [d.getHours(), d.getMinutes(), d.getSeconds()].map(v => String(v).padStart(2, '0')).join(':')
54}
55
56const size = (chars: number) => {
57  const tok = chars / 4 // rough chars-per-token estimate
58  return tok >= 1000 ? `~${(tok / 1000).toFixed(1)}k tok` : `~${Math.round(tok)} tok`
59}
60
61const fieldOf = (v: unknown, k: string): unknown =>
62  typeof v === 'object' && v !== null ? (v as Record<string, unknown>)[k] : undefined
63
64// The skill as the model's skill listing has it: `plugin:<name>` or its source
65// (`userSettings`, `built-in`, ...); undefined when the listing has no such skill.
66async function listedSource($: EngineInterface, skill: string): Promise<string | undefined> {
67  try {
68    const usage = await $.session.usage({ breakdown: 'summary' }) // local estimate, no request
69    const s = usage.context.breakdown?.skills?.skillFrontmatter.find(x => sameSkill(x.name, skill))
70    return s && (s.pluginName ? `plugin:${s.pluginName}` : s.source)
71  } catch {
72    return undefined
73  }
74}
75
76const prefixSource = (skill: string) => {
77  const i = skill.indexOf(':')
78  return i > 0 ? `plugin:${skill.slice(0, i)}` : undefined
79}
80
81// Records the load now and leaves it pending, so `skill.prompt` can add its size.
82async function begin($: EngineInterface, skill: string, entry: Omit<TraceEntry, 'seq' | 'turn' | 'at'>) {
83  const p: Pending = { skill, seq: await record($, entry), sized: false }
84  pending.push(p)
85  return p
86}
87
88const end = (p: Pending) => pending.splice(pending.indexOf(p), 1)
89
90export const statusText = (t: Trace) => (statusLine && t.total ? `skills: ${t.total}` : undefined)
91
92function showStatus($: EngineInterface, t: Trace) {
93  if (statusLine) $.ui.status(statusText(t))
94}
95
96async function record($: EngineInterface, e: Omit<TraceEntry, 'seq' | 'turn' | 'at'>) {
97  // The user's prompts so far; a typed /skill runs before its own prompt is counted.
98  const n = (await $.session.turns().catch(() => 0)) + (e.by === 'user' ? 1 : 0)
99  let seq = 0
100  const next = await update($, trace, t => {
101    seq = t.total + 1
102    return append(t, { ...e, seq, turn: n, at: Date.now() }, maxEntries)
103  })
104  showStatus($, next)
105  return seq
106}
107
108async function patch($: EngineInterface, seq: number, fields: Partial<TraceEntry>) {
109  await update($, trace, t => ({
110    ...t,
111    entries: t.entries.map(x => (x.seq === seq ? { ...x, ...fields } : x)),
112  }))
113}
114
115function outcome(r: ToolCallResult): { mode?: 'inline' | 'forked'; failed?: string } {
116  if ('deny' in r && typeof r.deny === 'string') return { failed: `denied: ${r.deny}` }
117  const res = r.result
118  const status = fieldOf(res, 'status')
119  const mode = status === 'forked' ? 'forked' : 'inline'
120  if (r.isError || fieldOf(res, 'success') === false) {
121    return { mode, failed: truncate(r.text ?? 'skill failed', ARGS_MAX) }
122  }
123  return { mode }
124}
125
126export const recordSkills: Register = (on, options) => {
127  const max = Number(options.skills_max_entries)
128  maxEntries = Number.isFinite(max) ? Math.min(MAX_ENTRIES_CAP, Math.max(1, Math.floor(max))) : DEFAULT_MAX_ENTRIES
129  showArgs = options.skills_show_args !== false
130  statusLine = options.skills_status_line === true
131
132  const argsOf = (args: string | undefined) => (showArgs && args ? truncate(args, ARGS_MAX) : undefined)
133
134  on('tool.call', { tool: 'Skill' }, async ($, e, next) => {
135    if (e.tool !== 'Skill') return next(e)
136    const skill = bareName(e.skill)
137    const source = (await listedSource($, skill)) ?? prefixSource(skill)
138    const p = await begin($, skill, { skill, source, by: 'model', args: argsOf(e.args) })
139    try {
140      const r = await next(e)
141      await patch($, p.seq, outcome(r))
142      return r
143    } finally {
144      end(p)
145    }
146  })
147
148  // A typed /name is a skill when the model's skill listing names it; every other
149  // command (built-ins, other mods' commands, /trace itself) passes by unrecorded.
150  on('command.run', async ($, e, next) => {
151    const source = await listedSource($, e.command)
152    if (source === undefined) return next(e)
153    const p = await begin($, e.command, { skill: e.command, source, by: 'user', args: argsOf(e.args) })
154    try {
155      return await next(e)
156    } finally {
157      end(p)
158    }
159  })
160
161  // Adds the size of what the model reads to the pending load; a prompt with none
162  // pending (a skill preloaded into a subagent) is recorded on its own.
163  on('skill.prompt', async ($, e, next) => {
164    const r = await next(e)
165    const p = pending.find(x => !x.sized && sameSkill(x.skill, e.skill))
166    if (p) {
167      p.sized = true
168      await patch($, p.seq, { chars: r.text.length })
169    } else {
170      const source = (await listedSource($, e.skill)) ?? prefixSource(e.skill)
171      await record($, { skill: e.skill, source, by: 'other', chars: r.text.length })
172    }
173    return r
174  })
175}
176
177export const skillsReport = (t: Trace) => `${plural(t.total, 'skill load')} this session.`
178
179export function skillsModel(t: Trace, since: number, onClear: () => void): TabModel {
180  const kinds = Object.keys(t.counts).length
181  const top = topCounts(t.counts, COUNTS_SHOWN).map(([name, n]) => `${name} ×${n}`)
182  if (kinds > COUNTS_SHOWN) top.push(`+${kinds - COUNTS_SHOWN} more`)
183  return {
184    summary: plural(t.total, 'skill load'),
185    context: plural(kinds, 'skill'),
186    actions: [{ key: 'clear', label: 'Clear', onPress: onClear }],
187    notes: top.length ? [top.join('  ')] : [],
188    checkedAt: t.entries.at(-1)?.at ?? since,
189    empty: 'No skills loaded yet.',
190    // Newest first: the pane keeps the top rows when they overflow.
191    rows: t.entries.toReversed().map(x => ({
192      id: String(x.seq),
193      state: x.failed ? 'error' : 'idle',
194      label: `${clock(x.at)} t${x.turn}`,
195      title: x.skill,
196      tags: [
197        { text: x.by, color: BY_COLOR[x.by] },
198        ...(x.source ? [{ text: x.source, dimColor: true }] : []),
199        ...(x.chars !== undefined ? [{ text: size(x.chars) }] : []),
200        ...(x.mode === 'forked' ? [{ text: 'forked', color: 'magenta' }] : []),
201        ...(x.failed ? [{ text: x.failed, color: 'red' }] : []),
202        ...(showArgs && x.args ? [{ text: `"${x.args}"`, dimColor: true }] : []),
203      ],
204    })),
205  }
206}
207
hooks/recorders/sources.ts 135 lines
1import { atom, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Ledger, Source, View } from '../../types'
5import type { Row } from '../tab-pane'
6import {
7  EMPTY, asLedger, citable, formatCitations, isFailed, isFetched, label, observe, plural, record, summary,
8} from './ledger'
9import type { CitationFormat, Observation } from './ledger'
10import type { TabModel } from './tab'
11
12const BUILTIN_TOOLS = ['WebFetch', 'WebSearch']
13const MAX_SESSION_SOURCES = 500
14const MAX_PROJECT_SOURCES = 300 // the store is one JSON file shared by every project, capped at 4 MiB
15const ledger = atom({ plugin: 'trace', key: 'ledger' } as const, EMPTY)
16
17// The plugin's settings; a change there reloads the module, which reads them again.
18let format: CitationFormat = 'markdown'
19let citeSearchResults = false
20let projectHistory = false
21let urlArg = 'url'
22let tools = BUILTIN_TOOLS
23
24// Parallel tool calls each read-modify-write the store; run those one at a time.
25let storeQueue: Promise<void> = Promise.resolve()
26
27const message = (err: unknown) => (err instanceof Error ? err.message : String(err))
28const escapeRegExp = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
29
30export const historyKey = (root: string) => `sources:${root}`
31
32async function save($: EngineInterface, obs: Observation) {
33  const turn = await $.session.turns()
34  const at = Date.now()
35  await update($, ledger, l => record(l, obs, turn, at, MAX_SESSION_SOURCES))
36  if (!projectHistory) return
37  const write = storeQueue.then(async () => {
38    const key = historyKey(await $.session.root())
39    await $.store.set(key, record(asLedger(await $.store.get(key)), obs, turn, at, MAX_PROJECT_SOURCES))
40  })
41  storeQueue = write.catch(() => {})
42  await write
43}
44
45// The text Insert citations writes, or why there is none.
46export function citations(shown: Ledger): { text: string } | { none: string } {
47  const list = citable(shown, citeSearchResults)
48  if (list.length) return { text: formatCitations(list, format) }
49  return { none: citeSearchResults ? 'trace: nothing to cite yet' : 'trace: no pages fetched yet to cite' }
50}
51
52export const recordSources: Register = (on, options) => {
53  format = options.sources_citation_format === 'numbered' ? 'numbered' : 'markdown'
54  citeSearchResults = options.sources_cite_search_results === true
55  projectHistory = options.sources_project_history === true
56  urlArg = String(options.sources_url_arg ?? '').trim() || 'url'
57  const extraTools = String(options.sources_extra_tools ?? '')
58    .split(',')
59    .map(t => t.trim())
60    .filter(Boolean)
61  tools = [...new Set([...BUILTIN_TOOLS, ...extraTools])]
62  // A RegExp matcher, since the extra tool names are only known from config.
63  const toolMatcher = new RegExp(`^(?:${tools.map(escapeRegExp).join('|')})$`)
64
65  on('tool.call', { tool: toolMatcher }, async ($, e, next) => {
66    const out = await next(e)
67    try {
68      const obs = observe(e.tool, { ...e }, out, urlArg)
69      if (obs) await save($, obs)
70    } catch (err) {
71      $.ui.toast(`trace: could not record ${e.tool} (${message(err)})`)
72    }
73    return out
74  })
75}
76
77const byCount = (count: (s: Source) => number) => (a: Source, b: Source) =>
78  count(b) - count(a) || b.lastAt - a.lastAt
79
80const sourceRow = (group: 'fetched' | 'seen', count: number, s: Source): Row => ({
81  id: `${group}:${s.url}`,
82  state: group === 'seen' ? 'idle' : isFailed(s) ? 'error' : 'ok',
83  label: group,
84  title: `${count}× ${s.domain}`,
85  tags: [
86    { text: s.title ?? label(s).slice(s.domain.length), dimColor: true },
87    ...(isFailed(s) ? [{ text: 'failed', color: 'red' }] : []),
88  ],
89  link: s.url,
90  copyText: s.url,
91})
92
93// Placed, the pane shows the list; where none can open (a -p run, an SDK host) the answer carries it.
94export function sourcesReport(current: Ledger, isPlaced: boolean) {
95  if (isPlaced) return `${summary(current)}.`
96  const all = citable(current, true)
97  return all.length ? `${summary(current)}\n${formatCitations(all, format)}` : 'No web sources consulted yet.'
98}
99
100export const shownView = (v: View): View => (projectHistory ? v : 'session')
101
102type Presses = { insert: () => void; clear: () => void; toggleView: () => void }
103
104export function sourcesModel(shown: Ledger, current: View, since: number, on: Presses): TabModel {
105  const fetched = shown.sources.filter(isFetched).sort(byCount(s => s.fetches))
106  const seen = shown.sources.filter(s => !isFetched(s)).sort(byCount(s => s.seen))
107  return {
108    summary: summary(shown),
109    context: current === 'project' ? 'this project, all sessions' : 'this session',
110    actions: [
111      { key: 'insert', label: 'Insert citations', isActive: true, onPress: on.insert },
112      { key: 'clear', label: 'Clear', onPress: on.clear },
113      ...(projectHistory
114        ? [{ key: 'view', label: current === 'project' ? 'Show this session' : 'Show project history', onPress: on.toggleView }]
115        : []),
116    ],
117    checkedAt: Math.max(since, ...shown.sources.map(s => s.lastAt), ...shown.searches.map(q => q.at)),
118    empty: `No web sources yet. ${tools.join(', ')} calls show up here.`,
119    rows: [
120      ...fetched.map(s => sourceRow('fetched', s.fetches, s)),
121      ...seen.map(s => sourceRow('seen', s.seen, s)),
122      ...shown.searches.toReversed().map((q, i): Row => ({
123        id: `search:${i}`,
124        state: q.ok ? 'idle' : 'error',
125        label: 'search',
126        title: `“${q.query}”`,
127        tags: [
128          { text: `→ ${plural(q.results, 'result')}`, dimColor: true },
129          ...(q.ok ? [] : [{ text: 'failed', color: 'red' }]),
130        ],
131      })),
132    ],
133  }
134}
135
hooks/recorders/tab.ts 5 lines
1import type { PaneModel } from '../tab-pane'
2
3// What a recorder builds for its tab; register.tsx adds the tabs and the copy handler.
4export type TabModel = Omit<PaneModel, 'tabs' | 'activeTab' | 'onTab' | 'onCopy'>
5
hooks/tab-pane.tsx 192 lines
1// The pane layout every tabbed mod draws: tabs, a summary line with actions, rows, a footer.
2// Canonical copy: shared/tab-pane.tsx. Edit it there and run shared/sync.sh; a plugin
3// installs alone, so each mod carries its own copy.
4import type { ElementTable, RenderSurface, RenderViewport } from 'claude-code'
5
6export type RowState = 'ok' | 'busy' | 'error' | 'idle'
7
8export type Tag = { text: string; color?: string; dimColor?: boolean; bold?: boolean }
9
10export type Row = {
11  id: string
12  state?: RowState
13  label?: string // the state in words; a dot is drawn without one
14  title: string
15  tags?: Tag[]
16  age?: string
17  sub?: string // a second, dim line
18  link?: string
19  copyText?: string
20  highlight?: boolean
21  actions?: { key: string; label: string }[] // buttons beside the row; a press calls onRowAction
22  heading?: boolean // a group's headline over the rows after it: its title in bold (a link when `link`), its copy button; nothing else
23}
24
25export type Action = { key: string; label: string; isActive?: boolean; onPress: () => void }
26
27export type PaneModel = {
28  tabs: { id: string; title: string }[]
29  activeTab: string
30  onTab: (id: string) => void
31  summary: string
32  context?: string
33  actions?: Action[]
34  error?: string
35  notes?: string[]
36  checkedAt?: number // undefined until the first answer
37  empty: string
38  rows: Row[]
39  footer?: string
40  onCopy: (text: string, surface: RenderSurface) => void
41  onRowAction?: (row: Row, key: string) => void
42  columns?: number // the pane's body width (`e.props.bodyColumns`); titles shrink to keep the rest of a row in view
43}
44
45const STATE_COLORS: Record<RowState, string | undefined> = {
46  ok: 'green',
47  busy: 'yellow',
48  error: 'red',
49  idle: undefined, // drawn dim
50}
51const DOT = '●'
52const MAX_TITLE_PAD = 32
53const MAX_SUB = 72
54const CHROME_ROWS = 5 // tabs, summary, footer, the "more" line and a spare
55const MIN_TITLE = 8
56const BUTTON_CHROME = 5 // "[ " and " ]" around a button's label, and the gap before it
57
58const truncate = (s: string, n: number) => (s.length > n ? `${s.slice(0, n - 1)}…` : s)
59const widest = (cells: string[], cap: number) => Math.min(cap, Math.max(0, ...cells.map(c => c.length)))
60
61// As many rows as the viewport has lines for; a row with a sub-line takes two.
62export function fitRows(rows: Row[], lines: number): Row[] {
63  const fit: Row[] = []
64  let left = Math.max(1, lines)
65  for (const row of rows) {
66    left -= row.sub ? 2 : 1
67    if (left < 0 && fit.length) break
68    fit.push(row)
69  }
70  if (fit.length > 1 && fit[fit.length - 1]!.heading) fit.pop() // no headline without a row under it
71  return fit
72}
73
74// `ui` is `$.ui.resolve(e)`: the validator follows `$` only inside the hooks module's own file,
75// so the caller resolves the elements and supplies the handlers.
76export function tabPane(ui: ElementTable, viewport: RenderViewport | undefined, m: PaneModel) {
77  const { Box, Text, Button, Link } = ui
78  const notes = m.notes ?? []
79  const shown = fitRows(m.rows, (viewport?.rows ?? 24) - CHROME_ROWS - notes.length - (m.error ? 1 : 0))
80  const hidden = m.rows.slice(shown.length).filter(r => !r.heading).length
81  const items = shown.filter(r => !r.heading) // headlines take no part in the columns
82  const labelWidth = widest(items.map(r => r.label ?? ''), MAX_TITLE_PAD)
83  const marks = items.some(r => r.highlight)
84  // Everything on a row's line but its title.
85  const rest = (r: Row) =>
86    (marks ? 2 : 0) +
87    (r.label === undefined ? 1 : labelWidth) +
88    1 +
89    (r.tags ?? []).reduce((n, t) => n + 1 + t.text.length, 0) +
90    (r.age ? 1 + r.age.length : 0) +
91    (r.copyText !== undefined ? 'copy'.length + BUTTON_CHROME : 0) +
92    (r.actions ?? []).reduce((n, a) => n + a.label.length + BUTTON_CHROME, 0)
93  const room = m.columns === undefined ? Infinity : Math.max(MIN_TITLE, m.columns - Math.max(0, ...items.map(rest)))
94  const titleWidth = Math.min(room, widest(items.map(r => r.title), MAX_TITLE_PAD))
95  const title = (r: Row) => (r.title.length > room ? truncate(r.title, room) : r.title).padEnd(titleWidth)
96
97  return (
98    <Box flexDirection="column">
99      {m.tabs.length > 1 && (
100        <Box flexDirection="row" gap={1}>
101          {m.tabs.map(t => (
102            <Button key={`tab:${t.id}`} variant={t.id === m.activeTab ? 'primary' : 'secondary'} onPress={() => m.onTab(t.id)}>
103              {t.title}
104            </Button>
105          ))}
106        </Box>
107      )}
108      <Box flexDirection="row" gap={1}>
109        <Text bold>
110          {m.summary}
111          {m.context && <Text dimColor> · {m.context}</Text>}
112        </Text>
113        {(m.actions ?? []).map(a => (
114          <Button key={a.key} variant={a.isActive ? 'primary' : 'secondary'} onPress={a.onPress}>
115            {a.label}
116          </Button>
117        ))}
118      </Box>
119      {m.error && <Text color="red">{m.error}</Text>}
120      {notes.map(n => (
121        <Text dimColor>{n}</Text>
122      ))}
123      {m.checkedAt === undefined && !m.error && <Text dimColor>Checking…</Text>}
124      {m.checkedAt !== undefined && !m.error && m.rows.length === 0 && <Text dimColor>{m.empty}</Text>}
125      {shown.map(r => {
126        if (r.heading) {
127          return (
128            <Box flexDirection="row" gap={1}>
129              <Text bold wrap="truncate-end">
130                {r.link ? <Link href={r.link}>{r.title}</Link> : r.title}
131              </Text>
132              {r.copyText !== undefined && (
133                <Button key={`copy:${r.id}`} dimColor onPress={press => m.onCopy(r.copyText!, press.surface)}>
134                  copy
135                </Button>
136              )}
137            </Box>
138          )
139        }
140        const color = r.state && STATE_COLORS[r.state]
141        return (
142          <Box flexDirection="column">
143            <Box flexDirection="row" gap={1}>
144              <Text wrap="truncate-end">
145                {marks && <Text color="cyan">{r.highlight ? '› ' : '  '}</Text>}
146                <Text color={color} dimColor={!color} bold={r.state !== 'idle'}>
147                  {r.label === undefined ? DOT : r.label.padEnd(labelWidth)}
148                </Text>{' '}
149                {title(r)}
150                {(r.tags ?? []).map(t => (
151                  <Text color={t.color} dimColor={t.dimColor} bold={t.bold}>
152                    {' '}
153                    {t.text}
154                  </Text>
155                ))}
156                {r.age && <Text dimColor> {r.age}</Text>}
157              </Text>
158              {r.copyText !== undefined && (
159                <Button key={`copy:${r.id}`} dimColor onPress={press => m.onCopy(r.copyText!, press.surface)}>
160                  copy
161                </Button>
162              )}
163              {(r.actions ?? []).map(a => (
164                <Button key={`${a.key}:${r.id}`} dimColor onPress={() => m.onRowAction?.(r, a.key)}>
165                  {a.label}
166                </Button>
167              ))}
168              {r.link && !r.sub && <Link href={r.link} />}
169            </Box>
170            {r.sub && (
171              <Box flexDirection="row" gap={1}>
172                <Text dimColor>
173                  {'    '}
174                  {truncate(r.sub, MAX_SUB)}
175                </Text>
176                {r.link && <Link href={r.link} />}
177              </Box>
178            )}
179          </Box>
180        )
181      })}
182      {hidden > 0 && <Text dimColor>…and {hidden} more</Text>}
183      {m.checkedAt !== undefined && (
184        <Text dimColor>
185          updated {new Date(m.checkedAt).toLocaleTimeString()}
186          {m.footer && <Text> · {m.footer}</Text>}
187        </Text>
188      )}
189    </Box>
190  )
191}
192
types/index.d.ts 59 lines
1// model: the Skill tool. user: a typed /skill. other: expanded with neither, e.g. a
2// skill preloaded into a subagent.
3export type InvokedBy = 'model' | 'user' | 'other'
4
5export type TraceEntry = {
6  seq: number
7  skill: string
8  source?: string // 'plugin:<name>', 'user', 'builtin', 'mcp'; absent when unknown
9  by: InvokedBy
10  turn: number // the user's prompt count when it loaded
11  at: number // epoch ms
12  args?: string // truncated; model invocations only
13  chars?: number // size of the instructions the model read; absent when none loaded
14  mode?: 'inline' | 'forked'
15  failed?: string // why a Skill call loaded nothing
16}
17
18export type Trace = {
19  entries: TraceEntry[] // chronological, bounded by skills_max_entries
20  counts: Record<string, number> // every load this session, unbounded by skills_max_entries
21  total: number
22}
23
24export type Source = {
25  url: string // normalized: no fragment, no tracking params
26  domain: string // host without a leading www.
27  title?: string
28  fetches: number // times a fetch tool asked for it
29  failures: number // of those, how many failed (error, denied, HTTP >= 400)
30  seen: number // times it came back as a search result
31  queries: string[] // the searches that surfaced it
32  firstTurn: number
33  lastTurn: number
34  lastAt: number // epoch ms
35}
36
37export type Search = {
38  query: string
39  results: number
40  ok: boolean
41  turn: number
42  at: number
43}
44
45export type Ledger = {
46  sources: Source[]
47  searches: Search[]
48}
49
50export type View = 'session' | 'project'
51
52export type Tab = 'skills' | 'sources'
53
54declare module 'claude-code' {
55  interface PluginState {
56    trace: { tab: Tab; skills: Trace; ledger: Ledger; view: View }
57  }
58}
59