Shows when your prompt cache goes cold, and stops the expensive send.

<img src="../../brand/pixel-icons/svg/thermostat.svg" width="64" height="64" alt="">
Shows when your prompt cache goes cold, and stops the expensive send.
Claude Code keeps your conversation in a prompt cache. After a stretch of quiet (about an hour on a subscription, 5 minutes on an API key) the cache lapses, and the next message re-reads the whole conversation at the full cache-write price. Thermostat shows you that clock, holds the surprise send once, and can keep the cache warm while you are away when that is actually cheaper.
/plugin install thermostat --marketplace modmyclaude/claude-code-mods
The countdown. At the end of the prompt footer, beside the mode labels:
cache warm · 41m left - quiet while there is timecache warm · 8m left - amber in the last 10 minutes (the last 2 of a 5-minute window)cache cold · 182k to re-read - once it has lapsed· warming 1h12m is added while /warm runsThis draws on the terminal and the desktop app. Where no footer draws (VS Code, mobile, or a footer that is hidden) the same text appears on the plain status line under the prompt, without colour. Nothing shows until the conversation has something cached.
The guard. When you send a prompt on a cold cache, it is held once and you see what it costs:
Cache went cold 23m ago: this send re-reads 150k tokens (~$1.20 vs ~$0.03 warm, Opus 5.5 API list prices). Send again to go ahead, or /clear to start fresh.
Send it again to go ahead (the words are put back in the prompt box if it is empty), or /clear to start a fresh conversation instead. It only holds prompts you type yourself (at the terminal, in the desktop app, or through Remote Control; never a headless claude -p run, where nobody would see the hold), only when the re-read is at least 20k tokens, never a prompt typed while a turn is running, and a hold counts as seen for 10 minutes.
Keep-warm. /warm 90m (or 2h, 1h30m, 45) sends a tiny ping just before the cache would go cold, for as long as you asked. Before every ping it compares what the remaining pings will cost with what one cold re-read would add; if pinging costs more, it stops and says so in the transcript. It also refuses up front when the whole window would not pay. /warm on its own shows where it stands, /warm off stops it. Only a /warm you type starts or stops it; another plugin's command cannot. Confirmations appear as a toast; when it will not start, the reason stays in the transcript as a dim note. A window can run up to 24 hours.
How a ping works: it re-sends the conversation's last request (same model, system prompt, tools and messages) with one line asking for the word "ok", tools denied. That reads the conversation's own cache entry, which restarts its timer. Nothing is added to your transcript. Every ping's usage is checked: if it did not read the conversation back from the cache, keep-warm stops rather than keep paying for pings that are not helping.
The receipt. /thermostat opens a pane (a transcript note where no pane can be drawn, as in claude -p) laid out to the collection's UI standard: the cache right now as one line in its colour (Warm · 41m left, Cold · 98k to re-read), the window's bar and the next send's price beneath, Bolt beside them (asleep while the cache is cold or empty); then a row each, the figure at the right edge and the detail dim beneath, for cold sends paid (and how much more than warm they cost), prompts held (and how many you answered with /clear), keep-warm pings (what they cost, and the sends that arrived warm because of them), money saved, Claude Code's own estimate of the session's total cost (what /cost sums at list or managed prices, not your bill), and the guard; a figure still at zero draws no row. One footnote says what the dollars are.
Every setting has a default, so the notice at install about options not yet set is expected; change them in /config.
| Field | Default | What it does |
|---|---|---|
guard | hold | hold stops the first cold send once and shows the cost. warn shows the cost in a toast and sends. off does nothing. |
cacheMinutes | 0 | The cache window in minutes, used until Claude Code reports its own. 0 works it out from your login. |
The window is worked out in this order: what Claude Code itself reports (its cache TTL on a model switch, or whether a resumed cache has expired), then cacheMinutes, then your login (60 minutes on a subscription, 5 on an API key), and 5 minutes when none of these is known.
Prices come from a small table of API list prices per model family (October 2026), with cache writes at 1.25x input for the 5-minute window and 2x for the hour. When Claude Code prices a re-cache itself (on a resume or a model switch, including an organisation's managed pricing), its rate is used instead. On a subscription the dollars measure how much of your plan a send uses, not a bill.
/warm and /thermostat answer with no command output when you type them, so they cost no tokens. The only command text is a one-line notice when /warm is run by something other than you (keep-warm changes only with a /warm you type) and a one-line error notice if either command fails; neither carries any of your data. No files, no settings, nothing kept across sessions./warm, at most one per cache window (about every 58 minutes on a subscription, every 4 minutes on a 5-minute window). Each costs roughly one cache read of the conversation plus a few tokens; the smart rule stops them as soon as they would cost more than they save, a window ends after 24 hours at most, and three failed pings in a row end it too.cacheMinutes. Other things can make a send cold early (tools or instructions changing, the cache being evicted); the receipt still books those from the real token counts.This mod was built from the Thermostat prompt.
More mods: the full catalogue.
hooks/register.tsx 758 lines1// Thermostat: a countdown to the prompt cache going cold, a guard that holds
2// the first expensive cold send once, keep-warm pings that stop when they
3// stop paying, and a receipt of what it all cost and saved.
4
5import { atom, read, update } from 'claude-code'
6import type { ClassicEventOf, EngineInterface, PromptOrigin, Register, ThemeKey, Timer, TurnUsage } from 'claude-code'
7
8import type { ThermostatAuth, ThermostatCache, ThermostatEngineWrite, ThermostatReceipt, ThermostatWarm } from '../types'
9import { boltSprite } from './bolt-draw'
10import { glyphRow } from './cells'
11import { receiptLines, receiptText } from './receipt'
12import type { ReceiptLine, ReceiptTone } from './receipt'
13import { engineWriteRate, estimatePingUsd, estimateSend, pricesFor, pricesLabel, usageUsd, writeRate } from './pricing'
14import type { Prices, SendEstimate } from './pricing'
15import { HOUR, MINUTE, SECOND, formatDuration, formatTokens, formatUsd, parseDuration, pingLeadMs, planKeepWarm, readSettings, resolveTtl, statusColor, statusText, temperature, ttlFromResume, clean } from './thermo'
16import type { Settings, Temperature, Ttl } from './thermo'
17
18type Dollar = EngineInterface
19
20/** How often the countdown redraws and keep-warm looks at the clock. */
21const TICK_MS = 30 * SECOND
22/** Below this a cold send costs pennies: not worth stopping anyone for. */
23const HOLD_MIN_TOKENS = 20_000
24/** How long a hold counts as seen, so the next send in that cold spell goes through. */
25const HOLD_ACK_MS = 10 * MINUTE
26/** Smaller re-reads are not counted as cold sends on the receipt. */
27const COLD_MIN_TOKENS = 4_096
28/** A request this large that neither read nor wrote the cache means caching is off. */
29const OFF_MIN_TOKENS = 4_096
30/** No footer drawn for this long: carry the countdown on the plain status line. */
31const FOOTER_STALE_MS = 90 * SECOND
32const MAX_WARM_MS = 24 * HOUR
33/** Consecutive failed pings before keep-warm gives up. */
34const MAX_PING_FAILURES = 3
35const PING_PROMPT =
36 'Keep-alive ping from the thermostat plugin, sent only to keep the prompt cache warm. Reply with the single word ok and nothing else. Do not use tools.'
37/** The pane `/thermostat` opens. */
38const RECEIPT_PANE = 'thermostat-receipt'
39/** Narrower than this, the receipt keeps every column for its lines and Bolt steps out. */
40const BOLT_MIN_COLUMNS = 40
41/** The receipt lines drawn beside Bolt: the state, the window and the next send. */
42const HEAD_KEYS = new Set(['state', 'window', 'next'])
43const USAGE = 'Use /warm 90m (or 2h, 1h30m, 45) to keep the cache warm while you are away, and /warm off to stop.'
44
45const EMPTY_CACHE: ThermostatCache = { lastAt: null, lastRealAt: null, tokens: 0, model: null, isOff: false }
46const EMPTY_RECEIPT: ThermostatReceipt = {
47 coldSends: 0,
48 coldTokens: 0,
49 coldExtraUsd: 0,
50 held: 0,
51 clearedAfterHold: 0,
52 clearAvoidedUsd: 0,
53 pings: 0,
54 pingUsd: 0,
55 rescued: 0,
56 rescuedUsd: 0,
57}
58
59const cacheState = atom({ plugin: 'thermostat', key: 'cache' } as const, EMPTY_CACHE)
60const warmState = atom({ plugin: 'thermostat', key: 'warm' } as const, null)
61const heldState = atom({ plugin: 'thermostat', key: 'held' } as const, null)
62const receiptState = atom({ plugin: 'thermostat', key: 'receipt' } as const, EMPTY_RECEIPT)
63const authState = atom({ plugin: 'thermostat', key: 'auth' } as const, null)
64const engineTtlState = atom({ plugin: 'thermostat', key: 'engineTtl' } as const, null)
65const engineWriteState = atom({ plugin: 'thermostat', key: 'engineWrite' } as const, null)
66
67/** Everything a decision needs, read at one moment. */
68type Snapshot = {
69 now: number
70 cache: ThermostatCache
71 warm: ThermostatWarm | null
72 auth: ThermostatAuth | null
73 ttl: Ttl
74 ttlMs: number
75 temp: Temperature
76 prices: Prices
77 /** The next send's input side, cold and warm. */
78 send: SendEstimate
79}
80
81/**
82 * Only a person at a prompt can read a hold and send again: the terminal's
83 * own composer, Remote Control, or the desktop app, whose prompts arrive as
84 * an SDK host's (`sdk`) with a desktop surface attached. A plain `claude -p`
85 * run is `sdk` with no surface: nobody would see the hold, so it is never held.
86 */
87async function isPerson($: Dollar, origin: PromptOrigin): Promise<boolean> {
88 if (origin.kind === 'composer' || origin.kind === 'bridge') return true
89 return origin.kind === 'sdk' && (await $.session.surfaces()).includes('desktop')
90}
91
92function pingSummary(warm: ThermostatWarm): string {
93 return `${warm.pings} ping${warm.pings === 1 ? '' : 's'} sent, ~${formatUsd(warm.usd)}.`
94}
95
96/**
97 * What lives only as long as this copy of the module: `register` starts it
98 * afresh on every load and reload, while `$.state` above keeps the session.
99 */
100type Live = {
101 settings: Settings
102 ticker: Timer | undefined
103 isTicking: boolean
104 isPinging: boolean
105 pingFailures: number
106 /** When a footer last drew the countdown; the status line stands in once it is stale. */
107 footerSeenAt: number
108 /** What this mod last put on the plain status line. */
109 shownStatus: string | undefined
110}
111
112function freshLive(settings: Settings): Live {
113 return {
114 settings,
115 ticker: undefined,
116 isTicking: false,
117 isPinging: false,
118 pingFailures: 0,
119 footerSeenAt: 0,
120 shownStatus: undefined,
121 }
122}
123
124let live: Live = freshLive(readSettings({}))
125
126/** List prices for a model, with Claude Code's own cache-write rate when it has priced that family. */
127function pricesWithEngine(model: string, tokens: number, engineWrite: ThermostatEngineWrite | null): Prices {
128 const list = pricesFor(model, tokens)
129 return engineWrite !== null && engineWrite.family === list.family ? { ...list, write: engineWrite.usdPerMTok } : list
130}
131
132async function snapshot($: Dollar): Promise<Snapshot> {
133 const [now, cache, warm, auth, engineTtl, engineWrite] = await Promise.all([
134 $.clock.now(),
135 read($, cacheState),
136 read($, warmState),
137 read($, authState),
138 read($, engineTtlState),
139 read($, engineWriteState),
140 ])
141 const ttl = resolveTtl(live.settings.cacheMinutes, engineTtl, auth)
142 const ttlMs = ttl.minutes * MINUTE
143 const prices = pricesWithEngine(cache.model ?? '', cache.tokens, engineWrite)
144 return {
145 now,
146 cache,
147 warm,
148 auth,
149 ttl,
150 ttlMs,
151 temp: temperature(now, cache, ttlMs),
152 prices,
153 send: estimateSend(cache.tokens, prices, ttl.minutes),
154 }
155}
156
157function statusFor(s: Snapshot): string {
158 return statusText(s.temp, s.cache.tokens, s.warm === null ? null : s.warm.until - s.now)
159}
160
161/** `the cache went cold 23m ago, so this send re-reads 182k tokens: ...` */
162/**
163 * The hold, as the person reads it under the desktop's own "a hook blocked your prompt"
164 * frame: the cost first, in two short lines; the frame already names the hold.
165 */
166function holdText(s: Snapshot, files: string): string {
167 return `${coldLine(s)}\nSend again to go ahead, or /clear to start fresh.${files}`
168}
169
170/** The cost of a cold send in one line, for the hold and the warn-only toast. */
171function coldLine(s: Snapshot): string {
172 const ago = s.temp.kind === 'cold' ? ` ${formatDuration(s.temp.msAgo)} ago` : ''
173 return (
174 `Cache went cold${ago}: this send re-reads ${formatTokens(s.cache.tokens)} tokens ` +
175 `(~${formatUsd(s.send.coldUsd)} vs ~${formatUsd(s.send.warmUsd)} warm, ${pricesLabel(s.prices)}).`
176 )
177}
178
179function startTicker($: Dollar, now: number): void {
180 if (live.ticker !== undefined) return
181 // A grace period: the footer has this long to draw before the status line stands in.
182 live.footerSeenAt = Math.max(live.footerSeenAt, now)
183 live.ticker = $.clock.every(TICK_MS, () => void tick($))
184}
185
186async function tick($: Dollar): Promise<void> {
187 if (live.isTicking) return
188 live.isTicking = true
189 try {
190 const s = await snapshot($)
191 // The footer label counts down by being drawn again.
192 $.ui.invalidate('ui.render')
193 // A surface that drew no footer lately (VS Code, mobile, a hidden footer)
194 // gets the countdown on the plain status line instead, without colour.
195 const fallback = s.now - live.footerSeenAt > FOOTER_STALE_MS ? statusFor(s) || undefined : undefined
196 if (fallback !== live.shownStatus) {
197 live.shownStatus = fallback
198 $.ui.status(fallback)
199 }
200 await keepWarm($, s)
201 } catch (error) {
202 $.ui.log(`thermostat: tick failed: ${String(error)}`, { to: 'debug' })
203 } finally {
204 live.isTicking = false
205 }
206}
207
208async function stopWarm($: Dollar, reason: string): Promise<void> {
209 const warm = await read($, warmState)
210 if (warm === null) return
211 await update($, warmState, () => null)
212 $.ui.log(`thermostat: ${reason}. ${pingSummary(warm)}`)
213}
214
215async function keepWarm($: Dollar, s: Snapshot): Promise<void> {
216 const warm = s.warm
217 if (warm === null) return
218 if (s.cache.lastAt === null) return stopWarm($, 'keep-warm stopped, nothing is cached any more')
219 const step = planKeepWarm({
220 now: s.now,
221 lastAt: s.cache.lastAt,
222 until: warm.until,
223 ttlMs: s.ttlMs,
224 leadMs: pingLeadMs(s.ttlMs),
225 pingUsd: warm.lastPingUsd ?? estimatePingUsd(s.cache.tokens, s.prices),
226 extraUsd: s.send.extraUsd,
227 })
228 switch (step.kind) {
229 case 'finished':
230 return stopWarm($, `keep-warm finished after ${formatDuration(warm.until - warm.startedAt)}`)
231 case 'cold':
232 return stopWarm($, 'keep-warm stopped, the cache went cold before a ping could go out (was this machine asleep?)')
233 case 'stop':
234 return stopWarm(
235 $,
236 `keep-warm stopped, ${step.pingsLeft} more ping${step.pingsLeft === 1 ? '' : 's'} (~${formatUsd(step.costUsd)}) ` +
237 `would cost more than the one cold re-read they save (~${formatUsd(s.send.extraUsd)})`,
238 )
239 case 'ping':
240 // Due only once no request has touched the cache for a window less the
241 // lead: a turn busy making requests never gets one, a turn stuck in one
242 // long tool call does, and needs it.
243 if (!live.isPinging) await ping($, s)
244 return
245 case 'covered':
246 case 'wait':
247 return
248 }
249}
250
251/**
252 * One keep-warm ping. `$.model.fork` re-sends the main thread's last request
253 * (its model, system prompt, tools and messages) with one line after it, so it
254 * reads the conversation's OWN cache entry, and a read restarts that entry's
255 * timer. A separate completion would warm a different entry and do nothing
256 * here. The fork's usage proves it every time: if it did not read the
257 * conversation back from the cache, keep-warm stops instead of pretending.
258 */
259async function ping($: Dollar, s: Snapshot): Promise<void> {
260 live.isPinging = true
261 try {
262 const sentAt = await $.clock.now()
263 const reply = await $.model.fork({ prompt: PING_PROMPT })
264 if (!reply.isAnswered && reply.reason === 'nothing-to-fork') {
265 return stopWarm($, 'keep-warm stopped, the conversation has nothing to keep warm')
266 }
267 const usage = reply.usage
268 if (usage.cache_read_input_tokens + usage.cache_creation_input_tokens === 0) {
269 // No request reached the cache (an API error, an abort): try again next tick, a few times.
270 live.pingFailures += 1
271 const why = reply.isAnswered ? 'no cache activity' : clean(reply.reason)
272 if (live.pingFailures >= MAX_PING_FAILURES) await stopWarm($, `keep-warm stopped, ${live.pingFailures} pings in a row failed (${why})`)
273 return
274 }
275 live.pingFailures = 0
276 const usd = usageUsd(usage, s.prices, s.ttl.minutes)
277 await update($, warmState, w => (w === null ? null : { ...w, pings: w.pings + 1, usd: w.usd + usd, lastPingUsd: usd }))
278 await update($, receiptState, r => ({ ...r, pings: r.pings + 1, pingUsd: r.pingUsd + usd }))
279 // Warm, the fork reads back nearly the whole conversation (all but the
280 // last reply, which the next real request writes either way).
281 if (usage.cache_read_input_tokens < s.cache.tokens * 0.5) {
282 return stopWarm(
283 $,
284 `keep-warm stopped, the ping read only ${formatTokens(usage.cache_read_input_tokens)} of ` +
285 `${formatTokens(s.cache.tokens)} tokens from the cache, so it was not keeping this conversation warm`,
286 )
287 }
288 // A read restarts the cache's clock from the start of the request.
289 await update($, cacheState, c => (c.lastAt === null ? c : { ...c, lastAt: Math.max(c.lastAt, sentAt) }))
290 } finally {
291 live.isPinging = false
292 }
293}
294
295/** Books one finished main-thread request: the cache's new clock, and whether it ran cold. */
296async function recordStep($: Dollar, sentAt: number, u: TurnUsage): Promise<void> {
297 const s = await snapshot($)
298 const prior = s.cache
299 const promptTokens = u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens
300 const prices = pricesWithEngine(u.model, promptTokens, await read($, engineWriteState))
301 const premiumPerToken = (writeRate(prices, s.ttl.minutes) - prices.read) / 1e6
302 const isOff = promptTokens >= OFF_MIN_TOKENS && u.cache_read_input_tokens === 0 && u.cache_creation_input_tokens === 0
303 // Warm, a request reads back at least the prompt before it; far less means the cache had lapsed.
304 const isCold =
305 !isOff && prior.lastAt !== null && prior.tokens >= COLD_MIN_TOKENS && u.cache_read_input_tokens < prior.tokens * 0.5
306 // Kept warm by pings: the last real request is older than the window, yet this one read the cache.
307 const isRescued =
308 !isCold &&
309 !isOff &&
310 prior.lastAt !== null &&
311 prior.lastRealAt !== null &&
312 prior.lastAt > prior.lastRealAt &&
313 sentAt - prior.lastRealAt > s.ttlMs
314 await update($, cacheState, () => ({
315 lastAt: sentAt,
316 lastRealAt: sentAt,
317 tokens: promptTokens + u.output_tokens,
318 model: u.model,
319 isOff,
320 }))
321 if (isCold) {
322 await update($, receiptState, r => ({
323 ...r,
324 coldSends: r.coldSends + 1,
325 coldTokens: r.coldTokens + u.cache_creation_input_tokens,
326 coldExtraUsd: r.coldExtraUsd + u.cache_creation_input_tokens * premiumPerToken,
327 }))
328 } else if (isRescued) {
329 await update($, receiptState, r => ({
330 ...r,
331 rescued: r.rescued + 1,
332 rescuedUsd: r.rescuedUsd + u.cache_read_input_tokens * premiumPerToken,
333 }))
334 }
335}
336
337async function resetCache($: Dollar): Promise<void> {
338 await update($, cacheState, () => EMPTY_CACHE)
339 await update($, heldState, () => null)
340}
341
342async function learnAuth($: Dollar): Promise<void> {
343 try {
344 // Only the kind is read: the credential stays with Claude Code, and this
345 // mod makes no network call that could spend the handle.
346 const found = await $.session.authorize()
347 const auth: ThermostatAuth = found === null ? 'none' : found.kind === 'bearer' ? 'bearer' : 'api-key'
348 await update($, authState, () => auth)
349 } catch {
350 // Unknown login: the 5-minute window is assumed until Claude Code says otherwise.
351 }
352}
353
354async function restoreDraft($: Dollar, text: string): Promise<void> {
355 try {
356 // Put the held words back so sending again is one key, never over a new draft.
357 const box = await $.prompt.read()
358 if (box.text.trim() === '') await $.prompt.fill({ text })
359 } catch {
360 // A convenience only: the hold message already says to send it again.
361 }
362}
363
364async function onResume($: Dollar, e: ClassicEventOf['classic.SessionStart']): Promise<void> {
365 const secondsAgo = e.seconds_since_last_response
366 if (secondsAgo === undefined || e.context_tokens === undefined || e.context_tokens <= 0) return
367 const now = await $.clock.now()
368 const lastAt = now - secondsAgo * SECOND
369 const model = e.model ?? (await $.session.model())
370 const tokens = e.context_tokens
371 await update($, cacheState, () => ({ lastAt, lastRealAt: lastAt, tokens, model, isOff: false }))
372 if (e.prompt_cache_likely_expired !== undefined) {
373 const minutes = ttlFromResume(secondsAgo, e.prompt_cache_likely_expired)
374 if (minutes !== null) await update($, engineTtlState, () => minutes)
375 }
376 await learnEngineWrite($, model, e.estimated_cache_write_usd, tokens)
377}
378
379/** Keeps the cache-write rate behind an estimate Claude Code made of re-caching `tokens` on `model`. */
380async function learnEngineWrite($: Dollar, model: string, estimatedUsd: number | undefined, tokens: number): Promise<void> {
381 const rate = estimatedUsd === undefined ? null : engineWriteRate(estimatedUsd, tokens)
382 if (rate === null) return
383 const family = pricesFor(model).family
384 await update($, engineWriteState, () => ({ family, usdPerMTok: rate }))
385}
386
387async function onClear($: Dollar): Promise<void> {
388 const [now, cache, held] = await Promise.all([$.clock.now(), read($, cacheState), read($, heldState)])
389 // A hold answered with /clear: the cold re-read was never paid.
390 if (held !== null && held.spell === cache.lastAt && now - held.at < HOLD_ACK_MS) {
391 await update($, receiptState, r => ({
392 ...r,
393 clearedAfterHold: r.clearedAfterHold + 1,
394 clearAvoidedUsd: r.clearAvoidedUsd + held.coldUsd,
395 }))
396 }
397 await resetCache($)
398 await stopWarm($, 'keep-warm stopped, /clear started a new conversation')
399}
400
401/**
402 * Where an answer goes: a command's `text` is read by the model, so nothing for the person alone goes there.
403 * A confirmation is a toast, a refusal a transcript note the person can read
404 * back, and only a caller that is not the person gets text, so it learns why.
405 */
406type Answer = { to: 'toast' | 'note' | 'caller'; text: string }
407
408async function warmCommand($: Dollar, args: string, origin: PromptOrigin): Promise<Answer> {
409 const arg = args.trim().toLowerCase()
410 // Pings cost money and stopping them can cost a cold re-read: only a person
411 // starts or stops keep-warm, never another plugin's command. Anyone may ask where it stands.
412 if (arg !== '' && !(await isPerson($, origin))) return { to: 'caller', text: 'Keep-warm changes only with a /warm you type.' }
413 if (arg === 'off' || arg === 'stop') {
414 const warm = await read($, warmState)
415 if (warm === null) return { to: 'note', text: 'Keep-warm is not on.' }
416 await update($, warmState, () => null)
417 return { to: 'toast', text: `Keep-warm off. ${pingSummary(warm)}` }
418 }
419 const s = await snapshot($)
420 if (arg === '') {
421 const text =
422 s.warm === null
423 ? `Keep-warm is off. ${USAGE}`
424 : `Keeping the cache warm for ${formatDuration(s.warm.until - s.now)} more. ${pingSummary(s.warm)} /warm off stops it.`
425 return { to: 'toast', text }
426 }
427 const ms = parseDuration(arg)
428 if (ms === null || ms < MINUTE || ms > MAX_WARM_MS) {
429 return { to: 'note', text: `Could not read "${clean(args.trim())}" as a duration from 1m to 24h. ${USAGE}` }
430 }
431 if (s.temp.kind === 'empty' || s.cache.lastAt === null) {
432 return { to: 'note', text: 'Nothing is cached yet: send a prompt first, then /warm.' }
433 }
434 if (s.temp.kind === 'off') {
435 return { to: 'note', text: 'Prompt caching looks switched off for this session, so there is nothing to keep warm.' }
436 }
437 if (s.temp.kind === 'cold') {
438 return { to: 'note', text: 'The cache is already cold, so your next prompt re-reads it either way. Run /warm after that.' }
439 }
440 const span = formatDuration(ms)
441 const pingUsd = s.warm?.lastPingUsd ?? estimatePingUsd(s.cache.tokens, s.prices)
442 const plan = planKeepWarm({
443 now: s.now,
444 lastAt: s.cache.lastAt,
445 until: s.now + ms,
446 ttlMs: s.ttlMs,
447 leadMs: pingLeadMs(s.ttlMs),
448 pingUsd,
449 extraUsd: s.send.extraUsd,
450 })
451 if (plan.kind === 'covered') {
452 return {
453 to: 'note',
454 text: `No pings needed: the cache stays warm ${formatDuration(s.temp.msLeft)} on its own, past the ${span} you asked for.`,
455 }
456 }
457 if (plan.kind === 'stop') {
458 return {
459 to: 'note',
460 text:
461 `Not started: keeping ${formatTokens(s.cache.tokens)} tokens warm for ${span} takes about ${plan.pingsLeft} pings ` +
462 `(~${formatUsd(plan.costUsd)}), more than the one cold re-read it would save (~${formatUsd(s.send.extraUsd)}).`,
463 }
464 }
465 const pings = plan.kind === 'ping' || plan.kind === 'wait' ? plan.pingsLeft : 0
466 await update($, warmState, () => ({ until: s.now + ms, startedAt: s.now, pings: 0, usd: 0, lastPingUsd: s.warm?.lastPingUsd ?? null }))
467 live.pingFailures = 0
468 startTicker($, s.now)
469 return {
470 to: 'toast',
471 text:
472 `Keeping the cache warm for ${span}: about ${pings} ping${pings === 1 ? '' : 's'} at ~${formatUsd(pingUsd)} each, ` +
473 `sent just before it would go cold. Going cold would add ~${formatUsd(s.send.extraUsd)} to your next send. /warm off stops it.`,
474 }
475}
476
477/** Claude Code's own estimate of the session's cost (list or managed prices, as /cost sums them), when it keeps one. */
478async function sessionTotalUsd($: Dollar): Promise<number | undefined> {
479 try {
480 return (await $.session.usage()).cost?.usd
481 } catch {
482 return undefined
483 }
484}
485
486/** Shows a `/warm` answer where it belongs; text only for a caller that is not the person. */
487function deliver($: Dollar, answer: Answer): { text?: string } {
488 if (answer.to === 'caller') return { text: answer.text }
489 if (answer.to === 'toast') $.ui.toast(answer.text, { timeoutMs: 8_000 })
490 else $.ui.log(`thermostat: ${answer.text}`)
491 return {}
492}
493
494/**
495 * Opens the receipt in a pane, which redraws as the numbers move. With no
496 * surface to place one on (a `-p` run), or a pane that is not placed, the
497 * receipt is a transcript note instead. Neither reaches the model.
498 */
499async function showReceipt($: Dollar): Promise<void> {
500 if ((await $.session.surfaces()).length > 0) {
501 const opened = await $.ui.open({ id: RECEIPT_PANE, title: 'Thermostat' })
502 if (opened.isPlaced) return
503 }
504 $.ui.log(receiptText(await receipt($, await snapshot($))))
505}
506
507/** The receipt's lines, from the moment's snapshot and what the session booked; the note joins their text. */
508async function receipt($: Dollar, s: Snapshot): Promise<ReceiptLine[]> {
509 const r = await read($, receiptState)
510 const total = await sessionTotalUsd($)
511 return receiptLines(
512 { now: s.now, tokens: s.cache.tokens, warm: s.warm, auth: s.auth, ttl: s.ttl, ttlMs: s.ttlMs, temp: s.temp, family: s.prices.family, warmUsd: s.send.warmUsd, coldUsd: s.send.coldUsd },
513 r,
514 total,
515 live.settings.guard,
516 )
517}
518
519export const register: Register = (on, options) => {
520 live.ticker?.cancel()
521 live = freshLive(readSettings(options))
522
523 on('session.start', async ($, e, next) => {
524 // Fires at start, on enable and on every reload, so the commands and the
525 // ticker come back with the module.
526 try {
527 await Promise.all([
528 $.command.register({
529 name: 'warm',
530 description: 'Keep the prompt cache warm while you are away (/warm 90m, /warm off)',
531 argumentHint: '<duration> | off',
532 immediate: true,
533 }),
534 $.command.register({
535 name: 'thermostat',
536 description: 'Prompt cache receipt: cold sends paid, pings sent, money saved',
537 immediate: true,
538 }),
539 learnAuth($),
540 ])
541 } catch (error) {
542 $.ui.log(`thermostat: setup failed: ${String(error)}`, { to: 'debug' })
543 }
544 startTicker($, await $.clock.now())
545 return next(e)
546 })
547
548 // The guard. It judges before calling `next`, and drops only a person's
549 // own prompt (they can read the hold and send again). A prompt typed while a
550 // turn runs (`turnId` set) joins that turn, whose next request pays the same
551 // either way, so it is never held.
552 on('prompt.submit', async ($, e, next) => {
553 if (live.settings.guard === 'off' || e.turnId !== undefined || !(await isPerson($, e.origin))) return next(e)
554 const s = await snapshot($)
555 if (s.temp.kind !== 'cold' || s.cache.lastAt === null || s.cache.tokens < HOLD_MIN_TOKENS) return next(e)
556 if (live.settings.guard === 'warn') {
557 $.ui.toast(coldLine(s), { timeoutMs: 10_000 })
558 return next(e)
559 }
560 const held = await read($, heldState)
561 // Held once already in this cold spell: this send is the confirmation.
562 if (held !== null && held.spell === s.cache.lastAt && s.now - held.at < HOLD_ACK_MS) return next(e)
563 const spell = s.cache.lastAt
564 await update($, heldState, () => ({ spell, at: s.now, coldUsd: s.send.coldUsd }))
565 await update($, receiptState, r => ({ ...r, held: r.held + 1 }))
566 $.clock.after(0, () => void restoreDraft($, e.text))
567 const files = e.attachments?.length ? ' Attach your files again: only the text comes back.' : ''
568 return { drop: holdText(s, files) }
569 })
570 // Fails open on purpose: if the estimate itself breaks, the prompt goes
571 // through. Blocking someone's prompt on a bug in a cost warning is worse
572 // than one missed warning. After a `next` call, `next(e)` replays what it
573 // settled to, so nothing runs twice.
574 .catch(($, e, next) => next(e))
575
576 // Every main-thread model request moves the cache's clock: it is read (or
577 // written) at the request's start. Subagents and pings use other prefixes.
578 on('turn.step', async function* ($, e, next) {
579 if (e.agentId !== undefined || live.isPinging) return yield* next(e)
580 let sentAt: number | undefined
581 try {
582 sentAt = await $.clock.now()
583 } catch {
584 sentAt = undefined
585 }
586 const result = yield* next(e)
587 if (sentAt !== undefined && result.usage !== null) {
588 try {
589 await recordStep($, sentAt, result.usage)
590 } catch (error) {
591 // Bookkeeping must never touch the turn.
592 $.ui.log(`thermostat: could not record a request: ${String(error)}`, { to: 'debug' })
593 }
594 }
595 return result
596 })
597
598 // Resume and fork carry how long ago the last response was; compaction and
599 // /clear start the conversation's cache over.
600 on('classic.SessionStart', async ($, e, next) => {
601 try {
602 if (e.source === 'resume' || e.source === 'fork') await onResume($, e)
603 else if (e.source === 'compact' || e.source === 'clear') await resetCache($)
604 } catch (error) {
605 $.ui.log(`thermostat: ${String(error)}`, { to: 'debug' })
606 }
607 return next(e)
608 }).catch(($, e, next) => next(e)) // observes only: never blocks a session start
609
610 // A model switch reports the engine's own cache window, and leaves the new
611 // model with nothing cached: keep-warm pings would only pay to write it.
612 on('classic.PostModelSwitch', async ($, e, next) => {
613 try {
614 await update($, engineTtlState, () => (e.cache_ttl === '1h' ? 60 : 5))
615 // `default` means Claude Code did not know the new model's price either.
616 if (e.pricing !== 'default') await learnEngineWrite($, e.to_model, e.estimated_cache_write_usd, e.context_tokens)
617 // A resume restores the model the transcript was cached on: nothing is lost.
618 if (e.source !== 'resume' && e.from_model !== e.to_model) {
619 await stopWarm($, 'keep-warm stopped, the model changed and its cache starts empty')
620 await resetCache($)
621 }
622 } catch (error) {
623 $.ui.log(`thermostat: ${String(error)}`, { to: 'debug' })
624 }
625 return next(e)
626 }).catch(($, e, next) => next(e)) // observes only: never blocks a model switch
627
628 on('session.end', async ($, e, next) => {
629 if (e.reason === 'clear') {
630 try {
631 await onClear($)
632 } catch (error) {
633 $.ui.log(`thermostat: ${String(error)}`, { to: 'debug' })
634 }
635 }
636 return next(e)
637 })
638
639 on('command.run', { command: 'warm' }, async ($, e) => deliver($, await warmCommand($, e.args, e.origin))).catch(() => ({
640 text: 'thermostat: /warm hit an error. Run /thermostat to see where keep-warm stands.',
641 }))
642
643 on('command.run', { command: 'thermostat' }, async $ => {
644 await showReceipt($)
645 return {}
646 }).catch(() => ({
647 text: 'thermostat: could not show the receipt this time.',
648 }))
649
650 // The receipt pane: one line each, live, since the state reads subscribe it.
651 // Bolt minds the cache beside the first line: asleep while it is cold
652 // or empty, up while warm. A static sprite, only where the pane has room.
653 // The receipt pane, after brand/UI-STANDARD.md: the state as a hero line in its colour, the
654 // window's bar and the next send's price beneath, Bolt beside them (asleep while the cache is
655 // cold or empty); then label and value rows, the value at the right edge and the detail dim
656 // beneath; one footnote. The engine draws the title bar, so the title line is for the note only.
657 on('ui.render', { component: 'Pane', requestId: RECEIPT_PANE }, async ($, e) => {
658 const elements = $.ui.resolve(e)
659 const { Box, Text } = elements
660 const s = await snapshot($)
661 const lines = await receipt($, s)
662 const paint = (tone: ReceiptTone): { color?: ThemeKey } => (tone === 'plain' ? {} : { color: tone })
663 const barColor: ThemeKey = s.temp.kind === 'warm' && s.temp.isAmber ? 'warning' : 'success'
664 const draw = (line: ReceiptLine) => {
665 switch (line.kind) {
666 case 'title':
667 return null
668 case 'hero':
669 return (
670 <Box key={line.key}>
671 <Text bold {...paint(line.tone)}>
672 {line.value ?? ''}
673 </Text>
674 </Box>
675 )
676 case 'bar':
677 return (
678 <Box key={line.key} flexDirection="row" gap={1}>
679 {glyphRow(elements, e.surface, line.bar ?? '', { color: barColor })}
680 <Box key="words" flexShrink={1}>
681 <Text wrap="wrap" {...paint(line.tone)}>
682 {line.value ?? ''}
683 </Text>
684 </Box>
685 </Box>
686 )
687 case 'row':
688 return (
689 <Box key={`row-${line.key}`} flexDirection="row" gap={1}>
690 <Box key="label" flexGrow={1} flexShrink={1}>
691 <Text color="subtle">{line.label ?? ''}</Text>
692 </Box>
693 <Box key="value" flexShrink={0} justifyContent="flex-end">
694 <Text {...paint(line.tone)}>{line.value ?? ''}</Text>
695 </Box>
696 </Box>
697 )
698 case 'note':
699 return (
700 <Box key={line.key} marginTop={1}>
701 <Text wrap="wrap" color="subtle">
702 {line.value ?? ''}
703 </Text>
704 </Box>
705 )
706 default:
707 return (
708 <Box key={line.key} paddingLeft={2} flexShrink={1}>
709 <Text wrap="wrap" {...paint(line.tone)}>
710 {line.value ?? ''}
711 </Text>
712 </Box>
713 )
714 }
715 }
716 const head = lines.filter(l => HEAD_KEYS.has(l.key)).map(draw)
717 const rest = lines.filter(l => !HEAD_KEYS.has(l.key) && l.kind !== 'title').map(draw)
718 return (
719 <Box flexDirection="column">
720 {e.props.bodyColumns >= BOLT_MIN_COLUMNS ? (
721 <Box key="head" flexDirection="row" gap={1}>
722 {boltSprite(elements, e.surface, { size: 'small', pose: s.temp.kind === 'warm' ? 'idle' : 'sleep' })}
723 <Box flexDirection="column" flexShrink={1}>
724 {head}
725 </Box>
726 </Box>
727 ) : (
728 head
729 )}
730 <Box key="gap" height={1} />
731 {rest}
732 </Box>
733 )
734 })
735
736 // The countdown, at the end of the prompt footer's mode labels: quiet while
737 // warm, amber near the end, cold-coloured once the cache has lapsed.
738 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
739 const s = await snapshot($)
740 live.footerSeenAt = s.now
741 const engine = await next(e)
742 if (s.temp.kind === 'empty') return engine
743 const { Box, Text } = $.ui.resolve(e)
744 const color = statusColor(s.temp)
745 return (
746 <Box flexDirection="row">
747 {engine}
748 {e.props.modes.length > 0 ? <Text dimColor> & </Text> : null}
749 <Box key="thermostat">
750 <Text color={color} dimColor={color === undefined}>
751 {statusFor(s)}
752 </Text>
753 </Box>
754 </Box>
755 )
756 })
757}
758hooks/bolt-draw.tsx 86 lines1// Draws Bolt, the modmyclaude mascot (brand/bolt-sprites), on the surface a hook draws for.
2// Same file in every mod that shows Bolt: mods install on their own, so they cannot share code.
3// Terminal: one Raster, a cell grid. Desktop: one code-font cell per character, since its Text is
4// proportional and a row of half-blocks would drift. Elsewhere: coloured Text runs.
5// Bolt is static here: no timer, no redraw of its own. Never draw it over a safety hold or in a toast.
6
7import type { BoxProps, ElementConstructor, RasterProps, RenderElement, TextProps } from 'claude-code'
8
9import { boltCells, boltRuns } from './bolt'
10import type { BoltCell, BoltSmallPose, BoltTinyPose } from './bolt'
11
12type Elements = {
13 Box: ElementConstructor<BoxProps>
14 Text: ElementConstructor<TextProps>
15 Raster?: ElementConstructor<RasterProps>
16}
17
18export type BoltPick = { size: 'small'; pose: BoltSmallPose } | { size: 'tiny'; pose: BoltTinyPose }
19
20/** Columns a pose takes: small is 10, tiny is 3. */
21export const BOLT_COLUMNS = { small: 10, tiny: 3 } as const
22
23/** The cells of a pose: half-blocks, two pixel rows per cell. */
24export function boltOf(pick: BoltPick): BoltCell[][] {
25 return pick.size === 'small' ? boltCells('small', pick.pose) : boltCells('tiny', pick.pose)
26}
27
28/** Bolt as one element under `key`, drawn the way `surface` lays out cells. */
29export function boltSprite(elements: Elements, surface: string, pick: BoltPick, key = 'bolt'): RenderElement {
30 const { Box, Text, Raster } = elements
31 const cells = boltOf(pick)
32 const columns = cells[0]?.length ?? 0
33 if (surface === 'terminal' && Raster !== undefined) {
34 return <Raster key={key} columns={columns} rows={cells.length} cells={rasterCells(cells)} />
35 }
36 const paint = (cell: BoltCell): TextProps => ({
37 ...(cell.fg !== undefined ? { color: cell.fg } : {}),
38 ...(cell.bg !== undefined ? { backgroundColor: cell.bg } : {}),
39 })
40 if (surface === 'desktop') {
41 return (
42 <Box key={key} flexDirection="column" flexShrink={0}>
43 {cells.map(line => (
44 <Box flexDirection="row" flexWrap="nowrap">
45 {line.map(cell =>
46 cell.fg === undefined && cell.bg === undefined ? <Box width={1} /> : <Box width={1}><Text {...paint(cell)}>{cell.ch}</Text></Box>,
47 )}
48 </Box>
49 ))}
50 </Box>
51 )
52 }
53 return (
54 <Box key={key} flexDirection="column" flexShrink={0}>
55 {cells.map(line => (
56 <Text>
57 {boltRuns(line).map(run => {
58 const text = run.ch.replace(/ /g, ' ')
59 return run.fg === undefined && run.bg === undefined ? text : <Text {...paint(run)}>{text}</Text>
60 })}
61 </Text>
62 ))}
63 </Box>
64 )
65}
66
67/** RasterProps.cells: standard base64 of little-endian u32 [codePoint, fg, bg] per cell, row-major. */
68export function rasterCells(cells: readonly (readonly BoltCell[])[]): string {
69 const DEFAULT = 0x01000000
70 const count = cells.reduce((sum, line) => sum + line.length, 0)
71 const view = new DataView(new ArrayBuffer(count * 12))
72 let at = 0
73 for (const line of cells) {
74 for (const cell of line) {
75 view.setUint32(at, cell.ch.codePointAt(0) ?? 32, true)
76 view.setUint32(at + 4, cell.fg === undefined ? DEFAULT : parseInt(cell.fg.slice(1), 16), true)
77 view.setUint32(at + 8, cell.bg === undefined ? DEFAULT : parseInt(cell.bg.slice(1), 16), true)
78 at += 12
79 }
80 }
81 let binary = ''
82 const bytes = new Uint8Array(view.buffer)
83 for (let i = 0; i < bytes.length; i += 1) binary += String.fromCharCode(bytes[i] ?? 0)
84 return btoa(binary)
85}
86hooks/cells.tsx 25 lines1// Glyph art (the window's bar) on the desktop app. Its Text is proportional, and a
2// Text that holds block characters is drawn in the code font, words and all, so a
3// row with a bar would change font and overflow. There the bar is drawn one
4// code-font cell per character, apart from the words. The same as redline's
5// hooks/cells.tsx glyphRow, copied so this mod installs on its own; keep the two in step.
6
7import type { BoxProps, ElementConstructor, RenderElement, TextProps } from 'claude-code'
8
9type Elements = { Box: ElementConstructor<BoxProps>; Text: ElementConstructor<TextProps> }
10
11/** One row of glyph art: a plain Text on the terminal, a row of one-cell Boxes on the desktop. */
12export function glyphRow(elements: Elements, surface: string, text: string, paint: TextProps = {}): RenderElement {
13 const { Box, Text } = elements
14 if (surface !== 'desktop') return <Text {...paint}>{text}</Text>
15 const cells: RenderElement[] = []
16 for (const run of text.match(/ +|[^ ]/gu) ?? []) {
17 cells.push(run.startsWith(' ') ? <Box width={run.length} /> : <Box width={1}><Text {...paint}>{run}</Text></Box>)
18 }
19 return (
20 <Box flexDirection="row" flexWrap="nowrap" flexShrink={0}>
21 {cells}
22 </Box>
23 )
24}
25hooks/receipt.ts 124 lines1// The /thermostat receipt as structured lines, after brand/UI-STANDARD.md: a
2// hero with the cache's state, the window's bar and the next send's price
3// beneath; label and value rows for what was paid, held, pinged and saved,
4// each detail dim beneath its row, nothing for a figure still at zero; one
5// footnote. The pane draws them by kind; where no pane can be placed, each
6// line's plain `text` goes to the transcript, so the two can never disagree.
7
8import type { ThermostatAuth, ThermostatReceipt, ThermostatWarm } from '../types'
9import { describeTtlSource, formatDuration, formatTokens, formatUsd } from './thermo'
10import type { Guard, Temperature, Ttl } from './thermo'
11
12/** How the pane draws a line: the title (text view only), the hero, the window's bar, a dim line under a row, a label and value row, the footnote. */
13export type ReceiptKind = 'title' | 'hero' | 'bar' | 'sub' | 'row' | 'note'
14export type ReceiptTone = 'success' | 'warning' | 'subtle' | 'plain'
15
16export type ReceiptLine = {
17 key: string
18 kind: ReceiptKind
19 tone: ReceiptTone
20 /** The line as plain text, for the transcript note. */
21 text: string
22 /** A row's label. */
23 label?: string
24 /** The words: a row's value (drawn at the right edge), the hero, a sub-line, the words beside the bar, the note. */
25 value?: string
26 /** The window's bar: glyphs, drawn apart from the words on the desktop. */
27 bar?: string
28}
29
30/** What the receipt reads off the moment's snapshot. */
31export type ReceiptInput = {
32 now: number
33 tokens: number
34 warm: Pick<ThermostatWarm, 'until'> | null
35 auth: ThermostatAuth | null
36 ttl: Pick<Ttl, 'source'>
37 ttlMs: number
38 temp: Temperature
39 family: string
40 warmUsd: number
41 coldUsd: number
42}
43
44const CELLS = 10
45
46/** How much of the window is left, as a bar of `cells`: full at the start, empty at the end. */
47export function warmBar(msLeft: number, ttlMs: number, cells = CELLS): string {
48 const filled = ttlMs <= 0 ? 0 : Math.max(0, Math.min(cells, Math.round((msLeft / ttlMs) * cells)))
49 return '█'.repeat(filled) + '░'.repeat(cells - filled)
50}
51
52export function receiptLines(s: ReceiptInput, r: ThermostatReceipt, total: number | undefined, guard: Guard): ReceiptLine[] {
53 const lines: ReceiptLine[] = []
54 const add = (key: string, kind: ReceiptKind, tone: ReceiptTone, words: { label?: string; value?: string; bar?: string }): void => {
55 const text =
56 kind === 'row' ? `${words.label ?? ''}: ${words.value ?? ''}` : kind === 'sub' ? ` ${words.value ?? ''}` : kind === 'bar' ? `${words.bar ?? ''} ${words.value ?? ''}` : (words.value ?? '')
57 lines.push({ key, kind, tone, text, ...(words.label === undefined ? {} : { label: words.label }), ...(words.value === undefined ? {} : { value: words.value }), ...(words.bar === undefined ? {} : { bar: words.bar }) })
58 }
59
60 // For the transcript note only: the pane's title bar already names the mod.
61 add('title', 'title', 'plain', { value: 'Thermostat receipt for this session' })
62
63 // The hero: the cache right now, the window it has, and what the next send costs.
64 const window = `${formatDuration(s.ttlMs)} window, ${describeTtlSource(s.ttl.source)}`
65 const tokens = `${formatTokens(s.tokens)} tokens, ${s.family} prices`
66 switch (s.temp.kind) {
67 case 'empty':
68 add('state', 'hero', 'subtle', { value: 'Nothing cached yet' })
69 add('window', 'sub', 'subtle', { value: window })
70 break
71 case 'off':
72 add('state', 'hero', 'subtle', { value: 'Caching looks off' })
73 add('window', 'sub', 'subtle', { value: 'the last large request neither read nor wrote the cache' })
74 break
75 case 'warm':
76 add('state', 'hero', s.temp.isAmber ? 'warning' : 'success', { value: `Warm · ${formatDuration(s.temp.msLeft)} left` })
77 add('window', 'bar', 'subtle', { bar: warmBar(s.temp.msLeft, s.ttlMs), value: window })
78 add('next', 'sub', 'subtle', { value: `Next send ≈${formatUsd(s.warmUsd)} warm, ≈${formatUsd(s.coldUsd)} cold (${tokens})` })
79 break
80 case 'cold':
81 add('state', 'hero', 'subtle', { value: `Cold · ${formatTokens(s.tokens)} to re-read` })
82 add('window', 'sub', 'subtle', { value: `cold for ${formatDuration(s.temp.msAgo)}; ${window}` })
83 add('next', 'sub', 'subtle', { value: `Next send ≈${formatUsd(s.coldUsd)} cold, ≈${formatUsd(s.warmUsd)} warm (${tokens})` })
84 break
85 }
86
87 // Rows: a figure each, its detail dim beneath. A figure still at zero draws no row.
88 const isQuiet = r.coldSends === 0 && r.held === 0 && r.pings === 0 && s.warm === null
89 if (isQuiet) add('quiet', 'sub', 'subtle', { value: 'Nothing paid cold, held or pinged yet' })
90 if (r.coldSends > 0) {
91 add('cold', 'row', 'plain', { label: 'Cold sends paid', value: `${r.coldSends}` })
92 add('cold-detail', 'sub', 'subtle', { value: `${formatTokens(r.coldTokens)} tokens re-read, ≈${formatUsd(r.coldExtraUsd)} more than warm` })
93 }
94 if (r.held > 0) {
95 add('held', 'row', 'plain', { label: 'Prompts held', value: `${r.held}` })
96 if (r.clearedAfterHold > 0) add('held-detail', 'sub', 'subtle', { value: `${r.clearedAfterHold} answered with /clear, ≈${formatUsd(r.clearAvoidedUsd)} not spent` })
97 }
98 if (r.pings > 0 || s.warm !== null) {
99 add('pings', 'row', 'plain', { label: 'Keep-warm pings', value: `${r.pings}` })
100 const parts = [`≈${formatUsd(r.pingUsd)}`]
101 if (r.rescued > 0) parts.push(`${r.rescued} send${r.rescued === 1 ? '' : 's'} found the cache warm thanks to them, ≈${formatUsd(r.rescuedUsd)} not spent`)
102 if (s.warm !== null) parts.push(`running now, ${formatDuration(s.warm.until - s.now)} left`)
103 add('pings-detail', 'sub', 'subtle', { value: parts.join(' · ') })
104 }
105 const saved = r.clearAvoidedUsd + r.rescuedUsd - r.pingUsd
106 if (!isQuiet && saved !== 0) {
107 add('saved', 'row', saved < 0 ? 'warning' : 'plain', { label: 'Saved', value: saved > 0 ? `≈${formatUsd(saved)}` : `-≈${formatUsd(-saved)}` })
108 add('saved-detail', 'sub', 'subtle', { value: saved > 0 ? 'not spent, less what the pings cost' : 'the pings cost more than they saved' })
109 }
110 if (total !== undefined) {
111 add('total', 'row', 'plain', { label: 'Session total', value: `≈${formatUsd(total)}` })
112 add('total-detail', 'sub', 'subtle', { value: "Claude Code's estimate, as /cost shows it" })
113 }
114 add('guard', 'row', 'plain', { label: 'Guard', value: guard })
115
116 add('note', 'note', 'subtle', { value: `Dollars are API list prices.${s.auth === 'bearer' ? ' On a subscription these measure plan usage, not a bill.' : ''}` })
117 return lines
118}
119
120/** The same receipt as text, for the transcript note. */
121export function receiptText(lines: readonly ReceiptLine[]): string {
122 return lines.map(l => l.text).join('\n')
123}
124hooks/pricing.ts 138 lines1// Cost math for the prompt cache. Pure: no `$`, no clock, so the tests can
2// table-drive it.
3
4/** API list prices in US dollars per million tokens. */
5export type Prices = {
6 /** The family the model id matched, for messages ("Opus 5.5"). */
7 family: string
8 /** Uncached input. */
9 input: number
10 output: number
11 /** Input served from the prompt cache. */
12 read: number
13 /**
14 * The cache-write rate as Claude Code itself priced it (its managed pricing
15 * when an organisation set one), when it has said; else derived from input.
16 */
17 write?: number
18}
19
20type Row = Prices & {
21 match: RegExp
22 /** Haiku 5.5 bills a prompt over 100k tokens at a higher tier. */
23 over100k?: Omit<Prices, 'family'>
24}
25
26// Anthropic API list prices, October 2026. First match wins, so the newer
27// and more specific ids come first. A cache write costs a multiple of
28// `input` (writeRate below), so it is not listed per row.
29const TABLE: readonly Row[] = [
30 { family: 'Fable 5.1', match: /(fable|mythos)-5-1/, input: 10, output: 50, read: 0.25 },
31 { family: 'Fable 5', match: /fable|mythos/, input: 10, output: 50, read: 1 },
32 { family: 'Opus 5.5', match: /opus-5-5/, input: 4, output: 20, read: 0.2 },
33 { family: 'Opus 4.5 to 5', match: /opus-(5|4-[5-9])/, input: 5, output: 25, read: 0.5 },
34 { family: 'Opus 4.1', match: /opus/, input: 15, output: 75, read: 1.5 },
35 { family: 'Sonnet 5 and 5.5', match: /sonnet-5/, input: 2, output: 10, read: 0.2 },
36 { family: 'Sonnet 4', match: /sonnet/, input: 3, output: 15, read: 0.3 },
37 // Haiku 5.5's cache-read rate is not published beside its prices: 0.1x input is assumed.
38 {
39 family: 'Haiku 5.5',
40 match: /haiku-5/,
41 input: 0.1,
42 output: 0.5,
43 read: 0.01,
44 over100k: { input: 0.5, output: 2.5, read: 0.05 },
45 },
46 { family: 'Haiku 4.5', match: /haiku/, input: 1, output: 5, read: 0.1 },
47]
48
49/** An id the table does not know is priced as a mid-range model and says so. */
50const UNKNOWN: Prices = { family: 'Sonnet 4 (model not recognised)', input: 3, output: 15, read: 0.3 }
51
52/**
53 * The prices for a model id as the API or Claude Code spells it
54 * (`claude-opus-5-5`, `us.anthropic.claude-opus-4-5-20251101-v1:0`,
55 * `claude-opus-4-5@20251101`), for a prompt of `promptTokens`.
56 */
57export function pricesFor(model: string, promptTokens = 0): Prices {
58 const id = model.toLowerCase()
59 const row = TABLE.find(one => one.match.test(id))
60 if (row === undefined) return UNKNOWN
61 const tier = row.over100k !== undefined && promptTokens > 100_000 ? row.over100k : row
62 return { family: row.family, input: tier.input, output: tier.output, read: tier.read }
63}
64
65/**
66 * What writing to the cache costs per million tokens: 1.25x input for the
67 * 5-minute TTL, 2x for the 1-hour one. Any window longer than 5 minutes
68 * can only be the 1-hour TTL, so it is priced that way.
69 */
70export function writeRate(prices: Prices, ttlMinutes: number): number {
71 return prices.write ?? prices.input * (ttlMinutes > 5 ? 2 : 1.25)
72}
73
74/** How a message names the prices it used. */
75export function pricesLabel(prices: Prices): string {
76 return prices.write === undefined ? `${prices.family} API list prices` : `${prices.family} prices, cache rate from Claude Code`
77}
78
79/**
80 * The cache-write rate behind an estimate Claude Code made itself
81 * (`estimated_cache_write_usd` for `contextTokens`), per million tokens. Too
82 * small a context makes a rounded estimate meaningless, so it is ignored.
83 */
84export function engineWriteRate(estimatedUsd: number, contextTokens: number): number | null {
85 if (!(contextTokens >= 10_000) || !(estimatedUsd > 0)) return null
86 return (estimatedUsd / contextTokens) * 1e6
87}
88
89/** One send's input side, cold (the context written again) and warm (read). */
90export type SendEstimate = {
91 tokens: number
92 coldUsd: number
93 warmUsd: number
94 /** What going cold adds: cold minus warm. */
95 extraUsd: number
96}
97
98/**
99 * Prices re-reading `tokens` of context on the next send. The reply costs
100 * the same either way, so only the input side is compared.
101 */
102export function estimateSend(tokens: number, prices: Prices, ttlMinutes: number): SendEstimate {
103 const coldUsd = (tokens * writeRate(prices, ttlMinutes)) / 1e6
104 const warmUsd = (tokens * prices.read) / 1e6
105 return { tokens, coldUsd, warmUsd, extraUsd: coldUsd - warmUsd }
106}
107
108/** The four token counts every usage report carries (ModelUsage). */
109export type Usage = {
110 input_tokens: number
111 output_tokens: number
112 cache_read_input_tokens: number
113 cache_creation_input_tokens: number
114}
115
116/** What a request with this usage cost at list prices. */
117export function usageUsd(usage: Usage, prices: Prices, ttlMinutes: number): number {
118 return (
119 (usage.input_tokens * prices.input +
120 usage.output_tokens * prices.output +
121 usage.cache_read_input_tokens * prices.read +
122 usage.cache_creation_input_tokens * writeRate(prices, ttlMinutes)) /
123 1e6
124 )
125}
126
127// A keep-warm ping re-sends the whole conversation (read from the cache)
128// plus one short line, and gets a word back. Thinking can add output, so
129// the output allowance is generous; once a ping has run, its measured cost
130// replaces this guess.
131export const PING_INPUT_TOKENS = 40
132export const PING_OUTPUT_TOKENS = 200
133
134/** A keep-warm ping's cost before one has been measured. */
135export function estimatePingUsd(tokens: number, prices: Prices): number {
136 return (tokens * prices.read + PING_INPUT_TOKENS * prices.input + PING_OUTPUT_TOKENS * prices.output) / 1e6
137}
138hooks/thermo.ts 233 lines1// The thermostat's reasoning about time: how long the cache lives, how warm
2// it is now, how the status reads, and when a keep-warm ping is worth it.
3// Pure, so every rule here is table-tested.
4
5import type { PluginOptions } from 'claude-code'
6
7import type { ThermostatAuth, ThermostatCache } from '../types'
8
9export const SECOND = 1000
10export const MINUTE = 60 * SECOND
11export const HOUR = 60 * MINUTE
12
13// ---------------------------------------------------------------- settings
14
15export type Guard = 'hold' | 'warn' | 'off'
16
17export type Settings = {
18 guard: Guard
19 /** The cache window in minutes when set; 0 works it out. */
20 cacheMinutes: number
21}
22
23/** Reads the userConfig values, falling back to the defaults on anything odd. */
24export function readSettings(options: PluginOptions): Settings {
25 const guard = options.guard === 'warn' || options.guard === 'off' ? options.guard : 'hold'
26 const minutes = Number(options.cacheMinutes)
27 const cacheMinutes = Number.isFinite(minutes) && minutes > 0 ? Math.min(minutes, 1440) : 0
28 return { guard, cacheMinutes }
29}
30
31// ------------------------------------------------------------- cache window
32
33/** Where the window came from, for the receipt. */
34export type TtlSource = 'setting' | 'engine' | 'subscription' | 'api-key' | 'default'
35
36export type Ttl = { minutes: number; source: TtlSource }
37
38/**
39 * The cache window: what Claude Code itself reported (its `cache_ttl` on a
40 * model switch, or its verdict on a resumed cache) wins, since it is the
41 * engine's own setting. Until it has said, your cacheMinutes setting, then
42 * your login: about an hour on a subscription, 5 minutes on an API key.
43 * Unknown means 5 minutes, the shorter guess, so the countdown never promises
44 * warmth that is not there.
45 */
46export function resolveTtl(settingMinutes: number, engineMinutes: number | null, auth: ThermostatAuth | null): Ttl {
47 if (engineMinutes !== null) return { minutes: engineMinutes, source: 'engine' }
48 if (settingMinutes > 0) return { minutes: settingMinutes, source: 'setting' }
49 if (auth === 'bearer') return { minutes: 60, source: 'subscription' }
50 if (auth === 'api-key') return { minutes: 5, source: 'api-key' }
51 return { minutes: 5, source: 'default' }
52}
53
54/** Words for the receipt: where the window figure came from. */
55export function describeTtlSource(source: TtlSource): string {
56 switch (source) {
57 case 'setting':
58 return 'from your cacheMinutes setting'
59 case 'engine':
60 return 'as Claude Code reported it'
61 case 'subscription':
62 return 'subscription login'
63 case 'api-key':
64 return 'API key login'
65 case 'default':
66 return 'no subscription or API key seen, so the shorter window is assumed'
67 }
68}
69
70/**
71 * Claude Code says on a resume whether the cache has likely expired after
72 * `secondsAgo`. Only two windows exist (5 minutes and 1 hour), so that answer
73 * pins the window whenever it disagrees with one of them.
74 */
75export function ttlFromResume(secondsAgo: number, isLikelyExpired: boolean): number | null {
76 if (isLikelyExpired && secondsAgo < 60 * 60) return 5
77 if (!isLikelyExpired && secondsAgo > 5 * 60) return 60
78 return null
79}
80
81// -------------------------------------------------------------- temperature
82
83export type Temperature =
84 | { kind: 'empty' }
85 | { kind: 'off' }
86 | { kind: 'warm'; msLeft: number; isAmber: boolean }
87 | { kind: 'cold'; msAgo: number }
88
89/**
90 * When the countdown turns amber: 10 minutes before the cache goes cold, or
91 * earlier in a short window (2 minutes of a 5-minute one) so the colour
92 * still means "soon".
93 */
94export function amberMs(ttlMs: number): number {
95 return Math.min(10 * MINUTE, ttlMs * 0.4)
96}
97
98/** How warm the cache is at `now`, measured from the start of the last request that read or wrote it. */
99export function temperature(now: number, cache: ThermostatCache, ttlMs: number): Temperature {
100 if (cache.lastAt === null || cache.tokens <= 0) return { kind: 'empty' }
101 if (cache.isOff) return { kind: 'off' }
102 const expiresAt = cache.lastAt + ttlMs
103 if (now >= expiresAt) return { kind: 'cold', msAgo: now - expiresAt }
104 const msLeft = expiresAt - now
105 return { kind: 'warm', msLeft, isAmber: msLeft < amberMs(ttlMs) }
106}
107
108// --------------------------------------------------------------- formatting
109
110/** `41m`, `60m`, `1h12m`, `<1m`: whole minutes, rounded down so it never overstates. */
111export function formatDuration(ms: number): string {
112 const minutes = Math.floor(Math.max(0, ms) / MINUTE)
113 if (minutes < 1) return '<1m'
114 if (minutes <= 60) return `${minutes}m`
115 const hours = Math.floor(minutes / 60)
116 const rest = minutes % 60
117 return rest === 0 ? `${hours}h` : `${hours}h${rest}m`
118}
119
120/** `950`, `48k`, `182k`, `1.2M`. */
121export function formatTokens(tokens: number): string {
122 if (tokens < 1000) return String(Math.round(tokens))
123 if (tokens < 999_500) return `${Math.round(tokens / 1000)}k`
124 return `${(tokens / 1e6).toFixed(1)}M`
125}
126
127/** `$1.82`, `$0.04`, `<$0.01`. */
128export function formatUsd(usd: number): string {
129 if (usd > 0 && usd < 0.005) return '<$0.01'
130 return `$${usd.toFixed(2)}`
131}
132
133/** The status line's text, e.g. `cache warm · 41m left`. */
134export function statusText(temp: Temperature, tokens: number, warmLeftMs: number | null): string {
135 const warming = warmLeftMs !== null && warmLeftMs > 0 ? ` · warming ${formatDuration(warmLeftMs)}` : ''
136 switch (temp.kind) {
137 case 'empty':
138 return ''
139 case 'off':
140 return 'cache off'
141 case 'warm':
142 return `cache warm · ${formatDuration(temp.msLeft)} left${warming}`
143 case 'cold':
144 return `cache cold · ${formatTokens(tokens)} to re-read`
145 }
146}
147
148/** The theme colour for each state; warm text stays as quiet as the footer around it. */
149export function statusColor(temp: Temperature): 'warning' | 'suggestion' | undefined {
150 if (temp.kind === 'warm' && temp.isAmber) return 'warning'
151 if (temp.kind === 'cold') return 'suggestion'
152 return undefined
153}
154
155// ------------------------------------------------------------ outside text
156
157// ANSI sequences (CSI, OSC, two-byte escapes, and the 8-bit CSI), then any
158// control character left: the engine refuses a drawn text that holds one.
159const ANSI = /\x1b\[[0-?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[@-_]|\x9b[0-?]*[ -/]*[@-~]/g
160const CONTROL = /[\x00-\x1f\x7f-\x9f]/g
161
162/** Makes text from outside the mod safe to draw: ANSI stripped, C0, DEL and C1 shown as U+FFFD. */
163export function clean(text: string): string {
164 return text.replace(ANSI, '').replace(CONTROL, '\uFFFD')
165}
166
167// ---------------------------------------------------------------- durations
168
169/**
170 * Reads `/warm`'s argument: `90m`, `2h`, `1h30m`, `1.5h`, `90 min`, or a bare
171 * number of minutes. Returns milliseconds, or null when it does not parse.
172 */
173export function parseDuration(text: string): number | null {
174 const s = text.trim().toLowerCase().replace(/\s+/g, '')
175 if (/^\d+(\.\d+)?$/.test(s)) return Math.round(Number(s) * MINUTE)
176 const m = /^(?:(\d+(?:\.\d+)?)(?:h|hr|hrs|hour|hours))?(?:(\d+(?:\.\d+)?)(?:m|min|mins|minute|minutes))?$/.exec(s)
177 if (m === null || (m[1] === undefined && m[2] === undefined)) return null
178 return Math.round(Number(m[1] ?? 0) * HOUR + Number(m[2] ?? 0) * MINUTE)
179}
180
181// ---------------------------------------------------------------- keep-warm
182
183/** How long before expiry a ping goes out: the status tick is 30 s, so 60 s is the least lead that cannot miss. */
184export function pingLeadMs(ttlMs: number): number {
185 return ttlMs <= 10 * MINUTE ? MINUTE : 2 * MINUTE
186}
187
188export type KeepWarmInput = {
189 now: number
190 /** Start of the last request that read or wrote the cache. */
191 lastAt: number
192 /** When the keep-warm window ends. */
193 until: number
194 ttlMs: number
195 leadMs: number
196 /** What one ping costs (measured, or estimated before the first). */
197 pingUsd: number
198 /** What one cold re-read costs over a warm one. */
199 extraUsd: number
200}
201
202export type KeepWarmStep =
203 /** The window has ended. */
204 | { kind: 'finished' }
205 /** Too late: the cache lapsed before a ping could go out. */
206 | { kind: 'cold' }
207 /** The cache outlives the window on its own; no ping needed. */
208 | { kind: 'covered' }
209 /** Pinging is worth it, but not yet. */
210 | { kind: 'wait'; pingAt: number; pingsLeft: number; costUsd: number }
211 | { kind: 'ping'; pingsLeft: number; costUsd: number }
212 /** The pings still needed cost more than the cold re-read they would save. */
213 | { kind: 'stop'; pingsLeft: number; costUsd: number }
214
215/**
216 * The smart keep-warm rule. Each ping lands `leadMs` before expiry and buys
217 * `ttlMs - leadMs` more warmth, so the pings still needed are the gap
218 * between expiry and the window's end over that step. Their total is
219 * weighed against the one cold re-read they prevent before every ping.
220 */
221export function planKeepWarm(i: KeepWarmInput): KeepWarmStep {
222 if (i.now >= i.until) return { kind: 'finished' }
223 const expiresAt = i.lastAt + i.ttlMs
224 if (i.now >= expiresAt) return { kind: 'cold' }
225 if (expiresAt >= i.until) return { kind: 'covered' }
226 const step = Math.max(i.ttlMs - i.leadMs, MINUTE)
227 const pingsLeft = Math.ceil((i.until - expiresAt) / step)
228 const costUsd = pingsLeft * i.pingUsd
229 if (costUsd > i.extraUsd) return { kind: 'stop', pingsLeft, costUsd }
230 const pingAt = expiresAt - i.leadMs
231 return i.now >= pingAt ? { kind: 'ping', pingsLeft, costUsd } : { kind: 'wait', pingAt, pingsLeft, costUsd }
232}
233hooks/bolt.ts 109 lines1// Bolt sprites for mods, v1.0.0. GENERATED by brand/bolt-sprites/tools/build.py: edit design.py, not this file.
2// Copy this file into your mod's hooks/ folder (mods install on their own, so they cannot import from brand/).
3// Rules (brand/BRAND.md): pink is the antenna light only, blush the cheeks only. Never recolour, rotate or stretch.
4// Never draw Bolt over a safety hold, and never in a toast (toasts are uncoloured text).
5
6export const BOLT_PALETTE = {
7 B: '#C8F53C',
8 S: '#9BC21A',
9 O: '#FF5C8A',
10 K: '#FF8FB1',
11 E: '#101114',
12 W: '#FBFBF8',
13 G: '#8E95A3',
14} as const
15
16export type BoltSize = 'small' | 'tiny'
17export type BoltSmallPose = 'idle' | 'run1' | 'run2' | 'jump' | 'crash' | 'cheer' | 'sleep' | 'sweat' | 'think' | 'egg' | 'egg-wobble-l' | 'egg-wobble-r' | 'egg-crack-1' | 'egg-crack-2' | 'egg-crack-3' | 'egg-hatch'
18export type BoltTinyPose = 'idle' | 'run1' | 'run2' | 'jump' | 'crash' | 'cheer' | 'sleep'
19export type BoltPose = BoltSmallPose | BoltTinyPose
20
21/** One text cell: a character and its colours (absent = the surface's own). */
22export type BoltCell = { ch: string; fg?: string; bg?: string }
23
24/** Pixel rows: one palette letter per pixel, '.' empty. Heights are even (two pixel rows per cell). */
25export const BOLT_SPRITES = {
26 small: {
27 'idle': ['....OO....', '....SS....', '..BBBBBB..', '.BBBBBBBB.', 'BBWEBBWEBB', 'BBEEBBEEBB', 'BKKBBBBKKB', 'BBBBEEBBBB', '.SSSSSSSS.', '..SS..SS..'],
28 'run1': ['...OO.....', '....SS....', '..BBBBBB..', '.BBBBBBBB.', 'BBBWEBBWEB', 'BBBEEBBEEB', 'BKKBBBBBKB', 'BBBBBEEBBB', '.SSSSSSSS.', 'SS......SS'],
29 'run2': ['....OO....', '....SS....', '..BBBBBB..', '.BBBBBBBB.', 'BBBWEBBWEB', 'BBBEEBBEEB', 'BKKBBBBBKB', 'BBBBBEEBBB', '.SSSSSSSS.', '...SSSS...'],
30 'jump': ['....OO....', '....SS....', '..BBBBBB..', '.BBBBBBBB.', 'BBWEBBWEBB', 'BBEEBBEEBB', 'BKKBBBBKKB', 'BBBBEEBBBB', '.SSSSSSSS.', '...S..S...'],
31 'crash': ['......OO..', '.....S....', '..BBBBBB..', '.BBBBBBBB.', 'BEBEBBEBEB', 'BBEBBBBEBB', 'BEBEBBEBEB', 'BBBBEEBBBB', '.SSSSSSSS.', 'SS......SS'],
32 'cheer': ['S...OO...S', 'S...SS...S', 'SBBBBBBBBS', '.BBBBBBBB.', 'BBWEBBWEBB', 'BBEEBBEEBB', 'BKKBBBBKKB', 'BBBEEEEBBB', '.SSSSSSSS.', '..SS..SS..'],
33 'sleep': ['....SS....', '....SS....', '..BBBBBB..', '.BBBBBBBB.', 'BBBBBBBBBB', 'BBEEBBEEBB', 'BKKBBBBKKB', 'BBBBBBBBBB', '.SSSSSSSS.', '..SS..SS..'],
34 'sweat': ['....OO....', '....SS...G', '..BBBBBB.G', '.BBBBBBBB.', 'BBWEBBWEBB', 'BBEEBBEEBB', 'BKKBBBBKKB', 'BBBBEEEBBB', '.SSSSSSSS.', '..SS..SS..'],
35 'think': ['....OO....', '....SS....', '..BBBBBB..', '.BBWEBWEB.', 'BBBEEBEEBB', 'BBBBBBBBBB', 'BKKBBBBKKB', 'BBBBBEBBBB', '.SSSSSSSS.', '..SS..SS..'],
36 'egg': ['....OO....', '....SS....', '...BWBB...', '..BWBBBB..', '..BBBBBB..', '.BBBBBBBB.', '.BBBBBBBB.', '.BBBBBBBS.', '..BBBBBS..', '...SSSS...'],
37 'egg-wobble-l': ['...OO.....', '...SS.....', '..BWBB....', '.BWBBBB...', '.BBBBBB...', '.BBBBBBBB.', '.BBBBBBBB.', '.BBBBBBBS.', '..BBBBBS..', '...SSSS...'],
38 'egg-wobble-r': ['.....OO...', '.....SS...', '....BWBB..', '...BWBBBB.', '...BBBBBB.', '.BBBBBBBB.', '.BBBBBBBB.', '.BBBBBBBS.', '..BBBBBS..', '...SSSS...'],
39 'egg-crack-1': ['....OO....', '....SS....', '...BWBB...', '..BWBBBB..', '..BBBBBB..', '.BBBBBBBB.', '.BEBBBBBB.', '.BBEBBBBS.', '..BBBBBS..', '...SSSS...'],
40 'egg-crack-2': ['....OO....', '....SS....', '...BWBB...', '..BWBBBB..', '..BBBBBB..', '.BBBBBBBB.', '.BEBEBBBB.', '.BBEBEBBS.', '..BBBBBS..', '...SSSS...'],
41 'egg-crack-3': ['....OO....', '....SS....', '...BWBB...', '..BWBBBB..', '..BBBBBB..', '.BBBBBBBB.', '.BEBEBEBB.', '.BBEBEBES.', '..BBBBBS..', '...SSSS...'],
42 'egg-hatch': ['......OO..', '......SS..', '....BWBBBB', '....B.B.B.', '..........', '..........', '.B.B.B.BB.', '.BSSSSSSB.', '..BBBBBS..', '...SSSS...'],
43 },
44 tiny: {
45 'idle': ['.O.', 'BEB', 'BBB', 'S.S'],
46 'run1': ['.O.', 'BEB', 'BBB', 'S.S'],
47 'run2': ['.O.', 'BEB', 'BBB', '.S.'],
48 'jump': ['.O.', 'BEB', 'BBB', '.S.'],
49 'crash': ['...', '.SO', 'BEB', 'SSS'],
50 'cheer': ['SOS', 'BEB', 'BBB', 'S.S'],
51 'sleep': ['.S.', 'BSB', 'BBB', 'S.S'],
52 },
53} as const
54
55/** Letters drawn over a whole cell (cell coordinates). */
56const GLYPHS: Record<string, readonly { row: number; col: number; ch: string; color: keyof typeof BOLT_PALETTE }[]> = {
57 'small/sleep': [{ row: 0, col: 8, ch: 'z', color: 'G' }],
58}
59
60/** The two run frames, in order. Swap them about every 120 ms while running. */
61export const BOLT_RUN = ['run1', 'run2'] as const
62
63function colour(key: string | undefined): string | undefined {
64 if (key === undefined || key === '.') return undefined
65 return (BOLT_PALETTE as Record<string, string>)[key]
66}
67
68/**
69 * Packs a pose into half-block text cells: rows / 2 lines of `cols` cells.
70 * Terminal: draw with Raster (see hatchling's rasterCells). Desktop: one fixed-width cell per character.
71 */
72export function boltCells(size: 'small', pose: BoltSmallPose): BoltCell[][]
73export function boltCells(size: 'tiny', pose: BoltTinyPose): BoltCell[][]
74export function boltCells(size: BoltSize, pose: BoltPose): BoltCell[][] {
75 const rows: readonly string[] = (BOLT_SPRITES[size] as Record<string, readonly string[]>)[pose] ?? []
76 const out: BoltCell[][] = []
77 for (let r = 0; r + 1 < rows.length; r += 2) {
78 const top = rows[r] ?? ''
79 const bottom = rows[r + 1] ?? ''
80 const line: BoltCell[] = []
81 for (let c = 0; c < top.length; c += 1) {
82 const t = colour(top[c])
83 const b = colour(bottom[c])
84 if (t === undefined && b === undefined) line.push({ ch: ' ' })
85 else if (b === undefined) line.push({ ch: '\u2580', fg: t })
86 else if (t === undefined) line.push({ ch: '\u2584', fg: b })
87 else if (t === b) line.push({ ch: '\u2588', fg: t })
88 else line.push({ ch: '\u2580', fg: t, bg: b })
89 }
90 out.push(line)
91 }
92 for (const glyph of GLYPHS[`${size}/${pose}`] ?? []) {
93 const line = out[glyph.row]
94 if (line !== undefined && glyph.col < line.length) line[glyph.col] = { ch: glyph.ch, fg: BOLT_PALETTE[glyph.color] }
95 }
96 return out
97}
98
99/** Merges a row of cells into runs of one style, for coloured Text spans. */
100export function boltRuns(line: readonly BoltCell[]): BoltCell[] {
101 const runs: BoltCell[] = []
102 for (const cell of line) {
103 const last = runs[runs.length - 1]
104 if (last !== undefined && last.fg === cell.fg && last.bg === cell.bg) last.ch += cell.ch
105 else runs.push({ ...cell })
106 }
107 return runs
108}
109types/index.d.ts 78 lines1// What thermostat keeps in `$.state`: plain data the host holds for the
2// session, so a hot reload of the code keeps the countdown and the receipt.
3
4/** What the main conversation's prompt cache holds, as the last request left it. */
5export type ThermostatCache = {
6 /** When the last request that read or wrote the cache started (ms); null when nothing is cached. */
7 lastAt: number | null
8 /** The same, counting only real model requests, never keep-warm pings. */
9 lastRealAt: number | null
10 /** Tokens the next request re-reads: the last request's whole input plus its reply. */
11 tokens: number
12 /** The model id the API reported for that request. */
13 model: string | null
14 /** True when a large request neither read nor wrote the cache: caching is switched off. */
15 isOff: boolean
16}
17
18/** A running `/warm`. */
19export type ThermostatWarm = {
20 /** When the keep-warm window ends (ms). */
21 until: number
22 startedAt: number
23 /** Pings sent in this window and what they cost. */
24 pings: number
25 usd: number
26 /** The last ping's measured cost, the best guess for the next. */
27 lastPingUsd: number | null
28}
29
30/** A prompt the guard held, so the next send in the same cold spell goes through. */
31export type ThermostatHeld = {
32 /** The cold spell it belongs to: the cache's `lastAt` when it was held. */
33 spell: number
34 /** When it was held (ms). */
35 at: number
36 /** What sending it cold would have cost. */
37 coldUsd: number
38}
39
40/** The session's running totals for `/thermostat`. */
41export type ThermostatReceipt = {
42 coldSends: number
43 coldTokens: number
44 /** What the cold sends cost over the same sends warm. */
45 coldExtraUsd: number
46 held: number
47 /** Holds followed by /clear instead of the cold send. */
48 clearedAfterHold: number
49 clearAvoidedUsd: number
50 pings: number
51 pingUsd: number
52 /** Sends that found the cache warm only because pings kept it so. */
53 rescued: number
54 rescuedUsd: number
55}
56
57/** A cache-write rate Claude Code priced itself (USD per million tokens), for one model family. */
58export type ThermostatEngineWrite = { family: string; usdPerMTok: number }
59
60/** The session's login: `bearer` a subscription, `api-key`, or `none` (a cloud provider or gateway). */
61export type ThermostatAuth = 'bearer' | 'api-key' | 'none'
62
63declare module 'claude-code' {
64 interface PluginState {
65 thermostat: {
66 cache: ThermostatCache
67 warm: ThermostatWarm | null
68 held: ThermostatHeld | null
69 receipt: ThermostatReceipt
70 auth: ThermostatAuth | null
71 /** The cache window in minutes as Claude Code itself reported it; null until it has. */
72 engineTtl: number | null
73 /** Claude Code's own cache-write rate for a model family, from an estimate it made; null until it has. */
74 engineWrite: ThermostatEngineWrite | null
75 }
76 }
77}
78