Prompt-cache meter above the input box: hit %, read/wrote/new tokens, a TTL countdown and advice; cache-expiry toasts and context-window alerts. /cache opens a…

A prompt-cache meter for Claude Code, written as a mod (function hooks).
● cache ██████████ 86% read 317k wrote 52.3k new 2 ⏱ 59m 1h · warm: keep going
warm, soon, expired (suggests /compact on big contexts), miss (names the cause: model changed, cache lapsed, prefix changed), off./cache: a pane with the countdown bar, the last request as a stacked read/wrote/new bar, and a per-turn table with a totals row. /cache stop, Esc or [ close ] closes it./cache setup: a step-by-step walkthrough. Start from a preset (Recommended, Quiet, Live), then one step per setting that explains what it is for, with a hint on each choice and a custom… field where a list makes sense. A review page shows everything; Save writes your Claude Code settings the same way /config does, and the mod reloads with them.30m, 15m, 5m, 1m)./compact or /clear.cache 86% · 59m on its own line under the input box, beside your statusline.Requires Claude Code 2.1.287 or later (mods).
From the marketplace (loads in every session):
/plugin marketplace add jmac122/cache-countdown
/plugin install cache-countdown@cache-countdown
For one session only: clone it and point --plugin-dir at the folder:
git clone https://github.com/jmac122/cache-countdown
claude --plugin-dir ./cache-countdown
It writes nothing to your settings until you press Save in /cache setup. On first load it shows one toast pointing at /cache setup, once ever.
There is no fixed interval. One timer sleeps until the next moment something changes on screen:
| Time left | Countdown shows | Wakes |
|---|---|---|
more than warnSeconds | 59m | every tickSeconds (default 60) |
the last warnSeconds (default 60) | 0:42 | every finalTickSeconds (default 1) |
| a toast mark | once, at the mark | |
| expired, nothing cached, caching off | 0:00 | never, until your next request |
meter row, footer line and toasts all off, /cache closed | never: nothing to draw or announce |
On a 1-hour cache with the defaults that's about 120 wakes per hour instead of 3,600. Each wake redraws only the band and the pane, which takes about 1–5 ms per redraw (measured in a live session).
Use /cache setup, the /config menu, or ~/.claude/settings.json under pluginConfigs["cache-countdown@cache-countdown"].options (marketplace install) or pluginConfigs["cache-countdown"].options (--plugin-dir):
| Option | Default | |||
|---|---|---|---|---|
ttl | auto | auto \ | 5m \ | 1h; the last two pin it |
tickSeconds | 60 | countdown step outside the final stretch; 60 shows 59m, below 60 shows m:ss | ||
warnSeconds | 60 | the final stretch: the band turns red and the countdown uses finalTickSeconds | ||
finalTickSeconds | 1 | countdown step inside the final stretch | ||
toast | true | expiry toasts on/off | ||
toastAt | 1m,10s,5s,1s | times left at which a toast fires, once each per cache entry, with units: 30m, 15m, 5m, 1m, 5m, 2m, 1m, 90s, 1h (a bare number is seconds). Keep marks at least 3 s apart: Claude Code drops a toast that comes within 2 s of the previous one | ||
contextAlertsAt | 50,25,10 | a toast each time the conversation crosses one of these levels of the context window (% remaining); off disables. Independent of the cache toasts | ||
compactWhenRemainingPct | 60 | once the window remaining is at or below this %, an expired cache suggests /compact instead of "keep going" (60 = 400k used on a 1M window, 80k on 200k) | ||
band | true | the meter row above the input box | ||
status | false | a short footer line beside your statusline |
ttl: auto)The first match wins:
FORCE_PROMPT_CACHING_5M=1 → 5mCLAUDE_CODE_PROMPT_CACHE_TTL → 5m / 1hpromptCacheTtl setting, read merged over user, project, local, --settings and managed settingsENABLE_PROMPT_CACHING_1H=1 → 1hRequest timing then corrects it. A cache hit more than 5 minutes after the previous request proves the 1h lifetime. A miss 5–60 minutes later, with the same model and a prompt that didn't shrink, says 5m. The /cache pane names the source in use. DISABLE_PROMPT_CACHING (and its _HAIKU, _SONNET and _OPUS forms) shows off.
cache-countdown makes no network requests and sends nothing anywhere. It runs no shell commands, starts no processes, and reads no files. Everything it shows comes from inside your Claude Code session, and its data stays on your machine.
It reads:
turn.step.$.session.usage(). The rate-limit info is used only to tell a Claude subscription (1h cache) apart from API billing (5m cache).promptCacheTtl setting, and the environment variables CLAUDE_CODE_PROMPT_CACHE_TTL, FORCE_PROMPT_CACHING_5M, ENABLE_PROMPT_CACHING_1H and DISABLE_PROMPT_CACHING (plus its _HAIKU, _SONNET and _OPUS forms).It writes:
$.config.set, only when you press Save in /cache setup, and only the ones you changed. They land under pluginConfigs in your Claude Code user settings, the same place /config puts them. The complete list of keys it can write is cache-countdown.ttl, .tickSeconds, .warnSeconds, .finalTickSeconds, .toast, .toastAt, .contextAlertsAt, .compactWhenRemainingPct, .band and .status. Each is spelled out in hooks/cache-countdown.tsx (writeSetting). It never sets environment variables, the permission mode, permissions, hooks, your statusline or any other setting.setupSeen, in the plugin's own store, so the first-run hint shows only once.Request history lives in session memory ($.state) and is gone when the session ends. There is no telemetry, no account and no remote server, so there is no privacy policy.
session.start: reads the settings and env vars above, registers /cache.session.end: on /clear, resets the meter.turn.step: records each main-loop request's token counts (subagents are skipped; they have their own cache prefixes). It passes every request through unchanged.command.run: answers /cache, /cache setup and /cache stop. Every other command passes straight through untouched; it never sees, changes or blocks them.ui.close: notices when its own pane closes.ui.render: draws the meter row (AbovePrompt) and its two panes; everything else is passed through.It changes nothing about requests, prompts, tools or other commands. Request history lives in $.state, so a hot reload keeps it.
claude plugin validate .
claude plugin test .
tsc -p .
hooks/cache.ts and hooks/setup.ts are pure logic with no engine calls. hooks/cache-countdown.tsx is the wiring.
hooks/cache-countdown.tsx 734 lines1/**
2 * cache-countdown: the wiring. turn.step records each main-loop request and
3 * builds the view the band and the pane draw from, once per request; one
4 * self-scheduling clock.after timer then carries only the time left, waking
5 * when the countdown text changes or a toast is due, and not at all once the
6 * cache has expired or caching is off. /cache opens the detail pane, /cache
7 * setup the walkthrough (setup.ts holds its pure half).
8 */
9import { atom, read, update } from 'claude-code'
10import type { EngineInterface, Register, TurnUsage } from 'claude-code'
11
12import type { CacheSample, CacheView, SetupChange, SetupDraft } from '../types'
13import {
14 accountKind,
15 advise,
16 baseLifetime,
17 buildView,
18 cachingOff,
19 contextToast,
20 countdownText,
21 crossLevels,
22 effectiveLifetime,
23 expiryToast,
24 formatCount,
25 marksBehind,
26 nextDelay,
27 observe,
28 readConfig,
29 remainingMs,
30 splitCells,
31 statusText,
32 takeMarks,
33 textBar,
34} from './cache'
35import type { Account, AdviceState, Config, Lifetime } from './cache'
36import { CUSTOM, FIELDS, PRESETS, REVIEW_STEP, STEP_COUNT, changes, display, draftFromOptions, encode, isListed } from './setup'
37import type { Field } from './setup'
38
39const PANE = 'cache'
40const SETUP = 'cache-setup'
41const MAX_SAMPLES = 200
42
43const TICK = { plugin: 'cache-countdown', key: 'tick' } as const
44const samplesAtom = atom({ plugin: 'cache-countdown', key: 'samples' } as const, [])
45const viewAtom = atom({ plugin: 'cache-countdown', key: 'view' } as const, null)
46const tickAtom = atom(TICK, 0)
47const observedAtom = atom({ plugin: 'cache-countdown', key: 'observed' } as const, null)
48const alertsAtom = atom({ plugin: 'cache-countdown', key: 'alerts' } as const, [])
49const paneAtom = atom({ plugin: 'cache-countdown', key: 'paneOpen' } as const, false)
50const draftAtom = atom({ plugin: 'cache-countdown', key: 'draft' } as const, null)
51const noteAtom = atom({ plugin: 'cache-countdown', key: 'setupNote' } as const, '')
52const pendingAtom = atom({ plugin: 'cache-countdown', key: 'pending' } as const, [])
53const stepAtom = atom({ plugin: 'cache-countdown', key: 'setupStep' } as const, 0)
54
55// ---------------------------------------------------------------- module state (starts over on every load)
56
57/** the options as stored (the walkthrough compares against these) */
58let current: Readonly<Record<string, unknown>> = {}
59/** the options, checked and parsed */
60let cfg: Config = readConfig({})
61
62type Environment = { ttl?: string; force5m?: string; enable1h?: string; off: { all?: string; haiku?: string; sonnet?: string; opus?: string } }
63let env: Environment = { off: {} }
64let settingTtl: unknown
65let account: Account = 'other'
66let contextWindow = 0
67
68let timer: ReturnType<EngineInterface['clock']['after']> | null = null
69let busy = false
70let again = false
71/** bumped by every stop, so a wake already under way does not schedule another */
72let epoch = 0
73/** how many of cfg.marks the current countdown has used up */
74let shown = 0
75let lastStatus: string | undefined
76let paneShown = false
77
78// ---------------------------------------------------------------- reading the world
79
80async function readEnvironment($: EngineInterface) {
81 const [ttl, force5m, enable1h, all, haiku, sonnet, opus] = await Promise.all([
82 $.env.get('CLAUDE_CODE_PROMPT_CACHE_TTL'),
83 $.env.get('FORCE_PROMPT_CACHING_5M'),
84 $.env.get('ENABLE_PROMPT_CACHING_1H'),
85 $.env.get('DISABLE_PROMPT_CACHING'),
86 $.env.get('DISABLE_PROMPT_CACHING_HAIKU'),
87 $.env.get('DISABLE_PROMPT_CACHING_SONNET'),
88 $.env.get('DISABLE_PROMPT_CACHING_OPUS'),
89 ])
90 env = { ttl, force5m, enable1h, off: { all, haiku, sonnet, opus } }
91 const merged = await $.settings.read().catch(() => ({}) as Readonly<Record<string, unknown>>)
92 settingTtl = merged.promptCacheTtl
93}
94
95async function refreshUsage($: EngineInterface) {
96 try {
97 const usage = await $.session.usage()
98 contextWindow = usage.context && usage.context.window > 0 ? usage.context.window : 0
99 account = accountKind(usage.rateLimits ?? [])
100 } catch {
101 // keep the last reading
102 }
103}
104
105const baseNow = (): Lifetime =>
106 baseLifetime({ option: cfg.ttl, force5m: env.force5m, envTtl: env.ttl, settingTtl, enable1h: env.enable1h, account })
107
108/** Rebuilds the view from the kept samples and stores it: once per request, at load and on /clear. */
109async function rebuild($: EngineInterface): Promise<CacheView> {
110 const samples = await read($, samplesAtom)
111 const lifetime = effectiveLifetime(baseNow(), await read($, observedAtom))
112 const view = buildView({ samples, lifetime, off: cachingOff(env.off, samples.at(-1)?.model), window: contextWindow })
113 await update($, viewAtom, () => view)
114 return view
115}
116
117// ---------------------------------------------------------------- per request
118
119async function recordStep($: EngineInterface, turnId: string, model: string, at: number, usage: TurnUsage) {
120 const samples = await read($, samplesAtom)
121 const prev = samples.at(-1)
122 const sample: CacheSample = {
123 at,
124 turnId,
125 turnNo: prev ? (prev.turnId === turnId ? prev.turnNo : prev.turnNo + 1) : 1,
126 model: usage.model || model,
127 read: usage.cache_read_input_tokens ?? 0,
128 write: usage.cache_creation_input_tokens ?? 0,
129 fresh: usage.input_tokens ?? 0,
130 output: usage.output_tokens ?? 0,
131 }
132 await update($, samplesAtom, list => [...list, sample].slice(-MAX_SAMPLES))
133 await refreshUsage($)
134 if (!baseNow().pinned) {
135 const before = await read($, observedAtom)
136 const after = observe(before, prev, sample)
137 if (after !== before) await update($, observedAtom, () => after)
138 }
139 const view = await rebuild($)
140 await checkContext($, view)
141 $.ui.log(
142 `step read=${sample.read} write=${sample.write} new=${sample.fresh} model=${sample.model} ttl=${view.ttlLabel} source=${view.source} window=${contextWindow || 'unknown'}`,
143 { to: 'debug' },
144 )
145 await restartCountdown($, view)
146}
147
148async function checkContext($: EngineInterface, view: CacheView) {
149 if (!view.last || view.windowLeft === null || cfg.levels.length === 0) return
150 const before = await read($, alertsAtom)
151 const { announce, announced } = crossLevels(cfg.levels, before, view.windowLeft)
152 if (announced.join() !== before.join()) await update($, alertsAtom, () => announced)
153 if (announce !== undefined) $.ui.toast(contextToast(view.windowLeft, view.last.prompt, view.window, cfg.compactWhenRemainingPct))
154}
155
156// ---------------------------------------------------------------- the timer
157
158function stopTimer() {
159 epoch++
160 again = false
161 timer?.cancel()
162 timer = null
163}
164
165const isLive = (view: CacheView | null): view is CacheView & { last: NonNullable<CacheView['last']> } =>
166 !!view && !!view.last && !view.off && !view.last.uncached
167
168/** A new countdown (a request arrived, or the module loaded): every mark still ahead is armed again. */
169async function restartCountdown($: EngineInterface, view: CacheView) {
170 stopTimer()
171 if (!isLive(view)) {
172 await setStatus($, statusText(view, 0, cfg))
173 return
174 }
175 shown = marksBehind(cfg.marks, remainingMs(view.last.at, view.ttl, await $.clock.now()))
176 await tick($)
177}
178
179/** One wake at a time; a wake asked for during one runs once right after it. */
180async function tick($: EngineInterface) {
181 if (busy) {
182 again = true
183 return
184 }
185 busy = true
186 try {
187 do {
188 again = false
189 timer?.cancel()
190 timer = null
191 const mine = epoch
192 const delay = await wake($)
193 if (!again && delay !== null && mine === epoch) timer = $.clock.after(delay, () => void tick($))
194 } while (again)
195 } finally {
196 busy = false
197 }
198}
199
200/** What one wake does: the toast due, the time left for the band and pane, the footer line; then how long to sleep. */
201async function wake($: EngineInterface): Promise<number | null> {
202 const view = await read($, viewAtom)
203 if (!isLive(view)) {
204 await setStatus($, statusText(view, 0, cfg))
205 return null
206 }
207 const left = remainingMs(view.last.at, view.ttl, await $.clock.now())
208 if (cfg.toast) {
209 const due = takeMarks(cfg.marks, shown, left)
210 shown = due.shown
211 if (due.fire !== undefined) $.ui.toast(expiryToast(due.fire, view.last.prompt))
212 }
213 if (cfg.band || paneShown) await $.state.set(TICK, left)
214 await setStatus($, statusText(view, left, cfg))
215 // nothing to draw and nothing to announce: sleep until a request or /cache asks again
216 if (!cfg.toast && !cfg.status && !cfg.band && !paneShown) return null
217 return nextDelay(left, cfg, cfg.toast ? cfg.marks : [], shown)
218}
219
220async function setStatus($: EngineInterface, text: string | undefined) {
221 if (!cfg.status || text === lastStatus) return
222 lastStatus = text
223 $.ui.status(text)
224}
225
226// ---------------------------------------------------------------- session
227
228async function startSession($: EngineInterface) {
229 stopTimer()
230 shown = 0
231 lastStatus = undefined
232 await readEnvironment($)
233 await refreshUsage($)
234 await $.command.register({
235 name: 'cache',
236 description: 'Prompt cache meter: countdown, last request, per-turn table',
237 argumentHint: '[setup|stop]',
238 immediate: true,
239 })
240 paneShown = await read($, paneAtom)
241 const view = await rebuild($)
242 const off = view.off ? `, prompt caching is off (${view.off})` : ''
243 $.ui.log(`cache-countdown loaded: ${view.ttlLabel} cache (${view.source})${off}, /cache opens the pane`, { to: 'debug' })
244 await applyPending($)
245 await firstRun($)
246 await restartCountdown($, view)
247}
248
249async function firstRun($: EngineInterface) {
250 const seen = await $.store.get('setupSeen').catch(() => true)
251 if (seen) return
252 $.ui.toast('cache-countdown is on: /cache setup walks through its settings')
253 await $.store.set('setupSeen', true).catch(() => undefined)
254}
255
256async function clearSession($: EngineInterface) {
257 stopTimer()
258 shown = 0
259 await update($, samplesAtom, () => [])
260 await update($, observedAtom, () => null)
261 await update($, alertsAtom, () => [])
262 await rebuild($)
263 if (cfg.status || lastStatus !== undefined) $.ui.status(undefined)
264 lastStatus = undefined
265}
266
267// ---------------------------------------------------------------- /cache
268
269async function setPane($: EngineInterface, open: boolean) {
270 paneShown = open
271 if ((await read($, paneAtom)) !== open) await update($, paneAtom, () => open)
272}
273
274async function closePane($: EngineInterface) {
275 await $.ui.close({ id: PANE }).catch(() => undefined)
276 await setPane($, false)
277}
278
279async function openPane($: EngineInterface): Promise<string> {
280 await $.ui.open({ id: PANE, title: 'cache', columns: 64, rows: 24, focus: true, closeOnEscape: true })
281 await setPane($, true)
282 const view = await read($, viewAtom)
283 const left = view?.last ? remainingMs(view.last.at, view.ttl, await $.clock.now()) : 0
284 // the pane draws from the tick: a live countdown wakes now (and writes it), anything else writes it once
285 if (isLive(view) && left > 0) await tick($)
286 else await $.state.set(TICK, left)
287 if (!view) return 'cache: not read yet · /cache stop closes'
288 return `${view.ttlLabel} cache (${view.source}) · ${advise(view, left, cfg).text} · /cache stop closes`
289}
290
291async function runCommand($: EngineInterface, args: string): Promise<{ text: string }> {
292 const word = args.trim().toLowerCase()
293 if (word === 'setup') {
294 await openSetup($)
295 return { text: 'cache setup opened: start from a preset, adjust each setting, then Save' }
296 }
297 if (word === 'stop') {
298 await closePane($)
299 return { text: 'cache pane closed' }
300 }
301 return { text: await openPane($) }
302}
303
304// ---------------------------------------------------------------- drawing helpers
305
306const STATE_COLOR: Record<AdviceState, string> = {
307 off: 'gray',
308 cold: 'gray',
309 uncached: 'gray',
310 expired: 'red',
311 soon: 'red',
312 miss: 'yellow',
313 warm: 'green',
314}
315
316/** green while plenty is left, yellow in the last third, red in the final stretch */
317function clockColor(left: number, ttl: number): string {
318 if (left <= cfg.warnSeconds * 1000) return 'red'
319 return left <= (ttl * 1000) / 3 ? 'yellow' : 'green'
320}
321
322/** what the setup walkthrough shows as "Detected now" */
323async function snapshot($: EngineInterface, now: number) {
324 const view = await read($, viewAtom)
325 const left = view?.last ? remainingMs(view.last.at, view.ttl, now) : 0
326 return {
327 ttl: view ? (view.off ? 'off' : view.ttlLabel) : 'unknown',
328 source: view ? (view.off ?? view.source) : 'not read yet',
329 left,
330 }
331}
332
333// ---------------------------------------------------------------- setup walkthrough: actions
334
335/**
336 * One config.set per option, each with its key spelled out, so anyone reading the
337 * source (the plugin directory's scan included) can see exactly which settings it
338 * can write: this plugin's own options under pluginConfigs, nothing else.
339 */
340async function writeSetting($: EngineInterface, change: SetupChange): Promise<{ deny?: string }> {
341 const value = change.value
342 switch (change.key) {
343 case 'ttl': return $.config.set({ key: 'cache-countdown.ttl', value: value })
344 case 'tickSeconds': return $.config.set({ key: 'cache-countdown.tickSeconds', value: value })
345 case 'warnSeconds': return $.config.set({ key: 'cache-countdown.warnSeconds', value: value })
346 case 'finalTickSeconds': return $.config.set({ key: 'cache-countdown.finalTickSeconds', value: value })
347 case 'toast': return $.config.set({ key: 'cache-countdown.toast', value: value })
348 case 'toastAt': return $.config.set({ key: 'cache-countdown.toastAt', value: value })
349 case 'contextAlertsAt': return $.config.set({ key: 'cache-countdown.contextAlertsAt', value: value })
350 case 'compactWhenRemainingPct': return $.config.set({ key: 'cache-countdown.compactWhenRemainingPct', value: value })
351 case 'band': return $.config.set({ key: 'cache-countdown.band', value: value })
352 case 'status': return $.config.set({ key: 'cache-countdown.status', value: value })
353 default: return { deny: 'not an option of this plugin' }
354 }
355}
356
357/**
358 * Writes the wizard's queued settings. Each write reloads the mod, which can end
359 * this environment mid-loop, so the queue lives in $.state: an item is taken off
360 * before it is written, and the next load's session.start carries on.
361 */
362async function applyPending($: EngineInterface) {
363 for (;;) {
364 const queue = (await read($, pendingAtom)) as SetupChange[]
365 const head = queue[0]
366 if (!head) return
367 await update($, pendingAtom, q => (q as SetupChange[]).slice(1))
368 const r = await writeSetting($, head).catch((err: unknown) => ({ deny: String(err) }))
369 const line = r.deny ? `${head.key} not saved (${r.deny})` : `${head.key} = ${encode(head.value)}`
370 await update($, noteAtom, note => (note ? `${note} · ${line}` : line))
371 if (queue.length === 1) $.ui.toast(`cache-countdown settings: ${(await read($, noteAtom)) as string}`)
372 }
373}
374
375async function openSetup($: EngineInterface) {
376 await update($, draftAtom, () => draftFromOptions(current))
377 await update($, noteAtom, () => '')
378 await update($, stepAtom, () => 0)
379 await $.ui.open({ id: SETUP, title: 'cache setup', focus: true, closeOnEscape: true, columns: 76, rows: 22 })
380 await $.store.set('setupSeen', true).catch(() => undefined)
381}
382
383async function saveSetup($: EngineInterface) {
384 await commitTyping($)
385 const draft = ((await read($, draftAtom)) as SetupDraft | null) ?? draftFromOptions(current)
386 const todo = changes(draft, current)
387 await $.ui.close({ id: SETUP }).catch(() => undefined)
388 await update($, draftAtom, () => null)
389 if (todo.length === 0) {
390 $.ui.toast('cache-countdown: nothing changed')
391 return
392 }
393 await update($, noteAtom, () => '')
394 await update($, pendingAtom, () => todo)
395 await applyPending($)
396}
397
398async function pickSetup($: EngineInterface, key: string, value: string | number | boolean) {
399 await update($, draftAtom, d => ({ ...((d as SetupDraft | null) ?? draftFromOptions(current)), [key]: value }))
400}
401
402/** custom…: mark the step custom and put the keyboard in its text field (focus waits for the field to be drawn) */
403async function startCustom($: EngineInterface, key: string) {
404 await update($, draftAtom, d => ({ ...((d as SetupDraft | null) ?? draftFromOptions(current)), [`${key}:custom`]: true }))
405 await $.ui.focus({ requestId: SETUP, key: `in:${key}` }).catch(() => undefined)
406}
407
408// text typed into a custom field and not yet committed: held here so typing never redraws (a redraw per key resets the field)
409const typing = new Map<string, string>()
410
411/** Commit a custom field's text: the raw text for the field, and the cleaned value when it is valid. */
412async function typeCustom($: EngineInterface, key: string, raw: string) {
413 typing.delete(key)
414 const clean = FIELDS.find(f => f.key === key)?.custom?.normalize(raw)
415 await update($, draftAtom, d => ({
416 ...((d as SetupDraft | null) ?? draftFromOptions(current)),
417 [`${key}:text`]: raw,
418 [`${key}:custom`]: true,
419 ...(clean !== undefined ? { [key]: clean } : {}),
420 }))
421}
422
423/** Before leaving a step (Next, Back, review, Save): commit whatever is still being typed. */
424async function commitTyping($: EngineInterface) {
425 for (const [key, raw] of [...typing]) await typeCustom($, key, raw)
426}
427
428async function gotoStep($: EngineInterface, step: number) {
429 await commitTyping($)
430 await update($, stepAtom, () => Math.max(0, Math.min(REVIEW_STEP, step)))
431}
432
433async function presetSetup($: EngineInterface, draft: SetupDraft) {
434 await update($, draftAtom, () => ({ ...draft }))
435}
436
437// ---------------------------------------------------------------- hooks
438
439export const register: Register = (on, options) => {
440 current = options
441 cfg = readConfig(options)
442
443 on('session.start', async ($, e, next) => {
444 const started = await next(e)
445 await startSession($)
446 return started
447 })
448
449 on('session.end', async ($, e, next) => {
450 if (e.reason === 'clear') {
451 try {
452 await clearSession($)
453 } catch (err) {
454 $.ui.log(`cache-countdown: reset after /clear failed (${String(err)})`, { to: 'debug' })
455 }
456 }
457 return next(e)
458 })
459
460 on('turn.step', async function* ($, e, next) {
461 // subagents have cache prefixes of their own: their requests say nothing about this one
462 if (e.agentId) return yield* next(e)
463 const at = await $.clock.now()
464 const result = yield* next(e)
465 if (result.usage) {
466 try {
467 await recordStep($, e.turnId, e.model, at, result.usage)
468 } catch (err) {
469 $.ui.log(`cache-countdown: could not record a request (${String(err)})`, { to: 'debug' })
470 }
471 }
472 return result
473 })
474
475 on('command.run', { command: 'cache' }, async ($, e) => runCommand($, e.args))
476
477 on('ui.close', { id: PANE }, async ($, e, next) => {
478 const closed = await next(e)
479 await setPane($, false).catch(() => undefined)
480 return closed
481 })
482
483 // the meter row above the input box
484 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
485 if (!cfg.band || e.props.hasSurvey) return next(e)
486 const view = await read($, viewAtom)
487 if (!view) return next(e)
488 const { Box, Text } = $.ui.resolve(e)
489 if (view.off && !view.last) {
490 return (
491 <Box flexDirection="row">
492 <Text dimColor wrap="truncate-end">{`cache · ${advise(view, 0, cfg).text}`}</Text>
493 </Box>
494 )
495 }
496 if (await read($, paneAtom)) return next(e)
497 const last = view.last
498 if (!last) return next(e)
499 const left = await read($, tickAtom)
500 const advice = advise(view, left, cfg)
501 const wide = e.props.bodyColumns >= 90
502 const timed = advice.state !== 'off' && advice.state !== 'uncached'
503 // the figures never shrink or wrap; only the advice tail gives way on a narrow body
504 return (
505 <Box flexDirection="row" columnGap={1}>
506 <Box flexDirection="row" columnGap={1} flexShrink={0}>
507 <Text color={STATE_COLOR[advice.state]}>●</Text>
508 <Text bold color="cyan">cache</Text>
509 <Text color={STATE_COLOR[advice.state]}>{textBar(last.hit / 100, wide ? 10 : 6)}</Text>
510 <Text bold>{`${last.hit}%`}</Text>
511 {wide ? <Text color="green">{`read ${formatCount(last.read)}`}</Text> : <Text dimColor>{`prompt ${formatCount(last.prompt)}`}</Text>}
512 {wide ? <Text color="yellow">{`wrote ${formatCount(last.write)}`}</Text> : null}
513 {wide ? <Text color="blue">{`new ${formatCount(last.fresh)}`}</Text> : null}
514 {timed ? <Text bold color={clockColor(left, view.ttl)}>{`⏱ ${countdownText(left, cfg)}`}</Text> : null}
515 </Box>
516 <Text dimColor wrap="truncate-end">{view.off ? advice.text : `${view.ttlLabel} · ${advice.text}`}</Text>
517 </Box>
518 )
519 })
520
521 // the /cache pane
522 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
523 const view = await read($, viewAtom)
524 const left = await read($, tickAtom)
525 const { Box, Text, Button } = $.ui.resolve(e)
526 // remote surfaces collapse runs of spaces; no-break spaces keep aligned text aligned
527 const aligned = (text: string) => (e.surface === 'terminal' ? text : text.replace(/ /g, ' '))
528 const width = Math.max(32, Math.min(e.props.bodyColumns || 64, 80))
529 const close = <Button key="close" label="close" role="dismiss" onPress={() => void closePane($)} />
530 if (!view) {
531 return (
532 <Box flexDirection="column">
533 <Text bold color="cyan">PROMPT CACHE</Text>
534 <Text dimColor>not read yet</Text>
535 {close}
536 </Box>
537 )
538 }
539 const advice = advise(view, left, cfg)
540 const last = view.last
541 const ttlMs = view.ttl * 1000
542 const lifeCells = Math.max(10, width - 24)
543 const barCells = Math.max(10, width - 2)
544 const parts = last ? splitCells([last.read, last.write, last.fresh], barCells) : []
545 const colors = ['green', 'yellow', 'blue']
546 const room = Math.max(1, (e.props.scroll?.bodyRows ?? 24) - 15)
547 const rows = view.rows.slice(-room)
548 const COLS = [6, 7, 9, 9, 9, 6]
549 const cells = (key: string, values: string[], bold?: boolean) => (
550 <Box key={key} flexDirection="row">
551 {values.map((v, i) => (
552 <Box key={`${key}:${i}`} width={COLS[i] ?? 8} justifyContent={i === 0 ? 'flex-start' : 'flex-end'}>
553 <Text bold={bold}>{v}</Text>
554 </Box>
555 ))}
556 </Box>
557 )
558 return (
559 <Box flexDirection="column">
560 <Box key="title" flexDirection="column">
561 <Text bold color="cyan">PROMPT CACHE</Text>
562 <Text dimColor wrap="wrap">{view.off ? `off · ${view.off}` : `${view.ttlLabel} lifetime · ${view.source}`}</Text>
563 </Box>
564 {isLive(view) ? (
565 <Box key="life" flexDirection="row" columnGap={1} marginTop={1}>
566 <Text color={clockColor(left, view.ttl)}>{left > 0 ? `⏱ ${countdownText(left, cfg)} left` : '⏱ expired'}</Text>
567 <Text color={clockColor(left, view.ttl)}>{textBar(left / ttlMs, lifeCells)}</Text>
568 <Text>{`${Math.round((100 * left) / ttlMs)}%`}</Text>
569 </Box>
570 ) : null}
571 <Box key="advice" flexDirection="column" marginTop={isLive(view) ? 0 : 1}>
572 <Text color={STATE_COLOR[advice.state]} wrap="wrap">{advice.text}</Text>
573 <Text dimColor>{last ? `${last.model} · prompt ${formatCount(last.prompt)} tokens` : 'no request yet'}</Text>
574 </Box>
575 {last ? (
576 <Box key="last" flexDirection="column" marginTop={1}>
577 <Box flexDirection="row" justifyContent="space-between" width={barCells}>
578 <Text bold>last request</Text>
579 <Text>{`${last.hit}% hit`}</Text>
580 </Box>
581 <Box flexDirection="row">
582 {parts.map((n, i) => (n > 0 ? <Box key={`seg:${i}`} width={n} height={1} backgroundColor={colors[i] ?? 'gray'} /> : null))}
583 </Box>
584 <Box flexDirection="row" columnGap={2}>
585 <Text color="green">{`■ read ${formatCount(last.read)}`}</Text>
586 <Text color="yellow">{`■ wrote ${formatCount(last.write)}`}</Text>
587 <Text color="blue">{`■ new ${formatCount(last.fresh)}`}</Text>
588 </Box>
589 </Box>
590 ) : null}
591 {view.rows.length ? (
592 <Box key="turns" flexDirection="column" marginTop={1}>
593 {cells('head', ['turn', 'steps', 'read', 'wrote', 'new', 'hit'], true)}
594 {rows.map(r =>
595 cells(`turn:${r.label}`, [r.label, String(r.steps), formatCount(r.read), formatCount(r.write), formatCount(r.fresh), `${r.hit}%`]),
596 )}
597 {cells('all', [view.total.label, String(view.total.steps), formatCount(view.total.read), formatCount(view.total.write), formatCount(view.total.fresh), `${view.total.hit}%`], true)}
598 </Box>
599 ) : null}
600 <Box key="foot" flexDirection="row" columnGap={2} marginTop={1}>
601 {close}
602 <Text dimColor>{aligned('Esc or /cache stop closes')}</Text>
603 </Box>
604 </Box>
605 )
606 })
607
608 // the /cache setup walkthrough
609 on('ui.render', { component: 'Pane', requestId: SETUP }, async ($, e) => {
610 const draft = ((await read($, draftAtom)) as SetupDraft | null) ?? draftFromOptions(current)
611 const step = Math.max(0, Math.min(REVIEW_STEP, (await read($, stepAtom)) as number))
612 const s = await snapshot($, await $.clock.now())
613 if (e.surface === 'mobile') {
614 const { Box, Text } = $.ui.resolve(e)
615 return (
616 <Box flexDirection="column">
617 <Text bold>cache-countdown setup</Text>
618 <Text dimColor>No pickers on this surface: use /config, or run /cache setup in the terminal or desktop app.</Text>
619 </Box>
620 )
621 }
622 const { Box, Text, Button, Input } = $.ui.resolve(e)
623 const terminal = e.surface === 'terminal'
624 // breathing room everywhere but the terminal's inline pane, whose height Claude Code caps
625 const gap = !terminal || e.props.placement === 'dock' ? 1 : 0
626 const count = changes(draft, current).length
627 const preset = PRESETS.find(p => FIELDS.every(f => encode(p.draft[f.key] ?? '') === encode(draft[f.key] ?? '')))
628
629 // one choice: a button that is the click target, ● on the picked one in the accent colour, a dim hint beside it
630 const option = (key: string, label: string, hint: string | undefined, picked: boolean, onPress: () => void) => (
631 <Box key={`opt:${key}`} flexDirection="row" columnGap={1}>
632 <Button key={`pick:${key}`} label={`${picked ? '●' : '○'} ${label}`} variant={picked ? 'primary' : undefined} onPress={onPress} />
633 {hint ? <Text dimColor wrap="wrap">{hint}</Text> : null}
634 </Box>
635 )
636 const heading = (title: string) => (
637 <Box key="head" flexDirection="column">
638 <Text dimColor>{`cache-countdown setup · step ${step + 1} of ${STEP_COUNT}`}</Text>
639 <Text bold color="cyan">{title}</Text>
640 </Box>
641 )
642 const help = (lines: string[]) => (
643 <Box key="help" flexDirection="column" marginTop={gap}>
644 {lines.map((line, i) => (
645 <Text key={`help:${i}`} wrap="wrap">{line}</Text>
646 ))}
647 </Box>
648 )
649 const nav = (
650 <Box key="nav" flexDirection="row" columnGap={2} marginTop={gap}>
651 {step > 0 ? <Button key="back" label="Back" onPress={() => void gotoStep($, step - 1)} /> : null}
652 {step < REVIEW_STEP ? (
653 <Button key="next" label="Next" variant="primary" onPress={() => void gotoStep($, step + 1)} />
654 ) : (
655 <Button key="save" label={count ? `Save ${count} change${count === 1 ? '' : 's'}` : 'Save'} variant="primary" onPress={() => void saveSetup($)} />
656 )}
657 {step < REVIEW_STEP ? <Button key="review" label="Skip to review" onPress={() => void gotoStep($, REVIEW_STEP)} /> : null}
658 <Button key="cancel" label="Cancel" role="dismiss" onPress={() => void $.ui.close({ id: SETUP })} />
659 </Box>
660 )
661
662 if (step === 0) {
663 return (
664 <Box flexDirection="column">
665 {heading('Start from a preset')}
666 {help([
667 'cache-countdown shows how much of each request Claude served from its prompt cache, and counts down to when that cache expires.',
668 'Pick a starting point. The next steps explain each setting so you can adjust it, or skip straight to the review.',
669 ])}
670 <Box key="choices" flexDirection="column" marginTop={gap}>
671 {PRESETS.map(p => option(`preset:${p.key}`, p.label, p.about, preset?.key === p.key, () => void presetSetup($, p.draft)))}
672 {preset ? null : <Text key="own" dimColor>{'● your own picks (keep them, or choose a preset)'}</Text>}
673 </Box>
674 {nav}
675 </Box>
676 )
677 }
678
679 if (step === REVIEW_STEP) {
680 return (
681 <Box flexDirection="column">
682 {heading('Review and save')}
683 {help(['Click a row to change it. Save writes your Claude Code settings, like /config, and the mod reloads with them.'])}
684 <Box key="rows" flexDirection="column" marginTop={gap}>
685 {FIELDS.map((f, i) => {
686 const changed = encode(draft[f.key] ?? '') !== encode(draftFromOptions(current)[f.key] ?? '')
687 return (
688 <Box key={`row:${f.key}`} flexDirection="row" columnGap={1}>
689 <Button key={`edit:${f.key}`} plain label={`${f.label}: ${display(f, draft[f.key])}`} onPress={() => void gotoStep($, i + 1)} />
690 {changed ? <Text color="yellow">changed</Text> : null}
691 </Box>
692 )
693 })}
694 </Box>
695 {nav}
696 </Box>
697 )
698 }
699
700 const f = FIELDS[step - 1] as Field
701 const value = draft[f.key]
702 const customOn = draft[`${f.key}:custom`] === true || (f.custom !== undefined && value !== undefined && !isListed(f, value))
703 const pick = (v: string | number | boolean) => {
704 void update($, draftAtom, d => ({ ...((d as SetupDraft | null) ?? draftFromOptions(current)), [f.key]: v, [`${f.key}:custom`]: false }))
705 }
706 return (
707 <Box flexDirection="column">
708 {heading(f.title)}
709 {help(f.key === 'ttl' ? [...f.help, `Detected now: ${s.ttl} (${s.source}).`] : f.help)}
710 <Box key="choices" flexDirection="column" marginTop={gap}>
711 {f.choices.map(c => option(`${f.key}:${encode(c.value)}`, c.label, c.hint, !customOn && encode(c.value) === encode(value ?? ''), () => pick(c.value)))}
712 {f.custom ? option(`${f.key}:${CUSTOM}`, 'custom…', 'type your own below', customOn, () => void startCustom($, f.key)) : null}
713 {f.custom ? (
714 <Box key="custom-field" flexDirection="row" columnGap={1}>
715 <Text dimColor>custom:</Text>
716 <Input
717 key={`in:${f.key}`}
718 placeholder={f.custom.placeholder}
719 value={typeof draft[`${f.key}:text`] === 'string' ? (draft[`${f.key}:text`] as string) : value !== undefined && !isListed(f, value) ? encode(value) : ''}
720 submitLabel="use"
721 onInput={raw => {
722 typing.set(f.key, raw)
723 }}
724 onSubmit={raw => void typeCustom($, f.key, raw)}
725 />
726 </Box>
727 ) : null}
728 </Box>
729 {nav}
730 </Box>
731 )
732 })
733}
734hooks/cache.ts 449 lines1/**
2 * cache.ts: cache-countdown's pure half. Parsing the options, choosing the
3 * cache lifetime, reading request timing, the advice, the countdown text and
4 * when the timer next has to wake, the per-request view, and the number
5 * formats. No `$`, no engine, no timers: everything here is a function of its
6 * arguments, so the tests call it directly.
7 */
8import type { CacheLast, CacheObserved, CacheSample, CacheTurnRow, CacheView } from '../types'
9
10// ---------------------------------------------------------------- options
11
12export const DEFAULT_MARKS = [60, 10, 5, 1]
13export const DEFAULT_LEVELS = [50, 25, 10]
14
15export type Config = {
16 /** 'auto', '5m' or '1h' */
17 ttl: string
18 warnSeconds: number
19 tickSeconds: number
20 finalTickSeconds: number
21 compactWhenRemainingPct: number
22 band: boolean
23 status: boolean
24 toast: boolean
25 /** seconds left at which a toast fires, descending */
26 marks: number[]
27 /** % of the context window remaining at which an alert fires, descending */
28 levels: number[]
29}
30
31const positive = (v: unknown, fallback: number): number => {
32 const n = typeof v === 'number' ? v : typeof v === 'string' && v.trim() !== '' ? Number(v) : NaN
33 return Number.isFinite(n) && n > 0 ? n : fallback
34}
35
36const flag = (v: unknown, fallback: boolean): boolean => {
37 if (typeof v === 'boolean') return v
38 if (typeof v === 'string') {
39 const s = v.trim().toLowerCase()
40 if (s === 'true') return true
41 if (s === 'false') return false
42 }
43 return fallback
44}
45
46const listText = (v: unknown): string | undefined => {
47 if (typeof v === 'string') return v
48 if (typeof v === 'number') return String(v)
49 if (Array.isArray(v)) return v.map(String).join(',')
50 return undefined
51}
52
53/** The options as `register` receives them, checked and filled in. */
54export function readConfig(o: Readonly<Record<string, unknown>>): Config {
55 const ttl = typeof o.ttl === 'string' ? o.ttl.trim().toLowerCase() : ''
56 return {
57 ttl: ttl === '5m' || ttl === '1h' ? ttl : 'auto',
58 warnSeconds: positive(o.warnSeconds, 60),
59 tickSeconds: positive(o.tickSeconds, 60),
60 finalTickSeconds: positive(o.finalTickSeconds, 1),
61 compactWhenRemainingPct: Math.min(100, positive(o.compactWhenRemainingPct, 60)),
62 band: flag(o.band, true),
63 status: flag(o.status, false),
64 toast: flag(o.toast, true),
65 marks: parseMarks(o.toastAt),
66 levels: parsePercents(o.contextAlertsAt),
67 }
68}
69
70const SPAN = /^(\d+(?:\.\d+)?|\.\d+)\s*(h|hr|hrs|hours?|m|min|mins|minutes?|s|sec|secs|seconds?)?$/
71
72/** One span ("1h", "1.5 min", "90s", or a bare number of seconds) in seconds; undefined when it is not one. */
73export function parseSpan(v: unknown): number | undefined {
74 const text = typeof v === 'number' ? String(v) : typeof v === 'string' ? v.trim().toLowerCase() : ''
75 const m = SPAN.exec(text)
76 if (!m) return undefined
77 const n = Number(m[1])
78 const unit = m[2] ?? 's'
79 const seconds = unit.startsWith('h') ? n * 3600 : unit.startsWith('m') ? n * 60 : n
80 if (!Number.isFinite(seconds) || seconds <= 0) return undefined
81 return Math.round(seconds * 1000) / 1000
82}
83
84/** The toast marks: a comma list of spans, deduplicated, longest first; nothing valid gives the default. */
85export function parseMarks(v: unknown): number[] {
86 const text = listText(v) ?? ''
87 const marks = [...new Set(text.split(/[,;]+/).map(parseSpan).filter((s): s is number => s !== undefined))]
88 return marks.length ? marks.sort((a, b) => b - a) : [...DEFAULT_MARKS]
89}
90
91/** A comma list of whole percents 1..99, deduplicated, highest first; off/none/empty is none; nothing valid gives `fallback`. */
92export function parsePercents(v: unknown, fallback: number[] = DEFAULT_LEVELS): number[] {
93 const text = listText(v)
94 if (text === undefined) return [...fallback]
95 const t = text.trim().toLowerCase()
96 if (t === '' || t === 'off' || t === 'none') return []
97 const found = new Set<number>()
98 for (const part of t.split(/[,;\s]+/)) {
99 const m = /^(\d+)%?$/.exec(part)
100 if (!m) continue
101 const n = Number(m[1])
102 if (n >= 1 && n <= 99) found.add(n)
103 }
104 return found.size ? [...found].sort((a, b) => b - a) : [...fallback]
105}
106
107// ---------------------------------------------------------------- lifetime
108
109export type Account = 'subscription' | 'credits' | 'other'
110
111/** What the session's rate-limit windows say about the account. */
112export function accountKind(limits: readonly { kind: string; percentUsed: number }[]): Account {
113 const plan = limits.filter(l => /^(five_hour|seven_day)/.test(l.kind))
114 if (plan.length === 0) return 'other'
115 return plan.some(l => l.percentUsed >= 100) ? 'credits' : 'subscription'
116}
117
118/** What the lifetime is decided from, as read at session start. */
119export type LifetimeInputs = {
120 /** the ttl option: 'auto', '5m' or '1h' */
121 option: string
122 force5m?: string
123 envTtl?: string
124 settingTtl?: unknown
125 enable1h?: string
126 account: Account
127}
128
129export type Lifetime = { ttl: number; source: string; pinned: boolean }
130
131const TRUTHY = new Set(['1', 'true', 'yes', 'on'])
132export const isTruthy = (v: string | undefined) => v !== undefined && TRUTHY.has(v.trim().toLowerCase())
133
134const ttlValue = (v: unknown): number | undefined => {
135 const s = typeof v === 'string' ? v.trim().toLowerCase() : ''
136 return s === '5m' ? 300 : s === '1h' ? 3600 : undefined
137}
138
139/** The cache lifetime before request timing has a say, first match wins. */
140export function baseLifetime(i: LifetimeInputs): Lifetime {
141 const option = ttlValue(i.option)
142 if (option) return { ttl: option, source: 'ttl option', pinned: true }
143 if (isTruthy(i.force5m)) return { ttl: 300, source: 'FORCE_PROMPT_CACHING_5M', pinned: false }
144 const env = ttlValue(i.envTtl)
145 if (env) return { ttl: env, source: 'CLAUDE_CODE_PROMPT_CACHE_TTL', pinned: false }
146 const setting = ttlValue(i.settingTtl)
147 if (setting) return { ttl: setting, source: 'promptCacheTtl setting', pinned: false }
148 if (isTruthy(i.enable1h)) return { ttl: 3600, source: 'ENABLE_PROMPT_CACHING_1H', pinned: false }
149 if (i.account === 'subscription') return { ttl: 3600, source: 'Claude subscription default', pinned: false }
150 if (i.account === 'credits') return { ttl: 300, source: 'usage credits (plan limit reached)', pinned: false }
151 return { ttl: 300, source: 'API key or cloud provider', pinned: false }
152}
153
154export const ttlLabel = (ttl: number) => (ttl >= 3600 && ttl % 3600 === 0 ? `${ttl / 3600}h` : `${Math.round(ttl / 60)}m`)
155
156/** The lifetime in force: the base one, corrected by what traffic showed unless the option pins it. */
157export function effectiveLifetime(base: Lifetime, observed: CacheObserved | null): Lifetime {
158 if (base.pinned || !observed) return base
159 if (observed.ttl === base.ttl) return { ...base, source: `${base.source}, confirmed by traffic` }
160 return { ttl: observed.ttl, source: `observed from request timing (${base.source} said ${ttlLabel(base.ttl)})`, pinned: false }
161}
162
163/** The variable that turns caching off for this model, or null. */
164export function cachingOff(env: { all?: string; haiku?: string; sonnet?: string; opus?: string }, model: string | undefined): string | null {
165 if (isTruthy(env.all)) return 'DISABLE_PROMPT_CACHING'
166 const m = (model ?? '').toLowerCase()
167 if (m.includes('haiku') && isTruthy(env.haiku)) return 'DISABLE_PROMPT_CACHING_HAIKU'
168 if (m.includes('sonnet') && isTruthy(env.sonnet)) return 'DISABLE_PROMPT_CACHING_SONNET'
169 if (m.includes('opus') && isTruthy(env.opus)) return 'DISABLE_PROMPT_CACHING_OPUS'
170 return null
171}
172
173// ---------------------------------------------------------------- reading traffic
174
175/*
176 * Thresholds, in one place:
177 * - a request READ MOST of what was cached when it read at least HALF of the
178 * previous request's prompt (the prefix it shares with this one); less, with
179 * something written, is a miss.
180 * - a prompt SHRANK when it is under HALF of the previous prompt: that is
181 * /compact or /clear rebuilding a smaller conversation, not a miss.
182 * - SLACK covers clock jitter and the time a request takes to reach the cache:
183 * a hit has to come more than 5 minutes + 20 s after the previous request to
184 * prove that a 5-minute entry would not have survived.
185 */
186export const MOST = 0.5
187export const SHRANK = 0.5
188export const SLACK_MS = 20_000
189const FIVE_MIN_MS = 300_000
190const HOUR_MS = 3_600_000
191
192export const promptSize = (s: { read: number; write: number; fresh: number }) => s.read + s.write + s.fresh
193
194const shrank = (prev: CacheSample, cur: CacheSample) => promptSize(cur) < promptSize(prev) * SHRANK
195const readMost = (prev: CacheSample, cur: CacheSample) => cur.read >= promptSize(prev) * MOST
196
197/** What a request says about the lifetime, given the one before it: the updated observation (unchanged when it proves nothing). */
198export function observe(observed: CacheObserved | null, prev: CacheSample | undefined, cur: CacheSample): CacheObserved | null {
199 if (!prev || prev.model !== cur.model) return observed
200 const gap = cur.at - prev.at
201 if (gap <= FIVE_MIN_MS + SLACK_MS) return observed
202 if (cur.read > 0 && readMost(prev, cur)) return { ttl: 3600, proven: true }
203 const missed = cur.write > 0 && !readMost(prev, cur) && !shrank(prev, cur)
204 if (missed && gap < HOUR_MS && !observed?.proven) return { ttl: 300, proven: false }
205 return observed
206}
207
208/** Why a request wrote the cache instead of reading it, or null when it did not miss. */
209export function missCause(prev: CacheSample | undefined, cur: CacheSample, ttl: number): string | null {
210 if (!prev) return null
211 if (cur.read === 0 && cur.write === 0) return null
212 if (shrank(prev, cur)) return null
213 if (cur.write === 0 || readMost(prev, cur)) return null
214 if (prev.model !== cur.model) return `model changed (${prev.model} → ${cur.model})`
215 if (cur.at - prev.at > ttl * 1000) return `the ${ttlLabel(ttl)} cache had lapsed`
216 return 'prompt prefix changed (effort, tools, system prompt or CLAUDE.md)'
217}
218
219// ---------------------------------------------------------------- the view
220
221export const hitPercent = (s: { read: number; write: number; fresh: number }) => {
222 const size = promptSize(s)
223 return size > 0 ? Math.round((s.read / size) * 100) : 0
224}
225
226/** % of the window left after a prompt of `size`, clamped 0..100. */
227export const windowLeftPct = (window: number, size: number) => Math.max(0, Math.min(100, Math.round((100 * (window - size)) / window)))
228
229export type ViewInputs = {
230 samples: readonly CacheSample[]
231 lifetime: Lifetime
232 off: string | null
233 window: number
234}
235
236function row(label: string, list: readonly CacheSample[]): CacheTurnRow {
237 const sum = { read: 0, write: 0, fresh: 0 }
238 for (const s of list) {
239 sum.read += s.read
240 sum.write += s.write
241 sum.fresh += s.fresh
242 }
243 return { label, steps: list.length, ...sum, hit: hitPercent(sum) }
244}
245
246/** Everything the band and pane draw, computed once when a request arrives. */
247export function buildView(i: ViewInputs): CacheView {
248 const n = i.samples.length
249 const cur = i.samples[n - 1]
250 const prev = i.samples[n - 2]
251 let last: CacheLast | null = null
252 if (cur) {
253 const prompt = promptSize(cur)
254 last = {
255 at: cur.at,
256 model: cur.model,
257 read: cur.read,
258 write: cur.write,
259 fresh: cur.fresh,
260 output: cur.output,
261 prompt,
262 hit: hitPercent(cur),
263 uncached: cur.read === 0 && cur.write === 0,
264 cause: missCause(prev, cur, i.lifetime.ttl),
265 }
266 }
267 const turns: CacheSample[][] = []
268 for (const s of i.samples) {
269 const group = turns[turns.length - 1]
270 if (group && group[0]?.turnId === s.turnId) group.push(s)
271 else turns.push([s])
272 }
273 return {
274 ttl: i.lifetime.ttl,
275 ttlLabel: ttlLabel(i.lifetime.ttl),
276 source: i.lifetime.source,
277 off: i.off,
278 window: i.window,
279 windowLeft: i.window > 0 && last ? windowLeftPct(i.window, last.prompt) : null,
280 last,
281 rows: turns.map(t => row(String(t[0]?.turnNo ?? ''), t)),
282 total: row('all', i.samples),
283 }
284}
285
286/** Remaining cache life in ms: counted from the start of the last request, never below 0. */
287export const remainingMs = (start: number, ttl: number, now: number) => Math.max(0, start + ttl * 1000 - now)
288
289// ---------------------------------------------------------------- advice
290
291export type AdviceState = 'off' | 'cold' | 'uncached' | 'expired' | 'soon' | 'miss' | 'warm'
292export type Advice = { state: AdviceState; text: string }
293
294/** The window used for the /compact decision when the real one is not known. */
295export const ASSUMED_WINDOW = 200_000
296
297/** One line of advice for the view at `left` ms of cache life. */
298export function advise(view: CacheView, left: number, s: Pick<Config, 'warnSeconds' | 'compactWhenRemainingPct'>): Advice {
299 if (view.off) return { state: 'off', text: `off: prompt caching is off (${view.off})` }
300 const last = view.last
301 if (!last) return { state: 'cold', text: 'cold: no request yet' }
302 if (last.uncached) return { state: 'uncached', text: 'not cached: the prompt is under the model minimum, or caching is off' }
303 if (left <= 0) {
304 const pct = windowLeftPct(view.window > 0 ? view.window : ASSUMED_WINDOW, last.prompt)
305 const shown = view.window > 0 ? ` (${pct}% of window remaining)` : ''
306 const tail = pct <= s.compactWhenRemainingPct ? '. /compact first, or /clear if done' : ', keep going'
307 return { state: 'expired', text: `expired: the next message rebuilds ${formatCount(last.prompt)}${shown}${tail}` }
308 }
309 if (left <= s.warnSeconds * 1000) return { state: 'soon', text: 'expires soon: any message refreshes it' }
310 if (last.cause) return { state: 'miss', text: `miss: ${last.cause}` }
311 return { state: 'warm', text: 'warm: keep going' }
312}
313
314// ---------------------------------------------------------------- countdown and timer
315
316export type Steps = Pick<Config, 'warnSeconds' | 'tickSeconds' | 'finalTickSeconds'>
317
318/** The countdown step (ms) for `left` ms: tickSeconds outside the final stretch, finalTickSeconds inside it. */
319export const stepMs = (left: number, s: Steps) => (left > s.warnSeconds * 1000 ? s.tickSeconds : s.finalTickSeconds) * 1000
320
321/** The countdown: whole minutes rounded up while the step is a minute or more, else m:ss with the seconds rounded up. */
322export function countdownText(left: number, s: Steps): string {
323 if (stepMs(left, s) >= 60_000) return `${Math.ceil(left / 60_000)}m`
324 const total = Math.ceil(left / 1000)
325 return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, '0')}`
326}
327
328/**
329 * How long the timer sleeps from `left` ms: until the next step boundary (the
330 * countdown's next value), the start of the final stretch, or the next toast
331 * mark not yet shown, whichever comes first; null once nothing is left.
332 * `marks` are seconds, descending; `shown` how many of them are used up.
333 */
334export function nextDelay(left: number, s: Steps, marks: readonly number[] = [], shown = 0): number | null {
335 if (left <= 0) return null
336 const step = stepMs(left, s)
337 const warn = s.warnSeconds * 1000
338 let target = Math.floor((left - 1) / step) * step
339 if (left > warn) target = Math.max(target, warn)
340 const mark = marks.slice(shown).find(m => m * 1000 < left)
341 if (mark !== undefined) target = Math.max(target, mark * 1000)
342 return Math.max(1, left - Math.max(0, target))
343}
344
345/**
346 * The toast marks at `left` ms: every mark at or above it is passed; of the
347 * passed ones not shown yet, only the smallest (the newest) fires.
348 */
349export function takeMarks(marks: readonly number[], shown: number, left: number): { fire: number | undefined; shown: number } {
350 let passed = shown
351 while (passed < marks.length && (marks[passed] ?? 0) * 1000 >= left) passed++
352 const fire = passed > shown && left > 0 ? marks[passed - 1] : undefined
353 return { fire, shown: passed }
354}
355
356/** How many marks are already behind a countdown starting at `left` ms (they never fire). */
357export const marksBehind = (marks: readonly number[], left: number) => marks.filter(m => m * 1000 >= left).length
358
359/** A span as a toast says it: `1 hr`, `30 min`, `1:30`, `10s`. */
360export function spanWords(seconds: number): string {
361 if (seconds >= 3600 && seconds % 3600 === 0) return `${seconds / 3600} hr`
362 if (seconds >= 60 && seconds % 60 === 0) return `${seconds / 60} min`
363 if (seconds > 60) return `${Math.floor(seconds / 60)}:${String(Math.round(seconds % 60)).padStart(2, '0')}`
364 return `${seconds}s`
365}
366
367export function expiryToast(mark: number, prompt: number): string {
368 const action = mark > 10 ? `send a message to keep ${formatCount(prompt)} warm` : 'send a message now'
369 return `cache expires in ${spanWords(mark)}: ${action}`
370}
371
372/** The footer line, or undefined while there is nothing to say. */
373export function statusText(view: CacheView | null, left: number, s: Steps): string | undefined {
374 if (!view) return undefined
375 if (view.off) return 'cache: off'
376 if (!view.last) return undefined
377 if (view.last.uncached) return 'cache: not cached'
378 return `cache ${view.last.hit}% · ${left > 0 ? countdownText(left, s) : 'expired'}`
379}
380
381// ---------------------------------------------------------------- context window alerts
382
383/**
384 * The levels crossed at `pct` % remaining, and the one to announce: the lowest
385 * level newly crossed. A level stays announced only while the window is still
386 * at or below it, so climbing back above it (after /compact) re-arms it.
387 */
388export function crossLevels(levels: readonly number[], announced: readonly number[], pct: number): { announce: number | undefined; announced: number[] } {
389 const crossed = levels.filter(l => pct <= l)
390 const fresh = crossed.filter(l => !announced.includes(l))
391 return { announce: fresh.length ? Math.min(...fresh) : undefined, announced: crossed }
392}
393
394export function contextToast(pct: number, used: number, window: number, compactPct: number): string {
395 const hint = pct <= compactPct ? ' · /compact or /clear frees room' : ''
396 return `context: ${pct}% of window remaining (${formatCount(used)} of ${formatCount(window)} used)${hint}`
397}
398
399// ---------------------------------------------------------------- formats
400
401/** 300, 84.2k, 1.2M. */
402export function formatCount(n: number): string {
403 const v = Math.max(0, Math.round(n))
404 const short = (x: number) => x.toFixed(1).replace(/\.0$/, '')
405 if (v < 1000) return String(v)
406 if (v < 999_950) return `${short(v / 1000)}k`
407 return `${short(v / 1_000_000)}M`
408}
409
410/** 3:20, or 1:00:00 from an hour up; seconds rounded up. */
411export function formatClock(ms: number): string {
412 const total = Math.ceil(Math.max(0, ms) / 1000)
413 const h = Math.floor(total / 3600)
414 const m = Math.floor((total % 3600) / 60)
415 const sec = String(total % 60).padStart(2, '0')
416 return h > 0 ? `${h}:${String(m).padStart(2, '0')}:${sec}` : `${m}:${sec}`
417}
418
419/** Whole cells for each part, adding up to `cells`; a part above zero gets at least one cell when there is room. */
420export function splitCells(parts: readonly number[], cells: number): number[] {
421 const sum = parts.reduce((a, b) => a + Math.max(0, b), 0)
422 if (sum <= 0 || cells <= 0) return parts.map(() => 0)
423 const exact = parts.map(p => (Math.max(0, p) / sum) * cells)
424 const out = exact.map(Math.floor)
425 let left = cells - out.reduce((a, b) => a + b, 0)
426 const byFraction = exact.map((x, i) => ({ i, f: x - Math.floor(x) })).sort((a, b) => b.f - a.f)
427 for (const { i } of byFraction) {
428 if (left <= 0) break
429 out[i] = (out[i] ?? 0) + 1
430 left--
431 }
432 // a part above zero shows: take a cell from the widest part for each one left at zero
433 for (let i = 0; i < parts.length; i++) {
434 if ((parts[i] ?? 0) <= 0 || (out[i] ?? 0) > 0) continue
435 let widest = -1
436 for (let j = 0; j < out.length; j++) if ((out[j] ?? 0) > 1 && (widest < 0 || (out[j] ?? 0) > (out[widest] ?? 0))) widest = j
437 if (widest < 0) break
438 out[widest] = (out[widest] ?? 0) - 1
439 out[i] = 1
440 }
441 return out
442}
443
444/** A bar of `cells` characters, `filled` of them solid. */
445export const textBar = (fraction: number, cells: number) => {
446 const filled = Math.max(0, Math.min(cells, Math.round(fraction * cells)))
447 return '█'.repeat(filled) + '░'.repeat(cells - filled)
448}
449hooks/setup.ts 253 lines1/**
2 * setup.ts: the /cache setup walkthrough's pure half. One step per setting,
3 * each with what it is for, its choices and their hints; presets; the diff
4 * between the picks and what settings hold. No `$`.
5 */
6import type { SetupChange, SetupDraft } from '../types'
7import { parsePercents, parseSpan } from './cache'
8
9export type { SetupChange, SetupDraft }
10
11type Value = string | number | boolean
12export type Choice = { value: Value; label: string; /** one line on why you would pick it */ hint?: string }
13export type Field = {
14 key: string
15 /** the step's heading */
16 title: string
17 /** the review page's row label */
18 label: string
19 /** what the setting is for, in plain words */
20 help: string[]
21 choices: Choice[]
22 /** a free-text choice: what to show in the empty field and how to clean what was typed (undefined: not valid) */
23 custom?: { placeholder: string; normalize: (raw: string) => string | undefined }
24}
25
26/** The value of the "custom…" choice. */
27export const CUSTOM = '__custom'
28
29const normalizeMarks = (raw: string) => {
30 const marks = raw.split(/[,;]+/).map(t => t.trim()).filter(t => parseSpan(t) !== undefined)
31 return marks.length ? marks.join(',') : undefined
32}
33const normalizePercents = (raw: string) => {
34 if (raw.trim().toLowerCase() === 'off') return 'off'
35 const marks = parsePercents(raw, [])
36 return marks.length ? marks.join(',') : undefined
37}
38
39export const FIELDS: Field[] = [
40 {
41 key: 'ttl',
42 title: 'Cache lifetime',
43 label: 'Cache lifetime',
44 help: [
45 'Claude caches the start of your conversation so each message re-sends it cheaply. The cache expires after this long without a message; the next message then pays to rebuild it.',
46 'auto follows Claude Code: 1 hour on a Claude subscription, 5 minutes on an API key or cloud provider, and corrects itself from request timing.',
47 ],
48 choices: [
49 { value: 'auto', label: 'auto', hint: 'default · recommended' },
50 { value: '1h', label: '1 hour', hint: 'pin it if auto guesses wrong' },
51 { value: '5m', label: '5 minutes', hint: 'pin it if auto guesses wrong' },
52 ],
53 },
54 {
55 key: 'tickSeconds',
56 title: 'Countdown step',
57 label: 'Countdown step',
58 help: ['How often the countdown above the input box updates while plenty of time is left.'],
59 choices: [
60 { value: 60, label: 'every minute', hint: 'default · shows "59m", near-zero cost' },
61 { value: 10, label: 'every 10 seconds', hint: 'shows "59:40"' },
62 { value: 1, label: 'every second', hint: 'shows "59:42", redraws every second' },
63 ],
64 },
65 {
66 key: 'warnSeconds',
67 title: 'Final stretch',
68 label: 'Final stretch',
69 help: ['The last stretch before the cache expires: the meter turns red and the countdown switches to the final-stretch step.'],
70 choices: [
71 { value: 30, label: 'last 30 seconds' },
72 { value: 60, label: 'last 60 seconds', hint: 'default' },
73 { value: 120, label: 'last 2 minutes' },
74 { value: 300, label: 'last 5 minutes' },
75 ],
76 },
77 {
78 key: 'finalTickSeconds',
79 title: 'Final stretch step',
80 label: 'Final stretch step',
81 help: ['How often the countdown updates inside the final stretch.'],
82 choices: [
83 { value: 1, label: 'every second', hint: 'default' },
84 { value: 5, label: 'every 5 seconds' },
85 { value: 10, label: 'every 10 seconds' },
86 ],
87 },
88 {
89 key: 'toasts',
90 title: 'Cache expiry toasts',
91 label: 'Cache expiry toasts',
92 help: [
93 "Small pop-ups in Claude Code's top-right corner (not your OS notification tray) warning that the cache is about to expire.",
94 'Send any message before the countdown hits zero and the cache stays warm for free.',
95 ],
96 choices: [
97 { value: '1m,10s,5s,1s', label: '1m, 10s, 5s, 1s', hint: 'default' },
98 { value: '30m,15m,5m,1m', label: '30m, 15m, 5m, 1m', hint: 'early warnings on a 1-hour cache' },
99 { value: '5m,2m,1m', label: '5m, 2m, 1m' },
100 { value: '1m', label: '1m only' },
101 { value: 'off', label: 'off', hint: 'no expiry toasts' },
102 ],
103 custom: { placeholder: 'e.g. 45m, 20m, 2m (h, m, s)', normalize: normalizeMarks },
104 },
105 {
106 key: 'contextAlertsAt',
107 title: 'Context window alerts',
108 label: 'Context alerts',
109 help: [
110 "A pop-up each time your conversation crosses a fill level of the model's context window, e.g. \"25% of window remaining\".",
111 "Handy if your status line doesn't show context usage. Separate from the cache toasts.",
112 ],
113 choices: [
114 { value: '50,25,10', label: '50%, 25%, 10% remaining', hint: 'default' },
115 { value: '75,50,25,10,5', label: '75, 50, 25, 10, 5% remaining', hint: 'more warnings' },
116 { value: '25,10', label: '25%, 10% remaining', hint: 'only when it is getting tight' },
117 { value: 'off', label: 'off' },
118 ],
119 custom: { placeholder: 'e.g. 75,50,25,10,5 (% of window remaining)', normalize: normalizePercents },
120 },
121 {
122 key: 'compactWhenRemainingPct',
123 title: '/compact suggestion',
124 label: '/compact suggestion',
125 help: [
126 'After the cache expires, the meter says "keep going", or suggests /compact (shrink the conversation) once this little of the context window is left.',
127 'Rebuilding a huge context is the expensive moment.',
128 ],
129 choices: [
130 { value: 75, label: 'at 75% remaining', hint: 'suggest early' },
131 { value: 60, label: 'at 60% remaining', hint: 'default' },
132 { value: 40, label: 'at 40% remaining' },
133 { value: 20, label: 'at 20% remaining', hint: 'only when nearly full' },
134 ],
135 },
136 {
137 key: 'band',
138 title: 'Meter above the input box',
139 label: 'Meter above input box',
140 help: ['The one-line meter above the box you type in: cache hit %, tokens, countdown and advice.'],
141 choices: [
142 { value: true, label: 'on', hint: 'default' },
143 { value: false, label: 'off', hint: 'keep /cache and the toasts only' },
144 ],
145 },
146 {
147 key: 'status',
148 title: 'Footer line',
149 label: 'Footer line',
150 help: ['A short copy of the meter ("cache 86% · 59m") on its own line in the footer, beside your status line.'],
151 choices: [
152 { value: false, label: 'off', hint: 'default' },
153 { value: true, label: 'on' },
154 ],
155 },
156]
157
158/** The manifest defaults, as the walkthrough's rows hold them. */
159export const DEFAULTS: SetupDraft = {
160 ttl: 'auto',
161 tickSeconds: 60,
162 warnSeconds: 60,
163 finalTickSeconds: 1,
164 toasts: '1m,10s,5s,1s',
165 contextAlertsAt: '50,25,10',
166 compactWhenRemainingPct: 60,
167 band: true,
168 status: false,
169}
170
171export type PresetName = 'recommended' | 'quiet' | 'live'
172
173export const PRESETS: { key: PresetName; label: string; about: string; draft: SetupDraft }[] = [
174 { key: 'recommended', label: 'Recommended', about: 'minute steps, 4 cache toasts, context alerts at 50/25/10%', draft: DEFAULTS },
175 {
176 key: 'quiet',
177 label: 'Quiet',
178 about: 'the meter only: no toasts, no alerts',
179 draft: { ...DEFAULTS, finalTickSeconds: 10, toasts: 'off', contextAlertsAt: 'off' },
180 },
181 {
182 key: 'live',
183 label: 'Live',
184 about: 'per-second countdown, footer line, more toasts and alerts',
185 draft: { ...DEFAULTS, tickSeconds: 1, toasts: '5m,2m,1m', contextAlertsAt: '75,50,25,10,5', status: true },
186 },
187]
188
189/** Steps: the preset, one per setting, then the review. */
190export const STEP_COUNT = FIELDS.length + 2
191export const REVIEW_STEP = STEP_COUNT - 1
192
193/** The value a choice carries as text. */
194export const encode = (v: Value) => String(v)
195
196/** A choice's text back to the field's type, from that field's choices. */
197export function decode(field: Field, raw: string): Value {
198 const hit = field.choices.find(c => encode(c.value) === raw)
199 if (hit) return hit.value
200 if (typeof field.choices[0]?.value === 'number') return Number(raw)
201 if (typeof field.choices[0]?.value === 'boolean') return raw === 'true'
202 return raw
203}
204
205/** Whether the field's value is one of its listed choices. */
206export const isListed = (field: Field, value: Value | undefined) => value !== undefined && field.choices.some(c => encode(c.value) === encode(value))
207
208/** What the review page shows for a value: the choice's label, or the custom text. */
209export function display(field: Field, value: Value | undefined): string {
210 const hit = field.choices.find(c => value !== undefined && encode(c.value) === encode(value))
211 return hit ? hit.label : value === undefined ? '' : `custom: ${encode(value)}`
212}
213
214/** Merge the toast pair into the walkthrough's one "toasts" row. */
215export function toastsRow(toast: unknown, toastAt: unknown): string {
216 if (toast === false) return 'off'
217 return typeof toastAt === 'string' && toastAt.trim() ? toastAt.replace(/\s+/g, '') : '1m,10s,5s,1s'
218}
219
220/** The walkthrough's "toasts" row back into the two settings it stands for. */
221export function splitToasts(row: Value): { toast: boolean; toastAt?: string } {
222 return row === 'off' ? { toast: false } : { toast: true, toastAt: String(row) }
223}
224
225/** The draft the walkthrough opens with: what settings hold now, as its rows. */
226export function draftFromOptions(values: Readonly<Record<string, unknown>>): SetupDraft {
227 const draft: SetupDraft = { ...DEFAULTS }
228 for (const f of FIELDS) {
229 if (f.key === 'toasts') continue
230 const v = values[f.key]
231 if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') draft[f.key] = v
232 }
233 draft.toasts = toastsRow(values.toast, values.toastAt)
234 return draft
235}
236
237/** The settings to write: each userConfig field whose value the draft changes. */
238export function changes(draft: SetupDraft, values: Readonly<Record<string, unknown>>): SetupChange[] {
239 const out: SetupChange[] = []
240 for (const f of FIELDS) {
241 const v = draft[f.key]
242 if (v === undefined) continue
243 if (f.key === 'toasts') {
244 const { toast, toastAt } = splitToasts(v)
245 if (values.toast !== toast) out.push({ key: 'toast', value: toast })
246 if (toastAt !== undefined && values.toastAt !== toastAt) out.push({ key: 'toastAt', value: toastAt })
247 continue
248 }
249 if (values[f.key] !== v) out.push({ key: f.key, value: v })
250 }
251 return out
252}
253types/index.d.ts 107 lines1/**
2 * cache-countdown's $.state contract: the values it keeps for the session
3 * (they survive a hot reload of the module, and nothing else).
4 */
5
6/** The /cache setup walkthrough's picks, one entry per row (plus `<row>:custom` / `<row>:text` helpers). */
7export type SetupDraft = Record<string, string | number | boolean>
8
9/** One setting the walkthrough will write. */
10export type SetupChange = { key: string; value: string | number | boolean }
11
12/** One main-loop model request, as turn.step reported it. */
13export type CacheSample = {
14 /** when the request started (clock ms): the cache lifetime counts from here */
15 at: number
16 turnId: string
17 /** 1, 2, 3... in the order turns arrived this session */
18 turnNo: number
19 model: string
20 /** prompt read from the cache */
21 read: number
22 /** prompt written to the cache */
23 write: number
24 /** prompt neither read nor written (uncached input) */
25 fresh: number
26 output: number
27}
28
29/** What request timing has shown about the cache lifetime. */
30export type CacheObserved = {
31 /** seconds: 300 or 3600 */
32 ttl: number
33 /** true once a late hit proved 1h; a later miss does not undo it */
34 proven: boolean
35}
36
37/** One row of the per-turn table (or the totals row). */
38export type CacheTurnRow = {
39 label: string
40 steps: number
41 read: number
42 write: number
43 fresh: number
44 /** whole percent of the prompt served from the cache */
45 hit: number
46}
47
48/** The last request, summed up once when it arrived. */
49export type CacheLast = {
50 /** start of the request (clock ms) */
51 at: number
52 model: string
53 read: number
54 write: number
55 fresh: number
56 output: number
57 /** read + write + fresh */
58 prompt: number
59 /** whole percent */
60 hit: number
61 /** read 0 and wrote 0 */
62 uncached: boolean
63 /** why it missed, or null when it did not */
64 cause: string | null
65}
66
67/** Everything the band, the pane and the timer draw from, rebuilt once per request (never per tick). */
68export type CacheView = {
69 /** cache lifetime in seconds */
70 ttl: number
71 /** `5m` or `1h` */
72 ttlLabel: string
73 /** where the lifetime comes from */
74 source: string
75 /** set when prompt caching is off: the variable that turned it off */
76 off: string | null
77 /** the model's context window, 0 when unknown */
78 window: number
79 /** % of the window left after the last request, null when the window is unknown */
80 windowLeft: number | null
81 last: CacheLast | null
82 /** per turn, oldest first */
83 rows: CacheTurnRow[]
84 /** every kept request together, labelled `all` */
85 total: CacheTurnRow
86}
87
88declare module 'claude-code' {
89 interface PluginState {
90 'cache-countdown': {
91 /** the last 200 main-loop requests */
92 samples: CacheSample[]
93 view: CacheView | null
94 /** ms left on the cache at the last wake; the only value a wake writes for the band and pane */
95 tick: number
96 observed: CacheObserved | null
97 /** context-window levels (% remaining) already announced */
98 alerts: number[]
99 paneOpen: boolean
100 draft: SetupDraft | null
101 setupNote: string
102 pending: SetupChange[]
103 setupStep: number
104 }
105 }
106}
107