Passive 50-minute prompt-cache reminder with a keyboard/mouse panel: keep warm, ping once, or compact, with live cost estimates.

Part of claude-mods. Moved here from the archived
danyuchn/claude-cache-panelrepo.
A quiet Claude Code mod that tells you when your prompt cache is about to go cold, and lets you decide in one click what to do about it: keep it warm, ping it once, or compact the conversation, with live cost estimates for each.
It does nothing until you have been idle for 50 minutes. No auto mode, no background spending you did not ask for.

The panel on a 96k-token Opus 5.5 conversation: keeping warm for 4 hours costs about $0.08, compacting $0.34, doing nothing $0.75.
The reminder sound is herdr's "done" chime, the one that goes with the green dot in the herdr agents sidebar.
Claude Code's prompt cache lasts one hour. Come back after 61 minutes to a 300k-token conversation and the whole thing is re-written at the (much higher) cache-write price. Most of the time that does not matter. When you know you are about to be away for a while, it does.
Existing cache keep-alive tools run on a timer all the time. This one is built for a different habit: ignore the cache normally, think about it only when I am leaving.
Normally: nothing on screen, nothing spent.
After 50 idle minutes (cache still warm, context large enough to be worth saving):
[ cache cools in 10m ] (a clickable button; shortened to [ cache 10m ] in narrow panes)Click the button, or type /warm, to open a small panel (keyboard: Up/Down + Enter; mouse works too):
| Control | What it does |
|---|---|
| Away (hours) | How long you will be gone. Blank = 4. |
| Cost rows | Keep warm vs. Compact vs. Do nothing, for that many hours. |
Best: line | The cheapest option, and how many hours keep-warm stays ahead. |
| Keep warm | Pings every 50 minutes until the window ends. |
| Ping once | One ping, about one more hour of warmth. |
| Compact now | Runs the built-in /compact. |
While warming, the band shows [ warm 3h58m ]. Typing does not stop it. When the window ends you get a one-line toast that dismisses itself. /warm off stops early.
Every 50 minutes the mod sends a tiny hidden request (Reply with exactly: ok) with $.model.fork, which answers over the same conversation prefix. The server serves it from your existing cache and restarts the one-hour timer. The request never appears in your transcript and does not grow your context.
Measured on Claude Code 2.1.289 with the cache TTL temporarily shortened to 5 minutes:
Caveat: in a brand-new session (about one turn old) the first ping writes a fresh cache entry instead of reading yours, so it costs like one rewrite; later pings read it normally. The mod tolerates one such miss and stops after two in a row.
The reminder only fires when letting the cache go cold would cost at least about $0.30 (API-equivalent), with a floor of 30k context tokens. Examples:
| Model | Reminder threshold |
|---|---|
| Opus 5 | ~31.6k tokens |
| Opus 5.5 | ~38.5k tokens |
| Sonnet 5.5 | ~78.9k tokens |
| Haiku 4.5 | ~157.9k tokens |
| Fable 5.1 | 30k (floor) |
Cost estimates use two bases and pick automatically: API list prices, or, when your account reports subscription rate limits, community-measured quota weights (cache read 0.028, 1h write 1.2, output 5, as multiples of one plain input token). The subscription weights were measured on only two models and are applied to the rest, so treat them as approximate. Prices were checked against the official pricing page on 2026-10-04; edit hooks/model.ts if they change.
Requires Claude Code 2.1.287 or newer (the version that introduced mods). Developed and tested on 2.1.289 on macOS.
claude plugin marketplace add danyuchn/claude-mods
claude plugin install cache-panel@dustin-mods
Already open sessions pick it up after /reload-plugins.
To try it from a clone for one session only:
git clone https://github.com/danyuchn/claude-mods.git
claude --plugin-dir claude-mods/cache-panel
Check the mod loads: claude plugin validate claude-mods/cache-panel.
The notification goes through herdr notification show, so you get sound only inside a herdr pane (the mod checks HERDR_ENV=1). Outside herdr you still get the on-screen button, just no sound.
CACHE_PANEL_FAST=1 claude --plugin-dir claude-mods/cache-panel
Fast mode reminds after 1 minute, pings every 2 minutes, and skips the context threshold. Send one message, wait a minute, and the button appears. Do not leave this mode on.
claude plugin test may refuse to run on your account if the mods rollout flag is off (it did on mine). tests/model.test.ts holds the cost-model assertions; the same checks run fine under plain Deno.shown: true); whether it matches your ear is up to your herdr settings.Ideas and pull requests are welcome.
.claude-plugin/plugin.json manifest
hooks/hooks.json points at the module
hooks/register.tsx events, timers, band + panel UI
hooks/model.ts pure cost model (no engine calls)
types/index.d.ts shape of the mod's $.state values
tests/model.test.ts cost-model assertions
MIT, see the repository license.
hooks/register.tsx 473 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { PingInfo, Snap, Warm } from '../types'
5import {
6 DEFAULT_HOURS,
7 HOUR,
8 MAX_HOURS,
9 PING_EVERY_MS,
10 TTL_MS,
11 fmtDur,
12 fmtHours,
13 fmtK,
14 fmtUsd,
15 makePlan,
16 parseHours,
17 pickLabel,
18 priceOf,
19 optionRows,
20 thresholdTokens,
21} from './model.ts'
22
23// Passive reminder at minute 50 of idle, one panel to decide keep-warm / ping / compact.
24// Everything except the reminder is opened by the person: the band button, or /warm.
25
26const PANE = 'cache-panel'
27const REMIND_AFTER_MS = 50 * 60_000
28const TICK_MS = 30_000
29const TOAST_MS = 5000
30const PING_PROMPT = 'Reply with exactly: ok'
31
32// CACHE_PANEL_FAST=1 shortens the clocks so the whole flow can be tried in a few minutes.
33let remindAfter = REMIND_AFTER_MS
34let pingEvery = PING_EVERY_MS
35let isFast = false
36
37const EMPTY_SNAP: Snap = { ctx: 0, model: '', hasLimits: false, fiveHour: null }
38
39const nowAt = atom({ plugin: 'cache-panel', key: 'nowAt' } as const, 0)
40const lastRequestAt = atom({ plugin: 'cache-panel', key: 'lastRequestAt' } as const, 0)
41const remindedFor = atom({ plugin: 'cache-panel', key: 'remindedFor' } as const, 0)
42const isReminding = atom({ plugin: 'cache-panel', key: 'isReminding' } as const, false)
43const hoursText = atom({ plugin: 'cache-panel', key: 'hoursText' } as const, '')
44const warm = atom({ plugin: 'cache-panel', key: 'warm' } as const, null)
45const snap = atom({ plugin: 'cache-panel', key: 'snap' } as const, EMPTY_SNAP)
46const note = atom({ plugin: 'cache-panel', key: 'note' } as const, '')
47
48let remindTimer: Timer | undefined
49let pingTimer: Timer | undefined
50
51function folderOf(cwd: string): string {
52 return cwd.split('/').filter(Boolean).pop() ?? cwd
53}
54
55async function refreshSnap($: EngineInterface): Promise<void> {
56 const u = await $.session.usage()
57 const model = await $.session.model()
58 const five = u.rateLimits.find(l => l.kind === 'five_hour')
59 const next: Snap = {
60 ctx: u.context.tokens ?? 0,
61 model,
62 hasLimits: u.rateLimits.length > 0,
63 fiveHour: five ? Math.round(five.percentUsed) : null,
64 }
65 await update($, snap, () => next)
66}
67
68/** Sound and a notice through herdr, the way herdr raises its own agent alerts. */
69async function notify($: EngineInterface, title: string, body: string): Promise<void> {
70 const inHerdr = await $.env.get('HERDR_ENV')
71 if (inHerdr !== '1') return
72 const bin = (await $.env.get('HERDR_BIN_PATH')) || 'herdr'
73 try {
74 const r = await $.process.run([bin, 'notification', 'show', title, '--body', body, '--sound', 'done'], { timeoutMs: 5000 })
75 $.ui.log(`notify exit=${r.exitCode} ${r.stdout.trim().slice(0, 120)}`, { to: 'debug' })
76 } catch (err) {
77 // The reminder line is still on screen; a failed sound is not worth a toast.
78 $.ui.log(`notify failed: ${err instanceof Error ? err.message : String(err)}`, { to: 'debug' })
79 }
80}
81
82async function isWarmNow($: EngineInterface, now: number): Promise<boolean> {
83 const w = await read($, warm)
84 return w !== null && now < w.deadline
85}
86
87/** Minute 50 of idle with the cache still alive: show the line and ring once per idle stretch. */
88async function remindIfDue($: EngineInterface): Promise<void> {
89 const now = await $.clock.now()
90 await update($, nowAt, () => now)
91 const last = await read($, lastRequestAt)
92 if (last === 0) return
93 const idle = now - last
94 if (idle < remindAfter - 1000 || idle >= TTL_MS) return
95 if (await isWarmNow($, now)) return
96 if ((await read($, remindedFor)) === last) return
97 await refreshSnap($)
98 const s = await read($, snap)
99 if (!isFast && s.ctx < thresholdTokens(priceOf(s.model))) return
100 await update($, remindedFor, () => last)
101 await update($, isReminding, () => true)
102 $.ui.log(`reminder due: idle ${fmtDur(idle)}, ctx ${fmtK(s.ctx)}, model ${s.model}`, { to: 'debug' })
103 const cwd = await $.session.cwd()
104 await notify($, `Cache cools in ${fmtDur(last + TTL_MS - now)}`, `${folderOf(cwd)} · ${fmtK(s.ctx)}`)
105}
106
107function armReminder($: EngineInterface): void {
108 remindTimer?.cancel()
109 remindTimer = $.clock.after(remindAfter, () => void remindIfDue($))
110}
111
112/** The next keep-warm ping, one period after the last request that touched the cache. */
113async function armPing($: EngineInterface): Promise<void> {
114 pingTimer?.cancel()
115 pingTimer = undefined
116 const w = await read($, warm)
117 if (w === null) return
118 const now = await $.clock.now()
119 if (now >= w.deadline) {
120 await endWarm($, 'expired')
121 return
122 }
123 const last = await read($, lastRequestAt)
124 // Nothing to keep warm until a request has run, and a cold cache is not worth paying for.
125 if (last === 0 || now - last >= TTL_MS) return
126 const delay = Math.min(Math.max(1000, last + pingEvery - now), w.deadline - now)
127 pingTimer = $.clock.after(delay, () => void runPing($))
128}
129
130/** A request, or a ping, touched the cache just now. */
131async function touch($: EngineInterface): Promise<void> {
132 const now = await $.clock.now()
133 await update($, nowAt, () => now)
134 await update($, lastRequestAt, () => now)
135 await update($, isReminding, () => false)
136 armReminder($)
137 await armPing($)
138}
139
140async function runPing($: EngineInterface): Promise<void> {
141 pingTimer = undefined
142 const w = await read($, warm)
143 if (w === null) return
144 const now = await $.clock.now()
145 if (now >= w.deadline) {
146 await endWarm($, 'expired')
147 return
148 }
149 const last = await read($, lastRequestAt)
150 // A request since the timer was set moved the ping later.
151 if (now - last < pingEvery - 1000) {
152 await armPing($)
153 return
154 }
155 await doPing($, 'window')
156}
157
158async function endWarm($: EngineInterface, why: 'expired' | 'stopped' | 'error', message?: string): Promise<void> {
159 pingTimer?.cancel()
160 pingTimer = undefined
161 await update($, warm, () => null)
162 if (why === 'expired') $.ui.toast('Keep-warm ended', { timeoutMs: TOAST_MS })
163 else if (why === 'stopped') $.ui.toast('Keep-warm stopped', { timeoutMs: TOAST_MS })
164 else $.ui.toast(message ?? 'Keep-warm stopped: ping failed', { timeoutMs: 8000 })
165 await remindIfDue($)
166}
167
168/** One tool-less fork over the transcript: the server answers it from the cache and the hour restarts. */
169async function doPing($: EngineInterface, kind: 'window' | 'once'): Promise<void> {
170 const now = await $.clock.now()
171 const last = await read($, lastRequestAt)
172 if (last > 0 && now - last >= TTL_MS) {
173 $.ui.toast('Cache is already cold', { timeoutMs: TOAST_MS })
174 return
175 }
176 let reply: Awaited<ReturnType<EngineInterface['model']['fork']>> | undefined
177 let failure = ''
178 try {
179 reply = await $.model.fork({ prompt: PING_PROMPT })
180 } catch (err) {
181 failure = err instanceof Error ? err.message : String(err)
182 }
183 const usage = reply !== undefined && 'usage' in reply ? reply.usage : undefined
184 if (usage === undefined) {
185 const why = reply !== undefined && !reply.isAnswered ? reply.reason : failure || 'no reply'
186 const text = `Ping failed: ${why}`
187 if (kind === 'window') await endWarm($, 'error', text)
188 else $.ui.toast(text, { timeoutMs: 8000 })
189 return
190 }
191 const isWarm = usage.cache_read_input_tokens > 0 && usage.cache_creation_input_tokens < 0.1 * usage.cache_read_input_tokens
192 const info: PingInfo = {
193 at: now,
194 read: usage.cache_read_input_tokens,
195 write: usage.cache_creation_input_tokens,
196 out: usage.output_tokens,
197 isWarm,
198 }
199 $.ui.log(`ping(${kind}): read ${info.read}, write ${info.write}, out ${info.out}, warm=${isWarm}`, { to: 'debug' })
200 await update($, note, () =>
201 isWarm
202 ? `Last ping: read ${fmtK(info.read)}, out ${info.out}`
203 : `Last ping: read ${fmtK(info.read)}, wrote ${fmtK(info.write)} (new entry)`,
204 )
205 if (!isWarm) {
206 // The ping wrote the cache: it is warm now. In a young session this is normal (the fork
207 // starts its own entry and the next ping reads it), so one miss in a row is tolerated.
208 if (kind === 'window') {
209 const w0 = await read($, warm)
210 if (w0 !== null && w0.misses === 0) {
211 await update($, warm, (w: Warm | null) => (w === null ? null : { ...w, misses: 1, last: info }))
212 } else {
213 await endWarm($, 'error', `Keep-warm stopped: pings keep missing (read ${fmtK(info.read)}, wrote ${fmtK(info.write)})`)
214 }
215 } else {
216 $.ui.toast(`Pinged: wrote ${fmtK(info.write)}, cache held another hour`, { timeoutMs: TOAST_MS })
217 }
218 await touch($)
219 return
220 }
221 if (kind === 'window') {
222 await update($, warm, (w: Warm | null) => (w === null ? null : { ...w, pings: w.pings + 1, misses: 0, last: info }))
223 } else {
224 $.ui.toast(`Pinged: cache read ${fmtK(info.read)}, held another hour`, { timeoutMs: TOAST_MS })
225 }
226 await touch($)
227}
228
229async function startWarm($: EngineInterface, hours: number): Promise<void> {
230 const now = await $.clock.now()
231 const last = await read($, lastRequestAt)
232 if (last > 0 && now - last >= TTL_MS) {
233 $.ui.toast('Cache is already cold', { timeoutMs: TOAST_MS })
234 return
235 }
236 await update($, warm, () => ({ startedAt: now, deadline: now + hours * HOUR, pings: 0, misses: 0, last: null }))
237 await update($, isReminding, () => false)
238 await armPing($)
239 $.ui.toast(`Keep warm on for ${fmtHours(hours)}`, { timeoutMs: 3000 })
240 await $.ui.close({ id: PANE })
241}
242
243async function openPanel($: EngineInterface): Promise<void> {
244 await refreshSnap($)
245 const now = await $.clock.now()
246 await update($, nowAt, () => now)
247 await update($, hoursText, () => '')
248 await $.ui.open({ id: PANE, title: 'Cache', focus: true, closeOnEscape: true, rows: 12 })
249}
250
251async function compactNow($: EngineInterface): Promise<void> {
252 await $.ui.close({ id: PANE })
253 try {
254 await $.command.run({ command: 'compact', args: '' })
255 } catch (err) {
256 $.ui.toast(`Compact failed: ${err instanceof Error ? err.message : String(err)}`, { timeoutMs: 8000 })
257 }
258}
259
260async function tick($: EngineInterface): Promise<void> {
261 const now = await $.clock.now()
262 await update($, nowAt, () => now)
263 const w = await read($, warm)
264 if (w !== null && now >= w.deadline) await endWarm($, 'expired')
265 const last = await read($, lastRequestAt)
266 if ((await read($, isReminding)) && last > 0 && now - last >= TTL_MS) await update($, isReminding, () => false)
267}
268
269export const register: Register = on => {
270 on('session.start', async ($, e, next) => {
271 const now = await $.clock.now()
272 await update($, nowAt, () => now)
273 await refreshSnap($)
274 if ((await $.env.get('CACHE_PANEL_FAST')) === '1') {
275 isFast = true
276 remindAfter = 60_000
277 pingEvery = 120_000
278 }
279 $.clock.every(TICK_MS, () => void tick($))
280 try {
281 await $.command.register({
282 name: 'warm',
283 description: 'Cache panel: keep warm, ping once, or compact',
284 argumentHint: '[hours | off]',
285 immediate: true,
286 })
287 } catch {
288 try {
289 await $.command.register({
290 name: 'cache-panel',
291 description: 'Cache panel: keep warm, ping once, or compact',
292 argumentHint: '[hours | off]',
293 immediate: true,
294 })
295 } catch {
296 // Both names are taken; the band button still opens the panel.
297 }
298 }
299 return next(e)
300 })
301
302 // /clear, /resume and /branch reset the session's state: stop the timers that belonged to it.
303 on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
304 remindTimer?.cancel()
305 remindTimer = undefined
306 pingTimer?.cancel()
307 pingTimer = undefined
308 await refreshSnap($)
309 return next(e)
310 })
311
312 // A request has just been sent: that is when the cache's hour starts.
313 on('turn.step', async function* ($, e, next) {
314 if (!e.agentId) await touch($)
315 return yield* next(e)
316 })
317
318 on('session.measure', async ($, e, next) => {
319 await refreshSnap($)
320 return next(e)
321 })
322
323 // The context was replaced: the new prefix is cold until the next request writes it.
324 on('session.compact', async ($, e, next) => {
325 const r = await next(e)
326 if (!e.agentId) {
327 remindTimer?.cancel()
328 remindTimer = undefined
329 pingTimer?.cancel()
330 pingTimer = undefined
331 await update($, lastRequestAt, () => 0)
332 await update($, isReminding, () => false)
333 }
334 return r
335 })
336
337 on('command.run', { command: ['warm', 'cache-panel'] }, async ($, e) => {
338 const arg = String(e.args ?? '').trim().toLowerCase()
339 if (arg === '') {
340 await openPanel($)
341 return {}
342 }
343 if (arg === 'off' || arg === 'stop') {
344 if ((await read($, warm)) === null) return { text: 'Keep-warm is not running' }
345 await endWarm($, 'stopped')
346 return {}
347 }
348 const hours = parseHours(arg)
349 if (hours === null) return { text: `Usage: /warm [hours 0-${MAX_HOURS} | off]` }
350 await startWarm($, hours)
351 return {}
352 })
353
354 // The one-line reminder, or the keep-warm status. Nothing is drawn the rest of the time.
355 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
356 const w = await read($, warm)
357 const now = await read($, nowAt)
358 const last = await read($, lastRequestAt)
359 const reminding = await read($, isReminding)
360 const showReminder = w === null && reminding && last > 0 && now - last < TTL_MS
361 if (e.props.hasSurvey || (w === null && !showReminder)) return next(e)
362
363 const below = await next(e)
364 const { Box, Button } = $.ui.resolve(e)
365 const short = e.props.bodyColumns < 40
366 const label =
367 w !== null
368 ? `warm ${fmtDur(w.deadline - now)}`
369 : short
370 ? `cache ${fmtDur(last + TTL_MS - now)}`
371 : `cache cools in ${fmtDur(last + TTL_MS - now)}`
372 const mine = <Button key="open" label={label} onPress={() => void openPanel($)} />
373
374 return below ? (
375 <Box flexDirection="column">
376 {mine}
377 {below}
378 </Box>
379 ) : (
380 mine
381 )
382 })
383
384 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
385 // Text fields exist in the terminal and the Desktop app only.
386 if (e.surface !== 'terminal' && e.surface !== 'desktop') return next(e)
387 const { Box, Text, Button, Input } = $.ui.resolve(e)
388 const s = await read($, snap)
389 const now = await read($, nowAt)
390 const last = await read($, lastRequestAt)
391 const w = await read($, warm)
392 const typed = await read($, hoursText)
393 const lastNote = await read($, note)
394
395 const price = priceOf(s.model)
396 const basis = s.hasLimits ? 'subscription' : 'api'
397 const parsed = parseHours(typed)
398 const hours = parsed ?? DEFAULT_HOURS
399 const isCold = last > 0 && now - last >= TTL_MS
400 const plan = makePlan({ ctx: s.ctx, price, basis, hours, lastReq: last, now, isWarm: !isCold })
401 // Short enough to fit above the prompt, so the arrow keys move focus and do not scroll.
402 const narrow = e.props.bodyColumns < 44
403
404 const cacheText = isCold
405 ? `cold ${fmtDur(now - last - TTL_MS)}`
406 : last > 0
407 ? `${fmtDur(last + TTL_MS - now)} left`
408 : 'no request'
409 const quota = s.fiveHour !== null ? ` · 5h ${s.fiveHour}%` : ''
410 const guess = price.isGuess ? ' · guessed' : ''
411
412 let advice: string
413 if (parsed === null) advice = `Hours: 0 to ${MAX_HOURS}`
414 else if (isCold) advice = 'Cache is cold: just send.'
415 else if (s.ctx < thresholdTokens(price)) advice = 'Small context: do nothing.'
416 else advice = narrow ? `Best: ${pickLabel(plan.recommended)}` : `Best: ${pickLabel(plan.recommended)} (warm wins to ~${plan.breakEvenHours.toFixed(1)}h)`
417 const legend = basis === 'subscription' ? '~ token-equiv (plan) · $ API price' : '~ token-equiv · $ API price'
418
419 return (
420 <Box flexDirection="column">
421 {narrow ? (
422 <Box flexDirection="column">
423 <Text bold>{`${price.id} · ctx ${fmtK(s.ctx)}`}</Text>
424 <Text>{`cache ${cacheText}${quota}${guess}`}</Text>
425 </Box>
426 ) : (
427 <Text bold>{`${price.id} · ctx ${fmtK(s.ctx)} · cache ${cacheText}${quota}${guess}`}</Text>
428 )}
429 <Input
430 key="hours"
431 label="Away (hours)"
432 placeholder={String(DEFAULT_HOURS)}
433 value={typed}
434 submitLabel="warm"
435 onInput={(v: string) => void update($, hoursText, () => v)}
436 onSubmit={(v: string) => {
437 const h = parseHours(v)
438 if (h === null) $.ui.toast(`Enter hours between 0 and ${MAX_HOURS}`, { timeoutMs: TOAST_MS })
439 else void startWarm($, h)
440 }}
441 />
442 {optionRows(plan, !isCold, narrow).map(line => (
443 <Text>{line}</Text>
444 ))}
445 {!narrow && <Text dimColor>{lastNote !== '' ? lastNote : legend}</Text>}
446 <Text>{advice}</Text>
447 {w !== null && (
448 <Button
449 key="stop"
450 label={`Stop warm (${fmtDur(w.deadline - now)} left, ${w.pings} ping${w.pings === 1 ? '' : 's'})`}
451 autoFocus
452 onPress={() => void endWarm($, 'stopped')}
453 />
454 )}
455 {w === null && !isCold && (
456 <Button
457 key="warm"
458 label={`Keep warm ${fmtHours(hours)}`}
459 autoFocus
460 onPress={() => {
461 if (parsed === null) $.ui.toast(`Enter hours between 0 and ${MAX_HOURS}`, { timeoutMs: TOAST_MS })
462 else void startWarm($, parsed)
463 }}
464 />
465 )}
466 {!isCold && <Button key="once" label="Ping once (+1h)" onPress={() => void doPing($, 'once')} />}
467 <Button key="compact" label="Compact now" onPress={() => void $.clock.after(50, () => void compactNow($))} />
468 {isCold && <Button key="close" label="Dismiss" autoFocus onPress={() => void $.ui.close({ id: PANE })} />}
469 </Box>
470 )
471 })
472}
473hooks/model.ts 266 lines1// Pure cost model for the cache panel: no engine calls in here.
2//
3// Two bases are computed side by side:
4// - API list prices (official pricing page, checked 2026-10-04)
5// - subscription weights, measured by a Reddit user as multiples of one plain
6// input token: cache read 0.028, 1h cache write 1.2, output 5. Approximate,
7// measured on Fable 5.1 and Opus 5 only; applied to every model here.
8
9export const MIN = 60_000
10export const HOUR = 60 * MIN
11/** The 1-hour prompt cache. */
12export const TTL_MS = HOUR
13/** A ping lands this long after the last request that touched the cache. */
14export const PING_EVERY_MS = 50 * MIN
15export const DEFAULT_HOURS = 4
16export const MAX_HOURS = 24
17/** Remind only when letting the cache go cold would cost at least this much (API-equivalent USD). */
18export const STAKE_USD = 0.3
19/** Never remind below this many context tokens, whatever the price. */
20export const FLOOR_TOKENS = 30_000
21/** Assumed size of the summary a compaction writes. */
22export const SUMMARY_TOKENS = 8_000
23/** Assumed size of the conversation right after a compaction. */
24export const COMPACTED_TOKENS = 20_000
25
26export type Basis = 'subscription' | 'api'
27
28export type Price = {
29 id: string
30 /** $ per million tokens */
31 inp: number
32 write: number
33 read: number
34 out: number
35 /** True when the model id was not found and a family default was used. */
36 isGuess: boolean
37}
38
39// key, base input, 1h cache write, cache read, output ($ per million tokens).
40// Longer keys first: an id matches the first key it starts with.
41const TABLE: ReadonlyArray<readonly [string, number, number, number, number]> = [
42 ['fable-5-1', 10, 20, 0.25, 50],
43 ['mythos-5-1', 10, 20, 0.25, 50],
44 ['fable-5', 10, 20, 1, 50],
45 ['mythos-5', 10, 20, 1, 50],
46 ['opus-5-5', 4, 8, 0.2, 20],
47 ['opus-5', 5, 10, 0.5, 25],
48 ['opus-4-8', 5, 10, 0.5, 25],
49 ['opus-4-7', 5, 10, 0.5, 25],
50 ['opus-4-6', 5, 10, 0.5, 25],
51 ['opus-4-5', 5, 10, 0.5, 25],
52 ['opus-4-1', 15, 30, 1.5, 75],
53 ['opus-4', 15, 30, 1.5, 75],
54 ['sonnet-5-5', 2, 4, 0.2, 10],
55 ['sonnet-5', 2, 4, 0.2, 10],
56 ['sonnet-4-6', 3, 6, 0.3, 15],
57 ['sonnet-4-5', 3, 6, 0.3, 15],
58 ['sonnet-4', 3, 6, 0.3, 15],
59 ['haiku-4-5', 1, 2, 0.1, 5],
60 ['haiku-3-5', 0.8, 1.6, 0.08, 4],
61]
62
63const FAMILY: ReadonlyArray<readonly [string, string]> = [
64 ['fable', 'fable-5-1'],
65 ['mythos', 'mythos-5-1'],
66 ['opus', 'opus-5-5'],
67 ['sonnet', 'sonnet-5-5'],
68 ['haiku', 'haiku-4-5'],
69]
70
71export function normalizeModel(model: string): string {
72 return String(model || '')
73 .toLowerCase()
74 .replace(/^claude-/, '')
75 .replace(/\[.*?\]/g, '')
76 .replace(/-\d{8}$/, '')
77 .trim()
78}
79
80function rowOf(key: string, isGuess: boolean): Price | null {
81 for (const [k, inp, write, read, out] of TABLE) {
82 if (k === key) return { id: k, inp, write, read, out, isGuess }
83 }
84 return null
85}
86
87export function priceOf(model: string): Price {
88 const id = normalizeModel(model)
89 for (const [k] of TABLE) {
90 if (id.startsWith(k)) return rowOf(k, false) as Price
91 }
92 for (const [family, key] of FAMILY) {
93 if (id.includes(family)) return rowOf(key, true) as Price
94 }
95 return rowOf('sonnet-5-5', true) as Price
96}
97
98export type Ratios = { r: number; w: number; o: number }
99
100/** Subscription weights, as multiples of one plain input token. */
101export const SUBSCRIPTION: Ratios = { r: 0.028, w: 1.2, o: 5 }
102
103export function apiRatios(p: Price): Ratios {
104 return { r: p.read / p.inp, w: p.write / p.inp, o: p.out / p.inp }
105}
106
107export function ratiosFor(basis: Basis, p: Price): Ratios {
108 return basis === 'subscription' ? SUBSCRIPTION : apiRatios(p)
109}
110
111/** Context tokens above which letting the cache go cold costs at least STAKE_USD. */
112export function thresholdTokens(p: Price): number {
113 const perToken = (p.write - p.read) / 1e6
114 return Math.max(FLOOR_TOKENS, Math.ceil(STAKE_USD / perToken))
115}
116
117/** One keep-warm ping over a context of `c` tokens, in plain-input-token units. */
118export function pingUnits(c: number, x: Ratios): number {
119 return c * x.r
120}
121
122/** Extra cost of letting the next turn rewrite the whole context instead of reading it. */
123export function nothingUnits(c: number, x: Ratios): number {
124 return c * (x.w - x.r)
125}
126
127/**
128 * A compaction: one request over the whole context (a read while the cache is
129 * warm, a write once it is cold), the summary, and the next turn writing the
130 * short new context instead of reading it.
131 */
132export function compactUnits(c: number, x: Ratios, isWarm: boolean): number {
133 return c * (isWarm ? x.r : x.w) + SUMMARY_TOKENS * x.o + COMPACTED_TOKENS * (x.w - x.r)
134}
135
136export function toUsd(units: number, p: Price): number {
137 return (units * p.inp) / 1e6
138}
139
140/** Pings a keep-warm window of `hours` from `now` will send, given the last cache touch. */
141export function pingsIn(hours: number, lastReq: number, now: number): number {
142 const end = now + hours * HOUR
143 const from = lastReq > 0 ? lastReq : now
144 return Math.max(0, Math.floor((end - from) / PING_EVERY_MS))
145}
146
147/** "" means the default; accepts 3, 3h, 1.5. Null when not a number in (0, MAX_HOURS]. */
148export function parseHours(text: string): number | null {
149 const t = text.trim().toLowerCase().replace(/h$/, '').trim()
150 if (t === '') return DEFAULT_HOURS
151 if (!/^\d+(\.\d+)?$/.test(t)) return null
152 const n = Number(t)
153 return n > 0 && n <= MAX_HOURS ? n : null
154}
155
156export type Pick = 'warm' | 'compact' | 'nothing'
157
158export type Plan = {
159 hours: number
160 pings: number
161 warmUnits: number
162 warmUsd: number
163 compactUnits: number
164 compactUsd: number
165 nothingUnits: number
166 nothingUsd: number
167 /** Hours of absence up to which keep-warm costs less than compacting. */
168 breakEvenHours: number
169 recommended: Pick
170 /** Turns after which compacting a cold context has paid for itself through smaller reads. */
171 paybackTurns: number
172}
173
174export type PlanInput = {
175 ctx: number
176 price: Price
177 basis: Basis
178 hours: number
179 lastReq: number
180 now: number
181 isWarm: boolean
182}
183
184export function makePlan(i: PlanInput): Plan {
185 const x = ratiosFor(i.basis, i.price)
186 const api = apiRatios(i.price)
187 const pings = i.isWarm ? pingsIn(i.hours, i.lastReq, i.now) : 0
188 const warmU = pings * pingUnits(i.ctx, x)
189 const compactU = compactUnits(i.ctx, x, i.isWarm)
190 const nothingU = nothingUnits(i.ctx, x)
191 const pingU = pingUnits(i.ctx, x)
192 const breakEvenHours = pingU > 0 ? ((compactUnits(i.ctx, x, true) / pingU) * PING_EVERY_MS) / HOUR : 0
193
194 let recommended: Pick
195 if (!i.isWarm || i.ctx < FLOOR_TOKENS) {
196 recommended = 'nothing'
197 } else {
198 const best: Pick = warmU <= compactU ? 'warm' : 'compact'
199 const bestU = Math.min(warmU, compactU)
200 recommended = nothingU <= bestU ? 'nothing' : best
201 }
202
203 const saving = (i.ctx - COMPACTED_TOKENS) * x.r
204 const extra = compactU - nothingU
205 const paybackTurns = extra <= 0 ? 0 : saving > 0 ? extra / saving : Infinity
206
207 return {
208 hours: i.hours,
209 pings,
210 warmUnits: warmU,
211 warmUsd: toUsd(pings * pingUnits(i.ctx, api), i.price),
212 compactUnits: compactU,
213 compactUsd: toUsd(compactUnits(i.ctx, api, i.isWarm), i.price),
214 nothingUnits: nothingU,
215 nothingUsd: toUsd(nothingUnits(i.ctx, api), i.price),
216 breakEvenHours,
217 recommended,
218 paybackTurns,
219 }
220}
221
222export function fmtK(n: number): string {
223 if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M'
224 if (n >= 1000) return Math.round(n / 1000) + 'k'
225 return String(Math.round(n))
226}
227
228export function fmtUsd(u: number): string {
229 if (u > 0 && u < 0.01) return '<$0.01'
230 return '$' + u.toFixed(2)
231}
232
233export function fmtDur(ms: number): string {
234 const total = Math.max(0, Math.round(ms / MIN))
235 const h = Math.floor(total / 60)
236 const m = total % 60
237 return h > 0 ? `${h}h${String(m).padStart(2, '0')}m` : `${m}m`
238}
239
240export function fmtHours(h: number): string {
241 return `${Number.isInteger(h) ? h : h.toFixed(1)}h`
242}
243
244const pad = (s: string, n: number): string => s.padEnd(n)
245
246/**
247 * The three options as one line each. Narrow (a side-by-side herdr pane, about 21
248 * usable columns): short labels, so nothing wraps and the whole panel stays short
249 * enough for the arrow keys to move focus instead of scrolling it.
250 */
251export function optionRows(p: Plan, isWarm: boolean, narrow: boolean): string[] {
252 const warmLabel = narrow ? (isWarm ? `Warm ${fmtHours(p.hours)}` : 'Warm') : isWarm ? `Keep warm ${fmtHours(p.hours)}` : 'Keep warm'
253 const items: Array<readonly [string, string, string]> = [
254 [warmLabel, isWarm ? '~' + fmtK(p.warmUnits) : 'n/a', isWarm ? fmtUsd(p.warmUsd) : 'n/a'],
255 ['Compact', '~' + fmtK(p.compactUnits), fmtUsd(p.compactUsd)],
256 [narrow ? 'Nothing' : 'Do nothing', '~' + fmtK(p.nothingUnits), fmtUsd(p.nothingUsd)],
257 ]
258 const w1 = narrow ? 9 : 14
259 const w2 = narrow ? 6 : 8
260 return items.map(([label, units, usd]) => `${pad(label, w1)}${pad(units, w2)}${usd}`)
261}
262
263export function pickLabel(pick: Pick): string {
264 return pick === 'warm' ? 'Keep warm' : pick === 'compact' ? 'Compact' : 'Do nothing'
265}
266types/index.d.ts 39 lines1export type Snap = {
2 ctx: number
3 model: string
4 hasLimits: boolean
5 fiveHour: number | null
6}
7
8export type PingInfo = {
9 at: number
10 read: number
11 write: number
12 out: number
13 isWarm: boolean
14}
15
16export type Warm = {
17 startedAt: number
18 deadline: number
19 pings: number
20 /** Consecutive pings that wrote instead of read. One is tolerated: a young session's first ping writes its own entry. */
21 misses: number
22 last: PingInfo | null
23}
24
25declare module 'claude-code' {
26 interface PluginState {
27 'cache-panel': {
28 nowAt: number
29 lastRequestAt: number
30 remindedFor: number
31 isReminding: boolean
32 hoursText: string
33 warm: Warm | null
34 snap: Snap
35 note: string
36 }
37 }
38}
39