SLOPSHOPPER

cache-panel

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

newpanebandcommandtoastmodel
v0.1.0MITupdated 2026-10-06danyuchn/claude-cache-panel
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cache-panel
│ ┃ Cache ✕ › fix the failing auth test and add an audit log call │ ┃ opus-5-5 · ctx 97k · cache no request · 5h │ ┃ 31% ⏺ Read(src/auth.ts) │ ┃ Away (hours): 4 ⏎ warm ⎿ Read 6 lines │ ┃ Keep warm 4h ~11k $0.08 ⏺ Update(src/auth.ts) │ ┃ Compact ~66k $0.34 ⎿ Added 2 lines, removed 1 line │ ┃ Do nothing ~114k $0.76 ⏺ Bash(bun test) │ ┃ ~ token-equiv (plan) · $ API price ⎿ 3 pass, 1 fail │ ┃ Best: Keep warm (warm wins to ~20.2h) │ ┃ [ Keep warm 4h ] ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ [ Ping once (+1h) ] │ ┃ [ Compact now ] ✻ Worked for 42s · done 4:20 PM │ │ › /warm │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Cache
opus-5-5 · ctx 97k · cache no request · 5h 31% Away (hours): 4 ⏎ warm Keep warm 4h ~11k $0.08 Compact ~66k $0.34 Do nothing ~114k $0.76 ~ token-equiv (plan) · $ API price Best: Keep warm (warm wins to ~20.2h) [ Keep warm 4h ] [ Ping once (+1h) ] [ Compact now ]
README

claude-cache-panel

Moved. This mod now lives in danyuchn/claude-mods, together with my other Claude Code mods. This repository is archived and no longer updated. Install the current version with:

claude plugin marketplace add danyuchn/claude-mods
claude plugin install cache-panel@dustin-mods

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 cache panel: cost comparison, recommendation, and three buttons

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.

Why

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.

What you get

Normally: nothing on screen, nothing spent.

After 50 idle minutes (cache still warm, context large enough to be worth saving):

  • one short line above the prompt: [ cache cools in 10m ] (a clickable button; shortened to [ cache 10m ] in narrow panes)
  • a herdr notification with the "done" sound, body = your folder name
  • it disappears when you type again or when the cache goes cold; ignore it and nothing happens

Click the button, or type /warm, to open a small panel (keyboard: Up/Down + Enter; mouse works too):

ControlWhat it does
Away (hours)How long you will be gone. Blank = 4.
Cost rowsKeep warm vs. Compact vs. Do nothing, for that many hours.
Best: lineThe cheapest option, and how many hours keep-warm stays ahead.
Keep warmPings every 50 minutes until the window ends.
Ping onceOne ping, about one more hour of warmth.
Compact nowRuns 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.

How keep-warm works

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:

  • ping 190 s after the last turn, then a real turn at 395 s (95 s past the TTL)
  • that turn read 96,093 cached tokens and wrote 43, so the ping did extend the main conversation's cache

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.

When is it worth reminding you?

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:

ModelReminder 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.130k (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.

Install

Requires Claude Code 2.1.287 or newer (the version that introduced mods). Developed and tested on 2.1.289 on macOS.

git clone https://github.com/danyuchn/claude-cache-panel.git

Try it for one session:

claude --plugin-dir /path/to/claude-cache-panel

Load it in every session by adding to ~/.claude/settings.json:

{
  "env": {
    "CLAUDE_CODE_PLUGIN_DIRS": "/path/to/claude-cache-panel"
  }
}

If you already use CLAUDE_CODE_PLUGIN_DIRS, add the path to your existing list instead of replacing it.

Check the mod loads: claude plugin validate /path/to/claude-cache-panel.

The sound needs herdr

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.

Try the whole flow in a few minutes

CACHE_PANEL_FAST=1 claude --plugin-dir /path/to/claude-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.

Limitations

  • Only verified in the terminal. The panel guards on the terminal and desktop surfaces, but the desktop app has not been tested.
  • The full one-hour path has not been waited out end to end; the mechanism was proven with a 5-minute TTL.
  • 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.
  • Subscription cost weights are approximate (see above).
  • The herdr sound was confirmed to be sent (shown: true); whether it matches your ear is up to your herdr settings.

Roadmap

  • Desktop version. A Claude Code Desktop build of the panel, with native notifications and the same keep-warm / ping / compact choices. This is the next major item.
  • Notification backends beyond herdr (terminal bell, OS notifications), selectable in options.
  • Configurable thresholds, ping interval, and default away hours via mod options.
  • Verified end-to-end run at the real one-hour TTL, and a cold-cache panel walkthrough.
  • Measured subscription weights for more models.

Ideas and pull requests are welcome.

Layout

.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

License

MIT

Source 3 files
hooks/register.tsx 473 lines
1import { 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}
473
hooks/model.ts 266 lines
1// 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}
266
types/index.d.ts 39 lines
1export 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