Counts down to prompt-cache expiry and, just before it, keeps the cache warm with a ping or compacts the conversation, so a long context is not re-written at…

A Claude Code mod that watches the prompt cache. It counts down to the cache's expiry. Just before the cache lapses, it either keeps the cache warm with a tiny ping or compacts the conversation. Either way, a long context is not written to the cache again at full price after you step away for a coffee.

Claude Code caches the conversation on Anthropic's side, and each reply on a warm cache pays a fraction of the price. The cache lives 5 minutes (API key, Bedrock, Vertex, Foundry) or 1 hour (a Claude subscription within its usage limits). After it expires, the next message pays to write the whole context to the cache again.
| Opus 5.5, list price | 100k context | 250k context |
|---|---|---|
| Next reply on a warm cache (cache read) | $0.02 | $0.05 |
| Next reply after a 5-minute cache expired (cache write) | $0.50 | $1.25 |
| Next reply after a 1-hour cache expired (cache write) | $0.80 | $2.00 |
A ping costs about as much as a cache read, so one well-timed ping is around 25× cheaper than a lapse. A compaction just before the lapse means the next message re-writes a short summary instead of the whole context.
You need Claude Code with mod (hooks module) support. cache-guard is built and tested on Claude Code 2.1.295, and older versions refuse the module.
Run this at the prompt of a Claude Code session in a terminal:
/plugin install cache-guard --marketplace kamilbakierzynski/cache-guard
y to add the marketplace.The mod is active at once, with no restart.
The same from a shell:
claude plugin marketplace add kamilbakierzynski/cache-guard
claude plugin install cache-guard@cache-guard
Claude desktop app: /plugin is not available in the desktop app's Code tab. Install it once from a terminal at the user scope, and it loads in the desktop app's local sessions too. There the band draws a progress ring instead of the terminal's dot.
Update to the latest commit:
claude plugin update cache-guard
Then run /reload-plugins in an open session.
Uninstall:
claude plugin uninstall cache-guard
28 min left with 5 pings on a 5-minute cache.saved $3.39 this session · saved $12.80 in all. They turn yellow as net −$0.04 when the mod has spent more than it spared so far.ping 1/5 in 3:25, compact in 4:30), the last thing the mod did, the time since the reply, and the context size.Claude is working. During a long tool it adds tool running 6:12 and the tool-wait pings.Cache expired · resume ≈ $1.25 at API price tells you what your next message will cost to re-cache.TTL 5 min / TTL 1 hour until it learns the TTL. It highlights the likely one: a subscription within its usage limits usually has 1 hour, an API key or a subscription past its limits 5 minutes. Under auto this rarely shows for long: the mod measures the TTL from the first replies' bills (see How it works).Everything the mod does leaves a dim line in the transcript, with ✓ or ✗:
✓ Ping 1/5 · cache kept warm · read 249k of 249k · $0.06 billed
✗ Ping 2/5 · missed the cache · read 2k of 249k, wrote 247k · $1.24 billed
✓ Ping 1 during a tool · cache kept warm · read 249k of 249k · $0.06 billed
✓ Compacted 249k → 15k before the cache expired · ≈ $0.06
✓ Reply read 249k from the cache the ping kept · spared $1.20 of re-caching
✓ Reply wrote 15k instead of 249k · spared $1.17 of re-caching
✗ Reply re-wrote 249k · the ping did not keep the cache warm (read 0k)
A line's cost comes from the session's cost ledger when it moved (billed), otherwise from the price list (≈). Pings never appear as messages: they are forks of the conversation that the model answers on the side.
A minute before the cache lapses (adjustable from 30 s to 10 min), you get a system notification, so you can reply in time even from another window or another session:
Prompt cache expires in 1 min, with what the next reply will cost once it has: ≈ $1.25 instead of $0.05.Compacting in 1 min, so you can come back while the full context is still there.In a terminal it goes through Claude Code's own notification channel (preferredNotifChannel). The Claude desktop app has no terminal to notify through, so there cache-guard raises the system's own notification instead: a Windows toast through PowerShell, osascript on macOS, notify-send on Linux. Nothing has to be installed. If you have turned Claude Code's notifications off, cache-guard sends none.
In the desktop app on Windows, a click on the notification brings you back to the session it is about, even from another session: it opens the app's claude://code/continue?session=… link. On macOS and Linux the notification shows, but the system's built-in notifier cannot carry a link.
Send a test in the panel's Notification section raises one at once, and says under the prompt whether it went out or why not.
Open it with ⚙ Settings on the band or with the /cache-guard command. The command also prints a report: the current state, the price at your model, and the stats.
Changes made on the band or in the panel are remembered across sessions. The plugin's options (below) are the defaults they reset to.
| Mode | Just before the cache expires |
|---|---|
| Watch | Nothing. Only the countdown, the cost of resuming, and the TTL learning. |
| Ping | Sends a tiny forked request (Reply with exactly: ok) that reads the whole cached context and so renews it, up to Pings before compact times per pause, then lets the cache lapse. |
| Compact (default) | Compacts the conversation with the selected profile's prompt, so your next message re-writes a short summary instead of the whole context. |
| Ping → Compact | Pings first, and compacts after the last ping or when a ping misses the cache. With ∞ pings it never compacts. |
The mod acts only when the context is at least Minimum context, the TTL is known, and Claude is not working. A new message from you cancels the plan, and the countdown starts again after the reply.
A tool that runs past the cache's lifetime makes the next request re-write the context too. Examples are a test suite, a build, or a slow MCP call. With Keep warm during long tools on (the default; never in Watch), the mod pings while a tool in the main conversation is running. It stops pinging once more pings would cost more than the one re-cache they save.
Set at install, or later with claude plugin configure cache-guard.
| Option | Default | Meaning |
|---|---|---|
mode | compact | watch, ping, compact or ping+compact |
ttl | auto | auto finds the session's TTL (measured, configured or reported) and only counts down until it does; 5m or 1h set it by hand |
maxPings | 2 | Keep-alive pings in one pause; 11 or more means unlimited |
marginSeconds | 30 | How long before expiry the ping or compaction runs |
minTokens | 50000 | Below this context size the mod only watches |
toolPings | true | Keep the cache warm while a long tool runs |
notify | true | A system notification shortly before the cache lapses or is compacted |
notifySeconds | 60 | How long before that the notification comes |
profile | code | Which compaction prompt is used: code, debug or plan |
instructionsCode | see below | Prompt of the code profile |
instructionsDebug | see below | Prompt of the debug profile |
instructionsPlan | see below | Prompt of the plan profile |
The prompt is added to Claude Code's own summary prompt. It is short on purpose, because every item asked for lengthens the summary that each later request sends. CLAUDE.md, the plan, skills and recently read files are re-attached after a compaction anyway, so the prompts leave them out.
file:line; temporary instrumentation to revert.Claude Code's own automatic compaction does not receive these prompts. To steer it too, add a # Compact instructions section to your CLAUDE.md.
auto the mod finds the session's cache TTL itself, usually within the first replies. The TTL belongs to the session: it is not carried over from earlier sessions. /cache-guard and the panel say where it came from. The sources, strongest first:FORCE_PROMPT_CACHING_5M, CLAUDE_CODE_PROMPT_CACHE_TTL, the promptCacheTtl setting, ENABLE_PROMPT_CACHING_1H (and ENABLE_PROMPT_CACHING_1H_BEDROCK), and Bedrock, Vertex or Foundry, which cache for 5 minutes.While the TTL is unknown, the band's hint uses the usage limits: reported and all under 100% suggests 1 hour; none reported, or one past its limit, suggests 5 minutes.
$.model.fork resends the conversation's last request with one question after it. It counts as warm only when the API reports at least 70% of the context read from the cache."promptCacheTtl": "1h" in settings, or the environment variable CLAUDE_CODE_PROMPT_CACHE_TTL=1h. Each cache write then costs 2× the input price instead of 1.25×.On a 1-hour cache cache-guard has much less to do, but it still helps with longer breaks.
git clone https://github.com/kamilbakierzynski/cache-guard
claude --plugin-dir ./cache-guard
With --plugin-dir the engine lays the API's types in .claude-plugin/types/, and then tsc -p . type-checks the mod.
Run the tests:
claude plugin test ./cache-guard
Validate the manifest, the marketplace file and the module:
claude plugin validate ./cache-guard
The repository is its own marketplace (.claude-plugin/marketplace.json), listing the mod at its root.
| File | Contents |
|---|---|
hooks/register.tsx | The hooks, timers, actions, band and settings panel |
hooks/view.ts | Pure helpers: the band's text, prices, savings, transcript lines |
types/index.d.ts | The mod's state contract |
tests/cache-guard.test.ts | The test suite |
MIT
hooks/register.tsx 1271 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, ModelForkResult, ModelUsage, Register, Timer, TurnUsage } from 'claude-code'
3
4import type { GuardMode, GuardProfile, GuardStats, GuardTtl, GuardView, Ttl } from '../types'
5import {
6 bandParts,
7 cacheCost,
8 callCost,
9 durationText,
10 INITIAL_VIEW,
11 kTokens,
12 line,
13 NO_STATS,
14 pingLimitText,
15 pingLine,
16 ringSvg,
17 savingsText,
18 statsLine,
19 TTL_MS,
20 ttlFromBill,
21 UNLIMITED_PINGS,
22 usdText,
23} from './view'
24
25const view = atom({ plugin: 'cache-guard', key: 'view' } as const, INITIAL_VIEW)
26
27const SETTINGS_PANE = 'cache-guard-settings'
28
29/** What the settings panel changes; kept in the store over the options' defaults. */
30type Settings = {
31 mode: GuardMode
32 ttlOption: GuardTtl
33 profile: GuardProfile
34 maxPings: number
35 marginMs: number
36 minTokens: number
37 /** Keep the cache warm with pings while a long tool runs in the main loop. */
38 toolPings: boolean
39 /** Raise a system notification shortly before the cache lapses or is compacted. */
40 notify: boolean
41 /** How long before that. */
42 notifyMs: number
43}
44
45/** A pause long enough to tell a 5m cache from a 1h one, waiting for the next turn's usage. */
46type Probe = { context: number }
47
48/** The mod's settings and the idle period it watches. */
49type Guard = Settings & {
50 /** Each profile's text in force: the panel's edit, else the options' text, else the built-in one. */
51 instructions: Record<GuardProfile, string>
52 /** Each profile's text as the options set it, the built-in one where left empty. */
53 optionInstructions: Record<GuardProfile, string>
54 learnedTtl?: Ttl
55 /** How `learnedTtl` was found, for the report and the panel. */
56 ttlSource?: string
57 /** The TTL the last requests' bills showed, and how many in a row showed it. */
58 billTtl?: { ttl: Ttl; count: number }
59 stats: GuardStats
60 isWorking: boolean
61 lastReplyAt?: number
62 /** When the cache was last touched; undefined while nothing warm is guarded. */
63 warmAt?: number
64 context: number
65 pings: number
66 /** The main loop's model, from the last reply's usage. */
67 model?: string
68 /** Off for this session: no countdown, no action, no band. */
69 isOff: boolean
70 hasActed: boolean
71 note: string
72 probe?: Probe
73 tick?: Timer
74 fire?: Timer
75 /** The notification before the cache lapses or is compacted. */
76 notice?: Timer
77 /** Which of the system's own notifiers worked last, where the terminal cannot notify. */
78 notifier?: number
79 /** The desktop app's link back to this session, which a click on the notification opens. */
80 link?: string
81 /** This session's stats; `stats` holds all sessions'. */
82 session: GuardStats
83 likelyTtl?: Ttl
84 /** When the main loop's latest model request started: a cache entry lives from there. */
85 requestAt?: number
86 /** Tool calls the main loop is running now; parallel calls share one wait. */
87 toolsRunning: number
88 toolSince?: number
89 /** When the cache was last touched during the current tool wait. */
90 waitAt?: number
91 waitPings: number
92 /** What the current tool wait's pings cost so far. */
93 waitSpent: number
94 waitFire?: Timer
95 /** Re-caching spared, credited once the next request shows the cache was found, not before. */
96 credit?: { amount: number; isChecked: boolean; before?: number; after?: number }
97}
98
99const MODES: readonly GuardMode[] = ['watch', 'ping', 'compact', 'ping+compact']
100const TTLS: readonly GuardTtl[] = ['auto', '5m', '1h']
101const PROFILES: readonly GuardProfile[] = ['code', 'debug', 'plan']
102
103const MODE_LABELS: Record<GuardMode, string> = {
104 watch: 'Watch',
105 ping: 'Ping',
106 compact: 'Compact',
107 'ping+compact': 'Ping → Compact',
108}
109
110const TTL_LABELS: Record<GuardTtl, string> = { auto: 'Auto', '5m': '5 min', '1h': '1 hour' }
111
112const PROFILE_LABELS: Record<GuardProfile, string> = { code: 'Code', debug: 'Debug', plan: 'Plan' }
113
114const MODE_HINTS: Record<GuardMode, string> = {
115 watch: 'Only count down; never touch the conversation.',
116 ping: 'Keep the cache warm with tiny forked questions, then let it lapse.',
117 compact: 'Compact the conversation just before the cache expires.',
118 'ping+compact': 'Keep the cache warm with pings first, then compact.',
119}
120
121/**
122 * The built-in texts behind the options' defaults: what a compaction is told on top of the engine's own
123 * summary prompt. Short on purpose: every item asked for lengthens the summary the next requests re-send.
124 * CLAUDE.md, the plan, skills and recent files are re-injected after compaction, so they are left out.
125 */
126const INSTRUCTIONS: Record<GuardProfile, string> = {
127 code:
128 "Keep it tight: pointers over content. Preserve: user's corrections/preferences/limits quoted verbatim; decisions + why; " +
129 'approaches ruled out (1 line each); exact paths, symbols, commands, failing test names, error text; ' +
130 'uncommitted/unverified changes; todo state; exact next step. Drop: tool output, code already on disk, resolved debugging detail.',
131 debug:
132 'Debug focus: symptom + exact repro command; current hypothesis and its evidence; hypotheses ruled out + why (1 line each); ' +
133 'exact error text, failing tests, file:line; temporary instrumentation to revert. Drop raw logs and resolved branches.',
134 plan:
135 "Design focus: goal, constraints and user's preferences verbatim; options considered: chosen + why, rejected + why; " +
136 'open questions for the user; agreed plan steps and status. No code snippets or file contents; reference paths only.',
137}
138
139/** A numeric setting the panel steps through. */
140type Stepper = {
141 key: string
142 label: string
143 hint: string
144 min: number
145 max: number
146 step: number
147 get: (v: GuardView) => number
148 set: (n: number) => Partial<Settings>
149 show: (n: number) => string
150}
151
152const STEPPERS: readonly Stepper[] = [
153 {
154 key: 'maxPings',
155 label: 'Pings before compact',
156 hint: 'Keep-alive pings in one pause. Ping → Compact compacts after the last one; Ping just stops. Past 10 is ∞.',
157 min: 0,
158 max: UNLIMITED_PINGS,
159 step: 1,
160 get: (v: GuardView) => v.maxPings,
161 set: (n: number): Partial<Settings> => ({ maxPings: n }),
162 show: pingLimitText,
163 },
164 {
165 key: 'margin',
166 label: 'Act before expiry',
167 hint: 'How long before the cache expires the ping or compact runs.',
168 min: 10_000,
169 max: 180_000,
170 step: 10_000,
171 get: (v: GuardView) => v.marginMs,
172 set: (n: number): Partial<Settings> => ({ marginMs: n }),
173 show: (n: number) => `${n / 1000}s`,
174 },
175 {
176 key: 'minTokens',
177 label: 'Minimum context',
178 hint: 'Below this the mod only watches: compacting a small context saves little.',
179 min: 10_000,
180 max: 500_000,
181 step: 10_000,
182 get: (v: GuardView) => v.minTokens,
183 set: (n: number): Partial<Settings> => ({ minTokens: n }),
184 show: kTokens,
185 },
186]
187
188const NOTIFY_STEPPER: Stepper = {
189 key: 'notifyLead',
190 label: 'Notify how early',
191 hint: 'How long before the cache expires, or before the compaction, the notification comes.',
192 min: 30_000,
193 max: 600_000,
194 step: 30_000,
195 get: v => v.notifyMs,
196 set: n => ({ notifyMs: n }),
197 show: durationText,
198}
199
200const PING_PROMPT = 'Automated cache keep-alive, not a message from the user. Reply with exactly: ok'
201
202/**
203 * A Windows toast from PowerShell's own app id, so nothing has to be installed or registered. With a link, a click
204 * on it opens the link: the desktop app, at this session.
205 */
206const WINDOWS_TOAST =
207 '$m=[Windows.UI.Notifications.ToastNotificationManager,Windows.UI.Notifications,ContentType=WindowsRuntime];' +
208 '$x=$m::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02);' +
209 "if($env:CACHE_GUARD_LINK){$x.DocumentElement.SetAttribute('activationType','protocol');" +
210 "$x.DocumentElement.SetAttribute('launch',$env:CACHE_GUARD_LINK)};" +
211 "$t=$x.GetElementsByTagName('text');" +
212 '$null=$t.Item(0).AppendChild($x.CreateTextNode($env:CACHE_GUARD_TITLE));' +
213 '$null=$t.Item(1).AppendChild($x.CreateTextNode($env:CACHE_GUARD_BODY));' +
214 "$m::CreateToastNotifier('{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\\WindowsPowerShell\\v1.0\\powershell.exe')" +
215 '.Show([Windows.UI.Notifications.ToastNotification]::new($x))'
216
217/**
218 * The system's own notifiers, tried in turn where no terminal can notify (the desktop app): Windows, macOS, Linux.
219 * The text travels in the environment or as plain arguments, never through a shell.
220 */
221const NATIVE_NOTIFIERS: readonly ((title: string, body: string) => string[])[] = [
222 () => ['powershell.exe', '-NoProfile', '-NonInteractive', '-Command', WINDOWS_TOAST],
223 () => [
224 'osascript',
225 '-e',
226 'display notification (system attribute "CACHE_GUARD_BODY") with title (system attribute "CACHE_GUARD_TITLE")',
227 ],
228 (title, body) => ['notify-send', '--app-name=cache-guard', title, body],
229]
230
231const pick = <T extends string>(value: unknown, allowed: readonly T[], fallback: T): T =>
232 allowed.includes(value as T) ? (value as T) : fallback
233
234const num = (value: unknown, fallback: number): number =>
235 typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : fallback
236
237const clamp = (n: number, min: number, max: number): number => Math.min(max, Math.max(min, n))
238
239const text = (value: unknown, fallback: string): string =>
240 typeof value === 'string' && value.trim() ? value.trim() : fallback
241
242const asTtl = (value: unknown): Ttl | undefined => (value === '5m' || value === '1h' ? value : undefined)
243
244/** An environment flag as Claude Code reads one. */
245const isOn = (value: string | undefined): boolean =>
246 value !== undefined && ['1', 'true', 'yes', 'on'].includes(value.trim().toLowerCase())
247
248/**
249 * The TTL Claude Code's own configuration fixes for the main conversation, checked in the engine's order;
250 * undefined where its automatic choice applies (1 hour on a subscription within its limits, else 5 minutes).
251 */
252async function configuredTtl($: EngineInterface): Promise<{ ttl: Ttl; source: string } | undefined> {
253 if (isOn(await $.env.get('FORCE_PROMPT_CACHING_5M'))) return { ttl: '5m', source: 'set by FORCE_PROMPT_CACHING_5M' }
254 const env = asTtl(await $.env.get('CLAUDE_CODE_PROMPT_CACHE_TTL'))
255 if (env) return { ttl: env, source: 'set by CLAUDE_CODE_PROMPT_CACHE_TTL' }
256 const settings = (await $.settings.read().catch(() => ({}))) as { promptCacheTtl?: unknown }
257 const setting = asTtl(settings.promptCacheTtl)
258 if (setting) return { ttl: setting, source: 'set by the promptCacheTtl setting' }
259 if (isOn(await $.env.get('ENABLE_PROMPT_CACHING_1H'))) return { ttl: '1h', source: 'set by ENABLE_PROMPT_CACHING_1H' }
260 const isBedrock = isOn(await $.env.get('CLAUDE_CODE_USE_BEDROCK'))
261 if (isBedrock && isOn(await $.env.get('ENABLE_PROMPT_CACHING_1H_BEDROCK'))) {
262 return { ttl: '1h', source: 'set by ENABLE_PROMPT_CACHING_1H_BEDROCK' }
263 }
264 if (isBedrock || isOn(await $.env.get('CLAUDE_CODE_USE_VERTEX')) || isOn(await $.env.get('CLAUDE_CODE_USE_FOUNDRY'))) {
265 return { ttl: '5m', source: 'Bedrock, Vertex and Foundry cache for 5 min' }
266 }
267
268 return undefined
269}
270
271const ttlOf = (g: Guard): Ttl | undefined => (g.ttlOption === 'auto' ? g.learnedTtl : g.ttlOption)
272
273const settingsOf = (g: Guard): Settings => ({
274 mode: g.mode,
275 ttlOption: g.ttlOption,
276 profile: g.profile,
277 maxPings: g.maxPings,
278 marginMs: g.marginMs,
279 minTokens: g.minTokens,
280 toolPings: g.toolPings,
281 notify: g.notify,
282 notifyMs: g.notifyMs,
283})
284
285/** Settings read from the options or the store, each checked and falling back to `base`. */
286const settingsFrom = (raw: Partial<Record<keyof Settings, unknown>>, base: Settings): Settings => ({
287 mode: pick(raw.mode, MODES, base.mode),
288 ttlOption: pick(raw.ttlOption, TTLS, base.ttlOption),
289 profile: pick(raw.profile, PROFILES, base.profile),
290 maxPings: Math.floor(clamp(num(raw.maxPings, base.maxPings), 0, UNLIMITED_PINGS)),
291 marginMs: clamp(num(raw.marginMs, base.marginMs), 0, 600_000),
292 minTokens: num(raw.minTokens, base.minTokens),
293 toolPings: typeof raw.toolPings === 'boolean' ? raw.toolPings : base.toolPings,
294 notify: typeof raw.notify === 'boolean' ? raw.notify : base.notify,
295 notifyMs: clamp(num(raw.notifyMs, base.notifyMs), 0, 3_600_000),
296})
297
298const nextAction = (g: Guard): 'ping' | 'compact' | null => {
299 const canPing = (g.mode === 'ping' || g.mode === 'ping+compact') && (g.maxPings >= UNLIMITED_PINGS || g.pings < g.maxPings)
300 if (canPing) return 'ping'
301 if (g.mode === 'compact' || g.mode === 'ping+compact') return 'compact'
302
303 return null
304}
305
306/** Reads what the first turn after a pause paid: a rewrite of the context means the cache had lapsed. */
307const ttlFromProbe = (probe: Probe, usage: ModelUsage): Ttl | undefined => {
308 if (usage.cache_creation_input_tokens >= 0.3 * probe.context) return '5m'
309 const isRead = usage.cache_read_input_tokens >= 0.7 * probe.context
310 if (usage.cache_creation_input_tokens < 0.1 * probe.context && isRead) return '1h'
311
312 return undefined
313}
314
315function stopTick(g: Guard) {
316 g.tick?.cancel()
317 g.tick = undefined
318}
319
320function unschedule(g: Guard) {
321 g.fire?.cancel()
322 g.fire = undefined
323 g.notice?.cancel()
324 g.notice = undefined
325}
326
327async function publish($: EngineInterface, g: Guard) {
328 const now = await $.clock.now()
329 await update($, view, () => ({
330 lastReplyAt: g.lastReplyAt ?? null,
331 warmAt: g.warmAt ?? null,
332 now,
333 isWorking: g.isWorking,
334 context: g.context,
335 ttl: ttlOf(g) ?? null,
336 mode: g.mode,
337 ttlOption: g.ttlOption,
338 profile: g.profile,
339 minTokens: g.minTokens,
340 marginMs: g.marginMs,
341 maxPings: g.maxPings,
342 pings: g.pings,
343 stats: g.stats,
344 session: g.session,
345 toolSince: g.toolSince ?? null,
346 toolPings: g.toolPings,
347 notify: g.notify,
348 notifyMs: g.notifyMs,
349 learnedTtl: g.learnedTtl ?? null,
350 ttlSource: g.ttlSource ?? null,
351 likelyTtl: g.likelyTtl ?? null,
352 model: g.model ?? null,
353 isOff: g.isOff,
354 nextAction: nextAction(g),
355 note: g.note,
356 }))
357}
358
359/** Keeps the band's clock running while the session waits. */
360function startTick($: EngineInterface, g: Guard) {
361 if (!g.tick && !g.isOff) g.tick = $.clock.every(1000, () => void publish($, g))
362}
363
364/** Takes the TTL the latest evidence shows; the session can change it (a subscription past its limits caches for 5 min). */
365async function learn($: EngineInterface, g: Guard, value: Ttl | undefined, source: string) {
366 if (value === undefined || g.learnedTtl === value) return
367 g.learnedTtl = value
368 g.ttlSource = source
369 if (g.ttlOption === 'auto') $.ui.toast(`cache-guard: this session's cache TTL is ${value} (${source})`)
370 await schedule($, g)
371}
372
373/** Learns the TTL once two requests in a row were billed at the same cache-write rate. */
374async function measured($: EngineInterface, g: Guard, ttl: Ttl | null) {
375 if (ttl === null) return
376 g.billTtl = g.billTtl?.ttl === ttl ? { ttl, count: g.billTtl.count + 1 } : { ttl, count: 1 }
377 if (g.billTtl.count >= 2) await learn($, g, ttl, 'measured from the cache-write price')
378}
379
380const added = (s: GuardStats, change: Partial<GuardStats>): GuardStats => {
381 const next: GuardStats = { ...s }
382 for (const key of Object.keys(change) as (keyof GuardStats)[]) next[key] += change[key] ?? 0
383
384 return next
385}
386
387/** Adds to the stats of all sessions, kept in the store, and to this session's. */
388async function count($: EngineInterface, g: Guard, change: Partial<GuardStats>) {
389 g.stats = added(g.stats, change)
390 g.session = added(g.session, change)
391 await $.store.set('stats', g.stats)
392}
393
394const costOf = (g: Guard) => cacheCost(g.model ?? null, g.context, ttlOf(g) ?? null)
395
396/** A ping's price, counted as spent whether or not it pays off. */
397async function pingSpent($: EngineInterface, g: Guard, reply: ModelForkResult) {
398 const spent = 'usage' in reply && reply.usage ? callCost(reply.usage, costOf(g), ttlOf(g) ?? null) : 0
399 await count($, g, { pings: 1, spentUsd: spent })
400
401 return spent
402}
403
404type Ping = { reply: ModelForkResult; at: number; cached: number; isWarm: boolean; spent: number }
405
406/**
407 * Sends one ping and writes what came back to the transcript: the answer, what the API read from the cache
408 * and wrote, the price, and how far the session's cost ledger moved, so a ping can be checked, not taken on trust.
409 */
410async function sendPing($: EngineInterface, g: Guard, label: string): Promise<Ping> {
411 const before = (await $.session.usage()).cost?.usd
412 const at = await $.clock.now()
413 const reply = await $.model.fork({ prompt: PING_PROMPT })
414 const after = (await $.session.usage()).cost?.usd
415 const spent = await pingSpent($, g, reply)
416 const cached = 'usage' in reply ? reply.usage.cache_read_input_tokens : 0
417 const isWarm = reply.isAnswered && cached >= 0.7 * g.context
418 $.ui.log(
419 pingLine({
420 label,
421 isWarm,
422 reason: reply.isAnswered ? undefined : reply.reason,
423 cached,
424 context: g.context,
425 written: 'usage' in reply ? reply.usage.cache_creation_input_tokens : 0,
426 spent,
427 billed: before === undefined || after === undefined ? null : Math.max(0, after - before),
428 }),
429 )
430
431 return { reply, at, cached, isWarm, spent }
432}
433
434/** Credits what the request just made spared, once its usage shows the cache was found. */
435async function settle($: EngineInterface, g: Guard, usage: ModelUsage) {
436 const credit = g.credit
437 g.credit = undefined
438 if (!credit) return
439 const isFound = !credit.isChecked || usage.cache_read_input_tokens >= 0.7 * g.context
440 const spared = `spared ${usdText(credit.amount)} of re-caching`
441 if (isFound && credit.before !== undefined && credit.after !== undefined) {
442 $.ui.log(`✓ Reply wrote ${kTokens(credit.after)} instead of ${kTokens(credit.before)} · ${spared}`)
443 } else if (isFound) {
444 $.ui.log(`✓ Reply read ${kTokens(usage.cache_read_input_tokens)} from the cache the ping kept · ${spared}`)
445 }
446 if (isFound) return count($, g, { avoidedUsd: credit.amount, saves: 1 })
447 // The ping said the cache was warm, yet the request that followed paid to write it again.
448 g.note = `the ping did not keep the cache: the reply re-wrote ${kTokens(usage.cache_creation_input_tokens)}`
449 $.ui.log(
450 `✗ Reply re-wrote ${kTokens(usage.cache_creation_input_tokens)} · the ping did not keep the cache warm ` +
451 `(read ${kTokens(usage.cache_read_input_tokens)})`,
452 )
453 await count($, g, { pingMisses: 1 })
454}
455
456/** Sets the action for the margin before the warm cache lapses, and the notification ahead of it. */
457async function schedule($: EngineInterface, g: Guard) {
458 unschedule(g)
459 const known = ttlOf(g)
460 if (g.isOff || g.warmAt === undefined || g.isWorking || known === undefined) return
461 const left = g.warmAt + TTL_MS[known] - (await $.clock.now())
462 if (left <= 0) return
463 const action = nextAction(g)
464 if (action !== null) g.fire = $.clock.after(Math.max(0, left - g.marginMs), () => void act($, g))
465 // A ping keeps the cache, so only the lapse or the compaction after the last one is worth a notification.
466 if (!g.notify || action === 'ping' || g.context < g.minTokens) return
467 const until = action === 'compact' ? left - g.marginMs : left
468 if (until > 0) g.notice = $.clock.after(Math.max(0, until - g.notifyMs), () => void notify($, g))
469}
470
471/** Tells the person, through their notification channel, that the cache is about to lapse or be compacted. */
472async function notify($: EngineInterface, g: Guard) {
473 g.notice = undefined
474 const known = ttlOf(g)
475 if (g.isOff || g.isWorking || g.warmAt === undefined || known === undefined) return
476 const left = g.warmAt + TTL_MS[known] - (await $.clock.now())
477 const isCompact = nextAction(g) === 'compact'
478 const until = isCompact ? left - g.marginMs : left
479 if (until <= 0) return
480 const cost = costOf(g)
481 const title = isCompact ? `Compacting in ${durationText(until)}` : `Prompt cache expires in ${durationText(until)}`
482 const body = isCompact
483 ? `cache-guard compacts ${kTokens(g.context)} before the cache expires. Reply before then to keep the full context.`
484 : `The next reply then re-writes ${kTokens(g.context)}: ≈ ${usdText(cost.cold)} instead of ${usdText(cost.warm)}.`
485 await raise($, g, title, body)
486}
487
488/** Raises a notification; says whether it went out, or why not. */
489async function raise($: EngineInterface, g: Guard, title: string, body: string): Promise<string | null> {
490 const sent = await $.ui.notify(body, { title }).catch(() => ({ isSent: false as const, reason: 'refused' as const }))
491 if (sent.isSent) return null
492 if (sent.reason === 'disabled') return "notifications are turned off in Claude Code's settings"
493 if (sent.reason === 'refused') return 'a Notification hook refused it'
494 // No terminal to notify through (the desktop app), or none that notifies: the system's own notification
495 // reaches the person in another session or another window too.
496 return (await nativeNotify($, g, title, body)) ? null : 'the system has no notifier cache-guard can run'
497}
498
499/** The Notification section's button: one notification now, to see that it arrives and where a click leads. */
500async function testNotify($: EngineInterface, g: Guard) {
501 const body = g.link ? 'Notifications work. Click this one to come back to the session.' : 'Notifications work.'
502 const failure = await raise($, g, 'cache-guard test', body)
503 $.ui.toast(failure ? `cache-guard: no notification: ${failure}` : 'cache-guard: test notification sent')
504}
505
506/** Raises the notification with the first of the system's notifiers that works, starting with the last one that did. */
507async function nativeNotify($: EngineInterface, g: Guard, title: string, body: string): Promise<boolean> {
508 const env = { CACHE_GUARD_TITLE: title, CACHE_GUARD_BODY: body, CACHE_GUARD_LINK: g.link ?? '' }
509 const order = NATIVE_NOTIFIERS.map((make, i) => ({ make, i }))
510 order.sort((a, b) => Number(b.i === g.notifier) - Number(a.i === g.notifier))
511 for (const { make, i } of order) {
512 const isShown = await $.process
513 .run(make(title, body), { env, timeoutMs: 15_000 })
514 .then(done => done.exitCode === 0)
515 .catch(() => false)
516 if (isShown) {
517 g.notifier = i
518 return true
519 }
520 }
521
522 return false
523}
524
525/** Starts guarding a cache touched at `at`. */
526async function arm($: EngineInterface, g: Guard, at: number) {
527 g.warmAt = at
528 startTick($, g)
529 await schedule($, g)
530 await publish($, g)
531}
532
533async function compact($: EngineInterface, g: Guard) {
534 g.hasActed = true
535 unschedule(g)
536 g.note = `compacting ${kTokens(g.context)}…`
537 await publish($, g)
538 try {
539 const done = await $.session.compact({ instructions: g.instructions[g.profile] })
540 if (done.skip !== undefined) {
541 g.note = `compact skipped: ${done.skip}`
542 $.ui.log(`✗ Compact skipped · ${done.skip}`)
543 return
544 }
545 const before = done.tokensBefore ?? g.context
546 const after = done.tokensAfter ?? 0
547 const spent = done.usage ? callCost(done.usage, costOf(g), ttlOf(g) ?? null) : 0
548 // The cache was about to lapse: the next reply writes the summary instead of the whole context.
549 const rates = costOf(g).rates
550 const write = ttlOf(g) === '1h' ? rates.write1h : rates.write5m
551 g.credit = { amount: ((before - after) * write) / 1e6, isChecked: false, before, after }
552 $.ui.log(`✓ Compacted ${kTokens(before)} → ${kTokens(after)} before the cache expired · ≈ ${usdText(spent)}`)
553 g.warmAt = undefined
554 g.context = after
555 await count($, g, { compactions: 1, tokensBefore: before, tokensAfter: after, spentUsd: spent })
556 g.note = `compacted ${kTokens(before)} → ${kTokens(after)}`
557 } catch {
558 // A turn started meanwhile: its end re-arms the countdown.
559 g.note = 'compact did not start (a turn was running)'
560 } finally {
561 await publish($, g)
562 }
563}
564
565async function ping($: EngineInterface, g: Guard) {
566 g.hasActed = true
567 g.pings += 1
568 const { at, cached, isWarm } = await sendPing($, g, `Ping ${g.pings}/${pingLimitText(g.maxPings)}`)
569 if (isWarm) {
570 g.credit = { amount: costOf(g).lapse, isChecked: true }
571 g.note = `ping ${g.pings}/${pingLimitText(g.maxPings)} kept ${kTokens(cached)} warm`
572 // The entry lives from the ping request's start, not from its end.
573 await arm($, g, at)
574 return
575 }
576 g.credit = undefined
577 await count($, g, { pingMisses: 1 })
578 g.note = `ping missed the cache (${kTokens(cached)} of ${kTokens(g.context)})`
579 $.ui.toast(`cache-guard: ${g.note}`)
580 if (g.mode === 'ping+compact') return compact($, g)
581 await publish($, g)
582}
583
584async function act($: EngineInterface, g: Guard) {
585 const known = ttlOf(g)
586 if (g.isOff || g.isWorking || g.warmAt === undefined || known === undefined) return
587 if ((await $.clock.now()) >= g.warmAt + TTL_MS[known]) return
588 g.context = (await $.session.usage()).context.tokens ?? g.context
589 if (g.context < g.minTokens) {
590 g.note = `${kTokens(g.context)} is below the minimum, no action`
591 await publish($, g)
592 return
593 }
594 const action = nextAction(g)
595 if (action === 'ping') return ping($, g)
596 if (action === 'compact') return compact($, g)
597}
598
599/** Sets the next ping of a tool wait for the margin before the cache it started on lapses. */
600async function scheduleWait($: EngineInterface, g: Guard) {
601 g.waitFire?.cancel()
602 g.waitFire = undefined
603 const known = ttlOf(g)
604 if (g.isOff || g.mode === 'watch' || !g.toolPings || g.toolsRunning === 0 || g.waitAt === undefined || !known) return
605 const left = g.waitAt + TTL_MS[known] - (await $.clock.now())
606 if (left <= 0) return
607 g.waitFire = $.clock.after(Math.max(0, left - g.marginMs), () => void waitPing($, g))
608}
609
610/** Keeps the cache warm while a tool runs, as long as pinging stays cheaper than re-caching. */
611async function waitPing($: EngineInterface, g: Guard) {
612 if (g.toolsRunning === 0 || g.isOff) return
613 const cost = costOf(g)
614 if (g.context < g.minTokens) {
615 g.note = `${kTokens(g.context)} is below the minimum, no ping`
616 return publish($, g)
617 }
618 if (g.waitSpent + cost.warm >= cost.lapse) {
619 g.note = 'long tool: re-caching is now cheaper than more pings'
620 return publish($, g)
621 }
622 const { at, cached, isWarm, spent } = await sendPing($, g, `Ping ${g.waitPings + 1} during a tool`)
623 g.waitSpent += spent
624 if (!isWarm) {
625 await count($, g, { pingMisses: 1 })
626 g.note = `ping missed the cache (${kTokens(cached)} of ${kTokens(g.context)})`
627 g.waitPings = 0
628 return publish($, g)
629 }
630 g.waitPings += 1
631 g.waitAt = at
632 g.note = `ping ${g.waitPings} kept ${kTokens(cached)} warm`
633 await scheduleWait($, g)
634 await publish($, g)
635}
636
637async function toolStarts($: EngineInterface, g: Guard) {
638 g.toolsRunning += 1
639 if (g.toolsRunning > 1) return
640 const now = await $.clock.now()
641 g.toolSince = now
642 g.waitAt = g.requestAt ?? now
643 g.waitPings = 0
644 g.waitSpent = 0
645 await scheduleWait($, g)
646 await publish($, g)
647}
648
649async function toolEnds($: EngineInterface, g: Guard) {
650 g.toolsRunning = Math.max(0, g.toolsRunning - 1)
651 if (g.toolsRunning > 0) return
652 g.waitFire?.cancel()
653 g.waitFire = undefined
654 const now = await $.clock.now()
655 if (g.waitPings > 0) {
656 g.credit = { amount: costOf(g).lapse, isChecked: true }
657 } else if (g.waitAt !== undefined && g.context >= 5000) {
658 // A tool that outlasted 5 minutes unpinged tells the TTL by what the next request pays.
659 const gapMs = now - g.waitAt
660 if (gapMs > TTL_MS['5m'] + 30_000 && gapMs < TTL_MS['1h'] - 30_000) g.probe = { context: g.context }
661 }
662 g.toolSince = undefined
663 g.waitAt = undefined
664 await publish($, g)
665}
666
667/**
668 * A main-loop model request: its usage names the model and the context, its bill the TTL its cache writes had,
669 * and it settles what the mod spared.
670 */
671async function stepped($: EngineInterface, g: Guard, usage: TurnUsage | null, billed: number | null) {
672 if (!usage) return
673 g.model = usage.model
674 if (billed !== null) await measured($, g, ttlFromBill(usage.model, usage, billed))
675 if (g.probe) await learn($, g, ttlFromProbe(g.probe, usage), 'learned from a pause')
676 g.probe = undefined
677 await settle($, g, usage)
678 g.context = usage.input_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens
679}
680
681async function changeSettings($: EngineInterface, g: Guard, change: Partial<Settings>) {
682 Object.assign(g, settingsFrom(change, settingsOf(g)))
683 await $.store.set('settings', settingsOf(g))
684 await schedule($, g)
685 await publish($, g)
686}
687
688async function resetSettings($: EngineInterface, g: Guard, defaults: Settings) {
689 Object.assign(g, defaults)
690 await $.store.delete('settings')
691 await schedule($, g)
692 await publish($, g)
693}
694
695/** Turns the mod off or on for this session alone; the session's state keeps it across reloads. */
696async function setOff($: EngineInterface, g: Guard, isOff: boolean) {
697 g.isOff = isOff
698 if (isOff) {
699 stopTick(g)
700 unschedule(g)
701 $.ui.toast('cache-guard is off for this session; /cache-guard turns it back on')
702 } else {
703 if (g.lastReplyAt !== undefined && !g.isWorking) startTick($, g)
704 await schedule($, g)
705 }
706 await publish($, g)
707}
708
709async function nextProfile($: EngineInterface, g: Guard) {
710 const profile = PROFILES[(PROFILES.indexOf(g.profile) + 1) % PROFILES.length]
711 await changeSettings($, g, { profile })
712}
713
714async function openSettings($: EngineInterface) {
715 await $.ui.open({ id: SETTINGS_PANE, title: 'Cache guard', focus: true, closeOnEscape: true })
716}
717
718/** The view as drawn, over the defaults: one written by an older version lacks the newer fields. */
719async function current($: EngineInterface): Promise<GuardView> {
720 const v = await read($, view)
721
722 return {
723 ...INITIAL_VIEW,
724 ...v,
725 stats: { ...NO_STATS, ...v.stats },
726 session: { ...NO_STATS, ...v.session },
727 }
728}
729
730async function report($: EngineInterface, g: Guard): Promise<string> {
731 const v = await current($)
732 const ttlText =
733 g.ttlOption === 'auto' ? `auto (${g.learnedTtl ? `${g.learnedTtl}, ${g.ttlSource}` : 'not known yet'})` : g.ttlOption
734 const cost = cacheCost(g.model ?? null, g.context, ttlOf(g) ?? null)
735 const lines = [
736 `session: ${g.isOff ? 'off (this session only)' : 'on'}`,
737 `mode: ${MODE_LABELS[g.mode]} · TTL: ${ttlText} · pings before compact: ${pingLimitText(g.maxPings)} · ` +
738 `act ${g.marginMs / 1000}s before expiry · minimum ${kTokens(g.minTokens)}`,
739 `notification: ${g.notify ? `${durationText(g.notifyMs)} before the cache expires or is compacted` : 'off'}`,
740 `now: ${line({ ...v, now: await $.clock.now() })}`,
741 `cost at ${cost.name ?? 'an assumed'} API price: next reply ≈ ${usdText(cost.warm)} while warm, ≈ ${usdText(cost.cold)} after expiry`,
742 `compact profile: ${g.profile} — ${g.instructions[g.profile]}`,
743 `this session: ${statsLine(g.session)}`,
744 `total: ${statsLine(g.stats)}`,
745 ]
746
747 return lines.join('\n')
748}
749
750/** The panel's edits of the profiles' texts, kept in the store over the options'. */
751async function loadInstructions($: EngineInterface, g: Guard) {
752 const edits = ((await $.store.get('instructions')) ?? {}) as Partial<Record<GuardProfile, unknown>>
753 g.instructions = { ...g.optionInstructions }
754 for (const profile of PROFILES) g.instructions[profile] = text(edits[profile], g.optionInstructions[profile])
755}
756
757/** Saves a profile's text from the panel; an empty one, or the options' own, drops the edit. */
758async function setInstruction($: EngineInterface, g: Guard, profile: GuardProfile, value: string) {
759 const edits = { ...(((await $.store.get('instructions')) ?? {}) as Partial<Record<GuardProfile, string>>) }
760 const trimmed = value.trim()
761 if (!trimmed || trimmed === g.optionInstructions[profile]) delete edits[profile]
762 else edits[profile] = trimmed
763 await $.store.set('instructions', edits)
764 await loadInstructions($, g)
765 await publish($, g)
766}
767
768async function restore($: EngineInterface, g: Guard) {
769 // The desktop app names its session; its claude:// link opens the app at it.
770 const host = await $.env.get('CLAUDE_CODE_HOST_SESSION_ID')
771 g.link = host && /^local_[A-Za-z0-9-]{1,64}$/.test(host) ? `claude://code/continue?session=${host}&source=cache-guard` : undefined
772 Object.assign(g, settingsFrom((await $.store.get('settings')) ?? {}, settingsOf(g)))
773 await loadInstructions($, g)
774 g.stats = { ...NO_STATS, ...((await $.store.get('stats')) as Partial<GuardStats> | undefined) }
775
776 // A reload keeps the session's state but not this module's: pick the countdown up where it was.
777 const v = await current($)
778 g.isOff = v.isOff
779 g.model = v.model ?? undefined
780 g.session = { ...NO_STATS, ...v.session }
781 g.likelyTtl = v.likelyTtl ?? undefined
782 // The TTL is the session's own: what this session learned, else what Claude Code's configuration fixes.
783 g.learnedTtl = v.learnedTtl ?? undefined
784 g.ttlSource = v.ttlSource ?? undefined
785 const configured = g.learnedTtl ? undefined : await configuredTtl($)
786 if (configured) {
787 g.learnedTtl = configured.ttl
788 g.ttlSource = configured.source
789 }
790 if (v.lastReplyAt === null) return publish($, g)
791 g.context = v.context
792 g.note = v.note
793 g.pings = v.pings
794 if (v.isWorking) {
795 // The engine reloads a mod as a turn ends, so that turn's reply is the last one.
796 const now = await $.clock.now()
797 g.lastReplyAt = now
798 return arm($, g, now)
799 }
800 g.lastReplyAt = v.lastReplyAt
801 if (v.warmAt !== null) return arm($, g, v.warmAt)
802 startTick($, g)
803 await publish($, g)
804}
805
806async function turnStarts($: EngineInterface, g: Guard) {
807 g.probe = undefined
808 if (g.warmAt !== undefined && !g.hasActed) {
809 const gapMs = (await $.clock.now()) - g.warmAt
810 const isTelling = gapMs > TTL_MS['5m'] + 30_000 && gapMs < TTL_MS['1h'] - 30_000
811 if (isTelling && g.context >= 5000) g.probe = { context: g.context }
812 }
813 stopTick(g)
814 unschedule(g)
815 g.isWorking = true
816 g.warmAt = undefined
817 g.requestAt = undefined
818 g.note = ''
819 await publish($, g)
820}
821
822async function turnEnds($: EngineInterface, g: Guard, usage: TurnUsage | undefined) {
823 if (g.probe && usage) await learn($, g, ttlFromProbe(g.probe, usage), 'learned from a pause')
824 if (usage?.model) g.model = usage.model
825 if (usage) await settle($, g, usage)
826 g.probe = undefined
827 g.isWorking = false
828 g.toolsRunning = 0
829 g.toolSince = undefined
830 const measured = await $.session.usage()
831 g.context = measured.context.tokens ?? 0
832 // Usage limits are reported on a subscription alone, which caches for an hour until it runs past them.
833 const isWithinLimits = measured.rateLimits.length > 0 && measured.rateLimits.every(limit => limit.percentUsed < 100)
834 g.likelyTtl = isWithinLimits ? '1h' : '5m'
835 g.pings = 0
836 g.hasActed = false
837 g.note = ''
838 const now = await $.clock.now()
839 g.lastReplyAt = now
840 await arm($, g, Math.min(now, g.requestAt ?? now))
841}
842
843async function compacted($: EngineInterface, g: Guard) {
844 unschedule(g)
845 g.warmAt = undefined
846 await publish($, g)
847}
848
849export const register: Register = (on, options) => {
850 const defaults = settingsFrom(
851 {
852 mode: options.mode,
853 ttlOption: options.ttl,
854 profile: options.profile,
855 maxPings: options.maxPings,
856 marginMs: num(options.marginSeconds, 30) * 1000,
857 minTokens: options.minTokens,
858 toolPings: options.toolPings,
859 notify: options.notify,
860 notifyMs: num(options.notifySeconds, 60) * 1000,
861 },
862 {
863 mode: 'compact',
864 ttlOption: 'auto',
865 profile: 'code',
866 maxPings: 2,
867 marginMs: 30_000,
868 minTokens: 50_000,
869 toolPings: true,
870 notify: true,
871 notifyMs: 60_000,
872 },
873 )
874 const optionInstructions: Record<GuardProfile, string> = {
875 code: text(options.instructionsCode, INSTRUCTIONS.code),
876 debug: text(options.instructionsDebug, INSTRUCTIONS.debug),
877 plan: text(options.instructionsPlan, INSTRUCTIONS.plan),
878 }
879 const g: Guard = {
880 ...defaults,
881 instructions: { ...optionInstructions },
882 optionInstructions,
883 stats: { ...NO_STATS },
884 isWorking: false,
885 context: 0,
886 pings: 0,
887 isOff: false,
888 hasActed: false,
889 note: '',
890 session: { ...NO_STATS },
891 toolsRunning: 0,
892 waitPings: 0,
893 waitSpent: 0,
894 }
895
896 on('session.start', async ($, e, next) => {
897 // The status line entry of earlier versions, should one still show.
898 $.ui.status(undefined)
899 await restore($, g)
900 await $.command.register({
901 name: 'cache-guard',
902 description: 'Cache guard: open its settings and show the countdown and stats',
903 })
904
905 return next(e)
906 })
907
908 on('command.run', { command: 'cache-guard' }, async $ => {
909 await openSettings($)
910
911 return { text: await report($, g) }
912 })
913
914 on('turn.start', async ($, e, next) => {
915 await turnStarts($, g)
916
917 return next(e)
918 })
919
920 on('turn.complete', async ($, e, next) => {
921 const done = await next(e)
922 if (e.agentId === undefined) await turnEnds($, g, e.usage)
923
924 return done
925 })
926
927 on('turn.step', async function* ($, e, next) {
928 if (e.agentId !== undefined) return yield* next(e)
929 g.requestAt = await $.clock.now()
930 const before = (await $.session.usage()).cost?.usd
931 const response = yield* next(e)
932 const after = (await $.session.usage()).cost?.usd
933 await stepped($, g, response.usage, before === undefined || after === undefined ? null : after - before)
934
935 return response
936 })
937
938 on('tool.call', async ($, e, next) => {
939 if (e.agentId !== undefined) return next(e)
940 await toolStarts($, g)
941 try {
942 return await next(e)
943 } finally {
944 await toolEnds($, g)
945 }
946 }).catch(($, e, next) => next(e))
947
948 on('session.compact', async ($, e, next) => {
949 const done = await next(e)
950 if (e.trigger !== 'precompute' && done.skip === undefined) await compacted($, g)
951
952 return done
953 }).catch(($, e, next) => next(e))
954
955 on('classic.PreCompact', async ($, e, next) => {
956 // Newer engines pass the session's cache TTL here; older ones leave it out.
957 await learn($, g, asTtl((e as { cache_ttl?: unknown }).cache_ttl), 'reported by Claude Code')
958
959 return next(e)
960 }).catch(($, e, next) => next(e))
961
962 on('classic.PreModelSwitch', async ($, e, next) => {
963 await learn($, g, e.cache_ttl, 'reported by Claude Code')
964
965 return next(e)
966 }).catch(($, e, next) => next(e))
967
968 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
969 const v = await current($)
970 if (e.props.hasSurvey || v.isOff) return next(e)
971 const p = bandParts(v)
972 const elements = $.ui.resolve(e)
973 const { Box, Button, Text } = elements
974 // The terminal's table answers `Svg` with an empty stand-in, so ask the surface rather than the table.
975 const Svg = e.surface !== 'terminal' && 'Svg' in elements ? elements.Svg : undefined
976 const session = savingsText(v.session)
977 const total = savingsText(v.stats)
978 const saved = total ? `${session ?? 'saved $0'} this session · ${total} in all` : null
979
980 return (
981 <Box flexDirection="row" flexWrap="wrap" alignItems="center" columnGap={2} rowGap={1} paddingX={1}>
982 <Box flexDirection="row" alignItems="center" columnGap={1} flexGrow={1} flexShrink={1}>
983 {Svg ? (
984 <Svg source={ringSvg(p)} alt={p.title} width={18} height={18} isInteractive={p.isSpinning ? true : undefined} />
985 ) : (
986 <Text color={p.tone}>{p.isSpinning ? '◌' : '●'}</Text>
987 )}
988 <Box flexDirection="column" flexShrink={1}>
989 <Box flexDirection="row" columnGap={2}>
990 <Text bold color={p.tone}>
991 {p.title}
992 </Text>
993 {saved ? <Text color={saved.startsWith('net') ? 'warning' : 'success'}>{saved}</Text> : null}
994 </Box>
995 <Text dimColor wrap="truncate-end">
996 {p.details.join(' · ')}
997 </Text>
998 </Box>
999 </Box>
1000 <Box flexDirection="row" flexWrap="wrap" alignItems="center" columnGap={1} rowGap={1}>
1001 {v.ttl === null
1002 ? (['5m', '1h'] as const).map(ttlOption => (
1003 <Button
1004 key={`ttl-${ttlOption}`}
1005 label={`TTL ${TTL_LABELS[ttlOption]}`}
1006 variant={ttlOption === (v.likelyTtl ?? '5m') ? 'primary' : 'secondary'}
1007 onPress={() => changeSettings($, g, { ttlOption })}
1008 />
1009 ))
1010 : null}
1011 {MODES.map(mode => (
1012 <Button
1013 key={`mode-${mode}`}
1014 label={MODE_LABELS[mode]}
1015 variant={mode === v.mode ? 'primary' : 'secondary'}
1016 onPress={() => changeSettings($, g, { mode })}
1017 />
1018 ))}
1019 <Button key="profile" label={`Profile: ${PROFILE_LABELS[v.profile]}`} onPress={() => nextProfile($, g)} />
1020 <Button key="settings" label="⚙ Settings" onPress={() => openSettings($)} />
1021 <Button key="off" label="Off" dimColor onPress={() => setOff($, g, true)} />
1022 </Box>
1023 </Box>
1024 )
1025 })
1026
1027 on('ui.render', { component: 'Pane', requestId: SETTINGS_PANE }, async ($, e) => {
1028 const v = await current($)
1029 const elements = $.ui.resolve(e)
1030 const { Box, Button, Text } = elements
1031 // Every surface but mobile draws a field.
1032 const Input = 'Input' in elements ? elements.Input : undefined
1033 const isEdited = g.instructions[v.profile] !== g.optionInstructions[v.profile]
1034 const learned = v.ttlOption === 'auto' ? (v.ttl ? `${v.ttl}, ${v.ttlSource ?? 'learned'}` : 'not learned yet') : undefined
1035 const cost = cacheCost(v.model, v.context, v.ttl)
1036 const rates = cost.rates
1037 // Unlimited pings keep an idle session warm for good: say what an hour of that costs against one re-cache.
1038 const pingsPerHour = 3_600_000 / Math.max(60_000, TTL_MS[v.ttl ?? '5m'] - v.marginMs)
1039 const perHour = pingsPerHour * cost.warm
1040 const unlimitedHint =
1041 v.context > 0
1042 ? `∞ while idle: ≈ ${usdText(perHour)} an hour at ${kTokens(v.context)}; past ` +
1043 `${(cost.lapse / perHour).toFixed(1)} h that is more than one re-cache (${usdText(cost.cold)}). ` +
1044 'Ping → Compact never compacts.'
1045 : '∞ while idle: the hourly cost shows after the first reply. Ping → Compact never compacts.'
1046 // Each row wraps its controls under its label when the panel is narrow.
1047 const row = { flexDirection: 'row', flexWrap: 'wrap', justifyContent: 'space-between', alignItems: 'center', columnGap: 2, rowGap: 1 } as const
1048 const stepRow = (s: Stepper) => (
1049 <Box {...row}>
1050 <Box flexDirection="column" flexShrink={1}>
1051 <Text>{s.label}</Text>
1052 <Text dimColor>{s.hint}</Text>
1053 {s.key === 'maxPings' && v.maxPings >= UNLIMITED_PINGS ? <Text color="warning">{unlimitedHint}</Text> : null}
1054 </Box>
1055 <Box flexDirection="row" alignItems="center" columnGap={1}>
1056 <Button
1057 key={`dec-${s.key}`}
1058 label="−"
1059 onPress={() => changeSettings($, g, s.set(Math.max(s.min, s.get(v) - s.step)))}
1060 />
1061 <Text bold>{s.show(s.get(v))}</Text>
1062 <Button
1063 key={`inc-${s.key}`}
1064 label="+"
1065 onPress={() => changeSettings($, g, s.set(Math.min(s.max, s.get(v) + s.step)))}
1066 />
1067 </Box>
1068 </Box>
1069 )
1070
1071 return (
1072 <Box flexDirection="column" paddingX={1} rowGap={1}>
1073 <Text bold>Session</Text>
1074 <Box {...row}>
1075 <Box flexDirection="column" flexShrink={1}>
1076 <Text>{v.isOff ? 'Off in this session' : 'On in this session'}</Text>
1077 <Text dimColor>Turning it off hides the band and stops every ping and compact until this session ends.</Text>
1078 </Box>
1079 <Button
1080 key="toggle-off"
1081 label={v.isOff ? 'Turn on' : 'Turn off for this session'}
1082 variant={v.isOff ? 'primary' : 'secondary'}
1083 onPress={() => setOff($, g, !v.isOff)}
1084 />
1085 </Box>
1086
1087 <Text bold>Behaviour</Text>
1088 <Box {...row}>
1089 <Box flexDirection="column" flexShrink={1}>
1090 <Text>Mode</Text>
1091 <Text dimColor>{MODE_HINTS[v.mode]}</Text>
1092 </Box>
1093 <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
1094 {MODES.map(mode => (
1095 <Button
1096 key={`set-mode-${mode}`}
1097 label={MODE_LABELS[mode]}
1098 variant={mode === v.mode ? 'primary' : 'secondary'}
1099 onPress={() => changeSettings($, g, { mode })}
1100 />
1101 ))}
1102 </Box>
1103 </Box>
1104
1105 <Box {...row}>
1106 <Box flexDirection="column" flexShrink={1}>
1107 <Text>Cache TTL</Text>
1108 <Text dimColor>
1109 {learned ? `Auto: ${learned}. The mod acts only once the TTL is known.` : 'Set by hand.'}
1110 </Text>
1111 </Box>
1112 <Box flexDirection="row" columnGap={1}>
1113 {TTLS.map(ttlOption => (
1114 <Button
1115 key={`set-ttl-${ttlOption}`}
1116 label={TTL_LABELS[ttlOption]}
1117 variant={ttlOption === v.ttlOption ? 'primary' : 'secondary'}
1118 onPress={() => changeSettings($, g, { ttlOption })}
1119 />
1120 ))}
1121 </Box>
1122 </Box>
1123
1124 <Box {...row}>
1125 <Box flexDirection="column" flexShrink={1}>
1126 <Text>Keep warm during long tools</Text>
1127 <Text dimColor>
1128 Pings while a tool (tests, a build) outlasts the cache, as long as that stays cheaper than re-caching. Off in Watch.
1129 </Text>
1130 </Box>
1131 <Box flexDirection="row" columnGap={1}>
1132 <Button
1133 key="set-toolPings-on"
1134 label="On"
1135 variant={v.toolPings ? 'primary' : 'secondary'}
1136 onPress={() => changeSettings($, g, { toolPings: true })}
1137 />
1138 <Button
1139 key="set-toolPings-off"
1140 label="Off"
1141 variant={v.toolPings ? 'secondary' : 'primary'}
1142 onPress={() => changeSettings($, g, { toolPings: false })}
1143 />
1144 </Box>
1145 </Box>
1146
1147 {STEPPERS.map(stepRow)}
1148
1149 <Text bold>Notification</Text>
1150 <Box {...row}>
1151 <Box flexDirection="column" flexShrink={1}>
1152 <Text>Notify before expiry</Text>
1153 <Text dimColor>
1154 A system notification before the cache lapses, or before the compaction, so you can reply in time. None while
1155 pings keep the cache warm. Without a terminal that notifies (the desktop app) it is the system's own notification.
1156 </Text>
1157 </Box>
1158 <Box flexDirection="row" columnGap={1}>
1159 <Button
1160 key="set-notify-on"
1161 label="On"
1162 variant={v.notify ? 'primary' : 'secondary'}
1163 onPress={() => changeSettings($, g, { notify: true })}
1164 />
1165 <Button
1166 key="set-notify-off"
1167 label="Off"
1168 variant={v.notify ? 'secondary' : 'primary'}
1169 onPress={() => changeSettings($, g, { notify: false })}
1170 />
1171 </Box>
1172 </Box>
1173 {v.notify ? stepRow(NOTIFY_STEPPER) : null}
1174 <Box {...row}>
1175 <Text dimColor>
1176 {g.link
1177 ? 'On Windows a click on the notification brings you back to this session.'
1178 : 'Check that notifications reach you.'}
1179 </Text>
1180 <Button key="notify-test" label="Send a test" onPress={() => testNotify($, g)} />
1181 </Box>
1182
1183 <Text bold>Compaction</Text>
1184 <Box {...row}>
1185 <Box flexDirection="column" flexShrink={1}>
1186 <Text>Instruction profile</Text>
1187 <Text dimColor>What a compaction is told to keep. The band's Profile button switches it too.</Text>
1188 </Box>
1189 <Box flexDirection="row" columnGap={1}>
1190 {PROFILES.map(profile => (
1191 <Button
1192 key={`set-profile-${profile}`}
1193 label={PROFILE_LABELS[profile]}
1194 variant={profile === v.profile ? 'primary' : 'secondary'}
1195 onPress={() => changeSettings($, g, { profile })}
1196 />
1197 ))}
1198 </Box>
1199 </Box>
1200 <Box flexDirection="column" rowGap={1}>hooks/view.ts 372 lines1import type { GuardStats, GuardView, Ttl } from '../types'
2
3export const TTL_MS: Record<Ttl, number> = { '5m': 5 * 60_000, '1h': 60 * 60_000 }
4
5export const NO_STATS: GuardStats = {
6 compactions: 0,
7 tokensBefore: 0,
8 tokensAfter: 0,
9 pings: 0,
10 pingMisses: 0,
11 avoidedUsd: 0,
12 spentUsd: 0,
13 saves: 0,
14}
15
16/** Where the band starts before the session's first refresh. */
17export const INITIAL_VIEW: GuardView = {
18 lastReplyAt: null,
19 warmAt: null,
20 now: 0,
21 isWorking: false,
22 context: 0,
23 ttl: null,
24 mode: 'compact',
25 ttlOption: 'auto',
26 profile: 'code',
27 minTokens: 50_000,
28 marginMs: 30_000,
29 maxPings: 2,
30 pings: 0,
31 stats: NO_STATS,
32 session: NO_STATS,
33 toolSince: null,
34 toolPings: true,
35 notify: true,
36 notifyMs: 60_000,
37 learnedTtl: null,
38 ttlSource: null,
39 likelyTtl: null,
40 model: null,
41 isOff: false,
42 nextAction: null,
43 note: '',
44}
45
46const MINUTE = 60_000
47
48/** A ping limit at or past this pings without end; the store and the options keep it as a plain number. */
49export const UNLIMITED_PINGS = 11
50
51export const pingLimitText = (n: number): string => (n >= UNLIMITED_PINGS ? '∞' : String(n))
52
53export const kTokens = (n: number): string => `${Math.round(n / 1000)}k`
54
55/** A countdown: minutes while far, m:ss in the last ten minutes. */
56export const clockText = (ms: number): string => {
57 if (ms > 10 * MINUTE) return `${Math.ceil(ms / MINUTE)} min`
58 const s = Math.max(0, Math.ceil(ms / 1000))
59 return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
60}
61
62/** A span in words: `45 s`, `1 min`, `2 min 30 s`. */
63export const durationText = (ms: number): string => {
64 const s = Math.max(0, Math.round(ms / 1000))
65 const m = Math.floor(s / 60)
66 if (m === 0) return `${s} s`
67 return s % 60 === 0 ? `${m} min` : `${m} min ${s % 60} s`
68}
69
70/** Time gone by: m:ss under an hour, then hours and minutes. */
71export const elapsedText = (ms: number): string => {
72 const s = Math.max(0, Math.floor(ms / 1000))
73 if (s < 3600) return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
74 return `${Math.floor(s / 3600)}h ${String(Math.floor((s % 3600) / 60)).padStart(2, '0')}m`
75}
76
77/** Rates in US dollars per million tokens. */
78export type Rates = { input: number; output: number; write5m: number; write1h: number; read: number }
79
80type Tier = Rates & { long?: Rates & { above: number } }
81
82/** The engine's baked-in model catalog's list prices (Claude Code 2.1.295), per pricing tier. */
83const TIERS = {
84 t2: { input: 2, output: 10, write5m: 2.5, write1h: 4, read: 0.2 },
85 t3: { input: 3, output: 15, write5m: 3.75, write1h: 6, read: 0.3 },
86 t4: { input: 4, output: 20, write5m: 5, write1h: 8, read: 0.2 },
87 t5: { input: 5, output: 25, write5m: 6.25, write1h: 10, read: 0.5 },
88 t10: { input: 10, output: 50, write5m: 12.5, write1h: 20, read: 1 },
89 t10r: { input: 10, output: 50, write5m: 12.5, write1h: 20, read: 0.25 },
90 t15: { input: 15, output: 75, write5m: 18.75, write1h: 30, read: 1.5 },
91 haiku35: { input: 0.8, output: 4, write5m: 1, write1h: 1.6, read: 0.08 },
92 haiku45: { input: 1, output: 5, write5m: 1.25, write1h: 2, read: 0.1 },
93 haiku55: {
94 input: 0.1,
95 output: 0.5,
96 write5m: 0.125,
97 write1h: 0.2,
98 read: 0.01,
99 long: { above: 100_000, input: 0.5, output: 2.5, write5m: 0.625, write1h: 1, read: 0.05 },
100 },
101} satisfies Record<string, Tier>
102
103/** Model ids, matched as the longest one the model's id contains, with their name and tier. */
104const MODELS: [string, string, keyof typeof TIERS][] = [
105 ['claude-3-5-haiku', 'Haiku 3.5', 'haiku35'],
106 ['claude-haiku-4-5', 'Haiku 4.5', 'haiku45'],
107 ['claude-haiku-5-5', 'Haiku 5.5', 'haiku55'],
108 ['claude-3-5-sonnet', 'Sonnet 3.5', 't3'],
109 ['claude-3-7-sonnet', 'Sonnet 3.7', 't3'],
110 ['claude-sonnet-4', 'Sonnet 4', 't3'],
111 ['claude-sonnet-4-5', 'Sonnet 4.5', 't3'],
112 ['claude-sonnet-4-6', 'Sonnet 4.6', 't3'],
113 ['claude-sonnet-5', 'Sonnet 5', 't2'],
114 ['claude-sonnet-5-5', 'Sonnet 5.5', 't2'],
115 ['claude-opus-4', 'Opus 4', 't15'],
116 ['claude-opus-4-1', 'Opus 4.1', 't15'],
117 ['claude-opus-4-5', 'Opus 4.5', 't5'],
118 ['claude-opus-4-6', 'Opus 4.6', 't5'],
119 ['claude-opus-4-7', 'Opus 4.7', 't5'],
120 ['claude-opus-4-8', 'Opus 4.8', 't5'],
121 ['claude-opus-5', 'Opus 5', 't5'],
122 ['claude-opus-5-5', 'Opus 5.5', 't4'],
123 ['claude-fable-5', 'Fable 5', 't10'],
124 ['claude-fable-5-1', 'Fable 5.1', 't10r'],
125 ['claude-mythos-5', 'Mythos 5', 't10'],
126 ['claude-mythos-5-1', 'Mythos 5.1', 't10r'],
127]
128
129/** What re-caching or reading the context costs, at the model's list price. */
130export type CacheCost = {
131 /** The model's name, or null when the price is the default tier's guess. */
132 name: string | null
133 rates: Rates
134 /** The next reply on a warm cache: the context read. */
135 warm: number
136 /** The next reply after expiry: the context written again at the TTL's rate. */
137 cold: number
138 /** What a reply on a warm cache saves over one after expiry. */
139 lapse: number
140}
141
142export const cacheCost = (model: string | null, context: number, ttl: Ttl | null): CacheCost => {
143 const id = (model ?? '').toLowerCase()
144 const found = MODELS.filter(([key]) => id.includes(key)).sort((a, b) => b[0].length - a[0].length)[0]
145 const tier: Tier = found ? TIERS[found[2]] : TIERS.t5
146 const rates: Rates = tier.long && context > tier.long.above ? tier.long : tier
147 const write = ttl === '1h' ? rates.write1h : rates.write5m
148
149 const warm = (context * rates.read) / 1e6
150 const cold = (context * write) / 1e6
151
152 return { name: found?.[1] ?? null, rates, warm, cold, lapse: cold - warm }
153}
154
155/** What one model call cost, from its usage, at the rates `cacheCost` found. */
156export const callCost = (
157 usage: { input_tokens: number; output_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number },
158 cost: CacheCost,
159 ttl: Ttl | null,
160): number => {
161 const r = cost.rates
162 const write = ttl === '1h' ? r.write1h : r.write5m
163
164 return (
165 (usage.input_tokens * r.input +
166 usage.output_tokens * r.output +
167 usage.cache_read_input_tokens * r.read +
168 usage.cache_creation_input_tokens * write) /
169 1e6
170 )
171}
172
173/**
174 * The TTL a request's cache writes were billed at, read from how far it moved the session's cost: a 1-hour write
175 * costs 1.6× a 5-minute one. Null when the request wrote too little to tell, the model's price is not known, or
176 * the amount fits neither (a fast-mode surcharge, another request billed meanwhile). A 10% regional surcharge fits.
177 */
178export const ttlFromBill = (
179 model: string | null,
180 usage: { input_tokens: number; output_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number },
181 billed: number,
182): Ttl | null => {
183 if (usage.cache_creation_input_tokens < 500 || billed <= 0) return null
184 const prompt = usage.input_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens
185 const cost = cacheCost(model, prompt, null)
186 if (cost.name === null) return null
187 const short = callCost(usage, cost, '5m')
188 const long = callCost(usage, cost, '1h')
189 for (const surcharge of [1, 1.1]) {
190 const slack = 0.25 * surcharge * (long - short)
191 if (Math.abs(billed - surcharge * short) < slack) return '5m'
192 if (Math.abs(billed - surcharge * long) < slack) return '1h'
193 }
194
195 return null
196}
197
198/** What a ping's transcript line reports. */
199export type PingReport = {
200 label: string
201 isWarm: boolean
202 /** Why no reply came, when none did. */
203 reason?: string
204 cached: number
205 context: number
206 written: number
207 /** The price the usage comes to at list rates. */
208 spent: number
209 /** How far the session's cost ledger moved; null where the host keeps none. */
210 billed: number | null
211}
212
213/** The cost part of a line: what the ledger billed, else the estimate, flagged when the ledger did not move. */
214const priceText = (spent: number, billed: number | null): string => {
215 if (billed === null) return `≈ ${usdText(spent)}`
216 if (billed > 0) return `${usdText(billed)} billed`
217
218 return `≈ ${usdText(spent)}, not in the session cost`
219}
220
221/** A ping's line in the transcript: ✓ or ✗, what it read and wrote, and what it cost. */
222export const pingLine = (p: PingReport): string => {
223 const price = priceText(p.spent, p.billed)
224 if (p.reason !== undefined) {
225 return `✗ ${p.label} · no reply (${p.reason})` + (p.spent > 0 || (p.billed ?? 0) > 0 ? ` · ${price}` : '')
226 }
227 if (p.isWarm) return `✓ ${p.label} · cache kept warm · read ${kTokens(p.cached)} of ${kTokens(p.context)} · ${price}`
228
229 return `✗ ${p.label} · missed the cache · read ${kTokens(p.cached)} of ${kTokens(p.context)}, wrote ${kTokens(p.written)} · ${price}`
230}
231
232/** The mod's net effect: what it spared minus what it spent; null while it has done nothing. */
233export const savingsText = (s: GuardStats): string | null => {
234 if (s.avoidedUsd === 0 && s.spentUsd === 0) return null
235 const net = s.avoidedUsd - s.spentUsd
236
237 return net >= 0 ? `saved ${usdText(net)}` : `net −${usdText(-net)}`
238}
239
240export const usdText = (n: number): string => (n === 0 ? '$0' : n < 0.01 ? '<$0.01' : `$${n.toFixed(n < 10 ? 2 : 1)}`)
241
242/** A theme color the band paints its state in. */
243export type Tone = 'success' | 'warning' | 'error' | 'subtle' | 'claude'
244
245/** What the band shows: a ring, a title in the state's color, and a quiet line of details. */
246export type BandParts = {
247 tone: Tone
248 title: string
249 details: string[]
250 /** How much of the TTL is left, 0 to 1; null draws an empty ring. */
251 share: number | null
252 isSpinning: boolean
253}
254
255const present = (...parts: (string | undefined)[]): string[] => parts.filter((part): part is string => Boolean(part))
256
257export const bandParts = (v: GuardView): BandParts => {
258 const context = v.context > 0 ? `${kTokens(v.context)} context` : undefined
259 const note = v.note || undefined
260
261 if (v.isOff) {
262 return { tone: 'subtle', title: 'Cache guard off', details: ['this session only'], share: null, isSpinning: false }
263 }
264 if (v.isWorking) {
265 const tool = v.toolSince !== null ? `tool running ${elapsedText(v.now - v.toolSince)}` : undefined
266 return {
267 tone: 'claude',
268 title: 'Claude is working',
269 details: present(context, tool, note ?? 'timer resumes after the reply'),
270 share: null,
271 isSpinning: true,
272 }
273 }
274 if (v.lastReplyAt === null) {
275 return { tone: 'subtle', title: 'Cache guard', details: ['starts after the next reply'], share: null, isSpinning: false }
276 }
277
278 const since = `${elapsedText(v.now - v.lastReplyAt)} since reply`
279 const resume = v.context > 0 ? `resume ≈ ${usdText(cacheCost(v.model, v.context, v.ttl).cold)} at API price` : undefined
280 if (v.warmAt === null) {
281 return { tone: 'subtle', title: 'Cache cold', details: present(note, resume, since, context), share: null, isSpinning: false }
282 }
283
284 if (v.ttl === null) {
285 const hint = v.likelyTtl === '1h' ? 'a subscription usually has 1 hour' : v.likelyTtl === '5m' ? 'an API key or a subscription past its limits has 5 min' : undefined
286 return {
287 tone: 'warning',
288 title: 'Pick the cache TTL',
289 details: present("cache-guard can't time anything until it knows", hint, since, context),
290 share: null,
291 isSpinning: false,
292 }
293 }
294
295 const ttlMs = TTL_MS[v.ttl]
296 const left = v.warmAt + ttlMs - v.now
297 if (left <= 0) {
298 return { tone: 'error', title: 'Cache expired', details: present(resume, note, since, context), share: 0, isSpinning: false }
299 }
300
301 const isBelow = v.mode !== 'watch' && v.context < v.minTokens
302 let plan: string | undefined
303 if (isBelow) plan = `below ${kTokens(v.minTokens)}, no action`
304 else if (v.nextAction === 'ping') plan = `ping ${v.pings + 1}/${pingLimitText(v.maxPings)} in ${clockText(left - v.marginMs)}`
305 else if (v.nextAction === 'compact') plan = `compact in ${clockText(left - v.marginMs)}`
306 const details = present(`${v.ttl} cache`, plan, note, since, context)
307
308 if (v.nextAction === 'ping' && !isBelow) {
309 if (v.maxPings >= UNLIMITED_PINGS) {
310 return { tone: 'success', title: 'Kept warm', details, share: left / ttlMs, isSpinning: false }
311 }
312 // The pings still to come each renew the cache: it lapses a TTL after the last one, sent a margin early.
313 const pingsLeft = v.maxPings - v.pings
314 const untilLast = left - v.marginMs + (pingsLeft - 1) * (ttlMs - v.marginMs)
315 const total = untilLast + ttlMs
316 const share = total / (total + v.now - v.lastReplyAt)
317 const tone: Tone = share < 0.25 ? 'warning' : 'success'
318
319 return { tone, title: `${clockText(total)} left`, details, share, isSpinning: false }
320 }
321
322 const share = left / ttlMs
323 const tone: Tone = left < MINUTE ? 'error' : share < 0.25 ? 'warning' : 'success'
324
325 return { tone, title: `${clockText(left)} left`, details, share, isSpinning: false }
326}
327
328/** The band as one line of text, for `/cache-guard` and a reader without color. */
329export const line = (v: GuardView): string => {
330 const p = bandParts(v)
331 return [p.title, ...p.details].join(' · ')
332}
333
334/** The stats as one line. */
335export const statsLine = (s: GuardStats): string =>
336 `${s.compactions} compactions (−${kTokens(s.tokensBefore - s.tokensAfter)} context) · ` +
337 `${s.pings} pings (${s.pingMisses} missed the cache) · ${s.saves} lapses avoided · ` +
338 `${usdText(s.avoidedUsd)} spared − ${usdText(s.spentUsd)} spent = ${savingsText(s) ?? 'nothing yet'}`
339
340const TONE_HEX: Record<Tone, string> = {
341 success: '#3fb950',
342 warning: '#d29922',
343 error: '#f85149',
344 subtle: '#8b949e',
345 claude: '#d97757',
346}
347
348const RING_STEPS = 40
349
350/** A small progress ring: the share of the TTL left, or a spinner while Claude works. */
351export const ringSvg = (p: BandParts): string => {
352 const color = TONE_HEX[p.tone]
353 const length = 2 * Math.PI * 7
354 const track = `<circle cx="9" cy="9" r="7" fill="none" stroke="${color}" stroke-opacity="0.22" stroke-width="2.5"/>`
355 let arc = ''
356 if (p.isSpinning) {
357 arc =
358 `<circle cx="9" cy="9" r="7" fill="none" stroke="${color}" stroke-width="2.5" stroke-linecap="round" ` +
359 `stroke-dasharray="${(length * 0.28).toFixed(2)} ${length.toFixed(2)}">` +
360 '<animateTransform attributeName="transform" type="rotate" from="0 9 9" to="360 9 9" dur="0.9s" repeatCount="indefinite"/>' +
361 '</circle>'
362 } else if (p.share !== null && p.share > 0) {
363 // Stepped, so the image changes now and then rather than every second.
364 const shown = Math.max(1, Math.round(p.share * RING_STEPS)) / RING_STEPS
365 arc =
366 `<circle cx="9" cy="9" r="7" fill="none" stroke="${color}" stroke-width="2.5" stroke-linecap="round" ` +
367 `stroke-dasharray="${(length * shown).toFixed(2)} ${length.toFixed(2)}" transform="rotate(-90 9 9)"/>`
368 }
369
370 return `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18">${track}${arc}</svg>`
371}
372types/index.d.ts 73 lines1export type GuardMode = 'watch' | 'ping' | 'compact' | 'ping+compact'
2export type GuardTtl = 'auto' | '5m' | '1h'
3export type Ttl = '5m' | '1h'
4export type GuardProfile = 'code' | 'debug' | 'plan'
5
6/** What the mod has done, kept across sessions. */
7export type GuardStats = {
8 compactions: number
9 tokensBefore: number
10 tokensAfter: number
11 pings: number
12 pingMisses: number
13 /** Re-caching the mod spared, in US dollars at list price: credited only once a reply found the cache it kept. */
14 avoidedUsd: number
15 /** What the mod's own pings and compactions cost, in US dollars at list price. */
16 spentUsd: number
17 /** Replies that found a cache the mod kept warm or made small. */
18 saves: number
19}
20
21/** What the band above the prompt draws, refreshed every second while the session waits. */
22export type GuardView = {
23 /** Clock time the last answer ended; null before the first one. */
24 lastReplyAt: number | null
25 /** Clock time the cache was last touched; null while nothing warm is guarded. */
26 warmAt: number | null
27 /** Clock time of the last refresh. */
28 now: number
29 isWorking: boolean
30 context: number
31 /** The TTL in force: the option, or the learned one under `auto`; null while unknown. */
32 ttl: Ttl | null
33 mode: GuardMode
34 ttlOption: GuardTtl
35 /** Which instructions a compaction is given. */
36 profile: GuardProfile
37 minTokens: number
38 marginMs: number
39 /** Pings allowed in one pause before `ping+compact` compacts (`ping` stops there). */
40 maxPings: number
41 /** Pings made in this pause. */
42 pings: number
43 stats: GuardStats
44 /** The same stats for this session alone. */
45 session: GuardStats
46 /** Clock time the main loop's running tool started; null while none runs. */
47 toolSince: number | null
48 /** Whether the cache is kept warm while a long tool runs. */
49 toolPings: boolean
50 /** Whether a system notification comes shortly before the cache lapses or is compacted. */
51 notify: boolean
52 /** How long before that the notification comes. */
53 notifyMs: number
54 /** The TTL learned for this session under `auto`, and how: measured, configured, reported or from a pause. */
55 learnedTtl: Ttl | null
56 ttlSource: string | null
57 /** The TTL this session most likely has, as a hint while it is unknown. */
58 likelyTtl: Ttl | null
59 /** The main loop's model as the last reply named it; prices the cache. */
60 model: string | null
61 /** Turned off for this session from the band or the settings panel. */
62 isOff: boolean
63 /** What the mod does when the countdown reaches the margin. */
64 nextAction: 'ping' | 'compact' | null
65 note: string
66}
67
68declare module 'claude-code' {
69 interface PluginState {
70 'cache-guard': { view: GuardView }
71 }
72}
73