SLOPSHOPPER

cache-clock

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.

newpanespinnercommandnetworktimer
v0.1.0MITupdated 2026-10-06xyzbuilds/cache-clock
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cache-clock
│ ┃ Prompt cache ✕ › fix the failing auth test and add an audit log call │ ┃ ⏱ Prompt cache5-minute cache · not known ye │ ┃ ────────────────────────────────────────── ⏺ Read(src/auth.ts) │ ┃ ──────────── ⎿ Read 6 lines │ ┃ Nothing cached yet: the first reply starts ⏺ Update(src/auth.ts) │ ┃ the clock. ⎿ 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 │ │ › /cache │ ⎿ cache-clock: Prompt cache opened in the side panel. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Prompt cache
⏱ Prompt cache 5-minute cache · not known yet ────────────────────────────────────────────────────── Nothing cached yet: the first reply starts the clock.
README

Cache clock

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)
  • Time left before the cache expires: one hour on a Claude subscription, five minutes on an API key, a cloud provider or usage credits, or what 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.
  • Hit rate: the share of input tokens this session read from the cache.
  • Misses: requests that had to rebuild the cache, each with its likely cause: expired after a pause, a model switch, a compaction, or a change at the start of the request (tools, MCP servers, effort, a plugin).
  • Next: what a short next message costs, at API list prices and before Claude's reply. Once the cache is cold, every token of the conversation is written to the cache again.
  • What to do: once the cache is cold and continuing costs at least three times a fresh start, the line suggests /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.
  • Resumed conversations open on the cold line when their cache has expired, before the first message is sent.

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

Install

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

Settings

In /config: the line under the prompt (on), notices in the conversation (on).

Tests

claude plugin validate .
claude plugin test .

MIT License · © 2026 XYZ Makes LLC

Source 4 files
hooks/register.tsx 389 lines
1import { 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}
389
hooks/cache.ts 131 lines
1import 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
131
hooks/prices.ts 71 lines
1// 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}
71
types/index.d.ts 11 lines
1export 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