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.

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.
| Mod | What it does | How to use it | |
|---|---|---|---|
| savvy-progress | A 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]` |
| skins | Re-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 | |
| filetree | A 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-tax | How 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-radius | For 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 | |
| reflect | Claude 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-theatre | Records 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 |
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.
git clone https://github.com/gauravruhela07/claude-mods
claude --plugin-dir claude-mods/filetree
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/hooks/prices.ts. Claude Code does not report 5-minute and 1-hour cache writes apart, so all writes use the 5-minute price.rg, falling back to grep) of import/require/from/include/use lines. It can miss aliased imports and Go's multi-line import blocks.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 filetreehooks/register.tsx 356 lines1import { 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}
356hooks/math.ts 164 lines1import 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)}`
164hooks/prices.ts 72 lines1/**
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
72types/index.d.ts 70 lines1/** 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