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

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.
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.
~/.claude/mods/cache-status.claude --plugin-dir ~/.claude/mods/cache-status~/.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.claude plugin validate ~/.claude/mods/cache-status/compact, /clear, the mods' own)./resume and /branch reset the row like /clear.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.· 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.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.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.hooks/register.tsx 507 lines1import 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}
507types/index.d.ts 49 lines1export 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