SLOPSHOPPER

cache-watch

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

newpanebandcommandtoastmodel
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cache-watch
│ ┃ cache-watch-handoff ✕ › fix the failing auth test and add an audit log call │ ┃ No handoff prompt yet. Run /cache-advice to │ ┃ prepare one. ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /cache-advice │ ⎿ cache-watch: Preparing cache advice above the prompt. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · cache-watch-handoff
No handoff prompt yet. Run /cache-advice to prepare one.
README

claude-code-mods

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.

ModWhat it does
cache-watchShows the prompt cache's hit rate and time left, and suggests the cheapest way to carry on before the cache expires.
sticky-todosKeeps 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.

Install

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.

Run from a clone (for development)

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.

cache-watch

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.

What it shows

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):

The cache row with 58 minutes left and a 99% hit rate, with the details panel open

About to expire, with the advice card:

The cache row in amber with 3 minutes left, above the advice card suggesting a new session

Expired:

The cache row in red, 12 minutes after expiry, warning that the next message re-caches 185k tokens

  • The bar and the time count down from the last request that read or wrote the cache. The bar is shaded red to amber to green from left to right, so its shrinking end drifts into red. The time is green while fresh, amber in the last five minutes, red once expired. A symbol and words carry the state too, so it never rests on color alone.
  • Hits is a sparkline of the last 12 requests and the share of the last request served from the cache. Each bar is shaded by its own hit rate on the same red, amber and green scale, so a miss shows red. Slots not yet filled show an empty track. Only the main conversation counts; subagents keep caches of their own.
  • more (hotkey 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.

Advice before the cache expires

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:

OptionWhen the model picks it
Continue hereThe remaining work is short, or it really needs the full history.
Compact nowThe 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 promptThe 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.

Alerts

  • Unexpected miss: the cache missed within five minutes of the last request. Something changed the start of the prompt, such as the connected tools or MCP servers. Misses after a compaction, a model switch or a stale resume are expected and stay quiet.
  • Five-minute caching: a request after a long idle gap missed the cache, so the session now looks like five-minute caching.

Tuning

The constants at the top of cache-watch/hooks/register.tsx:

ConstantDefaultMeaning
WARN_MIN5Minutes before expiry that the advice runs.
SMALL_CONTEXT30_000Below this many tokens, advise continuing without asking the model.
PROBE_GAP_MIN6Idle minutes after which a request tells five-minute from one-hour caching.

Status

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.

sticky-todos

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.

What it shows

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.

A transcript on the left and the Todos pane docked on the right, showing 2 of 4 tasks done

The pane up close:

The Todos pane: a progress ring reading 2 of 4, two finished tasks with green checks, one task in progress in orange, one pending task and a Clear done button

  • The ring fills as tasks finish, with the count inside. The line beside it says how many are done and how many are in progress.
  • The steps are joined by a thin line that turns green between finished tasks. A finished task has a green check and is dimmed and struck through. The task in progress has a spinning orange ring and shows its "doing" wording in bold. A task still to do has an empty circle. The spin stops when the system asks for reduced motion. Clear done removes the finished tasks.
  • The terminal cannot draw these images, so it shows colored symbols instead: ✔ done, ◐ in progress and ○ to do.
  • The pane opens by itself when the list gets its first item and closes when the list empties. If you close it while tasks remain, it stays closed until the list starts again. /todos opens it at any time.
  • The list survives a reload or a resume, and the pane comes back with it.
  • In the desktop app the pane docks beside the transcript. In the terminal it docks in fullscreen mode and otherwise sits above the prompt. A pane that opens by itself needs a terminal at least 144 columns wide; /todos opens it at any width.

Where the list comes from

The mod reads whichever todo tool the session gives the model:

ToolHow the mod reads it
TodoWriteEach call carries the whole list, which replaces the pane's.
TaskCreate and TaskUpdateEach call adds one task or changes one task's status or wording.
mcp__sticky-todos__set_todosThe 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.

Status

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.

Writing your own

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.

License

MIT

Source 2 files
hooks/register.tsx 507 lines
1import { 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}
507
types/index.d.ts 52 lines
1/** 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