SLOPSHOPPER

cache-band

Shows how hot or cold the prompt cache is above the prompt, with a Compact button.

newbandtoastmodeltimer
v0.1.0MITupdated 2026-10-04MohabYasser2/claude-code-mods/cache-band
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cache-band
› 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 Cache ░░░░░░░░░░░░ · no cache yet ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
Cache ░░░░░░░░░░░░ · no cache yet ⟨Claude Code's own drawing⟩
README

Claude Code mods

Three mods for Claude Code. I asked Claude to audit my last 30 Claude Code sessions and tell me where my time went. Each mod answers one thing it found.

ModWhat it doesWhat the audit found
Cache BandShows how warm the prompt cache is, keeps it warm while you're away, and compacts long conversations at 400K tokensMy cache went cold 91 times
Git Sync BandBranch, ahead/behind and changed files above the prompt, with Pull, Files and Commit & push buttons1,660 git checks done by hand
Scout RouterHas a Haiku judge decide whether each subagent needs Opus, and runs the rest on Sonnet243 of 309 subagents ran on Opus

They run on Claude Code's function hooks. Those are in early access, so the API can change between releases. I built and tested these on Claude Code 2.1.286 and 2.1.287.

Install

  1. Copy a mod's folder into ~/.claude/skills/, for example ~/.claude/skills/cache-band.
  2. New sessions load it. In a session that was already open, run /reload-plugins.

To check a mod before you load it, or to run its tests:

claude plugin validate ~/.claude/skills/cache-band
claude plugin test ~/.claude/skills/cache-band

When Claude Code loads a mod, it writes the claude-code type declarations into the mod's .claude-plugin/types folder, which tsconfig.json points at. That folder is git-ignored here.

Cache Band

Every message re-reads the whole conversation. The prompt cache keeps that cheap: a cache read costs about a tenth of the normal input price. On my plan a cache entry lives for an hour after it was last read. Once it goes cold, the next message writes the whole conversation into the cache again, and a one-hour cache write costs twice the input price. On a 400K conversation, that adds up.

The band sits above the prompt: a flame that cools into a snowflake as the hour runs out, the minutes left, two toggles and a Compact button.

Auto cache. Ten minutes before the cache would go cold, it pings it. The ping goes through $.model.fork, which sends the conversation's last request again from a background copy, with a one-line keep-alive at the end. The copy is word for word the same conversation, so the API serves it from the same cache, and that read keeps the cache for another hour. The reply is dropped and nothing is added to your chat. A "hi" typed into the chat would keep the cache warm too, but it stays in the conversation for good, and Claude might act on it.

It stops after 20 pings in a row. A ping reads the cache (0.1x) and a cold restart writes it (2x), so 20 pings cost about what the restart they prevent would, around 16 hours in. Your next message starts it again. If a ping finds the cache already gone, Auto cache pauses instead of paying full price every hour.

Auto compact. It compacts once the conversation passes 400K tokens, right after Claude's reply, while the cache is still warm, so reading the conversation for the summary is cheap. Why 400K:

  • Every message re-reads the whole conversation, so at 830K each message costs about twice what it does at 400K.
  • The longer the conversation, the more Claude misses (Chroma's context rot study).
  • Compacting too early summarizes a long task halfway through, and details get lost. Anthropic's API compaction defaults to 150K (docs). Claude Code's own auto-compact waits until about 83% of the window, around 830K on a 1M window.
  • 400K is twice the old 200K window, so a long task fits in one go, at under half the per-message cost of 830K.

Compact. The button runs /compact as if you typed it. The desktop app refuses a mod's direct compaction call, so this is the route that works there.

28 tests cover the gauge, the pings and Auto compact.

Git Sync Band

A band above the prompt with the branch, how far ahead or behind it is, the changed files and when you last pushed. It reads git status every minute and fetches every five, with no credential prompts and without taking git's index lock, so it never gets in the way of Claude's own git.

Before your prompt, it pulls with --ff-only when that can't lose anything, and tells Claude what came in, or why nothing did. Pull, Files and Commit & push run git straight from the band, without a turn in the chat. Commit & push writes the message with one small model call and shows it to you before anything runs. It won't commit files that look like secrets (.env files, keys, credentials).

6 tests cover the band's layouts, the divider and the commit flow.

Scout Router

Before a subagent starts on Opus, Scout Router asks a judge: one small Haiku call that reads the subagent's task and the start of its instructions, and answers opus or sonnet. Opus is for deep judgment where a mistake is costly: planning, design decisions, audits and reviews, grading other work, and subtle debugging that spans many parts. Everything else runs on Sonnet: writing code, tests or data to a clear brief, searching and summarizing, running checks, mechanical edits. When the judge is unsure, it picks Sonnet. It decides even when the main model asked for Opus by name.

Forks keep their model, since they share the main conversation's cache. Explore agents go straight to Sonnet. Agents already headed for Sonnet or Haiku aren't judged. If the judge can't answer (an error, a time-out, a usage limit), a word rule decides: planning, audit and review tasks stay on Opus, and the rest go to Sonnet. Each decision is logged.

The judge costs a few hundred Haiku tokens per subagent and adds about a second before it starts. 5 tests cover the judge, its fallback and what it skips.

License

MIT

Source 2 files
hooks/register.tsx 724 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, ModelForkResult, Register, RenderElement } from 'claude-code'
3
4import type { CacheView, Warmth } from '../types'
5
6const OFF: Warmth = {
7  isOn: false,
8  isCompactOn: false,
9  pings: 0,
10  isPinging: false,
11  isArmed: true,
12  retryAt: 0,
13}
14
15const viewAtom = atom({ plugin: 'cache-band', key: 'view' } as const, null)
16const compactingAtom = atom({ plugin: 'cache-band', key: 'isCompacting' } as const, false)
17const warmthAtom = atom({ plugin: 'cache-band', key: 'warmth' } as const, OFF)
18
19// This plan keeps the prompt cache for an hour after the last request.
20const TTL_MS = 60 * 60_000
21// Auto cache pings this long before the cache would go cold.
22const LEAD_MS = 10 * 60_000
23// A ping re-reads the conversation at a tenth of the input price, and a cold
24// rewrite costs twice that price, so 20 pings in a row cost what the rewrite
25// they prevent would (about 16 hours away). Past that, pings stop.
26const MAX_PINGS = 20
27const RETRY_MS = 5 * 60_000
28// Auto compact compacts once the conversation passes this size. Every message
29// re-reads the whole conversation, so its cost grows with it, and so does the
30// share of what Claude misses (Chroma's context rot study; on MRCR v2's
31// 8-needle test at 1M tokens, Opus 4.6 found 76%). Anthropic's API compaction
32// fires at 150k by default, and Claude Code's own waits until about 83% of the
33// window, some 830k of 1M. 400k sits between: twice the old 200k window for
34// long tasks, at under half the per-message cost of 830k.
35const AUTO_COMPACT_AT = 400_000
36// After a compaction that failed, Auto compact waits this long to try again.
37const COMPACT_RETRY_MS = 30 * 60_000
38const PING =
39  'Automatic keep-alive from the cache-band mod, sent to keep the prompt cache warm. It is not a message from the person. Use no tools. Reply with only: ok'
40const CELLS = 12
41// Eighth-cell steps. Block characters are drawn in the code font on desktop,
42// so the meter comes out even.
43const BLOCKS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
44
45const EMPTY: CacheView = { lastUsedAt: 0, ctxTokens: 0, ctxOverride: null, now: 0 }
46
47type Temperature = { word: string; tone: string; leftMs: number }
48
49const tokens = (n: number) =>
50  n >= 1_000_000 ? `${+(n / 1_000_000).toFixed(1)}M` : `${Math.round(n / 1000)}k`
51
52const span = (ms: number) => {
53  const m = Math.max(0, Math.round(ms / 60_000))
54  if (m < 60) return `${m}m`
55  const h = Math.floor(m / 60)
56
57  return m % 60 ? `${h}h ${m % 60}m` : `${h}h`
58}
59
60// Hot for the first 20 minutes, warm to 40, cooling to cold at 60. The colors
61// are theme names (the app's orange, amber and blue), so they follow the theme.
62const temperatureOf = (idleMs: number): Temperature => {
63  const leftMs = Math.max(0, TTL_MS - idleMs)
64  if (leftMs === 0) return { word: 'cold', tone: 'permission', leftMs }
65  if (leftMs > 40 * 60_000) return { word: 'hot', tone: 'claude', leftMs }
66  if (leftMs > 20 * 60_000) return { word: 'warm', tone: 'warning', leftMs }
67
68  return { word: 'cooling', tone: 'permission', leftMs }
69}
70
71const meterOf = (share: number) => {
72  const eighths = Math.round(Math.min(1, Math.max(0, share)) * CELLS * 8)
73  const full = Math.floor(eighths / 8)
74  const part = BLOCKS[eighths % 8] ?? ''
75
76  return {
77    filled: '█'.repeat(full) + part,
78    empty: '░'.repeat(Math.max(0, CELLS - full - (part ? 1 : 0))),
79  }
80}
81
82// The pictures are drawn as images, which can't read the app's theme, so their
83// colors are literal: mid-tones that hold on the light and the dark background
84// alike, close to the app's orange, amber and blue.
85const PAINT: Record<string, { top: string; bottom: string; core: string }> = {
86  hot: { top: '#F7B267', bottom: '#D97757', core: '#FFE3B8' },
87  warm: { top: '#F5D06E', bottom: '#DFA13A', core: '#FFF1C7' },
88  cooling: { top: '#AEBBFF', bottom: '#6C84EE', core: '#E1E6FF' },
89  cold: { top: '#AEBBFF', bottom: '#6C84EE', core: '#E1E6FF' },
90}
91const TRACK = 'rgba(128,128,128,0.24)'
92
93const FLAME =
94  'M7 .8C7.6 2.6 9.1 3.9 10.2 5.4 11.1 6.6 11.6 7.8 11.6 9.1 11.6 11.6 9.5 13.4 7 13.4 4.5 13.4 2.4 11.6 2.4 9.1 2.4 7.4 3.3 6 4.4 5.1 4.5 6.3 5.1 7.2 5.9 7.6 5.6 5.2 6.1 2.6 7 .8Z'
95const FLAME_CORE =
96  'M7.1 7.4C8.2 8.5 9.1 9.4 9.1 10.7 9.1 11.9 8.2 12.7 7.1 12.7 6 12.7 5.1 11.9 5.1 10.8 5.1 9.7 5.9 8.7 7.1 7.4Z'
97
98// Six arms, each with a pair of short branches pointing out.
99const SNOWFLAKE = (() => {
100  const at = (r: number, deg: number) => {
101    const a = (deg * Math.PI) / 180
102
103    return `${(7 + r * Math.cos(a)).toFixed(2)} ${(7.1 + r * Math.sin(a)).toFixed(2)}`
104  }
105  let d = ''
106  for (let deg = -90; deg < 270; deg += 60) {
107    d += `M7 7.1L${at(5.9, deg)}`
108    d += `M${at(5, deg - 19)}L${at(3.6, deg)}L${at(5, deg + 19)}`
109  }
110
111  return d
112})()
113
114// The icon's share of the gauge, before the bar starts.
115const GAUGE_ICON = 21
116
117// The cache's icon and how much of its hour is left, as one strip; `tw` is
118// the bar's length.
119const gaugeSvg = (word: string, share: number, tw: number) => {
120  const paint = PAINT[word] ?? PAINT.cold!
121  const [x, h, th] = [GAUGE_ICON, 16, 6]
122  const w = x + tw
123  const ty = (h - th) / 2
124  const fill = word === 'cold' ? 0 : Math.max(th, Math.round(Math.min(1, share) * tw))
125  const icon =
126    word === 'cold'
127      ? `<path d="${SNOWFLAKE}" fill="none" stroke="${paint.bottom}" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>`
128      : `<path d="${FLAME}" fill="url(#cb-flame)"/><path d="${FLAME_CORE}" fill="${paint.core}" opacity=".9"/>`
129  const glow =
130    word === 'hot'
131      ? `<rect x="${x}" y="${ty}" width="${fill}" height="${th}" rx="${th / 2}" fill="${paint.bottom}" opacity=".55" filter="url(#cb-glow)"/>`
132      : ''
133  const bar = fill
134    ? `${glow}<rect x="${x}" y="${ty}" width="${fill}" height="${th}" rx="${th / 2}" fill="url(#cb-bar)"/>`
135    : ''
136
137  return (
138    `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">` +
139    `<defs>` +
140    `<linearGradient id="cb-flame" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${paint.top}"/><stop offset="1" stop-color="${paint.bottom}"/></linearGradient>` +
141    `<linearGradient id="cb-bar" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="${paint.bottom}"/><stop offset="1" stop-color="${paint.top}"/></linearGradient>` +
142    `<filter id="cb-glow" x="-10%" y="-120%" width="120%" height="340%"><feGaussianBlur stdDeviation="1.6"/></filter>` +
143    `</defs>` +
144    `<g transform="translate(0 1)">${icon}</g>` +
145    `<rect x="${x}" y="${ty}" width="${tw}" height="${th}" rx="${th / 2}" fill="${TRACK}"/>` +
146    bar +
147    `</svg>`
148  )
149}
150
151// Compact as an icon: two arrows closing in on the middle.
152const COMPACT_PATH = 'M4 14h6v6M3 21l7-7M20 10h-6V4M21 3l-7 7'
153const COMPACT_INK = '#A3A095'
154const compactSvg = (isFaded: boolean) =>
155  `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="${COMPACT_INK}" ` +
156  `stroke-opacity="${isFaded ? 0.4 : 1}" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round">` +
157  `<path d="${COMPACT_PATH}"/></svg>`
158
159// A loop arrow going round: clockwise from the upper right to the top, where
160// its head points on.
161const LOOP = (() => {
162  const at = (deg: number, r: number) => {
163    const a = (deg * Math.PI) / 180
164
165    return [12 + r * Math.cos(a), 12 + r * Math.sin(a)] as const
166  }
167  // As wide as Auto compact's ring, so the two icons match.
168  const r = 9.4
169  const fix = (n: number) => n.toFixed(2)
170  const [sx, sy] = at(-35, r)
171  const [ex, ey] = at(-95, r)
172  const a = (-95 * Math.PI) / 180
173  const [tx, ty] = [-Math.sin(a), Math.cos(a)]
174  const [nx, ny] = [Math.cos(a), Math.sin(a)]
175  const head =
176    `M${fix(ex + 2.5 * nx)} ${fix(ey + 2.5 * ny)}L${fix(ex + 3.2 * tx)} ${fix(ey + 3.2 * ty)}` +
177    `L${fix(ex - 2.5 * nx)} ${fix(ey - 2.5 * ny)}Z`
178
179  return { arc: `M${fix(sx)} ${fix(sy)}A${r} ${r} 0 1 1 ${fix(ex)} ${fix(ey)}`, head }
180})()
181
182// A toggle drawn as an icon: orange in a soft glow when it's on, plain grey
183// when it's off. `draw` paints the icon in the color it's handed.
184const glowingSvg = (draw: (ink: string) => string, isOn: boolean) => {
185  const shape = draw(isOn ? '#F28B54' : 'rgba(140,140,140,0.85)')
186
187  return (
188    `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24">` +
189    (isOn
190      ? `<defs><radialGradient id="cb-halo"><stop offset="0" stop-color="#F5A15E" stop-opacity=".8"/>` +
191        `<stop offset=".55" stop-color="#F5A15E" stop-opacity=".35"/>` +
192        `<stop offset="1" stop-color="#F5A15E" stop-opacity="0"/></radialGradient>` +
193        `<filter id="cb-blur" x="-30%" y="-30%" width="160%" height="160%"><feGaussianBlur stdDeviation="2"/></filter></defs>` +
194        `<circle cx="12" cy="12" r="12" fill="url(#cb-halo)"/><g filter="url(#cb-blur)">${shape}</g>`
195      : '') +
196    shape +
197    `</svg>`
198  )
199}
200
201// Auto cache: the loop arrow.
202const autoCacheSvg = (isOn: boolean) =>
203  glowingSvg(
204    ink =>
205      `<path d="${LOOP.arc}" fill="none" stroke="${ink}" stroke-width="2.4" stroke-linecap="round"/>` +
206      `<path d="${LOOP.head}" fill="${ink}" stroke="${ink}" stroke-width="1" stroke-linejoin="round"/>`,
207    isOn,
208  )
209
210// Auto compact: Compact's arrows inside a ring.
211const autoCompactSvg = (isOn: boolean) =>
212  glowingSvg(
213    ink =>
214      `<circle cx="12" cy="12" r="9.8" fill="none" stroke="${ink}" stroke-width="2"/>` +
215      `<path d="${COMPACT_PATH}" fill="none" stroke="${ink}" stroke-width="3.6" stroke-linecap="round" ` +
216      `stroke-linejoin="round" transform="translate(12 12) scale(.52) translate(-12 -12)"/>`,
217    isOn,
218  )
219
220// A picture takes no click, so each one a person presses has a blank button
221// laid over it, centered on it: the button takes the click, and its hover
222// frames the picture. Its label is two blank Braille cells (0.65em each in the
223// symbol font Windows falls back to), enough to span an icon.
224const ICON_BLANK = '⠀⠀'
225
226// The layout is the fullest that fits the band's width in one row: every word;
227// then without the word "Cache" and the toggles' names; then icons alone (the
228// gauge, Auto cache, Auto compact and Compact), the words moving to hover
229// cards. It is measured with the longest status this conversation can show,
230// so it holds still as the status changes. Below TINY cells the bar shortens.
231const TINY = 30
232
233// Widths in the band's cells: its sans text runs about three quarters of a
234// cell a character, and a cell is about 7.8 pixels.
235const textCells = (text: string) => text.length * 0.75
236const pxCells = (px: number) => px / 7.8
237// A plain button's padding, both sides together, and an icon's blank button.
238const PAD = pxCells(12)
239const ICON_BUTTON = pxCells(30)
240
241const slugOf = (path: string) => path.replace(/[^a-zA-Z0-9]/g, '-')
242
243// Whether Auto cache will ping before the cache goes cold.
244const willPing = (w: Warmth) => w.isOn && w.isArmed && w.pings < MAX_PINGS
245
246// When the conversation last changed: its transcript's last write, which is
247// the end of the last reply. 0 when the transcript can't be found.
248async function lastWriteOf($: EngineInterface) {
249  const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME'))
250  if (!home) return 0
251  const projects = `${home.replace(/\\/g, '/')}/.claude/projects`
252  const file = `${await $.session.id()}.jsonl`
253  const guesses = [slugOf(await $.session.root()), slugOf(await $.session.cwd())]
254  for (const dir of guesses) {
255    const found = await $.fs.stat(`${projects}/${dir}/${file}`).catch(() => undefined)
256    if (found) return found.mtimeMs
257  }
258  // The session moved since it started: look for its transcript by name.
259  const dirs = await $.fs.list(projects).catch(() => [])
260  for (const dir of dirs) {
261    if (dir.kind !== 'dir' || guesses.includes(dir.name)) continue
262    const found = await $.fs.stat(`${projects}/${dir.name}/${file}`).catch(() => undefined)
263    if (found) return found.mtimeMs
264  }
265
266  return 0
267}
268
269async function refresh($: EngineInterface) {
270  const [u, now] = await Promise.all([$.session.usage(), $.clock.now()])
271  await update($, viewAtom, v => {
272    const view = v ?? EMPTY
273
274    return { ...view, ctxTokens: view.ctxOverride ?? u.context.tokens ?? 0, now }
275  })
276}
277
278// A request of the main conversation read its cache, which now keeps for an
279// hour from when the request was sent. Pings count from zero again.
280async function noteRequest($: EngineInterface, sentAt: number) {
281  await update($, viewAtom, v => ({ ...(v ?? EMPTY), lastUsedAt: Math.max(v?.lastUsedAt ?? 0, sentAt) }))
282  await update($, warmthAtom, w => ({ ...w, pings: 0, isArmed: true, retryAt: 0 }))
283}
284
285// Set when a compaction lands during a turn, so that turn's end keeps the
286// estimate instead of the stale size the summary request reported.
287let compactedThisTurn = false
288
289// Whether a turn of the main conversation is running; Auto compact waits.
290let isTurnRunning = false
291
292// Until the next reply reports the real size, estimate the conversation from
293// what a compaction kept, at about four characters a token.
294async function noteCompacted($: EngineInterface, kept: readonly { text: string }[]) {
295  const chars = kept.reduce((sum, m) => sum + m.text.length, 0)
296  const now = await $.clock.now()
297  await update($, viewAtom, v => ({
298    ...(v ?? EMPTY),
299    lastUsedAt: now,
300    ctxOverride: Math.round(chars / 4),
301  }))
302  // The last request was the long conversation; a ping would repeat it. Wait
303  // for the compacted one to be sent.
304  await update($, warmthAtom, w => ({ ...w, isArmed: false }))
305  await update($, compactingAtom, () => false)
306  await refresh($)
307}
308
309// Runs /compact as if typed. The desktop app runs Claude Code as an SDK
310// session, where $.session.compact is refused and a compaction only runs as a
311// /compact of its own; the command is queued until Claude is idle.
312async function compactNow($: EngineInterface) {
313  if (await read($, compactingAtom)) return
314  await update($, compactingAtom, () => true)
315  try {
316    await $.command.run({ command: 'compact' })
317    // The session.compact hook below normally saw it land. If it didn't, size
318    // the conversation from what's left of it.
319    if (await read($, compactingAtom)) await noteCompacted($, await $.session.messages())
320  } catch (error) {
321    const reason = error instanceof Error ? error.message : String(error)
322    $.ui.toast(`Couldn't compact: ${reason}`.slice(0, 160))
323    await update($, compactingAtom, () => false)
324  }
325}
326
327// What a ping's reply says about the cache. Its usage tells whether the API
328// served the conversation from the cache; a ping that paid for it in full
329// found the cache already gone, and pinging again would pay in full each time.
330async function settlePing($: EngineInterface, result: ModelForkResult, sentAt: number) {
331  if (!result.isAnswered) {
332    if (result.reason === 'nothing-to-fork') {
333      await update($, warmthAtom, w => ({ ...w, isArmed: false }))
334      return
335    }
336    if (result.reason === 'aborted') return
337    if (result.reason === 'api-error') {
338      await update($, warmthAtom, w => ({ ...w, retryAt: sentAt + RETRY_MS }))
339      return
340    }
341  }
342  // An answer, or a reply with no text: the request was made either way.
343  const usage = result.usage
344  const cached = usage.cache_read_input_tokens + usage.cache_creation_input_tokens
345  if (cached === 0 || usage.input_tokens > cached) {
346    await update($, warmthAtom, w => ({ ...w, isArmed: false }))
347    $.ui.toast('Auto cache paused: the cache had already gone cold. It starts again after your next message.')
348    return
349  }
350  await update($, viewAtom, v => ({ ...(v ?? EMPTY), lastUsedAt: sentAt }))
351  await update($, warmthAtom, w => ({ ...w, pings: w.pings + 1 }))
352}
353
354// Taken before the first await, so a tick and a press can't both act.
355let isActing = false
356
357// Auto cache, shortly before the cache would go cold: $.model.fork sends the
358// main conversation's last request again with a one-line question, the API
359// serves it from the cache and keeps the cache another hour, the reply is
360// dropped, and nothing joins the conversation.
361async function beforeCold($: EngineInterface) {
362  if (isActing) return
363  isActing = true
364  try {
365    const [warmth, view, now] = await Promise.all([read($, warmthAtom), read($, viewAtom), $.clock.now()])
366    if (!willPing(warmth) || !view || view.lastUsedAt === 0 || now < warmth.retryAt) return
367    const leftMs = TTL_MS - (now - view.lastUsedAt)
368    if (leftMs <= 0 || leftMs > LEAD_MS) return
369
370    await update($, warmthAtom, w => ({ ...w, isPinging: true }))
371    try {
372      await settlePing($, await $.model.fork({ prompt: PING }), now)
373    } catch {
374      await update($, warmthAtom, w => ({ ...w, retryAt: now + RETRY_MS }))
375    } finally {
376      await update($, warmthAtom, w => ({ ...w, isPinging: false }))
377    }
378  } finally {
379    isActing = false
380  }
381}
382
383// When Auto compact last tried; a compaction that failed is not retried soon.
384let lastAutoCompactAt = -Infinity
385
386// Auto compact: once the conversation has passed AUTO_COMPACT_AT, compact it
387// between turns, right after Claude's reply, while the cache is warm and
388// reading the conversation for its summary is cheap.
389async function maybeAutoCompact($: EngineInterface) {
390  const [warmth, view, isCompacting, now] = await Promise.all([
391    read($, warmthAtom),
392    read($, viewAtom),
393    read($, compactingAtom),
394    $.clock.now(),
395  ])
396  if (!warmth.isCompactOn || !view || view.ctxTokens < AUTO_COMPACT_AT) return
397  if (isTurnRunning || isCompacting || now - lastAutoCompactAt < COMPACT_RETRY_MS) return
398  lastAutoCompactAt = now
399  await compactNow($)
400}
401
402async function tick($: EngineInterface) {
403  await refresh($)
404  await beforeCold($)
405  await maybeAutoCompact($)
406}
407
408// Turning Auto cache on inside the last 10 minutes pings right away, and
409// Auto compact on past its size compacts right away.
410async function toggleAutoCache($: EngineInterface) {
411  await update($, warmthAtom, w => ({ ...w, isOn: !w.isOn }))
412  await beforeCold($)
413}
414
415async function toggleAutoCompact($: EngineInterface) {
416  await update($, warmthAtom, w => ({ ...w, isCompactOn: !w.isCompactOn }))
417  await maybeAutoCompact($)
418}
419
420export const register: Register = on => {
421  on('session.start', async ($, e, next) => {
422    const view = await read($, viewAtom)
423    if (!view || !view.lastUsedAt) {
424      const at = await lastWriteOf($)
425      await update($, viewAtom, v => ({ ...EMPTY, ctxOverride: v?.ctxOverride ?? null, lastUsedAt: at }))
426    }
427    // A ping the last load had out ended with it.
428    await update($, warmthAtom, w => ({ ...w, isPinging: false }))
429    await refresh($)
430    $.clock.every(30_000, () => void tick($))
431
432    return next(e)
433  })
434
435  on('turn.start', async ($, e, next) => {
436    isTurnRunning = true
437
438    return next(e)
439  })
440
441  // Each request the main conversation sends reads its cache: the meter's
442  // clock starts over from when it was sent, once the API has answered it.
443  on('turn.step', async function* ($, e, next) {
444    if (e.agentId) return yield* next(e)
445    const sentAt = await $.clock.now()
446    const result = yield* next(e)
447    if (result.usage) await noteRequest($, sentAt)
448
449    return result
450  })
451
452  // Every compaction that lands, wherever it came from: this band's button,
453  // Auto compact, a typed /compact, or Claude Code's own.
454  on('session.compact', async ($, e, next) => {
455    const result = await next(e)
456    if (!e.agentId && e.trigger !== 'precompute' && result.skip === undefined) {
457      compactedThisTurn = true
458      await noteCompacted($, result.messages)
459    }
460
461    return result
462  })
463
464  on('turn.complete', async ($, e, next) => {
465    const done = await next(e)
466    if (!e.agentId) {
467      isTurnRunning = false
468      const keepsEstimate = compactedThisTurn
469      compactedThisTurn = false
470      await update($, viewAtom, v => ({
471        ...(v ?? EMPTY),
472        ctxOverride: keepsEstimate ? (v?.ctxOverride ?? null) : null,
473      }))
474      await update($, compactingAtom, () => false)
475      await refresh($)
476      // The reply is in: Auto compact looks once the turn has let go.
477      $.clock.after(1_000, () => void maybeAutoCompact($))
478    }
479
480    return done
481  })
482
483  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
484    if (e.props.hasSurvey) return next(e)
485    const view = (await read($, viewAtom)) ?? EMPTY
486    // A new session, before its first reply: nothing is cached yet. The band
487    // shows from the start anyway, so the toggles can be set before then.
488    const isFresh = view.lastUsedAt === 0 || view.ctxTokens === 0
489
490    const [isCompacting, warmth] = await Promise.all([read($, compactingAtom), read($, warmthAtom)])
491    const below = await next(e)
492    const table = $.ui.resolve(e)
493    const { Box, Text, Button } = table
494    // The terminal has no Svg (its table hands one out that draws nothing), so
495    // it keeps the meter of block characters and words for the toggles.
496    const Svg = e.surface !== 'terminal' && 'Svg' in table ? table.Svg : undefined
497
498    const isWorking = e.props.isWorking
499    const t = temperatureOf(isWorking ? 0 : isFresh ? TTL_MS : Math.max(0, view.now - view.lastUsedAt))
500    const isEmpty = isFresh && !isWorking
501    const isCold = t.leftMs === 0
502    const { filled, empty } = meterOf(t.leftMs / TTL_MS)
503    const size = tokens(view.ctxTokens)
504    const untilAct = t.leftMs - LEAD_MS
505    const soon = untilAct < 60_000 ? '<1m' : span(untilAct)
506    const status = isWorking
507      ? 'in use'
508      : isEmpty
509        ? 'no cache yet'
510        : isCold
511          ? `next message rewrites ${size}`
512          : warmth.isPinging
513            ? 'pinging…'
514            : willPing(warmth)
515              ? `ping in ${soon}`
516              : `cold in ${span(t.leftMs)}`
517
518    // The cells each layout takes in one row, with the longest status.
519    const columns = e.props.bodyColumns
520    const cacheCells =
521      (Svg ? pxCells(GAUGE_ICON + 105) : CELLS) + 1 + textCells('cooling') + 1 + textCells(`· next message rewrites ${size}`)
522    const named = (name: string) => (Svg ? ICON_BUTTON + 1 + textCells(name) : textCells(`☐ ${name}`)) + PAD
523    const togglesFull = named('Auto cache') + 2 + named('Auto compact')
524    const togglesShort = Svg ? ICON_BUTTON + 1 + ICON_BUTTON : togglesFull
525    const compactCells = textCells('Compact') + PAD
526    const fullCells = Math.ceil(textCells('Cache') + 1 + cacheCells + 3 + togglesFull + 2 + compactCells)
527    const mediumCells = Math.ceil(cacheCells + 3 + togglesShort + 2 + compactCells)
528    const layout = columns >= fullCells ? 'full' : columns >= mediumCells || !Svg ? 'medium' : 'icons'
529    const isFull = layout === 'full'
530    const isIcons = layout === 'icons'
531    const bar = isIcons && columns < TINY ? 64 : 105
532    // A terminal narrower still keeps the cold status short.
533    const shownStatus = !Svg && columns < mediumCells && isCold && !isEmpty ? 'full rewrite next' : status
534
535    // A card that opens while the pointer is over the keyed Box around it; the
536    // first of several lines is its heading.
537    const card = (...lines: string[]) => (
538      <Box
539        position="absolute"
540        top={-4}
541        left={0}
542        display="none"
543        hover={{ display: 'flex' }}
544        flexDirection="column"
545        rowGap={1}
546        borderStyle="round"
547        borderColor="subtle"
548        backgroundColor="background"
549        paddingX={1}
550      >
551        {lines.map((line, i) => (
552          <Text bold={isIcons && i === 0 && lines.length > 2}>{line}</Text>
553        ))}
554      </Box>
555    )
556
557    // A picture a person presses: a blank button laid over it and centered on
558    // it takes the click, and its hover frames the picture.
559    const pressable = (key: string, picture: RenderElement, onPress: () => void, tip?: string) => (
560      <Box key={`${key}-box`} position="relative" flexDirection="row" alignItems="center" flexShrink={0}>
561        {picture}
562        <Box
563          position="absolute"
564          top={0}
565          left={0}
566          right={0}
567          height="100%"
568          flexDirection="row"
569          alignItems="center"
570          justifyContent="center"
571        >
572          <Button key={key} label={ICON_BLANK} plain onPress={onPress} />
573        </Box>
574        {tip !== undefined && card(tip)}
575      </Box>
576    )
577
578    // A toggle: its glowing icon, named beside it when the band has room and
579    // in a hover card when it hasn't. The terminal gets a box and its name.
580    const toggle = (key: string, name: string, isOn: boolean, svg: (isOn: boolean) => string, does: string, onPress: () => void) => {
581      if (!Svg) {
582        return <Button key={key} label={`${isOn ? '☑' : '☐'} ${name}`} plain dimColor={!isOn} onPress={onPress} />
583      }
584      const state = `${name} ${isOn ? 'on' : 'off'}`
585      const icon = pressable(
586        key,
587        <Svg source={svg(isOn)} alt={state} width={18} height={18} />,
588        onPress,
589        isFull ? undefined : `${state}: ${does}`,
590      )
591      if (!isFull) return icon
592
593      return (
594        <Box key={`${key}-named`} flexDirection="row" alignItems="center" columnGap={1} flexShrink={0}>
595          {icon}
596          <Button key={`${key}-name`} label={name} plain dimColor={!isOn} onPress={onPress} />
597        </Box>
598      )
599    }
600
601    // Each piece keeps its size; nothing wraps mid-phrase.
602    const gauge = Svg ? (
603      <Box flexShrink={0}>
604        <Svg
605          source={gaugeSvg(t.word, t.leftMs / TTL_MS, bar)}
606          alt={isEmpty ? 'No cache yet' : isCold ? 'Cache cold' : `Cache ${t.word}, ${span(t.leftMs)} left`}
607          width={GAUGE_ICON + bar}
608          height={16}
609        />
610      </Box>
611    ) : (
612      <Box flexDirection="row" flexShrink={0}>
613        <Text color={t.tone}>{filled}</Text>
614        <Text color="rate_limit_empty">{empty}</Text>
615      </Box>
616    )
617
618    const about = [
619      (isEmpty
620        ? `Claude starts caching this conversation with your first message, and keeps it for an hour after ` +
621          `it last used it.`
622        : `Claude keeps this conversation cached for an hour after it last used it.`) +
623        ` While it's warm, each message re-reads it from the cache, which costs far less. Once it's cold, ` +
624        `the next message writes it all into the cache again` +
625        (isEmpty ? '.' : ` (${size} tokens now).`) +
626        ` Compact shrinks the conversation, so both cost less.`,
627      `Auto cache pings a background copy of the conversation 10 minutes before the cache would go cold, ` +
628        `which keeps it hot without adding anything to the chat. A ping costs about a twentieth of a ` +
629        `rewrite, so it stops after ${MAX_PINGS} in a row (about 16 hours away) and starts again when you ` +
630        `send a message.` +
631        (warmth.pings > 0 ? ` Pinged ${warmth.pings}× since your last message.` : ''),
632      `Auto compact compacts the conversation once it passes ${tokens(AUTO_COMPACT_AT)} tokens, right ` +
633        `after Claude's reply. Past that size every message costs more and Claude misses more of what's ` +
634        `in it; Claude Code's own auto-compact would wait until about 830k.`,
635    ]
636    const word = t.word.charAt(0).toUpperCase() + t.word.slice(1)
637
638    const cache = (
639      <Box key="cache" flexDirection="row" alignItems="center" columnGap={1} flexShrink={0}>
640        {isFull && (
641          <Text dimColor wrap="truncate">
642            Cache
643          </Text>
644        )}
645        {gauge}
646        {!isIcons && !isEmpty && (
647          <Text color={t.tone} bold wrap="truncate">
648            {t.word}
649          </Text>
650        )}
651        {!isIcons && (
652          <Text dimColor wrap="truncate">
653            · {shownStatus}
654          </Text>
655        )}
656        {isIcons
657          ? card(isEmpty ? 'No cache yet · it starts with your first message' : `${word} · ${status}`, ...about)
658          : card(...about)}
659      </Box>
660    )
661
662    const autoCache = toggle(
663      'auto',
664      'Auto cache',
665      warmth.isOn,
666      autoCacheSvg,
667      "keeps the cache warm while you're away",
668      () => void toggleAutoCache($),
669    )
670    const autoCompact = toggle(
671      'auto-compact',
672      'Auto compact',
673      warmth.isCompactOn,
674      autoCompactSvg,
675      `compacts the conversation past ${tokens(AUTO_COMPACT_AT)} tokens`,
676      () => void toggleAutoCompact($),
677    )
678
679    // Nothing to compact before the first reply, nor while Claude works.
680    const compact = isWorking || isEmpty ? null : isIcons && Svg ? (
681      pressable(
682        'compact',
683        <Svg source={compactSvg(isCompacting)} alt={isCompacting ? 'Compacting' : 'Compact'} width={14} height={14} />,
684        () => void compactNow($),
685        isCompacting ? 'Compacting…' : 'Compact now',
686      )
687    ) : isCompacting ? (
688      <Text dimColor wrap="truncate">
689        compacting…
690      </Text>
691    ) : (
692      <Button key="compact" label="Compact" plain onPress={() => void compactNow($)} />
693    )
694
695    return (
696      <Box flexDirection="column">
697        <Box
698          flexDirection="row"
699          flexWrap="wrap"
700          alignItems="center"
701          justifyContent="space-between"
702          width="100%"
703          columnGap={isIcons ? 2 : 3}
704        >
705          {cache}
706          <Box
707            key="controls"
708            flexDirection="row"
709            flexWrap="wrap"
710            alignItems="center"
711            justifyContent="flex-end"
712            columnGap={isFull ? 2 : 1}
713          >
714            {autoCache}
715            {autoCompact}
716            {compact}
717          </Box>
718        </Box>
719        {below}
720      </Box>
721    )
722  })
723}
724
types/index.d.ts 34 lines
1export type CacheView = {
2  // When the main conversation last sent Claude a request, which is when its
3  // cache was last read; 0 when not known yet.
4  lastUsedAt: number
5  ctxTokens: number
6  // The conversation's size estimated after a compaction, until the next
7  // reply reports the real one.
8  ctxOverride: number | null
9  now: number
10}
11
12// The two toggles, and what Auto cache's pings and Auto compact have done.
13export type Warmth = {
14  // Auto cache: ping the cache warm while the person is away.
15  isOn: boolean
16  // Auto compact: compact the conversation once it passes a size.
17  isCompactOn: boolean
18  // Pings since the conversation last sent a request of its own.
19  pings: number
20  isPinging: boolean
21  // False until the conversation sends a request a ping can repeat: after a
22  // compaction, after a ping found the cache already gone, or before anything
23  // was sent in this session.
24  isArmed: boolean
25  // No ping before this time; set after a ping failed.
26  retryAt: number
27}
28
29declare module 'claude-code' {
30  interface PluginState {
31    'cache-band': { view: CacheView | null; isCompacting: boolean; warmth: Warmth }
32  }
33}
34