SLOPSHOPPER

cache-warm

Prompt-cache warmth above the prompt: a countdown bar, hit rate per reply, what the cache saved, lapse warnings and keep-warm pings (by hand or automatic)…

newbandspinnercommandtoastmodel
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cache-warm
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ 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-warm ⎿ cache-warm: Prompt cache band hidden. /cache-warm shows it again. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

cache-warm

A Claude Code mod that watches your conversation's prompt cache from above the prompt: how long it stays warm, how much each reply read from it, what it saved you, and a keep-warm ping that stops it lapsing while you are away. /cache-warm shows or hides it.

It starts folded to one line; the chevron or /cache-warm-collapse opens it.

Terminal, expanded:

♨ Cache · Warm · 42m left of 1h                                        ▾
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━──────────────────
Last reply 96% hit · 482k read · 3.1k written
Session    91% hit · 12M read · input −78%
History    ▁▇█████▇██•███
Keep-warm  auto · next ping in 39m · 2 pings, 961k re-read

[ Keep warm ] [ Auto on ]

Cooling (the last quarter of the TTL):

● Cools in 4m 10s: the next message would rewrite 484k tokens.
[ Keep warm ] [ Auto off ]

Collapsed:

♨ Cache · Warm · 42m left of 1h ━━━━━━━━━─── 96% hit                    ▸

What it shows

  • Warmth: time left before the cache lapses, as a bar that drains from full to empty. The TTL runs from when a request starts, so a long reply eats into it, and the countdown counts from there too. Warm, then cooling for the last quarter of the TTL, then cold. The TTL (5 minutes or 1 hour) is read from the transcript's usage or the engine's model-switch report; until one says, 1 hour is assumed and the header says so.
  • Last reply: the share of its input the cache served, and the tokens it read, wrote and sent uncached.
  • Session: the same over every reply, the input cost against sending everything uncached (a read costs a tenth, a fortieth on Claude Fable 5.1; a write 1.25× for 5m or 2× for 1h), and how many times the cache lapsed between two replies.
  • History: one bar per reply, its height the hit rate; a dot is a keep-warm ping.
  • Keep-warm: auto on or off, when the next automatic ping goes out, and what the pings re-read.

Keep warm

Keep warm (w, or /cache-warm-ping) re-sends the conversation's last request through $.model.fork with a one-word prompt after it. The API serves the prefix from the cache, which resets the TTL, at a tenth of the input price (a fortieth on Claude Fable 5.1); nothing is added to your conversation. A turn that is running keeps the cache warm by itself, so a ping waits for it to end.

Auto (a, or /cache-warm-auto) pings by itself 45 s (5m TTL) or 3 min (1h TTL) before the cache would lapse, while no turn runs. It stops after 4 pings in a row (set in the plugin's options) and starts again with your next message, so an empty desk does not keep paying all night.

Without Auto, a toast says when the cache starts cooling and when it went cold. Turn on Notify when the cache cools in the plugin's options for a native notification as well.

Commands

CommandDoes
/cache-warmShow or hide the band
/cache-warm-pingKeep the cache warm now
/cache-warm-autoTurn automatic keep-warm pings on or off
/cache-warm-collapseFold the band to one line, or open it again (the chevron)
/cache-warm-statusEvery figure, the TTL and where it came from, and the keep-warm settings

The band, the fold and Auto are remembered across sessions. The ping runs on your main model with its thinking settings, so a little output is billed on top of the read. The footer's Cache button is hidden for now, as is plan-progress's Progress (a decision; SHOW_FOOTER_BUTTON in hooks/register.tsx brings it back).

Install

/plugin install cache-warm@arhun-plugins

Develop

claude --plugin-dir plugins/cache-warm
claude plugin test plugins/cache-warm
Source 4 files
hooks/register.tsx 428 lines
1// cache-warm: the main conversation's prompt cache above the prompt. How long
2// it stays warm, what each reply read from it and wrote to it, what it saved,
3// and a keep-warm ping (by hand, or by itself while you are away) that re-reads
4// the cached prefix through $.model.fork before it lapses, so the next message
5// does not pay to write the whole conversation again.
6//
7// Where each figure comes from:
8// - Warmth: when the last main-thread request started (the cache's TTL runs from
9//   a request's start, not its reply) and the TTL it was cached with (the
10//   transcript's usage, or the engine's model-switch report).
11// - Hit rate, read, written: each response's usage as the API reported it.
12// - Saved: those counts priced at a tenth for a read (a fortieth on Claude Fable
13//   5.1) and 1.25× (5m) or 2× (1h) for a write, against sending every token uncached.
14
15import { atom, read, update } from 'claude-code'
16import type { EngineInterface, Register } from 'claude-code'
17
18import type { CacheWarmReply, CacheWarmSnapshot } from '../types'
19import {
20  autoMarginMs,
21  buildView,
22  emptySnapshot,
23  fmtSpan,
24  fmtTokens,
25  fmtTtl,
26  hitRate,
27  pct,
28  readRate,
29  savedShare,
30  TTL_1H,
31  TTL_5M,
32  ttlFromTranscript,
33  warmthOf,
34  withReply,
35} from './model'
36import { renderDesktop, renderTerminal, type Actions } from './view'
37
38const snapshotAtom = atom({ plugin: 'cache-warm', key: 'snapshot' } as const, null)
39const openAtom = atom({ plugin: 'cache-warm', key: 'isOpen' } as const, true)
40const collapsedAtom = atom({ plugin: 'cache-warm', key: 'isCollapsed' } as const, true)
41const autoAtom = atom({ plugin: 'cache-warm', key: 'isAuto' } as const, false)
42const pingingAtom = atom({ plugin: 'cache-warm', key: 'isPinging' } as const, false)
43const nowAtom = atom({ plugin: 'cache-warm', key: 'now' } as const, 0)
44
45// Decision (2026-10-15): the footer buttons (Cache here, Progress in plan-progress) are hidden; /cache-warm shows and
46// hides the band. The code stays so the button can come back by setting this to true.
47const SHOW_FOOTER_BUTTON = false
48
49const PING_PROMPT = 'Keep-alive ping from the cache-warm mod, not a request: reply with the single word "ok".'
50const MAX_TRANSCRIPT_BYTES = 4 * 1024 * 1024 - 1024
51
52const COMMANDS = [
53  { name: 'cache-warm', description: 'Prompt cache band: show or hide it' },
54  { name: 'cache-warm-ping', description: 'Prompt cache: keep it warm now (re-reads the cached conversation)' },
55  { name: 'cache-warm-auto', description: 'Prompt cache: turn automatic keep-warm pings on or off' },
56  { name: 'cache-warm-collapse', description: 'Prompt cache band: fold it to one line, or open it again' },
57  { name: 'cache-warm-status', description: 'Prompt cache: every figure, the TTL and the keep-warm settings' },
58] as const
59
60type Options = { maxAutoPings: number; notify: boolean }
61
62function optionsOf(raw: unknown): Options {
63  const o = (raw && typeof raw === 'object' ? raw : {}) as Record<string, unknown>
64  const max = Number(o['maxAutoPings'])
65  return { maxAutoPings: Number.isFinite(max) && max >= 1 ? Math.floor(max) : 4, notify: o['notify'] === true }
66}
67
68// Module state: rebuilt on reload; what the drawing needs lives in $.state.
69let isWorking = false
70let isPinging = false
71let stepsThisTurn = 0
72let turnStartedAt = 0
73// Automatic pings since the person last sent a message; capped so an empty desk does not ping all night.
74let autoPings = 0
75let isCapToasted = false
76// The lastAt each alert was raised for, so each fires once per cache entry.
77let warnedFor: number | null = null
78let lapsedFor: number | null = null
79
80async function current($: EngineInterface): Promise<CacheWarmSnapshot> {
81  const snap = await read($, snapshotAtom)
82  if (snap) return snap
83  const stored = await $.store.get('ttlMs')
84  const isKnown = stored === TTL_5M || stored === TTL_1H
85  return emptySnapshot(isKnown ? stored : TTL_1H, isKnown)
86}
87
88async function edit($: EngineInterface, fn: (s: CacheWarmSnapshot) => CacheWarmSnapshot) {
89  const base = await current($)
90  await update($, snapshotAtom, prev => fn(prev ?? base))
91  const now = await $.clock.now()
92  await update($, nowAtom, () => now)
93}
94
95async function setTtl($: EngineInterface, ttlMs: number) {
96  await $.store.set('ttlMs', ttlMs)
97  await edit($, s => ({ ...s, ttlMs, isTtlKnown: true }))
98}
99
100/** The conversation was replaced (clear, compact, model switch): nothing of it is cached now. */
101async function forget($: EngineInterface, prefixTokens = 0) {
102  await edit($, s => ({ ...s, lastAt: null, prefixTokens }))
103}
104
105async function learnTtl($: EngineInterface, transcriptPath: string | undefined) {
106  if (!transcriptPath) return
107  try {
108    const stat = await $.fs.stat(transcriptPath)
109    if (stat.size > MAX_TRANSCRIPT_BYTES) return
110    const text = await $.fs.read(transcriptPath)
111    const ttl = ttlFromTranscript(typeof text === 'string' ? text : '')
112    if (ttl !== null) await setTtl($, ttl)
113  } catch {
114    // unreadable: the stored TTL stands
115  }
116}
117
118type Usage = { input_tokens: number; output_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number; model?: string }
119
120/** `at` is when the request started: the cache entry's TTL runs from there, so a long reply eats into it. */
121async function record($: EngineInterface, u: Usage, isPing: boolean, at: number) {
122  const reply: CacheWarmReply = {
123    at,
124    read: u.cache_read_input_tokens,
125    written: u.cache_creation_input_tokens,
126    uncached: u.input_tokens,
127    isPing,
128  }
129  // A ping's own tail is never cached: the prefix the next message re-sends is unchanged.
130  const prefix = isPing ? null : u.cache_read_input_tokens + u.cache_creation_input_tokens + u.input_tokens + u.output_tokens
131  await edit($, s => withReply(s, reply, prefix, readRate(u.model)))
132}
133
134/** Re-reads the cached conversation through a fork, which resets the cache's TTL. Resolves what to tell the person. */
135async function ping($: EngineInterface): Promise<string> {
136  if (isPinging) return 'A keep-warm ping is already on its way.'
137  if (isWorking) return 'A turn is running: its own requests keep the cache warm.'
138  isPinging = true
139  await update($, pingingAtom, () => true)
140  try {
141    const startedAt = await $.clock.now()
142    const r = await $.model.fork({ prompt: PING_PROMPT })
143    if (!r.isAnswered && r.reason === 'nothing-to-fork') return 'Nothing cached to keep warm yet: the first reply writes the cache.'
144    const u = r.usage
145    if (u.cache_read_input_tokens + u.cache_creation_input_tokens > 0) await record($, u, true, startedAt)
146    if (!r.isAnswered && r.reason === 'api-error') return `Keep-warm ping failed: ${r.error}${r.status === null ? '' : ` (HTTP ${r.status})`}.`
147    if (!r.isAnswered && r.reason === 'aborted') return 'Keep-warm ping was cut short.'
148    const ttl = fmtTtl((await current($)).ttlMs)
149    if (u.cache_read_input_tokens > 0) return `Cache kept warm: ${fmtTokens(u.cache_read_input_tokens)} tokens re-read, ${ttl} on the clock again.`
150    if (u.cache_creation_input_tokens > 0) return `The cache had lapsed: the ping wrote ${fmtTokens(u.cache_creation_input_tokens)} tokens back.`
151    return 'The ping reached no cache.'
152  } catch (err) {
153    return `Keep-warm ping failed: ${err instanceof Error ? err.message : String(err)}`
154  } finally {
155    isPinging = false
156    await update($, pingingAtom, () => false).catch(() => undefined)
157  }
158}
159
160async function toggleCollapsed($: EngineInterface): Promise<boolean> {
161  const isCollapsed = !(await read($, collapsedAtom))
162  await update($, collapsedAtom, () => isCollapsed)
163  await $.store.set('isCollapsed', isCollapsed)
164  return isCollapsed
165}
166
167async function toggleAuto($: EngineInterface): Promise<boolean> {
168  const isAuto = !(await read($, autoAtom))
169  await update($, autoAtom, () => isAuto)
170  await $.store.set('isAuto', isAuto)
171  autoPings = 0
172  isCapToasted = false
173  return isAuto
174}
175
176async function toggleOpen($: EngineInterface): Promise<boolean> {
177  const isOpen = !(await read($, openAtom))
178  await update($, openAtom, () => isOpen)
179  await $.store.set('isOpen', isOpen)
180  return isOpen
181}
182
183function actionsFor($: EngineInterface, opts: Options): Actions {
184  return {
185    ping: async () => $.ui.toast(await ping($)),
186    auto: async () => {
187      const isAuto = await toggleAuto($)
188      $.ui.toast(
189        isAuto
190          ? `Auto keep-warm on: up to ${opts.maxAutoPings} pings while you are away, each a cache read that costs a fraction of a rewrite.`
191          : 'Auto keep-warm off.',
192      )
193    },
194    toggle: () => void toggleCollapsed($),
195  }
196}
197
198async function statusReport($: EngineInterface, opts: Options): Promise<string> {
199  const s = await current($)
200  const now = await $.clock.now()
201  const w = warmthOf(s, now)
202  const hit = hitRate(s.totals)
203  const saved = savedShare(s.totals, s.ttlMs)
204  const isAuto = await read($, autoAtom)
205  return [
206    `State: ${w.state}${w.state === 'warm' || w.state === 'cooling' ? `, ${fmtSpan(w.remainingMs)} left` : ''}`,
207    `TTL: ${fmtTtl(s.ttlMs)}${s.isTtlKnown ? '' : ' (assumed until a reply says)'}`,
208    `Last write or read: ${s.lastAt === null ? 'none' : new Date(s.lastAt).toISOString()}`,
209    `Prefix the next message re-sends: ${fmtTokens(s.prefixTokens)} tokens`,
210    `Session: ${hit === null ? '—' : pct(hit)} hit, ${fmtTokens(s.totals.read)} read, ${fmtTokens(s.totals.written)} written, ${fmtTokens(s.totals.uncached)} uncached`,
211    `Input cost against no cache: ${saved === null ? '—' : saved >= 0 ? `−${pct(saved)}` : `+${pct(-saved)}`}`,
212    `Lapses between replies: ${s.lapses}`,
213    `Keep-warm: ${isAuto ? 'auto' : 'off'}, ${s.pings} pings (${fmtTokens(s.pingRead)} re-read), ${autoPings}/${opts.maxAutoPings} automatic since your last message; auto pings ${fmtSpan(autoMarginMs(s.ttlMs))} before the cache lapses`,
214    `Notifications when cooling: ${opts.notify ? 'on' : 'off'}`,
215  ].join('\n')
216}
217
218/** Once a second: redraw when a shown figure changes, warn as the cache cools, ping when auto is on. */
219async function tick($: EngineInterface, opts: Options, last: { shown: string }) {
220  const s = await read($, snapshotAtom)
221  if (!s) return
222  const now = await $.clock.now()
223  const w = warmthOf(s, now)
224  const isOpen = await read($, openAtom)
225  const isAuto = await read($, autoAtom)
226
227  const shown = `${w.state}|${w.remainingMs > 0 ? fmtSpan(w.remainingMs) : fmtSpan(now - (s.lastAt ?? now))}|${Math.round(w.warmth * 72)}`
228  if (isOpen && shown !== last.shown) {
229    last.shown = shown
230    await update($, nowAtom, () => now)
231  }
232  if (isWorking || isPinging || s.lastAt === null) return
233
234  const isLive = w.state === 'warm' || w.state === 'cooling'
235  if (isAuto && isLive && w.remainingMs <= autoMarginMs(s.ttlMs)) {
236    if (autoPings < opts.maxAutoPings) {
237      autoPings += 1
238      const text = await ping($)
239      if (isOpen) $.ui.toast(text)
240      return
241    }
242    if (!isCapToasted) {
243      isCapToasted = true
244      $.ui.toast(`Auto keep-warm paused after ${opts.maxAutoPings} pings; your next message starts it again.`)
245    }
246  }
247
248  if (w.state === 'cooling' && warnedFor !== s.lastAt && !isAuto) {
249    warnedFor = s.lastAt
250    const text = `Prompt cache cools in ${fmtSpan(w.remainingMs)}: the next message would rewrite ${fmtTokens(s.prefixTokens)} tokens.`
251    if (isOpen) $.ui.toast(`${text} /cache-warm-ping keeps it warm.`)
252    if (opts.notify) void $.ui.notify(text, { title: 'Prompt cache' }).catch(() => undefined)
253  }
254  if (w.state === 'cold' && lapsedFor !== s.lastAt) {
255    lapsedFor = s.lastAt
256    if (isOpen && s.prefixTokens > 0) $.ui.toast(`Prompt cache went cold: the next message rewrites ${fmtTokens(s.prefixTokens)} tokens.`)
257  }
258}
259
260export const register: Register = (on, options) => {
261  const opts = optionsOf(options)
262
263  // ── Session lifecycle ──────────────────────────────────────────────────
264
265  on('session.start', async ($, e, next) => {
266    // One command each: the desktop composer drops anything typed after a slash command's name.
267    for (const c of COMMANDS) await $.command.register(c)
268    if ((await $.store.get('isCollapsed')) === false) await update($, collapsedAtom, () => false)
269    if ((await $.store.get('isAuto')) === true) await update($, autoAtom, () => true)
270    if ((await $.store.get('isOpen')) === false) await update($, openAtom, () => false)
271    const initial = await current($)
272    await update($, snapshotAtom, prev => prev ?? initial)
273    const last = { shown: '' }
274    $.clock.every(1000, () => void tick($, opts, last).catch(() => undefined))
275    return next(e)
276  })
277
278  // Resumed: the transcript says how long ago the last response was and how big the prefix is.
279  on('classic.SessionStart', async ($, e, next) => {
280    const now = await $.clock.now()
281    if ((e.source === 'resume' || e.source === 'fork') && typeof e.seconds_since_last_response === 'number') {
282      const lastAt = now - e.seconds_since_last_response * 1000
283      await edit($, s => ({ ...s, lastAt, prefixTokens: e.context_tokens ?? s.prefixTokens }))
284    } else if (e.source === 'clear' || e.source === 'compact') {
285      await forget($)
286    }
287    void learnTtl($, e.transcript_path)
288    return next(e)
289  }).catch((_$, e, next) => next(e))
290
291  // A turn started: its requests will refresh the cache, and the auto-ping budget starts over.
292  on('turn.start', async ($, e, next) => {
293    isWorking = true
294    stepsThisTurn = 0
295    turnStartedAt = await $.clock.now()
296    autoPings = 0
297    isCapToasted = false
298    return next(e)
299  })
300
301  // Each main-thread response reads and writes the cache as it lands.
302  on('turn.step', async function* ($, e, next) {
303    const startedAt = await $.clock.now()
304    const r = yield* next(e)
305    if (e.agentId === undefined && !isPinging && r.usage) {
306      stepsThisTurn += 1
307      void record($, r.usage, false, startedAt).catch(() => undefined)
308    }
309    return r
310  })
311
312  on('turn.complete', async ($, e, next) => {
313    const result = await next(e)
314    if (e.agentId === undefined) {
315      isWorking = false
316      // A host that reported no steps: the turn's summed usage stands in for its last response, timed from
317      // the turn's start, the earliest its request can have started.
318      if (stepsThisTurn === 0 && e.usage && !isPinging) await record($, e.usage, false, turnStartedAt)
319    }
320    return result
321  })
322
323  // After each turn, read which TTL the responses were cached with.
324  on('classic.Stop', ($, e, next) => {
325    void learnTtl($, e.transcript_path)
326    return next(e)
327  }).catch((_$, e, next) => next(e))
328
329  // The engine reports the TTL itself on a model switch; a switch also forfeits the cache.
330  on('classic.PostModelSwitch', async ($, e, next) => {
331    await setTtl($, e.cache_ttl === '5m' ? TTL_5M : TTL_1H)
332    await forget($, e.context_tokens)
333    return next(e)
334  }).catch((_$, e, next) => next(e))
335
336  // A compaction replaces the conversation: nothing of the new one is cached yet.
337  on('session.compact', async ($, e, next) => {
338    const result = await next(e)
339    if (e.trigger !== 'precompute' && !('skip' in result)) await forget($)
340    return result
341  }).catch((_$, e, next) => next(e))
342
343  // /clear starts a fresh conversation in the same process.
344  on('session.end', async ($, e, next) => {
345    const result = await next(e)
346    if (e.reason === 'clear') await forget($)
347    return result
348  })
349
350  // ── Commands ───────────────────────────────────────────────────────────
351
352  on('command.run', { command: 'cache-warm' }, async $ => {
353    const isOpen = await toggleOpen($)
354    return { text: isOpen ? 'Prompt cache band shown.' : 'Prompt cache band hidden. /cache-warm shows it again.' }
355  })
356
357  on('command.run', { command: 'cache-warm-ping' }, async $ => ({ text: await ping($) }))
358
359  on('command.run', { command: 'cache-warm-auto' }, async $ => {
360    const isAuto = await toggleAuto($)
361    return {
362      text: isAuto
363        ? `Auto keep-warm on: while you are away the cache is re-read before it lapses, up to ${opts.maxAutoPings} times.`
364        : 'Auto keep-warm off.',
365    }
366  })
367
368  on('command.run', { command: 'cache-warm-collapse' }, async $ => {
369    const isCollapsed = await toggleCollapsed($)
370    return { text: isCollapsed ? 'Prompt cache band folded to one line.' : 'Prompt cache band expanded.' }
371  })
372
373  on('command.run', { command: 'cache-warm-status' }, async $ => ({ text: await statusReport($, opts) }))
374
375  // ── The Cache button in the prompt footer ──────────────────────────────
376
377  // Drawn wherever it can be pressed, as plan-progress draws Progress; dim while the band is hidden or nothing is cached.
378  on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
379    if (!SHOW_FOOTER_BUTTON) return next(e)
380    if (e.surface === 'terminal' && e.viewport?.isFullscreen !== true) return next(e)
381    const [isOpen, snap] = await Promise.all([read($, openAtom), read($, snapshotAtom)])
382    const isEmpty = !snap || snap.lastAt === null
383    const { Box, Button } = $.ui.resolve(e)
384    // other mods add their labels to modes beneath us; keep them
385    const below = await next(e)
386    return (
387      <Box flexDirection="row" alignItems="center" gap={1}>
388        <Button key="cache-toggle" dimColor={!isOpen || isEmpty} label="Cache" onPress={() => void toggleOpen($)} />
389        {below}
390      </Box>
391    )
392  })
393
394  // ── The band above the prompt ──────────────────────────────────────────
395
396  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
397    if (e.props.hasSurvey || e.props.view.agentId !== undefined) return next(e)
398    if (e.surface !== 'terminal' && e.surface !== 'desktop' && e.surface !== 'vscode') return next(e)
399    const [snap, isOpen, isCollapsed, isAuto, pinging, tickNow] = await Promise.all([
400      read($, snapshotAtom),
401      read($, openAtom),
402      read($, collapsedAtom),
403      read($, autoAtom),
404      read($, pingingAtom),
405      read($, nowAtom),
406    ])
407    if (!snap || !isOpen) return next(e)
408    const now = Math.max(tickNow, await $.clock.now())
409    const vm = buildView(snap, now, isAuto, pinging)
410    const actions = actionsFor($, opts)
411    const E = $.ui.resolve(e)
412    const mine =
413      e.surface === 'terminal'
414        ? renderTerminal(E as Parameters<typeof renderTerminal>[0], vm, e.props.bodyColumns, actions, isCollapsed)
415        : renderDesktop(E as Parameters<typeof renderDesktop>[0], vm, e.props.bodyColumns, actions, isCollapsed)
416    // What the host and the plugins after this one draw here stays, under the band.
417    const below = await next(e)
418    if (!below) return mine
419    const { Box } = E
420    return (
421      <Box flexDirection="column" rowGap={1}>
422        {mine}
423        {below}
424      </Box>
425    )
426  })
427}
428
hooks/model.ts 213 lines
1// What the band shows, worked out from the snapshot alone: no engine calls,
2// so the tests can hold every figure.
3
4import type { CacheWarmReply, CacheWarmSnapshot, CacheWarmTotals } from '../types'
5
6export type Tone = 'claude' | 'suggestion' | 'merged' | 'inactive' | 'success' | 'warning' | 'error'
7export type CacheState = 'warm' | 'cooling' | 'cold' | 'empty'
8
9export const TTL_5M = 5 * 60_000
10export const TTL_1H = 60 * 60_000
11export const MAX_REPLIES = 32
12
13export const STATE_TONE: Record<CacheState, Tone> = {
14  warm: 'claude',
15  cooling: 'warning',
16  cold: 'suggestion',
17  empty: 'inactive',
18}
19export const STATE_LABEL: Record<CacheState, string> = { warm: 'Warm', cooling: 'Cooling', cold: 'Cold', empty: 'Empty' }
20export const STATE_GLYPH: Record<CacheState, string> = { warm: '♨', cooling: '♨', cold: '❄', empty: '○' }
21
22// Input prices relative to an uncached token: a cache read is a tenth (a
23// fortieth on Claude Fable 5.1), a write 1.25× for the 5-minute TTL and 2× for the hour.
24const READ_RATE = 0.1
25export const readRate = (model: string | undefined) => (model !== undefined && /fable-5-1/i.test(model) ? 0.025 : READ_RATE)
26export const writeRate = (ttlMs: number) => (ttlMs >= TTL_1H ? 2 : 1.25)
27
28/** How long before the cache lapses an automatic ping goes out. */
29export const autoMarginMs = (ttlMs: number) => (ttlMs >= TTL_1H ? 3 * 60_000 : 45_000)
30
31export const emptySnapshot = (ttlMs: number, isTtlKnown: boolean): CacheWarmSnapshot => ({
32  lastAt: null,
33  ttlMs,
34  isTtlKnown,
35  prefixTokens: 0,
36  replies: [],
37  totals: { read: 0, written: 0, uncached: 0, readCost: 0 },
38  pings: 0,
39  pingRead: 0,
40  lastPingAt: null,
41  lapses: 0,
42})
43
44export function fmtTokens(n: number): string {
45  if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(n >= 10_000_000 ? 0 : 1)}M`
46  if (n >= 1_000) return `${(n / 1_000).toFixed(n >= 10_000 ? 0 : 1)}k`
47  return String(Math.round(n))
48}
49
50/** Time left or ago: minutes alone from ten minutes up, then minutes and seconds. */
51export function fmtSpan(ms: number): string {
52  const s = Math.max(0, Math.ceil(ms / 1000))
53  if (s >= 600) return `${Math.ceil(s / 60)}m`
54  if (s >= 60) return `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, '0')}s`
55  return `${s}s`
56}
57
58export const fmtTtl = (ttlMs: number) => (ttlMs >= TTL_1H ? '1h' : `${Math.round(ttlMs / 60_000)}m`)
59
60export function hitRate(t: Pick<CacheWarmTotals, 'read' | 'written' | 'uncached'>): number | null {
61  const total = t.read + t.written + t.uncached
62  return total > 0 ? t.read / total : null
63}
64
65/** The share of the input bill the cache took off, against sending every token uncached; negative when writes outweigh reads. */
66export function savedShare(t: CacheWarmTotals, ttlMs: number): number | null {
67  const total = t.read + t.written + t.uncached
68  if (total === 0) return null
69  return 1 - ((t.readCost ?? READ_RATE * t.read) + writeRate(ttlMs) * t.written + t.uncached) / total
70}
71
72export type Warmth = {
73  state: CacheState
74  /** Milliseconds until the cache lapses; 0 once cold or empty. */
75  remainingMs: number
76  /** Remaining ÷ TTL, 0 to 1. */
77  warmth: number
78}
79
80/** Warm with more than a quarter of the TTL left, cooling under it, cold at 0; empty with nothing cached. */
81export function warmthOf(snap: Pick<CacheWarmSnapshot, 'lastAt' | 'ttlMs'>, now: number): Warmth {
82  if (snap.lastAt === null) return { state: 'empty', remainingMs: 0, warmth: 0 }
83  const remainingMs = Math.max(0, snap.lastAt + snap.ttlMs - now)
84  const warmth = snap.ttlMs > 0 ? remainingMs / snap.ttlMs : 0
85  const state: CacheState = remainingMs <= 0 ? 'cold' : warmth > 0.25 ? 'warm' : 'cooling'
86  return { state, remainingMs, warmth }
87}
88
89const BLOCKS = '▁▂▃▄▅▆▇█'
90export type Spark = { ch: string; tone: Tone; isDim: boolean }
91
92/** One glyph per reply, its height the hit rate; a ping is a dot, a reply that wrote more than it read is cold-blue. */
93export function sparkOf(replies: readonly CacheWarmReply[], n: number): Spark[] {
94  return replies.slice(-n).map(r => {
95    if (r.isPing) return { ch: '•', tone: 'inactive', isDim: true }
96    const rate = hitRate(r) ?? 0
97    const ch = BLOCKS[Math.min(BLOCKS.length - 1, Math.floor(rate * BLOCKS.length))] ?? BLOCKS[0]!
98    return { ch, tone: rate >= 0.5 ? 'claude' : 'suggestion', isDim: false }
99  })
100}
101
102export const pct = (x: number) => `${Math.round(x * 100)}%`
103
104export type ViewModel = Warmth & {
105  tone: Tone
106  label: string
107  glyph: string
108  /** The header's dim detail: time left, how long ago it lapsed, or that nothing is cached. */
109  detail: string
110  ttl: string
111  isTtlKnown: boolean
112  last: CacheWarmReply | null
113  lastHit: number | null
114  sessionHit: number | null
115  saved: number | null
116  totals: CacheWarmTotals
117  replies: CacheWarmReply[]
118  prefixTokens: number
119  pings: number
120  pingRead: number
121  lapses: number
122  isAuto: boolean
123  isPinging: boolean
124  /** When the next automatic ping goes out, from now; null when none is due. */
125  nextPingMs: number | null
126  /** A line for a cooling or cold cache; null otherwise. */
127  notice: string | null
128}
129
130export function buildView(snap: CacheWarmSnapshot, now: number, isAuto: boolean, isPinging: boolean): ViewModel {
131  const w = warmthOf(snap, now)
132  const last = [...snap.replies].reverse().find(r => !r.isPing) ?? null
133  const rewrite = snap.prefixTokens > 0 ? `${fmtTokens(snap.prefixTokens)} tokens` : 'the whole conversation'
134  const detail =
135    w.state === 'empty'
136      ? 'nothing cached yet'
137      : w.state === 'cold'
138        ? `lapsed ${fmtSpan(now - (snap.lastAt ?? now) - snap.ttlMs)} ago`
139        : `${fmtSpan(w.remainingMs)} left of ${fmtTtl(snap.ttlMs)}`
140  const notice =
141    w.state === 'cooling'
142      ? `Cools in ${fmtSpan(w.remainingMs)}: the next message would rewrite ${rewrite}.`
143      : w.state === 'cold' && snap.prefixTokens > 0
144        ? `Cold: the next message rewrites ${rewrite} at ${writeRate(snap.ttlMs)}× the input price.`
145        : null
146  const isLive = w.state === 'warm' || w.state === 'cooling'
147  return {
148    ...w,
149    tone: STATE_TONE[w.state],
150    label: STATE_LABEL[w.state],
151    glyph: STATE_GLYPH[w.state],
152    detail,
153    ttl: fmtTtl(snap.ttlMs),
154    isTtlKnown: snap.isTtlKnown,
155    last,
156    lastHit: last ? hitRate(last) : null,
157    sessionHit: hitRate(snap.totals),
158    saved: savedShare(snap.totals, snap.ttlMs),
159    totals: snap.totals,
160    replies: snap.replies,
161    prefixTokens: snap.prefixTokens,
162    pings: snap.pings,
163    pingRead: snap.pingRead,
164    lapses: snap.lapses,
165    isAuto,
166    isPinging,
167    nextPingMs: isAuto && isLive ? Math.max(0, w.remainingMs - autoMarginMs(snap.ttlMs)) : null,
168    notice,
169  }
170}
171
172/** A reply landed: it refreshed the cache, and if the one before had lapsed, that is a lapse. `r.at` is when its request started. */
173export function withReply(snap: CacheWarmSnapshot, r: CacheWarmReply, prefixTokens: number | null, rate = READ_RATE): CacheWarmSnapshot {
174  const isLapse = !r.isPing && snap.lastAt !== null && r.at - snap.lastAt > snap.ttlMs
175  return {
176    ...snap,
177    lastAt: r.read + r.written > 0 ? r.at : snap.lastAt,
178    prefixTokens: prefixTokens ?? snap.prefixTokens,
179    replies: [...snap.replies, r].slice(-MAX_REPLIES),
180    totals: r.isPing
181      ? snap.totals
182      : {
183          read: snap.totals.read + r.read,
184          written: snap.totals.written + r.written,
185          uncached: snap.totals.uncached + r.uncached,
186          readCost: (snap.totals.readCost ?? READ_RATE * snap.totals.read) + rate * r.read,
187        },
188    pings: r.isPing ? snap.pings + 1 : snap.pings,
189    pingRead: r.isPing ? snap.pingRead + r.read : snap.pingRead,
190    lastPingAt: r.isPing ? r.at : snap.lastPingAt,
191    lapses: isLapse ? snap.lapses + 1 : snap.lapses,
192  }
193}
194
195/** The TTL a response was cached with, from the last transcript lines that say. */
196export function ttlFromTranscript(text: string): number | null {
197  const lines = text.split('\n')
198  for (let i = lines.length - 1, seen = 0; i >= 0 && seen < 400; i--, seen++) {
199    const line = lines[i]
200    if (!line || !line.includes('cache_creation')) continue
201    try {
202      const row = JSON.parse(line) as { message?: { usage?: { cache_creation?: Record<string, number> } } }
203      const cc = row.message?.usage?.cache_creation
204      if (!cc) continue
205      if ((cc['ephemeral_1h_input_tokens'] ?? 0) > 0) return TTL_1H
206      if ((cc['ephemeral_5m_input_tokens'] ?? 0) > 0) return TTL_5M
207    } catch {
208      // a partial line; keep looking
209    }
210  }
211  return null
212}
213
hooks/view.tsx 269 lines
1// Drawing for the cache-warm band. One layout on every surface: text rows in
2// the theme's colours; the warmth bar is text on the terminal and a thin SVG
3// on the desktop, where box-drawing glyphs do not join up.
4
5import type { Elements, RenderElement } from 'claude-code'
6
7import { fmtSpan, fmtTokens, pct, sparkOf, type Tone, type ViewModel } from './model'
8
9export type Actions = {
10  ping: () => void | Promise<void>
11  auto: () => void | Promise<void>
12  /** Folds the band to one line or opens it again. */
13  toggle: () => void | Promise<void>
14}
15
16type TermEls = Elements['terminal']
17type DeskEls = Elements['desktop'] | Elements['vscode']
18
19const MAX_COLUMNS = 72
20const LABEL_W = 11
21const PX_PER_CELL = 8
22const MAX_PX = 520
23
24// ── Shared rows ────────────────────────────────────────────────────────────
25
26const isLive = (vm: ViewModel) => vm.state === 'warm' || vm.state === 'cooling'
27
28function header(E: TermEls, vm: ViewModel, chevron: RenderElement | null, tail: RenderElement | null) {
29  const { Box, Text } = E
30  return (
31    <Box key="header" flexDirection="row" columnGap={1}>
32      <Text color={vm.tone}>{vm.isPinging ? '↻' : vm.glyph}</Text>
33      <Box flexShrink={1} minWidth={0}>
34        <Text wrap="truncate">
35          <Text bold>Cache</Text>
36          <Text dimColor>{` · ${vm.isPinging ? 'Warming' : vm.label} · ${vm.detail}${vm.isTtlKnown || !isLive(vm) ? '' : ' (assumed)'}`}</Text>
37        </Text>
38      </Box>
39      {tail}
40      <Box flexGrow={1} />
41      {chevron}
42    </Box>
43  )
44}
45
46function stats(E: TermEls, vm: ViewModel, sparkCells: number) {
47  const { Box, Text } = E
48  const sep = <Text dimColor> · </Text>
49  const row = (key: string, label: string, content: RenderElement) => (
50    <Box key={key} flexDirection="row">
51      <Box width={LABEL_W} flexShrink={0}>
52        <Text dimColor>{label}</Text>
53      </Box>
54      <Box flexShrink={1} minWidth={0}>
55        <Text wrap="truncate">{content}</Text>
56      </Box>
57    </Box>
58  )
59
60  const last = vm.last ? (
61    <Text>
62      <Text bold>{vm.lastHit === null ? '—' : pct(vm.lastHit)}</Text>
63      <Text> hit</Text>
64      {sep}
65      <Text>{`${fmtTokens(vm.last.read)} read`}</Text>
66      {sep}
67      <Text>{`${fmtTokens(vm.last.written)} written`}</Text>
68      {vm.last.uncached > 0 ? sep : null}
69      {vm.last.uncached > 0 ? <Text dimColor>{`${fmtTokens(vm.last.uncached)} uncached`}</Text> : null}
70    </Text>
71  ) : (
72    <Text dimColor>no reply yet</Text>
73  )
74
75  const saved: Tone = vm.saved !== null && vm.saved < 0 ? 'error' : 'success'
76  const session = (
77    <Text>
78      <Text bold>{vm.sessionHit === null ? '—' : pct(vm.sessionHit)}</Text>
79      <Text> hit</Text>
80      {sep}
81      <Text>{`${fmtTokens(vm.totals.read)} read`}</Text>
82      {vm.saved === null ? null : sep}
83      {vm.saved === null ? null : (
84        <Text color={saved}>{vm.saved < 0 ? `input +${pct(-vm.saved)}` : `input −${pct(vm.saved)}`}</Text>
85      )}
86      {vm.lapses > 0 ? sep : null}
87      {vm.lapses > 0 ? <Text color="warning">{vm.lapses === 1 ? '1 lapse' : `${vm.lapses} lapses`}</Text> : null}
88    </Text>
89  )
90
91  const spark = sparkOf(vm.replies, sparkCells)
92  const history = spark.length ? (
93    <Text>
94      {spark.map(s => (
95        <Text color={s.tone} dimColor={s.isDim}>
96          {s.ch}
97        </Text>
98      ))}
99    </Text>
100  ) : (
101    <Text dimColor>—</Text>
102  )
103
104  const keep = (
105    <Text>
106      <Text color={vm.isAuto ? 'success' : undefined} dimColor={!vm.isAuto}>
107        {vm.isAuto ? 'auto' : 'off'}
108      </Text>
109      {vm.isPinging ? sep : null}
110      {vm.isPinging ? <Text color={vm.tone}>pinging…</Text> : null}
111      {!vm.isPinging && vm.nextPingMs !== null ? sep : null}
112      {!vm.isPinging && vm.nextPingMs !== null ? <Text dimColor>{`next ping in ${fmtSpan(vm.nextPingMs)}`}</Text> : null}
113      {vm.pings > 0 ? sep : null}
114      {vm.pings > 0 ? <Text dimColor>{`${vm.pings} ${vm.pings === 1 ? 'ping' : 'pings'}, ${fmtTokens(vm.pingRead)} re-read`}</Text> : null}
115    </Text>
116  )
117
118  return [
119    row('last', 'Last reply', last),
120    row('session', 'Session', session),
121    row('history', 'History', history),
122    row('keep', 'Keep-warm', keep),
123  ]
124}
125
126function actionsRow(E: TermEls, vm: ViewModel, actions: Actions) {
127  const { Box, Text, Button } = E
128  return (
129    <Box key="actions" flexDirection="column">
130      {vm.notice ? (
131        <Text wrap="wrap">
132          <Text color={vm.tone}>● </Text>
133          <Text>{vm.notice}</Text>
134        </Text>
135      ) : null}
136      <Box flexDirection="row" columnGap={1}>
137        {isLive(vm) ? (
138          <Button key="ping" label={vm.isPinging ? 'Warming…' : 'Keep warm'} variant="primary" hotkey="w" onPress={actions.ping} />
139        ) : null}
140        <Button key="auto" label={vm.isAuto ? 'Auto on' : 'Auto off'} hotkey="a" dimColor={!vm.isAuto} onPress={actions.auto} />
141      </Box>
142    </Box>
143  )
144}
145
146// ── Terminal ───────────────────────────────────────────────────────────────
147
148function barText(E: TermEls, vm: ViewModel, n: number) {
149  const { Text } = E
150  const filled = Math.max(vm.warmth > 0 ? 1 : 0, Math.round(vm.warmth * n))
151  return (
152    <Text key="bar">
153      <Text color={vm.tone}>{'━'.repeat(filled)}</Text>
154      <Text dimColor>{'─'.repeat(Math.max(0, n - filled))}</Text>
155    </Text>
156  )
157}
158
159export function renderTerminal(E: TermEls, vm: ViewModel, columns: number, actions: Actions, isCollapsed: boolean): RenderElement {
160  const { Box, Text, Button } = E
161  const cols = Math.max(24, Math.min(MAX_COLUMNS, columns))
162  const chevron = <Button key="collapse" plain dimColor label={isCollapsed ? '▸' : '▾'} onPress={actions.toggle} />
163
164  if (isCollapsed || vm.state === 'empty') {
165    const tail =
166      vm.state === 'empty' ? null : (
167        <Box flexDirection="row" columnGap={1} flexShrink={0}>
168          {cols >= 56 ? barText(E, vm, 12) : null}
169          {vm.lastHit === null ? null : <Text dimColor>{`${pct(vm.lastHit)} hit`}</Text>}
170        </Box>
171      )
172    return (
173      <Box key="cache-warm" flexDirection="column" width={cols}>
174        {header(E, vm, vm.state === 'empty' ? null : chevron, tail)}
175      </Box>
176    )
177  }
178
179  return (
180    <Box key="cache-warm" flexDirection="column" rowGap={1} width={cols}>
181      <Box flexDirection="column">
182        {header(E, vm, chevron, null)}
183        {barText(E, vm, cols)}
184        {stats(E, vm, cols - LABEL_W)}
185      </Box>
186      {actionsRow(E, vm, actions)}
187    </Box>
188  )
189}
190
191// ── Desktop, editor ────────────────────────────────────────────────────────
192
193// [light, dark] for each tone the bar takes, close to the hosts' own theme colours.
194const HEX: Record<Tone, [string, string]> = {
195  claude: ['#C15F3C', '#D97757'],
196  warning: ['#966C1E', '#FFC107'],
197  suggestion: ['#5769F7', '#B1B9F9'],
198  inactive: ['#87867F', '#87867F'],
199  success: ['#2C7A39', '#4EBA65'],
200  error: ['#AB2B3F', '#FF6B80'],
201  merged: ['#8250DF', '#AF87FF'],
202}
203
204export function barSvg(warmth: number, tone: Tone, width: number): string {
205  const h = 6
206  const fill = warmth > 0 ? Math.max(h, Math.round(warmth * width)) : 0
207  const [light, dark] = HEX[tone]
208  return (
209    `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${h}" viewBox="0 0 ${width} ${h}">` +
210    `<style>.t{fill:#3D3D3A}.f{fill:${dark}}@media (prefers-color-scheme:light){.t{fill:#E8E6DC}.f{fill:${light}}}</style>` +
211    `<rect class="t" width="${width}" height="${h}" rx="3"/>` +
212    (fill > 0 ? `<rect class="f" width="${fill}" height="${h}" rx="3"/>` : '') +
213    `</svg>`
214  )
215}
216
217// Lucide chevron-up (fold) / chevron-down (open), drawn as plan-progress and context-cache draw theirs: the picture
218// centred in its cell and a blank Button over it, so the host draws its own hover and focus ring and takes the press.
219const CHIP = 16
220const chevronSvg = (isCollapsed: boolean) =>
221  `<svg xmlns="http://www.w3.org/2000/svg" width="${CHIP}" height="${CHIP}" viewBox="0 0 24 24"><style>.c{stroke:#C2C0B6}` +
222  `@media (prefers-color-scheme:light){.c{stroke:#3D3D3A}}</style>` +
223  `<path class="c" fill="none" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" d="${isCollapsed ? 'm6 9 6 6 6-6' : 'm18 15-6-6-6 6'}"/></svg>`
224
225export function renderDesktop(E: DeskEls, vm: ViewModel, columns: number, actions: Actions, isCollapsed: boolean): RenderElement {
226  const T = E as unknown as TermEls
227  const { Box, Button, Svg } = E
228  const px = Math.max(120, Math.min(MAX_PX, columns * PX_PER_CELL - 32))
229  const chevron = (
230    <Box key="collapse-cell" width={3} height={1} flexShrink={0} justifyContent="center" alignItems="center">
231      <Svg source={chevronSvg(isCollapsed)} alt={isCollapsed ? 'expand the cache band' : 'collapse the cache band'} width={CHIP} height={CHIP} />
232      <Box position="absolute" top={0} left={0} right={0} bottom={0} justifyContent="center" alignItems="center">
233        <Button key="collapse" plain label={'  '} onPress={actions.toggle} />
234      </Box>
235    </Box>
236  )
237  const bar = (key: string, width: number) => (
238    <Svg key={key} source={barSvg(vm.warmth, vm.tone, width)} alt={`cache ${pct(vm.warmth)} warm`} width={width} height={6} />
239  )
240
241  if (isCollapsed || vm.state === 'empty') {
242    const tail =
243      vm.state === 'empty' ? null : (
244        <Box flexDirection="row" columnGap={1} alignItems="center" flexShrink={0}>
245          {bar('bar-mini', 96)}
246          {vm.lastHit === null ? null : <T.Text dimColor>{`${pct(vm.lastHit)} hit`}</T.Text>}
247        </Box>
248      )
249    return (
250      <Box key="cache-warm" flexDirection="column">
251        {header(T, vm, vm.state === 'empty' ? null : chevron, tail)}
252      </Box>
253    )
254  }
255
256  return (
257    <Box key="cache-warm" flexDirection="column" rowGap={1}>
258      <Box flexDirection="column">
259        {header(T, vm, chevron, null)}
260        <Box flexDirection="row" alignItems="center" height={1}>
261          {bar('bar', px)}
262        </Box>
263        {stats(T, vm, Math.floor(px / PX_PER_CELL) - LABEL_W)}
264      </Box>
265      {actionsRow(T, vm, actions)}
266    </Box>
267  )
268}
269
types/index.d.ts 66 lines
1// Contract for cache-warm: the values it keeps in $.state.
2
3/** One main-thread response's prompt-cache figures, or a keep-warm ping's. */
4export type CacheWarmReply = {
5  /** When it landed, epoch milliseconds. */
6  at: number
7  /** Input tokens the cache served. */
8  read: number
9  /** Input tokens written to the cache. */
10  written: number
11  /** Input tokens neither read nor written. */
12  uncached: number
13  /** A keep-warm ping rather than a reply of the conversation. */
14  isPing: boolean
15}
16
17/** Token sums over the session's main-thread replies (pings counted apart). */
18export type CacheWarmTotals = {
19  read: number
20  written: number
21  uncached: number
22  /** Reads priced against an uncached token on the model that answered (0.1×, 0.025× on Claude Fable 5.1); absent in snapshots from before 1.0.1. */
23  readCost?: number
24}
25
26/** What is known about the main conversation's prompt cache. */
27export type CacheWarmSnapshot = {
28  /** When the request that last wrote or read the cache started (the TTL runs from there); null when nothing is cached. */
29  lastAt: number | null
30  /** The cache's time to live in milliseconds (300000 or 3600000). */
31  ttlMs: number
32  /** True once the TTL was read from a real response or the engine. */
33  isTtlKnown: boolean
34  /** Prompt tokens the next request re-sends: what a cold cache rewrites. */
35  prefixTokens: number
36  /** The last replies, oldest first, at most 32. */
37  replies: CacheWarmReply[]
38  totals: CacheWarmTotals
39  /** Keep-warm pings this session that reached the cache. */
40  pings: number
41  /** Tokens those pings read from the cache. */
42  pingRead: number
43  /** When the last ping landed; null before any. */
44  lastPingAt: number | null
45  /** Times the cache lapsed between two replies (a rewrite the next message paid). */
46  lapses: number
47}
48
49declare module 'claude-code' {
50  interface PluginState {
51    'cache-warm': {
52      snapshot: CacheWarmSnapshot | null
53      /** The band is shown (the Cache button, /cache-warm). */
54      isOpen: boolean
55      /** Folded to one line, the default (the chevron, /cache-warm-collapse); mirrored to $.store. */
56      isCollapsed: boolean
57      /** Pings the cache by itself before it lapses while idle (/cache-warm-auto); mirrored to $.store. */
58      isAuto: boolean
59      /** A keep-warm ping is in flight. */
60      isPinging: boolean
61      /** The clock, written when a figure the band shows would change. */
62      now: number
63    }
64  }
65}
66