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…

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
/<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
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).WebSearch but were never fetched.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.
/plugin marketplace add Hula-Hoop-AI/supermods
/plugin install {m}@supermods
All options are rows in /config; a change applies at once.
| Option | Default | Effect |
|---|---|---|
skills_max_entries | 200 | How 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_args | true | Record and show the arguments of each load (truncated to 80 characters). Off, arguments are never stored. |
skills_status_line | false | Show skills: N in the status line under the prompt. |
sources_citation_format | markdown | markdown writes - Title; numbered writes [1] Title. url. |
sources_cite_search_results | false | Also cite pages that only appeared in search results. Off, only successfully fetched pages are cited. |
sources_extra_tools | empty | Comma-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_arg | url | The argument that holds the URL in the extra tools' calls. |
sources_project_history | false | Also 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. |
From claude plugin validate --strict:
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.$.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.sources_project_history, sources also go to the plugin's store, a JSON file under your Claude Code config directory, keyed by project path.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.disable-model-invocation) is not recorded when typed, since the listing is how the mod tells skills from plain commands.curl or a subagent's own MCP server reads outside tool.call are not seen./clear empties both tabs, as it does all session state. Project history stays.hooks/register.tsx 123 lines1import { 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}
123hooks/recorders/ledger.ts 181 lines1import 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}
181hooks/recorders/skills.ts 207 lines1import { 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}
207hooks/recorders/sources.ts 135 lines1import { 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}
135hooks/recorders/tab.ts 5 lines1import 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'>
5hooks/tab-pane.tsx 192 lines1// 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}
192types/index.d.ts 59 lines1// 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