See which skills and plugins activated in your Claude Code session, what triggered them, and what they really cost in tokens (with prompt-cache pricing).

A Claude Code mod that shows which skills and plugins have activated in your session, what triggered each one, and what it really costs in tokens.
Skills are cheap to list and expensive to load: once a skill's text is in the conversation, it is re-sent with every later request until the session is compacted or cleared. skill-trace makes that visible.
🧩 3 activations · ≈31k injected · ≈1.3M reread🧩 claude-api (Model) +≈29k/skill-trace:t to switch between chronological order and real-cost order/skill-trace inline prints the same data as a Markdown table in the transcript, so the mod is usable where panes and the status line are not drawn, such as claude.ai/code in a browser./skill-trace also hands the table to the model as context, so you can ask Claude about it.| Source | How it is detected |
|---|---|
| Skill | Any skill load, whether you typed /name, the model called the Skill tool, or it was preloaded into a subagent |
| Plugin MCP tool | A call to an mcp__plugin_<plugin>_… tool. The size of its result counts as injected. |
| Hook | Context injected by a settings or plugin hook, such as a SessionStart hook. Hooks that inject nothing are ignored. |
| Label | Meaning |
|---|---|
| Manual | You typed /skill-name |
| Model | Claude called the Skill tool on its own |
| Chained ← X | Claude loaded it while skill X was already active in the same turn |
| Subagent (type) | It was loaded inside a subagent. These rows are excluded from the main totals. |
| Hook (Event) | A hook injected it. The name is guessed from what was injected, because the API does not say which hook ran. |
Each request re-sends the whole conversation, but most of it is served from the prompt cache:
The model and cache usage are read from each real request, so a model switch in the middle of a session is priced correctly.
/plugin marketplace add madolphe/skill-trace
/plugin install skill-trace@skill-trace
Then run /skill-trace in a session.
Cloud sessions do not load the mod at startup: /plugin is not available there, project-level marketplaces in .claude/settings.json are skipped, and a CLAUDE_CODE_PLUGIN_DIRS variable set in the environment's settings reaches the shell but not the running Claude Code process. The mod is loaded with hot reloading instead, once per session.
At the start of a session, tell Claude:
Install the skill-trace mod by following https://github.com/madolphe/skill-trace#instructions-for-claude-code-agents
Then answer Enable for this session when Claude Code asks "Enable hot reloading for this session?", and run /skill-trace inline. The browser draws no pane or status line, so inline prints the table in the conversation. Activations that happened before the mod loaded are not counted.
Optional: add git clone --depth 1 https://github.com/madolphe/skill-trace /opt/skill-trace to your cloud environment's setup script so the source is already in place.
You were asked to install skill-trace in this session. Do these steps yourself, without asking the user to type anything else:
/opt/skill-trace if it exists. Otherwise run git clone --depth 1 https://github.com/madolphe/skill-trace <your scratchpad>/skill-trace. The mod is the plugins/skill-trace folder of that checkout.plugin-authoring skill with the Skill tool. Its text names this session's mods folder (/root/.claude/dev-mods/<session id>/) and starts the engine's watch on it. Without this step, Claude Code never asks to enable hot reloading.cp -r <checkout>/plugins/skill-trace <mods folder>/skill-trace. Copy the whole folder, including its hidden .claude-plugin/ directory.claude plugin validate <mods folder>/skill-trace. It must end with Validation passed./skill-trace inline (on the web) or /skill-trace (terminal and desktop). If hot reloading was declined, tell the user that the mod loads only once they enable it, and that they can ask for the question again.Do not run /plugin install, and do not edit .claude/settings.json or the environment's variables: none of these load the mod in a cloud session.
| Command | Effect |
|---|---|
/skill-trace | Open or close the pane |
/skill-trace inline | Print the table in the conversation instead of opening the pane, for surfaces that draw no pane (the claude.ai web client) |
/skill-trace nosvg | Draw the bars with characters instead of SVG in the desktop app |
/skill-trace svg | Switch back to SVG bars |
SessionStart hook in an already-running session, does not appear.hooks/model.ts (PRICES). An unknown model is priced at a 0.1 read ratio and its dollar total shows $?.The plugin lives in plugins/skill-trace:
hooks/model.ts pure logic: attribution, costs, formatting (unit-tested)
hooks/register.tsx event hooks, state, status line, toast, pane
types/index.d.ts $.state contract
hooks/*.test.ts(x) tests
claude plugin validate plugins/skill-trace
claude plugin test plugins/skill-trace
claude --plugin-dir plugins/skill-trace
MIT
hooks/register.tsx 277 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Activation, SortMode, Trigger } from '../types'
5import {
6 activeInTurn, applyCompaction, applyStep, classifyTrigger, estimateTokens,
7 compositionBar, compositionSvg, writeShare, shareLabel, cumulative, formatDollars, formatTokens, clockTime, guessHookName, sortRows, totals, triggerColor, isCacheMiss, parseMcpTool, pluginOf, pruneEmptyHooks, withRealCost, shortName, statusText, tableMarkdown, triggerLabel,
8} from './model'
9
10export const PANE = 'skill-trace'
11const SPAWNING = 'spawning' // loop of a skill preloaded while its subagent is being spawned
12const activations = atom({ plugin: 'skill-trace', key: 'activations' } as const, [] as Activation[])
13const sort = atom({ plugin: 'skill-trace', key: 'sort' } as const, 'time' as SortMode)
14
15// Transient markers: a hot reload drops them, the list in $.state stays.
16const live = {
17 manualCommand: undefined as string | undefined,
18 manualIds: [] as number[], // manual activations recorded before their turn.start
19 turnId: undefined as string | undefined,
20 spawning: undefined as string | undefined, // subagent type being spawned
21 agentTypes: new Map<string, string>(), // agentId -> subagent type
22 pendingSkills: new Map<string, string | undefined>(), // short skill name -> agentId
23 useSvg: true, // `/skill-trace nosvg` falls back to character bars on desktop
24}
25
26const agentType = (agentId: string) => live.agentTypes.get(agentId) ?? 'subagent'
27
28const textOf = (content: unknown): string =>
29 Array.isArray(content)
30 ? content.map(b => (b && typeof b === 'object' && 'text' in b && typeof b.text === 'string' ? b.text : '')).join('\n')
31 : ''
32
33// Observer only: every failure is swallowed so the chain never breaks.
34async function add($: EngineInterface, a: Omit<Activation, 'id' | 'at' | 'steps' | 'isCompacted' | 'effective' | 'written' | 'dollars' | 'isPriced'>): Promise<number | undefined> {
35 try {
36 const at = await $.clock.now()
37 let next: Activation[] = []
38 let id = 0
39 await update($, activations, list => {
40 id = (list.at(-1)?.id ?? 0) + 1
41 next = [...list, { ...a, id, at, steps: 0, isCompacted: false, effective: 0, written: 0, dollars: 0, isPriced: true }]
42 return next
43 })
44 $.ui.status(statusText(next))
45 if (a.loop === 'main') $.ui.toast(`🧩 ${a.name} (${triggerLabel(a.trigger)}) +≈${formatTokens(a.injected)}`)
46 return id
47 } catch {
48 return undefined
49 }
50}
51
52async function refresh($: EngineInterface, change: (list: Activation[]) => Activation[]) {
53 try {
54 let next: Activation[] = []
55 await update($, activations, list => (next = change(list)))
56 $.ui.status(statusText(next))
57 } catch {}
58}
59
60async function onSkill($: EngineInterface, skill: string, text: string) {
61 try {
62 const key = shortName(skill)
63 const hasPending = live.pendingSkills.has(key)
64 const pendingAgentId = live.pendingSkills.get(key)
65 const isPreload = !hasPending && live.spawning !== undefined
66 const loop = isPreload ? SPAWNING : (pendingAgentId ?? 'main')
67 const trigger: Trigger = isPreload
68 ? { kind: 'subagent', agentType: live.spawning! }
69 : classifyTrigger({
70 skill,
71 manualCommand: hasPending ? undefined : live.manualCommand,
72 pendingAgentId,
73 agentType: pendingAgentId ? agentType(pendingAgentId) : undefined,
74 activeInTurn: activeInTurn(await read($, activations), loop, live.turnId),
75 })
76 const id = await add($, { name: skill, plugin: pluginOf(skill), source: 'skill', trigger, loop, turnId: live.turnId, injected: estimateTokens(text) })
77 if (trigger.kind === 'manual' && id !== undefined) live.manualIds.push(id)
78 } catch {}
79}
80
81export const register: Register = on => {
82 on('command.run', ($, e, next) => {
83 live.manualCommand = e.command
84 return next(e)
85 })
86
87 on('turn.start', async ($, e, next) => {
88 live.turnId = e.turnId
89 if (live.manualIds.length > 0) {
90 // A typed /skill is expanded before its turn starts: it belongs to this turn.
91 const ids = live.manualIds
92 live.manualIds = []
93 await refresh($, list => list.map(a => (ids.includes(a.id) ? { ...a, turnId: e.turnId } : a)))
94 }
95 return next(e)
96 })
97
98 on('turn.complete', ($, e, next) => {
99 if (!('agentId' in e) || !e.agentId) live.manualCommand = undefined
100 return next(e)
101 })
102
103 on('agent.spawn', async ($, e, next) => {
104 live.spawning = e.subagentType
105 try {
106 const r = await next(e)
107 const agentId = r.agentId
108 if (agentId) {
109 live.agentTypes.set(agentId, e.subagentType)
110 await refresh($, list => list.map(a => (a.loop === SPAWNING ? { ...a, loop: agentId } : a)))
111 }
112 return r
113 } finally {
114 live.spawning = undefined
115 }
116 })
117
118 on('tool.call', async ($, e, next) => {
119 if (e.tool === 'Skill') {
120 const skill = (e as { skill?: unknown }).skill
121 if (typeof skill !== 'string') return next(e)
122 const key = shortName(skill)
123 live.pendingSkills.set(key, e.agentId)
124 try {
125 return await next(e)
126 } finally {
127 live.pendingSkills.delete(key)
128 }
129 }
130 const mcp = parseMcpTool(e.tool)
131 if (!mcp) return next(e)
132 const r = await next(e)
133 await add($, {
134 name: `${mcp.plugin} (${mcp.tool})`,
135 plugin: mcp.plugin,
136 source: 'mcp',
137 trigger: e.agentId ? { kind: 'subagent', agentType: agentType(e.agentId) } : { kind: 'model' },
138 loop: e.agentId ?? 'main',
139 turnId: live.turnId,
140 injected: estimateTokens(r.deny ?? r.text ?? ''),
141 })
142 return r
143 })
144
145 on('skill.prompt', async ($, e, next) => {
146 await onSkill($, e.skill, e.text)
147 return next(e)
148 })
149
150 on('session.append', async ($, e, next) => {
151 const loop = e.agentId ?? 'main'
152 if (e.door === 'compaction') {
153 await refresh($, list => applyCompaction(list, loop))
154 } else if (e.door === 'hook-context' && e.origin.kind === 'hook') {
155 const event = e.origin.event
156 const text = textOf(e.message.content)
157 if (estimateTokens(text) === 0) return next(e) // a hook that injected nothing costs nothing
158 const name = guessHookName(text, event)
159 await add($, { name, plugin: pluginOf(name), source: 'hook', trigger: { kind: 'hook', event }, loop, turnId: live.turnId, injected: estimateTokens(text) })
160 }
161 return next(e)
162 })
163
164 on('turn.step', async function* ($, e, next) {
165 const r = yield* next(e)
166 const step = { model: r.usage?.model ?? e.model, isMiss: isCacheMiss(r.usage) }
167 await refresh($, list => applyStep(list, e.agentId ?? 'main', step))
168 return r
169 })
170
171 on('session.end', async ($, e, next) => {
172 if (e.reason === 'clear') await refresh($, () => [])
173 return next(e)
174 })
175
176 on('session.start', async ($, e, next) => {
177 await refresh($, list => withRealCost(pruneEmptyHooks(list)))
178 try {
179 await $.command.register({ name: 'skill-trace', description: 'Show or hide the skills and plugins activated in this session and their token cost', argumentHint: '[inline | nosvg | svg]' })
180 } catch {}
181 return next(e)
182 })
183
184 on('command.run', { command: 'skill-trace' }, async ($, e) => {
185 if (e.args.trim() === 'nosvg' || e.args.trim() === 'svg') {
186 live.useSvg = e.args.trim() === 'svg'
187 return { text: `Desktop bars: ${live.useSvg ? 'SVG' : 'text'}.` }
188 }
189 const table = tableMarkdown(await read($, activations), await read($, sort))
190 // Surfaces that draw no pane (the web client): the table goes in the transcript.
191 // A title line first: the engine prefixes the plugin's name to the text, which
192 // on the table's header row would break the Markdown table.
193 if (e.args.trim() === 'inline') return { text: `**Skills & plugins**\n\n${table}`, context: [table] }
194 // The engine's record of open panes survives a hot reload; a module flag would not.
195 const pane = (await $.ui.panes()).find(p => p.id === PANE)
196 if (pane?.isShown) {
197 await $.ui.close({ id: PANE })
198 return { text: 'skill-trace pane closed.', context: [table] }
199 }
200 await $.ui.open({ id: PANE, title: 'Skills & plugins' })
201 return { text: 'skill-trace pane opened.', context: [table] }
202 })
203
204 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
205 try {
206 const elements = $.ui.resolve(e)
207 const { Box, Button, Text } = elements
208 const Svg = live.useSvg && e.surface === 'desktop' && 'Svg' in elements ? elements.Svg : undefined
209 const bar = (a: Activation, cells: number) => {
210 const color = triggerColor(a.trigger.kind)
211 return Svg
212 ? <Svg source={compositionSvg(a, color, cells * 10, 10, t.effective)} alt={compositionBar(a, cells, t.effective)} width={cells * 10} height={10} />
213 : <Text color={color}>{compositionBar(a, cells, t.effective)}</Text>
214 }
215 const pct = (x: number) => `${Math.round(x * 100)}%`
216 const mode = await read($, sort)
217 const list = await read($, activations)
218 const rows = sortRows(list, mode)
219 const t = totals(list)
220 const cols = e.props.bodyColumns ?? 80
221 const isNarrow = cols < 70
222 const barCells = Math.max(8, Math.min(16, cols - 24))
223 const costText = `≈${formatTokens(Math.round(t.effective))} ${t.isPriced ? formatDollars(t.dollars) : '$?'}`
224 const tile = (key: string, label: string, value: string) => (
225 <Box key={key} borderStyle="round" borderDimColor flexDirection="column" paddingX={1} flexGrow={1}>
226 <Text dimColor>{label}</Text>
227 <Text bold>{value}</Text>
228 </Box>
229 )
230 return (
231 <Box flexDirection="column" gap={1}>
232 {isNarrow
233 ? <Box key="kpi-line"><Text bold wrap="wrap">Injected ≈{formatTokens(t.injected)} · Reread ≈{formatTokens(t.cumulative)} · Cost {costText}</Text></Box>
234 : <Box flexDirection="row" gap={1}>
235 {tile('kpi-injected', 'Injected', `≈${formatTokens(t.injected)}`)}
236 {tile('kpi-reread', 'Reread', `≈${formatTokens(t.cumulative)}`)}
237 {tile('kpi-cost', 'Real cost', costText)}
238 </Box>}
239 <Box>
240 <Button key="sort" hotkey="t" label={`Sort: ${mode === 'time' ? 'time' : 'real cost'}`}
241 onPress={() => update($, sort, m => (m === 'time' ? 'cumulative' : 'time'))} />
242 </Box>
243 {rows.length === 0 && <Text dimColor>No activations yet.</Text>}
244 {rows.map(a => {
245 const isSub = a.loop !== 'main'
246 const hasCost = (a.effective ?? 0) > 0
247 const steps = a.isCompacted ? `${a.steps} req. (compacted)` : `${a.steps} req.`
248 const split = hasCost ? ` (writes ${pct(writeShare(a))} / reads ${pct(1 - writeShare(a))})` : ''
249 return (
250 <Box key={`row-${a.id}`} flexDirection="column">
251 <Box flexDirection="row" gap={1}>
252 <Text color={triggerColor(a.trigger.kind)} bold>{triggerLabel(a.trigger)}</Text>
253 <Text bold={!isSub} dimColor={isSub} wrap="truncate-end">{a.name}</Text>
254 </Box>
255 <Box key={`bar-${a.id}`} flexDirection="row" gap={1}>
256 {!isSub && hasCost && bar(a, barCells)}
257 <Text dimColor>
258 {isSub ? 'subagent, not in total' : hasCost ? shareLabel(a.effective ?? 0, t.effective, 0).trim() : 'no request yet'}
259 </Text>
260 </Box>
261 <Box key={`detail-${a.id}`}>
262 <Text dimColor wrap="wrap">
263 {clockTime(a.at)} · ≈{formatTokens(a.injected)} injected · {steps} · reread ≈{formatTokens(cumulative(a))} · cost ≈{formatTokens(Math.round(a.effective ?? 0))}{split}
264 </Text>
265 </Box>
266 </Box>
267 )
268 })}
269 </Box>
270 )
271 } catch (err) {
272 const { Text } = $.ui.resolve(e)
273 return <Text color="#e34948">skill-trace: render failed — {String(err)}</Text>
274 }
275 })
276}
277hooks/model.ts 225 lines1import type { Activation, SortMode, Trigger } from '../types'
2
3export const estimateTokens = (text: string): number => Math.ceil(text.length / 3.5)
4
5export const formatTokens = (n: number): string => {
6 if (n < 1000) return String(n)
7 if (n < 9950) return `${(n / 1000).toFixed(1)}k`
8 if (n < 999_500) return `${Math.round(n / 1000)}k`
9 return `${(n / 1_000_000).toFixed(1)}M`
10}
11
12const MCP_PLUGIN = /^mcp__plugin_([^_]+)_(.+?)__(.+)$/
13
14export const parseMcpTool = (tool: string) => {
15 const m = MCP_PLUGIN.exec(tool)
16 return m ? { plugin: m[1], server: m[2], tool: m[3] } : undefined
17}
18
19export const pluginOf = (skill: string): string | undefined =>
20 skill.includes(':') ? skill.split(':')[0] : undefined
21
22export const shortName = (skill: string): string => skill.split(':').pop() ?? skill
23
24export const matchesCommand = (skill: string, command: string | undefined): boolean =>
25 command !== undefined && (command === skill || command === shortName(skill))
26
27export const classifyTrigger = (a: {
28 skill: string
29 manualCommand?: string
30 pendingAgentId?: string
31 agentType?: string
32 activeInTurn?: string
33}): Trigger => {
34 if (matchesCommand(a.skill, a.manualCommand)) return { kind: 'manual' }
35 if (a.pendingAgentId !== undefined) return { kind: 'subagent', agentType: a.agentType ?? 'subagent' }
36 if (a.activeInTurn !== undefined) return { kind: 'chained', from: a.activeInTurn }
37 return { kind: 'model' }
38}
39
40const snippet = (text: string, max = 40): string => {
41 const flat = text.replace(/\s+/g, ' ').trim()
42 if (flat.length <= max) return flat
43 const cut = flat.slice(0, max)
44 return `${cut.slice(0, cut.lastIndexOf(' ') > 0 ? cut.lastIndexOf(' ') : max)}…`
45}
46
47// The API names the hook's event, never the hook: name it from what it injected.
48export const guessHookName = (text: string, event: string): string => {
49 const named = /'([a-z0-9-]+:[a-z0-9-]+)' skill/.exec(text)?.[1] ?? /^name:\s*([\w:-]+)\s*$/m.exec(text)?.[1]
50 if (named) return named
51 const s = snippet(text)
52 return s ? `"${s}"` : `Hook (${event})`
53}
54
55export const pruneEmptyHooks = (list: Activation[]): Activation[] =>
56 list.filter(a => !(a.source === 'hook' && a.injected === 0))
57
58export const triggerLabel = (t: Trigger): string => {
59 switch (t.kind) {
60 case 'manual': return 'Manual'
61 case 'model': return 'Model'
62 case 'chained': return `Chained ← ${shortName(t.from)}`
63 case 'subagent': return `Subagent (${t.agentType})`
64 case 'hook': return `Hook (${t.event})`
65 }
66}
67
68// Cache prices relative to a regular input token (claude-api skill, 2026-09).
69// Claude Code writes with the 1-hour TTL: 2x. Order matters: 5-5 before 5.
70const WRITE = 2
71const PRICES: Array<[RegExp, number, number]> = [
72 [/fable-5-1|mythos-5-1/, 10, 0.025],
73 [/fable-5|mythos-5/, 10, 0.1],
74 [/opus-5-5/, 4, 0.05],
75 [/opus-(5|4-[5-8])/, 5, 0.1],
76 [/sonnet-5-5|sonnet-5/, 2, 0.1],
77 [/sonnet-4/, 3, 0.1],
78 [/haiku-4-5/, 1, 0.1],
79]
80
81export const modelPricing = (model: string): { inputPerM: number | undefined; readRatio: number } => {
82 const hit = PRICES.find(([re]) => re.test(model))
83 return hit ? { inputPerM: hit[1], readRatio: hit[2] } : { inputPerM: undefined, readRatio: 0.1 }
84}
85
86type CacheUsage = { cache_read_input_tokens: number; cache_creation_input_tokens: number }
87
88// More written than read: the context was rewritten (TTL expired, model or tools changed).
89export const isCacheMiss = (usage: CacheUsage | null | undefined): boolean =>
90 !!usage && usage.cache_creation_input_tokens > usage.cache_read_input_tokens
91
92export type StepInfo = { model: string; isMiss: boolean }
93
94export const applyStep = (list: Activation[], loop: string, step: StepInfo = { model: '', isMiss: false }): Activation[] => {
95 const { inputPerM, readRatio } = modelPricing(step.model)
96 return list.map(a => {
97 if (a.loop !== loop || a.isCompacted) return a
98 const isWrite = a.steps === 0 || step.isMiss
99 const cost = a.injected * (isWrite ? WRITE : readRatio)
100 return {
101 ...a,
102 steps: a.steps + 1,
103 effective: (a.effective ?? 0) + cost,
104 written: (a.written ?? 0) + (isWrite ? cost : 0),
105 dollars: (a.dollars ?? 0) + (inputPerM ? (cost * inputPerM) / 1e6 : 0),
106 isPriced: (a.isPriced ?? true) && inputPerM !== undefined,
107 }
108 })
109}
110
111export const formatDollars = (n: number): string => (n < 0.01 ? '<$0.01' : `$${n.toFixed(2)}`)
112
113export const applyCompaction = (list: Activation[], loop: string): Activation[] =>
114 list.map(a => (a.loop === loop ? { ...a, isCompacted: true } : a))
115
116export const activeInTurn = (list: Activation[], loop: string, turnId: string | undefined) =>
117 turnId === undefined
118 ? undefined
119 : [...list].reverse().find(a => a.loop === loop && a.turnId === turnId && a.source === 'skill')?.name
120
121export const cumulative = (a: Activation): number => a.injected * a.steps
122
123export const sortRows = (list: Activation[], mode: SortMode): Activation[] =>
124 mode === 'time' ? [...list].sort((a, b) => a.id - b.id) : [...list].sort((a, b) => (b.effective ?? 0) - (a.effective ?? 0))
125
126export const totals = (list: Activation[]) => {
127 const main = list.filter(a => a.loop === 'main')
128 return {
129 count: main.length,
130 injected: main.reduce((s, a) => s + a.injected, 0),
131 cumulative: main.reduce((s, a) => s + cumulative(a), 0),
132 effective: main.reduce((s, a) => s + (a.effective ?? 0), 0),
133 dollars: main.reduce((s, a) => s + (a.dollars ?? 0), 0),
134 isPriced: main.every(a => a.isPriced ?? true),
135 }
136}
137
138export const statusText = (list: Activation[]): string => {
139 const t = totals(list)
140 if (t.count === 0) return '🧩 0 activation'
141 return `🧩 ${t.count} activation${t.count > 1 ? 's' : ''} · ≈${formatTokens(t.injected)} injected · ≈${formatTokens(t.cumulative)} reread`
142}
143
144const cellText = (s: string) => s.replace(/\|/g, '\\|')
145export const clockTime = (ms: number) => new Date(ms).toTimeString().slice(0, 5)
146
147export const tableMarkdown = (list: Activation[], mode: SortMode): string => {
148 if (list.length === 0) return '_No activations yet._'
149 const rows = sortRows(list, mode).map(a => {
150 const em = (s: string) => (a.loop === 'main' ? s : `_${s}_`)
151 const steps = a.isCompacted ? `${a.steps} compacted` : String(a.steps)
152 return `| ${clockTime(a.at)} | ${em(cellText(a.name))} | ${em(cellText(triggerLabel(a.trigger)))} | ≈${formatTokens(a.injected)} | ${steps} | ≈${formatTokens(cumulative(a))} | ≈${formatTokens(Math.round(a.effective ?? 0))} |`
153 })
154 const t = totals(list)
155 return [
156 '| Time | Skill / plugin | Trigger | Injected | Requests | Reread | Real cost |',
157 '|---|---|---|--:|--:|--:|--:|',
158 ...rows,
159 `| | **Total (main session)** | | **≈${formatTokens(t.injected)}** | | **≈${formatTokens(t.cumulative)}** | **≈${formatTokens(Math.round(t.effective))} (${t.isPriced ? formatDollars(t.dollars) : '$?'})** |`,
160 ].join('\n')
161}
162
163// Rows recorded before real costs existed: no per-step model history, so one
164// write plus reads at the generic ratio, left unpriced.
165export const withRealCost = (list: Activation[]): Activation[] =>
166 list.map(a => {
167 const firstWrite = a.steps === 0 ? 0 : a.injected * WRITE
168 if (a.effective === undefined)
169 return { ...a, effective: a.steps === 0 ? 0 : firstWrite + a.injected * 0.1 * (a.steps - 1), written: firstWrite, dollars: 0, isPriced: false }
170 return a.written === undefined ? { ...a, written: firstWrite } : a
171 })
172
173// A nonzero cost always shows at least one block, so small rows stay visible.
174export const costBar = (value: number, max: number, width: number): string => {
175 const filled = max <= 0 || value <= 0 || width <= 0 ? 0 : Math.min(width, Math.max(1, Math.round((value / max) * width)))
176 return '█'.repeat(filled) + '░'.repeat(width - filled)
177}
178
179const TRIGGER_COLORS: Record<Trigger['kind'], string> = {
180 manual: '#2a78d6',
181 model: '#1baf7a',
182 chained: '#6250d6',
183 subagent: '#eda100',
184 hook: '#898781',
185}
186
187export const triggerColor = (kind: Trigger['kind']): string => TRIGGER_COLORS[kind]
188
189// The bar is a share of the session's total real cost, so the rows add up to 100 %.
190export const shareLabel = (value: number, total: number, width: number): string => {
191 const share = total > 0 && value > 0 ? value / total : 0
192 const pct = share === 0 ? '0' : share < 0.01 ? '<1' : String(Math.round(share * 100))
193 return `${costBar(value, total, width)} ${pct}% of total cost`
194}
195
196export const writeShare = (a: Activation): number =>
197 (a.effective ?? 0) > 0 ? Math.min(1, (a.written ?? 0) / a.effective) : 0
198
199// Same track for every row (100 % = the session's total real cost). The segment
200// is this row's share; inside it, solid = cache writes, hatched = rereads.
201const segment = (a: Activation, width: number, total: number) => {
202 const value = a.effective ?? 0
203 const seg = width <= 0 || total <= 0 || value <= 0 ? 0 : Math.min(width, Math.max(1, Math.round((value / total) * width)))
204 const solid = Math.min(seg, Math.round(seg * writeShare(a)))
205 return { seg, solid }
206}
207
208export const compositionBar = (a: Activation, width: number, total: number): string => {
209 const { seg, solid } = segment(a, width, total)
210 return '█'.repeat(solid) + '▒'.repeat(seg - solid) + '·'.repeat(Math.max(0, width - seg))
211}
212
213export const compositionSvg = (a: Activation, color: string, width: number, height: number, total: number): string => {
214 const { seg, solid } = segment(a, width, total)
215 return [
216 `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">`,
217 `<defs><pattern id="h" width="4" height="4" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">`,
218 `<rect width="2" height="4" fill="${color}"/></pattern></defs>`,
219 `<rect x="0.5" y="0.5" width="${width - 1}" height="${height - 1}" fill="none" stroke="#898781" stroke-width="1" stroke-opacity="0.5"/>`,
220 `<rect x="0" y="0" width="${solid}" height="${height}" fill="${color}"/>`,
221 `<rect x="${solid}" y="0" width="${seg - solid}" height="${height}" fill="url(#h)"/>`,
222 '</svg>',
223 ].join('')
224}
225types/index.d.ts 37 lines1export type Trigger =
2 | { kind: 'manual' }
3 | { kind: 'model' }
4 | { kind: 'chained'; from: string }
5 | { kind: 'subagent'; agentType: string }
6 | { kind: 'hook'; event: string }
7
8export type Activation = {
9 id: number
10 at: number
11 name: string
12 plugin?: string
13 source: 'skill' | 'mcp' | 'hook'
14 trigger: Trigger
15 loop: string
16 turnId?: string
17 injected: number
18 steps: number
19 isCompacted: boolean
20 /** Real cost in input-token equivalents: cache writes 2x, reads at the model's ratio. */
21 effective: number
22 /** The part of `effective` paid as cache writes (first request, cache misses). */
23 written: number
24 /** Real cost in dollars (models with a known price only). */
25 dollars: number
26 /** False once a step ran on a model with no known price. */
27 isPriced: boolean
28}
29
30export type SortMode = 'time' | 'cumulative'
31
32declare module 'claude-code' {
33 interface PluginState {
34 'skill-trace': { activations: Activation[]; sort: SortMode }
35 }
36}
37