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

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.
| Mod | What it does | What the audit found |
|---|---|---|
| Cache Band | Shows how warm the prompt cache is, keeps it warm while you're away, and compacts long conversations at 400K tokens | My cache went cold 91 times |
| Git Sync Band | Branch, ahead/behind and changed files above the prompt, with Pull, Files and Commit & push buttons | 1,660 git checks done by hand |
| Scout Router | Has a Haiku judge decide whether each subagent needs Opus, and runs the rest on Sonnet | 243 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.
~/.claude/skills/, for example ~/.claude/skills/cache-band./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.
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:
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.
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.
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.
MIT
hooks/register.tsx 724 lines1import { 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}
724types/index.d.ts 34 lines1export 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