SLOPSHOPPER

cache-tax

How well the prompt cache works and what the cache tax costs: hit %, cost and the money paid above all-cache-read, per turn, with cache-bust alerts.

newpanecommandtoaststatus
v0.1.0no licenseupdated 2026-10-10gauravruhela07/claude-mods/cache-tax
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cache-tax
│ ┃ Cache tax ✕ › fix the failing auth test and add an audit log call │ ┃ Session · 1 turn · Opus 5.5 │ ┃ hit 93% · cost $0.08 · tax $0.03 (37% of ⏺ Read(src/auth.ts) │ ┃ cost) ⎿ Read 6 lines │ ┃ in 2.1k · write 4.3k · read 91k · out 1.5k ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ Turns, newest first ⏺ Bash(bun test) │ ┃ # input write read outpu hit cost ⎿ 3 pass, 1 fail │ ┃ t │ ┃ 1 2.1k 4.3k 91k 1.5k 93% $0.08 $0 ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ Lifetime, all sessions and subagents: 1 turn ✻ Worked for 42s · done 4:20 PM │ ┃ · hit 93% · cost $0.08 · tax $0.03 (37% of │ ┃ cost) › /cache-tax │ ┃ Cache writes priced at the 5-minute rate: ⎿ cache-tax: Cache tax pane opened. This session: cache 93% · tax │ ┃ the usage does not split 5-minute from │ ┃ 1-hour writes. │ ┃ Tax = cost - the same tokens with every │ ┃ input token a cache read. Prices: claude-api │ ┃ skill, read 2026-10-11. │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ cache-tax: cache 93% · tax $0.03

Draws

Pane · Cache tax
Session · 1 turn · Opus 5.5 hit 93% · cost $0.08 · tax $0.03 (37% of cost) in 2.1k · write 4.3k · read 91k · out 1.5k Turns, newest first # input write read outpu hit cost tax t 1 2.1k 4.3k 91k 1.5k 93% $0.08 $0.03 Lifetime, all sessions and subagents: 1 turn · hit 93% · cost $0.08 · tax $0.03 (37% of cost) Cache writes priced at the 5-minute rate: the usage does not split 5-minute from 1-hour writes. Tax = cost - the same tokens with every input token a cache read. Prices: claude-api skill, read 2026-10-11.
README

claude-mods

Seven mods for Claude Code. Each one is a plugin of function hooks: it draws panes, status lines and toasts inside Claude Code, in the terminal and in the desktop app's Code tab.

ModWhat it doesHow to use it
savvy-progressA live band above the prompt while Claude works: what it is doing now ("Reading format.ts · 4s"), elapsed time, steps, files read and changed, errors, and a progress bar: the real task list when Claude keeps one, otherwise a time bar guessed from how long your past turns took (clearly labelled a guess). A one-line summary after each turn.Shows by itself. `/savvy-progress [on\off]`
skinsRe-skins the spinner (glyphs, verbs, color), the turn's closing line and the prompt hints. Skins: matrix, pirate, zen, retro, space. Your choice is remembered./skin (picker), /skin matrix, /skin off
filetreeA live file-tree pane of the project. Marks the files Claude read (R), changed (M) and created (A) this session, and opens the folders they live in./filetree, /filetree touched, /filetree all, /filetree close
cache-taxHow well the prompt cache works, and the "tax": the money paid above the ideal where every input token is a cache read. Status line cache 94% · tax $0.12, a per-turn table, and a toast with the likely cause when the cache busts./cache-tax, /cache-tax reset
blast-radiusFor each file Claude edits, the other files that import it; a flag on wide-reaching shell commands (git reset --hard, rm -rf, force push, ...). Shows only, never blocks./blast-radius, /blast-radius session, /blast-radius clear
reflectClaude critiques its own recent work: what may be wrong or missed, risky assumptions, what to check next. One button puts the follow-ups in your prompt box (it never sends them). Optional auto mode, off by default./reflect [focus], /reflect show, `/reflect auto on\off`
replay-theatreRecords the session and plays it back like a film: scene by scene, step by step, with play/pause, step, speed and a timeline./replay, /replay last, /replay copy; keys k play, j/l step, h/n scene, s speed, r restart

Install

In a Claude Code terminal session, install any mod by name:

/plugin install savvy-progress --marketplace gauravruhela07/claude-mods

Answer y to add the marketplace, then pick a scope (user scope loads it in every session). Repeat for each mod you want: skins, filetree, cache-tax, blast-radius, reflect, replay-theatre.

A mod installed from a terminal at the user scope also loads in the desktop app's Code tab.

Try one from a clone, without installing

git clone https://github.com/gauravruhela07/claude-mods
claude --plugin-dir claude-mods/filetree

Notes

  • Cost: only reflect calls the model, and only when you run /reflect or turn auto mode on. It asks over the session's own transcript, so most of it is served from the prompt cache.
  • cache-tax prices are first-party API list prices as of 2026-10-11, in cache-tax/hooks/prices.ts. Claude Code does not report 5-minute and 1-hour cache writes apart, so all writes use the 5-minute price.
  • blast-radius finds dependents with a text search (rg, falling back to grep) of import/require/from/include/use lines. It can miss aliased imports and Go's multi-line import blocks.
  • These mods use the Claude Code function-hooks API, which is early access and can change between releases. They were built and tested on Claude Code 2.1.29x.

Develop

Each mod folder holds .claude-plugin/plugin.json, hooks/ (the hooks module), types/index.d.ts (its state contract) and tests/.

claude plugin validate filetree
claude plugin test filetree
Source 4 files
hooks/register.tsx 356 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, TurnUsage } from 'claude-code'
3
4import type { Buckets, Lifetime, Totals, Turn } from '../types'
5import {
6  KEEP_TURNS,
7  addTurn,
8  bucketsOf,
9  causeOf,
10  duration,
11  emptyTotals,
12  hitOf,
13  isBust,
14  moneyOf,
15  pct,
16  promptOf,
17  statusOf,
18  tokens,
19  totalUsd,
20  totalsFrom,
21  usd,
22} from './math'
23import type { Money } from './math'
24import { PRICES_SOURCE, cardOf, priceOf } from './prices'
25
26const PANE = 'cache-tax'
27const SHOWN_TURNS = 20
28
29const turns = atom({ plugin: 'cache-tax', key: 'turns' } as const, [])
30const session = atom({ plugin: 'cache-tax', key: 'session' } as const, emptyTotals())
31const subagents = atom({ plugin: 'cache-tax', key: 'subagents' } as const, emptyTotals())
32const lifetime = atom({ plugin: 'cache-tax', key: 'lifetime' } as const, null)
33const previous = atom({ plugin: 'cache-tax', key: 'previous' } as const, null)
34const probes = atom({ plugin: 'cache-tax', key: 'probes' } as const, [])
35const KEEP_PROBES = 32
36
37const USAGE = "Usage: /cache-tax opens the pane; /cache-tax reset clears this session's numbers. Nothing changed."
38
39const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`
40
41/** Prices one turn's tokens; null when the model is not in the table. */
42const priced = (usage: TurnUsage, b: Buckets, requests: number): { money: Money | null; write5m: number | null } => {
43  const price = priceOf(usage.model)
44  if (price === undefined) return { money: null, write5m: null }
45  const card = cardOf(price, promptOf(b) / Math.max(1, requests))
46
47  return { money: moneyOf(b, card), write5m: card.write5m }
48}
49
50const lifetimeFrom = (raw: unknown, now: number): Lifetime => {
51  const since = (raw as { since?: unknown } | undefined)?.since
52
53  return { ...totalsFrom(raw), since: typeof since === 'number' ? since : now }
54}
55
56// Lifetime writes go one after another, so a main turn and a subagent's ending
57// together do not both read the same old value.
58let storeQueue: Promise<unknown> = Promise.resolve()
59
60const addLifetime = ($: EngineInterface, b: Buckets, money: Money | null, model: string, isBusted: boolean, now: number) => {
61  const run = storeQueue.then(async () => {
62    const was = lifetimeFrom(await $.store.get('lifetime'), now)
63    const next: Lifetime = { ...addTurn(was, b, money, model, isBusted), since: was.since }
64    await $.store.set('lifetime', next)
65    await update($, lifetime, () => next)
66  })
67  storeQueue = run.catch(() => undefined)
68
69  return run
70}
71
72type Column = { id: string; head: string; width: number; rank: number; cell: (t: Turn) => string }
73
74// rank: which columns stay when the pane is narrow (lower stays longer).
75const COLUMNS: readonly Column[] = [
76  { id: 'n', head: '#', width: 5, rank: 0, cell: t => `${t.n}${t.isBusted ? '!' : ''}` },
77  { id: 'time', head: 'time', width: 6, rank: 8, cell: t => duration(t.durationMs) },
78  { id: 'input', head: 'input', width: 6, rank: 6, cell: t => tokens(t.input) },
79  { id: 'write', head: 'write', width: 6, rank: 3, cell: t => tokens(t.write) },
80  { id: 'read', head: 'read', width: 6, rank: 5, cell: t => tokens(t.read) },
81  { id: 'output', head: 'output', width: 6, rank: 7, cell: t => tokens(t.output) },
82  { id: 'hit', head: 'hit', width: 4, rank: 1, cell: t => pct(t.hit) },
83  { id: 'cost', head: 'cost', width: 7, rank: 4, cell: t => usd(t.cost) },
84  { id: 'tax', head: 'tax', width: 7, rank: 2, cell: t => usd(t.tax) },
85]
86
87/** The columns that fit `width` cells (one cell between columns), in table order. */
88const columnsFor = (width: number) => {
89  const kept = new Set<string>()
90  let used = 0
91  for (const col of [...COLUMNS].sort((a, b) => a.rank - b.rank)) {
92    const need = col.width + (kept.size === 0 ? 0 : 1)
93    if (used + need > width) break
94    kept.add(col.id)
95    used += need
96  }
97
98  return COLUMNS.filter(col => kept.has(col.id))
99}
100
101const summaryOf = (t: Totals) => {
102  const share = t.cost > 0 ? ` (${pct(t.tax / t.cost)} of cost)` : ''
103
104  return `hit ${pct(hitOf(t))} · cost ${totalUsd(t, t.cost)} · tax ${totalUsd(t, t.tax)}${t.priced > 0 ? share : ''}`
105}
106
107const bucketsLine = (t: Totals) =>
108  `in ${tokens(t.input)} · write ${tokens(t.write)} · read ${tokens(t.read)} · out ${tokens(t.output)}`
109
110export const register: Register = on => {
111  on('session.start', async ($, e, next) => {
112    await $.command.register({
113      name: 'cache-tax',
114      description: 'Prompt cache hit rate and the cache tax: open the pane, or reset the session numbers',
115      argumentHint: '[reset]',
116      immediate: true,
117    })
118    const now = await $.clock.now()
119    const raw = await $.store.get('lifetime')
120    if (raw !== undefined) await update($, lifetime, () => lifetimeFrom(raw, now))
121    const t = await read($, session)
122    if (t.turns > 0) $.ui.status(statusOf(t))
123
124    return next(e)
125  })
126
127  // A /clear starts a new conversation: its first big write is a cold cache, not a bust.
128  on('session.end', async ($, e, next) => {
129    if (e.reason === 'clear') await update($, previous, () => null)
130
131    return next(e)
132  })
133
134  // Per request of a turn: how many requests, and the biggest single cache
135  // write. A long turn writes a little on every step; only one request writing
136  // a lot is a re-write of the cache. The count also picks Haiku 5.5's card,
137  // which is chosen by one request's prompt, not the turn's sum.
138  on('turn.step', async function* ($, e, next) {
139    const res = yield* next(e)
140    if (res.usage !== null) {
141      const turnId = e.turnId
142      const write = res.usage.cache_creation_input_tokens
143      await update($, probes, list => {
144        const was = list.find(one => one.turnId === turnId)
145        const now = was === undefined
146          ? { turnId, requests: 1, maxWrite: write }
147          : { turnId, requests: was.requests + 1, maxWrite: Math.max(was.maxWrite, write) }
148
149        return [...list.filter(one => one.turnId !== turnId), now].slice(-KEEP_PROBES)
150      })
151    }
152
153    return res
154  })
155
156  on('turn.complete', async ($, e, next) => {
157    const res = await next(e)
158    const usage = e.usage
159    if (usage === undefined) return res
160
161    const now = await $.clock.now()
162    const b = bucketsOf(usage)
163    const step = (await read($, probes)).find(one => one.turnId === e.turnId)
164    if (step !== undefined) await update($, probes, list => list.filter(one => one.turnId !== e.turnId))
165
166    if (e.agentId !== undefined) {
167      const { money } = priced(usage, b, step?.requests ?? 1)
168      await update($, subagents, t => addTurn(t, b, money, usage.model, false))
169      await addLifetime($, b, money, usage.model, false, now)
170
171      return res
172    }
173
174    const { money, write5m } = priced(usage, b, step?.requests ?? 1)
175    const hit = hitOf(b)
176    const before = await read($, previous)
177    const rewrite = step?.maxWrite ?? b.write
178    const isBusted = isBust(rewrite, hit, before)
179    let cause: string | undefined
180
181    if (isBusted && before !== null) {
182      cause = causeOf(now - e.durationMs - before.endedAt, usage.model, before)
183      const price = write5m === null ? null : (rewrite * write5m) / 1e6
184      $.ui.toast(`Cache busted: ${tokens(rewrite)} tokens re-written (~${usd(price)}). Likely cause: ${cause}.`, {
185        timeoutMs: 8000,
186      })
187    }
188
189    const totals = await update($, session, t => addTurn(t, b, money, usage.model, isBusted))
190    const row: Turn = {
191      ...b,
192      n: totals.turns,
193      turnId: e.turnId,
194      endedAt: now,
195      durationMs: e.durationMs,
196      model: usage.model,
197      hit,
198      cost: money?.cost ?? null,
199      ideal: money?.ideal ?? null,
200      tax: money?.tax ?? null,
201      isBusted,
202      ...(cause === undefined ? {} : { cause }),
203    }
204    await update($, turns, list => [...list, row].slice(-KEEP_TURNS))
205    await update($, previous, () => ({ endedAt: now, model: usage.model, hit }))
206    $.ui.status(statusOf(totals))
207    await addLifetime($, b, money, usage.model, isBusted, now)
208
209    return res
210  })
211
212  on('command.run', { command: 'cache-tax' }, async ($, e) => {
213    const arg = e.args.trim().toLowerCase()
214
215    if (arg === '') {
216      const opened = await $.ui.open({ id: PANE, title: 'Cache tax' })
217      const t = await read($, session)
218      const now = t.turns === 0 ? 'No turns measured yet.' : `This session: ${statusOf(t)} over ${plural(t.turns, 'turn')}.`
219      const wait = opened.isPlaced ? '' : ' It shows once the window is wide enough.'
220
221      return { text: `Cache tax pane opened. ${now}${wait}` }
222    }
223
224    if (arg === 'reset') {
225      await update($, turns, () => [])
226      await update($, session, () => emptyTotals())
227      await update($, subagents, () => emptyTotals())
228      $.ui.status(undefined)
229
230      return { text: "Cache tax: this session's numbers are cleared. Lifetime totals are kept." }
231    }
232
233    return { text: USAGE }
234  })
235
236  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
237    const { Box, Text } = $.ui.resolve(e)
238    const width = Math.max(20, e.props.bodyColumns)
239    const list = await read($, turns)
240    const t = await read($, session)
241    const subs = await read($, subagents)
242    const life = await read($, lifetime)
243    const cols = columnsFor(width)
244    const shown = list.slice(-SHOWN_TURNS).reverse()
245    const last = list[list.length - 1]
246    const rows = []
247
248    if (t.turns === 0) {
249      rows.push(
250        <Box key="empty" flexDirection="column">
251          <Text dimColor wrap="wrap">
252            No turns measured yet. The numbers fill in as Claude answers.
253          </Text>
254        </Box>,
255      )
256    } else {
257      const model = last === undefined ? '' : ` · ${priceOf(last.model)?.name ?? last.model}`
258      rows.push(
259        <Box key="session" flexDirection="column">
260          <Text bold wrap="wrap">
261            Session · {plural(t.turns, 'turn')}
262            {model}
263          </Text>
264          <Text wrap="wrap">{summaryOf(t)}</Text>
265          <Text dimColor wrap="wrap">
266            {bucketsLine(t)}
267          </Text>
268          {t.busts > 0 && (
269            <Text color="warning" wrap="wrap">
270              {plural(t.busts, 'cache bust')} this session
271            </Text>
272          )}
273        </Box>,
274      )
275    }
276
277    if (subs.turns > 0) {
278      rows.push(
279        <Box key="subagents" flexDirection="column" marginTop={1}>
280          <Text wrap="wrap">
281            Subagents · {plural(subs.turns, 'turn')} · {summaryOf(subs)}
282          </Text>
283        </Box>,
284      )
285    }
286
287    if (shown.length > 0) {
288      rows.push(
289        <Box key="table-note" marginTop={1}>
290          <Text dimColor wrap="truncate-end">
291            {list.length > shown.length ? `Last ${shown.length} turns, newest first` : 'Turns, newest first'}
292          </Text>
293        </Box>,
294      )
295      rows.push(
296        <Box key="head" flexDirection="row">
297          {cols.map((col, i) => (
298            <Box key={`head-${col.id}`} width={col.width} marginLeft={i === 0 ? 0 : 1} justifyContent="flex-end">
299              <Text bold>{col.head}</Text>
300            </Box>
301          ))}
302        </Box>,
303      )
304      for (const turn of shown) {
305        rows.push(
306          <Box key={`turn-${turn.n}`} flexDirection="row">
307            {cols.map((col, i) => (
308              <Box
309                key={`turn-${turn.n}-${col.id}`}
310                width={col.width}
311                marginLeft={i === 0 ? 0 : 1}
312                justifyContent="flex-end"
313              >
314                <Text color={turn.isBusted ? 'warning' : undefined} wrap="truncate-end">
315                  {col.cell(turn)}
316                </Text>
317              </Box>
318            ))}
319          </Box>,
320        )
321      }
322      const busted = shown.find(turn => turn.isBusted && turn.cause !== undefined)
323      if (busted !== undefined) {
324        rows.push(
325          <Box key="bust" flexDirection="column">
326            <Text color="warning" wrap="wrap">
327              ! #{busted.n} re-wrote the cache: {busted.cause}
328            </Text>
329          </Box>,
330        )
331      }
332    }
333
334    const notes = []
335    if (life !== null && life.turns > 0) {
336      notes.push(`Lifetime, all sessions and subagents: ${plural(life.turns, 'turn')} · ${summaryOf(life)}`)
337    }
338    notes.push('Cache writes priced at the 5-minute rate: the usage does not split 5-minute from 1-hour writes.')
339    notes.push(`Tax = cost - the same tokens with every input token a cache read. Prices: ${PRICES_SOURCE}.`)
340    const unknown = [...new Set([...t.unknown, ...subs.unknown])]
341    if (unknown.length > 0) notes.push(`$? = no price for ${unknown.join(', ')}.`)
342
343    rows.push(
344      <Box key="footer" flexDirection="column" marginTop={1}>
345        {notes.map(note => (
346          <Text dimColor wrap="wrap">
347            {note}
348          </Text>
349        ))}
350      </Box>,
351    )
352
353    return <Box flexDirection="column">{rows}</Box>
354  })
355}
356
hooks/math.ts 164 lines
1import type { ModelUsage } from 'claude-code'
2
3import type { Buckets, Previous, Totals } from '../types'
4import type { Card } from './prices'
5
6/** A turn re-wrote the cache when one request wrote more than this many tokens. */
7export const BUST_TOKENS = 50_000
8/** How many turns the session keeps for the table. */
9export const KEEP_TURNS = 200
10const FIVE_MIN = 5 * 60_000
11const HOUR = 60 * 60_000
12const UNKNOWN_KEPT = 5
13
14export type Money = { cost: number; ideal: number; tax: number }
15
16export const bucketsOf = (usage: ModelUsage): Buckets => ({
17  input: usage.input_tokens,
18  write: usage.cache_creation_input_tokens,
19  read: usage.cache_read_input_tokens,
20  output: usage.output_tokens,
21})
22
23/** Every input token of the prompt, cached or not. */
24export const promptOf = (b: Buckets) => b.input + b.write + b.read
25
26/** Share of the prompt the cache served; null when there was no prompt. */
27export const hitOf = (b: Buckets): number | null => {
28  const prompt = promptOf(b)
29
30  return prompt > 0 ? b.read / prompt : null
31}
32
33/**
34 * What the tokens cost on `card`, what they would cost if every input token
35 * were a cache read (`ideal`), and the difference (`tax`). Cache writes are
36 * priced at the 5-minute rate: the usage does not split 5m from 1h writes.
37 */
38export const moneyOf = (b: Buckets, card: Card): Money => {
39  const cost = (b.input * card.input + b.write * card.write5m + b.read * card.read + b.output * card.output) / 1e6
40  const ideal = (promptOf(b) * card.read + b.output * card.output) / 1e6
41
42  return { cost, ideal, tax: cost - ideal }
43}
44
45export const emptyTotals = (): Totals => ({
46  turns: 0,
47  input: 0,
48  write: 0,
49  read: 0,
50  output: 0,
51  priced: 0,
52  cost: 0,
53  ideal: 0,
54  tax: 0,
55  busts: 0,
56  unknown: [],
57})
58
59/** Totals with one more turn added. */
60export const addTurn = (t: Totals, b: Buckets, money: Money | null, model: string, isBusted: boolean): Totals => ({
61  turns: t.turns + 1,
62  input: t.input + b.input,
63  write: t.write + b.write,
64  read: t.read + b.read,
65  output: t.output + b.output,
66  priced: t.priced + (money === null ? 0 : 1),
67  cost: t.cost + (money?.cost ?? 0),
68  ideal: t.ideal + (money?.ideal ?? 0),
69  tax: t.tax + (money?.tax ?? 0),
70  busts: t.busts + (isBusted ? 1 : 0),
71  unknown:
72    money !== null || model === '' || t.unknown.includes(model)
73      ? t.unknown
74      : [...t.unknown, model].slice(-UNKNOWN_KEPT),
75})
76
77const num = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) ? v : 0)
78
79/** A Totals from whatever JSON the store held (a missing or odd field reads as 0). */
80export const totalsFrom = (raw: unknown): Totals => {
81  const o = (typeof raw === 'object' && raw !== null ? raw : {}) as Record<string, unknown>
82  const unknown = Array.isArray(o.unknown) ? o.unknown.filter((m): m is string => typeof m === 'string') : []
83
84  return {
85    turns: num(o.turns),
86    input: num(o.input),
87    write: num(o.write),
88    read: num(o.read),
89    output: num(o.output),
90    priced: num(o.priced),
91    cost: num(o.cost),
92    ideal: num(o.ideal),
93    tax: num(o.tax),
94    busts: num(o.busts),
95    unknown: unknown.slice(-UNKNOWN_KEPT),
96  }
97}
98
99/**
100 * A busted turn re-wrote a large part of the cache it should have read: the
101 * biggest write is over 50k tokens, and the turn mostly missed or the turn
102 * before mostly hit. The first turn after a start is a cold cache, not a bust.
103 */
104export const isBust = (write: number, hit: number | null, previous: Previous | null) =>
105  previous !== null &&
106  hit !== null &&
107  write > BUST_TOKENS &&
108  (hit < 0.5 || (previous.hit !== null && previous.hit > 0.8))
109
110/** Why a busted turn likely re-wrote the cache. */
111export const causeOf = (gapMs: number, model: string, previous: Previous) => {
112  const causes: string[] = []
113  if (gapMs > HOUR) causes.push('idle past the 1-hour cache life')
114  else if (gapMs > FIVE_MIN) causes.push('idle past the 5-minute cache life')
115  if (previous.model !== '' && previous.model !== model) causes.push('model switched')
116
117  return causes.length > 0 ? causes.join(' and ') : 'the prompt prefix changed (system prompt, tools, or compaction)'
118}
119
120/** 950, 1.2k, 45k, 3.4M. */
121export const tokens = (n: number) => {
122  if (n < 1000) return String(Math.round(n))
123  if (n < 9_950) return `${(n / 1000).toFixed(1)}k`
124  if (n < 999_500) return `${Math.round(n / 1000)}k`
125  if (n < 9_950_000) return `${(n / 1e6).toFixed(1)}M`
126
127  return `${Math.round(n / 1e6)}M`
128}
129
130/** $0.12, $0.004, $12.30, $123; $? when unknown. */
131export const usd = (v: number | null) => {
132  if (v === null) return '$?'
133  const a = Math.abs(v)
134  const sign = v < 0 ? '-' : ''
135  if (a === 0) return '$0.00'
136  if (a < 0.01) return `${sign}$${a.toFixed(3)}`
137  if (a < 100) return `${sign}$${a.toFixed(2)}`
138
139  return `${sign}$${Math.round(a)}`
140}
141
142/** 94%; – when there is nothing to measure. */
143export const pct = (v: number | null) => (v === null ? '–' : `${Math.round(v * 100)}%`)
144
145/** 42s, 3m05s, 1h02m. */
146export const duration = (ms: number) => {
147  const s = Math.max(0, Math.round(ms / 1000))
148  if (s < 60) return `${s}s`
149  const m = Math.floor(s / 60)
150  if (m < 60) return `${m}m${String(s % 60).padStart(2, '0')}s`
151
152  return `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}m`
153}
154
155/** A sum of money over totals: `$?` when nothing was priced, `+?` when part was not. */
156export const totalUsd = (t: Totals, v: number) => {
157  if (t.turns > 0 && t.priced === 0) return '$?'
158
159  return t.priced < t.turns ? `${usd(v)}+?` : usd(v)
160}
161
162/** The status line: `cache 94% · tax $0.12`. */
163export const statusOf = (t: Totals) => `cache ${pct(hitOf(t))} · tax ${totalUsd(t, t.tax)}`
164
hooks/prices.ts 72 lines
1/**
2 * Prices, US dollars per million tokens, Anthropic first-party API rates.
3 *
4 * Source: the `claude-api` skill bundled with Claude Code 2.1.293 (its model
5 * table "cached: 2026-10-06", shared/models.md, the pricing notes in
6 * shared/model-migration.md and "Economics" in shared/prompt-caching.md),
7 * read on 2026-10-11. Cache writes are 1.25x input (5-minute) and 2x input
8 * (1-hour); cache reads 0.1x input, except where the skill names another rate:
9 * Fable 5.1 / Mythos 5.1 $0.25, Opus 5.5 $0.20, Fable 5 / Mythos 5 $1.00.
10 * Haiku 5.5 has a second card for prompts over 100K tokens ($0.50 / $2.50).
11 * Bedrock and Vertex bill their own rates; fast mode and batch are not priced.
12 */
13
14export type Card = {
15  input: number
16  output: number
17  write5m: number
18  write1h: number
19  read: number
20}
21
22export type Price = {
23  /** Matched against the model id as a whole segment (`opus-5` is not `opus-5-5`). */
24  family: string
25  name: string
26  card: Card
27  /** A card that applies when one request's prompt is longer than `above` tokens. */
28  long?: { above: number; card: Card }
29}
30
31export const PRICES_SOURCE = 'claude-api skill, read 2026-10-11'
32
33// prettier-ignore
34export const PRICES: readonly Price[] = [
35  { family: 'fable-5-1',  name: 'Fable 5.1',  card: { input: 10,   output: 50,   write5m: 12.5,  write1h: 20,   read: 0.25 } },
36  { family: 'mythos-5-1', name: 'Mythos 5.1', card: { input: 10,   output: 50,   write5m: 12.5,  write1h: 20,   read: 0.25 } },
37  { family: 'fable-5',    name: 'Fable 5',    card: { input: 10,   output: 50,   write5m: 12.5,  write1h: 20,   read: 1 } },
38  { family: 'mythos-5',   name: 'Mythos 5',   card: { input: 10,   output: 50,   write5m: 12.5,  write1h: 20,   read: 1 } },
39  { family: 'opus-5-5',   name: 'Opus 5.5',   card: { input: 4,    output: 20,   write5m: 5,     write1h: 8,    read: 0.2 } },
40  { family: 'opus-5',     name: 'Opus 5',     card: { input: 5,    output: 25,   write5m: 6.25,  write1h: 10,   read: 0.5 } },
41  { family: 'opus-4-8',   name: 'Opus 4.8',   card: { input: 5,    output: 25,   write5m: 6.25,  write1h: 10,   read: 0.5 } },
42  { family: 'opus-4-7',   name: 'Opus 4.7',   card: { input: 5,    output: 25,   write5m: 6.25,  write1h: 10,   read: 0.5 } },
43  { family: 'opus-4-6',   name: 'Opus 4.6',   card: { input: 5,    output: 25,   write5m: 6.25,  write1h: 10,   read: 0.5 } },
44  { family: 'sonnet-5-5', name: 'Sonnet 5.5', card: { input: 2,    output: 10,   write5m: 2.5,   write1h: 4,    read: 0.2 } },
45  { family: 'sonnet-5',   name: 'Sonnet 5',   card: { input: 2,    output: 10,   write5m: 2.5,   write1h: 4,    read: 0.2 } },
46  { family: 'sonnet-4-6', name: 'Sonnet 4.6', card: { input: 3,    output: 15,   write5m: 3.75,  write1h: 6,    read: 0.3 } },
47  { family: 'haiku-5-5',  name: 'Haiku 5.5',  card: { input: 0.1,  output: 0.5,  write5m: 0.125, write1h: 0.2,  read: 0.01 },
48    long: { above: 100_000, card:             { input: 0.5,  output: 2.5,  write5m: 0.625, write1h: 1,    read: 0.05 } } },
49  { family: 'haiku-4-5',  name: 'Haiku 4.5',  card: { input: 1,    output: 5,    write5m: 1.25,  write1h: 2,    read: 0.1 } },
50]
51
52const escape = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
53
54/**
55 * Whether `family` names this model id: the family stands as whole segments,
56 * not followed by a further version number (`opus-5` does not match
57 * `claude-opus-5-5`, but does match `claude-opus-5-20260101` or `opus-5[1m]`).
58 */
59const isFamily = (id: string, family: string) =>
60  new RegExp(`(^|[^a-z0-9])${escape(family)}(?![-.]\\d{1,2}(?!\\d))(?![a-z0-9])`).test(id)
61
62/** The price row for a model id, or undefined when the table has none. */
63export const priceOf = (model: string): Price | undefined => {
64  const id = model.toLowerCase()
65
66  return PRICES.find(row => isFamily(id, row.family))
67}
68
69/** The card a request of `promptPerRequest` input tokens is billed on. */
70export const cardOf = (price: Price, promptPerRequest: number): Card =>
71  price.long !== undefined && promptPerRequest > price.long.above ? price.long.card : price.card
72
types/index.d.ts 70 lines
1/** Token counts of one turn (or a sum of turns), by prompt-cache bucket. */
2export type Buckets = {
3  /** Uncached input tokens, billed at the full input price. */
4  input: number
5  /** Tokens written to the prompt cache. */
6  write: number
7  /** Tokens read from the prompt cache. */
8  read: number
9  /** Generated tokens. */
10  output: number
11}
12
13/** One main-loop turn as the pane's table shows it. */
14export type Turn = Buckets & {
15  /** Turn number in this session (1-based, restarts after a reset). */
16  n: number
17  turnId: string
18  /** When the turn ended, `$.clock.now()` milliseconds. */
19  endedAt: number
20  durationMs: number
21  /** Model id the API reported for the turn's last response. */
22  model: string
23  /** read / (input + write + read); null when the turn sent no input. */
24  hit: number | null
25  /** Dollars; null when the model is not in the price table. */
26  cost: number | null
27  /** What the same tokens would cost if every input token were a cache read. */
28  ideal: number | null
29  /** cost - ideal. */
30  tax: number | null
31  isBusted: boolean
32  /** Why the cache was likely re-written, set on a busted turn. */
33  cause?: string
34}
35
36/** Running totals over many turns. Money sums cover priced turns only. */
37export type Totals = Buckets & {
38  turns: number
39  /** Turns whose model had a price (the money sums cover these). */
40  priced: number
41  cost: number
42  ideal: number
43  tax: number
44  busts: number
45  /** Model ids seen that are not in the price table (a few, for the footer). */
46  unknown: string[]
47}
48
49/** Lifetime totals kept in `$.store` under `lifetime`, across sessions. */
50export type Lifetime = Totals & { since: number }
51
52/** What bust detection needs from the previous main-loop turn. */
53export type Previous = { endedAt: number; model: string; hit: number | null }
54
55/** Per-request facts of a running turn (main loop or a subagent's), from `turn.step`. */
56export type Probe = { turnId: string; requests: number; maxWrite: number }
57
58declare module 'claude-code' {
59  interface PluginState {
60    'cache-tax': {
61      turns: Turn[]
62      session: Totals
63      subagents: Totals
64      lifetime: Lifetime | null
65      previous: Previous | null
66      probes: Probe[]
67    }
68  }
69}
70