SLOPSHOPPER

cache-warmer

Keep the main conversation's prompt cache warm by automatically forking its last request, tools denied and billed like any request, shortly before the cache…

newpanebandcommandmodeltimer
★ 12v0.12.2MITupdated 2026-10-08paulbkim-dev/claude-code-cache-warmer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cache-warmer
│ ┃ Cache warmer ✕ › fix the failing auth test and add an audit log call │ ┃ ⢁ ⠄ │ ┃ ⠉⢧⠆⠠⡄ ⏺ Read(src/auth.ts) │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀ ▄▄▄▄▄▄ ⎿ Read 6 lines │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀ ▄▄▀▀▀▀▀▀▀▀ ⏺ Update(src/auth.ts) │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀ ⎿ Added 2 lines, removed 1 line │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀ ⏺ Bash(bun test) │ ┃ ▀ ▀ ▀ ▀ ⎿ 3 pass, 1 fail │ ┃ │ ┃ Cache Warmer ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ paulbkim.dev │ ┃ github.com/paulbkim-dev/claude-code-cach ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ › Configuration › /cache-warmer │ ┃ › Analytics ⎿ cache-warmer: Cache warmer opened. │ ┃ › Debug mode · off │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Cache warmer
⢁ ⠄ ⠉⢧⠆⠠⡄ ▀▀▀▀▀▀▀▀▀▀▀▀ ▄▄▄▄▄▄ ▀▀▀▀▀▀▀▀▀▀▀▀ ▄▄▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀ ▀ ▀ ▀ ▀ Cache Warmer paulbkim.dev github.com/paulbkim-dev/claude-code-cache-warmer › Configuration › Analytics › Debug mode · off
README

<img alt="Clawd, the Claude Code mascot, in the refreshing, warm, and expired states" src="assets/clawd-light.gif" width="274">

cache-warmer

Keeps the Claude Code prompt cache warm during a break, so your next prompt costs less.

Blog post · Website · X

English · 한국어 · 简体中文

What does it do?

Each prompt sends the full conversation to the API. The API keeps the start of the conversation in a prompt cache for 5 minutes or 1 hour. A prompt that reads the cache costs much less and starts faster. After the cache expires, the next prompt writes the full cache again at a higher price.

cache-warmer sends one small refresh shortly before the cache expires, so the cache stays warm. It works like the cache warmer in Pi.

flowchart LR
    subgraph with["With cache-warmer"]
        direction LR
        b1["Prompt"] --> b2["Break"] --> b3["Refresh<br/>keeps the cache"] --> b4["Next prompt<br/>reads the cache"]
    end
    subgraph without["Without cache-warmer"]
        direction LR
        a1["Prompt"] --> a2["Break"] --> a3["Cache expires"] --> a4["Next prompt<br/>writes the cache again"]
    end

Install

claude plugin marketplace add paulbkim-dev/claude-code-cache-warmer
claude plugin install cache-warmer@claude-code-cache-warmer

Restart Claude Code, then type /cache-warmer to open the pane. To stop all warming, run claude plugin disable cache-warmer.

What you see

During a refresh, a band above the prompt shows Clawd, the Claude Code mascot, beside a notice that the mod is resending the cached prompt. It shows the cache time in color (5m cyan, 1h magenta), the refresh interval, and the outcome: yellow while the refresh runs, green when the cache is warm, and red when it expired or failed. A failed refresh names the API error and its status, such as rate_limit 429. During a turn, the band goes 5 seconds after the refresh. In an idle session it stays until your next prompt, with Clawd still after 5 seconds. The pane's main page shows Clawd too.

  • The refresh never enters the conversation. The transcript keeps one notice row per refresh, starting with ☕, and no request sends it to the model.
  • The band has three styles, set on the Configuration page or in the /config row cache-warmer.band: default shows Clawd beside the notice, simplified shows the notice as one line, and off hides the band.
  • Each refresh request starts with [cache-warmer], so a request log or proxy can tell it from your prompts.
  • reduceMotion keeps Clawd still.
  • /cache-warmer preview plays the three states with no refresh.

The pane

/cache-warmer opens and closes a pane with this menu:

Configuration           this session, then defaults for new sessions
Analytics               costs and savings
Debug mode              on or off
  • The pane takes the keyboard when it opens over an empty prompt, with Configuration selected.
  • /cache-warmer on an open pane without the keyboard gives the keyboard back to it; on a pane with the keyboard, it closes the pane.
  • Tab and the arrow keys move between items. Enter opens a page or turns Debug mode on or off. Each page starts with a Back button, and Enter on a setting such as ● 5m ○ 1h moves it to the next option.
  • Escape closes the pane when the pane has focus, or when the prompt is idle and empty.
  • A line below the menu shows the next refresh, or why warming stopped.

Cache time

5 minutes1 hour
Refresh after4m30s54m
Cache write price1.25× input2× input
Warm time when idle, default limit27m30s5h30m
  • The default is 1 hour. Change it under Global on the Configuration page, in the /config row cache-warmer.ttl, or with /cache-warmer 5m or /cache-warmer 1h. The command also sets the current session.
  • The first main-conversation response locks the session's cache time. /clear or a new session unlocks it. While it is locked, the command refuses, and a new default applies to later sessions only.
  • The mod sets CLAUDE_CODE_PROMPT_CACHE_TTL for this Claude Code process, so it overrides promptCacheTtl and the shell. A change applies from the next request, which writes the cache once.
  • FORCE_PROMPT_CACHING_5M=1 keeps the cache at 5 minutes, and the Configuration page says so.
  • A refresh extends both cache times. In a live test on 2026-10-05, it kept a 1-hour cache warm past 60 minutes.
  • A refresh that reads less than half of the prefix counts as expired and stops warming.

When it refreshes

flowchart TD
    due["90% of the cache time is over"] --> rule{"Expected saving<br/>is $0.05 or more?"}
    rule -- No --> skip["No refresh"]
    rule -- Yes --> idle{"Is the session<br/>idle?"}
    idle -- "No, a turn runs" --> send["Refresh"]
    idle -- Yes --> left{"Idle refreshes left?"}
    left -- Yes --> send
    left -- No --> skip

A refresh is due at 90% of the cache time, and it must pass the rule from Pi:

chance of another request before expiry × extra cost to write the prefix again − refresh cost ≥ $0.05

The chance is 100% while a turn runs and 15% while the session is idle. So each model has a break-even prompt size, and a smaller prompt gets no refresh. The stop reason shows both sizes.

While the session is idle, each cache time gets at most its idle limit of refreshes after the last prompt. The limit is 0 to 20, 5 by default, set on the Configuration page or in cache-warmer.idle5m and cache-warmer.idle1h. When the last idle refresh is used, the band warns that warming stopped and shows when the cache expires, until your next prompt. While a turn runs, warming stops 60 minutes after the last prompt, or after two cache times if that is longer.

Warming also stops after compaction, /clear, a model change, a failed or expired refresh, or a timer that fired too late. The next request starts it again.

Debug mode

While debug mode is on, the newest refreshes show under the menu with their age, result, tokens, cost, and estimated saving. The mod also appends one JSON line per refresh and per stop to <config>/cache-warmer/debug/<session id>.jsonl, where <config> is CLAUDE_CONFIG_DIR or ~/.claude.

⚠️ The mod cannot see /rewind. After a rewind, refreshes keep the later prefix warm until the next request.

What it sends and stores

The mod sends each refresh automatically. Each refresh is a fork of the main conversation's last request, sent to the same model with this prompt:

[cache-warmer] Automated prompt cache refresh by the cache-warmer plugin, not a message from the user. Reply with the single word ok.

SendsOnly the refresh requests. No other network requests and no telemetry.
BillsEach refresh counts against your Claude plan or API key, like any request.
SetsCLAUDE_CODE_PROMPT_CACHE_TTL for the running Claude Code process
StoresAll-time totals in the plugin store, one notice row per refresh in the transcript, and the debug log while debug mode is on
StopsSet both idle limits to 0 to stop idle refreshes. Disable the plugin to stop all refreshes.

Credits

The refresh timing, the $0.05 rule, and the idea of an idle limit come from the cache warmer in Pi by Mario Zechner. cache-warmer ports them to a Claude Code mod and counts idle refreshes instead of stopping at 30 minutes. It copies no Pi code.

Support

Report problems at github.com/paulbkim-dev/claude-code-cache-warmer/issues. cache-warmer is released under the MIT License.

Source 6 files
hooks/register.tsx 1158 lines
1import { atom, read, update } from "claude-code";
2import type {
3  Args,
4  ConfigValue,
5  EngineInterface,
6  Frozen,
7  Hook,
8  MatchedEvent,
9  Next,
10  Register,
11  StreamNext,
12  Timer,
13  TurnStepChunk,
14  TurnStepResult,
15} from "claude-code";
16
17import type {
18  AllTime,
19  Anchor,
20  BandStyle,
21  IdleLimits,
22  Notice,
23  NoticeEnding,
24  Page,
25  Refresh,
26  Status,
27  Totals,
28  Ttl,
29  Warming,
30} from "../types";
31import { MASCOT_ROWS, TERMINAL_DEFAULT, cellsOf } from "./mascot";
32import { paneOf } from "./pages";
33import type { Mascot, PaneActions, PaneData } from "./pane";
34import {
35  MASCOT_KEY,
36  bandOf,
37  focusRowsOf,
38  layoutOf,
39  mascotBandOf,
40  rasterOf,
41} from "./pane";
42import type { ForkReply } from "./warmer";
43import {
44  DEFAULT_OUTPUT_TOKENS,
45  IDLE_LIMIT_DEFAULT,
46  TTL_MS,
47  ZERO_TOTALS,
48  addTotals,
49  costOf,
50  deadlineOf,
51  decide,
52  delayOf,
53  formatDuration,
54  formatTokens,
55  horizonOf,
56  isTtl,
57  limitOf,
58  missCostOf,
59  noticeOf,
60  outcomeOf,
61  promptTokensOf,
62  usageOf,
63} from "./warmer";
64
65const PANE = "cache-warmer";
66const CONFIG_KEY = "cache-warmer.ttl";
67const IDLE_KEYS = {
68  "5m": "cache-warmer.idle5m",
69  "1h": "cache-warmer.idle1h",
70} as const;
71const BAND_KEY = "cache-warmer.band";
72const ALL_TIME_KEY = "allTime";
73const FORK_PROMPT =
74  "[cache-warmer] Automated prompt cache refresh by the cache-warmer plugin, not a message from the user. Reply with the single word ok.";
75const NOTICE_MS = 5000;
76const FRAME_MS = 33;
77// The preview's warm and cold notices, in milliseconds after it starts.
78const PREVIEW_WARMED_MS = 6000;
79const PREVIEW_COLD_MS = 11_500;
80// How long before the pane opened a stopped warmer's Clawd fell asleep, so he shows already dozing.
81const DOZED_MS = 2000;
82
83const ttl = atom({ plugin: "cache-warmer", key: "ttl" } as const, "1h");
84const defaultTtl = atom(
85  { plugin: "cache-warmer", key: "defaultTtl" } as const,
86  "1h",
87);
88const isSessionTtl = atom(
89  { plugin: "cache-warmer", key: "isSessionTtl" } as const,
90  false,
91);
92const idleLimits = atom(
93  { plugin: "cache-warmer", key: "idleLimits" } as const,
94  { "5m": IDLE_LIMIT_DEFAULT, "1h": IDLE_LIMIT_DEFAULT },
95);
96const isForced = atom(
97  { plugin: "cache-warmer", key: "isForced" } as const,
98  false,
99);
100const isLocked = atom(
101  { plugin: "cache-warmer", key: "isLocked" } as const,
102  false,
103);
104const isDebug = atom(
105  { plugin: "cache-warmer", key: "isDebug" } as const,
106  false,
107);
108const bandStyle = atom(
109  { plugin: "cache-warmer", key: "bandStyle" } as const,
110  "default",
111);
112const page = atom({ plugin: "cache-warmer", key: "page" } as const, "main");
113const refreshes = atom(
114  { plugin: "cache-warmer", key: "refreshes" } as const,
115  [],
116);
117const status = atom({ plugin: "cache-warmer", key: "status" } as const, {
118  state: "waiting",
119});
120const notice = atom({ plugin: "cache-warmer", key: "notice" } as const, null);
121const totals = atom(
122  { plugin: "cache-warmer", key: "totals" } as const,
123  ZERO_TOTALS,
124);
125const allTime = atom({ plugin: "cache-warmer", key: "allTime" } as const, {
126  ...ZERO_TOTALS,
127  since: 0,
128});
129const now = atom({ plugin: "cache-warmer", key: "now" } as const, 0);
130const warming = atom({ plugin: "cache-warmer", key: "warming" } as const, {
131  anchor: null,
132  isRunning: false,
133  outputTokens: DEFAULT_OUTPUT_TOKENS,
134});
135
136let timer: Timer | undefined;
137let animation: Timer | undefined;
138let ticker: Timer | undefined;
139let hideNotice: Timer | undefined;
140// Notices shown and prompts started this process: a timer ends only the notice
141// it was set for, and a held notice goes with the next prompt.
142let notices = 0;
143let prompts = 0;
144let previewSteps: Timer[] = [];
145let isStill = false;
146// Claude Code's theme, "auto" resolved to "light" or "dark".
147let theme = "dark";
148// The pane's and the band's requestIds while they draw Clawd, each with the
149// color behind him there; blits repaint him in each.
150const sites = new Map<string, number>();
151let paneSince = 0;
152// The pane's Buttons by row as last drawn, and the one holding its focus ring.
153let focusRows: string[][] = [];
154let focused: string | undefined;
155let isPainting = false;
156let syncs = 0;
157// The anchor a refresh has claimed, one object per refresh; schedule() leaves
158// the anchor until chain() records the refresh.
159let forking: { at: number } | undefined;
160// Debug log appends and all-time store updates each run one at a time, since
161// each reads and rewrites the whole value; a failed one is logged and the next runs.
162let debugWrites = Promise.resolve();
163let allTimeWrites = Promise.resolve();
164
165type Scene = Pick<Notice, "mood" | "startedAt" | "since">;
166
167type DebugLine =
168  | (Omit<Refresh, "at"> & { at: string; phase: "run" | "idle"; ttl: Ttl })
169  | { at: string; reason: string };
170
171const effectiveTtl = async ($: EngineInterface): Promise<Ttl> =>
172  (await read($, isForced)) ? "5m" : read($, ttl);
173
174const debugPathOf = async ($: EngineInterface) => {
175  const configDir = await $.env.get("CLAUDE_CONFIG_DIR");
176  const home = await $.env.get("HOME");
177  if (configDir === undefined && home === undefined)
178    throw new Error("neither CLAUDE_CONFIG_DIR nor HOME is set");
179  return `${configDir ?? `${home}/.claude`}/cache-warmer/debug/${await $.session.id()}.jsonl`;
180};
181
182const appendDebug = async ($: EngineInterface, line: DebugLine) => {
183  try {
184    const path = await debugPathOf($);
185    const before = (await $.fs.exists(path)) ? await $.fs.read(path) : "";
186    await $.fs.write(path, `${before}${JSON.stringify(line)}\n`);
187  } catch (error) {
188    $.ui.log(
189      `Cache warmer could not write its debug log: ${error instanceof Error ? error.message : String(error)}`,
190    );
191  }
192};
193
194// While debug mode is on, one JSON line per refresh and per stop; a failed write is logged and warming goes on.
195const logDebug = async ($: EngineInterface, line: DebugLine) => {
196  if (!(await read($, isDebug))) return;
197  debugWrites = debugWrites.then(() => appendDebug($, line));
198  await debugWrites;
199};
200
201// SAFETY: only addToTotals and startSession write ALL_TIME_KEY, always as AllTime; the store's JSON round trip keeps it.
202const storedAllTime = async ($: EngineInterface) =>
203  (await $.store.get(ALL_TIME_KEY)) as AllTime | undefined;
204
205const addToAllTime = async ($: EngineInterface, delta: Partial<Totals>) => {
206  const next = addTotals(
207    (await storedAllTime($)) ?? { ...ZERO_TOTALS, since: await $.clock.now() },
208    delta,
209  );
210  await $.store.set(ALL_TIME_KEY, next);
211  await update($, allTime, () => next);
212};
213
214// This session's totals and the all-time totals in $.store take the same delta.
215const addToTotals = async ($: EngineInterface, delta: Partial<Totals>) => {
216  await update($, totals, (current) => addTotals(current, delta));
217  allTimeWrites = allTimeWrites.then(async () => {
218    try {
219      await addToAllTime($, delta);
220    } catch (error) {
221      $.ui.log(
222        `Cache warmer could not save its all-time totals: ${error instanceof Error ? error.message : String(error)}`,
223      );
224    }
225  });
226  await allTimeWrites;
227};
228
229const reportStop = async ($: EngineInterface, reason: string) => {
230  await update($, status, (): Status => ({ state: "stopped", reason }));
231  await logDebug($, {
232    at: new Date(await $.clock.now()).toISOString(),
233    reason,
234  });
235};
236
237// Compaction, /clear, a session's end and a model switch forget the chain:
238// only the next prompt's turn.step starts warming again, and the chain's fee
239// is wasted now, since no prompt will judge it.
240const forget = async ($: EngineInterface, reason: string) => {
241  timer?.cancel();
242  timer = undefined;
243  const { anchor } = await read($, warming);
244  if (anchor && anchor.feeUsd > 0)
245    await addToTotals($, { wastedUsd: anchor.feeUsd });
246  await update($, warming, (current): Warming => ({
247    ...current,
248    anchor: null,
249  }));
250  await reportStop($, reason);
251};
252
253// Stops the chain anchored at `at`, adding `feeUsd` to it, until the next
254// prompt; a prompt that replaced it meanwhile keeps its own warming. A timer
255// left for it finds it stopped. Answers whether it stopped that chain.
256const stopChain = async (
257  $: EngineInterface,
258  at: number,
259  reason: string,
260  feeUsd = 0,
261) => {
262  let isStopped = false;
263  await update($, warming, (state): Warming => {
264    const { anchor } = state;
265    isStopped = anchor?.at === at && !anchor.isStopped;
266    if (!anchor || !isStopped) return state;
267    return {
268      ...state,
269      anchor: { ...anchor, feeUsd: anchor.feeUsd + feeUsd, isStopped: true },
270    };
271  });
272  if (isStopped) await reportStop($, reason);
273  return isStopped;
274};
275
276// A running turn stops at the run horizon; an idle session after its lifetime's idle limit.
277// The anchor is read last, so the timer is set from the anchor as it stands:
278// a refresh that settled while an earlier read waited cannot leave a stale one.
279const schedule = async ($: EngineInterface) => {
280  const prompt = prompts;
281  const limits = await read($, idleLimits);
282  const at = await $.clock.now();
283  const { anchor: current, isRunning, outputTokens } = await read($, warming);
284  if (!current || current.isStopped || current.at === forking?.at) return;
285  const phase = isRunning ? "run" : "idle";
286  const nextAt = current.lastAt + delayOf(current.ttl);
287  const horizon = horizonOf(current.ttl);
288  if (phase === "run" && nextAt > current.at + horizon)
289    return stopChain(
290      $,
291      current.at,
292      `${formatDuration(horizon)} run limit reached`,
293    );
294  const limit = limits[current.ttl];
295  if (phase === "idle" && current.idleRefreshes >= limit) {
296    const isStopped = await stopChain(
297      $,
298      current.at,
299      `${limit} idle refreshes reached`,
300    );
301    // A limit of 0 turned idle warming off on purpose; it needs no warning.
302    if (isStopped && limit > 0) await warnIdleLimit($, current, limit, prompt);
303    return isStopped;
304  }
305  const decision = decide(
306    current.model,
307    current.promptTokens,
308    current.ttl,
309    phase,
310    outputTokens,
311  );
312  if (!decision)
313    return stopChain($, current.at, `no price for ${current.model}`);
314  if (decision.reason) return stopChain($, current.at, decision.reason);
315  timer?.cancel();
316  timer = $.clock.after(Math.max(0, nextAt - at), () => void refresh($));
317  await update($, status, (): Status => ({
318    state: "scheduled",
319    nextAt,
320    phase,
321    expectedUsd: decision.expectedUsd,
322  }));
323};
324
325const isPaneOpen = async ($: EngineInterface) =>
326  (await $.ui.panes()).some((pane) => pane.id === PANE);
327
328// Under "auto", a light background in COLORFGBG means light. Claude Code also
329// asks the terminal under "auto"; plugins cannot read that answer.
330const themeOf = async ($: EngineInterface): Promise<string> => {
331  const chosen = String(
332    (await $.config.list()).find((row) => row.key === "theme")?.value ?? "auto",
333  );
334  if (chosen !== "auto") return chosen;
335  const background = Number((await $.env.get("COLORFGBG"))?.split(";").at(-1));
336  return background === 7 || (background >= 9 && background <= 15)
337    ? "light"
338    : "dark";
339};
340
341// A docked pane is drawn on Claude Code's composerSidebarBackground (as of
342// 2.1.289); its ANSI themes name a terminal color, which a Raster cannot.
343const dockBackgroundOf = (name: string) => {
344  if (name === "dark" || name === "dark-daltonized") return 0x262626;
345  if (name === "light") return 0xf5f5f5;
346  if (name === "light-daltonized") return 0xebebeb;
347  return TERMINAL_DEFAULT;
348};
349
350// Reduced motion draws one frame: Clawd standing, the steam mid-rise.
351const mascotOf = (scene: Scene, at: number, background: number) => {
352  const palette = theme.startsWith("light") ? "light" : "dark";
353  return isStill
354    ? cellsOf(1, scene.mood, 3, palette, background)
355    : cellsOf(
356        (at - scene.startedAt) / 1000,
357        scene.mood,
358        (at - scene.since) / 1000,
359        palette,
360        background,
361      );
362};
363
364// The pane plays the notice while one moves, and otherwise the
365// warmer's state: Clawd sips while it warms and dozes once it stopped.
366const sceneOf = async ($: EngineInterface): Promise<Scene> => {
367  const shown = await liveNoticeOf($);
368  if (shown && shown.heldAt === null) return shown;
369  const isStopped = (await read($, status)).state === "stopped";
370  return {
371    mood: isStopped ? "cold" : "warming",
372    startedAt: paneSince,
373    since: paneSince - DOZED_MS,
374  };
375};
376
377// A refused blit means a site no longer shows Clawd: the pane closed or left
378// the main page, or the band's notice went. Painting there resumes when it
379// draws Clawd again.
380const paint = async ($: EngineInterface) => {
381  if (isPainting || sites.size === 0) return;
382  isPainting = true;
383  try {
384    const at = await $.clock.now();
385    for (const [requestId, background] of sites) {
386      const answer = await $.ui.blit({
387        requestId,
388        key: MASCOT_KEY,
389        cells: mascotOf(await sceneOf($), at, background),
390      });
391      if (answer.deny !== undefined) sites.delete(requestId);
392    }
393  } finally {
394    isPainting = false;
395  }
396};
397
398// Frames run while the pane is open or the band shows Clawd moving with a notice,
399// and the pane's clock ticks only while it is open. A theme change shows from
400// the next start. The latest call decides: one that a later call overtook while
401// it waited leaves the timers alone.
402const syncAnimation = async ($: EngineInterface) => {
403  const call = ++syncs;
404  const shown = await liveNoticeOf($);
405  const isOpen = await isPaneOpen($);
406  const isShown =
407    isOpen ||
408    (shown !== null &&
409      shown.heldAt === null &&
410      (await read($, bandStyle)) === "default");
411  const resolved = isShown && !animation ? await themeOf($) : theme;
412  if (call !== syncs) return;
413  theme = resolved;
414  if (!isOpen) {
415    ticker?.cancel();
416    ticker = undefined;
417  } else ticker ??= $.clock.every(1000, () => void tick($));
418  if (!isShown) {
419    animation?.cancel();
420    animation = undefined;
421  } else if (!animation && !isStill)
422    animation = $.clock.every(FRAME_MS, () => void paint($));
423};
424
425// A fading notice goes; a held one keeps Clawd on the frame he reached and
426// stops the frames. A notice shown since the timer was set is left alone.
427const endNotice = async (
428  $: EngineInterface,
429  shownAs: number,
430  ending: NoticeEnding,
431) => {
432  const at = await $.clock.now();
433  await update($, notice, (current) => {
434    if (!current || shownAs !== notices) return current;
435    return ending === "fades" ? null : { ...current, heldAt: at };
436  });
437  await syncAnimation($);
438};
439
440const showNotice = async (
441  $: EngineInterface,
442  shown: Pick<Notice, "ttl" | "head" | "detail" | "mood">,
443  ending: NoticeEnding,
444  prompt = prompts,
445) => {
446  hideNotice?.cancel();
447  hideNotice = undefined;
448  const shownAs = ++notices;
449  const at = await $.clock.now();
450  await update($, notice, (current): Notice => ({
451    ...shown,
452    ending,
453    startedAt: current?.startedAt ?? at,
454    since: current?.mood === shown.mood ? current.since : at,
455    heldAt: null,
456    prompt,
457  }));
458  await syncAnimation($);
459  if (ending !== "stays" && shownAs === notices)
460    hideNotice = $.clock.after(
461      NOTICE_MS,
462      () => void endNotice($, shownAs, ending),
463    );
464};
465
466// The notice on show: a held one from before the latest prompt is gone, even
467// when it was written after that prompt cleared the notice.
468const liveNoticeOf = async ($: EngineInterface) => {
469  const shown = await read($, notice);
470  return shown && (shown.ending !== "holds" || shown.prompt === prompts)
471    ? shown
472    : null;
473};
474
475// An outcome reached while a turn runs fades; one in an idle session holds until the next prompt.
476const outcomeEndingOf = async ($: EngineInterface): Promise<NoticeEnding> =>
477  (await read($, warming)).isRunning ? "fades" : "holds";
478
479// A notice row: the transcript file keeps it, and no request carries it.
480const appendNotice = async ($: EngineInterface, text: string) => {
481  try {
482    await $.session.append({
483      message: { type: "system", content: [{ type: "text", text }] },
484    });
485  } catch (error) {
486    $.ui.log(
487      `Cache warmer could not record the refresh: ${error instanceof Error ? error.message : String(error)}`,
488    );
489  }
490};
491
492// The last idle refresh warmed the cache once more; after it no refresh comes,
493// and the cache expires a lifetime after it.
494const warnIdleLimit = async (
495  $: EngineInterface,
496  current: Anchor,
497  limit: number,
498  prompt: number,
499) => {
500  // A prompt since the stop restarted warming, so nothing has stopped.
501  if (prompt !== prompts) return;
502  const expiresAt = new Date(current.lastAt + TTL_MS[current.ttl])
503    .toTimeString()
504    .slice(0, 5);
505  const head = "Warming stopped";
506  const detail = `all ${limit} idle refreshes used · cache expires at ${expiresAt}`;
507  await showNotice(
508    $,
509    { ttl: current.ttl, head, detail, mood: "cold" },
510    "holds",
511    prompt,
512  );
513  await appendNotice($, `☕ ${current.ttl} · ${head} · ${detail}`);
514};
515
516const record = async (
517  $: EngineInterface,
518  entry: Refresh,
519  phase: "run" | "idle",
520  entryTtl: Ttl,
521) => {
522  await update($, refreshes, (list) => [...list, entry].slice(-200));
523  await addToTotals($, { refreshes: 1, costUsd: entry.costUsd ?? 0 });
524  await logDebug($, {
525    ...entry,
526    at: new Date(entry.at).toISOString(),
527    phase,
528    ttl: entryTtl,
529  });
530};
531
532// Moves a warm refresh's chain on, when the chain is still current; answers whether it was.
533const extend = async (
534  $: EngineInterface,
535  current: Anchor,
536  entry: Refresh,
537  phase: "run" | "idle",
538) => {
539  let isChained = false;
540  await update($, warming, (state): Warming => {
541    const { anchor } = state;
542    isChained = anchor?.at === current.at && !anchor.isStopped;
543    if (!anchor || !isChained) return state;
544    return {
545      ...state,
546      outputTokens: entry.usage?.output || DEFAULT_OUTPUT_TOKENS,
547      anchor: {
548        ...anchor,
549        lastAt: entry.at,
550        refreshes: anchor.refreshes + 1,
551        idleRefreshes: anchor.idleRefreshes + (phase === "idle" ? 1 : 0),
552        feeUsd: anchor.feeUsd + (entry.costUsd ?? 0),
553      },
554    };
555  });
556  return isChained;
557};
558
559// The chain carries each refresh's fee. A refresh that a prompt overtook
560// during its fork kept nothing that prompt read, so its fee is wasted; so is
561// one whose chain was forgotten. Each check runs inside the write, so a stop
562// or a prompt that lands meanwhile wins.
563const chain = async (
564  $: EngineInterface,
565  current: Anchor,
566  entry: Refresh,
567  phase: "run" | "idle",
568) => {
569  const costUsd = entry.costUsd ?? 0;
570  const isWarmed = entry.result === "warmed";
571  const reason =
572    entry.result === "expired" ? "cache had expired" : "refresh failed";
573  const isChained = isWarmed
574    ? await extend($, current, entry, phase)
575    : await stopChain($, current.at, reason, costUsd);
576  // The anchor holds this refresh now, so scheduling it cannot fork it twice.
577  if (forking?.at === current.at) forking = undefined;
578  if (!isChained) {
579    if (costUsd > 0) await addToTotals($, { wastedUsd: costUsd });
580    return;
581  }
582  if (isWarmed) await schedule($);
583};
584
585const settle = async (
586  $: EngineInterface,
587  current: Anchor,
588  at: number,
589  phase: "run" | "idle",
590  missUsd: number,
591  reply: ForkReply,
592  prompt: number,
593) => {
594  const usage = usageOf(reply.usage);
595  // The fork's own write is its short tail; pricing it at the entry's lifetime overstates it at most.
596  const costUsd = costOf(current.model, usage, current.ttl);
597  const outcome = outcomeOf(reply, usage, current.promptTokens);
598  const savesUsd =
599    outcome.result === "warmed" && costUsd !== null ? missUsd - costUsd : null;
600  const entry: Refresh = {
601    at,
602    model: current.model,
603    usage,
604    costUsd,
605    savesUsd,
606    ...outcome,
607  };
608  await record($, entry, phase, current.ttl);
609  const { head, detail } = noticeOf(entry);
610  await appendNotice($, `☕ ${current.ttl} · ${head} · ${detail}`);
611  await showNotice(
612    $,
613    {
614      ttl: current.ttl,
615      head,
616      detail,
617      mood: entry.result === "warmed" ? "warmed" : "cold",
618    },
619    await outcomeEndingOf($),
620    prompt,
621  );
622  // A prompt that arrived during the fork set a new anchor and its own timer;
623  // the last idle refresh's warning replaces its notice.
624  await chain($, current, entry, phase);
625};
626
627const forkFor = async (
628  $: EngineInterface,
629  current: Anchor,
630  phase: "run" | "idle",
631  outputTokens: number,
632) => {
633  const prompt = prompts;
634  const at = await $.clock.now();
635  if (at > deadlineOf(current.lastAt, current.ttl))
636    return stopChain($, current.at, "refresh deadline missed");
637  const decision = decide(
638    current.model,
639    current.promptTokens,
640    current.ttl,
641    phase,
642    outputTokens,
643  );
644  if (!decision)
645    return stopChain($, current.at, `no price for ${current.model}`);
646  if (decision.reason) return stopChain($, current.at, decision.reason);
647  await update($, status, (): Status => ({ state: "refreshing" }));
648  stopPreview();
649  await showNotice(
650    $,
651    {
652      ttl: current.ttl,
653      head: "Refreshing cache",
654      detail: `resending the cached ${formatTokens(current.promptTokens)}-token prompt…`,
655      mood: "warming",
656    },
657    "stays",
658  );
659  const reply = await $.model.fork({ prompt: FORK_PROMPT });
660  if (!reply.isAnswered && reply.reason === "nothing-to-fork") {
661    await showNotice(
662      $,
663      { ttl: current.ttl, head: "Nothing to warm", detail: "", mood: "cold" },
664      await outcomeEndingOf($),
665      prompt,
666    );
667    return stopChain($, current.at, "nothing to refresh");
668  }
669  await settle($, current, at, phase, decision.missUsd, reply, prompt);
670};
671
672// The refresh claims its anchor until chain() records it there, so a
673// schedule() meanwhile, from a turn that ended, cannot fork it again.
674const refresh = async ($: EngineInterface) => {
675  timer = undefined;
676  const { anchor: current, isRunning, outputTokens } = await read($, warming);
677  if (!current || current.isStopped || current.at === forking?.at) return;
678  const claim = { at: current.at };
679  forking = claim;
680  try {
681    await forkFor($, current, isRunning ? "run" : "idle", outputTokens);
682  } finally {
683    if (forking === claim) forking = undefined;
684  }
685};
686
687const setTtl = async ($: EngineInterface, value: Ttl) => {
688  await $.env.set("CLAUDE_CODE_PROMPT_CACHE_TTL", value);
689  await update($, ttl, () => value);
690};
691
692const describeTtl = async ($: EngineInterface) => {
693  const chosen = await read($, ttl);
694  if (await read($, isForced))
695    return `Cache lifetime ${chosen}, overridden to 5m by FORCE_PROMPT_CACHING_5M.`;
696  return `Cache lifetime ${chosen}; refreshes every ${formatDuration(delayOf(chosen))}. The next request rewrites the cache once.`;
697};
698
699const lockedReason = async ($: EngineInterface) =>
700  (await read($, isLocked))
701    ? `the cache is written at ${await read($, ttl)} for this session; /clear or a new session unlocks it`
702    : undefined;
703
704// A saved default reaches this session only while no response has locked its lifetime.
705const applyDefault = async ($: EngineInterface, value: Ttl) => {
706  await update($, defaultTtl, () => value);
707  if (await read($, isLocked)) return;
708  await update($, isSessionTtl, () => false);
709  await setTtl($, value);
710};
711
712const logDenied = ($: EngineInterface, what: string, reason: string) =>
713  $.ui.log(`Cache warmer could not save the ${what}: ${reason}`);
714
715// The command sets this session and the saved default, and refuses once locked.
716// $.config.set skips this plugin's own config.set hook, so each caller applies the value itself.
717const chooseTtl = async ($: EngineInterface, value: Ttl) => {
718  const locked = await lockedReason($);
719  if (locked) return `Cache lifetime unchanged: ${locked}`;
720  const answer = await $.config.set({ key: CONFIG_KEY, value });
721  if (answer.deny !== undefined)
722    return `Cache lifetime unchanged: ${answer.deny}`;
723  await applyDefault($, value);
724  return describeTtl($);
725};
726
727// The Global page saves the default even while this session is locked.
728const chooseDefault = async ($: EngineInterface, value: Ttl) => {
729  const answer = await $.config.set({ key: CONFIG_KEY, value });
730  if (answer.deny !== undefined)
731    return logDenied($, "default lifetime", answer.deny);
732  await applyDefault($, value);
733};
734
735// The Session page changes this session alone, not the saved default.
736const chooseSessionTtl = async ($: EngineInterface, value: Ttl) => {
737  if (await read($, isLocked)) return;
738  await update($, isSessionTtl, () => true);
739  await setTtl($, value);
740};
741
742// A new limit applies to the warming under way; a refresh in flight schedules itself when it settles.
743const setLimit = async ($: EngineInterface, limitTtl: Ttl, count: number) => {
744  const value = limitOf(count);
745  const answer = await $.config.set({ key: IDLE_KEYS[limitTtl], value });
746  if (answer.deny !== undefined)
747    return logDenied($, `${limitTtl} idle limit`, answer.deny);
748  await update($, idleLimits, (limits) => ({ ...limits, [limitTtl]: value }));
749  if ((await read($, status)).state === "scheduled") await schedule($);
750};
751
752const bandStyleOf = (value: ConfigValue | undefined): BandStyle =>
753  value === "simplified" || value === "off" ? value : "default";
754
755const applyBand = async ($: EngineInterface, value: BandStyle) => {
756  await update($, bandStyle, () => value);
757  await syncAnimation($);
758};
759
760const setBand = async ($: EngineInterface, value: BandStyle) => {
761  const answer = await $.config.set({ key: BAND_KEY, value });
762  if (answer.deny !== undefined) return logDenied($, "band style", answer.deny);
763  await applyBand($, bandStyleOf(answer.value));
764};
765
766const tick = async ($: EngineInterface) => {
767  const at = await $.clock.now();
768  await update($, now, () => at);
769};
770
771// A reload from 0.5 kept state written without the 0.6 fields.
772const migrate = async ($: EngineInterface) => {
773  await update($, totals, (current) => ({ ...ZERO_TOTALS, ...current }));
774  await update($, warming, (current): Warming => ({
775    ...current,
776    anchor: current.anchor && {
777      ...current.anchor,
778      idleRefreshes: current.anchor.idleRefreshes ?? 0,
779      feeUsd: current.anchor.feeUsd ?? 0,
780    },
781  }));
782  // 0.6.3's Debug page is now the menu's toggle.
783  await update($, page, (current) =>
784    ["config", "analytics"].includes(current) ? current : "main",
785  );
786};
787
788const startSession = async (
789  $: EngineInterface,
790  option: Ttl,
791  limits: IdleLimits,
792  band: BandStyle,
793) => {
794  await $.command.register({
795    name: "cache-warmer",
796    description:
797      "Toggle the cache warmer pane, set the prompt cache lifetime, or preview the band",
798    argumentHint: "[5m|1h|preview]",
799  });
800  const forced = await $.env.get("FORCE_PROMPT_CACHING_5M");
801  await update($, isForced, () => forced === "1" || forced === "true");
802  await update($, defaultTtl, () => option);
803  await update($, idleLimits, () => limits);
804  await update($, bandStyle, () => band);
805  // A reload after the first response keeps the lifetime the cache was written
806  // with, and one after a Session page choice keeps that choice.
807  const isKept = (await read($, isLocked)) || (await read($, isSessionTtl));
808  await setTtl($, isKept ? await read($, ttl) : option);
809  await migrate($);
810  const stored = await storedAllTime($);
811  const total = stored ?? { ...ZERO_TOTALS, since: await $.clock.now() };
812  if (!stored) await $.store.set(ALL_TIME_KEY, total);
813  await update($, allTime, () => total);
814  isStill = (await $.config.list()).some(
815    (row) => row.key === "reduceMotion" && row.value === true,
816  );
817  // A reload cancels the old timers: drop a notice left up, re-arm warming
818  // and animate a pane left open.
819  await update($, notice, () => null);
820  await schedule($);
821  paneSince = await $.clock.now();
822  await syncAnimation($);
823};
824
825const stopPreview = () => {
826  for (const step of previewSteps) step.cancel();
827  previewSteps = [];
828};
829
830// Plays the band a refresh shows, without a fork: warming, warmed, then cold.
831const preview = async ($: EngineInterface) => {
832  stopPreview();
833  const shownTtl = await effectiveTtl($);
834  const step = (head: string, mood: Notice["mood"], isDone: boolean) =>
835    showNotice(
836      $,
837      { ttl: shownTtl, head, detail: "preview", mood },
838      isDone ? "fades" : "stays",
839    );
840  await step("Refreshing cache", "warming", false);
841  previewSteps = [
842    $.clock.after(
843      PREVIEW_WARMED_MS,
844      () => void step("Cache warmed", "warmed", false),
845    ),
846    $.clock.after(
847      PREVIEW_COLD_MS,
848      () => void step("Cache had expired", "cold", true),
849    ),
850  ];
851};
852
853const runCommand = async ($: EngineInterface, args: string) => {
854  const arg = args.trim();
855  if (isTtl(arg)) return { text: await chooseTtl($, arg) };
856  if (arg === "preview") {
857    if ((await read($, status)).state === "refreshing")
858      return { text: "A refresh is running; the band shows it now." };
859    await preview($);
860    return { text: "Cache warmer band preview started." };
861  }
862  if (arg) return { text: "Usage: /cache-warmer [5m|1h|preview]" };
863  const shown = (await $.ui.panes()).find((pane) => pane.id === PANE);
864  if (shown?.isFocused) {
865    await $.ui.close({ id: PANE });
866    await syncAnimation($);
867    return { text: "Cache warmer closed." };
868  }
869  // An open pane without the keyboard takes it back on the main page instead of closing.
870  if (!shown) paneSince = await $.clock.now();
871  await update($, page, (): Page => "main");
872  await $.ui.open({
873    id: PANE,
874    title: "Cache warmer",
875    rows: 14,
876    focus: true,
877    closeOnEscape: true,
878  });
879  await syncAnimation($);
880  return { text: shown ? "Cache warmer focused." : "Cache warmer opened." };
881};
882
883// A prompt is kept when it read an entry that would have expired without the
884// refreshes since the last prompt; it avoided rewriting the tokens it read.
885// Otherwise the previous chain's fee is wasted.
886const judgeChain = async (
887  $: EngineInterface,
888  previous: Anchor | null,
889  at: number,
890  cacheRead: number,
891) => {
892  if (!previous) return;
893  const isKept =
894    previous.refreshes > 0 &&
895    at - previous.at > TTL_MS[previous.ttl] &&
896    cacheRead >= previous.promptTokens / 2;
897  const keptUsd = isKept
898    ? missCostOf(previous.model, cacheRead, previous.ttl)
899    : null;
900  if (keptUsd !== null) await addToTotals($, { kept: 1, keptUsd });
901  else if (previous.feeUsd > 0)
902    await addToTotals($, { wastedUsd: previous.feeUsd });
903};
904
905const stepTurn = async function* (
906  $: EngineInterface,
907  e: Frozen<Args<"turn.step">>,
908  next: StreamNext<"turn.step">,
909): AsyncGenerator<TurnStepChunk, TurnStepResult> {
910  if (e.agentId !== undefined) return yield* next(e);
911  const at = await $.clock.now();
912  const before = (await read($, warming)).anchor;
913  // The lifetime this request is sent with; a choice made while it runs applies to the next one.
914  const entryTtl = await effectiveTtl($);
915  const result = yield* next(e);
916  if (!result.usage) return result;
917  await update($, isLocked, () => true);
918  const usage = usageOf(result.usage);
919  await judgeChain($, before, at, usage.cacheRead);
920  // Refreshes that settled while this request ran came after it, so they start the new chain.
921  const after = (await read($, warming)).anchor;
922  const isSameChain = before !== null && after?.at === before.at;
923  const model = result.usage.model;
924  await update($, warming, (current): Warming => ({
925    ...current,
926    anchor: {
927      at,
928      lastAt: at,
929      model,
930      promptTokens: promptTokensOf(usage),
931      ttl: entryTtl,
932      refreshes: isSameChain ? after.refreshes - before.refreshes : 0,
933      idleRefreshes: 0,
934      feeUsd: isSameChain ? after.feeUsd - before.feeUsd : 0,
935      isStopped: false,
936    },
937  }));
938  await schedule($);
939  return result;
940};
941
942// The next prompt takes down an outcome held from before it; a refresh under
943// way, or a notice shown since, keeps its place. A held notice's timer finds it gone.
944const onTurnStart: Hook<"turn.start"> = async ($, e, next) => {
945  prompts += 1;
946  const prompt = prompts;
947  await update($, warming, (current): Warming => ({
948    ...current,
949    isRunning: true,
950  }));
951  await update($, notice, (current) =>
952    current?.ending === "holds" && current.prompt < prompt ? null : current,
953  );
954  await syncAnimation($);
955  return next(e);
956};
957
958const onTurnComplete: Hook<"turn.complete"> = async ($, e, next) => {
959  if (e.agentId === undefined) {
960    await update($, warming, (current): Warming => ({
961      ...current,
962      isRunning: false,
963    }));
964    await schedule($);
965  }
966  return next(e);
967};
968
969// /config saves the default whatever the lock; applyDefault decides whether this session follows.
970const onTtlConfigSet: Hook<"config.set"> = async ($, e, next) => {
971  const answer = await next(e);
972  const value = String(answer.value);
973  if (answer.deny === undefined && isTtl(value)) await applyDefault($, value);
974  return answer;
975};
976
977// /config clamps an idle limit to 0..20 before saving it.
978const setIdleFromConfig = async (
979  $: EngineInterface,
980  e: Frozen<Args<"config.set">>,
981  next: Next<"config.set">,
982  limitTtl: Ttl,
983) => {
984  const answer = await next({ ...e, value: limitOf(e.value) });
985  if (answer.deny === undefined) {
986    const value = limitOf(answer.value);
987    await update($, idleLimits, (limits) => ({ ...limits, [limitTtl]: value }));
988    if ((await read($, status)).state === "scheduled") await schedule($);
989  }
990  return answer;
991};
992
993const renderBand = async (
994  $: EngineInterface,
995  e: Frozen<MatchedEvent<"ui.render", { component: "AbovePrompt" }>>,
996) => {
997  const shown = await liveNoticeOf($);
998  const style = await read($, bandStyle);
999  sites.delete(e.requestId);
1000  if (!shown || e.props.hasSurvey || style === "off") return undefined;
1001  if (
1002    style === "simplified" ||
1003    e.surface !== "terminal" ||
1004    e.props.maxRows < MASCOT_ROWS
1005  )
1006    return bandOf($.ui.resolve(e), shown);
1007  // A held notice draws the frame Clawd stopped on, and takes no blits.
1008  if (shown.heldAt === null) sites.set(e.requestId, TERMINAL_DEFAULT);
1009  const elements = $.ui.resolve(e);
1010  const at = shown.heldAt ?? (await $.clock.now());
1011  const cells = mascotOf(shown, at, TERMINAL_DEFAULT);
1012  return mascotBandOf(elements, shown, rasterOf(elements, cells));
1013};
1014
1015const renderPane = async (
1016  $: EngineInterface,
1017  e: Frozen<
1018    MatchedEvent<"ui.render", { component: "Pane"; requestId: typeof PANE }>
1019  >,
1020) => {
1021  const shown = await read($, page);
1022  const debugOn = await read($, isDebug);
1023  const data: PaneData = {
1024    page: shown,
1025    chosen: await read($, ttl),
1026    saved: await read($, defaultTtl),
1027    limits: await read($, idleLimits),
1028    isForced: await read($, isForced),
1029    isLocked: await read($, isLocked),
1030    isDebug: debugOn,
1031    bandStyle: await read($, bandStyle),
1032    state: await read($, status),
1033    totals: await read($, totals),
1034    allTime: await read($, allTime),
1035    list: await read($, refreshes),
1036    logPath: shown === "main" && debugOn ? await debugPathOf($) : "",
1037    at: Math.max(await read($, now), await $.clock.now()),
1038    width: e.props.bodyColumns,
1039    rows: e.props.scroll.bodyRows,
1040  };
1041  let mascot: Mascot | undefined;
1042  sites.delete(PANE);
1043  const scene = await sceneOf($);
1044  const layout = shown === "main" ? layoutOf(data) : undefined;
1045  if (e.surface === "terminal" && layout) {
1046    const background =
1047      e.props.placement === "dock" ? dockBackgroundOf(theme) : TERMINAL_DEFAULT;
1048    sites.set(PANE, background);
1049    const cells = mascotOf(scene, data.at, background);
1050    mascot = { raster: rasterOf($.ui.resolve(e), cells), layout };
1051  }
1052  const actions: PaneActions = {
1053    open: (next) => void update($, page, () => next),
1054    toggleDebug: () => void update($, isDebug, (current) => !current),
1055    setBand: (value) => void setBand($, value),
1056    chooseDefault: (value) => void chooseDefault($, value),
1057    chooseSession: (value) => void chooseSessionTtl($, value),
1058    setLimit: (limitTtl, count) => void setLimit($, limitTtl, count),
1059  };
1060  const tree = paneOf($.ui.resolve(e), data, actions, mascot);
1061  const drawn = focusRowsOf(tree);
1062  focusRows = drawn.rows;
1063  // A page's autoFocus Button takes the ring without raising ui.focus.
1064  if (!focusRows.flat().includes(focused ?? "")) focused = drawn.initial;
1065  return tree;
1066};
1067
1068// An arrow over a pane taller than its window scrolls it, leaving the focus
1069// behind out of sight; it moves the focus a row instead, which brings it into
1070// view. The wheel, and an arrow past the first or last row, still scroll.
1071const arrowToFocus = async (
1072  $: EngineInterface,
1073  e: Frozen<Args<"ui.scroll">>,
1074  next: Next<"ui.scroll">,
1075) => {
1076  if (e.origin.kind !== "person" || e.pointer || Math.abs(e.by) !== 1)
1077    return next(e);
1078  const index = focusRows.findIndex((row) => row.includes(focused ?? ""));
1079  const from = focusRows[index]?.indexOf(focused ?? "") ?? 0;
1080  const row = focusRows[index + e.by];
1081  const key = row?.[Math.min(from, row.length - 1)];
1082  if (index < 0 || !key) return next(e);
1083  const answer = await $.ui.focus({ requestId: PANE, key });
1084  return answer.deny === undefined ? {} : next(e);
1085};
1086
1087export const register: Register = (on, options) => {
1088  const chosen = String(options.ttl);
1089  const option = isTtl(chosen) ? chosen : "1h";
1090  const limits = {
1091    "5m": limitOf(options.idle5m ?? IDLE_LIMIT_DEFAULT),
1092    "1h": limitOf(options.idle1h ?? IDLE_LIMIT_DEFAULT),
1093  };
1094  const band = bandStyleOf(options.band);
1095  on("session.start", async ($, e, next) => {
1096    await startSession($, option, limits, band);
1097    return next(e);
1098  });
1099  on("command.run", { command: "cache-warmer" }, ($, e) =>
1100    runCommand($, e.args),
1101  );
1102  on("config.set", { key: CONFIG_KEY }, onTtlConfigSet);
1103  on("config.set", { key: "cache-warmer.idle5m" }, ($, e, next) =>
1104    setIdleFromConfig($, e, next, "5m"),
1105  );
1106  on("config.set", { key: "cache-warmer.idle1h" }, ($, e, next) =>
1107    setIdleFromConfig($, e, next, "1h"),
1108  );
1109  on("config.set", { key: BAND_KEY }, async ($, e, next) => {
1110    const answer = await next(e);
1111    if (answer.deny === undefined)
1112      await applyBand($, bandStyleOf(answer.value));
1113    return answer;
1114  });
1115  on("turn.start", onTurnStart);
1116  on("turn.step", async function* ($, e, next) {
1117    return yield* stepTurn($, e, next);
1118  });
1119  on("turn.complete", onTurnComplete);
1120  on("session.compact", async ($, e, next) => {
1121    const answer = await next(e);
1122    if (e.agentId === undefined) await forget($, "conversation compacted");
1123    return answer;
1124  });
1125  // Every ending forgets the chain, so its fee is settled before the process can exit.
1126  on("session.end", async ($, e, next) => {
1127    if (e.reason !== "clear") await forget($, "session ended");
1128    else {
1129      await forget($, "conversation cleared");
1130      await update($, isLocked, () => false);
1131      // /clear starts a new session id without session.start, so it takes the saved default here.
1132      await applyDefault($, await read($, defaultTtl));
1133    }
1134    return next(e);
1135  });
1136  on("classic.PostModelSwitch", async ($, e, next) => {
1137    await forget($, "model switched");
1138    return next(e);
1139  });
1140  on(
1141    "ui.render",
1142    { component: "AbovePrompt" },
1143    async ($, e, next) => (await renderBand($, e)) ?? next(e),
1144  );
1145  on("ui.render", { component: "Pane", requestId: PANE }, renderPane);
1146  on("ui.focus", { component: "Pane", requestId: PANE }, async ($, e, next) => {
1147    const answer = await next(e);
1148    if (answer.deny === undefined) focused = e.element;
1149    return answer;
1150  });
1151  on("ui.scroll", { component: "Pane", requestId: PANE }, arrowToFocus);
1152  on("ui.close", { id: PANE }, async ($, e, next) => {
1153    const answer = await next(e);
1154    await syncAnimation($);
1155    return answer;
1156  });
1157};
1158
hooks/mascot.ts 454 lines
1// Clawd, the Claude Code mascot, holding a steaming mug: the pane's animation.
2// Half blocks draw the sprite and braille dots draw the steam, packed as
3// RasterProps cells. A frame depends on time and mood alone, so dropped frames
4// never put it out of step.
5
6import type { Mood } from "../types";
7
8export const MASCOT_COLUMNS = 28;
9export const MASCOT_ROWS = 7;
10
11const WIDTH = MASCOT_COLUMNS;
12const HEIGHT = MASCOT_ROWS * 2;
13const NONE = -1;
14export const TERMINAL_DEFAULT = 0x01000000;
15const SPACE = 0x20;
16const UPPER_HALF = 0x2580;
17const LOWER_HALF = 0x2584;
18const BRAILLE = 0x2800;
19// Braille dot bits, row by row, left dot first.
20const DOTS = [0x01, 0x08, 0x02, 0x10, 0x04, 0x20, 0x40, 0x80];
21
22// Clawd's top-left while standing, in half-block pixels: one column wide, half a row tall.
23const X0 = 0;
24const Y0 = 4;
25
26const BODY = 0xd77757;
27const BODY_LIT = 0xe8916f;
28const BODY_SHADE = 0xb75f41;
29const BODY_DEEP = 0x9c4f35;
30const EYE = 0x1d1512;
31const CERAMIC_LIT = 0xfdfaf5;
32const COFFEE = 0x452818;
33const CREMA = 0x96643f;
34
35type Eyes = "open" | "shut" | "happy";
36
37type Pose = {
38  lift: number;
39  dip: number;
40  eyes: Eyes;
41  look: number;
42  wave: number;
43  sip: number;
44};
45
46type Cell = [glyph: number, foreground: number, background: number];
47type Rect = [x: number, y: number, w: number, h: number];
48type Tones = [
49  edge: number,
50  lit: number,
51  base: number,
52  shade: number,
53  deep: number,
54];
55
56export type Theme = "dark" | "light";
57
58// What sits against the terminal's background. On a light one the mug gets a
59// dark outline, and steam darkens where a dark one has it brighten.
60type Palette = {
61  ceramic: Tones;
62  rim: number;
63  handle: number;
64  steam: [faint: number, dense: number];
65  spark: [bright: number, dim: number];
66  snore: [bright: number, dim: number];
67};
68const DARK: Palette = {
69  ceramic: [0xeee8de, CERAMIC_LIT, 0xeee8de, 0xd3cabd, 0xb3a99b],
70  rim: 0xd6cdbf,
71  handle: 0xd9d1c4,
72  steam: [0x5d6570, 0xf6efe6],
73  spark: [0xffd88a, 0xa77c34],
74  snore: [0xa9bccd, 0x4f5965],
75};
76const LIGHT: Palette = {
77  ceramic: [0x9a9084, CERAMIC_LIT, 0xf1ebe1, 0xcfc5b7, 0x8a8074],
78  rim: 0x9a9084,
79  handle: 0x9a9084,
80  steam: [0xd2d6dc, 0x6a7380],
81  spark: [0xc0820f, 0xe9d3a0],
82  snore: [0x557089, 0xc5ced8],
83};
84// A steam particle in half-block pixels, and how bright it is from 0 to 1.
85type Speck = { x: number; y: number; glow: number };
86// The coffee's top-left and the steam's strength: 1 while hot, 0 once cold.
87type Source = { x: number; y: number; heat: number };
88
89// The mug's width; its handle hangs off the left, where Clawd grips it.
90const MUG_WIDTH = 8;
91
92// Particles from one point on the coffee follow one swaying strand.
93const EMITTERS = [
94  { offset: 1 + 0.2 * (MUG_WIDTH - 2), phase: 0 },
95  { offset: 1 + 0.5 * (MUG_WIDTH - 2), phase: 2.1 },
96  { offset: 1 + 0.8 * (MUG_WIDTH - 2), phase: 4.2 },
97];
98// Seconds between one emitter's particles, and the longest a particle lives.
99const SPAWN = 0.11;
100const LIFE = 2.3;
101const HEART_POINTS = 28;
102
103const SPARKS = [
104  { column: 1, row: 1, delay: 0.1 },
105  { column: 7, row: 0, delay: 0.5 },
106  { column: 13, row: 1, delay: 0.9 },
107  { column: 3, row: 0, delay: 1.5 },
108  { column: 11, row: 0, delay: 1.9 },
109];
110const SPARK_GLYPHS = [0xb7, 0x2b, 0x2a, 0x2b, 0xb7];
111
112const hash = (n: number): number => {
113  let x = Math.imul(n ^ 0x9e3779b9, 0x85ebca6b);
114  x ^= x >>> 13;
115  x = Math.imul(x, 0xc2b2ae35);
116  x ^= x >>> 16;
117  return (x >>> 0) / 4294967296;
118};
119
120const smoothstep = (from: number, to: number, x: number): number => {
121  const f = Math.min(1, Math.max(0, (x - from) / (to - from)));
122  return f * f * (3 - 2 * f);
123};
124
125const mix = (from: number, to: number, f: number): number => {
126  const channel = (shift: number) => {
127    const a = (from >> shift) & 0xff;
128    const b = (to >> shift) & 0xff;
129    return Math.round(a + (b - a) * f) << shift;
130  };
131  return channel(16) | channel(8) | channel(0);
132};
133
134const isBlinking = (t: number): boolean => {
135  const slot = Math.floor(t / 3.7);
136  const at = slot * 3.7 + 0.5 + hash(slot) * 2.6;
137  return t >= at && t < at + 0.14;
138};
139
140const idleOf = (t: number): Pose => ({
141  lift: 0,
142  dip: t % 2.8 > 1.6 ? 1 : 0,
143  eyes: isBlinking(t) ? "shut" : "open",
144  look: 0,
145  wave: 0,
146  sip: 0,
147});
148
149// Every 5.6 s: glance at the mug, raise it, sip with eyes shut, lower it.
150const sippingOf = (t: number): Pose => {
151  const pose = idleOf(t);
152  const p = (t + 5.6 - 1.4) % 5.6;
153  if (p >= 1.9) return pose;
154  const raise =
155    p < 0.65 ? smoothstep(0.35, 0.65, p) : 1 - smoothstep(1.45, 1.75, p);
156  return {
157    ...pose,
158    dip: 0,
159    look: p < 0.5 ? 1 : 0,
160    eyes: p >= 0.7 && p < 1.45 ? "shut" : pose.eyes,
161    sip: Math.round(raise * 3),
162  };
163};
164
165// One hop in 0.7 s: crouch, rise two pixels, land in a crouch.
166const hopOf = (x: number): Pick<Pose, "lift" | "dip"> => {
167  if (x < 0.1) return { lift: 0, dip: 1 };
168  if (x < 0.17) return { lift: 1, dip: 0 };
169  if (x < 0.36) return { lift: 2, dip: 0 };
170  if (x < 0.43) return { lift: 1, dip: 0 };
171  if (x < 0.55) return { lift: 0, dip: 1 };
172  return { lift: 0, dip: 0 };
173};
174
175const poseOf = (t: number, mood: Mood, m: number): Pose => {
176  if (mood === "warming") return sippingOf(t);
177  const pose = idleOf(t);
178  if (mood === "cold")
179    return {
180      ...pose,
181      dip: m > 0.5 ? 1 : 0,
182      eyes: m > 0.7 ? "shut" : pose.eyes,
183    };
184  if (m < 1.4) return { ...pose, ...hopOf(m % 0.7), eyes: "happy", wave: 2 };
185  return { ...pose, eyes: m < 2.6 ? "happy" : pose.eyes };
186};
187
188// The heart takes the place of most of the steam while it floats up.
189const heatOf = (mood: Mood, m: number): number => {
190  if (mood === "warmed") return 0.15 + 0.85 * smoothstep(2.2, 3.2, m);
191  if (mood === "cold") return Math.max(0, 1 - m / 1.8);
192  return 1;
193};
194
195const fill = (pixels: Int32Array, [x, y, w, h]: Rect, color: number) => {
196  for (let row = Math.max(0, y); row < Math.min(HEIGHT, y + h); row++)
197    for (let column = Math.max(0, x); column < Math.min(WIDTH, x + w); column++)
198      pixels[row * WIDTH + column] = color;
199};
200
201const eyeOf = (eyes: Eyes, x: number, top: number, inward: number): Rect[] => {
202  if (eyes === "open") return [[x, top + 2, 1, 2]];
203  if (eyes === "shut") return [[Math.min(x, x + inward), top + 3, 2, 1]];
204  return [
205    [x - 1, top + 3, 1, 1],
206    [x, top + 2, 1, 1],
207    [x + 1, top + 3, 1, 1],
208  ];
209};
210
211const drawClawd = (pixels: Int32Array, pose: Pose) => {
212  const top = Y0 - pose.lift + pose.dip;
213  for (const x of [3, 5, 10, 12])
214    fill(pixels, [X0 + x, top + 8, 1, 2 - pose.dip], BODY_SHADE);
215  fill(pixels, [X0 + 2, top, 12, 8], BODY);
216  fill(pixels, [X0 + 2, top, 12, 1], BODY_LIT);
217  fill(pixels, [X0 + 2, top, 1, 7], BODY_LIT);
218  fill(pixels, [X0 + 3, top + 7, 11, 1], BODY_SHADE);
219  fill(pixels, [X0, top + 4 - pose.wave, 2, 2], BODY);
220  for (const [x, inward] of [
221    [4, 1],
222    [11, -1],
223  ] as const)
224    for (const rect of eyeOf(pose.eyes, X0 + x + pose.look, top, inward))
225      fill(pixels, rect, EYE);
226};
227
228// The mug's top-left: the back of its rim, one row above the coffee.
229const mugOf = (pose: Pose) => ({
230  x: X0 + 17,
231  y: Y0 - pose.lift + pose.dip + 1 - pose.sip,
232});
233
234// Columns shade from a highlight at the left to a shadow at the right, so the
235// mug reads as a cylinder; the near lip below the coffee catches the light.
236const toneOf = (i: number, [edge, lit, base, shade, deep]: Tones): number => {
237  if (i === 0) return edge;
238  if (i === 1) return lit;
239  if (i === MUG_WIDTH - 2) return shade;
240  if (i === MUG_WIDTH - 1) return deep;
241  return base;
242};
243const STRIPE_TONES: Tones = [BODY, BODY_LIT, BODY, BODY_SHADE, BODY_DEEP];
244
245// Drawn after the mug, Clawd's right hand covers the outer side of the handle.
246const drawMug = (
247  pixels: Int32Array,
248  pose: Pose,
249  t: number,
250  { ceramic, rim, handle }: Palette,
251) => {
252  const { x, y } = mugOf(pose);
253  fill(pixels, [x + 1, y, MUG_WIDTH - 2, 1], rim);
254  for (let i = 0; i < MUG_WIDTH; i++) {
255    fill(pixels, [x + i, y + 1, 1, 5], toneOf(i, ceramic));
256    fill(pixels, [x + i, y + 4, 1, 1], toneOf(i, STRIPE_TONES));
257  }
258  fill(pixels, [x + 1, y + 1, MUG_WIDTH - 2, 1], COFFEE);
259  const glint = Math.floor(t * 0.8) % (MUG_WIDTH - 2);
260  fill(pixels, [x + 1 + glint, y + 1, 1, 1], CREMA);
261  fill(pixels, [x + 1, y + 2, MUG_WIDTH - 3, 1], CERAMIC_LIT);
262  fill(pixels, [x + 1, y + 6, MUG_WIDTH - 2, 1], ceramic[4]);
263  fill(pixels, [x - 2, y + 2, 2, 1], handle);
264  fill(pixels, [x - 2, y + 3, 1, 2], handle);
265  fill(pixels, [x - 2, y + 5, 2, 1], handle);
266  const top = Y0 - pose.lift + pose.dip;
267  fill(pixels, [X0 + 14, top + 4 - pose.sip, 2, 2], BODY);
268};
269
270// Particles leave the coffee at random speeds and ride one sway field, so
271// those at one height bend together into strands that widen and fade as they rise.
272const steamOf = (t: number, source: Source): Speck[] =>
273  EMITTERS.flatMap(({ offset, phase }, e) => {
274    const specks: Speck[] = [];
275    for (let n = Math.floor((t - LIFE) / SPAWN); n <= t / SPAWN; n++) {
276      const seed = (n * EMITTERS.length + e) * 8;
277      const life = 1.1 + hash(seed + 1) * (LIFE - 1.1);
278      const age = t - (n + 0.7 * hash(seed + 2)) * SPAWN;
279      if (hash(seed) >= source.heat || age <= 0 || age >= life) continue;
280      const h = (2.6 + 1.6 * hash(seed + 3)) * age * (1 - 0.12 * age);
281      const sway =
282        (0.2 + 0.17 * h) * Math.sin(0.85 * h - 2.9 * t + phase) +
283        0.05 * h * Math.sin(0.37 * h - 1.1 * t + 2.3 * phase);
284      const spread = (hash(seed + 4) - 0.5) * (0.2 + 0.3 * h);
285      specks.push({
286        x: source.x + offset + sway + spread + 0.14 * h,
287        y: source.y - h,
288        glow: 0.85 * smoothstep(0, 0.25, age) * (1 - age / life),
289      });
290    }
291    return specks;
292  });
293
294// A steam heart that floats up from the standing mug after a warm refresh,
295// then comes apart.
296const heartOf = (m: number): Speck[] => {
297  const age = m - 0.3;
298  if (age <= 0 || age >= 2.4) return [];
299  const scatter = smoothstep(1.4, 2.4, age);
300  const glow = 0.9 * smoothstep(0, 0.4, age) * (1 - scatter);
301  const cx = X0 + 17 + MUG_WIDTH / 2 + 0.35 * Math.sin(2.2 * age);
302  const cy = Y0 + 0.4 - 1.25 * age;
303  return Array.from({ length: HEART_POINTS }, (_, k) => {
304    const a = (k / HEART_POINTS) * 2 * Math.PI;
305    const hx = 16 * Math.sin(a) ** 3;
306    const hy =
307      13 * Math.cos(a) -
308      5 * Math.cos(2 * a) -
309      2 * Math.cos(3 * a) -
310      Math.cos(4 * a);
311    return {
312      x: cx + 0.14 * hx + scatter * 2.5 * (hash(k * 2) - 0.5),
313      y: cy - 0.125 * hy - scatter * 2 * hash(k * 2 + 1),
314      glow,
315    };
316  });
317};
318
319// Braille cells holding steam: dot bits and the brightest particle in each.
320const dotsOf = (
321  specks: Speck[],
322): Map<number, { bits: number; glow: number }> => {
323  const cells = new Map<number, { bits: number; glow: number }>();
324  for (const { x, y, glow } of specks) {
325    const dx = Math.floor(x * 2);
326    const dy = Math.floor(y * 2);
327    const column = Math.floor(dx / 2);
328    const row = Math.floor(dy / 4);
329    if (
330      glow < 0.06 ||
331      column < 0 ||
332      column >= WIDTH ||
333      row < 0 ||
334      row >= MASCOT_ROWS
335    )
336      continue;
337    const index = row * WIDTH + column;
338    const cell = cells.get(index) ?? { bits: 0, glow: 0 };
339    cell.bits |= DOTS[(dy - row * 4) * 2 + (dx - column * 2)] ?? 0;
340    cell.glow = Math.max(cell.glow, glow);
341    cells.set(index, cell);
342  }
343  return cells;
344};
345
346const cellOf = (
347  pixels: Int32Array,
348  column: number,
349  row: number,
350  steam: { bits: number; glow: number } | undefined,
351  [faint, dense]: Palette["steam"],
352  background: number,
353): Cell => {
354  const top = pixels[row * 2 * WIDTH + column] ?? NONE;
355  const bottom = pixels[(row * 2 + 1) * WIDTH + column] ?? NONE;
356  if (top !== NONE)
357    return [UPPER_HALF, top, bottom === NONE ? background : bottom];
358  if (bottom !== NONE) return [LOWER_HALF, bottom, background];
359  if (!steam) return [SPACE, TERMINAL_DEFAULT, background];
360  const tone = mix(faint, dense, Math.sqrt(steam.glow));
361  return [BRAILLE + steam.bits, tone, background];
362};
363
364// Glyphs drawn over empty cells: sparkles after a warm refresh, and a rising
365// "z" once the cache has gone cold.
366const glyphsOf = (
367  mood: Mood,
368  m: number,
369  { spark, snore }: Palette,
370  background: number,
371): [index: number, cell: Cell][] => {
372  if (mood === "warmed")
373    return SPARKS.flatMap(({ column, row, delay }) => {
374      const life = (m - delay) / 0.8;
375      if (life < 0 || life >= 1) return [];
376      const glyph =
377        SPARK_GLYPHS[Math.floor(life * SPARK_GLYPHS.length)] ?? SPACE;
378      const tone = mix(...spark, Math.abs(life - 0.5) * 2);
379      return [[row * WIDTH + column, [glyph, tone, background]]];
380    });
381  if (mood !== "cold" || m < 1) return [];
382  return [0, 0.9].map((delay) => {
383    const life = ((m - 1 + delay) % 1.8) / 1.8;
384    const index = (1 - Math.round(life)) * WIDTH + 14 + Math.round(life * 2);
385    const glyph = life < 0.5 ? 0x7a : 0x5a;
386    return [index, [glyph, mix(...snore, life), background]];
387  });
388};
389
390const BASE64 =
391  "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
392
393// Cells are twelve bytes each, so the length is a multiple of three and needs no padding.
394const base64Of = (bytes: Uint8Array): string => {
395  const out: string[] = [];
396  for (let i = 0; i < bytes.length; i += 3) {
397    const n =
398      ((bytes[i] ?? 0) << 16) |
399      ((bytes[i + 1] ?? 0) << 8) |
400      (bytes[i + 2] ?? 0);
401    out.push(
402      BASE64.charAt((n >> 18) & 63),
403      BASE64.charAt((n >> 12) & 63),
404      BASE64.charAt((n >> 6) & 63),
405      BASE64.charAt(n & 63),
406    );
407  }
408  return out.join("");
409};
410
411// `t` counts seconds since the scene started and `m` seconds since the mood
412// began; `background` fills the cells around Clawd.
413export const cellsOf = (
414  t: number,
415  mood: Mood,
416  m: number,
417  theme: Theme,
418  background: number,
419): string => {
420  const palette = theme === "light" ? LIGHT : DARK;
421  const pose = poseOf(t, mood, m);
422  const pixels = new Int32Array(WIDTH * HEIGHT).fill(NONE);
423  drawClawd(pixels, pose);
424  drawMug(pixels, pose, t, palette);
425  const mug = mugOf(pose);
426  const source = { x: mug.x, y: mug.y + 1, heat: heatOf(mood, m) };
427  const steam = dotsOf([
428    ...steamOf(t, source),
429    ...(mood === "warmed" ? heartOf(m) : []),
430  ]);
431  const words = new Uint32Array(WIDTH * MASCOT_ROWS * 3);
432  for (let row = 0; row < MASCOT_ROWS; row++)
433    for (let column = 0; column < WIDTH; column++) {
434      const index = row * WIDTH + column;
435      words.set(
436        cellOf(
437          pixels,
438          column,
439          row,
440          steam.get(index),
441          palette.steam,
442          background,
443        ),
444        index * 3,
445      );
446    }
447  for (const [index, cell] of glyphsOf(mood, m, palette, background)) {
448    const current = words[index * 3] ?? SPACE;
449    if (current !== UPPER_HALF && current !== LOWER_HALF)
450      words.set(cell, index * 3);
451  }
452  return base64Of(new Uint8Array(words.buffer));
453};
454
hooks/pages.tsx 198 lines
1import type { ElementTable } from "claude-code";
2
3import type { Totals } from "../types";
4import type { Mascot, PaneActions, PaneData } from "./pane";
5import { headerOf, mainPageOf, statusTextOf, tableOf } from "./pane";
6import {
7  MIN_SAVINGS_USD,
8  PRICES_AS_OF,
9  delayOf,
10  formatDuration,
11  formatUsd,
12  idleSpanOf,
13} from "./warmer";
14
15const TTLS = ["5m", "1h"] as const;
16const BAND_STYLES = ["default", "simplified", "off"] as const;
17
18// One button for a choice among a few options: it marks the current option and a press picks the next.
19const toggleOf = <T extends string>(
20  { Button }: ElementTable,
21  key: string,
22  options: readonly T[],
23  current: T,
24  choose: (value: T) => void,
25) => (
26  <Button
27    key={`toggle:${key}`}
28    label={options
29      .map((value) => `${value === current ? "●" : "○"} ${value}`)
30      .join("  ")}
31    onPress={() =>
32      choose(
33        options[(options.indexOf(current) + 1) % options.length] ?? current,
34      )
35    }
36  />
37);
38
39const LABELS = {
40  ttl: "Lifetime",
41  default: "Default lifetime",
42  band: "Refresh band",
43  "5m": "Idle refreshes, 5m",
44  "1h": "Idle refreshes, 1h",
45};
46const LABEL_WIDTH = Math.max(
47  ...Object.values(LABELS).map((label) => label.length),
48);
49
50// This session's settings, then the defaults new sessions start with.
51const configPageOf = (
52  elements: ElementTable,
53  data: PaneData,
54  actions: PaneActions,
55) => {
56  const { Box, Text, Button } = elements;
57  const { chosen, saved, limits, bandStyle, isForced, isLocked, state, at } =
58    data;
59  const effective = isForced ? "5m" : chosen;
60  const status = statusTextOf(state, at);
61  const label = (text: string) => (
62    <Text>{`${text.padEnd(LABEL_WIDTH)}  `}</Text>
63  );
64  return (
65    <Box flexDirection="column">
66      {headerOf(elements, "Configuration", actions.open)}
67      <Text key="session" bold>
68        This session
69      </Text>
70      <Box key="ttl" flexDirection="row">
71        {label(LABELS.ttl)}
72        {isLocked ? (
73          <Text bold>{chosen}</Text>
74        ) : (
75          toggleOf(elements, "ttl", TTLS, chosen, actions.chooseSession)
76        )}
77        {isLocked && (
78          <Text dimColor>{"  locked: /clear or a new session unlocks it"}</Text>
79        )}
80      </Box>
81      <Text key="interval" dimColor>
82        {`Refreshes every ${formatDuration(delayOf(effective))} · idle limit ${limits[effective]} refreshes`}
83      </Text>
84      {status && (
85        <Text key="status" dimColor>
86          {status}
87        </Text>
88      )}
89      {isForced && (
90        <Text key="forced" color="yellow">
91          {`FORCE_PROMPT_CACHING_5M overrides ${chosen}; the cache lives 5m.`}
92        </Text>
93      )}
94      <Box key="defaults" marginTop={1}>
95        <Text bold>Global</Text>
96      </Box>
97      <Box key="default" flexDirection="row">
98        {label(LABELS.default)}
99        {toggleOf(elements, "default", TTLS, saved, actions.chooseDefault)}
100      </Box>
101      <Box key="band" flexDirection="row">
102        {label(LABELS.band)}
103        {toggleOf(elements, "band", BAND_STYLES, bandStyle, actions.setBand)}
104      </Box>
105      {TTLS.map((ttl) => (
106        <Box key={`idle:${ttl}`} flexDirection="row">
107          {label(LABELS[ttl])}
108          <Button
109            key={`idle:${ttl}:-`}
110            label="−"
111            onPress={() => actions.setLimit(ttl, limits[ttl] - 1)}
112          />
113          <Text>{String(limits[ttl]).padStart(3).padEnd(4)}</Text>
114          <Button
115            key={`idle:${ttl}:+`}
116            label="+"
117            onPress={() => actions.setLimit(ttl, limits[ttl] + 1)}
118          />
119          <Text dimColor>
120            {`  covers ${formatDuration(idleSpanOf(limits[ttl], ttl))} idle`}
121          </Text>
122        </Box>
123      ))}
124      <Text key="note" dimColor>
125        {`Enter switches a setting. Each refresh still needs Pi's ${formatUsd(MIN_SAVINGS_USD)} expected saving.`}
126      </Text>
127    </Box>
128  );
129};
130
131const ANALYTICS_ROWS: [label: string, valueOf: (totals: Totals) => string][] = [
132  ["Refreshes", (totals) => String(totals.refreshes)],
133  ["Warming fee", (totals) => formatUsd(totals.costUsd)],
134  ["  no prompt followed", (totals) => formatUsd(totals.wastedUsd)],
135  ["Prompts kept", (totals) => String(totals.kept)],
136  ["Rewrites avoided", (totals) => formatUsd(totals.keptUsd)],
137  ["Net saved", (totals) => formatUsd(totals.keptUsd - totals.costUsd)],
138];
139
140const BASIS_LINES = [
141  "A prompt is kept when it reads the cache after the point where, without refreshes, it would have expired.",
142  `Avoided = cached tokens read × (write − read price). API list prices (${PRICES_AS_OF}).`,
143];
144
145const analyticsPageOf = (
146  elements: ElementTable,
147  { totals, allTime }: PaneData,
148  actions: PaneActions,
149) => {
150  const { Box, Text } = elements;
151  const lines = tableOf(
152    [
153      ["", "This session", "All time"],
154      ...ANALYTICS_ROWS.map(([label, valueOf]) => [
155        label,
156        valueOf(totals),
157        valueOf(allTime),
158      ]),
159    ],
160    [true, false, false],
161  );
162  return (
163    <Box flexDirection="column">
164      {headerOf(elements, "Analytics", actions.open)}
165      {lines.map((line, index) => (
166        <Text
167          key={`row:${index}`}
168          wrap="truncate-end"
169          dimColor={index === 0}
170          bold={index === lines.length - 1}
171        >
172          {line}
173        </Text>
174      ))}
175      <Text key="since" dimColor>
176        {`All time since ${new Date(allTime.since).toISOString().slice(0, 10)}`}
177      </Text>
178      {BASIS_LINES.map((line, index) => (
179        <Text key={`basis:${index}`} dimColor>
180          {line}
181        </Text>
182      ))}
183    </Box>
184  );
185};
186
187export const paneOf = (
188  elements: ElementTable,
189  data: PaneData,
190  actions: PaneActions,
191  mascot?: Mascot,
192) => {
193  if (data.page === "config") return configPageOf(elements, data, actions);
194  if (data.page === "analytics")
195    return analyticsPageOf(elements, data, actions);
196  return mainPageOf(elements, data, actions, mascot);
197};
198
hooks/pane.tsx 352 lines
1import type { ElementTable, RenderElement, RenderNode } from "claude-code";
2
3import type {
4  AllTime,
5  BandStyle,
6  IdleLimits,
7  Mood,
8  Notice,
9  Page,
10  Refresh,
11  Status,
12  Totals,
13  Ttl,
14} from "../types";
15import { MASCOT_COLUMNS, MASCOT_ROWS } from "./mascot";
16import { delayOf, formatDuration, formatTokens, formatUsd } from "./warmer";
17
18export const MASCOT_KEY = "mascot";
19// Plain text, which terminals link themselves: a Link where OSC 8 is unsupported draws its label and then the URL.
20const REPOSITORY = "github.com/paulbkim-dev/claude-code-cache-warmer";
21// Clawd stands left of the main page's column when this many columns stay beside him, and above it in a narrower pane.
22const SIDE_COLUMNS = 30;
23const SIDE_GAP = 2;
24// The main page's column above its status line besides the repository line: two header rows, a blank row and three menu items.
25const MENU_ROWS = 6;
26const COLUMN_GAP = "  ";
27
28export type PaneData = {
29  page: Page;
30  // This session's lifetime, and the saved default new sessions start with.
31  chosen: Ttl;
32  saved: Ttl;
33  limits: IdleLimits;
34  isForced: boolean;
35  isLocked: boolean;
36  isDebug: boolean;
37  bandStyle: BandStyle;
38  state: Status;
39  totals: Totals;
40  allTime: AllTime;
41  list: Refresh[];
42  logPath: string;
43  at: number;
44  width: number;
45  rows: number;
46};
47
48export type PaneActions = {
49  open: (page: Page) => void;
50  toggleDebug: () => void;
51  setBand: (value: BandStyle) => void;
52  chooseDefault: (value: Ttl) => void;
53  chooseSession: (value: Ttl) => void;
54  setLimit: (ttl: Ttl, count: number) => void;
55};
56
57export const statusTextOf = (state: Status, at: number): string | undefined => {
58  if (state.state === "waiting") return undefined;
59  if (state.state === "refreshing") return "Refreshing now.";
60  if (state.state === "stopped")
61    return `Stopped: ${state.reason}. The next prompt restarts warming.`;
62  const phase = state.phase === "run" ? "turn running" : "idle";
63  return `Next refresh in ${formatDuration(state.nextAt - at)} · ${phase} · expected saving ${formatUsd(state.expectedUsd)}`;
64};
65
66// Rows a text takes wrapped at spaces, a word longer than the width broken across rows.
67const rowsOf = (text: string, width: number) => {
68  let rows = 1;
69  let used = 0;
70  for (const word of text.split(" ")) {
71    const gap = used === 0 ? 0 : 1;
72    if (used + gap + word.length <= width) {
73      used += gap + word.length;
74      continue;
75    }
76    rows += Math.ceil(word.length / width) - (used === 0 ? 1 : 0);
77    used = word.length % width || width;
78  }
79  return rows;
80};
81
82// Lays rows of cells out in columns, each as wide as its widest cell; `isLeft` columns align left.
83export const tableOf = (rows: string[][], isLeft: boolean[]) => {
84  const widths = (rows[0] ?? []).map((_, index) =>
85    Math.max(...rows.map((row) => (row[index] ?? "").length)),
86  );
87  return rows.map((row) =>
88    row
89      .map((cell, index) =>
90        isLeft[index]
91          ? cell.padEnd(widths[index] ?? 0)
92          : cell.padStart(widths[index] ?? 0),
93      )
94      .join(COLUMN_GAP)
95      .trimEnd(),
96  );
97};
98
99// The keys of the Buttons a tree draws, in order, one list per row: a row Box
100// holds its Buttons together, and a Button anywhere else is a row by itself.
101// `initial` is the first autoFocus Button, which the ring starts on.
102export const focusRowsOf = (tree: RenderElement) => {
103  const rows: string[][] = [];
104  let initial: string | undefined;
105  // Text's string children hold no Buttons.
106  const isElement = (node: RenderNode): node is RenderElement =>
107    node instanceof Object;
108  const visit = (node: RenderElement, row: string[] | undefined) => {
109    if (node.type === "Button") {
110      if (node.props.autoFocus) initial ??= node.props.key;
111      if (row) row.push(node.props.key);
112      else rows.push([node.props.key]);
113      return;
114    }
115    if (node.type !== "Box") return;
116    const inner = node.props?.flexDirection === "row" ? [] : undefined;
117    if (inner) rows.push(inner);
118    for (const child of node.children ?? [])
119      if (isElement(child)) visit(child, inner);
120  };
121  visit(tree, undefined);
122  return { rows: rows.filter((row) => row.length > 0), initial };
123};
124
125// The pane and the band draw Clawd under one key; blits repaint him there.
126export const rasterOf = (
127  { Raster }: ElementTable<"terminal">,
128  cells: string,
129) => (
130  <Raster
131    key={MASCOT_KEY}
132    columns={MASCOT_COLUMNS}
133    rows={MASCOT_ROWS}
134    cells={cells}
135  />
136);
137
138const TTL_COLORS = { "5m": "cyan", "1h": "magenta" } satisfies Record<
139  Ttl,
140  string
141>;
142const MOOD_COLORS = {
143  warming: "yellow",
144  warmed: "green",
145  cold: "red",
146} satisfies Record<Mood, string>;
147
148const noticeTextOf = (
149  { Text }: ElementTable,
150  { ttl, head, detail, mood }: Notice,
151  wrap: "truncate-end" | "wrap",
152) => (
153  <Text wrap={wrap}>
154    <Text dimColor>{"☕ cache warmer "}</Text>
155    <Text color={TTL_COLORS[ttl]}>{ttl}</Text>
156    <Text dimColor>{` every ${formatDuration(delayOf(ttl))} · `}</Text>
157    <Text color={MOOD_COLORS[mood]}>{head}</Text>
158    {detail && <Text dimColor>{` · ${detail}`}</Text>}
159  </Text>
160);
161
162// One dim line with the lifetime and the outcome in color.
163export const bandOf = (elements: ElementTable, shown: Notice) =>
164  noticeTextOf(elements, shown, "truncate-end");
165
166// Clawd's raster with the notice wrapped beside him.
167export const mascotBandOf = (
168  elements: ElementTable<"terminal">,
169  shown: Notice,
170  raster: RenderElement,
171) => {
172  const { Box } = elements;
173  return (
174    <Box flexDirection="row" columnGap={2}>
175      {raster}
176      <Box flexDirection="column" justifyContent="center" flexShrink={1}>
177        {noticeTextOf(elements, shown, "wrap")}
178      </Box>
179    </Box>
180  );
181};
182
183// Every sub-page opens with the Back button, which holds the focus first, and its title.
184export const headerOf = (
185  { Box, Text, Button }: ElementTable,
186  title: string,
187  open: (page: Page) => void,
188) => (
189  <Box key="header" flexDirection="row" columnGap={2}>
190    <Button
191      key="back"
192      label="‹ Back"
193      plain
194      autoFocus
195      onPress={() => open("main")}
196    />
197    <Text bold>{title}</Text>
198  </Box>
199);
200
201const DEBUG_HEADER = ["ago", "result", "read", "write", "out", "cost", "saves"];
202const DEBUG_LEFT = DEBUG_HEADER.map((name) => name === "result");
203
204const debugCellsOf = (one: Refresh, at: number) => [
205  formatDuration(at - one.at),
206  one.detail ? `${one.result} (${one.detail})` : one.result,
207  one.usage ? formatTokens(one.usage.cacheRead) : "-",
208  one.usage ? formatTokens(one.usage.cacheWrite) : "-",
209  one.usage ? String(one.usage.output) : "-",
210  one.costUsd === null ? "?" : formatUsd(one.costUsd),
211  one.savesUsd === null ? "" : formatUsd(one.savesUsd),
212];
213
214const logTextOf = (path: string) => `Log: ${path}`;
215
216// Rows the main page's column takes at `width`, all but the debug table's refreshes, which take the rows left.
217const columnRowsOf = (
218  { state, at, isDebug, logPath }: PaneData,
219  width: number,
220) => {
221  const status = statusTextOf(state, at);
222  return (
223    MENU_ROWS +
224    rowsOf(REPOSITORY, width) +
225    (status ? rowsOf(status, width) : 0) +
226    (isDebug ? rowsOf(logTextOf(logPath), width) + 1 : 0)
227  );
228};
229
230// The log path, the column names and the newest refreshes that fit in `room` rows.
231const debugTableOf = (
232  { Text }: ElementTable,
233  { list, logPath, at }: PaneData,
234  room: number,
235) => {
236  const shown = room > 0 ? list.slice(-room) : [];
237  const [columns = "", ...lines] = tableOf(
238    [DEBUG_HEADER, ...shown.map((one) => debugCellsOf(one, at))],
239    DEBUG_LEFT,
240  );
241  return [
242    <Text key="log" dimColor wrap="wrap">
243      {logTextOf(logPath)}
244    </Text>,
245    <Text key="columns" dimColor wrap="truncate-end">
246      {list.length === 0 ? "No refreshes yet." : columns}
247    </Text>,
248    ...lines.map((line, index) => (
249      <Text
250        key={`refresh:${shown[index]?.at ?? index}`}
251        wrap="truncate-end"
252        dimColor={shown[index]?.result !== "warmed"}
253      >
254        {line}
255      </Text>
256    )),
257  ];
258};
259
260type Layout = "beside" | "above";
261
262// Clawd's raster and where layoutOf placed him.
263export type Mascot = { raster: RenderElement; layout: Layout };
264
265// Where Clawd fits on the main page: beside the column, above it with a blank row between, or nowhere.
266// Beside him the column scrolls, so a turn's spinner shrinking an inline pane keeps him drawn.
267export const layoutOf = (data: PaneData): Layout | undefined => {
268  if (
269    data.width - MASCOT_COLUMNS - SIDE_GAP >= SIDE_COLUMNS &&
270    data.rows >= MASCOT_ROWS
271  )
272    return "beside";
273  if (
274    data.width >= MASCOT_COLUMNS &&
275    data.rows >= MASCOT_ROWS + 1 + columnRowsOf(data, data.width)
276  )
277    return "above";
278  return undefined;
279};
280
281export const mainPageOf = (
282  elements: ElementTable,
283  data: PaneData,
284  actions: PaneActions,
285  mascot?: Mascot,
286) => {
287  const { Box, Text, Button } = elements;
288  const layout = mascot?.layout;
289  const width =
290    layout === "beside" ? data.width - MASCOT_COLUMNS - SIDE_GAP : data.width;
291  const status = statusTextOf(data.state, data.at);
292  const item = (key: string, label: string, onPress: () => void) => (
293    <Button
294      key={`menu:${key}`}
295      label={`› ${label}`}
296      plain
297      autoFocus={key === "config" ? true : undefined}
298      onPress={onPress}
299    />
300  );
301  const column = (
302    <Box key="menu" flexDirection="column" width={width}>
303      <Box
304        key="header"
305        flexDirection="column"
306        alignItems="center"
307        marginBottom={1}
308      >
309        <Text bold>Cache Warmer</Text>
310        <Text dimColor>paulbkim.dev</Text>
311        <Text dimColor wrap="wrap">
312          {REPOSITORY}
313        </Text>
314      </Box>
315      {item("config", "Configuration", () => actions.open("config"))}
316      {item("analytics", "Analytics", () => actions.open("analytics"))}
317      {item(
318        "debug",
319        `Debug mode · ${data.isDebug ? "on" : "off"}`,
320        actions.toggleDebug,
321      )}
322      {status && (
323        <Text key="status" dimColor wrap="wrap">
324          {status}
325        </Text>
326      )}
327      {data.isDebug &&
328        debugTableOf(
329          elements,
330          data,
331          data.rows -
332            (layout === "above" ? MASCOT_ROWS + 1 : 0) -
333            columnRowsOf(data, width),
334        )}
335    </Box>
336  );
337  if (!mascot) return column;
338  if (mascot.layout === "beside")
339    return (
340      <Box flexDirection="row" columnGap={SIDE_GAP}>
341        {mascot.raster}
342        {column}
343      </Box>
344    );
345  return (
346    <Box flexDirection="column" alignItems="center" rowGap={1}>
347      {mascot.raster}
348      {column}
349    </Box>
350  );
351};
352
hooks/warmer.ts 262 lines
1import type { ConfigValue, ModelForkResult, ModelUsage } from "claude-code";
2
3import type { Notice, Refresh, Totals, Ttl, Usage } from "../types";
4
5export const PRICES_AS_OF = "2026-10-04";
6
7type Price = { input: number; cacheRead: number; output: number };
8
9const price = (input: number, cacheRead: number, output: number): Price => ({
10  input,
11  cacheRead,
12  output,
13});
14
15// USD per million tokens at standard rates, from https://platform.claude.com/docs/en/about-claude/pricing.
16// Cache writes cost 1.25x input for the 5-minute lifetime and 2x for the 1-hour lifetime.
17const PRICES = {
18  "claude-fable-5-1": price(10, 0.25, 50),
19  "claude-mythos-5-1": price(10, 0.25, 50),
20  "claude-fable-5": price(10, 1, 50),
21  "claude-mythos-5": price(10, 1, 50),
22  "claude-opus-5-5": price(4, 0.2, 20),
23  "claude-opus-5": price(5, 0.5, 25),
24  "claude-opus-4-8": price(5, 0.5, 25),
25  "claude-opus-4-7": price(5, 0.5, 25),
26  "claude-opus-4-6": price(5, 0.5, 25),
27  "claude-opus-4-5": price(5, 0.5, 25),
28  "claude-sonnet-5-5": price(2, 0.2, 10),
29  "claude-sonnet-5": price(2, 0.2, 10),
30  "claude-sonnet-4-6": price(3, 0.3, 15),
31  "claude-sonnet-4-5": price(3, 0.3, 15),
32  "claude-haiku-4-5": price(1, 0.1, 5),
33} satisfies Record<string, Price>;
34
35const WRITE_MULTIPLIER = { "5m": 1.25, "1h": 2 } satisfies Record<Ttl, number>;
36
37export const TTL_MS = { "5m": 300_000, "1h": 3_600_000 } satisfies Record<
38  Ttl,
39  number
40>;
41
42// Pi 1.0.2's rule: send a refresh only when it is expected to save this much.
43export const MIN_SAVINGS_USD = 0.05;
44// Pi's measured chance that a prompt arrives before the entry expires while the session is idle.
45export const IDLE_CONTINUATION = 0.15;
46// A fork has no output cap; Opus 5.5 at high effort answered a refresh in 182 output tokens.
47export const DEFAULT_OUTPUT_TOKENS = 200;
48// The refreshes each lifetime may send while the session is idle, by default and at most.
49export const IDLE_LIMIT_DEFAULT = 5;
50export const IDLE_LIMIT_MAX = 20;
51
52export const ZERO_TOTALS: Totals = {
53  refreshes: 0,
54  costUsd: 0,
55  wastedUsd: 0,
56  kept: 0,
57  keptUsd: 0,
58};
59
60const isPriceKey = (id: string): id is keyof typeof PRICES =>
61  Object.hasOwn(PRICES, id);
62
63const rates = (model: string): Price | undefined => {
64  const id = model
65    .replace(/^(?:claude-gateway|anthropic)\//, "")
66    .replace(/\[\d+(?:k|m)\]$/, "");
67  const key = isPriceKey(id)
68    ? id
69    : id.match(/^(claude-[a-z]+-\d{1,2}(?:-\d{1,2})?)-\d{8}$/)?.[1];
70  return key !== undefined && isPriceKey(key) ? PRICES[key] : undefined;
71};
72
73// A fork that made a request; the other kind found nothing to fork.
74export type ForkReply = Exclude<ModelForkResult, { reason: "nothing-to-fork" }>;
75
76export const isTtl = (value: string): value is Ttl =>
77  value === "5m" || value === "1h";
78
79export const usageOf = (usage: ModelUsage): Usage => ({
80  input: usage.input_tokens,
81  output: usage.output_tokens,
82  cacheRead: usage.cache_read_input_tokens,
83  cacheWrite: usage.cache_creation_input_tokens,
84});
85
86export const promptTokensOf = (usage: Usage) =>
87  usage.input + usage.cacheRead + usage.cacheWrite;
88
89// Refresh at 90% of the lifetime, leaving at least ten seconds before expiry.
90export const delayOf = (ttl: Ttl) =>
91  Math.floor(Math.min(TTL_MS[ttl] * 0.9, TTL_MS[ttl] - 10_000));
92
93// Pi stops run warming 60 minutes after the last prompt request; the 1-hour
94// lifetime stretches that to two lifetimes so that it warms at all.
95export const horizonOf = (ttl: Ttl) => Math.max(60 * 60_000, 2 * TTL_MS[ttl]);
96
97// A /config value or option as an idle limit: a whole number from 0 to IDLE_LIMIT_MAX.
98export const limitOf = (value: ConfigValue) => {
99  const count = Math.round(Number(value));
100  return Number.isFinite(count)
101    ? Math.min(IDLE_LIMIT_MAX, Math.max(0, count))
102    : IDLE_LIMIT_DEFAULT;
103};
104
105// How long an idle cache stays warm: the limit's refreshes, then one lifetime.
106export const idleSpanOf = (count: number, ttl: Ttl) =>
107  count * delayOf(ttl) + TTL_MS[ttl];
108
109export const addTotals = <T extends Totals>(
110  base: T,
111  delta: Partial<Totals>,
112): T => ({
113  ...base,
114  refreshes: base.refreshes + (delta.refreshes ?? 0),
115  costUsd: base.costUsd + (delta.costUsd ?? 0),
116  wastedUsd: base.wastedUsd + (delta.wastedUsd ?? 0),
117  kept: base.kept + (delta.kept ?? 0),
118  keptUsd: base.keptUsd + (delta.keptUsd ?? 0),
119});
120
121// A timer that fires this late would likely find the entry expired and pay a full write.
122export const deadlineOf = (lastAt: number, ttl: Ttl) =>
123  lastAt + delayOf(ttl) + Math.floor((TTL_MS[ttl] - delayOf(ttl)) / 2);
124
125export const costOf = (
126  model: string,
127  usage: Usage,
128  ttl: Ttl,
129): number | null => {
130  const p = rates(model);
131  if (!p) return null;
132  return (
133    (usage.input * p.input +
134      usage.cacheWrite * p.input * WRITE_MULTIPLIER[ttl] +
135      usage.cacheRead * p.cacheRead +
136      usage.output * p.output) /
137    1_000_000
138  );
139};
140
141// What losing the entry adds to the next prompt: writing the prefix again instead of reading it.
142export const missCostOf = (
143  model: string,
144  promptTokens: number,
145  ttl: Ttl,
146): number | null => {
147  const p = rates(model);
148  if (!p) return null;
149  return Math.max(
150    0,
151    (promptTokens * (p.input * WRITE_MULTIPLIER[ttl] - p.cacheRead)) /
152      1_000_000,
153  );
154};
155
156export type Decision = {
157  warmUsd: number;
158  missUsd: number;
159  probability: number;
160  expectedUsd: number;
161  isWarm: boolean;
162  // Set when a refresh does not pay: the prompt is under the size where the expected saving reaches MIN_SAVINGS_USD.
163  reason?: string;
164};
165
166const breakEvenOf = (
167  p: Price,
168  ttl: Ttl,
169  probability: number,
170  outputTokens: number,
171): number | null => {
172  const savedPerToken =
173    probability * (p.input * WRITE_MULTIPLIER[ttl] - p.cacheRead) - p.cacheRead;
174  if (savedPerToken <= 0) return null;
175  return Math.ceil(
176    (MIN_SAVINGS_USD * 1_000_000 + outputTokens * p.output) / savedPerToken,
177  );
178};
179
180export const decide = (
181  model: string,
182  promptTokens: number,
183  ttl: Ttl,
184  phase: "run" | "idle",
185  outputTokens: number,
186): Decision | null => {
187  const p = rates(model);
188  const missUsd = missCostOf(model, promptTokens, ttl);
189  if (!p || missUsd === null || promptTokens <= 0) return null;
190  const warmUsd =
191    (promptTokens * p.cacheRead + outputTokens * p.output) / 1_000_000;
192  const probability = phase === "idle" ? IDLE_CONTINUATION : 1;
193  const expectedUsd = probability * missUsd - warmUsd;
194  const isWarm = expectedUsd >= MIN_SAVINGS_USD;
195  const breakEven = isWarm
196    ? null
197    : breakEvenOf(p, ttl, probability, outputTokens);
198  const where = `${model} at ${ttl} (${phase})`;
199  const reason = isWarm
200    ? undefined
201    : breakEven === null
202      ? `a refresh never saves on ${where}`
203      : `${formatTokens(promptTokens)} tokens is below the ${formatTokens(breakEven)} break-even on ${where}`;
204  return { warmUsd, missUsd, probability, expectedUsd, isWarm, reason };
205};
206
207// A refresh that read under half the prefix found the entry gone and wrote it again.
208export const outcomeOf = (
209  reply: ForkReply,
210  usage: Usage,
211  promptTokens: number,
212): Pick<Refresh, "result" | "detail"> => {
213  if (usage.cacheRead >= promptTokens / 2) return { result: "warmed" };
214  if (reply.isAnswered) return { result: "expired" };
215  return {
216    result: "failed",
217    detail:
218      reply.reason === "api-error"
219        ? `${reply.error} ${reply.status ?? ""}`.trim()
220        : reply.reason,
221  };
222};
223
224export const noticeOf = (entry: Refresh): Pick<Notice, "head" | "detail"> => {
225  const cost = `${entry.usage ? `read ${formatTokens(entry.usage.cacheRead)} · ` : ""}${entry.costUsd === null ? "cost unknown" : formatUsd(entry.costUsd)}`;
226  if (entry.result === "expired")
227    return {
228      head: "Cache had expired",
229      detail: `the refresh rewrote it · ${cost}`,
230    };
231  if (entry.result === "failed")
232    return {
233      head: "Cache refresh failed",
234      detail: entry.detail ? `${entry.detail} · ${cost}` : cost,
235    };
236  return {
237    head: "Cache warmed",
238    detail: `${cost} · saves ${entry.savesUsd === null ? "unknown" : formatUsd(entry.savesUsd)} vs rewrite`,
239  };
240};
241
242export const formatUsd = (usd: number): string => {
243  const sign = usd < 0 ? "-" : "";
244  const value = Math.abs(usd);
245  return `${sign}$${value > 0 && value < 0.01 ? value.toPrecision(2) : value.toFixed(2)}`;
246};
247
248export const formatTokens = (n: number): string =>
249  n >= 1_000_000
250    ? `${(n / 1_000_000).toFixed(1)}M`
251    : n >= 1000
252      ? `${(n / 1000).toFixed(1)}k`
253      : String(n);
254
255export const formatDuration = (ms: number): string => {
256  const s = Math.max(0, Math.round(ms / 1000));
257  if (s >= 3600)
258    return `${Math.floor(s / 3600)}h${Math.floor((s % 3600) / 60)}m`;
259  if (s >= 60) return `${Math.floor(s / 60)}m${s % 60 ? `${s % 60}s` : ""}`;
260  return `${s}s`;
261};
262
types/index.d.ts 125 lines
1export type Ttl = "5m" | "1h";
2
3export type Usage = {
4  input: number;
5  output: number;
6  cacheRead: number;
7  cacheWrite: number;
8};
9
10export type Refresh = {
11  at: number;
12  model: string;
13  usage: Usage | null;
14  costUsd: number | null;
15  // The rewrite the next prompt avoids, less this refresh's cost; earned only if a prompt follows before expiry.
16  savesUsd: number | null;
17  result: "warmed" | "expired" | "failed";
18  detail?: string;
19};
20
21export type Status =
22  | { state: "waiting" }
23  | {
24      state: "scheduled";
25      nextAt: number;
26      phase: "run" | "idle";
27      expectedUsd: number;
28    }
29  | { state: "refreshing" }
30  | { state: "stopped"; reason: string };
31
32// How Clawd behaves in the pane and the band: sipping while a refresh runs, hopping after
33// a warm one, dozing off over a cold mug after a failure or an expired cache.
34export type Mood = "warming" | "warmed" | "cold";
35
36// How a notice ends: a refresh under way stays until its outcome replaces it,
37// one during a turn fades after a few seconds, and one in an idle session
38// holds still from then on until the next prompt.
39export type NoticeEnding = "stays" | "fades" | "holds";
40
41// The band's line: `head` is the highlighted outcome and `detail` the dim rest.
42// `startedAt` is when the notice appeared, `since` when its mood began and
43// `heldAt` when Clawd stopped moving in a held notice, in epoch milliseconds;
44// `prompt` counts the prompts before the refresh it tells of.
45export type Notice = {
46  ttl: Ttl;
47  head: string;
48  detail: string;
49  mood: Mood;
50  ending: NoticeEnding;
51  startedAt: number;
52  since: number;
53  heldAt: number | null;
54  prompt: number;
55};
56
57export type Totals = {
58  refreshes: number;
59  costUsd: number;
60  // The fees of refresh chains that no kept prompt followed.
61  wastedUsd: number;
62  kept: number;
63  keptUsd: number;
64};
65
66// Totals across sessions, kept in $.store; `since` is when counting began, in epoch milliseconds.
67export type AllTime = Totals & { since: number };
68
69// The refreshes each lifetime may send while the session is idle.
70export type IdleLimits = { "5m": number; "1h": number };
71
72export type Page = "main" | "config" | "analytics";
73
74// The band above the prompt during a refresh: Clawd beside the notice, the notice as one line, or nothing.
75export type BandStyle = "default" | "simplified" | "off";
76
77// The main conversation's last request: the prefix a fork replays and keeps warm.
78export type Anchor = {
79  at: number;
80  lastAt: number;
81  model: string;
82  promptTokens: number;
83  ttl: Ttl;
84  refreshes: number;
85  // Refreshes sent while the session was idle; the idle limit counts these.
86  idleRefreshes: number;
87  // What this chain's refreshes cost: wasted unless the next prompt is kept.
88  feeUsd: number;
89  isStopped: boolean;
90};
91
92// Kept in state so that a reload, which a lifetime switch causes, can re-arm the timer.
93export type Warming = {
94  anchor: Anchor | null;
95  isRunning: boolean;
96  outputTokens: number;
97};
98
99declare module "claude-code" {
100  interface PluginState {
101    "cache-warmer": {
102      // This session's lifetime; `defaultTtl` is the saved one new sessions start with.
103      ttl: Ttl;
104      defaultTtl: Ttl;
105      // Set when the session page chose `ttl`, so a reload keeps it instead of the default.
106      isSessionTtl: boolean;
107      idleLimits: IdleLimits;
108      isForced: boolean;
109      // Set by the first main-thread response: the lifetime cannot change until /clear or a new session.
110      isLocked: boolean;
111      // While on, the main page lists the newest refreshes and a JSONL log records refreshes and stops.
112      isDebug: boolean;
113      bandStyle: BandStyle;
114      page: Page;
115      refreshes: Refresh[];
116      status: Status;
117      notice: Notice | null;
118      totals: Totals;
119      allTime: AllTime;
120      now: number;
121      warming: Warming;
122    };
123  }
124}
125