SLOPSHOPPER

cache-guard

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…

newpanebandguardcommandtoast
v0.9.0MITupdated 2026-10-15kamilbakierzynski/cache-guard
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cache-guard
│ ┃ Cache guard ✕ › fix the failing auth test and add an audit log call │ ┃ Session │ ┃ ⏺ Read(src/auth.ts) │ ┃ On in this session ⎿ Read 6 lines │ ┃ Turning it off hides the band and stops ⏺ Update(src/auth.ts) │ ┃ every ping and compact until this session ⎿ Added 2 lines, removed 1 line │ ┃ ends. ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ Behaviour │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ Mode │ ┃ Compact the conversation just before the ✻ Worked for 42s · done 4:20 PM │ ┃ cache expires. │ ┃ › /cache-guard │ ┃ Cache TTL ⎿ cache-guard: session: on │ ┃ Auto: not learned yet. The mod acts only ⎿ cache-guard: mode: Compact · TTL: auto (not known yet) · pings b │ ┃ once the TTL is known. ⎿ cache-guard: notification: 1 min before the cache expires or is │ ┃ ⎿ cache-guard: now: Pick the cache TTL · cache-guard can't time an │ ┃ Keep warm during long tools ⎿ cache-guard: cost at Opus 5.5 API price: next reply ≈ $0.02 whil │ ┃ Pings while a tool (tests, a build) ⎿ cache-guard: compact profile: code — Keep it tight: pointers ove │ ┃ outlasts the cache, as long as that stays │ ┃ cheaper than re-caching. Off in Watch. │ ┃ │ ┃ Pings before compact │ ┃ Keep-alive pings in one pause. Ping → │ ┃ Compact compacts after the last one; Ping ● Pick the cache TTL cache-guard can't time anything until it knows · a subscription usually has 1 hour · 0:00 since reply · 97k ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
● Pick the cache TTL cache-guard can't time anything until it knows · a subscription usually has 1 hour · 0:00 sin
Pane · Cache guard
Session On in this session [ Turning it off hides the band and stops every ping and compact until this session ends. Behaviour Mode [ Compact the conversation just before the cache expires. Cache TTL Auto: not learned yet. The mod acts only once the TTL is known. Keep warm during long tools Pings while a tool (tests, a build) outlasts the cache, as long as that stays cheaper than re-caching. Off in Watch. Pings before compact [ Keep-alive pings in one pause. Ping → Compact compacts after the last one; Ping just stops. Past 10 is ∞. Act before expiry [ − How long before the cache expires the ping or compact runs. Minimum context [ − ] Below this the mod only watches: compacting a small context saves little. Notification Notify before expiry A system notification before the cache lapses, or before the compaction, so you can reply in time. None while pings keep the cache warm. Without a terminal that notifies (the desktop app) it is the system's own notification. Notify how early [ − ] 1 How long before the cache expires, or before the compaction, the notification comes. Check that notifications reach you. [ Send a test ] Compaction Instruction profile [ C What a compaction is told to keep. The band's Profile button switches it too. Code prompt: Keep it tight: pointers over content. Preserve Default text: edit it above and press Enter to save. Cost Next reply ≈ $0.02 while the cache is warm, ≈ $0.49 once it has expired. Opus 5.5 list price per million tokens: cache write $5 (5 min) / $8 (1 hour), cache read $0.2. Only the context is counted, not the reply. Savings This session: nothing yet 0 compactions (−0k context) · 0 pings (0 missed the cache) · 0 lapses avoided · $0 spared − $0 spent = nothing yet All sessions: nothing yet 0 compactions (−0k context) · 0 pings (0 missed the cache) · 0 lapses avoided · $0 spared − $0 spent = nothing yet Spared re-caching is counted only once a reply found the cache the mod kept warm, or the smaller context it left; every ping and compaction is counted as spent. [ Reset to the options' defaults ] [ Close ]
README

cache-guard

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.

The cache-guard band above the prompt

Why

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 price100k context250k 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.

Install

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
  1. Answer y to add the marketplace.
  2. Pick a scope. User makes it work in every project.
  3. Set the options, or keep the defaults (all of them can be changed later).

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

What you see

The band above the prompt

  • Title. It shows how long the cache stays warm.
  • With pings planned, it counts down to the end of the last one: 28 min left with 5 pings on a 5-minute cache.
  • With unlimited pings it reads Kept warm.
  • Colour. Green while there is time, yellow under a quarter of it, red in the last minute.
  • Savings. Shown beside the title: 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.
  • Details line. The cache's TTL, the next action (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.
  • While Claude works. The band reads Claude is working. During a long tool it adds tool running 6:12 and the tool-wait pings.
  • After expiry. Cache expired · resume ≈ $1.25 at API price tells you what your next message will cost to re-cache.
  • Buttons.
  • Watch / Ping / Compact / Ping → Compact switch the mode.
  • Profile cycles the compaction prompt (Code → Debug → Plan).
  • ⚙ Settings opens the panel.
  • Off turns the mod off for this session.
  • Unknown TTL. The band asks Pick the cache TTL and offers 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).

Lines in the conversation

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 notification before the cache expires

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:

  • Watch, and Ping after its last ping: Prompt cache expires in 1 min, with what the next reply will cost once it has: ≈ $1.25 instead of $0.05.
  • Compact and Ping → Compact: Compacting in 1 min, so you can come back while the full context is still there.
  • No notification: while pings will keep the cache warm, below Minimum context, while Claude is working, or while the TTL is unknown. A new message cancels it.

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.

The settings panel

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.

  • Session. Turn the mod off (or back on) for this session only. While off, the band is hidden and nothing is pinged or compacted.
  • Mode, cache TTL, keep warm during long tools.
  • Pings before compact. 0–10, then ∞. At ∞ the panel says what an idle hour of pinging costs at your context and after how long that exceeds one re-cache.
  • Act before expiry. 10–180 s.
  • Minimum context. 10k–500k.
  • Notification. Turn the notification before expiry on or off, set how early it comes (30 s – 10 min), and send a test.
  • Compaction. Pick the instruction profile and edit its prompt in place: type, then Enter to save. Restore default or an empty field brings back the default text. Edits are kept across sessions.
  • Cost. The price of your next reply on a warm and on an expired cache, at your model's list price.
  • Savings. This session and all sessions: lapses avoided, re-caching spared, money spent on pings and compactions, and the net.
  • Reset to the options' defaults.

Changes made on the band or in the panel are remembered across sessions. The plugin's options (below) are the defaults they reset to.

Modes

ModeJust before the cache expires
WatchNothing. Only the countdown, the cost of resuming, and the TTL learning.
PingSends 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 → CompactPings 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.

Keeping the cache warm during long tools

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.

Options

Set at install, or later with claude plugin configure cache-guard.

OptionDefaultMeaning
modecompactwatch, ping, compact or ping+compact
ttlautoauto finds the session's TTL (measured, configured or reported) and only counts down until it does; 5m or 1h set it by hand
maxPings2Keep-alive pings in one pause; 11 or more means unlimited
marginSeconds30How long before expiry the ping or compaction runs
minTokens50000Below this context size the mod only watches
toolPingstrueKeep the cache warm while a long tool runs
notifytrueA system notification shortly before the cache lapses or is compacted
notifySeconds60How long before that the notification comes
profilecodeWhich compaction prompt is used: code, debug or plan
instructionsCodesee belowPrompt of the code profile
instructionsDebugsee belowPrompt of the debug profile
instructionsPlansee belowPrompt of the plan profile

Compaction profiles

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.

  • code: keep the user's corrections and preferences verbatim; decisions and why; approaches ruled out; exact paths, symbols, commands, failing tests and error text; uncommitted or unverified changes; todo state; the exact next step. Drop tool output, code already on disk and resolved debugging.
  • debug: the symptom and exact repro; the current hypothesis and its evidence; hypotheses ruled out and why; exact errors and file:line; temporary instrumentation to revert.
  • plan: the goal, constraints and preferences verbatim; options chosen and rejected, with reasons; open questions; the agreed steps and their status. No code, paths only.

Claude Code's own automatic compaction does not receive these prompts. To steer it too, add a # Compact instructions section to your CLAUDE.md.

How it works

  • TTL. Under 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:
  • Reported by Claude Code. The engine passes the TTL before a compaction or a model switch.
  • Measured from the cache-write price. The engine does not tell a mod whether a request's cache writes were for 5 minutes or 1 hour, but it bills them at that rate, and a 1-hour write costs 1.6× a 5-minute one. After every request in the main conversation, the mod compares how far the session's cost moved with what the request would cost at either rate, allowing for a 10% regional surcharge. Two requests in a row at the same rate set the TTL. A request that wrote under 500 tokens, or a bill that fits neither rate (fast mode, a subagent billed meanwhile), is skipped. The measurement keeps running, so a subscription that runs past its usage limits is seen switching to 5 minutes.
  • Set by Claude Code's configuration, read at session start in the engine's order: 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.
  • Learned from a pause. The first request after a pause of 5–60 minutes, or after an unpinged tool that ran that long: one that had to write most of the context again means 5 minutes, one that read it from the cache means 1 hour.

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.

  • Timing. The cache lives from the start of the last request, not the end of the reply, so the countdown starts there too. A ping renews the cache from the ping's own start.
  • Ping. $.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.
  • Prices. Prices are the list prices of Claude Code 2.1.295's model catalog, per model, for cache write (5 min and 1 hour), cache read, input and output. An unrecognised model is priced at the $5/M input tier. On a subscription you pay in usage limits rather than dollars, but the ratios are the same.
  • Savings. Savings are counted conservatively.
  • Every ping and compaction is counted as spent when it happens.
  • The re-caching it avoided is credited only when your next request actually found the cache the ping kept, or wrote the smaller compacted context.
  • A ping before you came back too late stays a pure cost.

Your cache TTL (as of Claude Code 2.1.295)

  • 1 hour: the main conversation on a Claude subscription within its usage limits.
  • 5 minutes: API keys, Bedrock, Vertex and Foundry; a subscription past its limits; and all subagents.
  • Forcing 1 hour: set "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.

Development

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.

FileContents
hooks/register.tsxThe hooks, timers, actions, band and settings panel
hooks/view.tsPure helpers: the band's text, prices, savings, transcript lines
types/index.d.tsThe mod's state contract
tests/cache-guard.test.tsThe test suite

License

MIT

Source 3 files
hooks/register.tsx 1271 lines
1import { 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 lines
1import 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}
372
types/index.d.ts 73 lines
1export 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