Claude Code mod: /keepwarm keeps your prompt cache alive through a break; a countdown, hit rate, context fill and API value above the prompt; and a /tokens…

Never lose your prompt cache to a coffee break again.
Claude Code re-sends your entire conversation with every message. The prompt cache is what makes that fast and cheap – but it only lives for one hour on a Claude subscription (five minutes on an API key). Step away longer, and your next message re-writes the whole context from scratch: a slower turn, billed at the cache-write rate. Nothing on screen warns you.
What a break costs: on Claude Opus 5.5, re-writing a 150k-token conversation after the cache expired costs about $1.20 at API prices – keeping it warm with one
/keepwarmping about $0.03 (pricing › prompt caching). On a subscription it is plan usage instead of dollars, at the same ratio.
Cache Meter puts the cache on screen and keeps it warm while you are away.
<table> <tr> <td width="33%" valign="top">
A live countdown above the prompt: minutes until the cache expires, the last request's hit rate, how full the context is – and what the session would cost on an API key.
</td> <td width="33%" valign="top">
Type /keepwarm before lunch or a meeting. Shortly before expiry one tiny ping keeps the cache alive – about 1/20 of a re-write, with hard limits so it never runs away.
</td> <td width="33%" valign="top">
/tokens opens a dashboard: every request's cache hits and misses, what caching saved, which CLAUDE.md files and MCP servers fill your context, API errors and cost per hour.
</td> </tr> </table>
Install – in your shell, then start a new Claude Code session:
claude plugin install cache-meter --marketplace florian101010/cache-meter --scope user
Or let Claude Code do it – paste this into a session:
Install the Claude Code mod from https://github.com/florian101010/cache-meter – follow its AGENTS.md.
It runs the same command (Claude Code may ask you to approve it); start a new session afterwards.
Try it in the new session:
/tokens – the dashboard; /tokens context shows which files and servers fill your context./keepwarm before your next break – then check after the break that the next answer reads from the cache (hit rate in the band).Safe by design: it never changes your prompts or tool calls and writes nothing to disk. It sends nothing on its own – only the /keepwarm pings and, if you press Count exactly, the token counts /context would make. Free and MIT-licensed.
The band above the prompt, always there:

/tokens – the dashboard, here as a sidebar beside the chat (Claude Code's fullscreen renderer):
<img src="https://raw.githubusercontent.com/florian101010/cache-meter/assets/screenshots/overview.png" width="640" alt="The /tokens overview: a big 60-minute countdown marked WARM with the expiry time and where keepwarm would ping, a keepwarm card with a 'Keep warm 2h' button, the context gauge with the auto-compact marker, session totals with 'cache saved 40% of input cost' and the plan limits, and a column chart of every request">
What is a Claude Code mod? A mod is a Claude Code plugin whose TypeScript hooks run inside Claude Code: it can draw above the prompt, open panes, add slash commands and react to events such as each model request. See the official mods overview. Cache Meter is a mod only – no skills, no MCP server, no settings hooks.
For AI coding agents: start with AGENTS.md – what the mod does, how to install it for a user, how to verify and test it, and the rules for changing it, on one page. Machine-readable index: llms.txt.
/keepwarmBefore you step away, type:
/keepwarm # 2 hours
/keepwarm 4h # any window from 10m to 8h
/keepwarm off # stop early
The band shows ↻ while it runs. Whenever 10 minutes or less of the one-hour TTL are left and no request is running, Cache Meter sends one tiny request over your conversation ($.model.fork, answer: warm). It reads the cached context, and a cache read keeps the entry alive for the duration of the original write – one hour for the main conversation (pricing › prompt caching: cache read, "same duration as the preceding write"). That is roughly one ping every 50 minutes.
What it costs, compared with letting the cache expire (multipliers of the base input price, from the pricing page):
| Re-write after expiry (1-hour cache write) | One ping (cache read) | Pings that cost as much as one re-write | |
|---|---|---|---|
| Most models | 2× | 0.1× | ≈ 20 |
| Claude Opus 5.5, Claude Sonnet 5.5 | 2× | 0.05× | ≈ 40 |
| Claude Fable 5.1, Claude Mythos 5.1 | 2× | 0.025× | ≈ 80 |
Each ping also pays for its few uncached tokens and its one-word answer. On a subscription these are shares of your plan limits rather than dollars; how cache reads count towards the 5-hour and weekly limits is not documented, so watch the limits in /tokens.
Safety limits – keepwarm switches itself off, with a toast, when:
It runs only in interactive sessions, never drops a message, and after an expensive cold re-write it only suggests /keepwarm – it never switches itself on.
Check that it works on your setup: 10–50 minutes after a ping, run /usage – the Prompt cache (main) line should say warm – or watch the hit rate of your next request in the band.
/tokens/tokens opens the dashboard, /tokens again closes it. Three tabs, each answering its own question:
| Tab | What it answers | Open with |
|---|---|---|
| Overview | How long is the cache still warm? Did the last requests hit it? What did caching save? How close are my plan limits? | /tokens |
| Context | What fills my context – which CLAUDE.md files, MCP servers, skills, agents? | /tokens context |
| Health | Did requests fail or get cut off? How long does the advisor take? What does the session cost per hour and per prompt? | /tokens health |
<img src="https://raw.githubusercontent.com/florian101010/cache-meter/assets/screenshots/context.png" width="49%" alt="The Context tab: where the context goes by category, the heaviest memory files, MCP servers, skills listed of total and agents"> <img src="https://raw.githubusercontent.com/florian101010/cache-meter/assets/screenshots/health.png" width="49%" alt="The Health tab: session age, prompts, cost as API value per prompt, tokens per prompt, stop reasons, API errors, server-side tool calls and the cache check">
Where it shows. In Claude Code's classic renderer the pane sits above the prompt, and your next prompt closes it so the conversation keeps its room. In the fullscreen renderer (/tui fullscreen) it docks beside the chat as a sidebar, opens there by itself at session start (option autoSidebar) and stays open while you work.
Switching tabs. /tokens context or /tokens health – a prefix is enough; after /tokens Claude Code shows the three tab names as a hint. Or click a tab (fullscreen renderer), or press ctrl+x, Tab, then 1–3.
Where the numbers come from. Everything is what Claude Code reports to mods – token counts per request, context breakdown, cost, plan limits, stop reasons, errors. Nothing is sent anywhere; only Count exactly in the Context tab asks the API to count tokens, once, when you press it.
/keepwarm/tokensBand above the prompt – one line that never wraps, drawn above whatever other mods put in the band:
| Part | Meaning |
|---|---|
● cache 42m | Time left until the main conversation's prompt cache expires. Green = warm, yellow = 17 % of the TTL or less, red = expired / empty |
■■■□□ | The same as a bar (up to 20 cells; dropped first on narrow terminals) |
93% hit | Cache hit rate of the last main request = read ÷ (new + read + written) |
☀ context 9% | Context fill against the model's window; the symbol tracks the distance to the auto-compact point (☀ ☁ ☂ ☇ ↯) |
(auto-compact 97%) | Where auto-compact triggers, on the same scale (off when disabled) |
· API value $1.10 | On a subscription: what the session would cost on an API key – a comparison, Claude Code's own figure as /cost shows it. On an API key or a cloud provider it reads · spent $1.10. Hidden at $0, dropped first on narrow terminals |
↻ | /keepwarm is running |
· rewrite ≈ … | When the cache has expired: what the next request will re-write (in $ when Claude Code estimated it, otherwise in tokens) |
[ Compact ] | Runs /compact; hidden while a turn is running |
Pane /tokens – the dashboard with its Overview, Context and Health tabs; see above. The Overview's request chart draws one column per request: its height is the share not read from the cache, so a cold re-write stands out as a tall red column, a first write of the cache in blue.
/keepwarm [10m … 8h | off] – shortly before the cache would expire, sends one tiny request over your conversation ($.model.fork) so the cache stays warm while you are away. Stops by itself on a miss, at the end of the window, after 12 pings, or when the TTL is 5 minutes.
Cold-rewrite hint – after a break that let the cache expire, a toast suggests /keepwarm before the next break. Nothing is ever switched on automatically and no prompt is ever dropped.
claude -p the hooks run but nothing is drawn, and /keepwarm refuses to start.In a Claude Code terminal session:
/plugin install cache-meter --marketplace florian101010/cache-meter
Answer y to add the marketplace, then pick a scope. Or from your shell:
claude plugin install cache-meter --marketplace florian101010/cache-meter --scope user
Run /reload-plugins in an open session, or start a new one. /plugin then shows … mods active · cache-meter. Details: Install plugins.
git clone https://github.com/florian101010/cache-meter.git
claude --plugin-dir /absolute/path/to/cache-meter # for one session (the clone's absolute path)
To load the clone in every session, add its absolute path to CLAUDE_CODE_PLUGIN_DIRS in the env block of ~/.claude/settings.json (paths separated by :; see settings and environment variables). Claude Code then loads the clone exactly as it is checked out – keep that checkout on main and develop in a separate worktree. While a copy named cache-meter is already loaded that way, --plugin-dir cannot load a second one (the first wins); see CONTRIBUTING.md for how to try a worktree.
A mod runs with your permissions and is not sandboxed. Read Privacy, cost and permissions first;
claude plugin validate <dir>lists every event a mod hooks and every call it makes (decide whether to trust a mod).
| What | How |
|---|---|
| See cache and context | the band above the prompt, always on |
| Open the full view | /tokens – again to close; Esc at an empty prompt closes it above the prompt |
| Open a tab directly | /tokens context, /tokens health, /tokens overview (a prefix is enough: /tokens h) – switches an open pane instead of closing it |
| Switch tabs | /tokens health etc.; or press ctrl+x, then Tab (the pane gets the keys), then 1–3 – Esc hands the keys back; clicking a tab works only in Claude Code's fullscreen renderer, which captures the mouse |
| Count the context exactly | x or Count exactly in the Context tab – one API request per tool and memory file, as /context |
| Pane beside the chat | /tui fullscreen (Claude Code's fullscreen renderer): from 110 columns the pane docks as a sidebar and stays open while you chat; /tui default switches back |
| Keep the cache warm | /keepwarm (2 h), /keepwarm 30m … /keepwarm 8h, also /keepwarm 120m – interactive sessions only |
| Stop keeping it warm | /keepwarm off (or 0) – reports pings and tokens read |
| Compact | the [ Compact ] button in the band or the pane (between turns only) |
Two options. Set them with /config → cache-meter.<option>, or in ~/.claude/settings.json under pluginConfigs → "cache-meter@cache-meter" (marketplace install) or "cache-meter@inline" (loaded with --plugin-dir or CLAUDE_CODE_PLUGIN_DIRS) → options.
| Option | Values | What it does |
|---|---|---|
cacheTtl | auto (default), 60m, 5m | Which cache lifetime the countdown assumes – see below |
autoSidebar | auto (default), off | auto: in Claude Code's fullscreen renderer (/tui fullscreen) the dashboard opens by itself as a sidebar at session start – from 144 terminal columns, 110 once you have opened it yourself – and stays closed once you close it. It never opens above the prompt by itself. off: only with /tokens |
With auto, Cache Meter follows Claude Code's own TTL controls and your plan state (full order). On an API key or a cloud provider Claude Code's default is five minutes: set cacheTtl to 5m, or set promptCacheTtl.
[!NOTE] Local and read-only. Cache Meter never changes your prompts or tool calls, never reads their text, writes nothing to disk and sends nothing on its own. Full details: PRIVACY.md · SECURITY.md.
| Source | ||
|---|---|---|
| Reads | What Claude Code reports to mods: token counts, model, stop reason and server-side tool calls of each request; context fill and its breakdown (Claude Code's free local estimate, including the paths of your CLAUDE.md files and the names of MCP servers, skills and agents); cost as /cost shows it; plan-limit percentages; number of prompts and the session's start time; API errors; Claude Code's own verdict on the cache at model switches and resumes; whether the session is fullscreen. Plus the TTL controls FORCE_PROMPT_CACHING_5M, CLAUDE_CODE_PROMPT_CACHE_TTL, ENABLE_PROMPT_CACHING_1H and the promptCacheTtl setting | The code (hooks/register.tsx); claude plugin validate . lists every event it hooks and every call it makes. Data sources: events › Follow a turn, reference › Mods API methods ($.session.usage, $.session.turns), events › Handle the settings hook events |
| Keeps | Everything in memory for the session only – gone when it ends and after /clear, /resume or /branch. Nothing on disk | interface › Keep state ($.state lasts until the session ends or /clear, /resume, /branch); no $.fs or $.store call in the code – check with claude plugin validate . |
| Sends | Nothing on its own. Only when you ask: /keepwarm pings and Count exactly in the Context tab – through Claude Code, with your credentials, to the provider your session already uses | api › Call a model ($.model.fork uses the session's credentials); $.session.usage({ breakdown: 'full' }) sends token-count requests (type declaration SessionUsageArgs); no $.http or $.process call in the code |
| Costs | Nothing, except /keepwarm: each ping reads your whole context at the model's cache-read price – billed like any request on an API key, counted against your limits on a plan. How cache reads count towards the 5-hour and weekly limits is not documented; watch them in the pane. Count exactly uses token counting, which is free on the API; whether Claude Code counts it against plan limits is not documented | pricing › Prompt caching (cache read 0.1×, 0.05× or 0.025× of the input price); api › Call a model (“bill to the user's plan, API key, or cloud provider”); token counting › Pricing and rate limits (“free to use”) |
| Event | What Cache Meter does there | What it changes |
|---|---|---|
session.start | Registers /tokens and /keepwarm, starts its 15-second timer, takes a first reading | Nothing |
classic.SessionStart (after /clear, /resume, /branch) | Reloads the TTL controls and the reading; on a resume continues the countdown and records Claude Code's verdict on the cache | Nothing – passes the event on with next(e); it adds no context and no instructions |
turn.step | Reads each request's token counts, model, stop reason and server-side tool calls | Nothing – the request and its result go through as they are |
classic.StopFailure | Counts the kind of API error a turn ended on | Nothing |
turn.complete | Notes that no main request is running (so /keepwarm never pings during one) | Nothing |
session.measure | Reads context fill, plan limits, cost and the context breakdown | Nothing |
session.compact | After a real compaction, resets the countdown, since the next request writes a fresh cache. The same name is the call $.session.compact that the Compact button makes; the hook sees every compaction, whoever starts it | Nothing – it never skips or alters a compaction |
classic.PostModelSwitch | Records the cache TTL, the re-write estimate and Claude Code's verdict on the cache | Nothing |
command.run (/tokens, /keepwarm only) | Answers its own two commands | Other commands are not matched |
prompt.submit | Closes its own pane above the prompt when you send a prompt | Nothing – the prompt is not read, changed or dropped |
ui.close | Notes that you closed its pane, so it doesn't reopen | Nothing – it never keeps a pane open |
ui.render (band, its own pane) | Draws the band – keeping what other mods draw below it – and the /tokens pane | Only its own drawing |
claude plugin validate . prints this list for the version you have.
Only what you start yourself. A /keepwarm ping reads your context at the cache-read price – about 1/20 of re-writing it after expiry (1/40 on Opus 5.5 and Sonnet 5.5, 1/80 on Fable 5.1 and Mythos 5.1), at most 12 pings per run. On an API key that is billed like any request, on a plan it counts against your limits. Count exactly uses free token counting. Everything else costs nothing.
Not on a subscription: it is what the same session would cost on an API key, as /cost computes it – a comparison. Your plan's usage is the 5-hour and weekly limits in the pane. On an API key or a cloud provider the band says spent instead, because there it is real cost. Cache Meter tells the two apart by whether Claude Code reports plan limits.
A cache read keeps the entry for the duration of its original write – one hour for the main conversation (pricing). Check it on your setup with /usage after a break (see above).
/usage reports the session's hit ratio and whether the cache is warm, when you ask. Cache Meter shows how long it stays warm, all the time, per request, with history – and can keep it warm.
Claude Code places it, not the mod: beside the chat only in its fullscreen renderer (/tui fullscreen) from 110 columns, otherwise above the prompt – there your next prompt closes it so it doesn't take rows from the conversation. In fullscreen it opens as a sidebar by itself at session start; set autoSidebar to off to stop that.
No. It hooks model requests only to read their token counts, stop reasons and server-side tool calls, and notes that you s
hooks/register.tsx 1444 lines1// cache-meter: what the prompt cache and the token budget are doing, at a glance.
2//
3// Band above the prompt: ● cache 42m ■■■■■■■□□□ 93% hit · ☀ context 9% (auto-compact 97%) [Compact]
4// Pane /tokens: every main-thread request as a stacked bar (read / written / new), session
5// sums, subagents, rate limits, cost, keepwarm.
6// /keepwarm [2h|off]: pings the cache through $.model.fork shortly before it lapses.
7//
8// Single source of truth for every API fact below: the official Claude Code mods documentation and
9// its pages (links in CONTRIBUTING.md) and the type declaration of the running build
10// (.claude-plugin/types/claude-code/index.d.ts) — not memory. Sections cited as "interface § …",
11// "prompt-caching § …" etc. are pages of that documentation.
12//
13// Sources: turn.step (each request's four token counts), session.measure (context, limits, cost),
14// $.session.usage summary (auto-compact point, estimated locally), classic.SessionStart (resume:
15// time since the last response; /clear, /resume, /branch reset $.state), classic.PostModelSwitch
16// (the engine's own cache_ttl and re-cache estimate).
17
18import { atom, read, update } from 'claude-code'
19import type { EngineInterface, Register } from 'claude-code'
20
21import type { CacheInfo, ColdWrite, Health, KeepWarm, Measure, ServerToolCall, Totals, TtlConfig, Usage4 } from '../types'
22import {
23 COLD_MIN,
24 HOUR,
25 MAX_PINGS,
26 PING_HIT_SHARE,
27 PING_SHARE,
28 PING_WRITE_SHARE,
29 ZERO,
30 add,
31 cacheStatus,
32 duration,
33 forecast,
34 hhmm,
35 hitPct,
36 litCells,
37 meterCells,
38 meterRuns,
39 markAt,
40 columnCells,
41 cacheSaving,
42 readMultiplier,
43 writeMultiplier,
44 FILL,
45 HEALTH,
46 parseKeepWarm,
47 rasterCells,
48 remainingLabel,
49 resolveTtl,
50 right,
51 short,
52 sparkline,
53 stack,
54 tall,
55 contextMapOf,
56 isNormalStop,
57 bump,
58 tally,
59 stopLabel,
60 errorLabel,
61} from './lib'
62import type { BreakdownLike, Mark } from './lib'
63
64const PANE = 'cache-meter'
65const TICK_MS = 15_000
66const STEPS_KEPT = 80
67
68const cacheA = atom({ plugin: 'cache-meter', key: 'cache' } as const, null)
69const stepsA = atom({ plugin: 'cache-meter', key: 'steps' } as const, [])
70const totalsA = atom({ plugin: 'cache-meter', key: 'totals' } as const, {
71 main: ZERO,
72 sub: ZERO,
73 mainSteps: 0,
74 subSteps: 0,
75 coldCount: 0,
76 coldWritten: 0,
77})
78const measureA = atom({ plugin: 'cache-meter', key: 'measure' } as const, null)
79const ttlA = atom({ plugin: 'cache-meter', key: 'reportedTtl' } as const, null)
80const keepA = atom({ plugin: 'cache-meter', key: 'keepWarm' } as const, {
81 until: null,
82 pings: 0,
83 readTokens: 0,
84 lastPingAt: null,
85})
86const coldA = atom({ plugin: 'cache-meter', key: 'coldWrites' } as const, [])
87const contextMapA = atom({ plugin: 'cache-meter', key: 'contextMap' } as const, null)
88const healthA = atom({ plugin: 'cache-meter', key: 'health' } as const, {
89 stops: {},
90 errors: {},
91 subErrors: {},
92 serverTools: {},
93 lastServerTools: null,
94 checks: { agreed: 0, differed: 0, last: null },
95})
96const ttlConfigA = atom({ plugin: 'cache-meter', key: 'ttlConfig' } as const, {
97 force5m: false,
98 envTtl: null,
99 settingTtl: null,
100 enable1h: false,
101})
102
103// Module values the top-level helpers read: the option (fixed per load), whether a person is at the
104// prompt, a ping in flight, and how many main-thread requests are running. None is drawn, and a reload
105// runs session.start again, so losing them costs nothing (interface § Keep state).
106let setting = 'auto'
107let isInteractive = false
108let isPinging = false
109let mainRequests = 0
110// Where the surface last seated the pane: beside a fullscreen transcript (dock) or above the prompt (inline).
111let seat: 'dock' | 'inline' | null = null
112// Opening the dashboard as a sidebar by itself (option autoSidebar): only once the band has been drawn
113// fullscreen, once per load, and never again after the person closed it.
114let autoSidebar = 'auto'
115let isFullscreenSeen = false
116let hasAutoOpened = false
117let isDismissed = false
118// The pane's open tab; a reload starts on the overview again.
119const TABS = ['overview', 'context', 'health'] as const
120let tab: (typeof TABS)[number] = 'overview'
121
122async function ttlNow($: EngineInterface) {
123 const measure = await read($, measureA)
124 const config = await read($, ttlConfigA)
125
126 return resolveTtl({ setting, ...config, reported: await read($, ttlA), limits: measure?.limits ?? [] })
127}
128
129// The TTL controls a session runs under (prompt-caching § Choose the TTL yourself). Read at start
130// and after /clear, /resume, /branch, which reset $.state (interface § Load a saved value again).
131async function loadTtlConfig($: EngineInterface) {
132 let settingTtl: string | null = null
133 try {
134 const settings: Record<string, unknown> = await $.settings.read()
135 settingTtl = typeof settings.promptCacheTtl === 'string' ? settings.promptCacheTtl : null
136 } catch {
137 settingTtl = null
138 }
139 const config: TtlConfig = {
140 force5m: (await $.env.get('FORCE_PROMPT_CACHING_5M')) === '1',
141 envTtl: (await $.env.get('CLAUDE_CODE_PROMPT_CACHE_TTL')) ?? null,
142 settingTtl,
143 enable1h: (await $.env.get('ENABLE_PROMPT_CACHING_1H')) === '1',
144 }
145 await update($, ttlConfigA, () => config)
146}
147
148// A first reading, so the band has the window and the auto-compact point before any turn.
149async function measureNow($: EngineInterface) {
150 try {
151 const usage = await $.session.usage({ breakdown: 'summary' })
152 const auto = autoCompactOf(usage.context.breakdown, usage.context.window)
153 await update($, measureA, m => ({
154 percent: usage.context.percent ?? null,
155 tokens: usage.context.tokens ?? null,
156 window: usage.context.window,
157 autoCompactPct: auto.pct,
158 isAutoCompactOff: auto.isOff,
159 autoCompactWindow: auto.isKnown ? usage.context.window : 0,
160 history: m?.history ?? [],
161 limits: usage.rateLimits.map(l => ({ kind: l.kind, percentUsed: l.percentUsed, resetsAt: l.resetsAt })),
162 usd: usage.cost?.usd ?? null,
163 startedAt: usage.startedAt,
164 prompts: m?.prompts ?? null,
165 }))
166 await mapContext($, usage.context.breakdown, 'summary')
167 } catch {
168 // No reading: the band waits for the first session.measure.
169 }
170}
171
172// Where the context goes, from a breakdown of $.session.usage (types: SessionContextBreakdown).
173// 'summary' is Claude Code's local estimate and sends no request; 'full' counts with the API.
174async function mapContext($: EngineInterface, breakdown: unknown, detail: 'summary' | 'full') {
175 if (breakdown === undefined || breakdown === null) return
176 try {
177 const at = await $.clock.now()
178 const map = contextMapOf(breakdown as BreakdownLike, detail, at)
179 await update($, contextMapA, () => map)
180 } catch {
181 // A breakdown of another shape: keep the last map.
182 }
183}
184
185// The exact count behind the Context tab's button: one token-count request per tool and memory
186// file, as /context does (types: SessionUsageArgs.breakdown), so only on a press.
187let isCounting = false
188async function countExactly($: EngineInterface) {
189 if (isCounting) return
190 isCounting = true
191 $.ui.invalidate('ui.render')
192 try {
193 const usage = await $.session.usage({ breakdown: 'full' })
194 await mapContext($, usage.context.breakdown, 'full')
195 } catch (error) {
196 $.ui.toast(`Exact count failed: ${error instanceof Error ? error.message : String(error)}`)
197 } finally {
198 isCounting = false
199 $.ui.invalidate('ui.render')
200 }
201}
202
203// Claude Code's own verdict on the cache against Cache Meter's clock, taken before either is acted on.
204// Skipped while Cache Meter knows nothing of the cache (no request seen, e.g. right after /resume reset $.state).
205async function recordCheck($: EngineInterface, engineWarm: boolean, source: 'model' | 'resume') {
206 const cache = await read($, cacheA)
207 if (cache === null) return
208 const now = await $.clock.now()
209 const ourWarm = cacheStatus(cache, now, (await ttlNow($)).ms).kind === 'warm'
210 await update($, healthA, prev => ({
211 ...prev,
212 checks: {
213 agreed: prev.checks.agreed + (engineWarm === ourWarm ? 1 : 0),
214 differed: prev.checks.differed + (engineWarm === ourWarm ? 0 : 1),
215 last: { at: now, engineWarm, ourWarm, source },
216 },
217 }))
218}
219
220// A pane opened unasked is placed from 144 terminal columns (110 once the person opened it before) and
221// waits undrawn below that (types: UiOpenResult); docked, it is a sidebar, never a takeover.
222async function maybeAutoOpen($: EngineInterface) {
223 if (autoSidebar === 'off' || !isInteractive || !isFullscreenSeen || hasAutoOpened || isDismissed) return
224 hasAutoOpened = true
225 if ((await $.ui.panes()).some(p => p.id === PANE)) return
226 await $.ui.open({ id: PANE, title: 'Tokens & Cache' })
227}
228
229async function tick($: EngineInterface) {
230 $.ui.invalidate('ui.render')
231 await maybeAutoOpen($)
232 const keep = await read($, keepA)
233 // No ping while a main request runs: that request touches the cache anyway.
234 if (keep.until === null || isPinging || mainRequests > 0) return
235 const now = await $.clock.now()
236 if (now > keep.until || keep.pings >= MAX_PINGS) {
237 await update($, keepA, k => ({ ...k, until: null }))
238 $.ui.toast(`Keepwarm finished (${keep.pings} pings)`)
239
240 return
241 }
242 const ttl = await ttlNow($)
243 if (ttl.ms < HOUR) {
244 await update($, keepA, k => ({ ...k, until: null }))
245 $.ui.toast(`Keepwarm off: the TTL is now ${ttl.label} (${ttl.source})`)
246
247 return
248 }
249 const cache = await read($, cacheA)
250 const status = cacheStatus(cache, now, ttl.ms)
251 if (status.kind !== 'warm' || cache === null) {
252 await update($, keepA, k => ({ ...k, until: null }))
253 $.ui.toast(status.kind === 'empty' ? 'Keepwarm off: no cache to keep warm' : 'Keepwarm off: the cache has already expired')
254
255 return
256 }
257 if (status.remaining > ttl.ms * PING_SHARE) return
258 isPinging = true
259 try {
260 // Timed at the request's start: the conservative end of the window (an assumption, see README).
261 const startedAt = await $.clock.now()
262 const reply = await $.model.fork({ prompt: 'Reply with the single word: warm' })
263 if (!reply.isAnswered) {
264 await update($, keepA, k => ({ ...k, until: null }))
265 $.ui.toast(`Keepwarm off: the ping got no answer (${reply.reason})`)
266
267 return
268 }
269 const measure = await read($, measureA)
270 const expected = measure?.tokens ?? cache.contextTokens
271 const got = reply.usage.cache_read_input_tokens
272 const wrote = reply.usage.cache_creation_input_tokens
273 await update($, keepA, k => ({ ...k, pings: k.pings + 1, readTokens: k.readTokens + got, lastPingAt: startedAt }))
274 // A miss (little read) or a partial miss (much written next to what was read): the cache was
275 // not what the TTL assumption said. Stop paying for pings.
276 if (got === 0 || (expected > 0 && got < expected * PING_HIT_SHARE) || wrote >= got * PING_WRITE_SHARE) {
277 await update($, keepA, k => ({ ...k, until: null }))
278 $.ui.toast(`Keepwarm off: the ping missed the cache (${short(got)} read, ${short(wrote)} written)`)
279
280 return
281 }
282 await update($, cacheA, c => (c === null ? c : { ...c, lastAt: startedAt }))
283 } finally {
284 isPinging = false
285 }
286}
287
288async function resetCache($: EngineInterface) {
289 await update($, cacheA, () => null)
290 await update($, measureA, m => (m === null ? m : { ...m, percent: null, tokens: null }))
291}
292
293async function compactNow($: EngineInterface) {
294 try {
295 const result = await $.session.compact()
296 if ('skip' in result && result.skip) {
297 $.ui.toast(`Compact skipped: ${result.skip}`)
298
299 return
300 }
301 // $.session.compact runs every hook but the caller's, so the session.compact hook below may
302 // not see this one; resetting here too is idempotent.
303 await resetCache($)
304 } catch (error) {
305 $.ui.toast(`Compact is not possible right now: ${error instanceof Error ? error.message : String(error)}`)
306 }
307}
308
309// Starts keepwarm for `ms`; the answer text explains what happens or why it did not start.
310async function startKeepWarm($: EngineInterface, ms: number): Promise<string> {
311 if (!isInteractive) return 'Keepwarm only works in an interactive session (no timer runs here). Not started.'
312 const now = await $.clock.now()
313 const ttl = await ttlNow($)
314 if (ttl.ms < HOUR) {
315 return `With a ${ttl.label} TTL (${ttl.source}) keepwarm is not worth it: a ping every few minutes. Not started.`
316 }
317 if (cacheStatus(await read($, cacheA), now, ttl.ms).kind !== 'warm') {
318 return 'No warm cache – start keepwarm after the next answer.'
319 }
320 await update($, keepA, () => ({ until: now + ms, pings: 0, readTokens: 0, lastPingAt: null }))
321
322 return (
323 `Keepwarm until ${hhmm(now + ms)}, at most ${MAX_PINGS} pings: one whenever at most ` +
324 `${Math.round((ttl.ms * PING_SHARE) / 60_000)} min of the TTL is left (TTL ${ttl.label}, ${ttl.source}). Each ping ` +
325 `reads the whole context from the cache. If a ping misses the cache, keepwarm switches itself off.`
326 )
327}
328
329async function stopKeepWarm($: EngineInterface): Promise<string> {
330 const keep = await read($, keepA)
331 await update($, keepA, k => ({ ...k, until: null }))
332
333 return `Keepwarm off (${keep.pings} pings, ${short(keep.readTokens)} tokens read from the cache).`
334}
335
336// The toast box is narrow and takes only text (types: $.ui.toast), so a press gets the short form;
337// /keepwarm prints the full explanation into the transcript.
338async function pressKeepWarm($: EngineInterface, ms: number | null) {
339 const text = ms === null ? await stopKeepWarm($) : await startKeepWarm($, ms)
340 const keep = await read($, keepA)
341 const ttl = await ttlNow($)
342 const short =
343 ms !== null && keep.until !== null
344 ? `Keepwarm on until ${hhmm(keep.until)} · pings at ≤ ${Math.round((ttl.ms * PING_SHARE) / 60_000)} min left`
345 : text.split(/(?<=[.)])\s/)[0] ?? text
346 $.ui.toast(short, { timeoutMs: 8_000 })
347}
348
349export const register: Register = (on, options) => {
350 setting = typeof options.cacheTtl === 'string' ? options.cacheTtl : 'auto'
351 autoSidebar = typeof options.autoSidebar === 'string' ? options.autoSidebar : 'auto'
352 isFullscreenSeen = false
353 hasAutoOpened = false
354 isDismissed = false
355 isPinging = false
356 mainRequests = 0
357
358 on('session.start', async ($, e, next) => {
359 const result = await next(e)
360 await loadTtlConfig($)
361 await measureNow($)
362 // Hooks also run under -p and the SDK, where nothing draws (overview § Where mods run).
363 isInteractive = e.isInteractive
364 if (e.isInteractive) {
365 // Returning the promise lets a mock clock await the tick (test § Test a timer).
366 $.clock.every(TICK_MS, () => tick($))
367 // Soon after start, once the band has been drawn and said whether the session is fullscreen.
368 $.clock.after(2_000, () => maybeAutoOpen($))
369 }
370 try {
371 await $.command.register({
372 name: 'tokens',
373 description: 'Prompt-cache and token dashboard – tabs: overview, context (what fills it), health (errors, cost rate)',
374 argumentHint: '[overview | context | health]',
375 })
376 await $.command.register({
377 name: 'keepwarm',
378 description: 'Keep the prompt cache warm (each ping costs one cache read of the context)',
379 argumentHint: '[2h | 30m | off] (10m … 8h)',
380 })
381 } catch {
382 // Without the commands the band still works.
383 }
384
385 return result
386 })
387
388 // /clear, /resume and /branch reset every $.state value and do not fire session.start again
389 // (interface § Load a saved value again after /clear). Resume and fork also say how long ago the
390 // last response was, so the countdown carries on.
391 on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
392 await loadTtlConfig($)
393 await measureNow($)
394 if ((e.source === 'resume' || e.source === 'fork') && e.seconds_since_last_response !== undefined) {
395 if (e.prompt_cache_likely_expired !== undefined) await recordCheck($, !e.prompt_cache_likely_expired, 'resume')
396 const now = await $.clock.now()
397 // seconds_since_last_response counts from the last response's end; Claude Code's own verdict
398 // prompt_cache_likely_expired wins over that clock (classic SessionStart input, types).
399 const cache: CacheInfo = {
400 lastAt: e.prompt_cache_likely_expired === true ? 0 : now - e.seconds_since_last_response * 1000,
401 hitPct: null,
402 contextTokens: e.context_tokens ?? 0,
403 rewriteUsd: e.estimated_cache_write_usd ?? null,
404 coldReason: e.prompt_cache_likely_expired === true ? 'resume' : null,
405 idleBeforeMs: e.seconds_since_last_response * 1000,
406 }
407 await update($, cacheA, () => cache)
408 }
409
410 return next(e)
411 })
412
413 // Each model request: the four token counts as the API reported them (events § Follow a turn).
414 // Whether a keepwarm fork raises turn.step is undocumented ('re-entry' covers a hook's own call,
415 // and the ping comes from a timer). Worst case it shows as one row in the history; no real request
416 // is filtered out here, because a filter would drop requests that end during a ping.
417 on('turn.step', async function* ($, e, next) {
418 const isMain = e.agentId === undefined
419 // Counted before the first await, so a turn.complete that follows cannot be overtaken.
420 if (isMain) mainRequests += 1
421 let result
422 let startedAt = 0
423 let before: CacheInfo | null = null
424 let wasCold = false
425 try {
426 startedAt = await $.clock.now()
427 // Was the main cache already expired when this request started? (read before the request)
428 before = isMain ? await read($, cacheA) : null
429 wasCold = before !== null && cacheStatus(before, startedAt, (await ttlNow($)).ms).kind === 'expired'
430 result = yield* next(e)
431 } finally {
432 if (isMain) mainRequests = Math.max(0, mainRequests - 1)
433 }
434 const usage = result.usage
435 if (usage !== null) {
436 const u: Usage4 = {
437 input: usage.input_tokens,
438 read: usage.cache_read_input_tokens,
439 write: usage.cache_creation_input_tokens,
440 output: usage.output_tokens,
441 }
442 if (e.agentId !== undefined) {
443 await update($, totalsA, t => ({ ...t, sub: add(t.sub, u), subSteps: t.subSteps + 1 }))
444 } else {
445 // Cold by the clock AND by the counts (more written than read), so a wrong TTL assumption
446 // cannot turn a warm request into a "cold" one. A request with no cache before it (session
447 // start, after /compact or /clear) writes the cache for the first time: not a loss.
448 const isCold = wasCold && before !== null && u.write >= COLD_MIN && u.read < u.write
449 const kind = isCold ? ('cold' as const) : before === null ? ('first' as const) : undefined
450 await update($, stepsA, list => [...list, { ...u, at: startedAt, model: usage.model, ...(kind ? { kind } : {}) }].slice(-STEPS_KEPT))
451 await update($, totalsA, t => ({ ...t, main: add(t.main, u), mainSteps: t.mainSteps + 1 }))
452 await update($, cacheA, () => ({
453 lastAt: startedAt,
454 hitPct: hitPct(u),
455 contextTokens: u.input + u.read + u.write + u.output,
456 rewriteUsd: null,
457 coldReason: null,
458 idleBeforeMs: null,
459 }))
460 if (isCold && before !== null) {
461 const cause: ColdWrite['cause'] = before.lastAt > 0 ? 'pause' : before.coldReason ?? 'model'
462 const idleMs = cause === 'pause' ? startedAt - before.lastAt : before.idleBeforeMs
463 const cold: ColdWrite = { at: startedAt, written: u.write, cause, idleMs }
464 await update($, coldA, list => [...list, cold].slice(-20))
465 await update($, totalsA, t => ({ ...t, coldCount: t.coldCount + 1, coldWritten: t.coldWritten + u.write }))
466 const keep = await read($, keepA)
467 // A hint, not an action: switching keepwarm on by itself would spend quota unasked.
468 // Only after a pause in this session: after a model switch or a resume, keepwarm could not have helped.
469 if (cause === 'pause' && isInteractive && !(keep.until !== null && keep.until > startedAt)) {
470 $.ui.toast(`Cache was cold: ${short(u.write)} tokens re-written – try /keepwarm before your next break`, {
471 timeoutMs: 10_000,
472 })
473 }
474 }
475 }
476 }
477 if (isMain) {
478 // Why the request stopped, when not normally, and the tool calls the API ran itself
479 // (events › Follow a turn: serverToolUses, v2.1.290+; no tool.call fires for these).
480 const stop = result.stopReason
481 const calls: ServerToolCall[] = (result.serverToolUses ?? []).map(t => ({
482 name: t.name,
483 ms: t.endedAt === undefined ? null : Math.max(0, t.endedAt - t.startedAt),
484 }))
485 if (!isNormalStop(stop) || calls.length > 0) {
486 await update($, healthA, prev => {
487 const serverTools = { ...prev.serverTools }
488 for (const c of calls) {
489 const sum = serverTools[c.name] ?? { count: 0, ms: 0 }
490 serverTools[c.name] = { count: sum.count + 1, ms: sum.ms + (c.ms ?? 0) }
491 }
492
493 return {
494 ...prev,
495 stops: stop !== null && !isNormalStop(stop) ? bump(prev.stops, stop) : prev.stops,
496 serverTools,
497 lastServerTools: calls.length > 0 ? { at: startedAt, calls } : prev.lastServerTools,
498 }
499 })
500 }
501 }
502
503 return result
504 })
505
506 // A turn ended on an error (settings hook event StopFailure, observed only): counted by kind,
507 // the main conversation and subagents apart (agent_id is set for a subagent).
508 on('classic.StopFailure', async ($, e, next) => {
509 await update($, healthA, prev =>
510 e.agent_id === undefined ? { ...prev, errors: bump(prev.errors, e.error) } : { ...prev, subErrors: bump(prev.subErrors, e.error) },
511 )
512
513 return next(e)
514 })
515
516 // A main turn has ended, also when aborted (events § Follow a turn; TurnCompleteInput.isAborted):
517 // no main request runs any more. Resets the counter even if an aborted stream never reached its
518 // finally above, so /keepwarm cannot stall silently.
519 on('turn.complete', async ($, e, next) => {
520 if (e.agentId === undefined) mainRequests = 0
521
522 return next(e)
523 })
524
525 // After each main-thread turn: context fill, rate limits, cost.
526 on('session.measure', async ($, e, next) => {
527 const result = await next(e)
528 const before = await read($, measureA)
529 // A summary reading after each turn: Claude Code estimates it locally and sends no request
530 // (types: SessionUsageArgs). It gives the auto-compact point, the context map and the start time.
531 let auto: { pct: number | null; isOff: boolean; isKnown: boolean } | null = null
532 let startedAt: number | null = null
533 let prompts: number | null = null
534 try {
535 const usage = await $.session.usage({ breakdown: 'summary' })
536 if (before === null || before.autoCompactWindow !== e.context.window) auto = autoCompactOf(usage.context.breakdown, e.context.window)
537 startedAt = usage.startedAt
538 await mapContext($, usage.context.breakdown, 'summary')
539 } catch {
540 auto = null
541 }
542 try {
543 prompts = await $.session.turns()
544 } catch {
545 prompts = null
546 }
547 await update($, measureA, m => {
548 const tokens = e.context.tokens ?? null
549 const history =
550 e.changed.includes('context') && tokens !== null ? [...(m?.history ?? []), tokens].slice(-12) : m?.history ?? []
551 const measured: Measure = {
552 percent: e.context.percent ?? null,
553 tokens,
554 window: e.context.window,
555 autoCompactPct: auto ? auto.pct : m?.autoCompactPct ?? null,
556 isAutoCompactOff: auto ? auto.isOff : m?.isAutoCompactOff ?? false,
557 autoCompactWindow: auto ? (auto.isKnown ? e.context.window : 0) : m?.autoCompactWindow ?? 0,
558 history,
559 limits: e.rateLimits.map(l => ({ kind: l.kind, percentUsed: l.percentUsed, resetsAt: l.resetsAt })),
560 usd: e.cost?.usd ?? m?.usd ?? null,
561 startedAt: startedAt ?? m?.startedAt ?? null,
562 prompts: prompts ?? m?.prompts ?? null,
563 }
564
565 return measured
566 })
567
568 return result
569 })
570
571 // A compaction replaces the prefix: the next request writes a fresh cache.
572 on('session.compact', async ($, e, next) => {
573 const result = await next(e)
574 if (e.trigger !== 'precompute' && e.agentId === undefined && !('skip' in result && result.skip)) {
575 await resetCache($)
576 }
577
578 return result
579 })
580
581 // A real model switch starts a cold cache; the event carries the engine's own TTL and estimate.
582 on('classic.PostModelSwitch', async ($, e, next) => {
583 // Before the cache is set cold below: what Claude Code and Cache Meter each thought of it.
584 await recordCheck($, e.prompt_cache_warm, e.source === 'resume' ? 'resume' : 'model')
585 await update($, ttlA, () => e.cache_ttl)
586 if (e.source !== 'resume' && e.from_model !== e.to_model) {
587 const cold: CacheInfo = {
588 lastAt: 0,
589 hitPct: null,
590 contextTokens: e.context_tokens,
591 rewriteUsd: e.estimated_cache_write_usd,
592 coldReason: 'model',
593 idleBeforeMs: null,
594 }
595 await update($, cacheA, () => cold)
596 await update($, measureA, m => (m === null ? m : { ...m, autoCompactWindow: 0 }))
597 }
598
599 return next(e)
600 })
601
602 // /tokens toggles. rows: the height asked for above the prompt, default a third (types: PaneOpenArgs.rows);
603 // closeOnEscape: Esc at the idle, empty prompt closes it too. No focus: hotkeys would swallow typing.
604 on('command.run', { command: 'tokens' }, async ($, e) => {
605 // /tokens <tab> opens on that tab, or switches to it when the pane is open: no focus chord needed.
606 const asked = (e.args ?? '').trim().toLowerCase()
607 const wanted = TABS.find(t => t === asked || (asked.length > 0 && t.startsWith(asked)))
608 if (asked !== '' && wanted === undefined) return { text: 'Usage: /tokens [overview | context | health]' }
609 const isOpen = (await $.ui.panes()).some(p => p.id === PANE)
610 if (wanted !== undefined) {
611 tab = wanted
612 $.ui.invalidate('ui.render')
613 if (isOpen) return { text: `Switched the pane to ${wanted}.` }
614 }
615 if (isOpen) {
616 isDismissed = true
617 await $.ui.close({ id: PANE })
618
619 return { text: 'Closed the "Tokens & Cache" pane.' }
620 }
621 // From about 114 terminal columns the body is wide enough for two columns, which needs fewer rows.
622 const columns = e.presentation?.columns ?? 80
623 const pane = { id: PANE, title: 'Tokens & Cache', rows: columns >= 114 ? 26 : 36 }
624 // Docked beside a fullscreen transcript from 110 columns (types: CommandPresentation), the pane is a
625 // sidebar: Esc only hands the keys back. Above the prompt Esc closes it (closeOnEscape takes only true).
626 const isDock = e.presentation?.isFullscreen === true && columns >= 110
627 await $.ui.open(isDock ? pane : { ...pane, closeOnEscape: true })
628
629 return {
630 text: isDock
631 ? 'Opened the "Tokens & Cache" sidebar. /tokens again closes it.'
632 : 'Opened the "Tokens & Cache" pane. Esc at an empty prompt or /tokens again closes it.',
633 }
634 })
635
636 // The person closed the dashboard (its close mark or ctrl+x x): it stays closed this session.
637 on('ui.close', async ($, e, next) => {
638 if (e.id === PANE && e.origin.kind === 'person') isDismissed = true
639
640 return next(e)
641 })
642
643 // Above the prompt the pane is a glance: it takes rows from the transcript, so the person's next prompt
644 // closes it (the band keeps the summary). Docked beside a fullscreen transcript it is a sidebar and stays.
645 on('prompt.submit', async ($, e, next) => {
646 if (e.origin.kind === 'composer' && seat === 'inline' && (await $.ui.panes()).some(p => p.id === PANE)) {
647 await $.ui.close({ id: PANE })
648 }
649
650 return next(e)
651 })
652
653 on('command.run', { command: 'keepwarm' }, async ($, e) => {
654 const ms = parseKeepWarm(e.args)
655 if (ms === undefined) return { text: 'Usage: /keepwarm [10m … 8h | off] – 2h when left out.' }
656
657 return { text: ms === null ? await stopKeepWarm($) : await startKeepWarm($, ms) }
658 })
659
660 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
661 // What the mods after this one draw: kept beneath this row (interface § Pick where to draw › Band).
662 const theirs = await next(e)
663 if (e.viewport?.isFullscreen === true) isFullscreenSeen = true
664 if (e.props.hasSurvey) return theirs
665 const measure = await read($, measureA)
666 const cache = await read($, cacheA)
667 const keep = await read($, keepA)
668 const ttl = await ttlNow($)
669 const now = await $.clock.now()
670 const status = cacheStatus(cache, now, ttl.ms)
671 const { Box, Button, Text } = $.ui.resolve(e)
672 const cols = e.props.bodyColumns
673
674 const percent = measure?.percent ?? null
675 const auto = measure?.autoCompactPct ?? null
676 const isKeeping = keep.until !== null && keep.until > now
677 const isLow = status.kind === 'warm' && status.remaining <= ttl.ms * PING_SHARE
678 const sky = percent === null ? null : forecast(percent, auto)
679 const showButton = !e.props.isWorking
680
681 // Every segment's width, so the row never wraps (AbovePrompt.bodyColumns).
682 const label = status.kind === 'warm' ? remainingLabel(status.remaining) : status.kind === 'empty' ? 'empty' : 'expired'
683 const hit = cache === null || cache.hitPct === null ? '–' : `${cache.hitPct}%`
684 const pct = percent === null ? '–' : `${percent}%`
685 const autoText = measure?.isAutoCompactOff ? ' (auto-compact off)' : auto === null ? '' : ` (auto-compact ${auto}%)`
686 const rewriteTokens = measure?.tokens ?? cache?.contextTokens ?? 0
687 const rewriteText =
688 status.kind === 'expired' && cache !== null && rewriteTokens > 0
689 ? ` · rewrite ${cache.rewriteUsd !== null ? `≈ $${cache.rewriteUsd.toFixed(2)}` : `≈ ${short(rewriteTokens)} tok`}`
690 : isLow && !isKeeping && isInteractive
691 ? ' · /keepwarm?'
692 : ''
693 const base =
694 '● cache '.length +
695 label.length +
696 (isKeeping ? 2 : 0) +
697 1 +
698 hit.length +
699 ' hit · '.length +
700 (sky ? 2 : 0) +
701 'context '.length +
702 pct.length +
703 (showButton ? '[ Compact ]'.length + 1 : 0)
704 // Claude Code's own cost figure, the one /cost shows: on a subscription a comparison value (what an
705 // API key would pay), on an API key or a cloud provider real spend. Hidden at $0.00.
706 const usd = measure?.usd ?? null
707 const costText = usd === null || usd < 0.005 ? '' : isPlan(measure) ? ` · API value $${usd.toFixed(2)}` : ` · spent $${usd.toFixed(2)}`
708 const minBar = 6
709 const showRewrite = rewriteText !== '' && base + rewriteText.length + autoText.length + minBar < cols
710 const showAuto = autoText !== '' && base + autoText.length + (showRewrite ? rewriteText.length : 0) + minBar < cols
711 // Dropped first when the row gets narrow: after it the auto-compact point, then the bar.
712 const showCost =
713 costText !== '' && base + costText.length + (showAuto ? autoText.length : 0) + (showRewrite ? rewriteText.length : 0) + minBar < cols
714 const free =
715 cols - base - (showAuto ? autoText.length : 0) - (showRewrite ? rewriteText.length : 0) - (showCost ? costText.length : 0) - 1
716 const cells = free >= minBar ? Math.min(20, free) : 0
717 const lit = status.kind === 'warm' ? litCells(status.remaining, ttl.ms, cells) : 0
718
719 const mine = (
720 <Box flexDirection="row">
721 <Text color={status.kind === 'warm' ? 'success' : 'error'}>● </Text>
722 <Text dimColor>cache </Text>
723 <Text color={status.kind === 'warm' ? (isLow ? 'warning' : 'success') : 'error'} bold={status.kind === 'warm'}>
724 {label}
725 </Text>
726 {isKeeping && <Text color="success"> ↻</Text>}
727 <Text> </Text>
728 {cells > 0 && <Text color={isLow ? 'warning' : 'success'}>{'■'.repeat(lit)}</Text>}
729 {cells > 0 && <Text color="inactive">{'■'.repeat(cells - lit)}</Text>}
730 {cells > 0 && <Text> </Text>}
731 <Text bold color={hitColor(cache?.hitPct ?? null)}>
732 {hit}
733 </Text>
734 <Text dimColor> hit · </Text>
735 {sky && <Text color={sky.color}>{sky.icon} </Text>}
736 <Text dimColor>context </Text>
737 <Text bold>{pct}</Text>
738 {showAuto && <Text dimColor>{autoText}</Text>}
739 {showCost && <Text dimColor>{costText}</Text>}
740 {showRewrite && (
741 <Text color={status.kind === 'expired' ? 'warning' : 'claude'} wrap="truncate-end">
742 {rewriteText}
743 </Text>
744 )}
745 {showButton && <Text> </Text>}
746 {showButton && <Button key="compact" label="Compact" onPress={() => compactNow($)} />}
747 </Box>
748 )
749
750 return (
751 <Box flexDirection="column">
752 {mine}
753 {theirs}
754 </Box>
755 )
756 })
757
758 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
759 const elements = $.ui.resolve(e)
760 const { Box, Button, Text } = elements
761 seat = e.props.placement
762 // Raster (colored cells) is drawn by the terminal only (reference › Elements); elsewhere text.
763 const Raster = 'Raster' in elements ? elements.Raster : null
764 const steps = await read($, stepsA)
765 const totals: Totals = await read($, totalsA)
766 const measure = await read($, measureA)
767 const cache = await read($, cacheA)
768 const keep: KeepWarm = await read($, keepA)
769 const colds = await read($, coldA)
770 const map = await read($, contextMapA)
771 const health: Health = await read($, healthA)
772 const ttl = await ttlNow($)
773 const now = await $.clock.now()
774 const status = cacheStatus(cache, now, ttl.ms)
775
776 // Three widths: a narrow dock (about 48 body columns beside a 120-column terminal) drops the
777 // secondary text; from 110 the cards sit in two columns, so the pane also needs fewer rows.
778 const cols = e.props.bodyColumns
779 const tier = cols < 64 ? 'narrow' : cols < 110 ? 'medium' : 'wide'
780 const left = tier === 'wide' ? Math.floor((cols - 1) / 2) : cols
781 const rightW = tier === 'wide' ? cols - 1 - left : cols
782 // A card has a round border and one column of padding on each side: 4 columns of chrome.
783 const leftIn = Math.max(16, left - 4)
784 const rightIn = Math.max(16, rightW - 4)
785 const fullIn = Math.max(16, cols - 4)
786
787 // A one-row meter: Raster cells in the terminal, the same runs as colored text elsewhere.
788 const gauge = (key: string, width: number, fraction: number, fill: number, theme: string, marks: readonly Mark[] = []) => {
789 const runs = meterRuns(width, fraction, marks)
790
791 return Raster ? (
792 <Raster key={key} columns={width} rows={1} cells={rasterCells(meterCells(runs, fill, marks))} />
793 ) : (
794 <Text wrap="truncate-end">
795 {runs.map(r => (
796 <Text color={r.role === 'fill' ? theme : r.role === 'track' ? 'inactive' : marks[r.role]?.theme}>{r.text}</Text>
797 ))}
798 </Text>
799 )
800 }
801
802 // --- cache --------------------------------------------------------------------------------
803 const isWarm = status.kind === 'warm'
804 const isLow = isWarm && status.remaining <= ttl.ms * PING_SHARE
805 const stateColor = isWarm ? (isLow ? 'warning' : 'success') : 'error'
806 const remaining = isWarm ? status.remaining : 0
807 const isSeconds = isWarm && remaining < 60_000
808 const digits = tall(isWarm ? String(Math.ceil(remaining / (isSeconds ? 1000 : 60_000))) : '--')
809 const lastAt = cache !== null && cache.lastAt > 0 ? cache.lastAt : null
810 const stateWord = isWarm ? 'WARM' : status.kind === 'empty' ? 'EMPTY' : 'EXPIRED'
811 const stateNote = isWarm
812 ? ` · ${isSeconds ? 's' : 'min'} left${lastAt === null || tier === 'narrow' ? '' : ` · until ${hhmm(lastAt + ttl.ms)}`}`
813 : status.kind === 'empty'
814 ? ' · waiting for the first answer'
815 : lastAt === null
816 ? ''
817 : ` · since ${hhmm(lastAt + ttl.ms)}`
818 const sessionHit = hitPct(totals.main)
819 const rewriteTokens = measure?.tokens ?? cache?.contextTokens ?? 0
820 const stake =
821 cache?.rewriteUsd != null ? `$${cache.rewriteUsd.toFixed(2)} (${short(rewriteTokens)} tokens)` : `${short(rewriteTokens)} tokens`
822 // Keepwarm pings once PING_SHARE of the TTL is left: the marker shows that point on the countdown.
823 const canKeep = isInteractive && isWarm && ttl.ms >= HOUR
824 const isKeeping = keep.until !== null && keep.until > now
825 const pingMarks: Mark[] = canKeep || isKeeping ? [{ at: markAt(PING_SHARE, leftIn), color: 0xd97757, theme: 'claude' }] : []
826
827 const cacheCard = (
828 <Box borderStyle="round" borderColor={stateColor} paddingX={1} flexDirection="column">
829 <Box flexDirection="row">
830 <Box flexDirection="column">
831 {digits.map(row => (
832 <Text color={stateColor} bold>
833 {row}
834 </Text>
835 ))}
836 </Box>
837 <Box flexDirection="column" marginLeft={2} flexShrink={1}>
838 <Text wrap="truncate-end">
839 <Text color={stateColor} bold>
840 {stateWord}
841 </Text>
842 <Text dimColor>{stateNote}</Text>
843 </Text>
844 <Text wrap="truncate-end">
845 <Text dimColor>hit </Text>
846 <Text bold color={hitColor(cache?.hitPct ?? null)}>
847 {cache?.hitPct == null ? '–' : `${cache.hitPct}%`}
848 </Text>
849 {tier !== 'narrow' && <Text dimColor> last · </Text>}
850 {tier !== 'narrow' && (
851 <Text bold color={hitColor(sessionHit)}>
852 {sessionHit === null ? '–' : `${sessionHit}%`}
853 </Text>
854 )}
855 {tier !== 'narrow' && <Text dimColor> session</Text>}
856 </Text>
857 <Text dimColor wrap="truncate-end">
858 TTL {ttl.label}
859 {tier === 'narrow' ? '' : ` · ${ttl.source}`}
860 </Text>
861 </Box>
862 </Box>
863 {gauge('countdown', leftIn, isWarm ? remaining / ttl.ms : 0, isWarm ? (isLow ? FILL.low : FILL.warm) : FILL.cold, stateColor, pingMarks)}
864 {status.kind === 'expired' && rewriteTokens > 0 && (
865 <Text color="warning" wrap="truncate-end">
866 next message re-writes ≈ {stake}
867 </Text>
868 )}
869 {isWarm && rewriteTokens > 0 && (
870 <Text dimColor wrap="truncate-end">
871 expiry costs a re-write of ≈ {stake}
872 {pingMarks.length > 0 && tier !== 'narrow' ? ' · ┃ keepwarm pings here' : ''}
873 </Text>
874 )}
875 </Box>
876 )
877
878 // --- keepwarm -----------------------------------------------------------------------------
879 const pingIn = isWarm ? Math.max(0, remaining - ttl.ms * PING_SHARE) : 0
880 const keepCard = (
881 <Box borderStyle="round" borderColor="claude" borderDimColor={!isKeeping} paddingX={1} flexDirection="column">
882 <Box flexDirection="row" justifyContent="space-between">
883 <Text wrap="truncate-end">
884 <Text color="claude" bold>
885 {isKeeping ? '↻ Keepwarm on' : '↻ Keepwarm'}
886 </Text>
887 <Text dimColor>
888 {isKeeping && keep.until !== null ? ` until ${hhmm(keep.until)}` : ` off${keep.pings > 0 && tier !== 'narrow' ? ` · last run ${keep.pings} pings` : ''}`}
889 </Text>
890 </Text>
891 {isKeeping ? (
892 <Button key="keepwarm-stop" plain hotkey="s" onPress={() => pressKeepWarm($, null)}>
893 <Text color="claude" bold>
894 Stop
895 </Text>
896 </Button>
897 ) : (
898 canKeep && (
899 <Button key="keepwarm-start" plain hotkey="w" onPress={() => pressKeepWarm($, 2 * HOUR)}>
900 <Text color="claude" bold>
901 Keep warm 2h
902 </Text>
903 </Button>
904 )
905 )}
906 </Box>
907 <Text dimColor wrap="truncate-end">
908 {isKeeping
909 ? `${keep.pings}/${MAX_PINGS} pings · ${short(keep.readTokens)} read · next ~${remainingLabel(pingIn)}`
910 : canKeep
911 ? 'Break ahead? A ping ≈ 1/20 of a re-write.'
912 : !isInteractive
913 ? 'Interactive sessions only.'
914 : ttl.ms < HOUR
915 ? `Not worth it at a ${ttl.label} TTL.`
916 : 'Available once the cache is warm.'}
917 </Text>
918 </Box>
919 )
920
921 // --- context ------------------------------------------------------------------------------
922 const percent = measure?.percent ?? null
923 const auto = measure?.autoCompactPct ?? null
924 const autoMarks: Mark[] = auto === null ? [] : [{ at: markAt(auto / 100, rightIn), color: 0xff6b81, theme: 'error' }]
925 const contextCard = (
926 <Box borderStyle="round" borderDimColor paddingX={1} flexDirection="column">
927 <Box flexDirection="row" justifyContent="space-between">
928 <Text wrap="truncate-end">
929 <Text color="claude" bold>
930 Context{' '}
931 </Text>
932 <Text bold>{percent === null ? '–' : `${percent}%`}</Text>
933 </Text>
934 <Text dimColor>{measure?.tokens == null ? '' : `${short(measure.tokens)} / ${short(measure.window)}`}</Text>
935 </Box>
936 {gauge('context', rightIn, (percent ?? 0) / 100, FILL.context, 'suggestion', autoMarks)}
937 <Box flexDirection="row" justifyContent="space-between">
938 <Text wrap="truncate-end">
939 {auto !== null && <Text color="error">┃</Text>}
940 <Text dimColor>
941 {measure?.isAutoCompactOff ? 'auto-compact off' : auto === null ? 'auto-compact unknown' : ` auto-compact ${auto}%`}
942 </Text>
943 </Text>
944 <Text dimColor>{tier !== 'narrow' && measure !== null && measure.history.length > 2 ? `trend ${sparkline(measure.history)}` : ''}</Text>
945 </Box>
946 </Box>
947 )
948
949 // --- session ------------------------------------------------------------------------------
950 // What caching saved: every input token at the base price against what reads and writes cost,
951 // with the last request's model and the current TTL for the whole session (README › Pane).
952 const saving = cacheSaving(totals.main, readMultiplier(steps.at(-1)?.model ?? ''), writeMultiplier(ttl.ms))
953 const errorCount = Object.values(health.errors).reduce((a, n) => a + n, 0)
954 const stopCount = Object.values(health.stops).reduce((a, n) => a + n, 0)
955 const issues = [
956 errorCount > 0 ? `${errorCount} API error${errorCount === 1 ? '' : 's'}` : '',
957 stopCount > 0 ? `${stopCount} unusual stop${stopCount === 1 ? '' : 's'}` : '',
958 ]
959 .filter(Boolean)
960 .join(' · ')
961 const limits = measure?.limits ?? []
962 // Name (11) + bar + ' 100%' (5) + ' · resets Mon 17:10' (19): the bar takes what the column leaves.
963 const limitBar = Math.max(6, Math.min(24, rightIn - 35))
964 const sessionCard = (
965 <Box borderStyle="round" borderDimColor paddingX={1} flexDirection="column">
966 <Box flexDirection="row" justifyContent="space-between">
967 <Box flexShrink={0} marginRight={1}>
968 <Text color="claude" bold>
969 Session
970 </Text>
971 </Box>
972 {saving !== null && (
973 <Text bold color={saving >= 0 ? 'success' : 'warning'} wrap="truncate-end">
974 {saving >= 0 ? `cache saved ${saving}%${tier === 'narrow' ? '' : ' of input cost'}` : `cache cost +${-saving}%`}
975 </Text>
976 )}
977 </Box>
978 <Text dimColor wrap="truncate-end">
979 {usageLine(totals.main)}
980 </Text>
981 <Text wrap="truncate-end">
982 <Text dimColor>{totals.mainSteps} requests · </Text>
983 <Text bold>{measure?.usd == null ? '–' : `${isPlan(measure) ? '≈ ' : ''}$${measure.usd.toFixed(2)}`}</Text>
984 <Text dimColor>{measure?.usd == null ? '' : costLabel(measure, tier !== 'narrow')}</Text>
985 </Text>
986 {totals.subSteps > 0 && (
987 <Text dimColor wrap="truncate-end">
988 subagents {usageLine(totals.sub)} · {totals.subSteps} requests
989 </Text>
990 )}
991 {issues !== '' && (
992 <Text color="warning" wrap="truncate-end">
993 {issues} – see 3: Health
994 </Text>
995 )}
996 {totals.mainSteps === 0 ? null : colds.length === 0 ? (
997 <Text wrap="truncate-end">
998 <Text dimColor>{'Cold '}</Text>
999 <Text color="success">None – warm for every request.</Text>
1000 </Text>
1001 ) : (
1002 <Box flexDirection="column">
1003 <Text wrap="truncate-end">
1004 <Text dimColor>{'Cold '}</Text>
1005 <Text color="warning" bold>
1006 Total {totals.coldCount}× · {short(totals.coldWritten)} tokens
1007 </Text>
1008 <Text dimColor>
1009 {colds.some(c => c.cause === 'pause') ? ' · /keepwarm before a break prevents this' : ''}
1010 {totals.coldCount > 3 ? ' · showing the last 3' : ''}
1011 </Text>
1012 </Text>
1013 {colds.slice(-3).map(c => (
1014 <Text wrap="truncate-end">
1015 <Text dimColor>{' '}{hhmm(c.at)} </Text>
1016 <Text color="warning">{short(c.written)}</Text>
1017 <Text dimColor>
1018 {' '}re-written
1019 {c.cause === 'model'
1020 ? ' (after a model switch)'
1021 : c.cause === 'resume'
1022 ? ` (after resume${c.idleMs === null ? '' : `, ${duration(c.idleMs)} idle`})`
1023 : ` after ${duration(c.idleMs ?? 0)} idle`}
1024 </Text>
1025 </Text>
1026 ))}
1027 </Box>
1028 )}
1029 {limits.length === 0 && (
1030 <Text dimColor wrap="truncate-end">
1031 Plan limits: no reading (no subscription, or no answer yet)
1032 </Text>
1033 )}
1034 {limits.map(l => {
1035 const used = Math.max(0, Math.min(limitBar, Math.round((l.percentUsed / 100) * limitBar)))
1036
1037 return (
1038 <Text wrap="truncate-end">
1039 <Text dimColor>{limitName(l.kind).padEnd(11)}</Text>
1040 <Text color={l.percentUsed >= 90 ? 'error' : l.percentUsed >= 70 ? 'warning' : 'success'}>{'█'.repeat(used)}</Text>
1041 <Text color="inactive">{'░'.repeat(limitBar - used)}</Text>
1042 <Text bold> {right(`${l.percentUsed}%`, 4)}</Text>
1043 <Text dimColor>{l.resetsAt ? ` · resets ${resetLabel(l.resetsAt)}` : ''}</Text>
1044 </Text>
1045 )
1046 })}
1047 </Box>
1048 )
1049
1050 // --- requests: one column per request, its height the share not read from the cache --------
1051 const chartRows = tier === 'narrow' ? 3 : 4
1052 const slots = Math.floor(fullIn / 2)
1053 const shown = steps.slice(-slots)
1054 const last = steps.at(-1)
1055 const lastHit = last === undefined ? null : hitPct(last)
1056 const columns = shown.map(s => {
1057 const all = s.input + s.read + s.write
1058 const hit = hitPct(s) ?? 100
1059
1060 // The first write of a cache (session start, after /compact) is not a loss: drawn in blue.
1061 const color = s.kind === 'first' ? FILL.context : hit >= 80 ? HEALTH.good : hit >= 50 ? HEALTH.fair : HEALTH.poor
1062
1063 return { share: all > 0 ? (s.input + s.write) / all : 0, color }
1064 })
1065 const requestsCard = (
1066 <Box borderStyle="round" borderDimColor paddingX={1} flexDirection="column">
1067 <Box flexDirection="row" justifyContent="space-between">
1068 <Box flexShrink={0} marginRight={1}>
1069 <Text color="claude" bold>
1070 Requests
1071 </Text>
1072 </Box>
1073 <Text dimColor wrap="truncate-end">
1074 {tier === 'wide'
1075 ? 'height = share not from cache · blue = first write · newest right'
1076 : tier === 'medium'
1077 ? 'height = not from cache · blue = first write'
1078 : 'not from cache'}
1079 </Text>
1080 </Box>
1081 {steps.length === 0 && <Text dimColor>No request measured yet.</Text>}
1082 {steps.length > 0 && Raster && (
1083 <Raster key="requests" columns={fullIn} rows={chartRows} cells={rasterCells(columnCells(columns, fullIn, chartRows))} />
1084 )}
1085 {steps.length > 0 && Raster && (
1086 <Box flexDirection="row" justifyContent="space-between">
1087 <Text dimColor>{hhmm(shown[0]!.at)}</Text>
1088 <Text dimColor>{hhmm(shown.at(-1)!.at)}</Text>
1089 </Box>
1090 )}
1091 {steps.length > 0 &&
1092 !Raster &&
1093 steps.slice(-4).map(s => (
1094 <Text wrap="truncate-end">
1095 <Text dimColor>{hhmm(s.at)} </Text>
1096 <Text>{right(short(s.input + s.read + s.write), 6)} </Text>
1097 <Text bold color={hitColor(hitPct(s))}>
1098 {right(hitPct(s) === null ? '–' : `${hitPct(s)}%`, 4)}
1099 </Text>
1100 <Text dimColor> hit · out {short(s.output)}</Text>
1101 </Text>
1102 ))}
1103 {last !== undefined && Raster && (
1104 <Text wrap="truncate-end">
1105 <Text dimColor>last {short(last.input + last.read + last.write)} in · </Text>
1106 <Text bold color={hitColor(lastHit)}>
1107 {lastHit === null ? '–' : `${lastHit}%`}
1108 </Text>
1109 <Text dimColor> hit · out {short(last.output)}</Text>
1110 </Text>
1111 )}
1112 </Box>
1113 )
1114
1115 // --- context tab: where the context goes ---------------------------------------------------
1116 const itemRow = (name: string, tokens: number, top: number, nameW: number, note = '') => {
1117 const barW = Math.max(4, Math.min(24, fullIn - nameW - 20))
1118 const lit = top > 0 ? Math.max(tokens > 0 ? 1 : 0, Math.round((tokens / top) * barW)) : 0
1119
1120 return (
1121 <Box flexDirection="row">
1122 <Box width={nameW}>
1123 <Text wrap="truncate-start">{name}</Text>
1124 </Box>
1125 <Text color="suggestion"> {'█'.repeat(lit)}</Text>
1126 <Text>{' '.repeat(barW - lit)}</Text>
1127 <Text bold> {right(short(tokens), 7)}</Text>
1128 <Text dimColor wrap="truncate-end">
1129 {note}
1130 </Text>
1131 </Box>
1132 )
1133 }
1134 const window = measure?.window ?? 0
1135 const pctOf = (tokens: number) => (window > 0 ? ` ${Math.round((tokens / window) * 100)}%` : '')
1136 const nameW = Math.max(12, Math.min(34, Math.floor(fullIn * 0.4)))
1137 const topCat = map?.categories[0]?.tokens ?? 0
1138 const topFile = map?.memoryFiles[0]?.tokens ?? 0
1139 const topServer = map?.mcpServers[0]?.tokens ?? 0
1140 const contextView = (
1141 <Box flexDirection="column">
1142 {contextCard}
1143 <Box borderStyle="round" borderDimColor paddingX={1} flexDirection="column">
1144 <Box flexDirection="row" justifyContent="space-between">
1145 <Box flexShrink={0} marginRight={1}>
1146 <Text color="claude" bold>
1147 Where it goes
1148 </Text>
1149 </Box>
1150 <Text dimColor wrap="truncate-end">
1151 {map === null ? 'no reading yet' : `${map.detail === 'full' ? 'exact' : 'estimate'} · ${hhmm(map.at)}`}
1152 </Text>
1153 </Box>
1154 {map === null && <Text dimColor>Claude Code reports it after the first answer.</Text>}
1155 {map !== null && map.categories.slice(0, 8).map(c => itemRow(c.name, c.tokens, topCat, nameW, pctOf(c.tokens)))}
1156 <Box flexDirection="row" justifyContent="space-between">
1157 <Text dimColor wrap="truncate-end">
1158 {tier === 'wide' ? 'Exact count: one API request per tool and memory file, as /context' : 'exact: 1 request per tool and file'}
1159 </Text>
1160 <Box flexShrink={0} marginLeft={2}>
1161 {isCounting ? (
1162 <Text color="claude">counting…</Text>
1163 ) : (
1164 <Button key="context-exact" plain hotkey="x" onPress={() => countExactly($)}>
1165 <Text color="claude" bold>
1166 Count exactly
1167 </Text>
1168 </Button>
1169 )}
1170 </Box>
1171 </Box>
1172 </Box>
1173 {map !== null && (
1174 <Box borderStyle="round" borderDimColor paddingX={1} flexDirection="column">
1175 <Text color="claude" bold>
1176 Heaviest items
1177 </Text>
1178 <Text dimColor>Memory files (CLAUDE.md and rules)</Text>
1179 {map.memoryFiles.length === 0 && <Text dimColor> none</Text>}
1180 {map.memoryFiles.slice(0, 5).map(f => itemRow(homeShort(f.name), f.tokens, topFile, nameW))}
1181 <Text dimColor wrap="truncate-end">
1182 MCP servers{map.deferredMcpTools > 0 ? ` · ${map.deferredMcpTools} more tools behind tool search, not loaded` : ''}
1183 </Text>
1184 {map.mcpServers.length === 0 && <Text dimColor> none loaded</Text>}
1185 {map.mcpServers.slice(0, 5).map(m => itemRow(m.name, m.tokens, topServer, nameW, ` · ${m.tools} tools`))}
1186 <Text wrap="truncate-end">
1187 <Text dimColor>Skills </Text>
1188 {map.skills === null ? (
1189 <Text dimColor>–</Text>
1190 ) : (
1191 <Text>
1192 <Text bold color={map.skills.included < map.skills.total ? 'warning' : undefined}>
1193 {map.skills.included} of {map.skills.total}
1194 </Text>
1195 <Text dimColor> listed · {short(map.skills.tokens)} tokens</Text>
1196 {map.skills.included < map.skills.total && <Text dimColor> · the rest did not fit the listing budget</Text>}
1197 </Text>
1198 )}
1199 </Text>
1200 {map.agents.length > 0 && (hooks/lib.ts 455 lines1// Pure helpers of cache-meter: no `$`, so the tests call them directly.
2
3import type { CacheInfo, ContextMap, Limit, Usage4 } from '../types'
4
5export const HOUR = 3_600_000
6export const FIVE_MIN = 300_000
7
8export type Ttl = { ms: number; label: string; source: string }
9
10/** The mod's own option, then Claude Code's documented controls, then what can be inferred. */
11export type TtlInputs = {
12 setting: string | undefined
13 force5m: boolean
14 envTtl: string | null
15 settingTtl: string | null
16 enable1h: boolean
17 reported: '5m' | '1h' | null
18 limits: readonly Limit[]
19}
20
21const fixed = (v: string | null | undefined): '5m' | '1h' | null => (v === '5m' || v === '1h' ? v : null)
22const of = (v: '5m' | '1h', source: string): Ttl =>
23 v === '1h' ? { ms: HOUR, label: '60m', source } : { ms: FIVE_MIN, label: '5m', source }
24
25/**
26 * The main conversation's cache lifetime. The API reports it per request only as the split of
27 * cache writes, which no mod event carries, so it is resolved here. Order after the mod's own option
28 * is Claude Code's (code.claude.com/docs/en/prompt-caching § Choose the TTL yourself):
29 * FORCE_PROMPT_CACHING_5M, CLAUDE_CODE_PROMPT_CACHE_TTL, promptCacheTtl, ENABLE_PROMPT_CACHING_1H,
30 * then the default: one hour on a subscription within plan usage, five minutes once usage credits
31 * are drawn (taken as a rate-limit window at 100 % or more). What a model switch reported comes
32 * after the overage rule, since it can go stale.
33 */
34export function resolveTtl(i: TtlInputs): Ttl {
35 if (i.setting === '60m') return of('1h', 'cache-meter option')
36 if (i.setting === '5m') return of('5m', 'cache-meter option')
37 if (i.force5m) return of('5m', 'FORCE_PROMPT_CACHING_5M')
38 const fromVariable = fixed(i.envTtl)
39 if (fromVariable) return of(fromVariable, 'CLAUDE_CODE_PROMPT_CACHE_TTL')
40 const setting = fixed(i.settingTtl)
41 if (setting) return of(setting, 'promptCacheTtl')
42 if (i.enable1h) return of('1h', 'ENABLE_PROMPT_CACHING_1H')
43 if (i.limits.some(l => l.percentUsed >= 100)) return of('5m', 'assumed: plan limit reached')
44 if (i.reported) return of(i.reported, 'reported on model switch')
45
46 return of('1h', 'assumed: subscription')
47}
48
49/** Share of the request's input the cache served: read ÷ (new + read + written), whole percent. */
50export function hitPct(u: Usage4): number | null {
51 const all = u.input + u.read + u.write
52
53 return all === 0 ? null : Math.round((u.read / all) * 100)
54}
55
56export type CacheStatus = { kind: 'empty' } | { kind: 'expired' } | { kind: 'warm'; remaining: number }
57
58export function cacheStatus(cache: CacheInfo | null, now: number, ttlMs: number): CacheStatus {
59 if (cache === null) return { kind: 'empty' }
60 const remaining = cache.lastAt + ttlMs - now
61
62 return remaining > 0 ? { kind: 'warm', remaining } : { kind: 'expired' }
63}
64
65/** `60m` down to `1m`, then `45s`. */
66export function remainingLabel(ms: number): string {
67 return ms >= 60_000 ? `${Math.ceil(ms / 60_000)}m` : `${Math.max(1, Math.ceil(ms / 1000))}s`
68}
69
70/** How many of `cells` are lit for the time left; a warm cache lights at least one. */
71export function litCells(remaining: number, ttlMs: number, cells: number): number {
72 if (remaining <= 0 || cells <= 0) return 0
73
74 return Math.min(cells, Math.max(1, Math.ceil((remaining / ttlMs) * cells)))
75}
76
77export type Forecast = { icon: string; color: string }
78
79// Single-width symbols, not emoji, so the row lines up in every terminal font.
80// Scaled to the auto-compact point.
81const FORECAST: readonly { upTo: number; icon: string; color: string }[] = [
82 { upTo: 25, icon: '☀', color: 'yellow' },
83 { upTo: 50, icon: '☁', color: 'cyan' },
84 { upTo: 75, icon: '☂', color: 'blue' },
85 { upTo: 90, icon: '☇', color: 'magenta' },
86 { upTo: Infinity, icon: '↯', color: 'red' },
87]
88
89export function forecast(percent: number, autoCompactPct: number | null): Forecast {
90 const scale = autoCompactPct && autoCompactPct > 0 ? autoCompactPct : 100
91 const share = (percent / scale) * 100
92 const band = FORECAST.find(f => share < f.upTo) ?? { icon: '↯', color: 'red' }
93
94 return { icon: band.icon, color: band.color }
95}
96
97const BARS = '▁▂▃▄▅▆▇█'
98
99/** Block-character chart, scaled to the largest value shown. */
100export function sparkline(values: readonly number[]): string {
101 const top = Math.max(1, ...values)
102
103 return values.map(v => BARS[Math.min(BARS.length - 1, Math.floor((v / top) * (BARS.length - 1)))]).join('')
104}
105
106export function short(n: number): string {
107 if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
108 if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`
109
110 return String(Math.round(n))
111}
112
113export function hhmm(ms: number): string {
114 const d = new Date(ms)
115
116 return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
117}
118
119/** Cells of each segment of a stacked bar whose full width stands for `top` tokens. */
120export function stack(u: Usage4, top: number, width: number): { read: number; write: number; input: number } {
121 if (top <= 0 || width <= 0) return { read: 0, write: 0, input: 0 }
122 const cell = (n: number) => (n > 0 ? Math.max(1, Math.round((n / top) * width)) : 0)
123
124 return { read: cell(u.read), write: cell(u.write), input: cell(u.input) }
125}
126
127/** `/keepwarm` argument → milliseconds of warming; `null` turns it off, `undefined` is unreadable. */
128export function parseKeepWarm(args: string): number | null | undefined {
129 const a = args.trim().toLowerCase()
130 if (a === '') return 2 * HOUR
131 if (a === 'off' || a === '0') return null
132 const m = /^(\d{1,3})\s*(h|m|min)?$/.exec(a)
133 if (!m) return undefined
134 const n = Number.parseInt(m[1] ?? '', 10)
135 const ms = m[2] === 'm' || m[2] === 'min' ? n * 60_000 : n * HOUR
136 if (ms < 10 * 60_000 || ms > 8 * HOUR) return undefined
137
138 return ms
139}
140
141/** When to ping: once this share of the TTL or less is left (≈ 10 min of 60m). */
142export const PING_SHARE = 0.17
143
144/** Most pings one /keepwarm run makes (8 h at one ping per ≈ 50 min is about ten). */
145export const MAX_PINGS = 12
146
147/** A ping counts as a hit when it read at least this share of the context from the cache. */
148export const PING_HIT_SHARE = 0.5
149
150/** A ping that writes this share of what it read or more missed part of the cache. */
151export const PING_WRITE_SHARE = 0.1
152
153/** A main request that finds the cache expired and writes at least this much counts as a cold rewrite. */
154export const COLD_MIN = 10_000
155
156/** `1h12m`, `45m`, `30s`. */
157export function duration(ms: number): string {
158 const m = Math.floor(ms / 60_000)
159 if (m >= 60) return `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}m`
160 if (m >= 1) return `${m}m`
161
162 return `${Math.max(0, Math.round(ms / 1000))}s`
163}
164
165export const ZERO: Usage4 = { input: 0, read: 0, write: 0, output: 0 }
166
167export function add(a: Usage4, b: Usage4): Usage4 {
168 return { input: a.input + b.input, read: a.read + b.read, write: a.write + b.write, output: a.output + b.output }
169}
170
171// ---------------------------------------------------------------------------------------------
172// Drawing helpers for the pane: tall digits and colored cells for a Raster (reference › Elements:
173// "Raster … cells"; types: RasterProps.cells = base64 of little-endian u32 triplets
174// [codePoint, foreground, background], 0x01000000 = the terminal's default color).
175
176/** Three-row block digits for the countdown, three columns each. */
177const TALL: Readonly<Record<string, readonly [string, string, string]>> = {
178 '0': ['█▀█', '█ █', '▀▀▀'],
179 '1': ['▀█ ', ' █ ', '▀▀▀'],
180 '2': ['▀▀█', '█▀▀', '▀▀▀'],
181 '3': ['▀▀█', ' ▀█', '▀▀▀'],
182 '4': ['█ █', '▀▀█', ' ▀'],
183 '5': ['█▀▀', '▀▀█', '▀▀▀'],
184 '6': ['█▀▀', '█▀█', '▀▀▀'],
185 '7': ['▀▀█', ' █', ' ▀'],
186 '8': ['█▀█', '█▀█', '▀▀▀'],
187 '9': ['█▀█', '▀▀█', '▀▀▀'],
188 '-': [' ', '━━━', ' '],
189}
190
191/** `tall('58')` → three rows that draw 58 in block digits; unknown characters are skipped. */
192export function tall(text: string): [string, string, string] {
193 const glyphs = [...text].map(c => TALL[c]).filter((g): g is readonly [string, string, string] => g !== undefined)
194 const row = (i: 0 | 1 | 2) => glyphs.map(g => g[i]).join(' ')
195
196 return [row(0), row(1), row(2)]
197}
198
199export const DEFAULT_COLOR = 0x01000000
200
201const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
202
203/** Standard padded base64, without relying on a global the environment may not have. */
204export function base64(bytes: Uint8Array): string {
205 let out = ''
206 for (let i = 0; i < bytes.length; i += 3) {
207 const a = bytes[i] ?? 0
208 const b = bytes[i + 1] ?? 0
209 const c = bytes[i + 2] ?? 0
210 const n = (a << 16) | (b << 8) | c
211 out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]!
212 out += i + 1 < bytes.length ? B64[(n >> 6) & 63]! : '='
213 out += i + 2 < bytes.length ? B64[n & 63]! : '='
214 }
215
216 return out
217}
218
219export type Cell = readonly [codePoint: number, fg: number, bg: number]
220
221/** Packs cells as a Raster's `cells` string. */
222export function rasterCells(cells: readonly Cell[]): string {
223 const words = new Uint32Array(cells.length * 3)
224 cells.forEach(([cp, fg, bg], i) => {
225 words[i * 3] = cp
226 words[i * 3 + 1] = fg
227 words[i * 3 + 2] = bg
228 })
229 const bytes = new Uint8Array(words.length * 4)
230 words.forEach((w, i) => {
231 bytes[i * 4] = w & 255
232 bytes[i * 4 + 1] = (w >>> 8) & 255
233 bytes[i * 4 + 2] = (w >>> 16) & 255
234 bytes[i * 4 + 3] = (w >>> 24) & 255
235 })
236
237 return base64(bytes)
238}
239
240/** Fill colors of the meters, as 0xRRGGBB (the terminal may round them to its palette, gallery › Raster). */
241export const FILL = {
242 warm: 0x40c070,
243 low: 0xf0b030,
244 cold: 0xe05060,
245 context: 0x7088f0,
246} as const
247
248/** Colors of the request columns by hit rate: the same thresholds as the hit rate's text color. */
249export const HEALTH = { good: 0x40c070, fair: 0xf0b030, poor: 0xe05060 } as const
250
251export const TRACK = 0x45475a
252
253/** A marker on a meter: the cell it sits on, its Raster color and the theme key the text bar uses. */
254export type Mark = { at: number; color: number; theme: string }
255
256/** A run of same-role characters of a meter; `role` is the index of a mark for a marker cell. */
257export type Run = { text: string; role: 'fill' | 'track' | number }
258
259/** Left-to-right partial blocks, 1/8 to 7/8 of a cell. */
260const EIGHTHS = ['▏', '▎', '▍', '▌', '▋', '▊', '▉']
261
262/** The cell a fraction (0…1) of a `width`-cell meter falls on. */
263export function markAt(fraction: number, width: number): number {
264 return Math.max(0, Math.min(width - 1, Math.round(fraction * width)))
265}
266
267/**
268 * A one-row meter as runs: `fraction` of `width` cells filled, to an eighth of a cell, the rest a
269 * dim track, markers drawn over both. One solid fill color, not a gradient: the terminal rounds
270 * Raster colors to a smaller palette (gallery › Raster), which turned a gradient into bands.
271 */
272export function meterRuns(width: number, fraction: number, marks: readonly Mark[] = []): Run[] {
273 const filled = Math.max(0, Math.min(1, fraction)) * width
274 let full = Math.floor(filled)
275 let part = Math.round((filled - full) * 8)
276 if (part === 8) {
277 full += 1
278 part = 0
279 }
280 const runs: Run[] = []
281 const push = (ch: string, role: Run['role']) => {
282 const last = runs[runs.length - 1]
283 if (last !== undefined && last.role === role) last.text += ch
284 else runs.push({ text: ch, role })
285 }
286 for (let i = 0; i < width; i++) {
287 const mark = marks.findIndex(m => m.at === i)
288 if (mark >= 0) push('┃', mark)
289 else if (i < full) push('█', 'fill')
290 else if (i === full && part > 0) push(EIGHTHS[part - 1]!, 'fill')
291 else push('░', 'track')
292 }
293
294 return runs
295}
296
297/** The meter's runs as Raster cells. */
298export function meterCells(runs: readonly Run[], fill: number, marks: readonly Mark[] = []): Cell[] {
299 return runs.flatMap(r =>
300 [...r.text].map((ch): Cell => [
301 ch.codePointAt(0)!,
302 r.role === 'fill' ? fill : r.role === 'track' ? TRACK : (marks[r.role]?.color ?? TRACK),
303 DEFAULT_COLOR,
304 ]),
305 )
306}
307
308/** Bottom-up partial blocks, 1/8 to 8/8 of a cell. */
309const LEVELS = [0x2581, 0x2582, 0x2583, 0x2584, 0x2585, 0x2586, 0x2587, 0x2588]
310
311/** One column of the request chart: its height as a share (0…1) and its color. */
312export type Column = { share: number; color: number }
313
314/**
315 * A column chart `rows` tall, row-major from the top: one two-cell column per value, newest at the
316 * right edge, heights to an eighth of a cell; any share above zero shows at least an eighth. Slots
317 * without a value, and the bottom row under a zero, show a dim baseline.
318 */
319export function columnCells(values: readonly Column[], width: number, rows: number): Cell[] {
320 const slots = Math.floor(width / 2)
321 const shown = values.slice(-slots)
322 const offset = slots - shown.length
323 const cells: Cell[] = []
324 for (let r = 0; r < rows; r++) {
325 const fromBottom = rows - 1 - r
326 for (let x = 0; x < width; x++) {
327 const v = x < slots * 2 ? shown[Math.floor(x / 2) - offset] : undefined
328 const levels = v === undefined ? 0 : v.share > 0 ? Math.max(1, Math.round(Math.min(1, v.share) * rows * 8)) : 0
329 const level = Math.max(0, Math.min(8, levels - fromBottom * 8))
330 if (level > 0 && v !== undefined) cells.push([LEVELS[level - 1]!, v.color, DEFAULT_COLOR])
331 else if (fromBottom === 0) cells.push([LEVELS[0]!, TRACK, DEFAULT_COLOR])
332 else cells.push([0x20, DEFAULT_COLOR, DEFAULT_COLOR])
333 }
334 }
335
336 return cells
337}
338
339/**
340 * Cache-read price as a multiple of the base input price, by model: 0.05× on Opus 5.5 and Sonnet
341 * 5.5, 0.025× on Fable 5.1 and Mythos 5.1, 0.1× elsewhere. Source and how to keep these current:
342 * CONTRIBUTING.md › Prices (pricing › Prompt caching, checked 2026-10-12).
343 */
344export function readMultiplier(model: string): number {
345 if (/(opus|sonnet)-5-5/.test(model)) return 0.05
346 if (/(fable|mythos)-5-1/.test(model)) return 0.025
347
348 return 0.1
349}
350
351/** Cache-write price as a multiple of the base input price: 2× for a one-hour write, 1.25× for five minutes. */
352export function writeMultiplier(ttlMs: number): number {
353 return ttlMs >= HOUR ? 2 : 1.25
354}
355
356/**
357 * The share of the input cost the cache saved, in percent, against sending every input token
358 * uncached: negative when writing cost more than reading saved. Null before any input.
359 */
360export function cacheSaving(u: Usage4, readMult: number, writeMult: number): number | null {
361 const all = u.input + u.read + u.write
362 if (all === 0) return null
363 const paid = u.input + u.read * readMult + u.write * writeMult
364
365 return Math.round((1 - paid / all) * 100)
366}
367
368/** Right-aligns `text` in `width` columns. */
369export function right(text: string, width: number): string {
370 return text.length >= width ? text : ' '.repeat(width - text.length) + text
371}
372
373/** The parts of `$.session.usage`'s breakdown a context map reads (types: SessionContextBreakdown). */
374export type BreakdownLike = {
375 categories: readonly { name: string; tokens: number; kind: string }[]
376 memoryFiles: readonly { path: string; tokens: number }[]
377 mcpTools: readonly { serverName: string; tokens: number; isLoaded: boolean }[]
378 agents: readonly { agentType: string; tokens: number }[]
379 skills?: { totalSkills: number; includedSkills: number; tokens: number }
380}
381
382const heaviest = <T extends { tokens: number }>(items: readonly T[]): T[] =>
383 [...items].filter(i => i.tokens > 0).sort((a, b) => b.tokens - a.tokens)
384
385/** The heaviest shares of the context, from a breakdown: categories in use, memory files, MCP servers, agents, skills. */
386export function contextMapOf(b: BreakdownLike, detail: ContextMap['detail'], at: number): ContextMap {
387 // Loaded MCP tools summed per server (a plain record: the directory's scan reads Map.set like the shell's set).
388 const servers: Record<string, { tokens: number; tools: number }> = {}
389 for (const tool of b.mcpTools) {
390 if (!tool.isLoaded) continue
391 const sum = servers[tool.serverName] ?? { tokens: 0, tools: 0 }
392 servers[tool.serverName] = { tokens: sum.tokens + tool.tokens, tools: sum.tools + 1 }
393 }
394
395 return {
396 detail,
397 at,
398 categories: heaviest(b.categories.filter(c => c.kind === 'used')).map(c => ({ name: c.name, tokens: c.tokens })),
399 memoryFiles: heaviest(b.memoryFiles).map(f => ({ name: f.path, tokens: f.tokens })),
400 mcpServers: heaviest(Object.entries(servers).map(([name, sum]) => ({ name, ...sum }))),
401 deferredMcpTools: b.mcpTools.filter(t => !t.isLoaded).length,
402 agents: heaviest(b.agents).map(a => ({ name: a.agentType, tokens: a.tokens })),
403 skills: b.skills ? { total: b.skills.totalSkills, included: b.skills.includedSkills, tokens: b.skills.tokens } : null,
404 }
405}
406
407/** A request that ended normally: its answer was complete, or it stopped to run tools. */
408export function isNormalStop(reason: string | null): boolean {
409 return reason === null || reason === 'end_turn' || reason === 'tool_use'
410}
411
412const STOP_LABELS: Readonly<Record<string, string>> = {
413 max_tokens: 'output limit',
414 model_context_window_exceeded: 'context full',
415 compaction: 'compaction',
416 refusal: 'refusal',
417 pause_turn: 'paused',
418 stop_sequence: 'stop sequence',
419}
420
421/** The stop reason in words (types: TurnStopReason). */
422export function stopLabel(reason: string): string {
423 return STOP_LABELS[reason] ?? reason
424}
425
426const ERROR_LABELS: Readonly<Record<string, string>> = {
427 rate_limit: 'rate limit',
428 overloaded: 'overloaded',
429 max_output_tokens: 'output limit',
430 server_error: 'server error',
431 billing_error: 'billing',
432 authentication_failed: 'authentication',
433 invalid_request: 'invalid request',
434 model_not_found: 'model not found',
435}
436
437/** The error a turn ended on, in words (types: SDKAssistantMessageError). */
438export function errorLabel(error: string): string {
439 return ERROR_LABELS[error] ?? error.replace(/_/g, ' ')
440}
441
442/** `{ a: 2, b: 1 }` → `a 2× · b 1×`, largest first; '' when empty. */
443export function tally(counts: Readonly<Record<string, number>>, label: (key: string) => string = k => k): string {
444 return Object.entries(counts)
445 .filter(([, n]) => n > 0)
446 .sort((a, b) => b[1] - a[1])
447 .map(([k, n]) => `${label(k)} ${n}×`)
448 .join(' · ')
449}
450
451/** Adds one to `key`. */
452export function bump(counts: Readonly<Record<string, number>>, key: string): Record<string, number> {
453 return { ...counts, [key]: (counts[key] ?? 0) + 1 }
454}
455types/index.d.ts 107 lines1export type Usage4 = { input: number; read: number; write: number; output: number }
2
3/** One main-thread model request as the API counted it, timed at the request's start. */
4export type Step = Usage4 & { at: number; model: string; kind?: 'first' | 'cold' }
5
6/** The main thread's prompt cache: when a request last touched it, and how well the last one hit it. */
7export type CacheInfo = {
8 /** Start of the request that last read or wrote the cache (the lifetime counts from there). 0 = known cold. */
9 lastAt: number
10 hitPct: number | null
11 /** Tokens the next request re-sends, as far as known (input + read + write + output of the last response). */
12 contextTokens: number
13 /** What re-caching would cost, when the engine estimated it (resume, model switch). */
14 rewriteUsd: number | null
15 /** Why the cache is known cold although the clock cannot say (lastAt 0): a model switch or a resume. */
16 coldReason: 'model' | 'resume' | null
17 /** On a cold resume: how long the session had been idle, as Claude Code reported it. */
18 idleBeforeMs: number | null
19}
20
21export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
22
23export type Measure = {
24 percent: number | null
25 tokens: number | null
26 window: number
27 /** Auto-compact threshold over the same window as `percent`; null while unknown or when off. */
28 autoCompactPct: number | null
29 /** True when the session reported auto-compact as switched off. */
30 isAutoCompactOff: boolean
31 /** The window the auto-compact figure was computed for; 0 = compute again. */
32 autoCompactWindow: number
33 /** Context tokens after each main-thread turn, oldest first. */
34 history: number[]
35 limits: Limit[]
36 usd: number | null
37 /** When this session's usage began ($.session.usage().startedAt; /clear starts it over). */
38 startedAt: number | null
39 /** Prompts the user has sent this session ($.session.turns()). */
40 prompts: number | null
41}
42
43/** Session sums; the cold-rewrite count and tokens are kept apart from the (capped) list of cold rewrites. */
44export type Totals = { main: Usage4; sub: Usage4; mainSteps: number; subSteps: number; coldCount: number; coldWritten: number }
45
46export type KeepWarm = { until: number | null; pings: number; readTokens: number; lastPingAt: number | null }
47
48/** A main request that found the cache expired and wrote it anew: after a pause, a model switch, or a resume. */
49export type ColdWrite = { at: number; written: number; cause: 'pause' | 'model' | 'resume'; idleMs: number | null }
50
51/** A named share of the context, in tokens. */
52export type ContextItem = { name: string; tokens: number }
53
54/** Where the context goes, from $.session.usage's breakdown: the heaviest items, and how they were counted. */
55export type ContextMap = {
56 /** 'summary' = Claude Code's local estimate (free); 'full' = counted by the API on request, as /context does. */
57 detail: 'summary' | 'full'
58 at: number
59 /** Categories that hold tokens (kind 'used'), heaviest first. */
60 categories: ContextItem[]
61 memoryFiles: ContextItem[]
62 /** Loaded MCP tools summed per server; `tools` counts them. */
63 mcpServers: (ContextItem & { tools: number })[]
64 /** MCP tools behind tool search, not in the context until loaded. */
65 deferredMcpTools: number
66 agents: ContextItem[]
67 skills: { total: number; included: number; tokens: number } | null
68}
69
70/** One server-side tool call (the API ran it, e.g. the advisor): no tool.call fires for these. */
71export type ServerToolCall = { name: string; ms: number | null }
72
73/** What went wrong or out of the ordinary this session, beyond the cache. */
74export type Health = {
75 /** Main requests by stop reason, only the ones that are not a normal end (end_turn, tool_use). */
76 stops: Record<string, number>
77 /** Errors a turn ended on (classic StopFailure), main conversation and subagents apart. */
78 errors: Record<string, number>
79 subErrors: Record<string, number>
80 /** Server-side tool calls of the main conversation, summed per tool name. */
81 serverTools: Record<string, { count: number; ms: number }>
82 /** The server-side tool calls of the last main request that had any. */
83 lastServerTools: { at: number; calls: ServerToolCall[] } | null
84 /** Claude Code's own verdict on the cache against Cache Meter's, at model switches and resumes. */
85 checks: { agreed: number; differed: number; last: { at: number; engineWarm: boolean; ourWarm: boolean; source: 'model' | 'resume' } | null }
86}
87
88/** The TTL controls Claude Code documents (prompt-caching § Choose the TTL yourself), read at start. */
89export type TtlConfig = { force5m: boolean; envTtl: string | null; settingTtl: string | null; enable1h: boolean }
90
91declare module 'claude-code' {
92 interface PluginState {
93 'cache-meter': {
94 cache: CacheInfo | null
95 steps: Step[]
96 totals: Totals
97 measure: Measure | null
98 reportedTtl: '5m' | '1h' | null
99 keepWarm: KeepWarm
100 ttlConfig: TtlConfig
101 coldWrites: ColdWrite[]
102 contextMap: ContextMap | null
103 health: Health
104 }
105 }
106}
107