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…

<img alt="Clawd, the Claude Code mascot, in the refreshing, warm, and expired states" src="assets/clawd-light.gif" width="274">
Keeps the Claude Code prompt cache warm during a break, so your next prompt costs less.
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
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.
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.
/config row cache-warmer.band: default shows Clawd beside the notice, simplified shows the notice as one line, and off hides the band.[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./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
/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.● 5m ○ 1h moves it to the next option.| 5 minutes | 1 hour | |
|---|---|---|
| Refresh after | 4m30s | 54m |
| Cache write price | 1.25× input | 2× input |
| Warm time when idle, default limit | 27m30s | 5h30m |
/config row cache-warmer.ttl, or with /cache-warmer 5m or /cache-warmer 1h. The command also sets the current session./clear or a new session unlocks it. While it is locked, the command refuses, and a new default applies to later sessions only.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.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.
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.
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.
| Sends | Only the refresh requests. No other network requests and no telemetry. |
| Bills | Each refresh counts against your Claude plan or API key, like any request. |
| Sets | CLAUDE_CODE_PROMPT_CACHE_TTL for the running Claude Code process |
| Stores | All-time totals in the plugin store, one notice row per refresh in the transcript, and the debug log while debug mode is on |
| Stops | Set both idle limits to 0 to stop idle refreshes. Disable the plugin to stop all refreshes. |
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.
Report problems at github.com/paulbkim-dev/claude-code-cache-warmer/issues. cache-warmer is released under the MIT License.
hooks/register.tsx 1158 lines1import { 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};
1158hooks/mascot.ts 454 lines1// 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};
454hooks/pages.tsx 198 lines1import 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};
198hooks/pane.tsx 352 lines1import 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};
352hooks/warmer.ts 262 lines1import 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};
262types/index.d.ts 125 lines1export 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