Shows prompt-cache hit rate and time to expiry, and offers the cheapest way to carry on before the cache lapses.

Mods for Claude Code: small plugins of function hooks that draw inside the terminal or the desktop app's Code tab and react to what the session does. Each folder here is one self-contained mod that works in any repository.
| Mod | What it does |
|---|---|
| cache-watch | Shows the prompt cache's hit rate and time left, and suggests the cheapest way to carry on before the cache expires. |
| sticky-todos | Keeps Claude's todo list in a pane beside the transcript, open only while the list has items. |
[!note] The function-hooks API is in early access and changes between Claude Code releases. These mods were built and checked against Claude Code 2.1.286. If a mod stops loading after an update, run
claude plugin validate <mod folder>to see what the engine now refuses.
This repository is a plugin marketplace. Add it once, then install the mods you want. Inside Claude Code:
/plugin marketplace add paweechinagarn/claude-code-mods
/plugin install cache-watch@claude-code-mods
/plugin install sticky-todos@claude-code-mods
/reload-plugins
The same commands work from a shell as claude plugin marketplace add ... and claude plugin install .... If a mod does not appear after /reload-plugins, restart Claude Code.
To pick up new versions, refresh the marketplace with /plugin marketplace update claude-code-mods.
A mod is code that runs inside Claude Code with the same access Claude Code has. Read it before you install it.
An installed mod is a cached copy. To edit a mod and see changes live, load it from a clone instead:
git clone https://github.com/paweechinagarn/claude-code-mods.git
For one terminal session, pass the mod's folder:
claude --plugin-dir <clone>/cache-watch
For every session, the desktop app included, list the folder in the env block of ~/.claude/settings.json. Separate several folders with ; on Windows and : elsewhere:
{
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "<clone>/cache-watch;<clone>/sticky-todos"
}
}
Interactive sessions watch these folders, so an edit or a git pull reloads the mod without a restart. Do not load the same mod both ways at once.
Claude Code caches the start of every request. A cached token costs about a tenth of a normal input token, but the cache only lives for a while after it was last used: one hour on most plans, five minutes on others or during usage overage. Once it expires, the next message pays to write the whole conversation into the cache again. On a long session that is the most expensive message you send.
One row above the prompt, readable at a glance. These images are mockups drawn with the mod's own bar code and styled like the desktop app, so spacing in the app differs slightly.
Fresh, with the details panel open (more, or the d key):

About to expire, with the advice card:

Expired:

d) opens the details: the cache lifetime and how the mod knows it, the last request's and the session's token counts, and what an expiry would cost.Claude Code does not report the cache lifetime on each request, so the mod assumes one hour until it sees proof: a request after a pause of six or more minutes that still hits the cache (one hour), or misses it (five minutes). A model switch reports the lifetime directly. The details panel says which of these it is. On narrow windows the row drops the sparkline and the lifetime.
The desktop app, the editor extension and the phone draw both bars as vector graphics: a rounded gradient pill and a row of rounded columns. The terminal draws them with line and block characters instead.
Five minutes before a one-hour cache expires, while you are idle, the mod asks the model one side question over your conversation and shows its answer with three buttons:
| Option | When the model picks it |
|---|---|
| Continue here | The remaining work is short, or it really needs the full history. |
| Compact now | The task is mid-flight and the history holds a lot that is no longer needed. Compacting while the cache is warm is cheaper than after. |
| Copy handoff prompt | The work has reached a natural break. The mod has already written a self-contained prompt; paste it into a new session. Show prompt opens it in a pane. |
All three buttons are always there; the recommended one is highlighted. With under 30,000 tokens of context the mod skips the question and says to continue, because re-caching a small context costs little. Run /cache-advice to get the same advice at any time.
[!important] The advice itself spends a little The side question reads the warm cache, about a twentieth of the cost of re-writing it, and that read also restarts the cache's one-hour clock. The mod asks once per idle stretch, so a session left alone all day spends one read, not one per hour.
The constants at the top of cache-watch/hooks/register.tsx:
| Constant | Default | Meaning |
|---|---|---|
WARN_MIN | 5 | Minutes before expiry that the advice runs. |
SMALL_CONTEXT | 30_000 | Below this many tokens, advise continuing without asking the model. |
PROBE_GAP_MIN | 6 | Idle minutes after which a request tells five-minute from one-hour caching. |
Version 0.2.0. Checked with claude plugin validate and a strict TypeScript build, and the line above the prompt is confirmed drawing in the desktop app. The advice flow, the copy button on the desktop surface and Compact now have not yet run in a real session.
When Claude works through a multi-step task it keeps a checklist, but the checklist sits in the transcript and scrolls away. This mod keeps it in a pane of its own, beside the transcript and clear of the messages and the prompt.
A pane beside the transcript, clear of the messages and the prompt. These images are mockups styled like the desktop app. The ring and the step icons come from the mod's own drawing code, but spacing in the app differs slightly. docs/sticky-todos/mockup.html is their source.

The pane up close:

/todos opens it at any time./todos opens it at any width.The mod reads whichever todo tool the session gives the model:
| Tool | How the mod reads it |
|---|---|
TodoWrite | Each call carries the whole list, which replaces the pane's. |
TaskCreate and TaskUpdate | Each call adds one task or changes one task's status or wording. |
mcp__sticky-todos__set_todos | The mod's own tool, in TodoWrite's shape. |
Some sessions, such as the desktop app's Code tab, give the model neither built-in tool. For those the mod registers its own set_todos tool, so the model can keep a list anyway. The tool's description tells the model to prefer a built-in todo tool when it has one.
Version 0.2.0. Checked with claude plugin validate and a strict TypeScript build. In a real desktop-app session, the mod's own set_todos tool filled the pane, and the ring, the stepper and the spinning icon drew as designed. Reading TodoWrite, TaskCreate and TaskUpdate has not yet run in a real session.
A mod is a folder with three files: .claude-plugin/plugin.json, hooks/hooks.json naming the module, and the hooks module exporting register(on). A mod that keeps state adds a types/index.d.ts contract. To ship a new mod from this repository, add its folder and list it in .claude-plugin/marketplace.json with its name and source. Inside Claude Code, the bundled plugin-authoring skill holds the full API for the version you run. cache-watch is a working example of a line above the prompt, a pane, a slash command, a timer and a model fork.
hooks/register.tsx 507 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, ModelUsage, Register } from 'claude-code'
3
4import type { CacheAdvice, CacheCounts, CachePick, CacheTtl } from '../types'
5
6// Warn this many minutes before the cache lapses.
7const WARN_MIN = 5
8// Below this many context tokens, re-caching is cheap: just carry on, no fork.
9const SMALL_CONTEXT = 30_000
10// A request after an idle gap longer than this tells 5-minute from 1-hour caching.
11const PROBE_GAP_MIN = 6
12// How many recent requests the hit-rate sparkline shows.
13const HISTORY = 12
14// Below this many columns, the row drops the sparkline and the lifetime.
15const NARROW = 72
16const PANE = 'cache-watch-handoff'
17
18const freshAt = atom({ plugin: 'cache-watch', key: 'freshAt' } as const, null)
19const activeAt = atom({ plugin: 'cache-watch', key: 'activeAt' } as const, null)
20const last = atom({ plugin: 'cache-watch', key: 'last' } as const, null)
21const total = atom({ plugin: 'cache-watch', key: 'total' } as const, { read: 0, write: 0, uncached: 0 })
22const history = atom({ plugin: 'cache-watch', key: 'history' } as const, [])
23const ttl = atom({ plugin: 'cache-watch', key: 'ttl' } as const, { minutes: 60, how: 'assumed' })
24const now = atom({ plugin: 'cache-watch', key: 'now' } as const, 0)
25const advice = atom({ plugin: 'cache-watch', key: 'advice' } as const, null)
26const advisedFor = atom({ plugin: 'cache-watch', key: 'advisedFor' } as const, null)
27const isExpanded = atom({ plugin: 'cache-watch', key: 'isExpanded' } as const, false)
28const isCompacting = atom({ plugin: 'cache-watch', key: 'isCompacting' } as const, false)
29
30const sum = (c: CacheCounts) => c.read + c.write + c.uncached
31const pct = (part: number, whole: number) => (whole === 0 ? 0 : Math.round((part / whole) * 100))
32const k = (n: number) =>
33 n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1000 ? `${Math.round(n / 1000)}k` : `${n}`
34const span = (ms: number) => {
35 const m = Math.max(0, Math.round(ms / 60_000))
36 return m >= 60 ? `${Math.floor(m / 60)}h ${m % 60}m` : `${m}m`
37}
38// One palette for the whole row: red when expired, amber when close, green when fresh.
39const RED = '#ef4444'
40const AMBER = '#f59e0b'
41const GREEN = '#22c55e'
42const rgb = (hex: string) => [1, 3, 5].map(i => parseInt(hex.slice(i, i + 2), 16))
43const blend = (a: string, b: string, t: number) =>
44 '#' + rgb(a).map((x, i) => Math.round(x + ((rgb(b)[i] ?? x) - x) * t).toString(16).padStart(2, '0')).join('')
45// Red at the left end, amber in the middle, green at the right: a shrinking bar's head drifts into red.
46const heat = (t: number) => (t < 0.5 ? blend(RED, AMBER, t * 2) : blend(AMBER, GREEN, (t - 0.5) * 2))
47
48/** Terminal: a thin line over a faint track, each filled cell colored by where it sits. */
49const barCells = (fraction: number, cells: number) => {
50 const filled = Math.max(0, Math.min(cells, Math.ceil(fraction * cells)))
51 return Array.from({ length: cells }, (_, i) =>
52 i < filled ? { glyph: '━', color: heat((i + 0.5) / cells) } : { glyph: '─', color: undefined },
53 )
54}
55const SPARK = '▁▂▃▄▅▆▇█'
56/** One bar per recent request, height and color by its hit rate; empty slots are a faint baseline. */
57const sparkCells = (values: number[]) => [
58 ...Array.from({ length: Math.max(0, HISTORY - values.length) }, () => ({ glyph: '▁', color: undefined })),
59 ...values.slice(-HISTORY).map(v => ({ glyph: SPARK[Math.round((v / 100) * 7)] ?? '▁', color: heat(v / 100) })),
60]
61
62// Surfaces that draw Svg (desktop, editor, phone) get vector bars; text fonts there do not tile glyphs.
63const TRACK = 'fill="#808080" fill-opacity="0.22"'
64const BAR_W = 120
65const BAR_H = 6
66const COL_W = 4
67const COL_GAP = 2
68const SPARK_H = 14
69const SPARK_W = HISTORY * (COL_W + COL_GAP) - COL_GAP
70
71/** A rounded pill: the red-to-green gradient spans the whole track, the fill shows the time left. */
72const barSvg = (fraction: number) => {
73 const w = Math.max(0, Math.min(BAR_W, fraction * BAR_W))
74 const fill = w > 0 ? `<rect width="${w.toFixed(1)}" height="${BAR_H}" rx="${BAR_H / 2}" fill="url(#g)"/>` : ''
75 return (
76 `<svg xmlns="http://www.w3.org/2000/svg" width="${BAR_W}" height="${BAR_H}" viewBox="0 0 ${BAR_W} ${BAR_H}">` +
77 `<defs><linearGradient id="g" gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="${BAR_W}" y2="0">` +
78 `<stop offset="0" stop-color="${RED}"/><stop offset="0.5" stop-color="${AMBER}"/><stop offset="1" stop-color="${GREEN}"/>` +
79 `</linearGradient></defs><rect width="${BAR_W}" height="${BAR_H}" rx="${BAR_H / 2}" ${TRACK}/>${fill}</svg>`
80 )
81}
82
83/** One rounded column per recent request over its own faint track: height and color by hit rate. */
84const sparkSvg = (values: number[]) => {
85 const slots = [...Array<number | null>(Math.max(0, HISTORY - values.length)).fill(null), ...values.slice(-HISTORY)]
86 const cols = slots.map((v, i) => {
87 const x = i * (COL_W + COL_GAP)
88 const track = `<rect x="${x}" width="${COL_W}" height="${SPARK_H}" rx="${COL_W / 2}" ${TRACK}/>`
89 if (v === null) return track
90 const h = Math.max(COL_W, (v / 100) * SPARK_H)
91 return track + `<rect x="${x}" y="${(SPARK_H - h).toFixed(1)}" width="${COL_W}" height="${h.toFixed(1)}" rx="${COL_W / 2}" fill="${heat(v / 100)}"/>`
92 })
93 return `<svg xmlns="http://www.w3.org/2000/svg" width="${SPARK_W}" height="${SPARK_H}" viewBox="0 0 ${SPARK_W} ${SPARK_H}">${cols.join('')}</svg>`
94}
95
96const lifetimeText = (t: CacheTtl) => {
97 const life = t.minutes === 60 ? '1 hour' : '5 minutes'
98 if (t.how === 'reported') return `${life}, reported by Claude Code on a model switch.`
99 if (t.how === 'observed') {
100 return t.minutes === 60
101 ? `${life}, confirmed: the cache survived a ${t.gapMin ?? PROBE_GAP_MIN}-minute pause.`
102 : `${life}, seen: the cache missed after a ${t.gapMin ?? PROBE_GAP_MIN}-minute pause.`
103 }
104 return `${life}, assumed. Confirmed after your first pause of ${PROBE_GAP_MIN}+ minutes.`
105}
106
107const LABEL: Record<CachePick, string> = {
108 continue: 'continue here as is',
109 compact: 'compact now, while the cache is warm',
110 handoff: 'start a new session from a handoff prompt',
111}
112
113const advisePrompt = (tokens: number) => `[cache-watch plugin] This is an automated side question from a plugin, not from the person. Do not continue the task and do not call tools.
114
115The prompt cache for this conversation (about ${k(tokens)} tokens) lapses in about ${WARN_MIN} minutes. After that, the next message re-sends the whole conversation at the full cache-write price. Pick the cheapest good way to carry on:
116- "continue": the remaining work is short, or it truly needs this full history.
117- "compact": the task is mid-flight and the history holds a lot that is no longer needed.
118- "handoff": the work has reached a natural break, or what comes next is a different phase.
119
120Reply with ONE JSON object and nothing else, no code fence:
121{"pick": "continue" | "compact" | "handoff",
122 "why": "one short plain sentence",
123 "compactFocus": "what a /compact summary must keep, in one or two sentences",
124 "handoff": "a complete, self-contained prompt for a fresh Claude Code session in the same directory: the goal, what is done, decisions made and why, files touched, the exact next steps, and open questions. Write it as the person would paste it."}`
125
126const parseAdvice = (text: string): CacheAdvice | null => {
127 const start = text.indexOf('{')
128 const end = text.lastIndexOf('}')
129 if (start < 0 || end <= start) return null
130 try {
131 const o = JSON.parse(text.slice(start, end + 1))
132 const pick: CachePick = ['continue', 'compact', 'handoff'].includes(o.pick) ? o.pick : 'compact'
133 return {
134 status: 'ready',
135 pick,
136 why: String(o.why ?? ''),
137 compactFocus: String(o.compactFocus ?? ''),
138 handoff: String(o.handoff ?? ''),
139 }
140 } catch {
141 return null
142 }
143}
144
145// Module variables reset on a hot reload; nothing here needs to survive one.
146let isBusy = false
147// A miss is expected right after a compaction, a model switch or a stale resume.
148let expectMiss = false
149
150async function record($: EngineInterface, u: ModelUsage) {
151 const at = await $.clock.now()
152 const counts: CacheCounts = {
153 read: u.cache_read_input_tokens,
154 write: u.cache_creation_input_tokens,
155 uncached: u.input_tokens,
156 }
157 const prevAt = await read($, freshAt)
158 const prev = await read($, last)
159 const size = sum(counts)
160 const hit = size === 0 ? 0 : counts.read / size
161
162 if (prevAt !== null && prev !== null && sum(prev) > SMALL_CONTEXT && !expectMiss) {
163 const gapMin = Math.round((at - prevAt) / 60_000)
164 if (gapMin > PROBE_GAP_MIN && gapMin < 60) {
165 const seen: CacheTtl = { minutes: hit > 0.5 ? 60 : 5, how: 'observed', gapMin }
166 const was = await read($, ttl)
167 await update($, ttl, () => seen)
168 if (seen.minutes === 5 && was.minutes === 60) {
169 $.ui.toast(
170 `Cache missed after ${gapMin}m idle: this session now looks like 5-minute caching (usage overage?).`,
171 { timeoutMs: 10_000 },
172 )
173 }
174 } else if (gapMin <= WARN_MIN && hit < 0.1) {
175 $.ui.toast(
176 `Unexpected cache miss: ${k(counts.write)} tokens re-cached. Something changed the prompt prefix (tools, MCP servers, settings?).`,
177 { timeoutMs: 10_000 },
178 )
179 }
180 }
181 expectMiss = false
182
183 await update($, freshAt, () => at)
184 await update($, activeAt, () => at)
185 await update($, now, () => at)
186 await update($, last, () => counts)
187 await update($, total, t => ({
188 read: t.read + counts.read,
189 write: t.write + counts.write,
190 uncached: t.uncached + counts.uncached,
191 }))
192 await update($, history, h => [...h, pct(counts.read, size)].slice(-HISTORY))
193 await update($, advice, () => null)
194}
195
196async function advise($: EngineInterface, forActive: number) {
197 await update($, advisedFor, () => forActive)
198 const counts = await read($, last)
199 const tokens = counts === null ? 0 : sum(counts)
200
201 if (tokens < SMALL_CONTEXT) {
202 await update($, advice, () => ({
203 status: 'ready',
204 pick: 'continue',
205 why: `Only ${k(tokens)} tokens of context: re-caching it costs little.`,
206 compactFocus: '',
207 handoff: '',
208 }))
209 return
210 }
211
212 $.ui.toast(`Prompt cache lapses in ${WARN_MIN}m. Preparing the cheapest way to carry on.`)
213 await update($, advice, () => ({ status: 'thinking', pick: 'compact', why: '', compactFocus: '', handoff: '' }))
214
215 const r = await $.model.fork({ prompt: advisePrompt(tokens) })
216 const parsed = r.isAnswered ? parseAdvice(r.text) : null
217
218 // Reading the cache refreshes it: the fork bought another full lifetime.
219 if ('usage' in r && r.usage && r.usage.cache_read_input_tokens > tokens / 2) {
220 const at = await $.clock.now()
221 await update($, freshAt, () => at)
222 }
223
224 if (parsed === null) {
225 await update($, advice, () => ({
226 status: 'failed',
227 pick: 'compact',
228 why: r.isAnswered ? 'The advice reply did not parse.' : `The advice request failed (${r.reason}).`,
229 compactFocus: '',
230 handoff: '',
231 }))
232 $.ui.toast('Cache advice failed. Compacting is the safe default for a large context.')
233 return
234 }
235
236 await update($, advice, () => parsed)
237 $.ui.toast(`Cache advice: ${LABEL[parsed.pick]}.`, { timeoutMs: 10_000 })
238}
239
240async function tick($: EngineInterface) {
241 const at = await $.clock.now()
242 await update($, now, () => at)
243 const fresh = await read($, freshAt)
244 const active = await read($, activeAt)
245 const life = await read($, ttl)
246 if (fresh === null || active === null || isBusy || life.minutes !== 60) return
247
248 const leftMin = life.minutes - (at - fresh) / 60_000
249 if (leftMin <= WARN_MIN && leftMin > 0 && (await read($, advisedFor)) !== active) {
250 await advise($, active)
251 }
252}
253
254async function copyHandoff($: EngineInterface, text: string, surface: Parameters<EngineInterface['ui']['copy']>[0]['surface']) {
255 const done = await $.ui.copy({ text, surface })
256 $.ui.toast(done.isCopied ? 'Handoff prompt copied. Paste it into a new session.' : 'Could not copy. Use Show prompt and copy it by hand.')
257}
258
259async function compactNow($: EngineInterface, focus: string) {
260 await update($, isCompacting, () => true)
261 try {
262 await $.session.compact(focus ? { instructions: focus } : undefined)
263 await update($, advice, () => null)
264 $.ui.toast('Compacted.')
265 } catch {
266 $.ui.toast('Cannot compact while a turn runs. Try again when it ends.')
267 } finally {
268 await update($, isCompacting, () => false)
269 }
270}
271
272export const register: Register = on => {
273 on('session.start', async ($, e, next) => {
274 $.clock.every(30_000, () => void tick($))
275 await $.command.register({
276 name: 'cache-advice',
277 description: 'Prepare cache advice now: continue, compact, or hand off to a new session',
278 })
279
280 return next(e)
281 })
282
283 on('command.run', { command: 'cache-advice' }, async $ => {
284 const active = (await read($, activeAt)) ?? (await $.clock.now())
285 void advise($, active)
286
287 return { text: 'Preparing cache advice above the prompt.' }
288 })
289
290 on('turn.start', ($, e, next) => {
291 isBusy = true
292
293 return next(e)
294 })
295
296 on('turn.complete', ($, e, next) => {
297 if (e.agentId === undefined) isBusy = false
298
299 return next(e)
300 })
301
302 on('turn.step', async function* ($, e, next) {
303 const result = yield* next(e)
304 if (e.agentId === undefined && result.usage !== null) await record($, result.usage)
305
306 return result
307 })
308
309 on('session.compact', async ($, e, next) => {
310 expectMiss = true
311
312 return next(e)
313 })
314
315 on('classic.PostModelSwitch', async ($, e, next) => {
316 expectMiss = true
317 await update($, ttl, () => ({ minutes: e.cache_ttl === '1h' ? 60 : 5, how: 'reported' }))
318
319 return next(e)
320 })
321
322 on('classic.SessionStart', ($, e, next) => {
323 if (e.prompt_cache_likely_expired) expectMiss = true
324
325 return next(e)
326 })
327
328 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
329 const fresh = await read($, freshAt)
330 const counts = await read($, last)
331 if (e.props.hasSurvey || fresh === null || counts === null) return next(e)
332
333 const life = await read($, ttl)
334 const sums = await read($, total)
335 const hits = await read($, history)
336 const at = await read($, now)
337 const tip = await read($, advice)
338 const isOpen = await read($, isExpanded)
339 const isCompactRunning = await read($, isCompacting)
340 const elements = $.ui.resolve(e)
341 const { Box, Text, Button } = elements
342 const Svg = 'Svg' in elements ? elements.Svg : undefined
343
344 const lifeMs = life.minutes * 60_000
345 const left = lifeMs - (at - fresh)
346 const state = left <= 0 ? 'expired' : left <= WARN_MIN * 60_000 ? 'warn' : 'fresh'
347 const tone = state === 'expired' ? RED : state === 'warn' ? AMBER : GREEN
348 // A symbol and words carry the state too, never the color alone.
349 const icon = state === 'expired' ? '✕' : state === 'warn' ? '▲' : '●'
350 const clock =
351 state === 'expired'
352 ? `expired ${span(-left)} ago`
353 : state === 'warn'
354 ? `expires in ${span(left)}`
355 : `${span(left)} left`
356 const isNarrow = (e.props.bodyColumns ?? 80) < NARROW
357 const size = sum(counts)
358
359 const row = (label: string, value: string) => (
360 <Box>
361 <Box width={14} flexShrink={0}>
362 <Text dimColor>{label}</Text>
363 </Box>
364 <Text>{value}</Text>
365 </Box>
366 )
367
368 return (
369 <Box flexDirection="column">
370 <Box gap={1} alignItems="center">
371 <Text color={tone}>{icon}</Text>
372 <Text bold>Cache</Text>
373 {Svg ? (
374 <Svg source={barSvg(left / lifeMs)} alt={clock} width={BAR_W} height={BAR_H} />
375 ) : (
376 <Text>
377 {barCells(left / lifeMs, isNarrow ? 10 : 16).map(cell => (
378 <Text color={cell.color} dimColor={cell.color === undefined}>
379 {cell.glyph}
380 </Text>
381 ))}
382 </Text>
383 )}
384 <Text color={state === 'fresh' ? undefined : tone} bold={state !== 'fresh'}>
385 {clock}
386 </Text>
387 {!isNarrow && <Text dimColor>of {life.minutes === 60 ? '1h' : '5m'}</Text>}
388 {!isNarrow && <Text dimColor>·</Text>}
389 {!isNarrow && <Text dimColor>hits</Text>}
390 {!isNarrow && Svg && (
391 <Svg
392 source={sparkSvg(hits)}
393 alt={`Cache hit rate of the last ${hits.length} requests`}
394 width={SPARK_W}
395 height={SPARK_H}
396 />
397 )}
398 {!isNarrow && !Svg && (
399 <Text>
400 {sparkCells(hits).map(cell => (
401 <Text color={cell.color} dimColor={cell.color === undefined}>
402 {cell.glyph}
403 </Text>
404 ))}
405 </Text>
406 )}
407 <Text color={heat(counts.read / Math.max(1, size))}>{pct(counts.read, size)}%</Text>
408 {state === 'expired' && <Text color={RED}>· next message re-caches {k(size)} tokens</Text>}
409 <Button
410 key="details"
411 plain
412 hotkey="d"
413 label={isOpen ? 'less' : 'more'}
414 onPress={() => update($, isExpanded, v => !v)}
415 />
416 </Box>
417
418 {isOpen && (
419 <Box flexDirection="column" marginLeft={2}>
420 {row('Lifetime', lifetimeText(life))}
421 {row(
422 'Last request',
423 `${pct(counts.read, size)}% from cache: ${k(counts.read)} read, ${k(counts.write)} written, ${k(counts.uncached)} new`,
424 )}
425 {row(
426 'Session',
427 `${pct(sums.read, sum(sums))}% from cache: ${k(sums.read)} tokens served at about a tenth of the price`,
428 )}
429 {row('If it expires', `the next message re-writes ${k(size)} tokens into the cache`)}
430 {row('Advice', `runs ${WARN_MIN}m before expiry, or now with /cache-advice`)}
431 </Box>
432 )}
433
434 {tip !== null && (
435 <Box
436 flexDirection="column"
437 borderStyle="round"
438 borderColor={tip.status === 'failed' ? RED : AMBER}
439 paddingX={1}
440 >
441 {tip.status === 'thinking' ? (
442 <Text>◌ Preparing advice: asking the model while the cache is still warm...</Text>
443 ) : (
444 <Box flexDirection="column">
445 <Text bold>
446 {tip.status === 'failed' ? '✕ Advice failed. ' : '▲ '}Suggested: {LABEL[tip.pick]}
447 </Text>
448 {tip.why !== '' && <Text dimColor>{tip.why}</Text>}
449 <Box gap={1} marginTop={1}>
450 {tip.handoff !== '' && (
451 <Button
452 key="copy"
453 hotkey="1"
454 label="Copy handoff prompt"
455 variant={tip.pick === 'handoff' ? 'primary' : 'secondary'}
456 onPress={() => copyHandoff($, tip.handoff, e.surface)}
457 />
458 )}
459 {tip.handoff !== '' && (
460 <Button
461 key="show"
462 hotkey="2"
463 label="Show prompt"
464 onPress={() => void $.ui.open({ id: PANE, title: 'Handoff prompt' })}
465 />
466 )}
467 {(tip.pick !== 'continue' || tip.handoff !== '') && (
468 <Button
469 key="compact"
470 hotkey="3"
471 label={isCompactRunning ? 'Compacting...' : 'Compact now'}
472 variant={tip.pick === 'compact' ? 'primary' : 'secondary'}
473 onPress={() => (isCompactRunning ? undefined : compactNow($, tip.compactFocus))}
474 />
475 )}
476 <Button
477 key="keep"
478 hotkey="4"
479 label="Continue here"
480 variant={tip.pick === 'continue' ? 'primary' : 'secondary'}
481 onPress={() => update($, advice, () => null)}
482 />
483 </Box>
484 </Box>
485 )}
486 </Box>
487 )}
488 </Box>
489 )
490 })
491
492 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
493 const tip = await read($, advice)
494 const { Box, Markdown, Button } = $.ui.resolve(e)
495 const text = tip?.handoff ?? ''
496
497 return (
498 <Box flexDirection="column" gap={1}>
499 <Markdown text={text || '_No handoff prompt yet. Run /cache-advice to prepare one._'} />
500 {text !== '' && (
501 <Button key="copy" hotkey="c" label="Copy" variant="primary" onPress={() => copyHandoff($, text, e.surface)} />
502 )}
503 </Box>
504 )
505 })
506}
507types/index.d.ts 52 lines1/** Token counts of the main thread's last request, or summed over the session. */
2export type CacheCounts = { read: number; write: number; uncached: number }
3
4/** How long a cache entry lives, and how the mod knows. */
5export type CacheTtl = {
6 minutes: 5 | 60
7 /**
8 * assumed: no evidence yet; observed: a request after a long gap showed it;
9 * reported: the engine said so on a model switch.
10 */
11 how: 'assumed' | 'observed' | 'reported'
12 /** The idle gap, in minutes, of the request that showed it. */
13 gapMin?: number
14}
15
16export type CachePick = 'continue' | 'compact' | 'handoff'
17
18/** The advice prepared shortly before the cache lapses. */
19export type CacheAdvice = {
20 /** thinking: the fork is running; ready: it answered; failed: it did not. */
21 status: 'thinking' | 'ready' | 'failed'
22 pick: CachePick
23 why: string
24 /** What to tell /compact to keep, when the pick is compact. */
25 compactFocus: string
26 /** A self-contained prompt for a new session, when the pick is handoff. */
27 handoff: string
28}
29
30declare module 'claude-code' {
31 interface PluginState {
32 'cache-watch': {
33 /** When the main thread's cache was last read or written, in ms. */
34 freshAt: number | null
35 /** When the person last ran a turn, in ms: the idle stretch starts here. */
36 activeAt: number | null
37 last: CacheCounts | null
38 total: CacheCounts
39 /** Hit percentages of the recent main-thread requests, oldest first. */
40 history: number[]
41 ttl: CacheTtl
42 /** The clock, ticked so the countdown redraws. */
43 now: number
44 advice: CacheAdvice | null
45 /** The activeAt the advice was prepared for, so it runs once per idle stretch. */
46 advisedFor: number | null
47 isExpanded: boolean
48 isCompacting: boolean
49 }
50 }
51}
52