SLOPSHOPPER

cache-status

Band row showing whether the prompt cache is warm or cold, time left, cold restarts this session, and an opt-in guard before sending into a lapsed cache

newbandtoastpromptprocesstimer
v0.7.0no licenseupdated 2026-10-20jquezada19/claude-mods/cache-status
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cache-status
› 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 ⟨Claude Code's own drawing⟩ ○ cache · waiting for first response ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ ○ cache · waiting for first response
README

cache-status

A Claude Code mod that draws a band above the prompt showing whether the session's prompt cache is warm or cold.

  • ● CACHE WARM (green) with time left and the last request's cache-hit share
  • ● CACHE WARM (yellow) under 5 minutes left
  • ○ CACHE COLD · lapsed 12m ago · next message re-caches (red) once the lifetime has lapsed
  • ○ CACHE COLD · expired while away · next message re-caches after a resume the engine reported as expired
  • ○ CACHE COLD · last response used no cache when the last request read nothing from the cache
  • ○ cache · waiting for first response (dim) before the first reply, and again after /clear, /resume or /branch
  • · N cold restarts · N misses · Xk re-cached once a big context has been written again after a lapse, or missed inside a known lifetime; each part shows only when it is not zero, and re-cached sums both (tokens, never dollars; also logged to ~/.claude/metrics/cache-restarts.jsonl; rows are appended with sh -c because $.fs.write only replaces a file, and several sessions append to the log at once, so a read-then-write would lose rows)

The lifetime is counted from the start of each main-conversation request, and from the transcript's real idle time after a resume or fork; /clear starts the row over. The TTL (1h or 5m) comes from, in order: the request's own 1h/5m cache-write split, when its usage carries one; the engine's cache_ttl at a main-conversation model switch, for that model only; the configured controls in Anthropic's order (FORCE_PROMPT_CACHING_5M, CLAUDE_CODE_PROMPT_CACHE_TTL, the promptCacheTtl setting, ENABLE_PROMPT_CACHING_1H); an earlier lifetime this session observed; else 1h, drawn as (assumed). The hit share is measured. Model, effort, MCP and plugin changes and compaction invalidate the cache without the band knowing until the next response.

Optional cold-send guard (coldSendGuard, default off; bigTokens, default 150000, both in /config under cache-status, or in settings.json under pluginConfigs → cache-status → options, as in { "pluginConfigs": { "cache-status": { "options": { "coldSendGuard": true, "bigTokens": 150000 } } } }): before a typed prompt (slash commands pass through) enters a lapsed cache with a big context, a Cache cold dialog asks Cache lapsed 12m ago. Sending now re-caches about 180k tokens. What should happen? and offers Send anyway / Compact now, then send (the prompt goes back in the box and compaction runs; press Enter once the row shows the smaller context) / Cancel. A dismissed dialog sends as typed. The row hides while a survey shows.

It stacks with other mods that draw in the same band (such as usage-hp) in CLAUDE_CODE_PLUGIN_DIRS order: the first mod listed draws at the bottom, the last at the top.

Install

Requires a Claude Code version with mods: the docs say v2.1.287 or later; seen working in the desktop app on 2.1.284 and 2.1.286.

  1. Clone the mods repository (see the mods README one folder up), so this mod sits at, for example, ~/.claude/mods/cache-status.
  2. Load it:
  3. one session: claude --plugin-dir ~/.claude/mods/cache-status
  4. every session, desktop app included: in ~/.claude/settings.json add "env": { "CLAUDE_CODE_PLUGIN_DIRS": "~/.claude/mods/cache-status" } (several paths are separated by :, or ; on Windows), then start a new session.
  5. Check: claude plugin validate ~/.claude/mods/cache-status

0.5.0 (2026-10-16)

  • The cold-send guard lets slash commands through (/compact, /clear, the mods' own).
  • A prompt the box cannot take back and too long for a drop reason (over 3,500 characters; the engine cuts a reason at 4,096) is sent as typed, with a toast, rather than risk losing text.
  • /resume and /branch reset the row like /clear.

0.6.0 (2026-10-16)

  • The lifetime is grounded, not guessed. The hit-rate inference that flipped the countdown to 5m is gone: on 2026-10-16 every observed main-conversation cache write over a week was 1h, and the 13 restarts the mod had logged at an inferred 5m were prefix misses inside a 1h lifetime. The row reads the engine's cache_ttl after a main-conversation model switch (kept in session state with the model switched to, applied only to that model's responses, forgotten on /clear and /resume, a subagent's switch ignored), then your configuration in the documented order, else shows 1h (assumed). A cache write's own 1h/5m split is used when the step usage carries it; on 2.1.300 it does not (only the four totals reach a mod), so that source waits on the engine.
  • Misses are not cold restarts. A big re-write is judged by the lifetime the earlier cache had: at or past it, or after a resume the engine called expired, it is a cold restart; inside a known (not assumed) lifetime it is a miss (· 1 miss), something changed the prefix; inside an assumed one it is not counted. Each logged row now carries kind (lapse/miss), ttl_basis and split_seen.
  • A resume the engine calls expired reads expired while away, and the cold-send guard asks before a big send, without inventing a 5m lifetime. The next response or a compaction clears it.
  • tests/mutate.py (this mod's; each mod has its own) runs a clean baseline first and counts a mutant as detected only when the module still loaded.

0.7.0 (2026-10-16)

  • An offer, never a switch. With the cold-send guard off, the first lapse in a conversation whose context is at least bigTokens shows one toast saying what it re-cached and that the guard can be turned on in /config under cache-status. Once per conversation; /clear and /resume start over. The guard is never turned on for you, and a failure in the offer never stops the lapse being counted.
Source 2 files
hooks/register.tsx 507 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3import type { CacheStatusLast, CacheStatusRestarts } from '../types'
4
5const MINUTE = 60_000
6const SHORT_TTL = 5 * MINUTE
7const LONG_TTL = 60 * MINUTE
8const TICK = 15_000
9// Under this much left the band turns yellow: a prompt now still hits.
10const CLOSE = 5 * MINUTE
11// A request this big whose cache-written share passes this, after a gap past
12// the lifetime, is a cold restart: the whole context written again.
13const RESTART_MIN_TOKENS = 30_000
14const RESTART_WRITTEN_SHARE = 0.5
15// The guard sizes the outgoing prompt as the last one plus the new text.
16const CHARS_PER_TOKEN = 4
17/** The longest prompt a drop reason can carry whole: the engine cuts a reason at 4,096 characters, the wording takes the rest. */
18const DROP_CAP = 3_500
19const SEND = 'Send anyway'
20const COMPACT = 'Compact now, then send'
21const CANCEL = 'Cancel'
22
23/** Where the lifetime on the row comes from, strongest first. */
24type TtlBasis = NonNullable<CacheStatusLast['ttlBasis']>
25
26const ttlOf = (v: unknown): number | null => (v === '5m' ? SHORT_TTL : v === '1h' ? LONG_TTL : null)
27
28/**
29 * The main conversation's lifetime as configured, in Anthropic's documented order:
30 * FORCE_PROMPT_CACHING_5M, CLAUDE_CODE_PROMPT_CACHE_TTL, the promptCacheTtl setting,
31 * ENABLE_PROMPT_CACHING_1H. null when nothing sets it: the default then depends on
32 * billing (1h on a subscription within plan usage, else 5m), which no field exposes.
33 */
34const configuredTtl = async ($: EngineInterface): Promise<{ ttlMs: number; basis: TtlBasis } | null> => {
35  if ((await $.env.get('FORCE_PROMPT_CACHING_5M')) === '1') {
36    return { ttlMs: SHORT_TTL, basis: 'env' }
37  }
38
39  const fromEnv = ttlOf(await $.env.get('CLAUDE_CODE_PROMPT_CACHE_TTL'))
40
41  if (fromEnv !== null) {
42    return { ttlMs: fromEnv, basis: 'env' }
43  }
44
45  let fromSetting: number | null = null
46
47  try {
48    fromSetting = ttlOf((await $.settings.read()).promptCacheTtl)
49  } catch {
50    fromSetting = null
51  }
52
53  if (fromSetting !== null) {
54    return { ttlMs: fromSetting, basis: 'setting' }
55  }
56
57  if ((await $.env.get('ENABLE_PROMPT_CACHING_1H')) === '1') {
58    return { ttlMs: LONG_TTL, basis: 'env' }
59  }
60
61  return null
62}
63
64/** The 1h/5m split of a request's cache writes, when the usage carries it (the API's cache_creation). */
65const splitOf = (usage: unknown): { fiveMin: number; oneHour: number } | null => {
66  const cc = (usage as { cache_creation?: { ephemeral_5m_input_tokens?: unknown; ephemeral_1h_input_tokens?: unknown } } | null)
67    ?.cache_creation
68
69  if (cc === undefined || cc === null) {
70    return null
71  }
72
73  const fiveMin = typeof cc.ephemeral_5m_input_tokens === 'number' ? cc.ephemeral_5m_input_tokens : 0
74  const oneHour = typeof cc.ephemeral_1h_input_tokens === 'number' ? cc.ephemeral_1h_input_tokens : 0
75
76  return { fiveMin, oneHour }
77}
78
79const LAST = { plugin: 'cache-status', key: 'last' } as const
80const RESTARTS = { plugin: 'cache-status', key: 'restarts' } as const
81/** The lifetime the engine reported at the last model switch, and the model it switched to. */
82const ENGINE = { plugin: 'cache-status', key: 'engine' } as const
83/** Whether this conversation was already offered the cold-send guard. */
84const OFFERED = { plugin: 'cache-status', key: 'offered' } as const
85
86const span = (ms: number): string => {
87  const minutes = Math.floor(ms / MINUTE)
88
89  if (minutes >= 60) {
90    const rest = minutes % 60
91
92    return `${Math.floor(minutes / 60)}h${rest === 0 ? '' : ` ${rest}m`}`
93  }
94
95  if (minutes >= 1) {
96    return `${minutes}m`
97  }
98
99  return `${Math.max(0, Math.floor(ms / 1000))}s`
100}
101
102const tokens = (n: number): string =>
103  n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${Math.round(n / 1e3)}k` : String(n)
104
105/** One JSONL row appended on the host; a failure to write never reaches the band. */
106const logRestart = async ($: EngineInterface, row: Record<string, unknown>): Promise<void> => {
107  const home = (await $.env.get('HOME')) ?? ''
108  const path = `${home}/.claude/metrics/cache-restarts.jsonl`
109
110  try {
111    await $.process.run(
112      ['sh', '-c', 'mkdir -p "$(dirname "$2")" && printf "%s\\n" "$1" >> "$2"', '_', JSON.stringify(row), path],
113      { timeoutMs: 5_000 },
114    )
115  } catch {
116    // the row is a measurement, never a blocker
117  }
118}
119
120/**
121 * A resumed or forked transcript: the engine says how long ago its last
122 * response was. Stored as an absolute time, so an idle session ages into
123 * lapsed on the clock alone.
124 */
125const seedFromIdle = async (
126  $: EngineInterface,
127  idleSeconds: number,
128  isLikelyExpired: boolean,
129  contextTokens: number,
130): Promise<void> => {
131  const now = await $.clock.now()
132  const idleMs = idleSeconds * 1000
133  // The lifetime is whatever is configured, else assumed; the engine's own verdict on
134  // whether the cache outlived the break is taken as given, never turned into a 5m guess.
135  const configured = await configuredTtl($)
136  const seeded: CacheStatusLast = {
137    at: now - idleMs,
138    hitPercent: 0,
139    ttlMs: configured?.ttlMs ?? LONG_TTL,
140    ttlBasis: configured?.basis ?? 'assumed',
141    isCached: true,
142    ...(isLikelyExpired ? { isExpired: true } : {}),
143    promptTokens: contextTokens,
144    model: '',
145  }
146  await $.state.set(LAST, seeded)
147}
148
149/**
150 * After a compaction that replaced the main transcript: the compaction request
151 * itself wrote the new, smaller context into the cache, so the reading moves to
152 * now and to that size. That is also what keeps the next step from reading as a
153 * cold restart, and the cold-send guard from asking again before any step runs.
154 */
155const afterCompaction = async ($: EngineInterface, tokensAfter: number | undefined): Promise<void> => {
156  const { value: seen } = await $.state.get(LAST)
157
158  if (seen !== null && seen !== undefined) {
159    // The compaction wrote the new context: whatever expired while away is cached again.
160    const { isExpired: _wasExpired, ...rest } = seen
161    const after: CacheStatusLast = {
162      ...rest,
163      at: await $.clock.now(),
164      isCached: true,
165      promptTokens: tokensAfter ?? 0,
166    }
167    await $.state.set(LAST, after)
168  }
169}
170
171export const register: Register = (on, options) => {
172  const isGuardOn = options.coldSendGuard === true
173  const bigTokens =
174    typeof options.bigTokens === 'number' && options.bigTokens > 0 ? options.bigTokens : 150_000
175
176  on('session.start', async ($, e, next) => {
177    // The countdown moves with the clock, not with any state write.
178    $.clock.every(TICK, () => $.ui.invalidate('ui.render'))
179
180    return next(e)
181  })
182
183  // Opt-in: before a big prompt enters a lapsed cache, ask. Compaction re-reads
184  // the cold context once and shrinks later turns; it pays off only if the
185  // session goes on.
186  on('prompt.submit', async ($, e, next) => {
187    const kind = e.origin.kind
188
189    // A slash command is not a prompt into the cache: /compact, /clear and the mods' own
190    // commands pass, and a skill command pays its own way like any send.
191    if (!isGuardOn || e.turnId !== undefined || e.command !== undefined || (kind !== 'composer' && kind !== 'bridge')) {
192      return next(e)
193    }
194
195    const { value: seen } = await $.state.get(LAST)
196
197    if (seen === null || seen === undefined || seen.isCached === false) {
198      return next(e)
199    }
200
201    const left = seen.ttlMs - ((await $.clock.now()) - seen.at)
202    const outgoing = seen.promptTokens + Math.ceil(e.text.length / CHARS_PER_TOKEN)
203
204    const isLapsed = left <= 0 || seen.isExpired === true
205
206    if (!isLapsed || outgoing < bigTokens) {
207      return next(e)
208    }
209
210    let answer: string
211
212    try {
213      answer = await $.ui.ask(
214        `${left <= 0 ? `Cache lapsed ${span(-left)} ago` : 'Cache expired while away'}. Sending now re-caches about ${tokens(outgoing)} tokens. What should happen?`,
215        { options: [SEND, COMPACT, CANCEL], header: 'Cache cold' },
216      )
217    } catch {
218      // dismissed, or nobody to ask: send as typed
219      return next(e)
220    }
221
222    if (answer !== COMPACT && answer !== CANCEL) {
223      return next(e)
224    }
225
226    let isFilled = false
227
228    try {
229      isFilled = (await $.prompt.fill({ text: e.text })).isFilled
230    } catch {
231      isFilled = false
232    }
233
234    // The text is never lost: when no box took it back, the drop reason carries it, and a
235    // prompt too long for a reason (the engine cuts one at 4,096 characters) is sent as typed.
236    if (!isFilled && e.text.length > DROP_CAP) {
237      try {
238        await $.ui.toast('cache-status: the box could not take a prompt this long back, so it was sent as typed')
239      } catch {
240        // no toast here: it is sent all the same
241      }
242
243      return next(e)
244    }
245
246    const where = isFilled ? 'The prompt is back in the box' : `The box could not take it back, so here it is:\n\n${e.text}\n\n`
247
248    if (answer === COMPACT) {
249      // The engine refuses a compaction from inside this hook (it would compact
250      // under the turn the hook holds), so it runs a tick later, off the hook.
251      // A plugin's own call does not pass through its own session.compact hook,
252      // so the reading is refreshed here from the result.
253      $.clock.after(0, async () => {
254        try {
255          const done = await $.session.compact()
256
257          if (done.skip === undefined) {
258            await afterCompaction($, done.tokensAfter)
259          }
260        } catch {
261          // refused or failed: the row keeps saying the cache is cold
262        }
263      })
264
265      return { drop: `Not sent: compacting now. ${where}${isFilled ? '; press Enter once the cache row shows the new context.' : 'Send it again once the cache row shows the new context.'}` }
266    }
267
268    return { drop: `Not sent: cache-status cold-send guard (Cancel). ${where}${isFilled ? '.' : ''}` }
269  })
270
271  // The idle time of a resumed or forked transcript rides the classic event,
272  // not session.start (whose input has no such field).
273  on('classic.SessionStart', async ($, e, next) => {
274    const idle = e.seconds_since_last_response
275    const { value: seen } = await $.state.get(LAST)
276
277    if (
278      (e.source === 'resume' || e.source === 'fork') &&
279      (seen === null || seen === undefined) &&
280      idle !== undefined
281    ) {
282      await seedFromIdle($, idle, e.prompt_cache_likely_expired === true, e.context_tokens ?? 0)
283    }
284
285    return next(e)
286  })
287
288  // /clear ends the conversation under this id and no session.start follows:
289  // start the row over.
290  // The engine names the main conversation's lifetime after every model switch (a
291  // command, the picker, the SDK, an automatic fallback, a resume): kept with the model
292  // it switched to, in session state so a reload keeps it. A subagent's switch is its own.
293  on('classic.PostModelSwitch', async ($, e, next) => {
294    const reported = ttlOf(e.cache_ttl)
295
296    if (reported !== null && e.agent_id === undefined) {
297      await $.state.set(ENGINE, { ttlMs: reported, model: e.to_model })
298    }
299
300    return next(e)
301  }).catch(($, e, next) => next(e))
302
303  on('session.end', async ($, e, next) => {
304    // /resume and /branch end this conversation too (reported as resume); no session.start follows.
305    if (e.reason === 'clear' || e.reason === 'resume') {
306      await $.state.set(LAST, null)
307      await $.state.set(RESTARTS, null)
308      await $.state.set(ENGINE, null)
309      await $.state.set(OFFERED, null)
310    }
311
312    return next(e)
313  })
314
315  // Compaction rewrites a shorter context on purpose. A precompute installs
316  // nothing, so it changes nothing here.
317  on('session.compact', async ($, e, next) => {
318    const done = await next(e)
319
320    if (e.agentId !== undefined || e.trigger === 'precompute' || done.skip !== undefined) {
321      return done
322    }
323
324    await afterCompaction($, done.tokensAfter)
325
326    return done
327  })
328
329  on('turn.step', async function* ($, e, next) {
330    // The lifetime runs from the start of the request, not the end of the response.
331    const startedAt = await $.clock.now()
332    const response = yield* next(e)
333    const usage = response.usage
334
335    // A subagent's requests keep a prefix of their own warm, not the session's.
336    if (e.agentId !== undefined || usage === null) {
337      return response
338    }
339
340    const cached =
341      usage.cache_read_input_tokens + usage.cache_creation_input_tokens
342    const prompt = usage.input_tokens + cached
343    const hit = prompt === 0 ? 0 : usage.cache_read_input_tokens / prompt
344
345    const { value: seen } = await $.state.get(LAST)
346    const gap = seen === null || seen === undefined ? 0 : startedAt - seen.at
347    // The lifetime: what this request's own cache writes say, else what the engine said
348    // at a model switch, else what is configured, else what an earlier write showed,
349    // else 1h assumed (observed main-conversation writes were 1h, 2026-10-16).
350    // A hit rate says nothing about the lifetime: a changed prefix misses inside it.
351    const split = splitOf(usage)
352    const configured = await configuredTtl($)
353    const observed = split === null ? null : split.fiveMin > 0 ? SHORT_TTL : split.oneHour > 0 ? LONG_TTL : null
354    const known = seen?.ttlBasis === 'observed' ? { ttlMs: seen.ttlMs, basis: 'observed' as const } : null
355    // The engine's report covers the model it switched to, nothing else.
356    const { value: engine } = await $.state.get(ENGINE)
357    const reported = engine !== null && engine !== undefined && engine.model === usage.model ? engine.ttlMs : null
358    const { ttlMs, basis }: { ttlMs: number; basis: TtlBasis } =
359      observed !== null
360        ? { ttlMs: observed, basis: 'observed' }
361        : reported !== null
362          ? { ttlMs: reported, basis: 'engine' }
363          : (configured ?? known ?? { ttlMs: LONG_TTL, basis: 'assumed' })
364
365    const written = usage.cache_creation_input_tokens
366    const isSameModel =
367      seen === null || seen === undefined || seen.model === '' || seen.model === usage.model
368    const isRewrite =
369      seen !== null &&
370      seen !== undefined &&
371      isSameModel &&
372      prompt > RESTART_MIN_TOKENS &&
373      written / prompt > RESTART_WRITTEN_SHARE
374    // Judged by the lifetime the earlier cache had (the new request's says nothing about
375    // it). Past it, or after a resume the engine called expired, it is a lapse (a cold
376    // restart). Inside a lifetime that was known, not assumed, it is a miss: something
377    // changed the prefix. Inside an assumed one it says nothing either way.
378    const prevTtl = seen?.ttlMs ?? ttlMs
379    const prevBasis: TtlBasis = seen?.ttlBasis ?? 'assumed'
380    const kind = !isRewrite
381      ? null
382      : seen?.isExpired === true || gap >= prevTtl
383        ? 'lapse'
384        : prevBasis === 'assumed'
385          ? null
386          : 'miss'
387
388    if (kind !== null) {
389      const { value: before } = await $.state.get(RESTARTS)
390      const restarts: CacheStatusRestarts =
391        kind === 'lapse'
392          ? {
393              ...(before ?? { misses: 0, missTokens: 0 }),
394              count: (before?.count ?? 0) + 1,
395              tokens: (before?.tokens ?? 0) + written,
396              lastAt: startedAt,
397            }
398          : {
399              count: before?.count ?? 0,
400              tokens: before?.tokens ?? 0,
401              lastAt: startedAt,
402              misses: (before?.misses ?? 0) + 1,
403              missTokens: (before?.missTokens ?? 0) + written,
404            }
405      await $.state.set(RESTARTS, restarts)
406      await logRestart($, {
407        ts: new Date(startedAt).toISOString(),
408        session: await $.session.id(),
409        kind,
410        tokens: written,
411        gap_min: Math.round(gap / MINUTE),
412        ttl_min: prevTtl / MINUTE,
413        ttl_basis: prevBasis,
414        split_seen: split !== null,
415      })
416    }
417
418    // The guard is opt-in. After the first lapse big enough for the guard to have asked
419    // about, say once that it exists; never turn it on unasked. Recorded after the lapse
420    // is counted, and nothing here can stop that accounting.
421    if (kind === 'lapse' && !isGuardOn && prompt >= bigTokens) {
422      try {
423        const { value: wasOffered = false } = await $.state.get(OFFERED)
424
425        if (!wasOffered) {
426          await $.state.set(OFFERED, true)
427          await $.ui.toast(
428            `cache-status: that lapse re-cached ${tokens(written)} tokens. The cold-send guard can ask before the next big send: turn it on in /config under cache-status.`,
429            { timeoutMs: 12_000 },
430          )
431        }
432      } catch {
433        // the offer is a courtesy: a failure here skips it
434      }
435    }
436
437    const fresh: CacheStatusLast = {
438      at: startedAt,
439      hitPercent: Math.round(hit * 100),
440      ttlMs,
441      ttlBasis: basis,
442      isCached: cached > 0,
443      promptTokens: prompt,
444      model: usage.model,
445    }
446    await $.state.set(LAST, fresh)
447
448    return response
449  })
450
451  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
452    // Like every other band mod, this row goes after whatever is drawn beneath:
453    // the rows then stack in CLAUDE_CODE_PLUGIN_DIRS order, last listed on top
454    // (until 2026-10-13 this row pinned itself above the rest).
455    const beneath = await next(e)
456
457    if (e.props.hasSurvey) {
458      return beneath
459    }
460
461    const { Box, Text } = $.ui.resolve(e)
462    const { value: seen } = await $.state.get(LAST)
463
464    if (seen === null || seen === undefined) {
465      return (
466        <Box flexDirection="column">
467          {beneath}
468          <Text dimColor>○ cache · waiting for first response</Text>
469        </Box>
470      )
471    }
472
473    const left = seen.ttlMs - ((await $.clock.now()) - seen.at)
474    const ttl = `${seen.ttlMs === LONG_TTL ? '1h' : '5m'}${seen.ttlBasis === 'assumed' ? ' (assumed)' : ''}`
475    // Nothing read or written: there is no cached prefix to be warm.
476    const isCold = seen.isCached === false || seen.isExpired === true || left <= 0
477    const detail = !isCold
478      ? ` · ${span(left)} left of ${ttl} · last hit ${seen.hitPercent}%`
479      : seen.isCached === false
480        ? ' · last response used no cache'
481        : seen.isExpired === true && left > 0
482          ? ' · expired while away · next message re-caches'
483          : ` · lapsed ${span(-left)} ago · next message re-caches`
484    const { value: restarts } = await $.state.get(RESTARTS)
485    const misses = restarts?.misses ?? 0
486    const reCached = (restarts?.tokens ?? 0) + (restarts?.missTokens ?? 0)
487    const tally =
488      restarts === null || restarts === undefined
489        ? ''
490        : `${restarts.count > 0 ? ` · ${restarts.count} cold restart${restarts.count === 1 ? '' : 's'}` : ''}${
491            misses > 0 ? ` · ${misses} miss${misses === 1 ? '' : 'es'}` : ''
492          } · ${tokens(reCached)} re-cached`
493
494    return (
495      <Box flexDirection="column">
496        {beneath}
497        <Box>
498          <Text color={isCold ? 'red' : left < CLOSE ? 'yellow' : 'green'} bold>
499            {isCold ? '○ CACHE COLD' : '● CACHE WARM'}
500          </Text>
501          <Text dimColor>{`${detail}${tally}`}</Text>
502        </Box>
503      </Box>
504    )
505  })
506}
507
types/index.d.ts 49 lines
1export type CacheStatusLast = {
2  /** When the last main-thread request started, ms since the epoch. */
3  at: number
4  /** Share of that request's prompt the cache served, 0 to 100. */
5  hitPercent: number
6  /** The cache lifetime in force, in ms. */
7  ttlMs: number
8  /** Where ttlMs came from: the request's own cache writes, the engine at a model switch, env or settings, or assumed. */
9  ttlBasis?: 'observed' | 'engine' | 'env' | 'setting' | 'assumed'
10  /** Whether that response read or wrote any cache tokens. */
11  isCached: boolean
12  /** Set on a resume the engine reported as likely expired, until the next response. */
13  isExpired?: boolean
14  /** That request's whole prompt (uncached + cache read + cache written), tokens. */
15  promptTokens: number
16  /** The model that answered, as the API names it; '' when seeded from a resume. */
17  model: string
18}
19
20export type CacheStatusRestarts = {
21  /** Requests this session that re-cached a big context after the cache lapsed. */
22  count: number
23  /** Cache-written tokens across those requests. */
24  tokens: number
25  /** When the last one (lapse or miss) started, ms since the epoch. */
26  lastAt: number
27  /** Big re-writes inside a known lifetime: the prefix changed, the cache had not lapsed. */
28  misses?: number
29  /** Cache-written tokens across those misses. */
30  missTokens?: number
31}
32
33/** The lifetime the engine reported at the last main-conversation model switch, and the model it switched to. */
34export type CacheStatusEngine = {
35  ttlMs: number
36  model: string
37}
38
39declare module 'claude-code' {
40  interface PluginState {
41    'cache-status': {
42      last: CacheStatusLast | null
43      restarts: CacheStatusRestarts | null
44      engine: CacheStatusEngine | null
45      offered: boolean | null
46    }
47  }
48}
49