Shows how long Claude Code's prompt cache stays warm, how many tokens your next message re-sends, and what it costs now versus after the cache expires.

A Claude Code mod that makes the prompt cache visible. One line under the prompt:
cache ● 1h ████████████ 59m left · hit 96% · misses 0 · next ≈ $0.05
cache ● 5m ██░░░░░░░░░░ 53s left · hit 36% · misses 1 · next ≈ $0.02
cache ○ cold · next message re-caches 182k tokens ≈ $1.46 · start fresh ≈ $0.18 (/clear)
promptCacheTtl, CLAUDE_CODE_PROMPT_CACHE_TTL, ENABLE_PROMPT_CACHING_1H or FORCE_PROMPT_CACHING_5M set. Green, amber in the last quarter, red once expired./clear with its cost (system prompt, tools and memory only). In the cache's last quarter, with a big conversation, it suggests /compact before stepping away./cache opens the details in the side panel: the countdown, warm and cold cost side by side, what each option costs (keep going, start fresh, compact), every request as ● (read from cache) or ○ (rebuilt), and each miss with its cost. A dim line in the conversation marks the moment the cache expires and each rebuild over 20k tokens.
Prices are Claude API list prices per model, read from Anthropic's pricing page at most once a day and kept across sessions. When the page can't be read or its table changes shape, the last good copy is used, else the built-in list (2026-10-06); /cache shows which. On a subscription the prices show what the same tokens would cost on the API, not a charge.
Needs a Claude Code build with mods. Any terminal.
git clone https://github.com/xyzbuilds/cache-clock.git
claude --plugin-dir ./cache-clock
To load it in every session, add the folder's full path to CLAUDE_CODE_PLUGIN_DIRS in the env block of ~/.claude/settings.json (folders separated by :).
In /config: the line under the prompt (on), notices in the conversation (on).
claude plugin validate .
claude plugin test .
MIT License · © 2026 XYZ Makes LLC
hooks/register.tsx 389 lines1import { atom, read } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Clock, Req, Ttl } from '../types'
5import { adviceOf, bar, baseOf, BIG_REBUILD, classify, compactCost, contextOf, costOf, durationText, FIVE_MIN, freshCost, HOUR, moneyText, nextCost, phaseOf, shortModel, tokensText, ttlOf } from './cache'
6import { BUILT_IN, BUILT_IN_DATE, parsePricing, priceOf, type PriceTable, writePrice } from './prices'
7
8const EMPTY: Clock = {
9 last: null,
10 history: [],
11 misses: [],
12 totals: { requests: 0, input: 0, output: 0, read: 0, write: 0, usd: 0 },
13 ttl: { ms: FIVE_MIN, label: '5-minute cache', why: 'not known yet' },
14 window: 0,
15 base: 0,
16 isWorking: false,
17}
18const clockAtom = atom({ plugin: 'cache-clock', key: 'clock' } as const, EMPTY)
19const nowAtom = atom({ plugin: 'cache-clock', key: 'now' } as const, 0)
20
21const PANE = 'cache-clock'
22const TICK_MS = 5_000
23const SLOW_TICK_MS = 30_000
24
25let isActive = false
26let showLine = true
27let notices = true
28let env: Record<string, string | undefined> = {}
29let setting: unknown
30let ticker: { cancel: () => void } | undefined
31let drawnAt = 0
32let prices: PriceTable = BUILT_IN
33let pricedAt = 0
34const PRICES_MS = 24 * 3_600_000
35
36const priceFor = (model: string) => priceOf(model, prices)
37let noticedExpiry = 0
38
39// ---- prices ------------------------------------------------------------
40
41// Anthropic's pricing page, read at most once a day and kept across sessions. When it can't
42// be read or no longer looks like a price table, the last good copy or the built-in one stays.
43async function loadPrices($: EngineInterface): Promise<void> {
44 const held = (await $.store.get('prices')) as { table: PriceTable; at: number } | undefined
45 if (held?.table) {
46 prices = held.table
47 pricedAt = held.at
48 }
49 const now = await $.clock.now()
50 if (held && now - held.at < PRICES_MS) return
51 const page = await $.http.fetch('https://platform.claude.com/docs/en/about-claude/pricing.md', { headers: { accept: 'text/markdown' } })
52 const table = page.ok ? parsePricing(page.text) : undefined
53 if (!table) return
54 prices = table
55 pricedAt = now
56 await $.store.set('prices', { table, at: now })
57}
58
59// ---- state -------------------------------------------------------------
60
61async function getClock($: EngineInterface): Promise<Clock> {
62 return (await $.state.get({ plugin: 'cache-clock', key: 'clock' })).value ?? EMPTY
63}
64
65async function setClock($: EngineInterface, clock: Clock): Promise<void> {
66 await $.state.set({ plugin: 'cache-clock', key: 'clock' }, clock)
67}
68
69async function currentTtl($: EngineInterface): Promise<{ ttl: Ttl; window: number }> {
70 const usage = await $.session.usage().catch(() => undefined)
71 const limits = usage?.rateLimits ?? []
72 const ttl = ttlOf({
73 env,
74 setting,
75 isSubscription: limits.some(l => l.kind === 'five_hour' || l.kind === 'seven_day'),
76 isOverLimit: limits.some(l => l.percentUsed >= 100),
77 })
78 return { ttl, window: usage?.context.window ?? 0 }
79}
80
81async function recordStep($: EngineInterface, at: number, usage: { model: string; input_tokens: number; output_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number }): Promise<void> {
82 const clock = await getClock($)
83 const { ttl, window } = await currentTtl($)
84 const price = priceFor(usage.model)
85 const tokens = { input: usage.input_tokens, output: usage.output_tokens, read: usage.cache_read_input_tokens, write: usage.cache_creation_input_tokens }
86 const found = classify(clock.last, { ...tokens, at, model: usage.model }, ttl.ms)
87 const req: Req = { at, model: usage.model, ...tokens, ...found, usd: costOf(tokens, price, ttl.ms) }
88 const t = clock.totals
89 await setClock($, {
90 ...clock,
91 ttl,
92 window,
93 last: req,
94 history: [...clock.history, req].slice(-40),
95 misses: req.isMiss ? [...clock.misses, req].slice(-10) : clock.misses,
96 totals: { requests: t.requests + 1, input: t.input + req.input, output: t.output + req.output, read: t.read + req.read, write: t.write + req.write, usd: t.usd + req.usd },
97 })
98 if (clock.base === 0 || t.requests % 20 === 0) await refreshBase($)
99 const wasNoticed = req.cause?.startsWith('expired') && noticedExpiry === clock.last?.at
100 if (notices && req.isMiss && req.write >= BIG_REBUILD && !wasNoticed) {
101 $.ui.log(`Cache rebuilt: ${tokensText(req.write)} tokens written (${req.cause}) · ≈ ${moneyText((req.write * writePrice(price, ttl.ms)) / 1e6)} at API prices`)
102 }
103}
104
105async function refreshBase($: EngineInterface): Promise<void> {
106 const usage = await $.session.usage({ breakdown: 'summary' }).catch(() => undefined)
107 const categories = usage?.context.breakdown?.categories
108 if (!categories) return
109 const clock = await getClock($)
110 await setClock($, { ...clock, base: baseOf(categories) })
111}
112
113// Claude Code says at start how long ago a resumed conversation last got a reply and
114// whether its cache has likely expired; /clear starts a new conversation, /compact a
115// shorter one.
116async function startedFrom($: EngineInterface, e: { source: string; seconds_since_last_response?: number; context_tokens?: number; prompt_cache_likely_expired?: boolean; model?: string }): Promise<void> {
117 const clock = await getClock($)
118 if (e.source === 'clear') return setClock($, { ...EMPTY, ttl: clock.ttl, window: clock.window })
119 if (e.source === 'compact') return setClock($, { ...clock, last: null })
120 if ((e.source !== 'resume' && e.source !== 'fork') || !e.context_tokens || e.seconds_since_last_response === undefined) return
121 const now = await $.clock.now()
122 let { ttl } = await currentTtl($)
123 let at = now - e.seconds_since_last_response * 1000
124 const looksExpired = now - at >= ttl.ms
125 if (e.prompt_cache_likely_expired === false && looksExpired) ttl = { ms: HOUR, label: '1-hour cache', why: 'Claude subscription' }
126 if (e.prompt_cache_likely_expired === true && !looksExpired) at = now - ttl.ms - 1000
127 const seed: Req = { at, model: e.model ?? '', input: 0, output: 0, read: e.context_tokens, write: 0, isMiss: false, usd: 0, isSeed: true }
128 await setClock($, { ...EMPTY, ttl, window: clock.window, last: seed })
129 await refreshBase($)
130 await tick($, true)
131}
132
133async function setWorking($: EngineInterface, isWorking: boolean): Promise<void> {
134 const clock = await getClock($)
135 if (clock.isWorking !== isWorking) await setClock($, { ...clock, isWorking })
136 await tick($, true)
137}
138
139// The line redraws every 30 s, every 5 s in its last two minutes. The expiry notice
140// goes out once per expiry, for a context big enough to matter.
141async function tick($: EngineInterface, force = false): Promise<void> {
142 const now = await $.clock.now()
143 const clock = await getClock($)
144 const left = clock.last ? clock.last.at + clock.ttl.ms - now : Infinity
145 if (force || now - drawnAt >= SLOW_TICK_MS || (left > -TICK_MS && left < 120_000)) {
146 drawnAt = now
147 await $.state.set({ plugin: 'cache-clock', key: 'now' }, now)
148 }
149 const last = clock.last
150 if (!last || clock.isWorking || clock.ttl.isOff || left > 0 || noticedExpiry === last.at) return
151 noticedExpiry = last.at
152 const next = nextCost(last, priceFor(last.model), clock.ttl.ms)
153 if (notices && next.tokens >= BIG_REBUILD) {
154 const advice = adviceOf(last, 'expired', priceFor(last.model), clock.ttl.ms, clock.base)
155 const fresh = advice ? ` Starting fresh with /clear: ≈ ${moneyText(advice.usd)}; save anything you need from this conversation first.` : ''
156 $.ui.log(`Prompt cache expired (${clock.ttl.label}). The next message re-caches ${tokensText(next.tokens)} tokens: ≈ ${moneyText(next.cold)} at API prices, against ${moneyText(next.warm)} while it was warm.${fresh}`)
157 }
158}
159
160async function openPane($: EngineInterface): Promise<void> {
161 await $.ui.open({ id: PANE, title: 'Prompt cache' })
162}
163
164// ---- drawing -----------------------------------------------------------
165
166type View = Parameters<EngineInterface['ui']['resolve']>[0]
167
168const COLOR = { warm: 'green', low: 'yellow', working: 'green', expired: 'red', off: 'gray', none: 'gray' } as const
169
170function lineView($: EngineInterface, e: View, clock: Clock, now: number) {
171 const { Text } = $.ui.resolve(e)
172 const last = clock.last!
173 const phase = phaseOf(last, clock.ttl, clock.isWorking, now)
174 const color = COLOR[phase]
175 const next = nextCost(last, priceFor(last.model), clock.ttl.ms)
176 const t = clock.totals
177 const hit = t.requests ? `${Math.round((100 * t.read) / Math.max(1, t.read + t.write + t.input))}%` : '—'
178 const ttlShort = clock.ttl.ms >= 3_600_000 ? '1h' : '5m'
179 const advice = adviceOf(last, phase, priceFor(last.model), clock.ttl.ms, clock.base)
180 if (phase === 'off') {
181 return (
182 <Text wrap="truncate-end">
183 <Text color="gray">cache off</Text>
184 <Text>{` · each message re-sends ${tokensText(next.tokens)} tokens ≈ ${moneyText(next.cold)}`}</Text>
185 </Text>
186 )
187 }
188 if (phase === 'expired') {
189 return (
190 <Text wrap="truncate-end">
191 <Text color="red">cache ○ cold</Text>
192 <Text>{` · next message re-caches ${tokensText(next.tokens)} tokens ≈ ${moneyText(next.cold)}`}</Text>
193 {advice ? <Text color="cyan">{` · ${advice.text}`}</Text> : <Text dimColor>{` (warm: ${moneyText(next.warm)})`}</Text>}
194 </Text>
195 )
196 }
197 const left = Math.max(0, last.at + clock.ttl.ms - now)
198 const fraction = phase === 'working' ? 1 : left / clock.ttl.ms
199 const cells = bar(fraction, 12)
200 const filled = cells.replace(/░/g, '')
201 return (
202 <Text wrap="truncate-end">
203 <Text color={color}>{`cache ● ${ttlShort} `}</Text>
204 <Text color={color}>{filled}</Text>
205 <Text dimColor>{cells.slice(filled.length)}</Text>
206 <Text color={color}>{phase === 'working' ? ' in use' : ` ${durationText(left)} left`}</Text>
207 <Text>{` · hit ${hit} · misses ${clock.misses.length} · next ≈ ${moneyText(next.warm)}`}</Text>
208 {advice && <Text color="cyan">{` · ${advice.text}`}</Text>}
209 </Text>
210 )
211}
212
213function paneView($: EngineInterface, e: View, clock: Clock, now: number, width: number) {
214 const { Box, Text } = $.ui.resolve(e)
215 const rule = '─'.repeat(width)
216 const last = clock.last
217 const header = (
218 <Box flexDirection="row" justifyContent="space-between">
219 <Text bold>⏱ Prompt cache</Text>
220 <Text dimColor>{`${clock.ttl.label} · ${clock.ttl.why}`}</Text>
221 </Box>
222 )
223 if (!last) {
224 return (
225 <Box flexDirection="column" paddingX={1}>
226 {header}
227 <Text dimColor>{rule}</Text>
228 <Text dimColor>Nothing cached yet: the first reply starts the clock.</Text>
229 </Box>
230 )
231 }
232 const price = priceFor(last.model)
233 const next = nextCost(last, price, clock.ttl.ms)
234 const phase = phaseOf(last, clock.ttl, clock.isWorking, now)
235 const left = Math.max(0, last.at + clock.ttl.ms - now)
236 const barWidth = Math.max(10, width - 22)
237 const costWidth = Math.max(10, width - 26)
238 const t = clock.totals
239 const hit = Math.round((100 * t.read) / Math.max(1, t.read + t.write + t.input))
240 const fresh = clock.base > 0 ? freshCost(clock.base, price, clock.ttl.ms) : undefined
241 const compact = compactCost(last, price)
242 const options: string[] =
243 phase === 'expired'
244 ? [
245 `Continue: ≈ ${moneyText(next.cold)} for the next message, then ≈ ${moneyText(next.warm)} a message while warm.`,
246 ...(fresh !== undefined ? [`Start fresh with /clear: ≈ ${moneyText(fresh)}. Claude forgets this conversation; save what you need to a file first.`] : []),
247 `/compact first: re-reads all ${tokensText(next.tokens)} tokens without the cache to summarize them, then each message is small.`,
248 ]
249 : [
250 `Keep going: ≈ ${moneyText(next.warm)} a short message while warm.`,
251 `Stepping away for longer than ${phase === 'working' ? (clock.ttl.ms >= HOUR ? 'an hour' : '5 minutes') : durationText(left)}? /compact now ≈ ${moneyText(compact)}; once cold, the next message is ≈ ${moneyText(next.cold)}.`,
252 ]
253 return (
254 <Box flexDirection="column" paddingX={1}>
255 {header}
256 <Text dimColor>{rule}</Text>
257 <Text color={COLOR[phase]}>
258 {phase === 'expired' ? `${bar(0, barWidth)} expired` : `${bar(phase === 'working' ? 1 : left / clock.ttl.ms, barWidth)} ${phase === 'working' ? 'in use' : `${durationText(left)} left`}`}
259 </Text>
260 <Box flexDirection="column" marginTop={1}>
261 <Text bold color="blue">NEXT MESSAGE</Text>
262 <Text wrap="wrap">{`Re-sends ${tokensText(next.tokens)} tokens${clock.window ? ` of the ${tokensText(clock.window)} window` : ''}.`}</Text>
263 <Text>
264 <Text color="green">{`cache warm ${bar(next.warm / Math.max(next.cold, 1e-9), costWidth)} `}</Text>
265 <Text>{moneyText(next.warm)}</Text>
266 </Text>
267 <Text>
268 <Text color="red">{`cache expired ${bar(1, costWidth)} `}</Text>
269 <Text>{moneyText(next.cold)}</Text>
270 </Text>
271 <Text dimColor wrap="wrap">{`${price.name}${price.isGuess ? ' (price unknown, Opus 5.5 assumed)' : ''} at API prices, before Claude's reply. Prices: ${pricedAt ? `Anthropic's pricing page, read ${durationText(now - pricedAt)} ago` : `built-in list of ${BUILT_IN_DATE}`}.`}</Text>
272 </Box>
273 <Box flexDirection="column" marginTop={1}>
274 <Text bold color="cyan">WHAT TO DO</Text>
275 {options.map((o, i) => (
276 <Text key={`o-${i}`} wrap="wrap">{`• ${o}`}</Text>
277 ))}
278 </Box>
279 <Text dimColor>{rule}</Text>
280 <Box flexDirection="column">
281 <Text bold color="magenta">THIS SESSION</Text>
282 <Text>
283 {clock.history.map((r, i) => (
284 <Text key={`h-${i}`} color={r.isMiss ? 'red' : 'green'}>
285 {r.isMiss ? '○' : '●'}
286 </Text>
287 ))}
288 </Text>
289 <Text wrap="wrap">{`hit ${hit}% · ${t.requests} requests · ${tokensText(t.read)} read from cache · ${tokensText(t.write)} written · ≈ ${moneyText(t.usd)} at API prices`}</Text>
290 {clock.misses.length > 0 && (
291 <Box flexDirection="column" marginTop={1}>
292 <Text dimColor>Rebuilt:</Text>
293 {clock.misses.map((m, i) => (
294 <Text key={`m-${i}`} wrap="wrap">{`${durationText(now - m.at)} ago · ${tokensText(m.write)} tokens · ${m.cause ?? 'unknown'} · ${moneyText(m.usd)}`}</Text>
295 ))}
296 </Box>
297 )}
298 </Box>
299 <Text dimColor>{rule}</Text>
300 <Text wrap="wrap">
301 <Text bold>Resets the cache: </Text>
302 <Text>/model, fast mode on, adding an MCP server, /compact, a long pause.</Text>
303 </Text>
304 <Text wrap="wrap">
305 <Text bold>Keeps it: </Text>
306 <Text>editing files, skills, /rewind, subagents, permission modes.</Text>
307 </Text>
308 <Text dimColor wrap="wrap">{`Last request on ${shortModel(last.model)} · context ${tokensText(contextOf(last))} tokens.`}</Text>
309 </Box>
310 )
311}
312
313// ---- the mod -----------------------------------------------------------
314
315export const register: Register = (on, options) => {
316 showLine = options.showLine !== false
317 notices = options.notices !== false
318
319 on('session.start', async ($, e, next) => {
320 const started = await next(e)
321 isActive = e.isInteractive
322 if (!isActive) return started
323 env = {
324 DISABLE_PROMPT_CACHING: await $.env.get('DISABLE_PROMPT_CACHING'),
325 FORCE_PROMPT_CACHING_5M: await $.env.get('FORCE_PROMPT_CACHING_5M'),
326 CLAUDE_CODE_PROMPT_CACHE_TTL: await $.env.get('CLAUDE_CODE_PROMPT_CACHE_TTL'),
327 ENABLE_PROMPT_CACHING_1H: await $.env.get('ENABLE_PROMPT_CACHING_1H'),
328 }
329 setting = ((await $.settings.read().catch(() => undefined)) as Record<string, unknown> | undefined)?.promptCacheTtl
330 await $.command.register({ name: 'cache', description: 'Prompt cache: time left, next message size and cost, hits and misses', immediate: true })
331 ticker?.cancel()
332 ticker = $.clock.every(TICK_MS, () => void tick($).catch(() => undefined))
333 $.clock.after(2_000, () => void loadPrices($).catch(() => undefined))
334 return started
335 })
336
337 on('turn.step', async function* ($, e, next) {
338 if (!isActive || e.agentId !== undefined) return yield* next(e)
339 const at = await $.clock.now()
340 const result = yield* next(e)
341 const usage = result.usage
342 if (usage) $.clock.after(0, () => void recordStep($, at, usage).catch(() => undefined))
343 return result
344 })
345
346 on('classic.SessionStart', async ($, e, next) => {
347 const done = await next(e)
348 $.clock.after(0, () => void startedFrom($, e).catch(() => undefined))
349 return done
350 })
351
352 on('turn.start', async ($, e, next) => {
353 if (isActive) await setWorking($, true)
354 return next(e)
355 })
356
357 on('turn.complete', async ($, e, next) => {
358 const done = await next(e)
359 if (isActive && e.agentId === undefined) $.clock.after(0, () => void setWorking($, false).catch(() => undefined))
360 return done
361 })
362
363 on('command.run', { command: 'cache' }, async $ => {
364 await openPane($)
365 return { text: 'Prompt cache opened in the side panel.' }
366 })
367
368 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
369 const engine = await next(e)
370 if (!isActive || !showLine) return engine
371 const clock = await read($, clockAtom)
372 const now = (await read($, nowAtom)) || (await $.clock.now())
373 if (!clock.last) return engine
374 const { Box } = $.ui.resolve(e)
375 return (
376 <Box flexDirection="column">
377 {lineView($, e, clock, now)}
378 {engine}
379 </Box>
380 )
381 })
382
383 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
384 const clock = await read($, clockAtom)
385 const now = (await read($, nowAtom)) || (await $.clock.now())
386 return paneView($, e, clock, now, Math.max(20, e.props.bodyColumns - 2))
387 })
388}
389hooks/cache.ts 131 lines1import type { Req, Ttl } from '../types'
2import { type Price, writePrice } from './prices'
3
4export const HOUR = 3_600_000
5export const FIVE_MIN = 300_000
6export const SHORT_PROMPT = 200
7export const BIG_REBUILD = 20_000
8export const BIG_CONTEXT = 50_000
9export const SUMMARY_TOKENS = 6_000
10
11const on = (value: string | undefined) => value === '1' || value?.toLowerCase() === 'true'
12const ttl = (ms: number, why: string): Ttl => ({ ms, label: ms === HOUR ? '1-hour cache' : '5-minute cache', why })
13
14// Claude Code's own order: FORCE_PROMPT_CACHING_5M, CLAUDE_CODE_PROMPT_CACHE_TTL, the
15// promptCacheTtl setting, ENABLE_PROMPT_CACHING_1H, then the default for the account.
16export function ttlOf(o: { env: Record<string, string | undefined>; setting?: unknown; isSubscription: boolean; isOverLimit: boolean }): Ttl {
17 if (on(o.env.DISABLE_PROMPT_CACHING)) return { ms: 0, label: 'caching off', why: 'DISABLE_PROMPT_CACHING', isOff: true }
18 if (on(o.env.FORCE_PROMPT_CACHING_5M)) return ttl(FIVE_MIN, 'FORCE_PROMPT_CACHING_5M')
19 const chosen = o.env.CLAUDE_CODE_PROMPT_CACHE_TTL
20 if (chosen === '5m' || chosen === '1h') return ttl(chosen === '1h' ? HOUR : FIVE_MIN, 'CLAUDE_CODE_PROMPT_CACHE_TTL')
21 if (o.setting === '5m' || o.setting === '1h') return ttl(o.setting === '1h' ? HOUR : FIVE_MIN, 'promptCacheTtl setting')
22 if (on(o.env.ENABLE_PROMPT_CACHING_1H)) return ttl(HOUR, 'ENABLE_PROMPT_CACHING_1H')
23 if (o.isSubscription && !o.isOverLimit) return ttl(HOUR, 'Claude subscription')
24 return ttl(FIVE_MIN, o.isSubscription ? 'subscription on usage credits' : 'API key or cloud provider')
25}
26
27type Usage = { input: number; output: number; read: number; write: number }
28
29export const contextOf = (r: Usage) => r.input + r.read + r.write + r.output
30const prefixOf = (r: Usage) => r.input + r.read + r.write
31
32export function costOf(u: Usage, price: Price, ttlMs: number): number {
33 return (u.input * price.input + u.read * price.read + u.write * writePrice(price, ttlMs) + u.output * price.output) / 1e6
34}
35
36// A request that had to write most of what the one before it had cached rebuilt the cache.
37export function classify(prev: Req | null, cur: Usage & { at: number; model: string }, ttlMs: number): { isMiss: boolean; cause?: string } {
38 const prefix = prefixOf(cur)
39 if (cur.write < 2000) return { isMiss: false }
40 if (!prev) return cur.read >= prefix / 2 ? { isMiss: false } : { isMiss: true, cause: 'first message of the session' }
41 if (cur.read >= 0.8 * prefixOf(prev)) return { isMiss: false }
42 if (!prev.isSeed && prev.model && cur.model && prev.model !== cur.model) return { isMiss: true, cause: `switched model (${shortModel(prev.model)} → ${shortModel(cur.model)})` }
43 if (ttlMs > 0 && cur.at - prev.at > ttlMs) return { isMiss: true, cause: `expired after ${durationText(cur.at - prev.at)} idle` }
44 if (prefix < 0.6 * prefixOf(prev)) return { isMiss: true, cause: 'conversation compacted or cleared' }
45 return { isMiss: true, cause: 'the start of the request changed (tools, MCP servers, effort or a plugin)' }
46}
47
48export function nextCost(last: Req, price: Price, ttlMs: number): { tokens: number; warm: number; cold: number } {
49 const cached = prefixOf(last)
50 const fresh = last.output + SHORT_PROMPT
51 const write = writePrice(price, ttlMs)
52 return { tokens: cached + fresh, warm: (cached * price.read + fresh * write) / 1e6, cold: ((cached + fresh) * write) / 1e6 }
53}
54
55export function shortModel(model: string): string {
56 const name = model.replace(/^.*claude-/, '').replace(/-\d{8}$/, '').replace(/-(\d)-(\d)$/, ' $1.$2').replace(/-(\d)$/, ' $1')
57 return name.charAt(0).toUpperCase() + name.slice(1)
58}
59
60export function tokensText(n: number): string {
61 if (n >= 1e6) return `${(n / 1e6).toFixed(n >= 1e7 ? 0 : 1).replace(/\.0$/, '')}M`
62 if (n >= 1000) return `${Math.round(n / 1000)}k`
63 return String(n)
64}
65
66export function moneyText(usd: number): string {
67 if (usd < 0.01) return '<$0.01'
68 return usd < 10 ? `$${usd.toFixed(2)}` : `$${Math.round(usd)}`
69}
70
71export function durationText(ms: number): string {
72 const s = Math.max(0, Math.floor(ms / 1000))
73 if (s < 60) return `${s}s`
74 const m = Math.floor(s / 60)
75 if (m < 60) return `${m}m`
76 const h = Math.floor(m / 60)
77 return m % 60 ? `${h}h ${m % 60}m` : `${h}h`
78}
79
80export const bar = (fraction: number, width: number) => {
81 const filled = Math.round(Math.min(1, Math.max(0, fraction)) * width)
82 return '█'.repeat(filled) + '░'.repeat(width - filled)
83}
84
85export type Phase = 'none' | 'working' | 'warm' | 'low' | 'expired' | 'off'
86
87export function phaseOf(last: Req | null, ttl: Ttl, isWorking: boolean, now: number): Phase {
88 if (ttl.isOff) return 'off'
89 if (!last) return 'none'
90 if (isWorking) return 'working'
91 const left = last.at + ttl.ms - now
92 if (left <= 0) return 'expired'
93 return left < ttl.ms / 4 ? 'low' : 'warm'
94}
95
96// A new conversation (/clear) writes only what every conversation starts with: system
97// prompt, tools, CLAUDE.md and memory.
98export function freshCost(base: number, price: Price, ttlMs: number): number {
99 return ((base + SHORT_PROMPT) * writePrice(price, ttlMs)) / 1e6
100}
101
102// /compact while the cache is warm reads the conversation from the cache and writes a summary.
103export function compactCost(last: Req, price: Price): number {
104 return (prefixOf(last) * price.read + SUMMARY_TOKENS * price.output) / 1e6
105}
106
107export type Advice = { kind: 'fresh' | 'compact'; usd: number; text: string }
108
109// Starting fresh is suggested once the cache is cold and continuing costs at least three
110// times as much; compacting, in the cache's last quarter with a big conversation.
111export function adviceOf(last: Req, phase: Phase, price: Price, ttlMs: number, base: number): Advice | undefined {
112 const next = nextCost(last, price, ttlMs)
113 if (next.tokens < BIG_CONTEXT || base <= 0) return undefined
114 if (phase === 'expired') {
115 const usd = freshCost(base, price, ttlMs)
116 return next.cold >= 3 * usd ? { kind: 'fresh', usd, text: `start fresh ≈ ${moneyText(usd)} (/clear)` } : undefined
117 }
118 if (phase === 'low') {
119 const usd = compactCost(last, price)
120 return next.cold >= 3 * usd ? { kind: 'compact', usd, text: `stepping away? /compact ≈ ${moneyText(usd)}` } : undefined
121 }
122 return undefined
123}
124
125const BASE_SKIP = new Set(['Messages', 'Free space', 'Autocompact buffer', 'Compact buffer'])
126
127export function baseOf(categories: readonly { name: string; tokens: number; isDeferred?: boolean }[]): number {
128 return categories.filter(c => !BASE_SKIP.has(c.name) && !c.isDeferred).reduce((n, c) => n + c.tokens, 0)
129}
130
131hooks/prices.ts 71 lines1// Claude API list prices in US dollars per million tokens. The mod reads Anthropic's pricing
2// page (platform.claude.com/docs/en/about-claude/pricing.md) once a day; this built-in copy of
3// it (2026-10-06) is the fallback.
4export type Price = { name: string; input: number; write5m: number; write1h: number; read: number; output: number; isGuess?: boolean }
5export type PriceTable = Record<string, Price>
6
7export const BUILT_IN_DATE = '2026-10-06'
8
9const row = (name: string, input: number, write5m: number, write1h: number, read: number, output: number): Price => ({ name, input, write5m, write1h, read, output })
10
11export const BUILT_IN: PriceTable = {
12 'fable-5-1': row('Fable 5.1', 10, 12.5, 20, 0.25, 50),
13 'mythos-5-1': row('Mythos 5.1', 10, 12.5, 20, 0.25, 50),
14 'fable-5': row('Fable 5', 10, 12.5, 20, 1, 50),
15 'mythos-5': row('Mythos 5', 10, 12.5, 20, 1, 50),
16 'opus-5-5': row('Opus 5.5', 4, 5, 8, 0.2, 20),
17 'opus-5': row('Opus 5', 5, 6.25, 10, 0.5, 25),
18 'opus-4-8': row('Opus 4.8', 5, 6.25, 10, 0.5, 25),
19 'opus-4-7': row('Opus 4.7', 5, 6.25, 10, 0.5, 25),
20 'opus-4-6': row('Opus 4.6', 5, 6.25, 10, 0.5, 25),
21 'opus-4-5': row('Opus 4.5', 5, 6.25, 10, 0.5, 25),
22 'opus-4-1': row('Opus 4.1', 15, 18.75, 30, 1.5, 75),
23 'opus-4': row('Opus 4', 15, 18.75, 30, 1.5, 75),
24 'sonnet-5-5': row('Sonnet 5.5', 2, 2.5, 4, 0.2, 10),
25 'sonnet-5': row('Sonnet 5', 2, 2.5, 4, 0.2, 10),
26 'sonnet-4-6': row('Sonnet 4.6', 3, 3.75, 6, 0.3, 15),
27 'sonnet-4-5': row('Sonnet 4.5', 3, 3.75, 6, 0.3, 15),
28 'sonnet-4': row('Sonnet 4', 3, 3.75, 6, 0.3, 15),
29 'haiku-4-5': row('Haiku 4.5', 1, 1.25, 2, 0.1, 5),
30 'haiku-3-5': row('Haiku 3.5', 0.8, 1, 1.6, 0.08, 4),
31}
32
33// The longest key a model id contains wins, so claude-opus-5-5 is Opus 5.5, not Opus 5.
34export function priceOf(model: string, table: PriceTable = BUILT_IN): Price {
35 const id = model.toLowerCase()
36 const key = Object.keys(table)
37 .sort((a, b) => b.length - a.length)
38 .find(k => id.includes(k))
39 const opus = table['opus-5-5'] ?? BUILT_IN['opus-5-5']!
40 return key ? table[key]! : { ...opus, name: model || 'this model', isGuess: true }
41}
42
43export const writePrice = (price: Price, ttlMs: number) => (ttlMs >= 3_600_000 ? price.write1h : price.write5m)
44
45const dollars = (cell: string) => {
46 const match = /\$([\d.]+)\s*\/\s*MTok/.exec(cell)
47 return match ? Number(match[1]) : NaN
48}
49
50// Reads the model table of Anthropic's pricing page: Model | Base input | 5m cache writes |
51// 1h cache writes | Cache hits | Output. Anything that does not look like it is refused.
52export function parsePricing(markdown: string): PriceTable | undefined {
53 const lines = markdown.split('\n')
54 const header = lines.findIndex(l => /^\|\s*Model\s*\|/.test(l) && l.includes('5m cache writes') && l.includes('1h cache writes'))
55 if (header < 0) return undefined
56 const table: PriceTable = {}
57 for (const line of lines.slice(header + 2)) {
58 if (!line.startsWith('|')) break
59 const cells = line.split('|').slice(1, -1).map(c => c.trim())
60 if (cells.length < 6) continue
61 const name = cells[0]!.replace(/\(.*\)/, '').replace(/<[^>]+>/g, '').trim()
62 const values = cells.slice(1, 6).map(dollars)
63 if (!/^Claude /.test(name) || values.some(v => !(v > 0))) continue
64 const short = name.replace(/^Claude /, '')
65 const [input, write5m, write1h, read, output] = values as [number, number, number, number, number]
66 table[short.toLowerCase().replace(/[ .]+/g, '-')] = row(short, input, write5m, write1h, read, output)
67 }
68 const looksRight = Object.keys(table).length >= 5 && ['opus', 'sonnet', 'haiku'].every(f => Object.keys(table).some(k => k.startsWith(f)))
69 return looksRight ? table : undefined
70}
71types/index.d.ts 11 lines1export type Ttl = { ms: number; label: string; why: string; isOff?: boolean }
2export type Req = { at: number; model: string; input: number; output: number; read: number; write: number; isMiss: boolean; cause?: string; usd: number; isSeed?: boolean }
3export type Totals = { requests: number; input: number; output: number; read: number; write: number; usd: number }
4export type Clock = { last: Req | null; history: Req[]; misses: Req[]; totals: Totals; ttl: Ttl; window: number; base: number; isWorking: boolean }
5
6declare module 'claude-code' {
7 interface PluginState {
8 'cache-clock': { clock: Clock; now: number }
9 }
10}
11