SLOPSHOPPER

thermostat

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

newpanespinnercommandtoaststatus
★ 1v0.1.1MITupdated 2026-10-13modmyclaude/claude-code-mods/mods/thermostat
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · thermostat
│ ┃ Thermostat ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ ██ z Nothing cached yet │ thermostat │ │ ┃ ▄██████▄ 5m window, API key login ⏺ Read(src/auth.ts) │ Keep-warm is off. Use /warm 90m (or 2h, │ │ ┃ ██▀▀██▀▀██ ⎿ Read 6 lines │ 1h30m, 45) to keep the cache warm while │ │ ┃ █▀▀████▀▀█ ⏺ Update(src/auth.ts) │ you are away, and /warm off to stop. │ │ ┃ ▀██▀▀██▀ ⎿ Added 2 lines, re╰────────────────────────────────────────────╯ │ ┃ ⏺ Bash(bun test) │ ┃ Nothing paid cold, held or pinged yet ⎿ 3 pass, 1 fail │ ┃ Session total │ ┃ Claude Code's estimate, as /cost shows it ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ Guard │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ Dollars are API list prices. │ › /warm │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Thermostat
██ z Nothing cached yet ▄██████▄ 5m window, API key login ██▀▀██▀▀██ █▀▀████▀▀█ ▀██▀▀██▀ Nothing paid cold, held or pinged yet Session total Claude Code's estimate, as /cost shows it Guard Dollars are API list prices.
README

<img src="../../brand/pixel-icons/svg/thermostat.svg" width="64" height="64" alt="">

Thermostat

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.

Install

/plugin install thermostat --marketplace modmyclaude/claude-code-mods

Use it

The countdown. At the end of the prompt footer, beside the mode labels:

  • cache warm · 41m left - quiet while there is time
  • cache 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 runs

This 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.

Settings

Every setting has a default, so the notice at install about options not yet set is expected; change them in /config.

FieldDefaultWhat it does
guardholdhold stops the first cold send once and shows the cost. warn shows the cost in a toast and sends. off does nothing.
cacheMinutes0The 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.

What it can touch

  • Reads: each prompt as you submit it (to decide whether to hold it; the text is not kept), the token counts of each main-conversation model request, what Claude Code reports on a resume or model switch, the prompt box (only to check it is empty before putting a held prompt back), which kind of login the session uses (the kind only: the credential stays with Claude Code, and this mod makes no network requests that could use it), and Claude Code's estimate of the session's cost.
  • Writes: its own session state, the prompt box (a held prompt's words, only into an empty box), the footer label and status line, the receipt pane, toasts, and short dim notes in the transcript. None of these is sent to the model: /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.
  • Processes and network: none.
  • Model calls: only keep-warm pings, only after you run /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.

Limits

  • The window is a model of the cache, not a reading of it. Claude Code only states its cache TTL on a model switch (and implies it on a resume), so until then it comes from your login or 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.
  • The countdown starts with the first model request after the mod loads, or with a resume. Installed in the middle of a conversation, it shows nothing until your next message.
  • After a model switch the new model starts with nothing cached, so that first send is neither held nor counted as a cold send.
  • A ping can make the model think, which is billed as output; the measured cost of each ping feeds the next decision. Pings cannot go out while the computer sleeps; if it sleeps past the expiry, keep-warm stops and says so.
  • In the desktop app the held prompt may not go back into the prompt box (the app can refuse that); send it again from your history. The desktop app is recognised by its prompts arriving as an SDK host's with a desktop surface attached; prompts from the VS Code extension are not held.
  • The guard puts a held prompt's words back into the prompt box (only when the box is empty); attached files are not put back.
  • The footer label is checked through the engine's test kit on the terminal and desktop surfaces; how each terminal paints it has not been checked by hand.
  • Prices are list prices unless Claude Code priced a re-cache itself. Amazon Bedrock and Google Vertex AI bill separately.

Built from a prompt

This mod was built from the Thermostat prompt.

More mods: the full catalogue.

Source 8 files
hooks/register.tsx 758 lines
1// 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}
758
hooks/bolt-draw.tsx 86 lines
1// 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}
86
hooks/cells.tsx 25 lines
1// 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}
25
hooks/receipt.ts 124 lines
1// 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}
124
hooks/pricing.ts 138 lines
1// 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}
138
hooks/thermo.ts 233 lines
1// 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}
233
hooks/bolt.ts 109 lines
1// 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}
109
types/index.d.ts 78 lines
1// 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