SLOPSHOPPER

skill-trace

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

newpaneguardcommandtoaststatus
v0.1.0MITupdated 2026-10-06madolphe/skill-trace/plugins/skill-trace
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · skill-trace
│ ┃ Skills & plugins ✕ › fix the failing auth test and add an audit log call │ ┃ Injected ≈0 · Reread ≈0 · Cost ≈0 <$0.01 │ ┃ ⏺ Read(src/auth.ts) │ ┃ [ Sort: time ] ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ No activations yet. ⎿ 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 │ │ › /skill-trace │ ⎿ skill-trace: skill-trace pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ skill-trace: 🧩 0 activation

Draws

Pane · Skills &amp; plugins
Injected ≈0 · Reread ≈0 · Cost ≈0 <$0.01 [ Sort: time ] No activations yet.
README

skill-trace

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.

What you get

  • A status line entry: 🧩 3 activations · ≈31k injected · ≈1.3M reread
  • A toast each time something activates: 🧩 claude-api (Model) +≈29k
  • A pane, toggled with /skill-trace:
  • three totals: tokens injected, tokens reread, and real cost in token equivalents and dollars
  • one entry per activation with a colored trigger badge, the skill or plugin name, and a cost bar
  • the bar's track is the session's total real cost and is the same for every row. The colored segment is this activation's share of that total. The solid part is cache writes, the hatched part is cache reads.
  • press t to switch between chronological order and real-cost order
  • A table in the conversation: /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.
  • The same data for Claude: /skill-trace also hands the table to the model as context, so you can ask Claude about it.

What counts as an activation

SourceHow it is detected
SkillAny skill load, whether you typed /name, the model called the Skill tool, or it was preloaded into a subagent
Plugin MCP toolA call to an mcp__plugin_<plugin>_… tool. The size of its result counts as injected.
HookContext injected by a settings or plugin hook, such as a SessionStart hook. Hooks that inject nothing are ignored.

Trigger labels

LabelMeaning
ManualYou typed /skill-name
ModelClaude called the Skill tool on its own
Chained ← XClaude 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.

How the cost is computed

Each request re-sends the whole conversation, but most of it is served from the prompt cache:

  • Injected is the estimated size of the text the skill added: characters ÷ 3.5.
  • Reread is injected × the number of requests since activation, as raw tokens. This is not the size of your context window, which only counts each token once.
  • Real cost is computed per request:
  • the first request after activation, and any request that missed the cache, pays a cache write of injected × 2 (1-hour TTL);
  • every other request pays a cache read of injected × the model's read ratio: 0.05 on Opus 5.5, 0.025 on Fable/Mythos 5.1, 0.1 elsewhere.
  • the dollar amount is the cost in token equivalents × the model's input price.

The model and cache usage are read from each real request, so a model switch in the middle of a session is priced correctly.

Install

/plugin marketplace add madolphe/skill-trace
/plugin install skill-trace@skill-trace

Then run /skill-trace in a session.

On Claude Code on the web (claude.ai/code)

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.

Instructions for Claude Code agents

You were asked to install skill-trace in this session. Do these steps yourself, without asking the user to type anything else:

  1. Get the source. Use /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.
  2. Load the 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.
  3. Copy the mod into that folder: cp -r <checkout>/plugins/skill-trace <mods folder>/skill-trace. Copy the whole folder, including its hidden .claude-plugin/ directory.
  4. Check it with claude plugin validate <mods folder>/skill-trace. It must end with Validation passed.
  5. Tell the user, in one short message, to answer Enable for this session when Claude Code asks "Enable hot reloading for this session?". The mod loads when your turn ends.
  6. Confirm at the start of your next turn. The notice says whether the mod loaded. If it did, tell the user to run /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.

Commands

CommandEffect
/skill-traceOpen or close the pane
/skill-trace inlinePrint the table in the conversation instead of opening the pane, for surfaces that draw no pane (the claude.ai web client)
/skill-trace nosvgDraw the bars with characters instead of SVG in the desktop app
/skill-trace svgSwitch back to SVG bars

Limits

  • Early-access API. skill-trace is built on Claude Code's function hooks, which are early access and may change between releases. It was written and tested against Claude Code 2.1.286.
  • Activations before load are not seen. Anything that activated before the mod loaded, such as a SessionStart hook in an already-running session, does not appear.
  • Token counts are estimates. They are ±10–15%, because the mod API has no token-count call.
  • The cache TTL is assumed to be 1 hour. That is what Claude Code uses, and the per-request usage does not report the TTL.
  • Cache-miss detection is a heuristic. A request that wrote more than it read is treated as a miss.
  • Prices are hard-coded as of September 2026 in hooks/model.ts (PRICES). An unknown model is priced at a 0.1 read ratio and its dollar total shows $?.

Development

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

License

MIT

Source 3 files
hooks/register.tsx 277 lines
1import { 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}
277
hooks/model.ts 225 lines
1import 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}
225
types/index.d.ts 37 lines
1export 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