Keeps the main conversation's prompt cache warm while you are away, within a window you choose, and shows its state above the prompt.

Stop paying full price every time you come back to a long Claude Code chat.
cache-keeper keeps your chat's memory warm while you're away, with one tiny refresh before it expires.


The problem
When it bites
The prices (from Anthropic's pricing and prompt caching pages, per 1 million tokens)
| Opus 5.5 | Sonnet 5.5 | |
|---|---|---|
| Normal input | $4 | $2 |
| Rebuilding the memory (1-hour cache) | $8 | $4 |
| Reading a warm memory | $0.20 | $0.10 |
👉 Rebuilding costs 40× more than reading.
What cache-keeper does
Examples use a 50/50 mix of Opus 5.5 and Sonnet 5.5, at list prices.
One break: a 600k-token chat, you're away 3 hours
| Without | With cache-keeper | |
|---|---|---|
| What happens | the whole chat is rebuilt | 3 tiny refreshes |
| Cost | $3.60 | $0.27 |
A heavy day: the memory would expire 4 times, about 2 hours each, on a 600k chat
| Without | With cache-keeper | |
|---|---|---|
| Per day | $14.40 | ~$0.72 |
| Per month (22 workdays) | ~$317 | ~$16 |
On a 700k chat: about $4.20 per rebuild against about $0.11 per refresh.
On a Pro or Max plan you don't pay these dollars directly. But the same rebuild eats into your usage limits, often a few percent of your week in one message.
In Claude Code:
/plugin install cache-keeper --marketplace parab-aditya/cache-keeper
Or from a shell:
claude plugin install cache-keeper --marketplace parab-aditya/cache-keeper
Then start a new session.
Desktop app: + next to the prompt → Plugins → Add plugin → cache-keeper (add parab-aditya/cache-keeper as a marketplace first if it isn't listed).
Update: claude plugin marketplace update heycache && claude plugin update cache-keeper@heycache, then a new session.
Uninstall: claude plugin uninstall cache-keeper@heycache, then claude plugin marketplace remove heycache.
For a team: add this to the project's .claude/settings.json:
{
"extraKnownMarketplaces": {
"heycache": { "source": { "source": "github", "repo": "parab-aditya/cache-keeper" } }
},
"enabledPlugins": { "cache-keeper@heycache": true }
}
Needs Claude Code 2.1.287+ in a terminal, or the desktop app 2.1.286+. Not in WSL or cloud sessions.
/cache on.The band above the prompt shows what's happening:
| State | Meaning |
|---|---|
| 🔥 warm | memory is kept warm; shows the next refresh |
| ✨ refreshing | a refresh is being sent |
| ⏸ off | warming is off for this session |
| 🟠 cooling | off or finished, and the memory expires within 10 minutes |
| ❄️ cold | memory expired; shows what your next message will cost |
| ⏹ stopped | it stopped, and says why |
The gear opens settings. Hover any setting to see what it does.
The terminal gets one quiet line with small buttons: k keep, o on/off, s settings.
| Command | What it does |
|---|---|
/cache | status |
/cache on / off | turn warming on or off for this session |
/cache keep 2h | keep warm for 2h, 4h, 8h, 12h or forever (add --save to make it your default) |
/cache stats | what it spent and what it saved: this session and all time |
/cache sessions | your other sessions that are warming |
/cache stop 2 / stop all | stop one of them, or all |
/cache settings | open settings |
/cache refresh-now | refresh right now |
/cache debug | details, for troubleshooting |
Change them in the settings sheet, or with /config (rows named cache-keeper.*).
| Setting | Default | What it does |
|---|---|---|
| Keep warm for | 8h | how long after your last message it keeps warming, once you turn it on |
| Refresh before it expires | 5 min | how early it refreshes before the hour is up |
| Skip chats smaller than | 30k tokens | small chats are cheap to rebuild, so they're skipped |
| Stop at weekly usage | 90% | stops warming when your weekly plan usage hits this |
| Band | full | full, minimal (one line) or off |
| 1-hour cache for API keys | off | switch API-key sessions to the 1-hour cache |
defaults read -g AppleInterfaceStyle). On the desktop, your session's own record in the app (~/Library/Application Support/Claude*/claude-code-sessions/): its title and whether it's archived.~/.claude/cache-keeper/ (numbers only, never your chat);~/.claude/cache-keeper/live/, which your other sessions read to show who's warming (a Stop writes a small .stop.json there);CLAUDE_CODE_PROMPT_CACHE_TTL=1h only if you turn on the 1-hour option, and unsets it when you turn it off.Delete ~/.claude/cache-keeper/ to remove all its files.
Run from another folder (for example cd /tmp):
claude plugin validate <path-to-this-folder>
claude plugin test <path-to-this-folder>
npx -y tsx tools/preview.ts draws every state into docs/preview.html. node tools/record-gifs.mjs records the GIFs (needs ffmpeg).
Licence texts: THIRD_PARTY.md. cache-keeper is MIT-licensed: LICENSE.
hooks/register.tsx 293 lines1// cache-keeper wiring only (design.md §8): every hook delegates to keeper, command or band.
2// Observers never change behaviour: our part runs inside `safely`, and every hook returns what `next` returned.
3import { atom, read, update } from 'claude-code'
4import type { EngineInterface, Register, Timer } from 'claude-code'
5
6import { bandModel, bandTree, pressButton, sheetActions } from './band'
7import { ARGUMENT_HINT, runCommand } from './command'
8import {
9 onClassicSessionStart,
10 onClassicStop,
11 onCompact,
12 onConfigSet,
13 onMeasure,
14 onModelSwitch,
15 onPermission,
16 onPromptSubmit,
17 onSessionEnd,
18 onSubagentStop,
19 onToolCall,
20 onTurnComplete,
21 noteBand,
22 noteIdentity,
23 onTurnStart,
24 refreshTheme,
25 resetKeeper,
26 setSurface,
27 safely,
28 start,
29 stepEnd,
30 stepStart,
31} from './keeper'
32import type { Io, KeeperState } from './io'
33import { isDrawn, noteDrawn, rescan } from './live'
34import { resetLog } from './log'
35import { paneActions, paneModel, paneTree } from './pane'
36import { DEFAULT_SETTINGS } from './policy'
37
38// State ($.state, survives a reload). Declared here, not in keeper.ts: the loader lists a module's state by
39// scanning this file, and wants each `read`/`update` to name one of these consts.
40// Every atom carries the shape tag 'ck2': a value another version of the code wrote (the Phase 0 spike, an older
41// build) reads as absent, so it cannot leave an anchor without a chain. Bump the tag when a shape changes.
42const anchorA = atom({ plugin: 'cache-keeper', key: 'anchor' } as const, null, { shape: 'ck2' })
43const chainA = atom({ plugin: 'cache-keeper', key: 'chain' } as const, null, { shape: 'ck2' })
44const statusA = atom({ plugin: 'cache-keeper', key: 'status' } as const, { kind: 'waiting' }, { shape: 'ck2' })
45const settingsA = atom({ plugin: 'cache-keeper', key: 'settings' } as const, DEFAULT_SETTINGS, { shape: 'ck2' })
46const turnA = atom({ plugin: 'cache-keeper', key: 'turn' } as const, null, { shape: 'ck2' })
47const dialogA = atom({ plugin: 'cache-keeper', key: 'dialog' } as const, null, { shape: 'ck2' })
48const tasksA = atom({ plugin: 'cache-keeper', key: 'tasks' } as const, [], { shape: 'ck2' })
49const limitsA = atom({ plugin: 'cache-keeper', key: 'limits' } as const, null, { shape: 'ck2' })
50const contextTokensA = atom({ plugin: 'cache-keeper', key: 'contextTokens' } as const, null, { shape: 'ck2' })
51const costUsdA = atom({ plugin: 'cache-keeper', key: 'costUsd' } as const, null, { shape: 'ck2' })
52const refreshingA = atom({ plugin: 'cache-keeper', key: 'refreshing' } as const, null, { shape: 'ck2' })
53const lastA = atom({ plugin: 'cache-keeper', key: 'last' } as const, null, { shape: 'ck2' })
54const sessionA = atom({ plugin: 'cache-keeper', key: 'session' } as const, { refreshes: 0, costUsd: 0, avoided: 0, grossUsd: 0, since: 0 }, { shape: 'ck2' })
55const resumedA = atom({ plugin: 'cache-keeper', key: 'resumed' } as const, null, { shape: 'ck2' })
56const surfaceA = atom({ plugin: 'cache-keeper', key: 'surface' } as const, null, { shape: 'ck2' })
57const lightA = atom({ plugin: 'cache-keeper', key: 'light' } as const, false, { shape: 'ck2' })
58const sheetOpenA = atom({ plugin: 'cache-keeper', key: 'sheetOpen' } as const, false, { shape: 'ck2' })
59const ttlSetA = atom({ plugin: 'cache-keeper', key: 'ttlSet' } as const, false, { shape: 'ck2' })
60const elsewhereA = atom({ plugin: 'cache-keeper', key: 'elsewhere' } as const, [], { shape: 'ck2' })
61const onAtA = atom({ plugin: 'cache-keeper', key: 'onAt' } as const, null, { shape: 'ck2' })
62
63/** keeper.ts names state by key; each key reads and updates its own atom. */
64const stateOf = ($: EngineInterface) => ({
65 anchor: { read: () => read($, anchorA), update: (fn: (v: KeeperState['anchor']) => KeeperState['anchor']) => update($, anchorA, fn) },
66 chain: { read: () => read($, chainA), update: (fn: (v: KeeperState['chain']) => KeeperState['chain']) => update($, chainA, fn) },
67 status: { read: () => read($, statusA), update: (fn: (v: KeeperState['status']) => KeeperState['status']) => update($, statusA, fn) },
68 settings: { read: () => read($, settingsA), update: (fn: (v: KeeperState['settings']) => KeeperState['settings']) => update($, settingsA, fn) },
69 turn: { read: () => read($, turnA), update: (fn: (v: KeeperState['turn']) => KeeperState['turn']) => update($, turnA, fn) },
70 dialog: { read: () => read($, dialogA), update: (fn: (v: KeeperState['dialog']) => KeeperState['dialog']) => update($, dialogA, fn) },
71 tasks: { read: () => read($, tasksA), update: (fn: (v: KeeperState['tasks']) => KeeperState['tasks']) => update($, tasksA, fn) },
72 limits: { read: () => read($, limitsA), update: (fn: (v: KeeperState['limits']) => KeeperState['limits']) => update($, limitsA, fn) },
73 contextTokens: { read: () => read($, contextTokensA), update: (fn: (v: KeeperState['contextTokens']) => KeeperState['contextTokens']) => update($, contextTokensA, fn) },
74 costUsd: { read: () => read($, costUsdA), update: (fn: (v: KeeperState['costUsd']) => KeeperState['costUsd']) => update($, costUsdA, fn) },
75 refreshing: { read: () => read($, refreshingA), update: (fn: (v: KeeperState['refreshing']) => KeeperState['refreshing']) => update($, refreshingA, fn) },
76 last: { read: () => read($, lastA), update: (fn: (v: KeeperState['last']) => KeeperState['last']) => update($, lastA, fn) },
77 session: { read: () => read($, sessionA), update: (fn: (v: KeeperState['session']) => KeeperState['session']) => update($, sessionA, fn) },
78 resumed: { read: () => read($, resumedA), update: (fn: (v: KeeperState['resumed']) => KeeperState['resumed']) => update($, resumedA, fn) },
79 surface: { read: () => read($, surfaceA), update: (fn: (v: KeeperState['surface']) => KeeperState['surface']) => update($, surfaceA, fn) },
80 light: { read: () => read($, lightA), update: (fn: (v: KeeperState['light']) => KeeperState['light']) => update($, lightA, fn) },
81 sheetOpen: { read: () => read($, sheetOpenA), update: (fn: (v: KeeperState['sheetOpen']) => KeeperState['sheetOpen']) => update($, sheetOpenA, fn) },
82 ttlSet: { read: () => read($, ttlSetA), update: (fn: (v: KeeperState['ttlSet']) => KeeperState['ttlSet']) => update($, ttlSetA, fn) },
83 elsewhere: { read: () => read($, elsewhereA), update: (fn: (v: KeeperState['elsewhere']) => KeeperState['elsewhere']) => update($, elsewhereA, fn) },
84 onAt: { read: () => read($, onAtA), update: (fn: (v: KeeperState['onAt']) => KeeperState['onAt']) => update($, onAtA, fn) },
85})
86
87const errText = (err: unknown) => (err instanceof Error ? err.message : String(err))
88
89/** Every engine call the glue modules make, spelled `$.noun.event(...)` here because the loader follows `$` no further. */
90const ioOf = ($: EngineInterface): Io => ({
91 now: () => $.clock.now(),
92 after: (ms, fn) => $.clock.after(ms, fn),
93 read: key => stateOf($)[key].read() as never,
94 update: (key, fn) => stateOf($)[key].update(fn as never) as never,
95 home: () => $.env.get('HOME'),
96 sessionId: () => $.session.id(),
97 setTtlEnv: value => $.env.set('CLAUDE_CODE_PROMPT_CACHE_TTL', value),
98 settings: () => $.settings.read(),
99 authKind: async () => (await $.session.authorize())?.kind ?? null,
100 usage: () => $.session.usage(),
101 fork: prompt => $.model.fork({ prompt }),
102 append: async text => {
103 await $.session.append({ message: { type: 'system', content: [{ type: 'text', text }] } })
104 },
105 debug: text => $.ui.log(text, { to: 'debug' }),
106 fsExists: path => $.fs.exists(path),
107 fsRead: path => $.fs.read(path),
108 fsWrite: (path, text) => $.fs.write(path, text),
109 fsList: async path => (await $.fs.list(path)).map(f => ({ name: f.name, kind: f.kind, mtimeMs: f.mtimeMs })),
110 run: async argv => {
111 const r = await $.process.run(argv, { timeoutMs: 3000 })
112 return { exitCode: r.exitCode, stdout: r.stdout }
113 },
114 storeGet: key => $.store.get(key),
115 storeSet: (key, value) => $.store.set(key, value),
116 saveConfig: async (key, value) => {
117 try {
118 const r = await $.config.set({ key, value })
119 return r.deny
120 } catch (err) {
121 return errText(err)
122 }
123 },
124 configRows: async () => (await $.config.list()).map(r => ({ key: r.key, value: r.value })),
125 openPane: async () => (await $.ui.open({ id: 'cache-keeper', title: 'cache-keeper', focus: true, closeOnEscape: true, rows: 12 })).isPlaced,
126 closePane: () => $.ui.close({ id: 'cache-keeper' }),
127 invalidate: () => $.ui.invalidate('ui.render'),
128 toast: text => $.ui.toast(text),
129})
130
131export const register: Register = (on, options) => {
132 resetKeeper()
133 resetLog()
134 let tick: Timer | undefined
135
136 on('session.start', async ($, e, next) => {
137 const io = ioOf($)
138 try {
139 await $.command.register({
140 name: 'cache',
141 description: 'Keep the prompt cache warm: status, on/off, settings, stats',
142 argumentHint: ARGUMENT_HINT,
143 immediate: true,
144 })
145 } catch (err) {
146 io.debug(`cache-keeper: command.register failed: ${errText(err)}`)
147 }
148 await safely(io, 'session.start', async () => {
149 noteIdentity({ cwd: e.cwd })
150 const env = {
151 force: await $.env.get('FORCE_PROMPT_CACHING_5M'),
152 envTtl: await $.env.get('CLAUDE_CODE_PROMPT_CACHE_TTL'),
153 enable1h: await $.env.get('ENABLE_PROMPT_CACHING_1H'),
154 }
155 await start(io, options, {
156 ...(env.force !== undefined ? { force: env.force } : {}),
157 ...(env.envTtl !== undefined ? { envTtl: env.envTtl } : {}),
158 ...(env.enable1h !== undefined ? { enable1h: env.enable1h } : {}),
159 })
160 // The desktop band's countdown ticks inside its own image and its look changes are timed in keeper.ts, so it is not
161 // redrawn each minute (a redraw restarts its animations). A terminal band's text is static: it redraws each minute.
162 // The same tick looks at the app's theme.
163 tick?.cancel()
164 tick = $.clock.every(60_000, () => {
165 void safely(io, 'tick', async () => {
166 if ((await read($, surfaceA)) === 'terminal') $.ui.invalidate('ui.render')
167 await refreshTheme(io)
168 // The other sessions' list: looked at each minute while this session has drawn a band or a pane.
169 if (isDrawn()) await rescan(io)
170 })
171 })
172 })
173 return next(e)
174 })
175
176 on('classic.SessionStart', async ($, e, next) => {
177 await safely(ioOf($), 'classic.SessionStart', () => onClassicSessionStart(ioOf($), e))
178 return next(e)
179 })
180
181 on('turn.start', async ($, e, next) => {
182 await safely(ioOf($), 'turn.start', () => onTurnStart(ioOf($), e))
183 return next(e)
184 })
185
186 on('turn.step', async function* ($, e, next) {
187 const io = ioOf($)
188 const ctx = await stepStart(io, e)
189 let r
190 try {
191 r = yield* next(e)
192 } finally {
193 // finally, not catch: a consumer that stops iterating early must still close the step.
194 await stepEnd(io, ctx, r?.usage ?? null)
195 }
196 return r
197 })
198
199 on('turn.complete', async ($, e, next) => {
200 await safely(ioOf($), 'turn.complete', () => onTurnComplete(ioOf($), e))
201 return next(e)
202 })
203
204 on('tool.call', ($, e, next) => onToolCall(ioOf($), e, e as unknown as Record<string, unknown>, () => next(e)))
205
206 on('classic.PermissionRequest', async ($, e, next) => {
207 await safely(ioOf($), 'classic.PermissionRequest', () => onPermission(ioOf($), e))
208 return next(e)
209 })
210
211 on('prompt.submit', async ($, e, next) => {
212 await safely(ioOf($), 'prompt.submit', () => onPromptSubmit(ioOf($), e))
213 return next(e)
214 })
215
216 on('classic.Stop', async ($, e, next) => {
217 await safely(ioOf($), 'classic.Stop', () => onClassicStop(ioOf($), e))
218 return next(e)
219 })
220
221 on('classic.SubagentStop', async ($, e, next) => {
222 await safely(ioOf($), 'classic.SubagentStop', () => onSubagentStop(ioOf($), e))
223 return next(e)
224 })
225
226 on('session.measure', async ($, e, next) => {
227 await safely(ioOf($), 'session.measure', () => onMeasure(ioOf($), e))
228 return next(e)
229 })
230
231 on('session.compact', async ($, e, next) => {
232 const r = await next(e)
233 // precompute installs nothing: the conversation is not compacted.
234 if (e.agentId === undefined && e.trigger !== 'precompute' && r.skip === undefined)
235 await safely(ioOf($), 'session.compact', () => onCompact(ioOf($)))
236 return r
237 })
238
239 on('session.end', async ($, e, next) => {
240 await safely(ioOf($), 'session.end', () => onSessionEnd(ioOf($), e.reason))
241 return next(e)
242 })
243
244 on('classic.PostModelSwitch', async ($, e, next) => {
245 await safely(ioOf($), 'classic.PostModelSwitch', () => onModelSwitch(ioOf($)))
246 return next(e)
247 })
248
249 on('config.set', async ($, e, next) => {
250 const r = await next(e)
251 if (r.deny === undefined && e.key.startsWith('cache-keeper.'))
252 await safely(ioOf($), 'config.set', () => onConfigSet(ioOf($), e.key, r.value))
253 if (r.deny === undefined && e.key === 'theme') await safely(ioOf($), 'config.set', () => refreshTheme(ioOf($)))
254 return r
255 })
256
257 on('command.run', { command: 'cache' }, async ($, e) => {
258 try {
259 return await runCommand(ioOf($), e.args)
260 } catch (err) {
261 return { text: `cache-keeper: ${errText(err)}` }
262 }
263 })
264
265 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
266 if (e.surface !== 'terminal' && e.surface !== 'desktop') return next(e)
267 const io = ioOf($)
268 const geo = { desktop: e.surface === 'desktop', columns: e.props.bodyColumns, maxRows: e.props.maxRows }
269 noteBand(geo)
270 if (noteDrawn()) $.clock.after(0, () => void safely(io, 'scan', () => rescan(io)))
271 const model = await bandModel(io, e.props.hasSurvey, geo).catch(err => {
272 io.debug(`cache-keeper: band: ${errText(err)}`)
273 return null
274 })
275 // $.state.set is refused while a render draws: record the surface from a clock callback. Before the band
276 // check, so the theme probes stop at the first render in a terminal even when the band draws nothing.
277 const surface = e.surface
278 $.clock.after(0, () => void setSurface(io, surface))
279 if (model === null) return next(e)
280 const inner = await next(e)
281 if ('deny' in inner) return inner
282 const target = e.surface === 'desktop' ? ({ surface: 'desktop', ui: $.ui.resolve(e) } as const) : ({ surface: 'terminal', ui: $.ui.resolve(e) } as const)
283 return bandTree(target, e.props.bodyColumns, model, key => void pressButton(io, key, geo), () => sheetActions(io).close(), sheetActions(io), inner)
284 })
285
286 on('ui.render', { component: 'Pane', requestId: 'cache-keeper' }, async ($, e, next) => {
287 if (e.surface === 'mobile') return next(e)
288 const io = ioOf($)
289 if (noteDrawn()) $.clock.after(0, () => void safely(io, 'scan', () => rescan(io)))
290 return paneTree($.ui.resolve(e), await paneModel(io), paneActions(io))
291 })
292}
293hooks/band.tsx 295 lines1// cache-keeper band (design.md §9, brief 2b). The structure of the desktop band is HeyCubit/effortless's (MIT, (c) 2026
2// HeyCubit): a rounded root with absolute art layers, a dot, the line, and the controls; a custom control is a drawn Svg
3// with a blank Button laid over it. The controls are in the flow at the right end and the line gives way to them (it
4// truncates), so text never runs under a control. The ui.render hook itself lives in register.tsx (the loader follows `$`
5// and `on` only inside that file); this file builds the model and the tree.
6import type { Elements, RenderElement } from 'claude-code'
7
8import type { KeepFor } from '../types'
9import { PALETTE, artLayers, chipSvg, clockFrames, clockSvg, closeSvg, dotSvg, emberSvg, gearSvg, saveSvg, toggleSvg } from './art'
10import type { ArtColors, ArtState } from './art'
11import { hit } from './hit'
12import type { Io } from './io'
13import { A, lineInputOf, openSettings, safely, setSettings, setSheet, snapshot, themeSoon, toggleWarming } from './keeper'
14import { paneActions, paneModel } from './pane'
15import type { PaneModel } from './pane'
16import { elsewhereTitle } from './live'
17import { bandLine, fmtMinutes, keepLabel, lineText, nextKeep, tierOf } from './policy'
18import type { Line, Seg, Tier } from './policy'
19import { sheetBody } from './sheet'
20import type { SheetActions } from './sheet'
21import { CELL_PX, CHIP_CELLS, ELSEWHERE_CELLS, GEAR_CELLS, SAVE_CELLS, TOGGLE_CELLS, artStateOf, cellsPx, coldStripFits, sheetFits } from './view'
22import type { ButtonKey } from './view'
23
24export type BandModel = {
25 /** Null when there is nothing to say (no request yet): only an open sheet is drawn then. */
26 line: Line | null
27 minimal: boolean
28 art: ArtState
29 light: boolean
30 /** `$.clock.now()` at this render: the phase every looping animation is locked to. */
31 nowMs: number
32 /** Warming is on (the toggle is right). */
33 on: boolean
34 /** The session's window: what the keep chip shows (it is hidden while warming is off). */
35 keepFor: KeepFor
36 tier: Tier
37 /** How many of the user's other sessions are warming (the ember chip shows it; 0 hides the chip). */
38 elsewhere: number
39 /** The settings sheet is open (desktop, full style, room for it): its body's model. */
40 sheet: PaneModel | null
41}
42
43/** Where a band is drawn: what a gear press needs to decide between the sheet and the Pane. */
44export type Geo = { desktop: boolean; columns: number; maxRows: number }
45
46/** What to draw, or null when the band is off, a survey owns the slot, or there is nothing to say. */
47export async function bandModel(io: Io, hasSurvey: boolean, geo: Geo): Promise<BandModel | null> {
48 const s = await snapshot(io)
49 if (s.settings.band === 'off' || hasSurvey) return null
50 themeSoon(io)
51 const tier = tierOf(geo.columns)
52 const line = bandLine(lineInputOf(s), s.settings.band, tier)
53 const open = s.sheetOpen && geo.desktop && s.settings.band === 'full'
54 const fits = sheetFits(geo.maxRows, geo.columns)
55 // A sheet the slot has shrunk below drops the whole tree: draw the band, then close the sheet and open the Pane in its place, after the draw.
56 // The state read-and-clear makes it once per close, however many draws queued one.
57 if (open && !fits) void io.after(0, () => void safely(io, 'sheet.close', () => closeToPane(io)))
58 if (!line && !(open && fits)) return null
59 return {
60 line,
61 minimal: s.settings.band === 'minimal',
62 art: line ? artStateOf(line) : 'dim',
63 light: s.light,
64 nowMs: s.now,
65 on: s.settings.keepFor !== 'off',
66 keepFor: s.settings.keepFor,
67 tier,
68 elsewhere: s.elsewhere.length,
69 sheet: open && fits ? await paneModel(io) : null,
70 }
71}
72
73/** Closes the open sheet and opens the Pane in its place (once: a second call finds the sheet already closed). */
74async function closeToPane(io: Io): Promise<void> {
75 let was = false
76 await io.update(A.sheetOpen, v => ((was = v), false))
77 if (was && !(await io.openPane())) io.toast('cache-keeper: widen the window to see the settings')
78}
79
80/** A button press: writes state (never during a render), then the scheduler looks again. */
81export const pressButton = (io: Io, key: ButtonKey, geo: Geo): Promise<void> =>
82 safely(io, `band.${key}`, async () => {
83 if (key === 'toggle') return toggleWarming(io)
84 // The chip opens the list: the Pane's "Warming elsewhere" section.
85 if (key === 'elsewhere') return void ((await io.openPane()) || io.toast('cache-keeper: widen the window to see the other sessions'))
86 if (key === 'keep') {
87 const { keepFor } = await io.read(A.settings)
88 // Off has no window to cycle: the chip is hidden then; a stale press does nothing.
89 return keepFor === 'off' ? undefined : setSettings(io, { keepFor: nextKeep(keepFor) })
90 }
91 // The gear again closes the sheet.
92 if (await io.read(A.sheetOpen)) return void (await setSheet(io, false))
93 if ((await openSettings(io, geo)) === 'none') io.toast('cache-keeper: widen the window to see the settings')
94 })
95
96/** What the sheet's own controls do (the pane's, with Close shutting the sheet). */
97export const sheetActions = (io: Io): SheetActions => paneActions(io, () => setSheet(io, false))
98
99type Press = (key: ButtonKey) => void
100
101/** Terminal colours are theme keys (they follow the person's theme); the ember and the cold ice blue are raw, darker on a light theme. */
102const termColor = (art: ArtState, light: boolean): string =>
103 art === 'warm' || art === 'refreshing' ? (light ? '#b45309' : '#f39a52')
104 : art === 'cooling' ? 'warning'
105 : art === 'error' ? 'error'
106 : art === 'cold' ? (light ? '#0b6bcb' : '#7cc4ff')
107 : 'inactive'
108
109type Desktop = Elements['desktop']
110
111/** A countdown as a self-ticking image (no redraw), in the band's text colour. */
112function clock(ui: Desktop, ms: number, color: string) {
113 const { Box, Svg } = ui
114 const frames = clockFrames(ms, fmtMinutes)
115 const svg = clockSvg(frames, color)
116 const w = /^<svg[^>]* width="(\d+)"/.exec(svg)?.[1]
117 return (
118 <Box flexShrink={0}>
119 <Svg source={svg} alt={frames[0]?.label ?? ''} width={Number(w)} height={17} />
120 </Box>
121 )
122}
123
124/** A text with a countdown in it, in a row that gives way from the right: each part truncates, the countdown never does. */
125function segRow(ui: Desktop, seg: Seg, color: string, dim: string) {
126 const { Box, Text } = ui
127 return (
128 <Box flexDirection="row" alignItems="center" gap={1} flexShrink={1000} minWidth={0} overflow="hidden">
129 {seg.pre ? (
130 <Box flexShrink={1000} minWidth={0}>
131 <Text color={color} wrap="truncate">
132 {seg.word ? <Text bold>{seg.word}</Text> : null}
133 {seg.word ? seg.pre.slice(seg.word.length) : seg.pre}
134 </Text>
135 </Box>
136 ) : null}
137 {seg.ms !== null ? clock(ui, seg.ms, color) : null}
138 {seg.post ? (
139 <Box flexShrink={1000} minWidth={0}>
140 <Text color={dim} wrap="truncate">
141 {seg.post}
142 </Text>
143 </Box>
144 ) : null}
145 </Box>
146 )
147}
148
149function desktop(ui: Desktop, columns: number, m: BandModel, press: Press, close: () => void, inner: RenderElement, sheetAct: SheetActions) {
150 const { Box, Text, Svg } = ui
151 const c: ArtColors = PALETTE[m.light ? 'light' : 'dark'][m.art]
152 const line = m.line
153 // The full-width overlays are drawn at the band's width: the app scales an Svg down to its box but never up.
154 const fullWidth = Math.max(760, Math.round(columns * CELL_PX))
155 const open = m.sheet !== null
156 const gearPx = cellsPx(GEAR_CELLS)
157 // The keep chip is the window: hidden while warming is off (there is no window then), in the tiny tier (no room) and while the sheet is
158 // open (its first row is the window then).
159 const showKeep = m.on && m.tier !== 'tiny' && !open
160 // Beside it, a flame and a count while other sessions are warming (hidden at none and while the sheet is open, like the keep chip). Ember whatever this band's tone.
161 const showElsewhere = m.elsewhere > 0 && !open
162 const layers = artLayers(m.art, { light: m.light, nowMs: m.nowMs, strip: coldStripFits(columns, Array.from(line?.text ?? '').length), fadeDown: open })
163 return (
164 <Box flexDirection="column">
165 <Box
166 key="ck-band"
167 position="relative"
168 flexDirection="column"
169 paddingX={1}
170 overflow="hidden"
171 borderStyle="round"
172 backgroundColor={c.bg}
173 borderColor={c.edge}
174 >
175 {/* Art stays behind in absolute layers; the sheet has no full-width overlay (it is taller than the 37 px they are drawn for). */}
176 {layers
177 .filter(l => l.anchor === 'right' || !open)
178 .map(l =>
179 l.anchor === 'right' ? (
180 // Taller than the band and clipped by it, so the art reaches every edge on the right.
181 <Box key={l.key} position="absolute" top={-1} right={0} {...(open ? {} : { bottom: -1 })}>
182 <Svg source={l.svg} alt={`cache ${m.art} art`} width={l.width} height={l.height} />
183 </Box>
184 ) : (
185 <Box key={l.key} position="absolute" top={0} left={0} right={0} bottom={0}>
186 <Svg source={l.svg} alt={`cache ${m.art} rim`} width={fullWidth} height={l.height} />
187 </Box>
188 ),
189 )}
190 <Box key="ck-row" flexDirection="row" gap={1} alignItems="center">
191 <Box flexShrink={0}>
192 <Svg source={dotSvg(m.art, m.light, m.nowMs)} alt={`cache ${m.art}`} width={10} height={10} />
193 </Box>
194 {open ? (
195 <Box flexShrink={0}>
196 <Text color={c.accent} bold>
197 cache-keeper
198 </Text>
199 </Box>
200 ) : null}
201 {/* The line and, over it, the card the pointer reveals (an absolute child painted over the line, same background). */}
202 <Box key="ck-line" position="relative" flexDirection="row" alignItems="center" flexGrow={1} flexShrink={1000} minWidth={0} overflow="hidden">
203 {line ? segRow(ui, line, c.text, c.dim) : null}
204 {line?.hover ? (
205 <Box
206 position="absolute"
207 top={0}
208 bottom={0}
209 left={0}
210 right={0}
211 flexDirection="row"
212 alignItems="center"
213 backgroundColor={c.bg}
214 display="none"
215 hover={{ display: 'flex' }}
216 >
217 {segRow(ui, line.hover, c.text, c.dim)}
218 </Box>
219 ) : null}
220 </Box>
221 {/* The controls are in the flow, as wide as they are: the line above gives way to them. A positioned box paints over the art. */}
222 <Box key="ck-controls" position="relative" flexShrink={0} flexDirection="row" alignItems="center" gap={1}>
223 <Box position="absolute" top={0} left={0} />
224 {showElsewhere ? hit(ui, { id: 'ck-elsewhere', cells: ELSEWHERE_CELLS, svg: emberSvg(m.elsewhere, m.light, cellsPx(ELSEWHERE_CELLS), m.nowMs), alt: elsewhereTitle(m.elsewhere), height: 18, onPress: () => press('elsewhere') }) : null}
225 {showKeep ? hit(ui, { id: 'ck-keep', cells: CHIP_CELLS, svg: chipSvg(keepLabel(m.keepFor), c, cellsPx(CHIP_CELLS)), alt: `Keep warm for ${m.keepFor === 'forever' ? 'no end' : m.keepFor}`, height: 18, onPress: () => press('keep') }) : null}
226 {m.sheet?.dirty ? hit(ui, { id: 'ck-save', cells: SAVE_CELLS, svg: saveSvg('Save as default', c, cellsPx(SAVE_CELLS)), alt: 'Save as default', height: 18, onPress: sheetAct.save }) : null}
227 {hit(ui, { id: 'ck-toggle', cells: TOGGLE_CELLS, svg: toggleSvg(m.on, c, cellsPx(TOGGLE_CELLS)), alt: m.on ? 'Auto-refresh on' : 'Auto-refresh off', height: 16, onPress: () => press('toggle') })}
228 {open
229 ? // Drawn in the gear's stroke and box, not a text glyph; a plain Button, not role="dismiss": the app may draw its own close control at the band's edge for that.
230 hit(ui, { id: 'ck-dismiss', cells: GEAR_CELLS, svg: closeSvg(c.text, gearPx), alt: 'Close settings', height: 14, onPress: close })
231 : hit(ui, { id: 'ck-settings', cells: GEAR_CELLS, svg: gearSvg(c.text, gearPx), alt: 'Settings', height: 14, onPress: () => press('settings') })}
232 </Box>
233 </Box>
234 {m.sheet ? sheetBody(ui, columns, m.sheet, c, sheetAct) : null}
235 </Box>
236 {inner}
237 </Box>
238 )
239}
240
241function terminal(ui: Elements['terminal'], m: BandModel, press: Press, inner: RenderElement) {
242 const { Box, Text, Button } = ui
243 if (!m.line) return inner
244 return (
245 <Box flexDirection="column">
246 <Box flexDirection="row" gap={1}>
247 <Text color={termColor(m.art, m.light)}>{m.line.symbol}</Text>
248 <Box flexGrow={1} flexShrink={1} minWidth={0}>
249 <Text dimColor={m.art === 'dim' || m.art === 'off'} wrap="truncate">
250 {m.line.word ? <Text bold>{m.line.word}</Text> : null}
251 {m.line.word ? m.line.text.slice(m.line.word.length) : m.line.text}
252 </Text>
253 </Box>
254 {m.elsewhere > 0 ? (
255 <Box key="ck-elsewhere" flexShrink={0}>
256 <Text dimColor wrap="truncate">{m.tier === 'tiny' ? `+${m.elsewhere}` : `+${m.elsewhere} warming elsewhere`}</Text>
257 </Box>
258 ) : null}
259 {m.on && m.tier !== 'tiny' ? <Button key="ck-keep" label={`keep ${keepLabel(m.keepFor)}`} hotkey="k" onPress={() => press('keep')} /> : null}
260 <Button key="ck-toggle" label={m.on ? 'on' : 'off'} hotkey="o" onPress={() => press('toggle')} />
261 <Button key="ck-settings" label="settings" hotkey="s" onPress={() => press('settings')} />
262 </Box>
263 {inner}
264 </Box>
265 )
266}
267
268/** The surface and its element table together: a terminal table also has an `Svg` (an empty fragment), so the surface decides, not the table. */
269export type BandUi = { surface: 'desktop'; ui: Elements['desktop'] } | { surface: 'terminal'; ui: Elements['terminal'] }
270
271/** The band above the engine's own tree: art, line and controls on desktop (or the sheet); one row on the terminal; one dim line when minimal. */
272export function bandTree(
273 target: BandUi,
274 columns: number,
275 m: BandModel,
276 press: Press,
277 close: () => void,
278 sheetAct: SheetActions,
279 inner: RenderElement,
280) {
281 if (m.minimal && m.line) {
282 const { Box, Text } = target.ui
283 return (
284 <Box flexDirection="column">
285 <Text dimColor wrap="truncate">
286 {m.elsewhere > 0 ? `${lineText(m.line)} · +${m.elsewhere} warming elsewhere` : lineText(m.line)}
287 </Text>
288 {inner}
289 </Box>
290 )
291 }
292 return target.surface === 'desktop' ? desktop(target.ui, columns, m, press, close, inner, sheetAct) : terminal(target.ui, m, press, inner)
293}
294
295hooks/command.ts 216 lines1// cache-keeper /cache command (design.md §13): parsing and short markdown answers.
2import type { KeepFor } from '../types'
3import { saveRow } from './defaults'
4import type { Io } from './io'
5import { elsewhereTitle, requestStop, rescan, rowText } from './live'
6import { logTail, readAllTime } from './log'
7import {
8 A,
9 bandGeo,
10 lineInputOf,
11 openSettings,
12 refreshNow,
13 setSettings,
14 snapshot,
15 statusLine,
16 themeLine,
17 toggleWarming,
18 ttlExplain,
19 guardBlock,
20 warmingOn,
21} from './keeper'
22import type { Snap } from './keeper'
23
24import {
25 KEEP_MS,
26 OPTIONS,
27 coldDetail,
28 expiresAt,
29 fmtClock,
30 fmtDate,
31 fmtIn,
32 fmtPct,
33 fmtSpan,
34 fmtTokens,
35 fmtUsd,
36 savingsOf,
37 totalsText,
38 windowEnd,
39} from './policy'
40
41export const ARGUMENT_HINT =
42 'status | on | off | keep <2h|4h|8h|12h|forever> [--save] | lead <min> [--save] | refresh-now | stats | settings | sessions | stop <n|all> | debug'
43
44const USAGE = [
45 'Usage: `/cache <sub>`',
46 '- `status` (default), `stats`, `debug`',
47 '- `on` / `off`: warm for the saved window / stop warming this session',
48 '- `keep <2h|4h|8h|12h|forever> [--save]`, `lead <2-15> [--save]`',
49 '- `refresh-now`, `settings`',
50 '- `sessions`: your other sessions that are keeping the cache warm; `stop <n|all>` asks them to stop',
51].join('\n')
52
53const KEEPS: readonly KeepFor[] = ['2h', '4h', '8h', '12h', 'forever']
54
55/** The session ids `/cache sessions` last printed, in order: `/cache stop <n>` means the n-th of these (matched by id, the list may have moved). */
56let printed: string[] = []
57
58const ago = (s: Snap, at: number) => `${fmtIn(s.now - at)} ago`
59
60function details(s: Snap): string {
61 const rows: string[] = []
62 // The short cold line leaves out when it went cold and why.
63 const cold = coldDetail(lineInputOf(s))
64 if (cold) rows.push(cold)
65 rows.push(`TTL: ${ttlExplain(s)}`)
66 if (s.anchor && s.chain) {
67 rows.push(
68 `Last request: ${ago(s, s.anchor.at)} · ${fmtTokens(s.anchor.promptTokens)} tokens · ${s.anchor.model || 'model unknown'}`,
69 )
70 const exp = expiresAt(s.chain, s.anchor.ttl)
71 rows.push(`Cache expires: ${fmtClock(exp)} (${exp > s.now ? `in ${fmtIn(exp - s.now)}` : 'already cold'})`)
72 if (s.status.kind === 'scheduled')
73 rows.push(
74 `Next refresh: ${fmtClock(s.status.nextAt)} (in ${fmtIn(s.status.nextAt - s.now)}; ${fmtIn(s.settings.leadMin * 60_000)} lead on 1h)`,
75 )
76 const end = windowEnd(s.anchor, s.settings, s.tasks.length > 0)
77 rows.push(
78 `Window: ${s.settings.keepFor}${s.tasks.length > 0 ? ' (extended by background tasks)' : ''}` +
79 (Number.isFinite(end) ? `, ends ${fmtClock(end)}` : ''),
80 )
81 rows.push(`Kept this chain: ${s.chain.refreshes}× (${fmtUsd(s.chain.costUsd)})`)
82 } else {
83 rows.push('No request yet in this conversation.')
84 }
85 rows.push(`Settings: keep ${s.settings.keepFor}, lead ${s.settings.leadMin}m, min ${fmtTokens(s.settings.minTokens)}, weekly guard ${s.settings.weeklyGuard}%`)
86 if (s.tasks.length > 0) rows.push(`Background tasks: ${s.tasks.map(t => `${t.kind} ${t.desc || t.id}`).join('; ')}`)
87 if (s.dialog) rows.push(`Waiting on you: ${s.dialog.tool}`)
88 if (s.limits) {
89 const parts = [
90 s.limits.fiveHour !== undefined ? `5h ${fmtPct(s.limits.fiveHour)}` : '',
91 s.limits.weekly !== undefined ? `weekly ${fmtPct(s.limits.weekly)}` : '',
92 ].filter(Boolean)
93 if (parts.length > 0) rows.push(`Limits: ${parts.join(' · ')}`)
94 }
95 if (s.last) {
96 rows.push(
97 `Last refresh: ${s.last.result} ${ago(s, s.last.at)}, read ${fmtTokens(s.last.cacheRead)}, ` +
98 `${s.last.costUsd === null ? `~${fmtUsd(s.last.estUsd)}` : fmtUsd(s.last.costUsd)}, ${s.last.ms} ms, ${s.last.phase}` +
99 (s.last.detail && s.last.result !== 'warmed' ? `, ${s.last.detail}` : ''),
100 )
101 }
102 return rows.map(r => `- ${r}`).join('\n')
103}
104
105async function stats(io: Io, s: Snap): Promise<string> {
106 const all = await readAllTime(io)
107 const row = (name: string, t: Parameters<typeof totalsText>[0]) => `- ${name}: ${totalsText(t)}`
108 const sv = savingsOf(all, s.session)
109 return [
110 '**cache-keeper stats**',
111 `${sv.hero} (${sv.detail}) · ${sv.session}`,
112 row('This session', s.session),
113 row(`All time (since ${fmtDate(all.since)})`, all),
114 sv.note,
115 ].join('\n')
116}
117
118async function save(io: Io, field: string, value: string): Promise<string | undefined> {
119 return saveRow(io, field, value)
120}
121
122export async function runCommand(io: Io, args: string): Promise<{ text: string }> {
123 const words = args.trim().split(/\s+/).filter(Boolean)
124 const wantSave = words.includes('--save')
125 const [sub = 'status', arg] = words.filter(w => w !== '--save')
126 const say = (text: string) => ({ text })
127 switch (sub) {
128 case 'status': {
129 const s = await snapshot(io)
130 return say(`**${statusLine(s)}**\n${details(s)}`)
131 }
132 case 'stats':
133 return say(await stats(io, await snapshot(io)))
134 // `resume` is `on`, `pause` is `off`: there is one switch.
135 case 'on':
136 case 'resume': {
137 const keepFor = await warmingOn(io)
138 const blocked = await guardBlock(io)
139 if (blocked) return say(blocked)
140 return say(`Warming on: ${keepFor === 'forever' ? 'no end' : `${keepFor} after each message`}.`)
141 }
142 case 'off':
143 case 'pause':
144 // toggleWarming records the window, so `/cache on` restores it.
145 if ((await io.read(A.settings)).keepFor !== 'off') await toggleWarming(io)
146 return say('Auto-refresh off for this session. `/cache on` turns it back on.')
147 case 'keep': {
148 const keepFor = KEEPS.find(k => k === arg)
149 if (!keepFor) return say('Usage: `/cache keep <2h|4h|8h|12h|forever> [--save]`')
150 await setSettings(io, { keepFor })
151 const denied = wantSave ? await save(io, 'keepFor', keepFor) : undefined
152 return say(
153 `Keeping the cache warm for ${keepFor === 'forever' ? 'as long as the session lives' : fmtSpan(KEEP_MS[keepFor])} after each message` +
154 (wantSave ? (denied ? ` (not saved as default: ${denied}).` : ' (saved as default).') : '.'),
155 )
156 }
157 case 'lead': {
158 const n = Number(arg)
159 if (!Number.isInteger(n) || n < 2 || n > 15) return say('Usage: `/cache lead <2-15> [--save]` (minutes before the 1-hour cache expires)')
160 await setSettings(io, { leadMin: n })
161 const canSave = (OPTIONS.lead as readonly number[]).includes(n)
162 const denied = wantSave && canSave ? await save(io, 'lead', String(n)) : undefined
163 return say(
164 `Refreshing ${n} minutes before expiry` +
165 (wantSave
166 ? canSave
167 ? denied ? ` (not saved as default: ${denied}).` : ' (saved as default).'
168 : ` (not saved: the saved default takes one of ${OPTIONS.lead.join(', ')}).`
169 : '.'),
170 )
171 }
172 case 'refresh-now':
173 return say(`Refresh: ${await refreshNow(io)}`)
174 case 'settings': {
175 const where = await openSettings(io)
176 if (where === 'sheet') return say('Settings opened above the prompt.')
177 return say(where === 'pane' ? 'Settings pane opened.' : 'The settings pane has no room here: widen the window, or use `/config` (rows `cache-keeper.*`).')
178 }
179 case 'sessions': {
180 const list = await rescan(io)
181 printed = list.map(e => e.sessionId)
182 if (list.length === 0) return say('No other session is warming.')
183 const now = await io.now()
184 return say([
185 `**${elsewhereTitle(list.length)}**`,
186 ...list.map((e, i) => `${i + 1}. ${rowText(e, now)}`),
187 'Stop one: `/cache stop 2` · all: `/cache stop all`',
188 ].join('\n'))
189 }
190 case 'stop': {
191 const list = await rescan(io)
192 const n = Number(arg)
193 if (arg !== 'all' && !(Number.isInteger(n) && n >= 1)) return say('Usage: `/cache stop <n|all>` (numbers are the ones `/cache sessions` printed)')
194 if (list.length === 0) return say('No other session is warming.')
195 const id = arg === 'all' ? undefined : (printed.length > 0 ? printed : list.map(e => e.sessionId))[n - 1]
196 if (arg !== 'all' && id === undefined) return say(`There is no session ${n}: run \`/cache sessions\` to see the list.`)
197 const targets = arg === 'all' ? list : list.filter(e => e.sessionId === id)
198 const moved = printed.length > 0 && JSON.stringify(printed) !== JSON.stringify(list.map(e => e.sessionId))
199 if (targets.length === 0) return say('That session is no longer warming: run `/cache sessions` for the current list.')
200 const done = await requestStop(io, targets)
201 const lines = done.map(t => `Asked ${t.label} to stop; it stops before its next refresh.`)
202 if (done.length < targets.length) lines.push('Could not write the stop request for every session (see the debug log).')
203 if (moved && arg !== 'all') lines.push('The list changed since `/cache sessions`: matched by session, not by number.')
204 return say(lines.join('\n'))
205 }
206 case 'debug': {
207 const s = await snapshot(io)
208 const tail = await logTail(io, 10)
209 const dump = JSON.stringify({ ...s, bandGeo: bandGeo() }, null, 1)
210 return say(['```json', dump, '```', themeLine(), tail.length > 0 ? '```\n' + tail.join('\n') + '\n```' : 'The log is empty.'].join('\n'))
211 }
212 default:
213 return say(USAGE)
214 }
215}
216hooks/keeper.ts 1158 lines1// cache-keeper engine glue (design.md §4, §8, §11): atoms, the one refresh timer, refresh(), stop().
2// Patterns adapted from paulbkim-dev/claude-code-cache-warmer (MIT, (c) Paul B. Kim): the claim object with a
3// finally-clear, schedule() reading state last, re-arming in session.start, the system notice row.
4import type { ModelForkResult, Timer } from 'claude-code'
5
6import type {
7 Anchor,
8 Chain,
9 Dialog,
10 Elsewhere,
11 Limits,
12 RefreshRecord,
13 KeepFor,
14 Resumed,
15 Settings,
16 Status,
17 StopCode,
18 Task,
19 Totals,
20 Ttl,
21} from '../types'
22import type { Io, KeeperState } from './io'
23import { drainSave, keptDefaults, forgetKept } from './defaults'
24import { addAllTime, logLine, readLearned, saveLearned } from './log'
25import { HEARTBEAT_MS, desktopState, entryOf, resetLive, stopAskedAt, writeEntry } from './live'
26import type { Ident } from './live'
27import { sheetFits } from './view'
28import type { Learned } from './log'
29import {
30 CLAIM_STALE_MS,
31 COOL_MS,
32 coolMs,
33 DEFAULT_SETTINGS,
34 FORK_PROMPT,
35 RETRY_DELAYS_MS,
36 SAFETY_MS,
37 TTL_MS,
38 TTL_WHY_TEXT,
39 bandLine,
40 decide,
41 expiresAt,
42 fmtClock,
43 fmtIn,
44 fmtSpan,
45 fmtTokens,
46 fmtUsd,
47 limitsOf,
48 lineText,
49 nextRefreshAt,
50 outcomeOf,
51 readOptions,
52 refreshUsd,
53 rewriteUsd,
54 ttlOf,
55 ttlWhy,
56 windowEnd,
57 appearanceOf,
58} from './policy'
59import type { Decision, LineInput, TtlInputs } from './policy'
60
61// ---------------------------------------------------------------------------
62// State keys ($.state, survives a reload). The atoms themselves are declared in register.tsx: the hooks loader
63// only lists state a module reads and writes when its atoms are consts of that file. Never written from a render.
64
65export const A = {
66 anchor: 'anchor',
67 chain: 'chain',
68 status: 'status',
69 settings: 'settings',
70 turn: 'turn',
71 dialog: 'dialog',
72 tasks: 'tasks',
73 limits: 'limits',
74 contextTokens: 'contextTokens',
75 costUsd: 'costUsd',
76 refreshing: 'refreshing',
77 last: 'last',
78 session: 'session',
79 resumed: 'resumed',
80 surface: 'surface',
81 light: 'light',
82 sheetOpen: 'sheetOpen',
83 ttlSet: 'ttlSet',
84 elsewhere: 'elsewhere',
85 onAt: 'onAt',
86} as const
87
88// A status change is the moment this session's live file (live.ts) may be out of date: one place, here.
89const put = <K extends keyof KeeperState>(io: Io, key: K, v: KeeperState[K]) =>
90 io.update(key, () => v).then(r => {
91 if (key === A.status) void publish(io)
92 return r
93 })
94const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b)
95const errText = (err: unknown) => (err instanceof Error ? err.message : String(err))
96
97// ---------------------------------------------------------------------------
98// Module variables (lost on reload, fine)
99
100type Base = { pct: number; cost: number; exact: boolean }
101
102let timer: Timer | undefined
103/** The one-off redraws at the cooling threshold and at expiry (the countdown itself ticks in its own image). */
104let viewTimer: Timer | undefined
105/** The band as last drawn: where a gear press or /cache settings can put the sheet (module variable, lost on reload). */
106let seenBand: { desktop: boolean; columns: number; maxRows: number } | null = null
107/** Mirrors the state claim for code that must not await. */
108let forking = false
109let schedChain: Promise<unknown> = Promise.resolve()
110let ttlEnv: Omit<TtlInputs, 'limits'> = { authKind: null }
111let saved: Settings = DEFAULT_SETTINGS
112/** Main-thread requests (turn.step) started and in flight: a fork that overlapped one cannot read its own cost off the ledger. */
113const mainSteps = { started: 0, inFlight: 0 }
114let learned: Learned = {}
115/** `anchor.at` of the last window-end notice: buttons can leave `done` and come back, and the conversation hears it once. */
116let noticedAt: number | undefined
117const baselines: { five?: Base; week?: Base } = {}
118const warned = new Set<string>()
119const surfaceSeen = new Set<string>()
120/** What the live file says about who this session is (the title from the classic SessionStart, the folder from session.start). */
121let ident: Ident = {}
122let ended = false
123/** The heartbeat of the live file, armed while this session is warming. */
124let beat: Timer | undefined
125/** Why the user-off stop in flight is happening, when it is another session's request. */
126let remoteWhy: string | undefined
127
128export const noteIdentity = (i: Ident): void => {
129 ident = { ...ident, ...i }
130}
131
132export function resetKeeper(): void {
133 ident = {}
134 ended = false
135 beat?.cancel()
136 beat = undefined
137 remoteWhy = undefined
138 resetLive()
139 lastOn = null
140 themeAt = undefined
141 themeNote = 'theme: not read yet'
142 timer?.cancel()
143 timer = undefined
144 viewTimer?.cancel()
145 viewTimer = undefined
146 seenBand = null
147 forking = false
148 schedChain = Promise.resolve()
149 ttlEnv = { authKind: null }
150 saved = DEFAULT_SETTINGS
151 mainSteps.started = 0
152 mainSteps.inFlight = 0
153 learned = {}
154 delete baselines.five
155 delete baselines.week
156 warned.clear()
157 surfaceSeen.clear()
158}
159
160const warn = (io: Io, where: string, err: unknown) => {
161 if (warned.has(where)) return
162 warned.add(where)
163 try {
164 io.debug(`cache-keeper: ${where}: ${errText(err)}`)
165 } catch {
166 // nowhere left to say it
167 }
168}
169
170/** Runs an observer's own part; a throw is logged once and never reaches the engine. */
171export async function safely(io: Io, where: string, fn: () => Promise<unknown>): Promise<void> {
172 try {
173 await fn()
174 } catch (err) {
175 warn(io, where, err)
176 }
177}
178
179export const savedSettings = (): Settings => saved
180export const learnedRates = (): Learned => learned
181
182// ---------------------------------------------------------------------------
183// Snapshot: every piece of state at one moment
184
185export type Snap = {
186 now: number
187 anchor: Anchor | null
188 chain: Chain | null
189 status: Status
190 settings: Settings
191 turn: { id: string; since: number } | null
192 dialog: Dialog | null
193 tasks: Task[]
194 limits: Limits | null
195 contextTokens: number | null
196 costUsd: number | null
197 refreshing: { claimAt: number } | null
198 last: RefreshRecord | null
199 session: Totals
200 resumed: Resumed | null
201 surface: 'terminal' | 'desktop' | null
202 light: boolean
203 sheetOpen: boolean
204 elsewhere: Elsewhere[]
205 /** When warming was last turned on in this session (the keep chip, /cache on, a setting); state, so a reload keeps it. */
206 onAt: number | null
207}
208
209export async function snapshot(io: Io): Promise<Snap> {
210 const [now, anchor, chain, status, settings, turn, dialog, tasks, limits] = await Promise.all([
211 io.now(),
212 io.read(A.anchor),
213 io.read(A.chain),
214 io.read(A.status),
215 io.read(A.settings),
216 io.read(A.turn),
217 io.read(A.dialog),
218 io.read(A.tasks),
219 io.read(A.limits),
220 ])
221 const [contextTokens, costUsd, refreshing, last, session, resumed, surface, light, sheetOpen, elsewhere, onAt] = await Promise.all([
222 io.read(A.contextTokens),
223 io.read(A.costUsd),
224 io.read(A.refreshing),
225 io.read(A.last),
226 io.read(A.session),
227 io.read(A.resumed),
228 io.read(A.surface),
229 io.read(A.light),
230 io.read(A.sheetOpen),
231 io.read(A.elsewhere),
232 io.read(A.onAt),
233 ])
234 return {
235 now, anchor, chain, status, settings, turn, dialog, tasks, limits,
236 contextTokens, costUsd, refreshing, last, session, resumed, surface, light, sheetOpen, elsewhere, onAt,
237 }
238}
239
240const inFlight = (s: Snap) =>
241 forking || (s.refreshing !== null && s.now - s.refreshing.claimAt < CLAIM_STALE_MS)
242
243const ttlInputs = (limits: Limits | null): TtlInputs => ({ ...ttlEnv, limits })
244
245/** Why the TTL is what it is, for /cache status. */
246export const ttlExplain = (s: Snap): string => {
247 const w = ttlWhy(ttlInputs(s.limits))
248 return `${s.anchor?.ttl ?? w.ttl} (${TTL_WHY_TEXT[w.why]})`
249}
250
251export const lineInputOf = (s: Snap): LineInput => {
252 const per = learned.usdPerPoint5h
253 const pct5h =
254 per && s.anchor ? rewriteUsd(s.anchor.model, s.anchor.promptTokens, s.anchor.ttl) / per : undefined
255 return {
256 now: s.now, anchor: s.anchor, chain: s.chain, status: s.status, settings: s.settings,
257 dialog: s.dialog, tasks: s.tasks, limits: s.limits, resumed: s.resumed,
258 ...(pct5h !== undefined ? { pct5h } : {}),
259 }
260}
261
262export const statusLine = (s: Snap): string => {
263 const l = bandLine(lineInputOf(s))
264 return l ? lineText(l) : 'No cache to keep yet (no request in this conversation).'
265}
266
267// ---------------------------------------------------------------------------
268// Usage: context, cost, limits, plus the learned plan-percent rates (§6)
269
270async function learn(io: Io, limits: Limits, cost: number | null): Promise<void> {
271 if (cost === null) return
272 let changed = false
273 const step = (k: 'five' | 'week', pct: number | undefined, field: keyof Learned) => {
274 if (pct === undefined) {
275 delete baselines[k]
276 return
277 }
278 const b = baselines[k]
279 if (!b || pct < b.pct) {
280 // First reading, or the window reset: the next rise is only a partial point, so it re-bases without learning.
281 baselines[k] = { pct, cost, exact: false }
282 return
283 }
284 if (pct === b.pct) return
285 if (b.exact && cost > b.cost) {
286 const rate = (cost - b.cost) / (pct - b.pct)
287 const prev = learned[field]
288 learned = { ...learned, [field]: prev ? 0.3 * rate + 0.7 * prev : rate }
289 changed = true
290 }
291 baselines[k] = { pct, cost, exact: true }
292 }
293 step('five', limits.fiveHour, 'usdPerPoint5h')
294 step('week', limits.weekly, 'usdPerPointWeek')
295 if (changed) void saveLearned(io, learned)
296}
297
298/** Writes context, cost and limits to state. Returns the limits as they were before. */
299async function ingest(
300 io: Io,
301 now: number,
302 rateLimits: readonly { kind: string; percentUsed: number }[],
303 tokens: number | undefined,
304 cost: number | undefined,
305): Promise<{ before: Limits | null; limits: Limits }> {
306 const limits = limitsOf(rateLimits, now)
307 const before = await io.read(A.limits)
308 await Promise.all([
309 put(io, A.limits, limits),
310 put(io, A.contextTokens, tokens ?? null),
311 put(io, A.costUsd, cost ?? null),
312 ])
313 await learn(io, limits, cost ?? null)
314 return { before, limits }
315}
316
317async function readUsage(io: Io) {
318 try {
319 const u = await io.usage()
320 const now = await io.now()
321 const { limits } = await ingest(io, now, u.rateLimits, u.context.tokens, u.cost?.usd)
322 return { costUsd: u.cost?.usd ?? null, limits }
323 } catch (err) {
324 warn(io, 'session.usage', err)
325 return undefined
326 }
327}
328
329export async function onMeasure(
330 io: Io,
331 e: { context: { tokens?: number }; rateLimits: readonly { kind: string; percentUsed: number }[]; cost?: { usd: number } },
332): Promise<void> {
333 const s = await snapshot(io)
334 const { before, limits } = await ingest(io, s.now, e.rateLimits, e.context.tokens, e.cost?.usd)
335 const guard = s.settings.weeklyGuard
336 const crossed = ((before?.weekly ?? -1) >= guard) !== ((limits.weekly ?? -1) >= guard)
337 if (before?.overLimit !== limits.overLimit || crossed) await schedule(io)
338}
339
340// ---------------------------------------------------------------------------
341// Session start and settings
342
343/** Environment values register.tsx reads with literal names (the loader lists them). */
344export type StartEnv = { force?: string; envTtl?: string; enable1h?: string }
345
346export async function start(io: Io, options: Readonly<Record<string, unknown>>, env: StartEnv): Promise<void> {
347 // Saved defaults a /config row refused (see defaults.ts) sit over the manifest options.
348 const defaults = readOptions({ ...options, ...(await keptDefaults(io)) })
349 saved = defaults
350 ended = false
351 const settings = await io.settings().catch(err => {
352 warn(io, 'settings.read', err)
353 return {} as Record<string, unknown>
354 })
355 const authKind = await io.authKind().catch(err => {
356 warn(io, 'session.authorize', err)
357 return null
358 })
359 ttlEnv = {
360 ...(env.force !== undefined ? { force: env.force } : {}),
361 ...(env.envTtl !== undefined ? { envTtl: env.envTtl } : {}),
362 setting: settings.promptCacheTtl,
363 ...(env.enable1h !== undefined ? { enable1h: env.enable1h } : {}),
364 authKind,
365 }
366 const now = await io.now()
367 // The session's settings come from the options only the first time (session.since marks it); a reload keeps
368 // what the session changed.
369 if ((await io.read(A.session)).since === 0) {
370 // A new session never warms on its own: the user turns it on (the band's switch or /cache on), which then uses the saved window.
371 await put(io, A.settings, { ...defaults, keepFor: 'off' })
372 await io.update(A.session, t => ({ ...t, since: now }))
373 }
374 const claim = await io.read(A.refreshing)
375 if (claim && now - claim.claimAt >= CLAIM_STALE_MS) {
376 await put(io, A.refreshing, null)
377 await logLine(io, 'claim-stale', { claimAt: claim.claimAt })
378 }
379 const u = await readUsage(io)
380 learned = await readLearned(io)
381 await applyUpgrade(io, await io.read(A.settings), u?.limits ?? null)
382 await schedule(io)
383 await refreshTheme(io)
384 // A "Save as default" that is still writing: each config.set reloads this module, which lands here again.
385 await drainSave(io, defaults, false)
386}
387
388/** When the theme was last looked at, and what it said (`/cache debug`): module variables, lost on a reload. */
389let themeAt: number | undefined
390let themeNote = 'theme: not read yet'
391/** The render path looks at the theme no more often than this. */
392export const THEME_EVERY_MS = 15_000
393const APP_CONFIG = 'Library/Application Support/Claude/config.json'
394
395/**
396 * The app's appearance into state (`light`), so the band redraws when it changes: the `theme` row, else (a terminal: dark), else the desktop app's `userThemeMode`, else
397 * the OS (policy.appearanceOf). The app's file and the OS are read only when the row leaves it open; a read that fails is skipped.
398 */
399export async function refreshTheme(io: Io): Promise<void> {
400 try {
401 themeAt = await io.now()
402 const themeRow = (await io.configRows()).find(r => r.key === 'theme')?.value
403 let appMode: string | null | undefined
404 let os: 'Dark' | 'Light' | null | undefined
405 let how = appearanceOf({ themeRow })
406 // A terminal's background is neither the desktop app's mode nor the OS appearance: `auto` stays dark there (0.1.4), no probes.
407 if (how === undefined && (await io.read(A.surface)) === 'terminal') how = 'dark'
408 if (how === undefined) {
409 appMode = await io.home().then(async h => (h ? (JSON.parse(await io.fsRead(`${h}/${APP_CONFIG}`)) as { userThemeMode?: unknown }).userThemeMode : undefined)).then(v => (typeof v === 'string' ? v : null), () => null)
410 how = appearanceOf({ themeRow, appMode })
411 }
412 if (how === undefined) {
413 os = await io.run(['defaults', 'read', '-g', 'AppleInterfaceStyle']).then(r => (r.exitCode === 0 && r.stdout.trim() === 'Dark' ? 'Dark' : 'Light'), () => null)
414 how = appearanceOf({ themeRow, appMode, os })
415 }
416 themeNote = `theme: ${typeof themeRow === 'string' ? themeRow : '-'} · app: ${appMode ?? '-'} · os: ${os === undefined ? '-' : (os ?? '?')} → ${how}`
417 if ((await io.read(A.light)) !== (how === 'light')) await put(io, A.light, how === 'light')
418 } catch (err) {
419 warn(io, 'config.list', err)
420 }
421}
422
423/** From a draw: a look at the theme, at most once per THEME_EVERY_MS, off the render (the write redraws; a draw never awaits it). */
424export const themeSoon = (io: Io): void =>
425 void io.after(0, () =>
426 void safely(io, 'theme', async () => {
427 const now = await io.now()
428 if (themeAt === undefined || now - themeAt >= THEME_EVERY_MS) await refreshTheme(io)
429 }),
430 )
431
432/** `/cache debug`: how the palette was decided, last time. */
433export const themeLine = (): string => themeNote
434
435/** What the session pays by: an API key (the 1-hour cache is its switch, there is no weekly plan usage), else a subscription. */
436export const sessionKind = (): 'subscription' | 'api-key' => (ttlEnv.authKind === 'api-key' ? 'api-key' : 'subscription')
437
438/** upgradeTo1h: an API-key session with no TTL override asks for the 1h cache (applies from its next request). */
439async function applyUpgrade(io: Io, s: Settings, limits: Limits | null): Promise<void> {
440 if (s.upgradeTo1h && ttlWhy(ttlInputs(limits)).why === 'default' && ttlEnv.authKind === 'api-key') {
441 await io.setTtlEnv('1h')
442 ttlEnv = { ...ttlEnv, envTtl: '1h' }
443 await put(io, A.ttlSet, true)
444 }
445}
446
447export async function setSettings(io: Io, patch: Partial<Settings>): Promise<void> {
448 const wasOff = (await io.read(A.settings)).keepFor === 'off'
449 const next = await io.update(A.settings, s => ({ ...s, ...patch }))
450 if (wasOff && next.keepFor !== 'off') await put(io, A.onAt, await io.now())
451 if (patch.upgradeTo1h) await applyUpgrade(io, next, await io.read(A.limits))
452 // Off again: take back the variable the mod set (never one the user set).
453 else if (patch.upgradeTo1h === false && (await io.read(A.ttlSet))) {
454 await io.setTtlEnv(undefined)
455 delete ttlEnv.envTtl
456 await put(io, A.ttlSet, false)
457 }
458 await schedule(io)
459}
460
461/** `/cache on` and the band's On: back to the saved window (8h when the saved default is off). */
462// The window the session had when the switch went off, so On restores what the keep chip chose (lost on a reload: then the saved one).
463let lastOn: KeepFor | null = null
464
465export async function warmingOn(io: Io): Promise<KeepFor> {
466 const keepFor = lastOn ?? (saved.keepFor === 'off' ? '8h' : saved.keepFor)
467 lastOn = null
468 await setSettings(io, { keepFor })
469 const blocked = await guardBlock(io)
470 if (blocked) io.toast(`cache-keeper: ${blocked}`)
471 return keepFor
472}
473
474/** Why warming that was just turned on stopped at once: the weekly guard. Undefined when it is warming. */
475export async function guardBlock(io: Io): Promise<string | undefined> {
476 const s = await snapshot(io)
477 if (s.status.kind !== 'stopped' || s.status.code !== 'weekly-guard') return undefined
478 const used = s.limits?.weekly
479 return `Can't warm: weekly usage${used !== undefined ? ` ${Math.round(used)}%` : ''} is over your ${s.settings.weeklyGuard}% guard. Raise "Stop at weekly usage" in settings to warm anyway.`
480}
481
482/** The band's switch: off stops warming for the session; on goes back to the saved window. */
483export async function toggleWarming(io: Io): Promise<void> {
484 const cur = (await io.read(A.settings)).keepFor
485 if (cur === 'off') return void (await warmingOn(io))
486 lastOn = cur
487 await setSettings(io, { keepFor: 'off' })
488}
489
490/** Remembers the band as drawn, so the gear and /cache settings know whether the sheet fits. */
491export const noteBand = (b: { desktop: boolean; columns: number; maxRows: number }): void => {
492 seenBand = b
493}
494
495export const bandGeo = () => seenBand
496
497export const setSheet = (io: Io, open: boolean): Promise<unknown> => put(io, A.sheetOpen, open)
498
499/**
500 * Opens the settings where they fit: the in-band sheet when the band is drawn on the desktop in full style with the rows
501 * for it (`geo` is the band the press came from, else the last one drawn), else the Pane. Returns where, or 'none' when
502 * the Pane has no room either.
503 */
504export async function openSettings(io: Io, geo?: { desktop: boolean; columns: number; maxRows: number }): Promise<'sheet' | 'pane' | 'none'> {
505 const g = geo ?? seenBand
506 if (g?.desktop && (await io.read(A.settings)).band === 'full' && sheetFits(g.maxRows, g.columns)) {
507 await setSheet(io, true)
508 return 'sheet'
509 }
510 return (await io.openPane()) ? 'pane' : 'none'
511}
512
513export async function setSurface(io: Io, surface: 'terminal' | 'desktop'): Promise<void> {
514 if (surfaceSeen.has(surface)) return
515 surfaceSeen.add(surface)
516 await put(io, A.surface, surface)
517}
518
519// ---------------------------------------------------------------------------
520// Scheduler: the only place that arms the timer
521
522/** Stops that hold until the next main request; user-off and weekly-guard are re-decided every time. */
523const REEVALUATED = new Set<StopCode>(['user-off', 'weekly-guard'])
524
525export function schedule(io: Io): Promise<void> {
526 const run = schedChain.then(() => scheduleNow(io)).then(() => armView(io)).then(() => publish(io)).catch(err => warn(io, 'schedule', err))
527 schedChain = run
528 return run as Promise<void>
529}
530
531/** This session is keeping its cache warm: on, with a chain to keep, and refreshes planned or running (a held or finished one is not). The band's warm look, and what the live file calls `warming`. */
532export const isKept = (s: Snap): boolean =>
533 s.settings.keepFor !== 'off' && s.anchor !== null && s.chain !== null && (s.status.kind === 'scheduled' || s.status.kind === 'waiting' || s.status.kind === 'refreshing' || s.status.kind === 'retrying')
534
535/**
536 * Writes this session's live file from the state as it is when the write's turn comes, and keeps the heartbeat (a write each
537 * HEARTBEAT_MS, which also looks for a stop request) armed exactly while the session is warming. Never throws.
538 */
539export const publish = (io: Io, force = false): Promise<void> =>
540 writeEntry(
541 io,
542 async () => {
543 const s = await snapshot(io)
544 const warming = !ended && isKept(s)
545 if (!warming) {
546 beat?.cancel()
547 beat = undefined
548 } else if (!beat) {
549 beat = io.after(HEARTBEAT_MS, () => {
550 beat = undefined
551 void safely(io, 'heartbeat', async () => {
552 await checkRemoteStop(io)
553 await publish(io, true)
554 })
555 })
556 }
557 // On the desktop, the sidebar's own title names the session best (a terminal session has none).
558 const title = s.surface === 'desktop' ? (await desktopState(io)).title : undefined
559 return entryOf(s, warming, await io.sessionId(), title ? { ...ident, title } : ident)
560 },
561 force,
562 )
563
564/** Another session asked this one to stop after it last turned warming on. */
565async function remoteStopDue(io: Io): Promise<boolean> {
566 const at = await stopAskedAt(io)
567 return at !== undefined && at > ((await io.read(A.onAt)) ?? (await io.read(A.session)).since)
568}
569
570/** Why this session must stop warming now: another session asked, or (on the desktop) the app archived or deleted it. */
571async function stopWhy(io: Io): Promise<string | undefined> {
572 if (await remoteStopDue(io)) return 'stopped from another session'
573 if ((await io.read(A.surface)) !== 'desktop') return undefined
574 const d = await desktopState(io)
575 return d.archived ? 'session archived' : d.gone ? 'session deleted' : undefined
576}
577
578/**
579 * Stops exactly like `/cache off` (so `/cache on` restores the window), as another session asked or because the desktop app archived or
580 * deleted this one: logs the stop with its reason and says so once in the conversation. True when it stopped. Checked right before
581 * every fork and on each heartbeat. Returns the reason when it stopped.
582 */
583export async function checkRemoteStop(io: Io): Promise<string | undefined> {
584 const why = await stopWhy(io)
585 if (why === undefined) return undefined
586 const cur = (await io.read(A.settings)).keepFor
587 if (cur === 'off') return undefined
588 lastOn = cur
589 remoteWhy = why
590 try {
591 await setSettings(io, { keepFor: 'off' })
592 } finally {
593 remoteWhy = undefined
594 }
595 await notice(io, await snapshot(io), why)
596 return why
597}
598
599/**
600 * The band redraws only when its state changes, so the moments its look changes with no state change get one redraw each:
601 * the cache starting to cool (10 minutes left, when nothing is refreshing it) and its expiry. The countdown digits tick
602 * in their own image and need none.
603 */
604async function armView(io: Io): Promise<void> {
605 viewTimer?.cancel()
606 viewTimer = undefined
607 const s = await snapshot(io)
608 if (!s.anchor || !s.chain) return
609 const exp = expiresAt(s.chain, s.anchor.ttl)
610 const kept = isKept(s)
611 // +1 ms: the band is cold only after the expiry, not at it.
612 const at = (kept ? [exp + 1] : [exp - coolMs(s.anchor.ttl), exp + 1]).find(t => t > s.now)
613 if (at === undefined) return
614 viewTimer = io.after(at - s.now, () => {
615 io.invalidate()
616 void armView(io)
617 })
618}
619
620const putStatus = async (io: Io, current: Status, next: Status) => {
621 if (!same(current, next)) await put(io, A.status, next)
622}
623
624async function scheduleNow(io: Io): Promise<void> {
625 const s = await snapshot(io)
626 const { now, status } = s
627 timer?.cancel()
628 timer = undefined
629 if (status.kind === 'stopped' && !REEVALUATED.has(status.code)) return
630 const flying = inFlight(s)
631 const d = decide({
632 now, anchor: s.anchor, chain: s.chain, settings: s.settings,
633 turnOpen: s.turn !== null, tasksAlive: s.tasks.length > 0, limits: s.limits, forkInFlight: flying,
634 })
635 const wasPlanned = status.kind === 'scheduled' || status.kind === 'retrying' || status.kind === 'refreshing'
636 const recheck = (until: number) => {
637 timer = io.after(Math.max(0, until - now), () => void schedule(io))
638 }
639 switch (d.act) {
640 case 'refresh':
641 if (status.kind === 'retrying') {
642 // The backoff owns the clock: a stale chain must not turn into an immediate fork.
643 const retry = status
644 timer = io.after(Math.max(0, retry.nextAt - now), () => void refresh(io, retry.attempt, 'retry', retry.nextAt))
645 return
646 }
647 if (s.anchor && s.chain) void refresh(io, 0, 'due', nextRefreshAt(s.chain, s.anchor.ttl, s.settings.leadMin))
648 return
649 case 'wait':
650 if (!Number.isFinite(d.until)) {
651 await putStatus(io, status, { kind: 'waiting' })
652 return
653 }
654 if (!flying && s.anchor && s.chain)
655 await putStatus(io, status, { kind: 'scheduled', nextAt: nextRefreshAt(s.chain, s.anchor.ttl, s.settings.leadMin) })
656 recheck(d.until)
657 return
658 case 'hold':
659 await putStatus(io, status, { kind: 'holding', reason: d.reason })
660 if (d.reason === 'idle-5m' && s.anchor && s.chain) {
661 const next = nextRefreshAt(s.chain, s.anchor.ttl, s.settings.leadMin)
662 if (next > now) recheck(next)
663 }
664 return
665 case 'done':
666 await finishWindow(io, s)
667 return
668 case 'stop':
669 // A refresh that was never planned (a 5m session, a held one) simply lapses; only a planned one is "missed".
670 if (d.code === 'missed' && !wasPlanned) {
671 // Re-decided stops (user-off, weekly-guard) must not outlive their cause: the cache just lapsed.
672 if (status.kind === 'stopped') await putStatus(io, status, { kind: 'waiting' })
673 return
674 }
675 await stop(io, d.code, d.code === 'user-off' && remoteWhy ? remoteWhy : d.reason, d.coldSince)
676 return
677 }
678}
679
680async function finishWindow(io: Io, s: Snap): Promise<void> {
681 if (s.status.kind === 'done' || !s.anchor || !s.chain) return
682 await put(io, A.status, { kind: 'done', code: 'window', at: s.now })
683 const exp = expiresAt(s.chain, s.anchor.ttl)
684 const span = fmtSpan(windowEnd(s.anchor, s.settings, s.tasks.length > 0) - s.anchor.at)
685 await logLine(io, 'stop', { code: 'window', ...summary(s) })
686 if (noticedAt === s.anchor.at) return
687 noticedAt = s.anchor.at
688 await notice(io, s, `window ended (${span}) · cache expires ${fmtClock(exp)}`)
689}
690
691// ---------------------------------------------------------------------------
692// Stop and the transcript notice
693
694const summary = (s: Snap) => ({
695 refreshes: s.chain?.refreshes ?? 0,
696 costUsd: s.chain?.costUsd ?? 0,
697 keptMs:
698 s.anchor && s.chain ? Math.max(0, Math.min(s.now, expiresAt(s.chain, s.anchor.ttl)) - s.anchor.at) : 0,
699})
700
701const NO_NOTICE = new Set<StopCode>(['compacted', 'cleared', 'session-ended', 'nothing-to-fork', 'user-off'])
702
703async function notice(io: Io, s: Snap, tail: string): Promise<void> {
704 const k = summary(s)
705 const text =
706 `cache-keeper · kept warm ${fmtIn(k.keptMs)} · ${k.refreshes} refresh${k.refreshes === 1 ? '' : 'es'}` +
707 ` · ${fmtUsd(k.costUsd)} · ${tail}`
708 try {
709 await io.append(text)
710 } catch (err) {
711 warn(io, 'session.append', err)
712 }
713}
714
715export async function stop(
716 io: Io,
717 code: StopCode,
718 reason: string,
719 coldSince?: number,
720 forget = false,
721): Promise<void> {
722 timer?.cancel()
723 timer = undefined
724 const s = await snapshot(io)
725 const again = s.status.kind === 'stopped' && s.status.code === code
726 if (!again) {
727 await put(io, A.status, {
728 kind: 'stopped', code, reason, at: s.now, ...(coldSince !== undefined ? { coldSince } : {}),
729 })
730 // A session that starts off (the default) has kept nothing yet: no stop line for it.
731 if (code !== 'user-off' || s.chain) await logLine(io, 'stop', { code, reason, ...summary(s) })
732 }
733 if (forget) await Promise.all([put(io, A.anchor, null), put(io, A.chain, null)])
734 if (!again && !NO_NOTICE.has(code)) await notice(io, s, `stopped: ${reason}`)
735}
736
737// ---------------------------------------------------------------------------
738// Refresh
739
740export type RefreshHow = 'due' | 'retry' | 'now'
741
742const ZERO = { input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 }
743
744/** Returns a one-line account of what happened (used by /cache refresh-now). */
745export async function refresh(
746 io: Io,
747 attempt: number,
748 how: RefreshHow,
749 dueAt?: number,
750): Promise<string> {
751 // Before the claim and the fork: a stop another session asked for is honoured first, so no refresh forks after one it could have seen.
752 const stopped = await checkRemoteStop(io)
753 if (stopped) return `${stopped[0]?.toUpperCase()}${stopped.slice(1)}.`
754 const s = await snapshot(io)
755 if (inFlight(s)) return 'A refresh is already running.'
756 const { anchor, chain } = s
757 const d: Decision = decide({
758 now: s.now, anchor, chain, settings: s.settings,
759 turnOpen: s.turn !== null, tasksAlive: s.tasks.length > 0, limits: s.limits, forkInFlight: false,
760 })
761 const next = anchor && chain ? nextRefreshAt(chain, anchor.ttl, s.settings.leadMin) : 0
762 const ok =
763 d.act === 'refresh' ||
764 (how !== 'due' && d.act === 'wait' && d.until === next) ||
765 (how === 'now' && (d.act === 'done' || (d.act === 'hold' && d.reason === 'idle-5m')))
766 if (!ok || !anchor || !chain) {
767 if (how === 'now') {
768 if (d.act === 'wait' && !Number.isFinite(d.until)) return 'Nothing to refresh yet: no request has been made in this conversation.'
769 if (d.act === 'stop') return `Not refreshing: ${d.reason}.`
770 if (d.act === 'hold') return 'Not refreshing: the context is below the minimum.'
771 return 'Not refreshing now.'
772 }
773 await schedule(io)
774 return 'rescheduled'
775 }
776 // From here to the claim there is no await: two timers cannot both pass the check above.
777 forking = true
778 const stepsAtStart = { started: mainSteps.started, inFlight: mainSteps.inFlight }
779 const dueTime = dueAt ?? s.now
780 let line = ''
781 let retrying = false
782 try {
783 await put(io, A.refreshing, { claimAt: s.now })
784 await put(io, A.status, { kind: 'refreshing', since: s.now, attempt })
785 const before = await readUsage(io)
786 const t0 = await io.now()
787 const phase: RefreshRecord['phase'] = s.dialog ? 'dialog' : s.turn ? 'turn' : s.tasks.length > 0 ? 'tasks' : 'idle'
788 let reply: ModelForkResult
789 try {
790 reply = await io.fork(FORK_PROMPT)
791 } catch (err) {
792 reply = { isAnswered: false, reason: 'api-error', status: null, error: 'unknown', usage: ZERO }
793 warn(io, 'model.fork', err)
794 }
795 const t1 = await io.now()
796 const after = await readUsage(io)
797 const ledger =
798 before?.costUsd != null && after?.costUsd != null ? Math.max(0, after.costUsd - before.costUsd) : null
799 const out = outcomeOf(reply, anchor.promptTokens, attempt)
800 const usage = 'usage' in reply ? reply.usage : ZERO
801 const estUsd = refreshUsd(anchor.model, anchor.promptTokens)
802 // The ledger also took any main request that ran beside the fork (it re-anchored, or is still in flight):
803 // then, or when the rise is far beyond one refresh, book the estimate and record no cost.
804 const overlapped =
805 stepsAtStart.inFlight > 0 || mainSteps.started !== stepsAtStart.started || mainSteps.inFlight > 0 ||
806 (await io.read(A.anchor))?.at !== anchor.at
807 const delta = ledger !== null && (overlapped || ledger > 3 * estUsd) ? null : ledger
808 const result: RefreshRecord['result'] =
809 out.result === 'warmed' ? 'warmed' : out.result === 'cold' ? 'cold' : 'failed'
810 const detail =
811 out.result === 'retry' || out.result === 'stop'
812 ? out.detail
813 : 'isAnswered' in reply && reply.isAnswered
814 ? 'answered'
815 : 'reason' in reply
816 ? reply.reason
817 : undefined
818 const w = (v: number | undefined, k: 'weeklyBefore' | 'weeklyAfter' | 'fiveHourBefore' | 'fiveHourAfter') =>
819 v === undefined ? {} : { [k]: v }
820 const record: RefreshRecord = {
821 at: t0, ms: t1 - t0, lateMs: Math.max(0, t0 - dueTime), attempt, phase, result,
822 ...(detail !== undefined ? { detail } : {}),
823 promptTokens: anchor.promptTokens,
824 cacheRead: usage.cache_read_input_tokens,
825 cacheWrite: usage.cache_creation_input_tokens,
826 input: usage.input_tokens,
827 output: usage.output_tokens,
828 costUsd: delta,
829 estUsd,
830 ...w(before?.limits.weekly, 'weeklyBefore'),
831 ...w(after?.limits.weekly, 'weeklyAfter'),
832 ...w(before?.limits.fiveHour, 'fiveHourBefore'),
833 ...w(after?.limits.fiveHour, 'fiveHourAfter'),
834 }
835 await put(io, A.last, record)
836 await logLine(io, 'refresh', { ...record })
837 const spent = delta ?? (result === 'failed' ? 0 : estUsd)
838 if (result !== 'failed' || spent > 0) {
839 const n = result === 'failed' ? 0 : 1
840 await io.update(A.session, t => ({ ...t, refreshes: t.refreshes + n, costUsd: t.costUsd + spent }))
841 await addAllTime(io, { refreshes: n, costUsd: spent })
842 }
843 line = `${result}${detail && result !== 'warmed' ? ` (${detail})` : ''}: read ${fmtTokens(record.cacheRead)}, ${fmtUsd(spent)}`
844
845 // Judge against the state as it is now: a main request may have landed while the fork ran.
846 const cur = await snapshot(io)
847 const sameAnchor = cur.anchor?.at === anchor.at
848 if (sameAnchor && cur.chain) {
849 if (out.result === 'warmed' || out.result === 'cold') {
850 // max(): a main request that started during the fork already touched the entry later than t0.
851 const touchAt = Math.max(cur.chain.touchAt, t0)
852 const warmed = out.result === 'warmed'
853 await put(io, A.chain, {
854 touchAt,
855 refreshes: cur.chain.refreshes + (warmed ? 1 : 0),
856 costUsd: cur.chain.costUsd + (warmed ? spent : 0),
857 })
858 }
859 if (out.result === 'cold') {
860 await stop(io, 'cold', 'cache was already cold at the refresh: the prompt changed (rewind, MCP, upgrade) or the TTL is shorter than expected')
861 } else if (out.result === 'stop') {
862 await stop(io, out.code, `${out.detail}`)
863 } else if (out.result === 'retry') {
864 const delay = RETRY_DELAYS_MS[Math.min(attempt, RETRY_DELAYS_MS.length - 1)] as number
865 const now = await io.now()
866 if (now + delay < expiresAt(cur.chain, anchor.ttl) - SAFETY_MS) {
867 retrying = true
868 const status: Status = { kind: 'retrying', nextAt: now + delay, attempt: attempt + 1, error: out.detail }
869 await put(io, A.status, status)
870 timer?.cancel()
871 timer = io.after(delay, () => void refresh(io, attempt + 1, 'retry', now + delay))
872 } else {
873 await stop(io, 'api-error', `${out.detail}; no time left to retry`)
874 }
875 }
876 }
877 } catch (err) {
878 warn(io, 'refresh', err)
879 line = `refresh failed: ${errText(err)}`
880 } finally {
881 forking = false
882 await put(io, A.refreshing, null).catch(err => warn(io, 'refreshing', err))
883 }
884 if (!retrying) await schedule(io)
885 return line
886}
887
888export const refreshNow = (io: Io): Promise<string> => refresh(io, 0, 'now')
889
890// ---------------------------------------------------------------------------
891// Main request: anchor, chain, avoided rewrites
892
893export type StepCtx = { at: number; prevTouch: number | null; hadLimits: boolean; ttlAtStart: Ttl }
894
895/** Before a main request: it is a cache touch; returns what stepEnd needs, or undefined for a subagent. */
896export async function stepStart(io: Io, e: { agentId?: string }): Promise<StepCtx | undefined> {
897 if (e.agentId !== undefined) return undefined
898 try {
899 const at = await io.now()
900 const s = await snapshot(io)
901 const ctx: StepCtx = {
902 at, prevTouch: s.chain?.touchAt ?? null,
903 hadLimits: s.limits?.fiveHour !== undefined || s.limits?.weekly !== undefined,
904 ttlAtStart: ttlOf(ttlInputs(s.limits)),
905 }
906 if (s.chain) await io.update(A.chain, c => (c ? { ...c, touchAt: at } : c))
907 await schedule(io)
908 mainSteps.started++
909 mainSteps.inFlight++
910 return ctx
911 } catch (err) {
912 warn(io, 'turn.step', err)
913 return undefined
914 }
915}
916
917/** After a main request. `usage` is the step result's usage (null when no response arrived). */
918export async function stepEnd(
919 io: Io,
920 ctx: StepCtx | undefined,
921 usage: { model: string; input_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number } | null,
922): Promise<void> {
923 if (!ctx) return
924 mainSteps.inFlight--
925 try {
926 if (!usage) {
927 // No response: the entry was not touched, so give back the touch taken at the start.
928 if (ctx.prevTouch !== null)
929 await io.update(A.chain, c => (c && c.touchAt === ctx.at ? { ...c, touchAt: ctx.prevTouch as number } : c))
930 await schedule(io)
931 return
932 }
933 const read_ = usage.cache_read_input_tokens
934 const promptTokens = usage.input_tokens + read_ + usage.cache_creation_input_tokens
935 const s = await snapshot(io)
936 if (
937 s.anchor && s.chain && s.chain.refreshes > 0 &&
938 ctx.at - s.anchor.at > TTL_MS[s.anchor.ttl] &&
939 read_ >= s.anchor.promptTokens / 2
940 ) {
941 // What the rewrite this request did not pay would have cost; the refreshes' own spend is booked as it happens.
942 const gross = rewriteUsd(usage.model, read_, s.anchor.ttl)
943 await io.update(A.session, t => ({ ...t, avoided: t.avoided + 1, grossUsd: t.grossUsd + gross }))
944 await addAllTime(io, { avoided: 1, grossUsd: gross })
945 await logLine(io, 'avoided', { grossUsd: gross, savedUsd: gross - s.chain.costUsd, refreshes: s.chain.refreshes, costUsd: s.chain.costUsd, cacheRead: read_ })
946 }
947 // A subscription's windows only show after its first response: look again so the TTL guess has them.
948 let ttl = ctx.ttlAtStart
949 if (!ctx.hadLimits && ttlEnv.authKind === 'bearer') {
950 const u = await readUsage(io)
951 if (u) ttl = ttlOf(ttlInputs(u.limits))
952 }
953 await Promise.all([
954 put(io, A.anchor, { at: ctx.at, model: usage.model, promptTokens, ttl }),
955 put(io, A.chain, { touchAt: ctx.at, refreshes: 0, costUsd: 0 }),
956 put(io, A.resumed, null),
957 ])
958 // A re-decided stop stays put: resetting it would announce the same stop again after every request.
959 const keep = s.status.kind === 'refreshing' || (s.status.kind === 'stopped' && REEVALUATED.has(s.status.code))
960 if (!keep) await put(io, A.status, { kind: 'waiting' })
961 await schedule(io)
962 } catch (err) {
963 warn(io, 'turn.step', err)
964 }
965}
966
967// ---------------------------------------------------------------------------
968// Turn, dialog, tasks
969
970export async function onTurnStart(io: Io, e: { turnId: string }): Promise<void> {
971 const since = await io.now()
972 await put(io, A.turn, { id: e.turnId, since })
973 // A permission prompt never outlives its turn: one a turn.complete missed (an abort) clears here.
974 const d = await io.read(A.dialog)
975 if (d?.tool.startsWith('permission:')) await put(io, A.dialog, null)
976 await schedule(io)
977}
978
979export async function onTurnComplete(io: Io, e: { agentId?: string }): Promise<void> {
980 if (e.agentId !== undefined) return
981 await put(io, A.turn, null)
982 const d = await io.read(A.dialog)
983 if (d?.tool.startsWith('permission:')) await put(io, A.dialog, null)
984 await schedule(io)
985}
986
987const DIALOG_TOOLS = new Set(['AskUserQuestion', 'ExitPlanMode'])
988export const isDialogTool = (tool: string) => DIALOG_TOOLS.has(tool)
989
990export async function dialogOpen(io: Io, e: { tool: string; tool_use_id: string }): Promise<void> {
991 const since = await io.now()
992 await put(io, A.dialog, { id: e.tool_use_id, tool: e.tool, since })
993}
994
995/** The call resolved: its own dialog closes, and so does a permission prompt shown for this tool. */
996export async function dialogClose(io: Io, e: { tool: string; tool_use_id: string }): Promise<void> {
997 const d = await io.read(A.dialog)
998 if (d && (d.id === e.tool_use_id || d.tool === `permission:${e.tool}`)) await put(io, A.dialog, null)
999}
1000
1001export async function onPermission(io: Io, e: { tool_name: string; agent_id?: string }): Promise<void> {
1002 // A subagent's or a fork's prompt is not a question to the person at the main prompt.
1003 if (e.agent_id !== undefined) return
1004 const since = await io.now()
1005 await put(io, A.dialog, { id: `perm-${since}`, tool: `permission:${e.tool_name}`, since })
1006}
1007
1008/** The whole tool.call hook: observes a main-thread call, returns what `next` returned. */
1009export async function onToolCall<R extends { deny?: unknown; result?: unknown }>(
1010 io: Io,
1011 e: { tool: string; tool_use_id: string; agentId?: string },
1012 input: Record<string, unknown>,
1013 next: () => Promise<R>,
1014): Promise<R> {
1015 // A fork's tool attempt carries an agentId: it is denied and must not touch task or dialog state.
1016 if (e.agentId !== undefined) return next()
1017 if (isDialogTool(e.tool)) await safely(io, 'tool.call', () => dialogOpen(io, e))
1018 try {
1019 const r = await next()
1020 await safely(io, 'tool.call', () => onToolDone(io, e, input, r))
1021 return r
1022 } finally {
1023 await safely(io, 'tool.call', () => dialogClose(io, e))
1024 }
1025}
1026
1027const asString = (v: unknown) => (typeof v === 'string' && v !== '' ? v : undefined)
1028const cut = (s: string, n: number) => (s.length > n ? `${s.slice(0, n - 1)}…` : s)
1029
1030/** After a main-thread tool call returned: Bash/Monitor/Agent that started background work, TaskStop that ended it. */
1031export async function onToolDone(
1032 io: Io,
1033 e: { tool: string },
1034 input: Record<string, unknown>,
1035 r: { deny?: unknown; result?: unknown },
1036): Promise<void> {
1037 if (r.deny !== undefined || typeof r.result !== 'object' || r.result === null) return
1038 const res = r.result as Record<string, unknown>
1039 const now = await io.now()
1040 const desc = cut(asString(input.description) ?? asString(input.command) ?? '', 80)
1041 let add: Task | undefined
1042 let gone: string | undefined
1043 if (e.tool === 'Bash' && input.run_in_background === true) {
1044 const id = asString(res.backgroundTaskId)
1045 if (id) add = { id, kind: 'shell', desc, since: now }
1046 } else if (e.tool === 'Monitor') {
1047 const id = asString(res.taskId)
1048 if (id) add = { id, kind: 'monitor', desc, since: now }
1049 } else if (e.tool === 'Agent' && res.status === 'async_launched') {
1050 const id = asString(res.agentId)
1051 if (id) add = { id, kind: 'agent', desc, since: now }
1052 } else if (e.tool === 'TaskStop') {
1053 gone = asString(res.task_id) ?? asString(input.task_id) ?? asString(input.shell_id)
1054 }
1055 if (!add && !gone) return
1056 const before = await io.read(A.tasks)
1057 const after = add
1058 ? before.some(t => t.id === add.id) ? before : [...before, add]
1059 : before.filter(t => t.id !== gone)
1060 if (after.length === before.length) return
1061 await put(io, A.tasks, after)
1062 await schedule(io)
1063}
1064
1065const removeTasks = async (io: Io, gone: (t: Task) => boolean): Promise<void> => {
1066 const before = await io.read(A.tasks)
1067 const after = before.filter(t => !gone(t))
1068 if (after.length === before.length) return
1069 await put(io, A.tasks, after)
1070 await schedule(io)
1071}
1072
1073export async function onPromptSubmit(io: Io, e: { origin: { kind: string }; text: string }): Promise<void> {
1074 if (e.origin.kind !== 'task-notification') return
1075 const ids = new Set([...e.text.matchAll(/<task-id>([^<]+)<\/task-id>/g)].map(m => (m[1] ?? '').trim()))
1076 if (ids.size > 0) await removeTasks(io, t => ids.has(t.id))
1077}
1078
1079const kindOf = (type: string): Task['kind'] => (type === 'shell' ? 'shell' : type === 'monitor' ? 'monitor' : 'agent')
1080
1081/** The list at a main turn end is authoritative: ours it still names stay, ones it names that we missed are added. */
1082export async function onClassicStop(
1083 io: Io,
1084 e: { background_tasks?: readonly { id: string; type: string; description: string }[] },
1085): Promise<void> {
1086 if (e.background_tasks === undefined) return
1087 const live = e.background_tasks
1088 const before = await io.read(A.tasks)
1089 const now = await io.now()
1090 const kept = before.filter(t => live.some(l => l.id === t.id))
1091 const added = live
1092 .filter(l => !before.some(t => t.id === l.id))
1093 .map((l): Task => ({ id: l.id, kind: kindOf(l.type), desc: cut(l.description, 80), since: now }))
1094 const after = [...kept, ...added]
1095 if (same(after.map(t => t.id), before.map(t => t.id))) return
1096 await put(io, A.tasks, after)
1097 await schedule(io)
1098}
1099
1100export const onSubagentStop = (io: Io, e: { agent_id: string }): Promise<void> =>
1101 removeTasks(io, t => t.id === e.agent_id)
1102
1103// ---------------------------------------------------------------------------
1104// Lifecycle
1105
1106export async function onClassicSessionStart(
1107 io: Io,
1108 e: { source: string; session_title?: string; context_tokens?: number; estimated_cache_write_usd?: number; prompt_cache_likely_expired?: boolean },
1109): Promise<void> {
1110 if (e.session_title) noteIdentity({ title: e.session_title })
1111 if (e.source === 'resume' || e.source === 'fork') {
1112 await put(io, A.resumed, {
1113 tokens: e.context_tokens ?? 0,
1114 ...(e.estimated_cache_write_usd !== undefined ? { estUsd: e.estimated_cache_write_usd } : {}),
1115 cold: e.prompt_cache_likely_expired === true,
1116 })
1117 } else if (e.source === 'clear' || e.source === 'compact') {
1118 timer?.cancel()
1119 timer = undefined
1120 await Promise.all([put(io, A.anchor, null), put(io, A.chain, null), put(io, A.status, { kind: 'waiting' })])
1121 await schedule(io)
1122 }
1123}
1124
1125export const onCompact = (io: Io) => stop(io, 'compacted', 'conversation compacted', undefined, true)
1126export async function onSessionEnd(io: Io, reason: string): Promise<void> {
1127 // Ended before the stop: the live file says warming:false whatever the state is when its write comes. A clear is not the end: the session goes on.
1128 ended = reason !== 'clear'
1129 await (reason === 'clear'
1130 ? stop(io, 'cleared', 'conversation cleared', undefined, true)
1131 : stop(io, 'session-ended', 'session ended', undefined, true))
1132 await publish(io, true)
1133}
1134export const onModelSwitch = (io: Io) => stop(io, 'model-switched', 'model switched', undefined, true)
1135
1136/** A `/config` row of ours changed from the menu: apply it to the session (a reload reseeds nothing). */
1137export async function onConfigSet(io: Io, key: string, value: unknown): Promise<void> {
1138 const field = key.replace(/^cache-keeper\./, '')
1139 const o = readOptions({ [field]: value })
1140 const patch: Partial<Settings> | undefined =
1141 field === 'keepFor' ? { keepFor: o.keepFor }
1142 : field === 'lead' ? { leadMin: o.leadMin }
1143 : field === 'minTokens' ? { minTokens: o.minTokens }
1144 : field === 'weeklyGuard' ? { weeklyGuard: o.weeklyGuard }
1145 : field === 'band' ? { band: o.band }
1146 : field === 'upgradeTo1h' ? { upgradeTo1h: o.upgradeTo1h }
1147 : undefined
1148 if (patch) {
1149 await forgetKept(io, field)
1150 if (patch.keepFor !== undefined) {
1151 saved = { ...saved, keepFor: patch.keepFor }
1152 // A new saved window never turns warming on by itself: a session that is off stays off (On then uses the new window).
1153 if ((await io.read(A.settings)).keepFor === 'off') return
1154 }
1155 await setSettings(io, patch)
1156 }
1157}
1158hooks/io.ts 49 lines1// What the engine glue needs from `$`, as plain functions. The hooks loader follows `$` only into functions
2// declared in the hooks module itself (register.tsx), never across an import, so keeper.ts, log.ts and
3// command.ts take an `Io` that register.tsx builds from each hook's `$` (ioOf) and never see `$` at all.
4import type { ModelForkResult, PluginState, SessionUsage, ShapedValue, Timer } from 'claude-code'
5
6/** The plugin's whole state, as declared in types/index.d.ts (every key `Shaped`; atoms read and take the plain value). */
7export type KeeperState = { [K in keyof PluginState['cache-keeper']]: ShapedValue<PluginState['cache-keeper'][K]> }
8
9export type Io = {
10 now(): Promise<number>
11 after(ms: number, fn: () => void): Timer
12 /** One state value by key (the atoms live in register.tsx). */
13 read<K extends keyof KeeperState>(key: K): Promise<KeeperState[K]>
14 update<K extends keyof KeeperState>(key: K, fn: (v: KeeperState[K]) => KeeperState[K]): Promise<KeeperState[K]>
15 /** env HOME */
16 home(): Promise<string | undefined>
17 sessionId(): Promise<string>
18 /** Sets CLAUDE_CODE_PROMPT_CACHE_TTL for this process, or unsets it. */
19 setTtlEnv(value: '5m' | '1h' | undefined): Promise<void>
20 /** `$.settings.read()` */
21 settings(): Promise<Readonly<Record<string, unknown>>>
22 authKind(): Promise<'bearer' | 'api-key' | null>
23 usage(): Promise<SessionUsage>
24 fork(prompt: string): Promise<ModelForkResult>
25 /** A system notice row: never read by the model. */
26 append(text: string): Promise<void>
27 /** Debug log only. */
28 debug(text: string): void
29 fsExists(path: string): Promise<boolean>
30 fsRead(path: string): Promise<string>
31 fsWrite(path: string, text: string): Promise<void>
32 /** `$.fs.list(path)`: the directory's entries (rejects when it is missing). */
33 fsList(path: string): Promise<readonly { name: string; kind: string; mtimeMs: number }[]>
34 /** `$.process.run(argv)`: rejects when the command cannot start. */
35 run(argv: readonly string[]): Promise<{ exitCode: number; stdout: string }>
36 storeGet(key: string): Promise<unknown>
37 storeSet(key: string, value: unknown): Promise<void>
38 /** `$.config.set`: resolves with the deny reason, or undefined when written (a rejection counts as a deny). */
39 saveConfig(key: string, value: string): Promise<string | undefined>
40 /** `$.config.list()` rows, as `{ key, value }` (the `theme` row and our own `cache-keeper.*` rows). */
41 configRows(): Promise<readonly { key: string; value: unknown }[]>
42 /** `$.ui.open` for our pane; resolves whether it was placed. */
43 openPane(): Promise<boolean>
44 closePane(): Promise<void>
45 /** `$.ui.invalidate('ui.render')`: the bands draw again. */
46 invalidate(): void
47 toast(text: string): void
48}
49hooks/live.ts 288 lines1// The live registry (phase 4): one small file per session in ~/.claude/cache-keeper/live/, written by that session only (so no
2// cross-process race: `$.store` is not used for it), read by the user's other sessions to list who is warming and to ask one to stop.
3// `<id>.json` is the session's LiveEntry; `<id>.stop.json` is another session's request that it stop. $.fs has no delete, so a file
4// stays: a reader skips one that is not warming or is older than STALE_MS (a quit or crashed app stops refreshing anyway).
5import type { Elsewhere, LiveEntry, Status } from '../types'
6import type { Io } from './io'
7import type { Snap } from './keeper'
8import { fmtClock, fmtIn, fmtUsd, windowEnd } from './policy'
9
10export const STALE_MS = 12 * 60_000
11export const HEARTBEAT_MS = 5 * 60_000
12/** A stop request shows as "stopping…" for this long (the target looks on each heartbeat, so a bit over one). */
13export const STOPPING_MS = 6 * 60_000
14
15const errText = (err: unknown) => (err instanceof Error ? err.message : String(err))
16
17export const liveDir = async (io: Io): Promise<string | undefined> => {
18 const home = await io.home()
19 return home ? `${home}/.claude/cache-keeper/live` : undefined
20}
21
22// ---------------------------------------------------------------------------
23// This session's file
24
25export type Ident = { title?: string; cwd?: string }
26
27const oneLine = (t: string | undefined) => (t ?? '').replace(/[\r\n\t]+/g, ' ').trim().slice(0, 60)
28const folderOf = (cwd: string | undefined) => (cwd ?? '').split(/[\\/]/).filter(Boolean).pop() ?? ''
29/** The session title when known (the desktop sidebar's, else the SessionStart one), else the working folder's name, else the first 8 characters of the id. Never prompt text. */
30export const labelOf = (sessionId: string, id: Ident): string => oneLine(id.title) || oneLine(folderOf(id.cwd)) || sessionId.slice(0, 8)
31
32export function entryOf(s: Snap, warming: boolean, sessionId: string, id: Ident): LiveEntry {
33 const end = s.anchor ? windowEnd(s.anchor, s.settings, s.tasks.length > 0) : undefined
34 return {
35 v: 1,
36 sessionId,
37 label: labelOf(sessionId, id),
38 ...(id.cwd ? { cwd: id.cwd } : {}),
39 warming,
40 status: s.status.kind,
41 keepFor: s.settings.keepFor,
42 ...(end !== undefined && Number.isFinite(end) ? { windowEnd: end } : {}),
43 ...(s.status.kind === 'scheduled' || s.status.kind === 'retrying' ? { nextAt: s.status.nextAt } : {}),
44 ...(s.anchor ? { lastPromptAt: s.anchor.at } : {}),
45 ...(s.contextTokens !== null ? { contextTokens: s.contextTokens } : {}),
46 costUsd: s.session.costUsd,
47 seen: s.now,
48 }
49}
50
51// The desktop app's record of this session: each desktop session has a file <app>/claude-code-sessions/<account>/<org>/local_<id>.json
52// (app: `Claude`, or a second profile such as `Claude-Second`) holding its `cliSessionId`, its sidebar `title` (renames included) and
53// `isArchived`. Found once among the newest files, then re-read at most once a minute. A deleted session's file goes away.
54const APP_SUPPORT = 'Library/Application Support'
55export const TITLE_EVERY_MS = 60_000
56export type DesktopState = { title?: string; archived: boolean; gone: boolean }
57let deskFile: { sid: string; path: string } | undefined
58let deskAt = -Infinity
59let desk: DesktopState = { archived: false, gone: false }
60/** Checks in a row that found the session's file missing: two (a minute or more apart) mean it was deleted, not mid-rewrite. */
61let misses = 0
62
63type DeskRecord = { cliSessionId?: unknown; title?: unknown; isArchived?: unknown }
64const fold = (d: DeskRecord): DesktopState => ({
65 ...(typeof d.title === 'string' && d.title.trim() ? { title: d.title } : desk.title !== undefined ? { title: desk.title } : {}),
66 archived: d.isArchived === true,
67 gone: false,
68})
69
70/** This desktop session's sidebar title and whether it was archived or deleted; nothing found means none (a terminal session, another OS). Never throws. */
71export async function desktopState(io: Io): Promise<DesktopState> {
72 const now = await io.now()
73 if (now - deskAt < TITLE_EVERY_MS) return desk
74 deskAt = now
75 try {
76 const [home, sid] = await Promise.all([io.home(), io.sessionId()])
77 if (home === undefined) return desk
78 if (deskFile?.sid === sid) {
79 if (!(await io.fsExists(deskFile.path))) {
80 misses += 1
81 return (desk = { ...desk, gone: misses >= 2 })
82 }
83 misses = 0
84 const d = JSON.parse(await io.fsRead(deskFile.path)) as DeskRecord
85 if (d.cliSessionId === sid) desk = fold(d)
86 return desk
87 }
88 // A /clear gives the same desktop session a new id: look again.
89 deskFile = undefined
90 misses = 0
91 const files: { path: string; mtimeMs: number }[] = []
92 for (const app of await io.fsList(`${home}/${APP_SUPPORT}`).catch(() => [])) {
93 if (app.kind !== 'dir' || !app.name.startsWith('Claude')) continue
94 const root = `${home}/${APP_SUPPORT}/${app.name}/claude-code-sessions`
95 for (const a of await io.fsList(root).catch(() => []))
96 if (a.kind === 'dir')
97 for (const o of await io.fsList(`${root}/${a.name}`))
98 if (o.kind === 'dir')
99 for (const f of await io.fsList(`${root}/${a.name}/${o.name}`))
100 if (/^local_[\w-]+\.json$/.test(f.name)) files.push({ path: `${root}/${a.name}/${o.name}/${f.name}`, mtimeMs: f.mtimeMs })
101 }
102 // ponytail: only the 8 newest are read (the app rewrites a session's file as it is used); a miss tries again a minute later.
103 for (const f of files.sort((x, y) => y.mtimeMs - x.mtimeMs).slice(0, 8)) {
104 const d = JSON.parse(await io.fsRead(f.path)) as DeskRecord
105 if (d.cliSessionId !== sid) continue
106 deskFile = { sid, path: f.path }
107 return (desk = fold(d))
108 }
109 } catch (err) {
110 io.debug(`cache-keeper: desktop record failed: ${errText(err)}`)
111 }
112 return desk
113}
114
115let writeChain: Promise<unknown> = Promise.resolve()
116let lastSig = ''
117let lastAt = 0
118let failShown = false
119let drawn = false
120
121/** Forget in-memory state (a fresh load, or a test). */
122export function resetLive(): void {
123 writeChain = Promise.resolve()
124 lastSig = ''
125 lastAt = 0
126 failShown = false
127 drawn = false
128 asked.clear()
129 deskFile = undefined
130 deskAt = -Infinity
131 desk = { archived: false, gone: false }
132 misses = 0
133}
134
135/** A band or pane has been drawn: the minute scan starts to matter. True the first time. */
136export const noteDrawn = (): boolean => {
137 const first = !drawn
138 drawn = true
139 return first
140}
141export const isDrawn = (): boolean => drawn
142
143/**
144 * Writes this session's file, one write at a time like logLine; never throws. `build` runs when the write's turn comes, so the file
145 * holds the latest state. A write that changes nothing (but `seen`) is skipped unless `force` (the heartbeat) or a heartbeat is due.
146 */
147export function writeEntry(io: Io, build: () => Promise<LiveEntry>, force = false): Promise<void> {
148 const run = async () => {
149 const dir = await liveDir(io)
150 if (dir === undefined) return
151 const entry = await build()
152 const { seen, ...rest } = entry
153 const sig = JSON.stringify(rest)
154 if (!force && sig === lastSig && seen - lastAt < HEARTBEAT_MS) return
155 await io.fsWrite(`${dir}/${entry.sessionId}.json`, JSON.stringify(entry))
156 lastSig = sig
157 lastAt = seen
158 }
159 const done = writeChain.then(run).catch(err => {
160 if (failShown) return
161 failShown = true
162 io.debug(`cache-keeper: live write failed: ${errText(err)}`)
163 })
164 writeChain = done
165 return done as Promise<void>
166}
167
168// ---------------------------------------------------------------------------
169// Reading the others
170
171/** Most recent first, then by label. */
172const byRecent = (a: Elsewhere, b: Elsewhere) => b.seen - a.seen || a.label.localeCompare(b.label)
173
174const num = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) ? v : undefined)
175
176/** Sessions asked to stop, and when: their row says "stopping…" until their file says they stopped (or STOPPING_MS passes). */
177const asked = new Map<string, number>()
178
179/** The other sessions that are warming now, as their files say. Never throws; an unreadable or odd file is skipped. */
180export async function scan(io: Io): Promise<Elsewhere[]> {
181 try {
182 const dir = await liveDir(io)
183 if (dir === undefined) return []
184 const [now, own] = await Promise.all([io.now(), io.sessionId()])
185 const files = await io.fsList(dir).catch(() => [])
186 const out: Elsewhere[] = []
187 for (const f of files) {
188 // `<id>.json` only: a stop request has a second dot, and the id becomes part of a path.
189 const id = /^([\w-]+)\.json$/.exec(f.name)?.[1]
190 if (id === undefined || id === own || now - f.mtimeMs >= STALE_MS) continue
191 try {
192 const e = JSON.parse(await io.fsRead(`${dir}/${f.name}`)) as Partial<LiveEntry>
193 const seen = num(e.seen)
194 if (e.v !== 1 || e.warming !== true || seen === undefined || now - seen >= STALE_MS) continue
195 const at = asked.get(id)
196 const [windowEnd_, nextAt, lastPromptAt] = [num(e.windowEnd), num(e.nextAt), num(e.lastPromptAt)]
197 out.push({
198 sessionId: id,
199 label: oneLine(typeof e.label === 'string' ? e.label : '') || id.slice(0, 8),
200 ...(typeof e.cwd === 'string' ? { cwd: e.cwd } : {}),
201 status: e.status ?? 'waiting',
202 ...(windowEnd_ !== undefined ? { windowEnd: windowEnd_ } : {}),
203 ...(nextAt !== undefined ? { nextAt } : {}),
204 ...(lastPromptAt !== undefined ? { lastPromptAt } : {}),
205 costUsd: num(e.costUsd) ?? 0,
206 seen,
207 ...(at !== undefined && now - at < STOPPING_MS ? { stopping: true } : {}),
208 })
209 } catch {
210 // half-written or not ours: skip
211 }
212 }
213 return out.sort(byRecent)
214 } catch (err) {
215 io.debug(`cache-keeper: live scan failed: ${errText(err)}`)
216 return []
217 }
218}
219
220/** Scans and puts the list in the `elsewhere` atom, only when it changed (a write redraws the band and the pane). Returns the list. */
221export async function rescan(io: Io): Promise<Elsewhere[]> {
222 const list = await scan(io)
223 if (JSON.stringify(list) !== JSON.stringify(await io.read('elsewhere'))) await io.update('elsewhere', () => list)
224 return list
225}
226
227// ---------------------------------------------------------------------------
228// Stop requests
229
230/** Asks each of `targets` to stop: writes `<id>.stop.json` (this session is its only writer at that moment). Returns the ones written. */
231export async function requestStop(io: Io, targets: readonly Elsewhere[]): Promise<Elsewhere[]> {
232 const dir = await liveDir(io)
233 if (dir === undefined) return []
234 const [now, by] = await Promise.all([io.now(), io.sessionId()])
235 const done: Elsewhere[] = []
236 for (const t of targets) {
237 try {
238 await io.fsWrite(`${dir}/${t.sessionId}.stop.json`, JSON.stringify({ at: now, by }))
239 asked.set(t.sessionId, now)
240 done.push(t)
241 } catch (err) {
242 io.debug(`cache-keeper: stop request failed: ${errText(err)}`)
243 }
244 }
245 await rescan(io)
246 return done
247}
248
249/** When another session last asked this one to stop; undefined when nobody did (or the file is unreadable). */
250export async function stopAskedAt(io: Io): Promise<number | undefined> {
251 try {
252 const dir = await liveDir(io)
253 if (dir === undefined) return undefined
254 const path = `${dir}/${await io.sessionId()}.stop.json`
255 if (!(await io.fsExists(path))) return undefined
256 return num((JSON.parse(await io.fsRead(path)) as { at?: unknown }).at)
257 } catch {
258 return undefined
259 }
260}
261
262// ---------------------------------------------------------------------------
263// Rows (the /cache sessions list and the pane)
264
265const STATUS_WORD: Record<Status['kind'], string> = {
266 scheduled: 'warm', waiting: 'warm', refreshing: 'refreshing', retrying: 'retrying', holding: 'holding', done: 'done', stopped: 'stopped',
267}
268
269/** `3h10m` as `3h 10m`. */
270const spaced = (ms: number) => fmtIn(ms).replace(/^(\d+h)(\d)/, '$1 $2')
271
272/** One session as a line: label, folder, state, how long it is kept, the next refresh, how long since its last prompt, what its refreshes cost. */
273export function rowText(e: Elsewhere, now: number): string {
274 const folder = folderOf(e.cwd)
275 return [
276 e.label,
277 folder !== '' && folder !== e.label ? folder : '',
278 STATUS_WORD[e.status],
279 e.windowEnd !== undefined ? `warm until ${fmtClock(e.windowEnd)}` : 'no end',
280 e.nextAt !== undefined ? `next refresh ${fmtClock(e.nextAt)}` : '',
281 e.lastPromptAt !== undefined ? `idle ${spaced(now - e.lastPromptAt)}` : '',
282 `refreshes ${fmtUsd(e.costUsd)}`,
283 e.stopping ? 'stopping…' : '',
284 ].filter(Boolean).join(' · ')
285}
286
287export const elsewhereTitle = (n: number): string => `Warming in ${n} other session${n === 1 ? '' : 's'}`
288hooks/log.ts 134 lines1// cache-keeper log (design.md §11): a JSONL file per session, and the $.store totals.
2// $.fs has no append, so the text stays in memory and is written whole, one write at a time
3// (serialized chain adapted from paulbkim-dev/claude-code-cache-warmer, MIT, (c) Paul B. Kim).
4import type { Totals } from '../types'
5import type { Io } from './io'
6
7export const MAX_LOG_CHARS = 2_000_000
8
9const errText = (err: unknown) => (err instanceof Error ? err.message : String(err))
10
11let logChain: Promise<unknown> = Promise.resolve()
12let logText: string | null = null
13let logPath = ''
14let storeChain: Promise<unknown> = Promise.resolve()
15let failShown = false
16
17/** Forget in-memory state (a fresh load, or a test). */
18export function resetLog(): void {
19 logChain = Promise.resolve()
20 storeChain = Promise.resolve()
21 logText = null
22 logPath = ''
23 failShown = false
24}
25
26const fail = (io: Io, what: string, err: unknown) => {
27 if (failShown) return
28 failShown = true
29 io.debug(`cache-keeper: ${what} failed: ${errText(err)}`)
30}
31
32/** Over `max` characters: drop the oldest half of the lines. */
33export function capText(text: string, max = MAX_LOG_CHARS): string {
34 if (text.length <= max) return text
35 const lines = text.split('\n').filter(Boolean)
36 return `${lines.slice(lines.length >> 1).join('\n')}\n`
37}
38
39export async function logPathOf(io: Io): Promise<string | undefined> {
40 const home = await io.home()
41 return home ? `${home}/.claude/cache-keeper/${await io.sessionId()}.jsonl` : undefined
42}
43
44/** Appends one JSON line; never throws; resolves once written. */
45export function logLine(io: Io, ev: string, fields: Record<string, unknown> = {}): Promise<void> {
46 const run = async () => {
47 const path = await logPathOf(io)
48 if (path === undefined) return
49 const t = await io.now()
50 const line = JSON.stringify({ t, iso: new Date(t).toISOString(), ev, ...fields })
51 if (logText === null || logPath !== path) {
52 logText = (await io.fsExists(path)) ? await io.fsRead(path) : ''
53 logPath = path
54 }
55 logText = capText(`${logText}${line}\n`)
56 await io.fsWrite(path, logText)
57 }
58 const done = logChain.then(run).catch(err => fail(io, 'log write', err))
59 logChain = done
60 return done as Promise<void>
61}
62
63/** The last `n` lines of this session's log, oldest first. */
64export async function logTail(io: Io, n: number): Promise<string[]> {
65 await logChain
66 try {
67 const path = await logPathOf(io)
68 if (path === undefined || !(await io.fsExists(path))) return []
69 return (await io.fsRead(path)).split('\n').filter(Boolean).slice(-n)
70 } catch (err) {
71 fail(io, 'log read', err)
72 return []
73 }
74}
75
76// ---------------------------------------------------------------------------
77// $.store: all-time totals and the learned plan-percent rates, each read-modify-write through one chain.
78
79const emptyTotals = (since: number): Totals => ({ refreshes: 0, costUsd: 0, avoided: 0, grossUsd: 0, since })
80const num = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) ? v : 0)
81
82export async function readAllTime(io: Io): Promise<Totals> {
83 const v = (await io.storeGet('allTime')) as (Partial<Totals> & { savedUsd?: number }) | undefined
84 if (typeof v !== 'object' || v === null) return emptyTotals(await io.now())
85 const costUsd = num(v.costUsd)
86 return {
87 refreshes: num(v.refreshes),
88 costUsd,
89 avoided: num(v.avoided),
90 // Totals written before grossUsd had a net `savedUsd`: carry it over as gross = net + cost, so the net stays what it was.
91 grossUsd: v.grossUsd !== undefined ? num(v.grossUsd) : num(v.savedUsd) + costUsd,
92 since: num(v.since) || (await io.now()),
93 }
94}
95
96/** Adds `delta` to the all-time totals. Never throws. */
97export function addAllTime(io: Io, delta: Partial<Omit<Totals, 'since'>>): Promise<void> {
98 const run = async () => {
99 const t = await readAllTime(io)
100 await io.storeSet('allTime', {
101 ...t,
102 refreshes: t.refreshes + (delta.refreshes ?? 0),
103 costUsd: t.costUsd + (delta.costUsd ?? 0),
104 avoided: t.avoided + (delta.avoided ?? 0),
105 grossUsd: t.grossUsd + (delta.grossUsd ?? 0),
106 })
107 }
108 const done = storeChain.then(run).catch(err => fail(io, 'all-time save', err))
109 storeChain = done
110 return done as Promise<void>
111}
112
113export type Learned = { usdPerPoint5h?: number; usdPerPointWeek?: number }
114
115export async function readLearned(io: Io): Promise<Learned> {
116 try {
117 const v = (await io.storeGet('learned')) as Learned | undefined
118 if (typeof v !== 'object' || v === null) return {}
119 return {
120 ...(num(v.usdPerPoint5h) > 0 ? { usdPerPoint5h: num(v.usdPerPoint5h) } : {}),
121 ...(num(v.usdPerPointWeek) > 0 ? { usdPerPointWeek: num(v.usdPerPointWeek) } : {}),
122 }
123 } catch (err) {
124 fail(io, 'learned read', err)
125 return {}
126 }
127}
128
129export function saveLearned(io: Io, learned: Learned): Promise<void> {
130 const done = storeChain.then(() => io.storeSet('learned', learned)).catch(err => fail(io, 'learned save', err))
131 storeChain = done
132 return done as Promise<void>
133}
134hooks/pane.tsx 163 lines1// cache-keeper settings pane (design.md §10, brief C, 2c): one savings line, one Select per setting (the band style is /config's alone; each applied to the
2// session at once), and a footer; Save as default writes them to the /config rows (defaults.ts). The status line and the
3// totals are in /cache status and /cache stats.
4// The ui.render hook (component Pane, requestId 'cache-keeper') lives in register.tsx; this file builds the model and the tree.
5import type { Elements } from 'claude-code'
6
7import type { Elsewhere, KeepFor, Settings } from '../types'
8import { changes, saveAsDefault } from './defaults'
9import type { Io } from './io'
10import { elsewhereTitle, requestStop, rowText } from './live'
11import { safely, savedSettings, sessionKind, setSettings, snapshot } from './keeper'
12import { readAllTime } from './log'
13import { OPTIONS, savingsOf } from './policy'
14import type { Savings } from './policy'
15import { NAMES } from './view'
16import type { SessionKind } from './view'
17
18export type PaneModel = {
19 settings: Settings
20 /** What the session pays by: the sheet shows the weekly guard or the 1-hour switch, not both. */
21 kind: SessionKind
22 /** The session differs from the saved defaults in something that can be saved. */
23 dirty: boolean
24 /** The savings line: what the refreshes saved, overall and this session. */
25 savings: Savings
26 /** The user's other warming sessions (the "Warming elsewhere" section), and the time their idle spans count from. */
27 elsewhere: Elsewhere[]
28 now: number
29}
30
31export async function paneModel(io: Io): Promise<PaneModel> {
32 const s = await snapshot(io)
33 const all = await readAllTime(io)
34 return {
35 settings: s.settings,
36 kind: sessionKind(),
37 dirty: Object.keys(changes(s.settings, savedSettings())).length > 0,
38 savings: savingsOf(all, s.session),
39 elsewhere: s.elsewhere,
40 now: s.now,
41 }
42}
43
44export type Row = {
45 key: string
46 label: string
47 options: readonly { value: string; label: string }[]
48 value: (s: Settings) => string
49 patch: (v: string) => Partial<Settings>
50}
51const opt = (values: readonly (string | number)[], label: (v: string) => string) =>
52 values.map(v => ({ value: String(v), label: label(String(v)) }))
53
54export const ROWS: readonly Row[] = [
55 {
56 key: 'ck-keepFor', label: NAMES.keepFor,
57 options: opt(OPTIONS.keepFor, v => (v === 'off' ? 'Off' : v === 'forever' ? 'No end' : v)),
58 value: s => s.keepFor,
59 patch: v => ({ keepFor: v as KeepFor }),
60 },
61 {
62 key: 'ck-lead', label: NAMES.lead,
63 options: opt(OPTIONS.lead, v => `${v} min`),
64 value: s => String(s.leadMin),
65 patch: v => ({ leadMin: Number(v) }),
66 },
67 {
68 key: 'ck-minTokens', label: NAMES.minTokens,
69 options: opt(OPTIONS.minTokens, v => `${Number(v) / 1000}k`),
70 value: s => String(s.minTokens),
71 patch: v => ({ minTokens: Number(v) }),
72 },
73 {
74 key: 'ck-weeklyGuard', label: NAMES.weeklyGuard,
75 options: opt(OPTIONS.weeklyGuard, v => `${v}%`),
76 value: s => String(s.weeklyGuard),
77 patch: v => ({ weeklyGuard: Number(v) }),
78 },
79 {
80 key: 'ck-upgradeTo1h', label: NAMES.api,
81 options: opt(['off', 'on'], v => (v === 'on' ? '1-hour cache' : 'off')),
82 value: s => (s.upgradeTo1h ? 'on' : 'off'),
83 patch: v => ({ upgradeTo1h: v === 'on' }),
84 },
85]
86
87/** A change applies to the session at once (reschedules); Save as default and Close are presses. */
88export const paneActions = (io: Io, onClose: () => Promise<unknown> = () => io.closePane()) => ({
89 set: (patch: Partial<Settings>) => void safely(io, 'pane.set', () => setSettings(io, patch)),
90 save: () =>
91 void safely(io, 'pane.save', async () => {
92 const s = await snapshot(io)
93 await saveAsDefault(io, s.settings, savedSettings())
94 }),
95 close: () => void safely(io, 'pane.close', onClose),
96 /** Asks other sessions to stop: their live files get a stop request, and their rows say "stopping…" until they report back. */
97 stop: (targets: readonly Elsewhere[]) => void safely(io, 'pane.stop', () => requestStop(io, targets)),
98})
99
100/** The savings in one line: the figure bold and the session dim, or one dim line until something was avoided. */
101export const savingsLine = (ui: Elements['terminal'] | Elements['desktop'] | Elements['vscode'], m: PaneModel) => {
102 const { Box, Text } = ui
103 return m.savings.has ? (
104 <Box key="ck-savings" flexDirection="row" gap={1}>
105 <Text bold>{m.savings.hero}</Text>
106 <Text dimColor wrap="truncate">{`· ${m.savings.session}`}</Text>
107 </Box>
108 ) : (
109 <Text key="ck-savings" dimColor wrap="wrap">
110 {m.savings.empty}
111 </Text>
112 )
113}
114
115/** The "Warming elsewhere" section: a row per other session with a Stop button (or "stopping…"), and Stop all from two. At most MAX_ROWS rows; /cache sessions has the rest. */
116const MAX_ROWS = 5
117const elsewhereSection = (ui: Elements['terminal'] | Elements['desktop'] | Elements['vscode'], m: PaneModel, act: ReturnType<typeof paneActions>) => {
118 const { Box, Text, Button } = ui
119 if (m.elsewhere.length === 0) return null
120 const live = m.elsewhere.filter(e => !e.stopping)
121 return (
122 <Box key="ck-elsewhere" flexDirection="column">
123 <Text bold>{elsewhereTitle(m.elsewhere.length)}</Text>
124 {m.elsewhere.slice(0, MAX_ROWS).map(e => (
125 <Box key={`ck-else-${e.sessionId}`} flexDirection="row" gap={1}>
126 <Box flexGrow={1} flexShrink={1} minWidth={0}>
127 <Text dimColor={e.stopping === true} wrap="truncate">{rowText(e, m.now)}</Text>
128 </Box>
129 {e.stopping ? null : <Button key={`ck-stop-${e.sessionId}`} label="Stop" onPress={() => act.stop([e])} />}
130 </Box>
131 ))}
132 {m.elsewhere.length > MAX_ROWS ? <Text dimColor>{`+${m.elsewhere.length - MAX_ROWS} more: /cache sessions`}</Text> : null}
133 {m.elsewhere.length >= 2 && live.length > 0 ?<Button key="ck-stop-all" label="Stop all" onPress={() => act.stop(live)} /> : null}
134 </Box>
135 )
136}
137
138export function paneTree(
139 ui: Elements['terminal'] | Elements['desktop'] | Elements['vscode'],
140 m: PaneModel,
141 act: ReturnType<typeof paneActions>,
142) {
143 const { Box, Button, Select } = ui
144 return (
145 <Box flexDirection="column" gap={1}>
146 {savingsLine(ui, m)}
147 {elsewhereSection(ui, m, act)}
148 <Box flexDirection="column">
149 {ROWS.map(r => {
150 // /cache lead 7 puts a value in the session that the row does not list: show it rather than a wrong pick.
151 const cur = r.value(m.settings)
152 const options = r.options.some(o => o.value === cur) ? r.options : [...r.options, { value: cur, label: cur }]
153 return <Select key={r.key} label={r.label} options={options} value={cur} onSelect={v => act.set(r.patch(v))} />
154 })}
155 </Box>
156 <Box flexDirection="row" gap={1}>
157 {m.dirty ? <Button key="ck-save" variant="primary" label="Save as default" hotkey="s" onPress={act.save} /> : null}
158 <Button key="ck-close" label="Close" hotkey="c" onPress={act.close} />
159 </Box>
160 </Box>
161 )
162}
163hooks/policy.ts 588 lines1// cache-keeper policy: pure functions, no `$`, no engine imports except types (design.md §5, §6, §9, §11).
2// The retry-once-then-stop shape of outcomeOf and the "judge by cache read before isAnswered" order are
3// adapted from paulbkim-dev/claude-code-cache-warmer (MIT, (c) Paul B. Kim).
4import type { ModelForkResult } from 'claude-code'
5
6import type { ArtState } from './art'
7
8import type {
9 Anchor,
10 BandStyle,
11 Chain,
12 Dialog,
13 KeepFor,
14 Limits,
15 Resumed,
16 Settings,
17 Status,
18 StopCode,
19 Task,
20 Ttl,
21} from '../types'
22
23// Measured: 1 request, ~91 input, 4 output, read = whole prefix, write 0. Do not change without re-measuring.
24export const FORK_PROMPT =
25 '[cache-keeper] Automated prompt-cache refresh from the cache-keeper plugin, not a message from the user. The conversation above is being handled in the main thread: do not continue it, do not call any tools, and do not deliberate. Reply with only the word: ok'
26
27export const TTL_MS: Record<Ttl, number> = { '5m': 300_000, '1h': 3_600_000 }
28export const KEEP_MS: Record<KeepFor, number> = {
29 off: 0,
30 '2h': 7_200_000,
31 '4h': 14_400_000,
32 '8h': 28_800_000,
33 '12h': 43_200_000,
34 forever: Infinity,
35}
36/** Background tasks extend the window up to 24h from the anchor. */
37export const TASK_CAP_MS = 86_400_000
38/** 5m sessions: warm only while a turn runs, up to 1h from the anchor. */
39export const FIVE_MIN_RUN_CAP_MS = 3_600_000
40/** A refresh must start at least 20 s before expiry. */
41export const SAFETY_MS = 20_000
42export const CLAIM_STALE_MS = 180_000
43export const RETRY_DELAYS_MS = [30_000, 90_000, 180_000]
44/** Re-check delay while a fork is in flight. */
45export const IN_FLIGHT_RECHECK_MS = 15_000
46
47// ---------------------------------------------------------------------------
48// Settings (plugin.json userConfig -> Settings)
49
50export const OPTIONS = {
51 keepFor: ['off', '2h', '4h', '8h', '12h', 'forever'] as const,
52 lead: [2, 3, 5, 10, 15] as const,
53 minTokens: [10_000, 30_000, 50_000, 100_000] as const,
54 weeklyGuard: [50, 75, 80, 90, 95, 100] as const,
55 band: ['full', 'minimal', 'off'] as const,
56}
57
58export const DEFAULT_SETTINGS: Settings = {
59 keepFor: '8h',
60 leadMin: 5,
61 minTokens: 30_000,
62 weeklyGuard: 90,
63 band: 'full',
64 upgradeTo1h: false,
65}
66
67const pick = <T extends string | number>(value: unknown, allowed: readonly T[], dflt: T): T => {
68 const v = typeof value === 'string' && typeof allowed[0] === 'number' ? Number(value) : value
69 return allowed.find(a => a === v) ?? dflt
70}
71
72/** Validating reader of the plugin's userConfig values; a bad or missing value gives the default. */
73export function readOptions(options: Readonly<Record<string, unknown>> | undefined): Settings {
74 const o = options ?? {}
75 const up = o.upgradeTo1h
76 return {
77 keepFor: pick<KeepFor>(o.keepFor, OPTIONS.keepFor, DEFAULT_SETTINGS.keepFor),
78 leadMin: pick<number>(o.lead, OPTIONS.lead, DEFAULT_SETTINGS.leadMin),
79 minTokens: pick<number>(o.minTokens, OPTIONS.minTokens, DEFAULT_SETTINGS.minTokens),
80 weeklyGuard: pick<number>(o.weeklyGuard, OPTIONS.weeklyGuard, DEFAULT_SETTINGS.weeklyGuard),
81 band: pick<BandStyle>(o.band, OPTIONS.band, DEFAULT_SETTINGS.band),
82 upgradeTo1h: up === 'on' || up === true,
83 }
84}
85
86// ---------------------------------------------------------------------------
87// TTL rule (§5)
88
89export type TtlInputs = {
90 /** FORCE_PROMPT_CACHING_5M */
91 force?: string
92 /** CLAUDE_CODE_PROMPT_CACHE_TTL */
93 envTtl?: string
94 /** promptCacheTtl from the settings */
95 setting?: unknown
96 /** ENABLE_PROMPT_CACHING_1H */
97 enable1h?: string
98 authKind: 'bearer' | 'api-key' | null
99 limits: Limits | null
100}
101
102export type TtlWhy = 'forced-5m' | 'env' | 'setting' | 'enable-1h' | 'subscription' | 'over-limit' | 'default'
103
104const truthy = (v: string | undefined) => v === '1' || v?.toLowerCase() === 'true'
105const isTtl = (v: unknown): v is Ttl => v === '5m' || v === '1h'
106
107export function ttlWhy(x: TtlInputs): { ttl: Ttl; why: TtlWhy } {
108 if (truthy(x.force)) return { ttl: '5m', why: 'forced-5m' }
109 if (isTtl(x.envTtl)) return { ttl: x.envTtl, why: 'env' }
110 if (isTtl(x.setting)) return { ttl: x.setting, why: 'setting' }
111 if (truthy(x.enable1h)) return { ttl: '1h', why: 'enable-1h' }
112 const l = x.limits
113 if (x.authKind === 'bearer' && l && (l.fiveHour !== undefined || l.weekly !== undefined))
114 return l.overLimit ? { ttl: '5m', why: 'over-limit' } : { ttl: '1h', why: 'subscription' }
115 return { ttl: '5m', why: 'default' }
116}
117
118export const ttlOf = (x: TtlInputs): Ttl => ttlWhy(x).ttl
119
120export const TTL_WHY_TEXT: Record<TtlWhy, string> = {
121 'forced-5m': 'FORCE_PROMPT_CACHING_5M is set',
122 env: 'CLAUDE_CODE_PROMPT_CACHE_TTL is set',
123 setting: 'promptCacheTtl in settings',
124 'enable-1h': 'ENABLE_PROMPT_CACHING_1H is set',
125 subscription: 'subscription session',
126 'over-limit': 'subscription over its limit',
127 default: 'no subscription seen',
128}
129
130/** Rate-limit windows (SessionUsage.rateLimits) to the contract's Limits. */
131export function limitsOf(rateLimits: readonly { kind: string; percentUsed: number }[], now: number): Limits {
132 const pct = (kind: string) => rateLimits.find(r => r.kind === kind)?.percentUsed
133 const fiveHour = pct('five_hour')
134 const weekly = pct('seven_day')
135 return {
136 ...(fiveHour !== undefined ? { fiveHour } : {}),
137 ...(weekly !== undefined ? { weekly } : {}),
138 overLimit: rateLimits.some(r => r.percentUsed >= 100),
139 at: now,
140 }
141}
142
143// ---------------------------------------------------------------------------
144// Schedule math and decide (§6)
145
146/** 1h: clamp(leadMin, 2, 15) minutes; 5m: 30 s fixed. */
147export const leadMs = (ttl: Ttl, leadMin: number): number =>
148 ttl === '5m' ? 30_000 : Math.min(15, Math.max(2, leadMin)) * 60_000
149
150export const expiresAt = (chain: Chain, ttl: Ttl): number => chain.touchAt + TTL_MS[ttl]
151
152export const nextRefreshAt = (chain: Chain, ttl: Ttl, leadMin: number): number =>
153 expiresAt(chain, ttl) - leadMs(ttl, leadMin)
154
155export function windowEnd(anchor: Anchor, s: Settings, tasksAlive: boolean): number {
156 if (s.keepFor === 'forever') return Infinity
157 const end = anchor.at + KEEP_MS[s.keepFor]
158 return tasksAlive ? Math.max(end, anchor.at + TASK_CAP_MS) : end
159}
160
161export type Decision =
162 | { act: 'refresh' }
163 | { act: 'wait'; until: number }
164 | { act: 'hold'; reason: 'small' | 'idle-5m' }
165 | { act: 'done' }
166 | { act: 'stop'; code: StopCode; reason: string; coldSince?: number }
167
168export function decide(x: {
169 now: number
170 anchor: Anchor | null
171 chain: Chain | null
172 settings: Settings
173 turnOpen: boolean
174 tasksAlive: boolean
175 limits: Limits | null
176 forkInFlight: boolean
177}): Decision {
178 const { now, anchor, chain, settings: s, limits } = x
179 if (s.keepFor === 'off') return { act: 'stop', code: 'user-off', reason: 'off' }
180 if (!anchor || !chain) return { act: 'wait', until: Infinity }
181 if (anchor.promptTokens < s.minTokens) return { act: 'hold', reason: 'small' }
182 if (limits?.weekly !== undefined && limits.weekly >= s.weeklyGuard)
183 return { act: 'stop', code: 'weekly-guard', reason: `weekly ${limits.weekly}% ≥ guard ${s.weeklyGuard}%` }
184 const exp = expiresAt(chain, anchor.ttl)
185 if (now > exp - SAFETY_MS)
186 return { act: 'stop', code: 'missed', reason: 'refresh missed: the cache expired before it ran', coldSince: exp }
187 if (exp >= windowEnd(anchor, s, x.tasksAlive)) return { act: 'done' }
188 if (anchor.ttl === '5m' && !(x.turnOpen && now - anchor.at < FIVE_MIN_RUN_CAP_MS))
189 return { act: 'hold', reason: 'idle-5m' }
190 if (x.forkInFlight) return { act: 'wait', until: now + IN_FLIGHT_RECHECK_MS }
191 const next = nextRefreshAt(chain, anchor.ttl, s.leadMin)
192 if (now < next) return { act: 'wait', until: next }
193 return { act: 'refresh' }
194}
195
196// ---------------------------------------------------------------------------
197// Fork outcome (§6)
198
199export type ForkOutcome =
200 | { result: 'warmed' }
201 | { result: 'cold' }
202 | { result: 'retry'; detail: string }
203 | { result: 'stop'; code: StopCode; detail: string }
204
205const FATAL = new Set([
206 'authentication_failed',
207 'oauth_org_not_allowed',
208 'account_on_hold',
209 'verification_required',
210 'billing_error',
211 'invalid_request',
212 'model_not_found',
213 'cloud_credential_error',
214])
215const TRANSIENT = new Set(['overloaded', 'server_error', 'unknown'])
216
217export function outcomeOf(reply: ModelForkResult, promptTokens: number, attempt: number): ForkOutcome {
218 if (reply.isAnswered === false && reply.reason === 'nothing-to-fork')
219 return { result: 'stop', code: 'nothing-to-fork', detail: 'nothing to fork' }
220 // A read of at least half the prompt counts before isAnswered: an empty-reply that read still touched the entry.
221 if (reply.usage.cache_read_input_tokens >= promptTokens / 2) return { result: 'warmed' }
222 if (reply.isAnswered || reply.reason === 'empty-reply') return { result: 'cold' }
223 const retry = (detail: string): ForkOutcome =>
224 attempt < RETRY_DELAYS_MS.length
225 ? { result: 'retry', detail }
226 : { result: 'stop', code: 'api-error', detail }
227 if (reply.reason === 'aborted')
228 return attempt === 0
229 ? { result: 'retry', detail: 'aborted' }
230 : { result: 'stop', code: 'api-error', detail: 'aborted' }
231 const detail = `${reply.error}${reply.status === null ? '' : ` ${reply.status}`}`
232 if (reply.status === 429 || reply.error === 'rate_limit') return { result: 'stop', code: 'rate-limited', detail }
233 if (FATAL.has(reply.error)) return { result: 'stop', code: 'api-error', detail }
234 if (TRANSIENT.has(reply.error) || reply.status === null) return retry(detail)
235 return { result: 'stop', code: 'api-error', detail }
236}
237
238// ---------------------------------------------------------------------------
239// Pricing (docs/research/docs-pricing.md §4, read 2026-10-13, USD per MTok)
240
241export type Rates = { input: number; cacheRead: number; output: number; isGuess: boolean }
242type Row = readonly [input: number, cacheRead: number, output: number]
243
244export const PRICES_AS_OF = '2026-10-13'
245const PRICES: Record<string, Row> = {
246 'claude-fable-5-1': [10, 0.25, 50],
247 'claude-fable-5': [10, 1, 50],
248 'claude-opus-5-5': [4, 0.2, 20],
249 'claude-opus-5': [5, 0.5, 25],
250 'claude-opus-4-8': [5, 0.5, 25],
251 'claude-opus-4-7': [5, 0.5, 25],
252 'claude-opus-4-6': [5, 0.5, 25],
253 'claude-opus-4-5': [5, 0.5, 25],
254 'claude-opus-4-1': [15, 1.5, 75],
255 'claude-opus-4': [15, 1.5, 75],
256 'claude-sonnet-5-5': [2, 0.1, 10],
257 'claude-sonnet-5': [2, 0.2, 10],
258 'claude-sonnet-4-6': [3, 0.3, 15],
259 'claude-sonnet-4-5': [3, 0.3, 15],
260 'claude-sonnet-4': [3, 0.3, 15],
261 'claude-haiku-4-5': [1, 0.1, 5],
262 'claude-haiku-3-5': [0.8, 0.08, 4],
263}
264const HAIKU_55_LOW: Row = [0.1, 0.01, 0.5]
265const HAIKU_55_HIGH: Row = [0.5, 0.05, 2.5]
266const OPUS_55 = PRICES['claude-opus-5-5'] as Row
267
268/** `claude-opus-5-5[1m]`, `anthropic/claude-opus-5-5-20260101`, `claude-gateway/...` -> `claude-opus-5-5`. */
269export const normalizeModel = (model: string): string =>
270 model
271 .trim()
272 .toLowerCase()
273 .replace(/^(anthropic|claude-gateway)\//, '')
274 .replace(/\[[^\]]*\]$/, '')
275 .replace(/-(\d{8}|latest)$/, '')
276
277/** Unknown models price as Opus 5.5 with `isGuess`. Haiku 5.5 tiers by prompt length (input + read + write). */
278export function ratesOf(model: string, promptTokens = 0): Rates {
279 const id = normalizeModel(model)
280 const row = id === 'claude-haiku-5-5' ? (promptTokens > 100_000 ? HAIKU_55_HIGH : HAIKU_55_LOW) : PRICES[id]
281 const [input, cacheRead, output] = row ?? OPUS_55
282 return { input, cacheRead, output, isGuess: row === undefined }
283}
284
285/** One refresh: read x tokens + 91 input + 5 output (the measured shape of a FORK_PROMPT request). */
286export function refreshUsd(model: string, promptTokens: number): number {
287 const r = ratesOf(model, promptTokens)
288 return (promptTokens * r.cacheRead + 91 * r.input + 5 * r.output) / 1_000_000
289}
290
291/** What the next request pays to write the prefix again: tokens x input x (1h ? 2 : 1.25). */
292export function rewriteUsd(model: string, promptTokens: number, ttl: Ttl): number {
293 const r = ratesOf(model, promptTokens)
294 return (promptTokens * r.input * (ttl === '1h' ? 2 : 1.25)) / 1_000_000
295}
296
297// ---------------------------------------------------------------------------
298// Formatting
299
300export function fmtTokens(n: number): string {
301 if (n < 1000) return String(Math.round(n))
302 if (n < 10_000) return `${Math.round(n / 100) / 10}k`
303 if (n < 1_000_000) return `${Math.round(n / 1000)}k`
304 return `${Math.round(n / 100_000) / 10}M`
305}
306
307export function fmtUsd(v: number): string {
308 if (v < 0) return v > -0.005 ? '$0.00' : `-${fmtUsd(-v)}`
309 if (v > 0 && v < 0.005) return '<$0.01'
310 return v >= 100 ? `$${Math.round(v)}` : `$${v.toFixed(2)}`
311}
312
313/** '45s' | '41m' | '1h05m' */
314export function fmtIn(ms: number): string {
315 const s = Math.round(Math.max(0, ms) / 1000)
316 if (s < 60) return `${s}s`
317 const m = Math.round(s / 60)
318 if (m < 60) return `${m}m`
319 return `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}m`
320}
321
322/** Local date, '2026-10-13'. */
323export function fmtDate(at: number): string {
324 const d = new Date(at)
325 return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
326}
327
328/** Local time of day on a 12-hour clock (the user's choice): '2:05 PM', '6:00 AM'. */
329export function fmtClock(at: number): string {
330 const d = new Date(at)
331 const h = d.getHours() % 12 || 12
332 return `${h}:${String(d.getMinutes()).padStart(2, '0')} ${d.getHours() < 12 ? 'AM' : 'PM'}`
333}
334
335/** '41%', '23.5%' */
336export const fmtPct = (n: number): string => `${Math.round(n * 10) / 10}%`
337
338/** '8h' for whole hours, else fmtIn. */
339export const fmtSpan = (ms: number): string => (ms % 3_600_000 === 0 ? `${ms / 3_600_000}h` : fmtIn(ms))
340
341/** The label a minute-granular countdown shows for `ms` left: '50m', '1h05m', 'now' under half a minute. */
342export function fmtMinutes(ms: number): string {
343 const m = Math.round(Math.max(0, ms) / 60_000)
344 if (m < 1) return ms >= 30_000 ? '1m' : 'now'
345 return m < 60 ? `${m}m` : `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}m`
346}
347
348// ---------------------------------------------------------------------------
349// Savings (brief 2b §4): what the refreshes avoided against what they cost, at API list prices.
350
351export type Money = { refreshes: number; costUsd: number; avoided: number; grossUsd: number }
352
353/** Rewrites avoided minus every refresh's spend: negative until the first avoided rewrite has paid for the refreshes. */
354export const netUsd = (t: Money): number => t.grossUsd - t.costUsd
355
356export type Savings = {
357 /** Something was avoided: the sheet and the pane show `hero` and `session`; otherwise the one dim `empty` line. */
358 has: boolean
359 /** `Saved $12.40`, or `Spent $0.48 so far` while the refreshes have not paid for themselves. */
360 hero: string
361 /** `this session $3.20` */
362 session: string
363 /** The dim line shown instead of the figures until a rewrite has been avoided. */
364 empty: string
365 /** The sum behind the hero (for /cache stats). */
366 detail: string
367 note: string
368}
369
370const plural0 = (n: number, one: string, many = `${one}s`) => `${n} ${n === 1 ? one : many}`
371
372/** One totals row for /cache stats and the pane: the three numbers (rewrites avoided, refreshes' cost, net) and the counts. */
373export const totalsText = (t: Money): string => {
374 const net = netUsd(t)
375 return (
376 `${plural0(t.refreshes, 'refresh', 'refreshes')} · ${plural0(t.avoided, 'rewrite')} avoided (would have cost ${fmtUsd(t.grossUsd)}) · ` +
377 `refreshes cost ${fmtUsd(t.costUsd)} · ${net >= 0 ? `saved ${fmtUsd(net)}` : `net cost ${fmtUsd(-net)}`}`
378 )
379}
380
381export const SAVINGS_NOTE = 'At API list prices. On a Pro/Max plan this is usage, not billing.'
382
383export function savingsOf(all: Money, session: Money): Savings {
384 const net = netUsd(all)
385 return {
386 has: all.avoided > 0,
387 hero: net >= 0 ? `Saved ${fmtUsd(net)}` : `Spent ${fmtUsd(-net)} so far`,
388 session: `this session ${fmtUsd(netUsd(session))}`,
389 empty: 'No savings yet — they show when you return to a warm cache.',
390 detail: `rewrites avoided ${fmtUsd(all.grossUsd)} − refreshes ${fmtUsd(all.costUsd)}`,
391 note: SAVINGS_NOTE,
392 }
393}
394
395/** The window the band's chip cycles through: 2h, 4h, 8h, 12h, no end, and round again (from `off` it starts at 2h). */
396export const KEEP_CYCLE = ['2h', '4h', '8h', '12h', 'forever'] as const
397export const nextKeep = (k: KeepFor): KeepFor => KEEP_CYCLE[(KEEP_CYCLE.findIndex(v => v === k) + 1) % KEEP_CYCLE.length] as KeepFor
398export const keepLabel = (k: KeepFor): string => (k === 'forever' ? '∞' : k)
399
400// ---------------------------------------------------------------------------
401// Band line (brief 2b §2 copy table), shared by the band and /cache status
402
403/** What a line's tone is called is what its art is called (art.ts). */
404export type LineTone = ArtState
405/** How much text the band has room for: from its columns, 90+ full, 60-89 short, under 60 tiny (symbol and controls only). */
406export type Tier = 'full' | 'short' | 'tiny'
407export const tierOf = (columns: number): Tier => (columns >= 90 ? 'full' : columns >= 60 ? 'short' : 'tiny')
408
409/** A text with an optional countdown in it: `pre` `ms` `post`. The desktop draws the countdown as a self-ticking image.
410 * `word` is the state word (a prefix of `pre`, or empty) that the band draws bold. */
411export type Seg = { pre: string; ms: number | null; post: string; word?: string }
412export const segText = (s: Seg, fmt: (ms: number) => string = fmtIn): string =>
413 [s.pre, s.ms === null ? '' : fmt(s.ms), s.post].filter(Boolean).join(' ')
414
415/** `text` is the whole line flat (the terminal, /cache status); `hover` the desktop's card over the line, or null. */
416export type Line = Seg & { symbol: string; tone: LineTone; text: string; hover: Seg | null }
417
418/** The cache with this long left is "cooling": the band warns. */
419export const COOL_MS = 600_000
420/** The cooling alert window: 10 min, or a third of a shorter TTL. */
421export const coolMs = (ttl: Ttl): number => Math.min(COOL_MS, TTL_MS[ttl] / 3)
422
423export type LineInput = {
424 now: number
425 anchor: Anchor | null
426 chain: Chain | null
427 status: Status
428 settings: Settings
429 dialog: Dialog | null
430 tasks: readonly Task[]
431 limits: Limits | null
432 resumed: Resumed | null
433 /** Learned estimate of one rewrite as a percent of the 5-hour window, when known. */
434 pct5h?: number
435}
436
437const plural = (n: number, one: string) => `${n} ${one}${n === 1 ? '' : 's'}`
438
439/** An anchor and a chain only mean something together: a value another version of the code wrote can leave just one. */
440const paired = (x: LineInput): { anchor: Anchor; chain: Chain } | null => (x.anchor && x.chain ? { anchor: x.anchor, chain: x.chain } : null)
441
442/** Cold now? When it went cold and whether a missed refresh (the Mac slept) is why; `since` is null for a resumed session. */
443function coldInfo(x: LineInput): { since: number | null; missed: boolean } | null {
444 const p = paired(x)
445 const { status } = x
446 if (!p) return status.kind !== 'stopped' && x.resumed?.cold ? { since: null, missed: false } : null
447 const exp = expiresAt(p.chain, p.anchor.ttl)
448 if (x.now <= exp || status.kind === 'refreshing') return null
449 const stopped = status.kind === 'stopped'
450 return { since: stopped && status.coldSince !== undefined ? status.coldSince : exp, missed: stopped && status.code === 'missed' }
451}
452
453/** The detail the short cold line leaves out (when it went cold, why), for /cache status and the pane; null when not cold. */
454export function coldDetail(x: LineInput): string | null {
455 const c = coldInfo(x)
456 if (!c) return null
457 if (c.since === null) return 'Resumed session: its cache had already expired'
458 return `${c.missed ? 'Refresh missed while asleep · c' : 'C'}ache cold since ${fmtClock(c.since)}`
459}
460
461/** Stops that are an error or a guard (the band is rose); the rest the person or the conversation caused (dim). */
462const ERROR_STOPS = new Set<StopCode>(['weekly-guard', 'rate-limited', 'api-error', 'missed', 'cold', 'nothing-to-fork'])
463
464/** The short reason a stop gets in the band. */
465export function stopWhy(code: StopCode, s: Settings): string {
466 switch (code) {
467 case 'weekly-guard': return `weekly usage hit the ${s.weeklyGuard}% guard`
468 case 'rate-limited': return 'rate-limited'
469 case 'api-error': return 'API error'
470 case 'missed': return 'refresh missed'
471 case 'cold': return 'prompt changed'
472 case 'model-switched': return 'model switched'
473 case 'compacted': return 'conversation compacted'
474 case 'cleared': return 'conversation cleared'
475 case 'session-ended': return 'session ended'
476 case 'nothing-to-fork': return 'nothing to refresh'
477 case 'window': return 'window ended'
478 case 'user-off': return 'off'
479 }
480}
481
482const OFF_WORDS = ['Auto-refresh off', 'Off', 'off']
483const STOP_WORDS = ['Stopped', 'stopped']
484
485const seg = (pre: string, ms: number | null = null, post = ''): Seg => ({ pre, ms, post })
486
487/** The state word of a segment: the longest of `words` that `pre` starts with (none for the minimal style or a countdown-only text). */
488const withWord = (s: Seg, words: readonly string[]): Seg => {
489 const word = words.filter(w => s.pre.startsWith(w)).sort((a, b) => b.length - a.length)[0]
490 return word ? { ...s, word } : s
491}
492
493/**
494 * The one-line state, or null when there is nothing to say (no anchor, not resumed cold). `tier` picks how much text
495 * (see `tierOf`); `minimal` is the one dim word-only line of the minimal band style (no countdown: nothing redraws it each minute on the desktop). The weekly figure is
496 * never in it (it is in /cache status and the settings sheet).
497 */
498export function bandLine(x: LineInput, style: 'full' | 'minimal' = 'full', tier: Tier = 'full'): Line | null {
499 const { now, status, settings } = x
500 const p = paired(x)
501 const anchor = p?.anchor
502 const chain = p?.chain
503 const exp = p ? expiresAt(p.chain, p.anchor.ttl) : null
504 const warmLeft = exp !== null && exp > now ? exp - now : null
505 const cooling = warmLeft !== null && p !== null && warmLeft <= coolMs(p.anchor.ttl)
506 const min = style === 'minimal'
507 const pick = (full: Seg, short: Seg, tiny: Seg): Seg => (tier === 'full' ? full : tier === 'short' ? short : tiny)
508 const hover: Seg | null =
509 warmLeft !== null && chain
510 ? seg(`Cache expires ${fmtClock(exp as number)} · in`, warmLeft, chain.refreshes > 0 ? `· kept ${chain.refreshes}× (${fmtUsd(chain.costUsd)})` : '')
511 : null
512 const out = (symbol: string, tone: LineTone, full: Seg, short: Seg, tiny: Seg, minimal: string, words: readonly string[]): Line => {
513 const s = min ? seg(minimal) : withWord(pick(full, short, tiny), words)
514 return { symbol, tone, ...s, text: segText(s), hover }
515 }
516
517 if (coldInfo(x)) {
518 const tokens = anchor?.promptTokens ?? x.resumed?.tokens ?? 0
519 const usd = anchor ? rewriteUsd(anchor.model, tokens, anchor.ttl) : (x.resumed?.estUsd ?? rewriteUsd('', tokens, '1h'))
520 const pct = x.pct5h !== undefined ? ` · ~${fmtPct(x.pct5h)} of 5h` : ''
521 return out('❄', 'cold',
522 seg(`Cache cold · next message rewrites ${fmtTokens(tokens)} (~${fmtUsd(usd)})${pct}`),
523 seg(`Cold · rewrite ${fmtTokens(tokens)} ~${fmtUsd(usd)}`), seg('cold'), 'cold', ['Cache cold', 'Cold', 'cold'])
524 }
525 if (settings.keepFor === 'off') {
526 const tone: LineTone = cooling ? 'cooling' : 'off'
527 return warmLeft !== null
528 ? out(cooling ? '◐' : '○', tone, seg('Auto-refresh off · cache expires in', warmLeft), seg('Off · expires', warmLeft), seg('off', warmLeft), 'off', OFF_WORDS)
529 : out('○', tone, seg('Auto-refresh off'), seg('Off'), seg('off'), 'off', OFF_WORDS)
530 }
531 // On, but no message seen yet (a new chat, or the mod started fresh in an old one): say so rather than vanish.
532 const pending = (): Line =>
533 out('○', 'dim', seg('Auto-refresh on · starts with your next message'), seg('On · starts next message'), seg('on'), 'on', ['Auto-refresh on', 'On', 'on'])
534 if (!anchor && status.kind !== 'stopped') return pending()
535 switch (status.kind) {
536 case 'refreshing':
537 return out('◌', 'refreshing', seg('Refreshing…'), seg('Refreshing…'), seg('refresh'), 'refreshing', ['Refreshing', 'refresh'])
538 case 'retrying': {
539 const why = status.error.split(' ')[0] ?? status.error
540 const ms = Math.max(0, status.nextAt - now)
541 return out('◌', 'error', seg(`Refresh failed (${why}) · retrying in`, ms), seg('Failed · retry', ms), seg('retry', ms), 'retrying', ['Refresh failed', 'Failed', 'retry'])
542 }
543 case 'holding':
544 if (status.reason === 'idle-5m')
545 return out('○', 'dim', seg('5-minute cache · refreshed only while Claude works'), seg('5m cache · refreshed while working'), seg('5m cache'), '5m cache', ['5-minute cache', '5m cache'])
546 return out('○', 'dim',
547 seg(`Context ${fmtTokens(anchor?.promptTokens ?? 0)} is below the ${fmtTokens(settings.minTokens)} minimum · not kept warm`),
548 seg(`Context ${fmtTokens(anchor?.promptTokens ?? 0)} · not kept`), seg('not kept'), 'not kept', [`Context ${fmtTokens(anchor?.promptTokens ?? 0)}`, 'not kept'])
549 case 'done': {
550 const span = anchor ? fmtSpan(windowEnd(anchor, settings, x.tasks.length > 0) - anchor.at) : ''
551 const tone: LineTone = cooling ? 'cooling' : 'dim'
552 const doneWords = [`Kept warm for ${span}`, 'Done', 'done']
553 return warmLeft !== null
554 ? out(cooling ? '◐' : '○', tone, seg(`Kept warm for ${span} · auto-refresh ended · cache expires in`, warmLeft), seg('Done · expires', warmLeft), seg('done', warmLeft), 'done', doneWords)
555 : out('○', tone, seg(`Kept warm for ${span} · auto-refresh ended`), seg('Done'), seg('done'), 'done', doneWords)
556 }
557 case 'stopped': {
558 const why = stopWhy(status.code, settings)
559 const tone: LineTone = ERROR_STOPS.has(status.code) ? 'error' : cooling ? 'cooling' : 'dim'
560 return warmLeft !== null
561 ? out('■', tone, seg(`Stopped: ${why} · cache expires in`, warmLeft), seg('Stopped · expires', warmLeft), seg('stopped', warmLeft), 'stopped', STOP_WORDS)
562 : out('■', tone, seg(`Stopped: ${why}`), seg('Stopped'), seg('stopped'), 'stopped', STOP_WORDS)
563 }
564 default: {
565 // scheduled, or waiting with an anchor (a main request just landed)
566 if (!anchor || !chain) return pending()
567 const next = (status.kind === 'scheduled' ? status.nextAt : nextRefreshAt(chain, anchor.ttl, settings.leadMin)) - now
568 const extra = [x.dialog ? '· waiting on your answer' : '', x.tasks.length > 0 ? `· ${plural(x.tasks.length, 'background task')}` : ''].filter(Boolean).join(' ')
569 return out('●', 'warm', seg('Warm · next refresh in', next, extra), seg('Warm · refresh', next), seg('', next), 'warm', ['Warm'])
570 }
571 }
572}
573
574export const lineText = (l: Line): string => `${l.symbol} ${l.text}`
575
576/**
577 * Which palette the band wears. The config row `theme` decides when it says light or dark; under `auto` (or anything else) the desktop
578 * app's own mode (`userThemeMode`) does when it is light or dark; else the OS (`defaults read -g AppleInterfaceStyle`: Dark, or Light
579 * when the key is absent). A probe that could not run (`os: null`) reads as dark. `undefined`: not decided yet, `os` has not been asked.
580 */
581export function appearanceOf(i: { themeRow: unknown; appMode?: string | null; os?: 'Dark' | 'Light' | null }): 'light' | 'dark' | undefined {
582 const row = typeof i.themeRow === 'string' ? i.themeRow : ''
583 if (row.startsWith('light')) return 'light'
584 if (row.startsWith('dark')) return 'dark'
585 if (i.appMode === 'light' || i.appMode === 'dark') return i.appMode
586 return i.os === undefined ? undefined : i.os === 'Light' ? 'light' : 'dark'
587}
588hooks/art.ts 504 lines1// Desktop band art for cache-keeper. Pure module: strings and constants only, no `$`, no engine imports.
2//
3// Techniques adapted from HeyCubit/effortless (MIT, Copyright (c) 2026 HeyCubit, hooks/register.tsx): the
4// `inPhase` phase-lock, the rim glow (blurred halo + crisp inner line), the art recipe (left-transparent
5// gradient + fade mask + grain + sparse particles with staggered CSS keyframes), the 8-tooth cog and the cache clock.
6//
7// How the band uses it: `artLayers(state, { light, nowMs })` returns Svg layers drawn behind the Text/Buttons,
8// in order. 'right' layers are 440x64, bottom-right anchored art ("xMaxYMid slice") that fades out over the left
9// ~45% so the text stays readable; give them an absolute Box `top={-1} bottom={-1} right={0}` so the band
10// (overflow hidden) clips them. 'full' layers are 760x37, stretched over the whole band ("none").
11// Every source is constant per state + theme + `nowMs`: the cold art and the overlays take one `inPhase` style at the end,
12// the ember art (warm, refreshing, cooling) gives each element its own negative delay computed from `nowMs`, so a redraw
13// that restarts the <img> still shows every loop at the right phase. Use a fresh `nowMs` ($.clock.now()) per draw.
14// Also here: the toggle, the cog, the status dot, the savings hero and the self-ticking countdown (`clockSvg`, from
15// effortless's `cacheClockSvg`): one <text> per minute shown by a step-end animation, so the band needs no redraw.
16//
17// Rules the art keeps (effortless's notes): < 20 000 chars each, width/height on the root, no clipPath, no
18// script or event attributes, CSS keyframes only, strokes on stretched layers use non-scaling-stroke.
19
20export type ArtState = 'warm' | 'refreshing' | 'off' | 'cooling' | 'error' | 'cold' | 'dim'
21export type ArtTheme = 'dark' | 'light'
22export type ArtColors = { bg: string; edge: string; accent: string; text: string; dim: string }
23export type ArtLayer = { key: string; svg: string; width: number; height: number; anchor: 'right' | 'full' }
24
25export const ART_STATES: readonly ArtState[] = ['warm', 'refreshing', 'off', 'cooling', 'error', 'cold', 'dim']
26export const ART_RIGHT = { width: 440, height: 64 } as const
27export const ART_FULL = { width: 760, height: 37 } as const
28
29/** Box backgroundColor / borderColor and Text colours. The band picks `dark` or `light` (no theme API in Svg).
30 * warm / refreshing are ember orange, off a neutral grey, cooling an amber-red alert (its text the warning colour),
31 * error a rose, cold the ice the person liked, dim a still grey. */
32export const PALETTE: Record<ArtTheme, Record<ArtState, ArtColors>> = {
33 dark: {
34 warm: { bg: '#1c120c', edge: '#6b3b1d', accent: '#f39a52', text: '#f4e4d6', dim: '#cbb29c' },
35 refreshing: { bg: '#20140a', edge: '#8a5524', accent: '#ffbd5a', text: '#fff0dc', dim: '#dcc7a6' },
36 off: { bg: '#151414', edge: '#2e2a28', accent: '#8d8782', text: '#d6d2ce', dim: '#8d8782' },
37 cooling: { bg: '#1d1010', edge: '#8a3a24', accent: '#f27a4a', text: '#ffcc80', dim: '#c79a80' },
38 error: { bg: '#1d0f14', edge: '#8a2640', accent: '#ff6b88', text: '#ffd5dd', dim: '#c493a0' },
39 cold: { bg: '#0e1820', edge: '#2f5c80', accent: '#7cc4ff', text: '#dcebf7', dim: '#8da8bd' },
40 dim: { bg: '#141416', edge: '#2a2a2f', accent: '#8b8b93', text: '#d4d4d8', dim: '#8b8b93' },
41 },
42 light: {
43 warm: { bg: '#fdf0e4', edge: '#e8bf96', accent: '#b04f08', text: '#3a1f0a', dim: '#654529' },
44 refreshing: { bg: '#fcebd4', edge: '#e5b06c', accent: '#a14a00', text: '#3a1c04', dim: '#654220' },
45 off: { bg: '#f3f1ef', edge: '#d9d4cf', accent: '#6e6862', text: '#2a2724', dim: '#625b55' },
46 cooling: { bg: '#fdebe3', edge: '#e2a387', accent: '#b8431a', text: '#6b2b0a', dim: '#74432f' },
47 error: { bg: '#fdeaee', edge: '#e5a3b3', accent: '#be123c', text: '#4a0f1e', dim: '#703946' },
48 cold: { bg: '#e8f2fb', edge: '#a9c9e4', accent: '#1f6fb2', text: '#14293a', dim: '#4c6a84' },
49 dim: { bg: '#f2f2f4', edge: '#d4d4da', accent: '#6b6b75', text: '#2a2a30', dim: '#686870' },
50 },
51}
52
53/** Art colours per state and theme: three gradient stops with their alphas, the radial's alpha, particles,
54 * grain, and the rim line / halo. Light mode uses deeper inks because white glints vanish on a pale band. */
55type Ink = { c: [string, string, string]; a: [number, number, number]; glow: number; spark: string; grain: string; grainA: number; rim: string; haloA: number }
56const INK: Record<ArtTheme, Record<ArtState, Ink>> = {
57 dark: {
58 warm: { c: ['#c2410c', '#ea7a2a', '#ffb35c'], a: [0.08, 0.2, 0.31], glow: 0.19, spark: '#ffcf8a', grain: '#fff', grainA: 0.05, rim: '#f39a52', haloA: 0.18 },
59 refreshing: { c: ['#d9780f', '#f5a524', '#ffd27a'], a: [0.12, 0.26, 0.4], glow: 0.22, spark: '#fff1c2', grain: '#fff', grainA: 0.06, rim: '#ffc266', haloA: 0.22 },
60 off: { c: ['#8b8580', '#8b8580', '#a39d98'], a: [0.02, 0.04, 0.07], glow: 0.07, spark: '#a39d98', grain: '#fff', grainA: 0.04, rim: '#8d8782', haloA: 0 },
61 cooling: { c: ['#b45309', '#c2410c', '#5b8bd6'], a: [0.1, 0.22, 0.3], glow: 0.18, spark: '#ffb08a', grain: '#fff', grainA: 0.05, rim: '#f27a4a', haloA: 0.22 },
62 error: { c: ['#9f1239', '#e11d48', '#fb7185'], a: [0.1, 0.22, 0.32], glow: 0.16, spark: '#ffb3c1', grain: '#fff', grainA: 0.05, rim: '#ff6b88', haloA: 0.2 },
63 cold: { c: ['#5aa9e6', '#8fd0ff', '#cdeaff'], a: [0.12, 0.28, 0.42], glow: 0.22, spark: '#fff', grain: '#fff', grainA: 0.07, rim: '#cfeeff', haloA: 0.16 },
64 dim: { c: ['#8b8b93', '#8b8b93', '#a0a0a8'], a: [0.03, 0.06, 0.09], glow: 0.05, spark: '#a0a0a8', grain: '#fff', grainA: 0.05, rim: '#8b8b93', haloA: 0 },
65 },
66 light: {
67 warm: { c: ['#ea7a2a', '#e0661a', '#c2540a'], a: [0.08, 0.17, 0.26], glow: 0.14, spark: '#b45309', grain: '#000', grainA: 0.04, rim: '#b45309', haloA: 0.16 },
68 refreshing: { c: ['#f0a030', '#e08a10', '#c26a00'], a: [0.1, 0.22, 0.32], glow: 0.18, spark: '#a14a00', grain: '#000', grainA: 0.05, rim: '#a14a00', haloA: 0.18 },
69 off: { c: ['#6e6862', '#6e6862', '#59534e'], a: [0.02, 0.04, 0.06], glow: 0.06, spark: '#6e6862', grain: '#000', grainA: 0.04, rim: '#6e6862', haloA: 0 },
70 cooling: { c: ['#d97706', '#c2410c', '#3b6fc4'], a: [0.1, 0.2, 0.28], glow: 0.16, spark: '#b8431a', grain: '#000', grainA: 0.05, rim: '#b8431a', haloA: 0.18 },
71 error: { c: ['#be123c', '#e11d48', '#be185d'], a: [0.08, 0.18, 0.28], glow: 0.14, spark: '#be123c', grain: '#000', grainA: 0.05, rim: '#be123c', haloA: 0.16 },
72 cold: { c: ['#5aa9e6', '#4a93d4', '#2b7bbd'], a: [0.1, 0.2, 0.3], glow: 0.18, spark: '#2b7bbd', grain: '#000', grainA: 0.05, rim: '#2b7bbd', haloA: 0.16 },
73 dim: { c: ['#6b6b75', '#6b6b75', '#55555f'], a: [0.03, 0.05, 0.07], glow: 0.04, spark: '#6b6b75', grain: '#000', grainA: 0.04, rim: '#6b6b75', haloA: 0 },
74 },
75}
76
77const NS = 'xmlns="http://www.w3.org/2000/svg"'
78const n1 = (x: number) => String(Math.round(x * 10) / 10)
79
80/** Phase-locks every looping animation in `svg` to `elapsedMs`. The app swaps the band tree on every redraw and a
81 * fresh image restarts its CSS animations; a negative delay puts them back where they were. Adapted from
82 * effortless's `inPhase`, except the delay is `calc(var(--o) - elapsed)`: an element that carries its own
83 * stagger as `style="--o:-4s"` keeps it, so one image can hold several staggered loops. */
84export function inPhase(svg: string, elapsedMs: number): string {
85 const s = (Math.max(0, elapsedMs) / 1000).toFixed(3)
86 return svg.replace('</svg>', `<style>svg *{animation-delay:calc(var(--o,0s) - ${s}s) !important}</style></svg>`)
87}
88
89// ---------- right-anchored art (440x64, viewBox 440x64, bottom-right anchored) ----------
90
91const W = ART_RIGHT.width
92const H = ART_RIGHT.height
93const MID = H / 2 // the band shows the middle ~37px of the 64px art box
94
95/** A branching frost line from (x, y) at `deg`, `len` long; side branches at 55% along. */
96function branch(x: number, y: number, deg: number, len: number, depth: number): string {
97 const r = (deg * Math.PI) / 180
98 const ex = x + len * Math.cos(r)
99 const ey = y + len * Math.sin(r)
100 let d = `M${n1(x)} ${n1(y)}L${n1(ex)} ${n1(ey)}`
101 if (depth > 0) {
102 const bx = x + len * 0.55 * Math.cos(r)
103 const by = y + len * 0.55 * Math.sin(r)
104 d += branch(bx, by, deg - 45, len * 0.45, depth - 1) + branch(bx, by, deg + 45, len * 0.45, depth - 1)
105 }
106 return d
107}
108
109/** A six-armed snowflake, each arm with two side twigs. */
110function flake(cx: number, cy: number, r: number): string {
111 let d = ''
112 for (let k = 0; k < 6; k++) {
113 const a = (k * Math.PI) / 3 - Math.PI / 2
114 const pt = (f: number, off: number, side: number) => {
115 const aa = a + side * off
116 return `${n1(cx + f * Math.cos(aa))} ${n1(cy + f * Math.sin(aa))}`
117 }
118 const tip = `${n1(cx + r * Math.cos(a))} ${n1(cy + r * Math.sin(a))}`
119 d += `M${n1(cx)} ${n1(cy)}L${tip}`
120 for (const f of [0.5, 0.78]) {
121 for (const side of [-1, 1]) {
122 const base = `${n1(cx + r * f * Math.cos(a))} ${n1(cy + r * f * Math.sin(a))}`
123 d += `M${base}L${pt(r * f + r * 0.22, 0.75 - f * 0.4, side)}`
124 }
125 }
126 }
127 return d
128}
129
130/** The window the cold flakes and glints live in, in art x. The 440 px art is right-anchored, so on a 760 px band art x
131 * maps to band x - 320: the button cluster (~230 px) starts at about art x 210 and the shortened cold text ends before
132 * about art x 125, which leaves this ~85 px strip visible between them (a wider band only adds room to its left). */
133export const COLD_MIN_X = 125
134export const COLD_MAX_X = 210
135
136/** The left-fade mask: transparent up to `from`, opaque from `to` (fractions of the 440 px width). */
137const fade = (from: string, to: string) =>
138 `<linearGradient id="fade" x1="0" x2="1"><stop offset="${from}" stop-color="#fff" stop-opacity="0"/><stop offset="${to}" stop-color="#fff" stop-opacity="1"/></linearGradient><mask id="m"><rect width="${W}" height="${H}" fill="url(#fade)"/></mask>`
139
140const motes = (pts: [number, number, number, number][], color: string) =>
141 pts.map(([x, y, r, o]) => `<circle cx="${x}" cy="${y}" r="${r}" fill="${color}" opacity="${o}"/>`).join('')
142
143const rings = (color: string, o: number) =>
144 [58, 92, 126].map((r, i) => `<circle cx="478" cy="${MID}" r="${r}" fill="none" stroke="${color}" stroke-opacity="${n1(o - i * 0.03)}" stroke-width="1"/>`).join('')
145
146/** The ember an off band keeps glowing faintly (static). */
147const OFF_EMBER: Record<ArtTheme, string> = { dark: '#f39a52', light: '#c2540a' }
148
149/** A negative `animation-delay` that puts an element with a `period`-second loop (and its own `offset`) where it would be
150 * `nowMs` after the page started: every loop in the ember art is locked to the clock by its own delay, because a redraw
151 * restarts a CSS animation and `inPhase` would give all of an image's elements the same delay. */
152const lag = (nowMs: number, period: number, offset = 0): string => (-((nowMs / 1000 + offset) % period)).toFixed(3)
153
154/** Embers: x, y, radius, rise period (s), stagger offset (s), sideways drift (px). Each rises 40 px, fading in and out. */
155type Ember = readonly [x: number, y: number, r: number, period: number, offset: number, dx: number]
156const EMBERS: readonly Ember[] = [
157 [262, 54, 1.3, 7.5, 0, -6], [296, 56, 1.0, 9, 2.2, 5], [330, 55, 1.5, 6.5, 4.1, -4], [362, 57, 1.1, 8.5, 1.3, 6],
158 [392, 54, 1.4, 7, 5.6, -5], [418, 56, 1.0, 9.5, 3.2, 4], [346, 58, 0.9, 8, 6.4, 3], [280, 56, 1.2, 10, 7.4, -3],
159]
160/** `speed` shortens every loop (refreshing), `size` grows them. Cooling embers sink instead (`sink` px). */
161function embers(nowMs: number, color: string, o: { n?: number; speed?: number; size?: number; sink?: number } = {}): string {
162 const { n = EMBERS.length, speed = 1, size = 1, sink } = o
163 return EMBERS.slice(0, n)
164 .map(([x, y, r, p, off, dx]) => {
165 const per = p / speed
166 return `<circle class="e" cx="${x}" cy="${y}" r="${n1(r * size)}" fill="${color}" style="animation:${sink === undefined ? 'up' : 'sink'} ${n1(per)}s linear ${lag(nowMs, per, off)}s infinite;--x:${dx}px${sink === undefined ? '' : `;--y:${sink}px`}"/>`
167 })
168 .join('')
169}
170const EMBER_CSS =
171 '.e{opacity:0}@keyframes up{0%{transform:translate(0,0);opacity:0}12%{opacity:.9}65%{opacity:.45}100%{transform:translate(var(--x,0px),-40px);opacity:0}}' +
172 '@keyframes sink{0%{transform:translate(0,0);opacity:0}15%{opacity:.8}100%{transform:translate(var(--x,0px),var(--y,16px));opacity:0}}'
173const BREATHE = (lo: number) => `@keyframes br{0%,100%{opacity:${lo}}50%{opacity:1}}`
174
175/** The sheet's art is a corner glow: it also fades out toward its bottom edge (the band's own art is clipped by the band instead). */
176const VFADE = `<linearGradient id="vf" x1="0" x2="0" y1="0" y2="1"><stop offset=".4" stop-color="#fff" stop-opacity="1"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></linearGradient><mask id="m2"><rect width="${W}" height="${H}" fill="url(#vf)"/></mask>`
177
178/** The art recipe: tinted gradient from the right, radial bloom, grain, then per-state extras, all under the fade mask.
179 * Ember states (warm, refreshing, cooling, error) lock their loops to `nowMs` themselves; cold uses `--o` + `inPhase`. */
180function rightArt(state: ArtState, theme: ArtTheme, strip: boolean, nowMs: number, fadeDown = false): string {
181 const k = INK[theme][state]
182 const [c1, c2, c3] = k.c
183 let css = 'svg{background:transparent;display:block}'
184 let extra = ''
185 let defs = ''
186 // How the heat gradient and bloom breathe: inline, with the phase-locked delay.
187 let br = ''
188 if (state === 'warm') {
189 // A soft heat glow breathing on an 8 s loop, 8 embers rising and fading on 6.5-10 s loops, the left clear.
190 css += BREATHE(0.78) + EMBER_CSS
191 br = ` style="animation:br 8s ease-in-out ${lag(nowMs, 8)}s infinite"`
192 extra = rings(k.rim, 0.1) + embers(nowMs, k.spark, { size: 1.35 })
193 } else if (state === 'refreshing') {
194 // The same palette brighter, embers faster and larger; the golden flare is the sweep overlay (1.6 s).
195 css += EMBER_CSS
196 extra = rings(k.rim, 0.2) + embers(nowMs, k.spark, { speed: 2.2, size: 1.25 })
197 } else if (state === 'off') {
198 // No motion: a faint ember glow at rest.
199 extra = ''
200 } else if (state === 'cooling') {
201 css += BREATHE(0.45) + EMBER_CSS
202 br = ` style="animation:br 2s ease-in-out ${lag(nowMs, 2)}s infinite"`
203 extra = embers(nowMs, k.spark, { n: 4, size: 1.1, sink: 18 })
204 } else if (state === 'error') {
205 css += BREATHE(0.5)
206 br = ` style="animation:br 3s ease-in-out ${lag(nowMs, 3)}s infinite"`
207 extra = motes([[300, 40, 0.9, 0.35], [344, 24, 1, 0.4], [388, 42, 0.9, 0.35], [418, 25, 0.8, 0.4]], k.spark)
208 } else if (state === 'cold') {
209 css +=
210 `.f{transform-box:fill-box;transform-origin:center;animation:spin linear infinite;animation-delay:var(--o,0s)}` +
211 `.fp{fill:none;stroke:${k.rim};stroke-width:.85;stroke-linecap:round;stroke-linejoin:round;opacity:.9}` +
212 `.rv{animation-direction:reverse}` +
213 `.d{animation:drift ease-in-out infinite alternate;animation-delay:var(--o,0s)}` +
214 `.gl{fill:${k.spark};opacity:0;animation:tw 4.5s ease-in-out infinite;animation-delay:var(--o,0s)}` +
215 `.fr{stroke:${k.rim};stroke-width:.6;fill:none;stroke-linecap:round;opacity:.42}` +
216 '.br{animation:br 7s ease-in-out infinite;animation-delay:var(--o,0s)}@keyframes br{0%,100%{opacity:.85}50%{opacity:1}}' +
217 '@keyframes spin{to{transform:rotate(360deg)}}@keyframes drift{from{transform:translate(0,0)}to{transform:translate(var(--x,4px),var(--y,3px))}}' +
218 '@keyframes tw{0%,66%,100%{opacity:0}78%{opacity:.9}}'
219 defs = `<linearGradient id="rime" x1="0" x2="0" y1="0" y2="1"><stop offset="0" stop-color="${c3}" stop-opacity=".2"/><stop offset=".26" stop-color="${c3}" stop-opacity="0"/><stop offset=".74" stop-color="${c3}" stop-opacity="0"/><stop offset="1" stop-color="${c3}" stop-opacity=".2"/></linearGradient>`
220 const frost = [
221 branch(W, 15, 105, 20, 2), branch(W, 15, 140, 24, 2), branch(W, 15, 172, 16, 1),
222 branch(W, 49, -105, 20, 2), branch(W, 49, -140, 24, 2), branch(W, 49, -172, 16, 1),
223 ].join('')
224 const glints = ([[133, 44, 0.9, 0], [141, 26, 0.8, -1.3], [159, 16, 0.8, -2.4], [185, 47, 0.8, -0.7], [204, 29, 0.9, -3.1], [166, 46, 0.8, -1.9]] as const)
225 .map(([x, y, r, o]) => `<circle class="gl" cx="${x}" cy="${y}" r="${r}" style="--o:${o}s"/>`)
226 .join('')
227 // Each flake: an outer drift (sway a few px, 15-22 s, alternating) around an inner spin (11-17 s).
228 const flakes = ([[149, 37, 6.5, 12, 18, -4, '3px', '-3px', false], [173, 25, 7.5, 15, 22, -9, '-3px', '3px', true], [196, 38, 6, 11, 16, -2, '3px', '3px', false]] as const)
229 .map(([x, y, r, spin, drift, o, dx, dy, rev]) =>
230 `<g class="d" style="animation-duration:${drift}s;--o:${o}s;--x:${dx};--y:${dy}"><g class="f${rev ? ' rv' : ''}" style="animation-duration:${spin}s;--o:${n1(o * 1.7)}s"><path class="fp" d="${flake(x, y, r)}"/></g></g>`)
231 .join('')
232 // Without a free strip (a narrow band) the flakes would sit under the text: leave them out, keep the frost.
233 extra = `<rect width="${W}" height="${H}" fill="url(#rime)"/><path class="fr" d="${frost}"/>${strip ? glints + flakes : ''}`
234 }
235 // 'dim' has no extras and no motion.
236 return (
237 `<svg ${NS} width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" preserveAspectRatio="xMaxYMid slice"><style>${css}</style>` +
238 `<defs><linearGradient id="g" x1="0" x2="1"><stop offset=".15" stop-color="${c1}" stop-opacity="0"/><stop offset=".5" stop-color="${c1}" stop-opacity="${k.a[0]}"/><stop offset=".8" stop-color="${c2}" stop-opacity="${k.a[1]}"/><stop offset="1" stop-color="${c3}" stop-opacity="${k.a[2]}"/></linearGradient>` +
239 `<radialGradient id="r" cx="420" cy="${MID}" r="130" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="${state === 'off' ? OFF_EMBER[theme] : c3}" stop-opacity="${k.glow}"/><stop offset="1" stop-color="${state === 'off' ? OFF_EMBER[theme] : c3}" stop-opacity="0"/></radialGradient>` +
240 `<pattern id="grain" width="3" height="3" patternUnits="userSpaceOnUse"><rect width="1" height="1" fill="${k.grain}" fill-opacity="${k.grainA}"/></pattern>${state === 'cold' ? fade('.1', '.28') : fade('.12', '.5')}${defs}${fadeDown ? VFADE : ''}</defs>` +
241 `${fadeDown ? '<g mask="url(#m2)">' : ''}<g mask="url(#m)"><rect${br || ' class="br"'} width="${W}" height="${H}" fill="url(#g)"/><rect${br} width="${W}" height="${H}" fill="url(#r)"/><rect width="${W}" height="${H}" fill="url(#grain)"/>${extra}</g>${fadeDown ? '</g>' : ''}</svg>`
242 )
243}
244
245// ---------- full-width overlays (760x37, stretched: "none", non-scaling strokes) ----------
246
247const FW = ART_FULL.width
248const FH = ART_FULL.height
249
250/** The effortless rim: a blurred 4px halo under a crisp 1.5px line, 1px inside the border all round. */
251const rimShapes = (k: Ink) =>
252 `<rect x="0" y="0" width="${FW}" height="${FH}" rx="8" fill="none" stroke="${k.rim}" stroke-width="4" opacity="${k.haloA}" filter="url(#b)"/>` +
253 `<rect x=".75" y="1.75" width="${FW - 1.5}" height="${FH - 3.5}" rx="8.25" fill="none" stroke="${k.rim}" stroke-width="1.5" vector-effect="non-scaling-stroke"/>`
254
255const fullSvg = (css: string, defs: string, body: string) =>
256 `<svg ${NS} width="${FW}" height="${FH}" viewBox="0 0 ${FW} ${FH}" preserveAspectRatio="none"><style>${css}</style><defs><filter id="b" x="-10%" y="-60%" width="120%" height="220%"><feGaussianBlur stdDeviation="1.5"/></filter>${defs}</defs>${body}</svg>`
257
258/** A rim that breathes between `lo` and `hi` over `seconds`. */
259function breathingRim(state: ArtState, theme: ArtTheme, seconds: number, lo: number, hi: number): string {
260 const css = `.r{opacity:${lo};animation:r ${seconds}s ease-in-out infinite;animation-delay:var(--o,0s)}@keyframes r{0%,100%{opacity:${lo}}50%{opacity:${hi}}}`
261 return fullSvg(css, '', `<g class="r">${rimShapes(INK[theme][state])}</g>`)
262}
263
264/** Refreshing: a soft slanted golden flare crossing the whole width every 1.6 s (moving 0-85% of the loop, then a rest),
265 * over a still rim. */
266function sweepSvg(theme: ArtTheme): string {
267 const k = INK[theme].refreshing
268 const light = theme === 'light'
269 // Light mode: white on a pale band is invisible (1.1:1), so the highlight is the accent at low alpha instead.
270 const core = light ? k.rim : '#ffe9b0'
271 const css =
272 '.s{animation:s 1.6s cubic-bezier(.35,.15,.65,.85) infinite;animation-delay:var(--o,0s)}' +
273 '@keyframes s{0%{transform:translateX(-300px)}85%,100%{transform:translateX(820px)}}'
274 const defs =
275 `<linearGradient id="w" x1="0" x2="1"><stop offset="0" stop-color="${k.rim}" stop-opacity="0"/><stop offset=".5" stop-color="${core}" stop-opacity="${light ? 0.2 : 0.12}"/><stop offset="1" stop-color="${k.rim}" stop-opacity="0"/></linearGradient>` +
276 `<linearGradient id="v" x1="0" x2="1"><stop offset="0" stop-color="${core}" stop-opacity="0"/><stop offset=".5" stop-color="${core}" stop-opacity="${light ? 0.28 : 0.18}"/><stop offset="1" stop-color="${core}" stop-opacity="0"/></linearGradient>`
277 const body =
278 `<g opacity=".6">${rimShapes(k)}</g>` +
279 `<g class="s"><rect x="0" y="-6" width="260" height="${FH + 12}" fill="url(#w)" transform="skewX(-22)"/><rect x="107" y="-6" width="46" height="${FH + 12}" fill="url(#v)" transform="skewX(-22)"/></g>`
280 return fullSvg(css, defs, body)
281}
282
283// ---------- layers ----------
284
285/** The full-width overlay of a state (a rim that breathes, or the refreshing flare), built once per state and theme. */
286const overlays = new Map<string, { key: string; src: string } | null>()
287
288function overlay(state: ArtState, theme: ArtTheme): { key: string; src: string } | null {
289 const id = `${theme}:${state}`
290 const hit = overlays.get(id)
291 if (hit !== undefined) return hit
292 const rim = (seconds: number, lo: number, hi: number) => ({ key: `ck-${state}-rim`, src: breathingRim(state, theme, seconds, lo, hi) })
293 const out =
294 state === 'warm' ? rim(8, 0.4, 0.58)
295 : state === 'refreshing' ? { key: 'ck-refreshing-sweep', src: sweepSvg(theme) }
296 : state === 'cold' ? rim(7, 0.3, 0.7)
297 : state === 'cooling' ? rim(2, 0.3, 0.85)
298 : state === 'error' ? rim(3, 0.25, 0.7)
299 : null // off and dim keep no overlay: still
300 overlays.set(id, out)
301 return out
302}
303
304/**
305 * The layers to draw behind the band for `state`, in paint order. Every looping one is already phase-locked to `nowMs`:
306 * the ember art by its own per-element delays, the cold art and the overlays by `inPhase`.
307 * `strip: false` leaves the cold flakes and glints out (default true): the band has no free strip beside its text.
308 * `fadeDown: true` (the sheet) also fades the right art out toward its bottom edge.
309 */
310export function artLayers(state: ArtState, o: { light: boolean; nowMs: number; strip?: boolean; fadeDown?: boolean }): ArtLayer[] {
311 const theme: ArtTheme = o.light ? 'light' : 'dark'
312 const right = rightArt(state, theme, o.strip ?? true, o.nowMs, o.fadeDown ?? false)
313 const layers: ArtLayer[] = [
314 { key: `ck-${state}-art`, svg: state === 'cold' ? inPhase(right, o.nowMs) : right, ...ART_RIGHT, anchor: 'right' },
315 ]
316 const full = overlay(state, theme)
317 if (full) layers.push({ key: full.key, svg: inPhase(full.src, o.nowMs), ...ART_FULL, anchor: 'full' })
318 return layers
319}
320
321// ---------- icons ----------
322
323/** The settings cog: 14 px, 8 teeth, outline, centred in a `boxW` px wide image (the width of its click target). Drawn (not
324 * a font glyph, which differs per font). From effortless's `settingsSvg`. */
325export function gearSvg(color: string, boxW = 14): string {
326 const step = (2 * Math.PI) / 8
327 const pts: string[] = []
328 for (let i = 0; i < 8; i++) {
329 const a = i * step
330 const at = (f: number, r: number) => `${(7 + r * Math.cos(a + f * step)).toFixed(2)},${(7 + r * Math.sin(a + f * step)).toFixed(2)}`
331 pts.push(at(-0.42, 4.6), at(-0.27, 4.6), at(-0.2, 6.1), at(0.2, 6.1), at(0.27, 4.6), at(0.42, 4.6))
332 }
333 return `<svg ${NS} width="${boxW}" height="14" viewBox="0 0 ${boxW} 14"><g transform="translate(${n1((boxW - 14) / 2)} 0)" fill="none" stroke="${color}" stroke-width="1.3" stroke-linejoin="round" shape-rendering="geometricPrecision"><path d="M${pts.join('L')}Z"/><circle cx="7" cy="7" r="2"/></g></svg>`
334}
335
336/** The on/off switch: a 28x16 pill (scaled to `h` px tall, 16 by default) centred in a `boxW` px image (its click target). On = the accent, the knob
337 * right; off = a neutral track, the knob left. No text. */
338export function toggleSvg(on: boolean, c: ArtColors, boxW = 31, h = 16): string {
339 const w = boxW / (h / 16) // the drawing's own width: the viewBox scales it to `h`
340 const x = (w - 28) / 2
341 const track = on
342 ? `<rect x="${n1(x)}" y="1" width="28" height="14" rx="7" fill="${c.accent}"/>`
343 : `<rect x="${n1(x + 0.5)}" y="1.5" width="27" height="13" rx="6.5" fill="${c.dim}" fill-opacity=".22" stroke="${c.dim}" stroke-width="1"/>`
344 const knob = on
345 ? `<circle cx="${n1(x + 21)}" cy="8" r="5.2" fill="${c.bg}"/>`
346 : `<circle cx="${n1(x + 7)}" cy="8" r="4.6" fill="${c.dim}"/>`
347 return `<svg ${NS} width="${boxW}" height="${h}" viewBox="0 0 ${n1(w)} 16">${track}${knob}</svg>`
348}
349
350/** The keep chip: the window as a small outlined pill (`8h`, `∞`) centred in a `boxW` px image (its click target). The text
351 * takes the app's band font; the pill is the state's accent. */
352export function chipSvg(label: string, c: ArtColors, boxW = 39): string {
353 const w = Math.min(boxW - 2, Math.max(26, Math.ceil([...label].length * 8 + 14)))
354 const x = n1((boxW - w) / 2)
355 return (
356 `<svg ${NS} width="${boxW}" height="18" viewBox="0 0 ${boxW} 18"><rect x="${x}" y="1" width="${w}" height="16" rx="8" fill="${c.accent}" fill-opacity=".14" stroke="${c.accent}" stroke-opacity=".7" stroke-width="1"/>` +
357 `<text x="${n1(boxW / 2)}" y="13" text-anchor="middle" style="font-size:12px;font-weight:600;fill:${c.text}">${label}</text></svg>`
358 )
359}
360
361/**
362 * The elsewhere chip: a small flame and the number of the user's other sessions that are warming (`9+` from ten), as an outlined pill in
363 * the ember colours (always, whatever this session's own tone: it is about other sessions), centred in a `boxW` px image (its click
364 * target). The flame breathes gently (2.6 s, opacity and a slight lift), phase-locked to `nowMs` like the dot, so a redraw does not restart it.
365 */
366export function emberSvg(count: number, light: boolean, boxW: number, nowMs: number): string {
367 const e = PALETTE[light ? 'light' : 'dark'].warm
368 const label = count > 9 ? '9+' : String(count)
369 const w = Math.min(boxW - 2, Math.ceil([...label].length * 7.5 + 28))
370 const x = (boxW - w) / 2
371 const flame = 'M5 .6C5.7 3 8.7 4.7 8.7 8.3A3.7 3.7 0 0 1 1.3 8.3C1.3 6.9 1.9 5.9 2.7 5C3 6 3.5 6.4 4 6.4 4 4 4.1 2 5 .6Z'
372 const css = '.f{transform-box:fill-box;transform-origin:50% 100%;animation:f 2.6s ease-in-out infinite}@keyframes f{0%,100%{opacity:.8;transform:scale(1)}50%{opacity:1;transform:scale(1.08,1.12)}}'
373 const svg =
374 `<svg ${NS} width="${boxW}" height="18" viewBox="0 0 ${boxW} 18"><style>${css}</style><rect x="${n1(x)}" y="1" width="${n1(w)}" height="16" rx="8" fill="${e.accent}" fill-opacity=".14" stroke="${e.accent}" stroke-opacity=".7" stroke-width="1"/>` +
375 `<g transform="translate(${n1(x + 8)} 2.5)"><path class="f" d="${flame}" fill="${e.accent}"/></g>` +
376 `<text x="${n1(x + w - 9)}" y="13" text-anchor="end" style="font-size:12px;font-weight:600;fill:${e.text}">${label}</text></svg>`
377 return inPhase(svg, nowMs)
378}
379
380/** The close mark: an X in the gear's stroke (1.3 px, the text colour), 14 px high, centred in a `boxW` px image (its click target). Drawn, not
381 * a glyph: a text X in a blank-padded Button came out as two dots. */
382export function closeSvg(color: string, boxW = 24): string {
383 return `<svg ${NS} width="${boxW}" height="14" viewBox="0 0 ${boxW} 14"><g transform="translate(${n1((boxW - 14) / 2)} 0)" fill="none" stroke="${color}" stroke-width="1.3" stroke-linecap="round" shape-rendering="geometricPrecision"><path d="M3.6 3.6L10.4 10.4M10.4 3.6L3.6 10.4"/></g></svg>`
384}
385
386/** The info mark after a setting's label: a ring with an i, 13 px, in the close mark's stroke family. */
387export function infoSvg(color: string): string {
388 return `<svg ${NS} width="13" height="13" viewBox="0 0 11 11"><g fill="none" stroke="${color}" stroke-width="1.1" stroke-linecap="round" shape-rendering="geometricPrecision"><circle cx="5.5" cy="5.5" r="4.7"/><path d="M5.5 5.1v2.6"/></g><circle cx="5.5" cy="3.3" r=".75" fill="${color}"/></svg>`
389}
390
391/** The Save as default pill: the accent's outlined pill with its label in the text colour (accent on the tinted fill fell under 4.5:1 on the light theme), filling a `boxW` px image (its click target). */
392export function saveSvg(label: string, c: ArtColors, boxW: number): string {
393 return (
394 `<svg ${NS} width="${boxW}" height="18" viewBox="0 0 ${boxW} 18"><rect x="1" y="1" width="${n1(boxW - 2)}" height="16" rx="8" fill="${c.accent}" fill-opacity=".14" stroke="${c.accent}" stroke-opacity=".7" stroke-width="1"/>` +
395 `<text x="${n1(boxW / 2)}" y="13" text-anchor="middle" style="font-size:12px;font-weight:600;fill:${c.text}">${label}</text></svg>`
396 )
397}
398
399const lum = (h: string): number => {
400 const [r, g, b] = [1, 3, 5].map(i => parseInt(h.slice(i, i + 2), 16) / 255).map(v => (v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4)) as [number, number, number]
401 return 0.2126 * r + 0.7152 * g + 0.0722 * b
402}
403const contrast = (a: string, b: string): number => (Math.max(lum(a), lum(b)) + 0.05) / (Math.min(lum(a), lum(b)) + 0.05)
404/** The label on an accent fill: the panel colour (it is what the pill is cut out of), or white where that is the clearer of the two (the light theme's deeper accents). */
405export const onAccent = (c: ArtColors): string => (contrast(c.bg, c.accent) >= contrast('#ffffff', c.accent) ? c.bg : '#ffffff')
406
407/**
408 * The segmented control: one rounded track, `labels` in segments `cells` wide (any ratio; the image is `boxW` px and each segment is its
409 * share of it), hairlines between them, the selected one an inset pill in the accent with its label in the panel colour, bold. `selected`
410 * -1 selects none. `h` px high (20 by default; the drawing scales), one row. The label text takes the app's band font.
411 */
412export function segSvg(labels: readonly string[], cells: readonly number[], selected: number, c: ArtColors, boxW: number, h = 20): string {
413 const w = boxW / (h / 20) // the drawing's own width: the viewBox scales it to `h` px tall (20 by default)
414 const k = w / cells.reduce((a, b) => a + b, 0)
415 const edges = cells.reduce<number[]>((xs, w) => [...xs, (xs[xs.length - 1] ?? 0) + w * k], [0])
416 const parts = labels.map((label, i) => {
417 const x0 = edges[i] as number
418 const x1 = edges[i + 1] as number
419 const on = i === selected
420 return (
421 (on ? `<rect x="${n1(x0 + 2.5)}" y="2.5" width="${n1(x1 - x0 - 5)}" height="15" rx="7.5" fill="${c.accent}"/>` : '') +
422 (i > 0 && i !== selected && i - 1 !== selected ? `<line x1="${n1(x0)}" y1="5.5" x2="${n1(x0)}" y2="14.5" stroke="${c.edge}" stroke-width="1"/>` : '') +
423 `<text x="${n1((x0 + x1) / 2)}" y="14.6" text-anchor="middle" style="font-size:13px;font-weight:${on ? 700 : 500};fill:${on ? onAccent(c) : c.dim}">${label}</text>`
424 )
425 })
426 return `<svg ${NS} width="${boxW}" height="${h}" viewBox="0 0 ${n1(w)} 20"><rect x=".5" y=".5" width="${n1(w - 1)}" height="19" rx="9.5" fill="${c.accent}" fill-opacity=".06" stroke="${c.edge}" stroke-width="1"/>${parts.join('')}</svg>`
427}
428
429/**
430 * The dot slider: `n` stops at equal steps (each the centre of an equal slice of the `boxW` px image) joined by a line; the line and the
431 * dots up to the selected stop in the accent, the rest in the dim colour (at least 3:1 on the band's background; the edge colour is 1.3-2.0:1) as a thin track and ring dots, so the filled part differs by shape and width as well as colour; the selected stop a larger ring with a dot in it. `h` px high (20 by default; the drawing scales).
432 */
433export function sliderSvg(n: number, selected: number, c: ArtColors, boxW: number, h = 20): string {
434 const w = boxW / (h / 20)
435 const cx = (i: number) => n1(((i + 0.5) * w) / n)
436 const line = (a: number, b: number, color: string, w: number) => `<line x1="${cx(a)}" y1="10" x2="${cx(b)}" y2="10" stroke="${color}" stroke-width="${w}" stroke-linecap="round"/>`
437 const dots = Array.from({ length: n }, (_, i) =>
438 i === selected
439 ? `<circle cx="${cx(i)}" cy="10" r="9.5" fill="${c.accent}" fill-opacity=".16"/><circle cx="${cx(i)}" cy="10" r="7" fill="${c.bg}" stroke="${c.accent}" stroke-width="2"/><circle cx="${cx(i)}" cy="10" r="2.8" fill="${c.accent}"/>`
440 : i < selected
441 ? `<circle cx="${cx(i)}" cy="10" r="3.5" fill="${c.accent}"/>`
442 : `<circle cx="${cx(i)}" cy="10" r="3" fill="${c.bg}" stroke="${c.dim}" stroke-width="1.5"/>`, // a ring: told from a filled dot by shape, since dim equals accent in the off and dim states
443 )
444 return `<svg ${NS} width="${boxW}" height="${h}" viewBox="0 0 ${n1(w)} 20">${line(0, n - 1, c.dim, 1)}${selected > 0 ? line(0, selected, c.accent, 2) : ''}${dots.join('')}</svg>`
445}
446
447/** One `<text>` frame of a countdown: when it shows (ms after the draw) and for how long (Infinity: to the end). */
448export type ClockFrame = { label: string; at: number; ms: number }
449
450const CLOCK_MAX_FRAMES = 64
451
452/**
453 * The frames of a minute-by-minute countdown from `ms` left, each label what `fmtMinutes` says for the time left while
454 * it shows: the first label to the nearest half-minute boundary, one per minute after, and `now` for the last 30 s.
455 * At most 64 frames; the last one that fits then stays (a countdown from more than 63 minutes).
456 */
457export function clockFrames(ms: number, label: (ms: number) => string): ClockFrame[] {
458 const m0 = Math.round(Math.max(0, ms) / 60_000)
459 if (m0 < 1) return [{ label: label(ms), at: 0, ms: Infinity }]
460 const frames: ClockFrame[] = [{ label: label(m0 * 60_000), at: 0, ms: ms - (m0 - 0.5) * 60_000 }]
461 let m = m0 - 1
462 for (; m >= 1 && frames.length < CLOCK_MAX_FRAMES - 1; m--)
463 frames.push({ label: label(m * 60_000), at: ms - (m + 0.5) * 60_000, ms: 60_000 })
464 const last = frames[frames.length - 1] as ClockFrame
465 // Cut short (over an hour left): the last label stays rather than claim `now`.
466 if (m >= 1) last.ms = Infinity
467 else frames.push({ label: label(0), at: last.at + last.ms, ms: Infinity })
468 return frames
469}
470
471/** px of a digit and of a letter in the app's band font at 13 px (a letter, `m` and `h` above all, is wider than the 7.9 px cell). */
472const DIGIT_PX = 7.8
473const LETTER_PX = 10.5
474export const clockWidth = (frames: readonly ClockFrame[]): number =>
475 Math.ceil(Math.max(...frames.map(f => [...f.label].reduce((w, ch) => w + (/\d/.test(ch) ? DIGIT_PX : LETTER_PX), 0)))) + 2
476
477/**
478 * A countdown that ticks with no redraw (effortless's `cacheClockSvg`, MIT, (c) 2026 HeyCubit): one `<text>` frame per
479 * minute, each shown for its minute by a `step-end` animation delayed to when it starts. The app sets the image's font
480 * to the band's; the colour is given.
481 */
482export function clockSvg(frames: readonly ClockFrame[], color: string): string {
483 const w = clockWidth(frames)
484 const sec = (v: number) => (Number.isFinite(v) ? n1(Math.round(v) / 1000) : '100000')
485 const texts = frames
486 .map(f => `<text x="0" y="12.5" style="animation-delay:${sec(f.at)}s;animation-duration:${sec(f.ms)}s">${f.label}</text>`)
487 .join('')
488 return `<svg ${NS} width="${w}" height="17" viewBox="0 0 ${w} 17"><style>text{font-size:13px;fill:${color};opacity:0;animation:f 1s step-end 1}@keyframes f{0%{opacity:1}100%{opacity:0}}</style>${texts}</svg>`
489}
490
491/** A 10 px status dot in the state's accent: filled, hollow for `dim` and `off`; pulses for `refreshing` (1.6 s), `cooling`
492 * (2 s) and `error` (3 s), phase-locked to `nowMs` like the art (a redraw restarts the image). */
493export function dotSvg(state: ArtState, light: boolean, nowMs: number): string {
494 const c = PALETTE[light ? 'light' : 'dark'][state].accent
495 const period = state === 'refreshing' ? 1.6 : state === 'cooling' ? 2 : state === 'error' ? 3 : 0
496 const css = period ? `.p{animation:p ${period}s ease-in-out infinite}@keyframes p{0%,100%{opacity:1}50%{opacity:.3}}` : ''
497 const shape =
498 state === 'dim' || state === 'off'
499 ? `<circle cx="5" cy="5" r="3" fill="none" stroke="${c}" stroke-width="1.3"/>`
500 : `<circle cx="5" cy="5" r="4.2" fill="${c}" opacity=".22"/><circle cx="5" cy="5" r="2.8" fill="${c}"/>`
501 const svg = `<svg ${NS} width="10" height="10" viewBox="0 0 10 10"><style>${css}</style><g class="${period ? 'p' : ''}">${shape}</g></svg>`
502 return period ? inPhase(svg, nowMs) : svg
503}
504hooks/hit.tsx 48 lines1// Click targets for drawn controls (the band's toggle, gear and keep chip, the sheet's segments and slider stops). A custom control
2// is an Svg with a blank Button laid over it (pattern from HeyCubit/effortless, MIT); band.tsx and sheet.tsx draw both through these two.
3import type { Elements } from 'claude-code'
4
5import { CELL_PX, cellsPx, hitLabel } from './view'
6
7type Desktop = Elements['desktop']
8
9/** The absolute layer's one cell: a Box `cells` wide that clips a blank Button whose label is far wider than it. One NBSP is ~2.6 px, not a cell. */
10function cell(ui: Desktop, key: string, cells: number, px: number, onPress: () => void, scope?: string) {
11 const { Box, Button } = ui
12 return (
13 <Box key={`${key}-cell`} flexShrink={0} width={cells} flexDirection="row" alignItems="center" justifyContent="center" overflow="hidden">
14 <Box flexShrink={0}>
15 <Button key={key} plain label={hitLabel(px)} hover={{ backgroundColor: '#00000000', ...(scope ? { scope } : {}) }} onPress={onPress} />
16 </Box>
17 </Box>
18 )
19}
20
21/**
22 * A drawn control with blank Buttons over it. The Svg is exactly the box (`cells` wide, summed); the Buttons sit in an absolute layer
23 * over the whole box, one per entry of `cells`, each in a Box exactly that many cells wide (so each covers exactly its own part of the
24 * drawing) that clips a label of no-break spaces far wider than it. One segment = the toggle, the gear, the chip; several = a
25 * segmented control's segments or a slider's stops. A `scope` puts every Button in that hover group (a sheet setting's: an Svg is never a pointer
26 * target, so the blank Buttons over it have to carry the group).
27 */
28export function hitRow(
29 ui: Desktop,
30 o: { id: string; cells: readonly number[]; svg: string; alt: string; height: number; keys: readonly string[]; onPress: (i: number) => void; scope?: string },
31) {
32 const { Box, Svg } = ui
33 const total = o.cells.reduce((a, b) => a + b, 0)
34 return (
35 <Box key={`${o.id}-box`} position="relative" flexShrink={0} width={total} height={1} flexDirection="row" alignItems="center">
36 <Svg source={o.svg} alt={o.alt} width={cellsPx(total)} height={o.height} />
37 <Box position="absolute" top={0} bottom={0} left={0} right={0} flexDirection="row" justifyContent="center" overflow="hidden">
38 {o.cells.map((c, i) => cell(ui, o.keys[i] as string, c, c * CELL_PX, () => o.onPress(i), o.scope))}
39 </Box>
40 </Box>
41 )
42}
43
44/** One drawn control with one blank Button over all of it. */
45export function hit(ui: Desktop, o: { id: string; cells: number; svg: string; alt: string; height: number; onPress: () => void }) {
46 return hitRow(ui, { id: o.id, cells: [o.cells], svg: o.svg, alt: o.alt, height: o.height, keys: [o.id], onPress: o.onPress })
47}
48hooks/sheet.tsx 176 lines1// The settings sheet (brief 2b §4, 2c §2, 2d, 2e, 2f): when the gear is pressed on the desktop, the band becomes a taller rounded panel in the
2// same palette and art. band.tsx draws the panel and its header row (title, line, Save as default when the session differs, the
3// switch, a drawn close); this file draws the body below it: a blank row, the savings line, a blank row, then four settings in two blocks with a blank row
4// between them (view.sheetBlocks: the weekly guard on a subscription, the 1-hour switch on an API key). In two columns (90+) a block is a row of two settings,
5// each a label row above its control row; inline a block is two rows, the label column then the control. A label, a drawn info mark, a control in one
6// drawn-control vocabulary (the segmented pill, the dot slider, the switch), each a drawn Svg with blank Buttons over it (hit.tsx); an Svg is never a
7// pointer target, so every Text and Button of a setting (and a blank Text over the mark) names the setting's hover scope itself. Hovering a setting shows its
8// help in place of the savings line: one hidden absolute Box per setting (the help alone, no name in front: the longest help is about 600 px, a name in
9// front would wrap most of them), revealed through the hover scope `ck-tip-<id>`. `view.sheetRows` counts the rows (11 in both layouts), view.sheetModel
10// says what each setting shows. No hotkeys, no Select, no footer, no captions.
11import type { Elements } from 'claude-code'
12
13import type { Settings } from '../types'
14import { infoSvg, segSvg, sliderSvg, toggleSvg } from './art'
15import type { ArtColors } from './art'
16import { hitRow } from './hit'
17import type { PaneModel } from './pane'
18import { CONTROL_PX, GUTTER_CELLS, INLINE_CONTROL_PX, LABEL_CELLS, MARK_CELLS, MARK_PX, SHEET_TOGGLE_CELLS, STOP_CELLS, TWO_COLUMNS, cellsPx, hitLabel, segCells, settingRows, sheetBlocks, sheetModel } from './view'
19import type { SheetRow } from './view'
20
21/** What the sheet's controls do: a setting applies to the session at once, Save as default (the header pill), close (the header mark). */
22export type SheetActions = { set: (patch: Partial<Settings>) => void; save: () => void; close: () => void }
23
24/** The hover group a setting's label, control and help share. */
25export const tipScope = (id: string): string => `ck-tip-${id}`
26
27export function sheetBody(ui: Elements['desktop'], columns: number, m: PaneModel, c: ArtColors, act: SheetActions) {
28 const { Box, Text, Svg } = ui
29 const two = columns >= TWO_COLUMNS
30 // Inline, a setting's row is ~19 px with no blank row beside it: a 20 px control would touch its neighbour's.
31 const ctlPx = two ? CONTROL_PX : INLINE_CONTROL_PX
32 // Every Text and Button of a setting names its group itself: a Box's scope is not enough (an Svg is an image and never a pointer target).
33 const control = (r: SheetRow) => {
34 const scope = tipScope(r.id)
35 if (r.kind === 'toggle') {
36 return hitRow(ui, {
37 id: 'ck-api-toggle', cells: [SHEET_TOGGLE_CELLS], svg: toggleSvg(r.on, c, cellsPx(SHEET_TOGGLE_CELLS), ctlPx), height: ctlPx, keys: ['ck-api-toggle'],
38 alt: `1-hour cache for API keys, ${r.on ? 'on' : 'off'}`,
39 onPress: () => act.set({ upgradeTo1h: !r.on }), scope,
40 })
41 }
42 const dots = r.kind === 'dots'
43 const cells = r.stops.map(s => (dots ? STOP_CELLS : segCells(s.label)))
44 const px = cellsPx(cells.reduce((a, b) => a + b, 0))
45 const pre = dots ? 'ck-dot-lead-' : `ck-seg-${r.id}-`
46 return hitRow(ui, {
47 id: `ck-${dots ? 'dots' : 'seg'}-${r.id}`,
48 cells,
49 svg: dots ? sliderSvg(r.stops.length, r.selected, c, px, ctlPx) : segSvg(r.stops.map(s => s.label), cells, r.selected, c, px, ctlPx),
50 height: ctlPx,
51 keys: r.stops.map(s => `${pre}${s.value}`),
52 alt: `${r.name}: ${r.stops.map(s => s.label).join(', ')}; ${r.stops[r.selected]?.label ?? 'none'} selected`,
53 onPress: i => act.set(r.patch((r.stops[i] as { value: string }).value)),
54 scope,
55 })
56 }
57 const rows = sheetModel(m.settings, m.kind)
58 // The info mark: the drawn ring, and over exactly its box a blank Text in the setting's group (the Svg itself is no pointer target; the Text is
59 // not a Button, so it takes no focus and no press). Its alt carries the help.
60 const mark = (r: SheetRow) => (
61 <Box key={`ck-mark-${r.id}`} position="relative" flexShrink={0} width={MARK_CELLS} height={1} flexDirection="row" alignItems="center">
62 <Svg source={infoSvg(c.dim)} alt={`About ${r.name}: ${r.help}`} width={MARK_PX} height={MARK_PX} />
63 <Box position="absolute" top={0} bottom={0} left={0} right={0} overflow="hidden">
64 <Box flexShrink={0}>
65 <Text hover={{ scope: tipScope(r.id) }}>{hitLabel(MARK_PX)}</Text>
66 </Box>
67 </Box>
68 </Box>
69 )
70 // The label in the text colour (it is the primary text; it turns accent while its group is lit) and the info mark. Where the control sits beside
71 // it (inline) the space between belongs to this Box (paddingRight), so the pointer never crosses a gap on the way to the control; inline the Box is a
72 // fixed-width label column.
73 const label = (r: SheetRow) => (
74 <Box
75 key={`ck-label-${r.id}`}
76 flexDirection="row"
77 alignItems="center"
78 gap={1}
79 minWidth={0}
80 hover={{ scope: tipScope(r.id) }}
81 {...(two ? { alignSelf: 'flex-start' as const, flexShrink: 1 } : { width: LABEL_CELLS, flexShrink: 0, paddingRight: 1 })}
82 >
83 <Box flexShrink={1} minWidth={0}>
84 <Text color={c.text} wrap="truncate" hover={{ scope: tipScope(r.id), color: c.accent }}>
85 {r.label}
86 </Text>
87 </Box>
88 {mark(r)}
89 </Box>
90 )
91 // The control and the short value beside it (the slider's `5 min`).
92 const ctl = (r: SheetRow) => (
93 <Box key={`ck-control-${r.id}`} flexDirection="row" alignItems="center" alignSelf="flex-start" gap={1} flexShrink={0} hover={{ scope: tipScope(r.id) }}>
94 {control(r)}
95 {r.value ? (
96 <Text color={c.text} wrap="truncate" hover={{ scope: tipScope(r.id) }}>
97 {r.value}
98 </Text>
99 ) : null}
100 </Box>
101 )
102 // One unit: the label above its control (two columns) or beside it (inline). Units are `second` apart by the gutter.
103 const setting = (r: SheetRow, second: boolean) => (
104 <Box key={`ck-row-${r.id}`} flexDirection="column" height={settingRows(columns)} {...(two ? { flexGrow: 1, flexShrink: 1, width: 0, minWidth: 0 } : {})} {...(two && second ? { marginLeft: GUTTER_CELLS } : {})}>
105 {two ? (
106 <Box flexDirection="column" height={2}>
107 <Box flexDirection="row" height={1}>
108 {label(r)}
109 </Box>
110 <Box flexDirection="row" height={1}>
111 {ctl(r)}
112 </Box>
113 </Box>
114 ) : (
115 <Box flexDirection="row" alignItems="center" height={1}>
116 {label(r)}
117 {ctl(r)}
118 </Box>
119 )}
120 </Box>
121 )
122 return (
123 <Box key="ck-sheet" position="relative" flexDirection="column" paddingX={2}>
124 <Box key="ck-savings" flexDirection="row" marginTop={1}>
125 {m.savings.has ? (
126 <Box flexDirection="row" gap={1}>
127 <Box flexShrink={0}>
128 <Text bold color={c.accent}>
129 {m.savings.hero}
130 </Text>
131 </Box>
132 <Box flexShrink={1} minWidth={0}>
133 <Text color={c.dim} wrap="truncate">
134 {`· ${m.savings.session}`}
135 </Text>
136 </Box>
137 </Box>
138 ) : (
139 <Text color={c.dim} wrap="truncate">
140 {m.savings.empty}
141 </Text>
142 )}
143 </Box>
144 <Box key="ck-settings-grid" flexDirection="column" marginTop={1}>
145 {sheetBlocks(m.kind).map((block, n) => (
146 <Box key={`ck-block-${n}`} flexDirection={two ? 'row' : 'column'} {...(n > 0 ? { marginTop: 1 } : {})}>
147 {block.map((id, i) => setting(rows.find(r => r.id === id) as SheetRow, i > 0))}
148 </Box>
149 ))}
150 </Box>
151 {/* The help, one hidden Box per setting over the savings row and the blank row under it (a long help takes the second line), in the panel's
152 background so it covers the savings text (inline, on one truncated line: a second line would be cut through by the 9.5 px blank row; the full help is the mark's alt); the label and control Boxes of the same setting reveal it by the shared scope. */}
153 {rows.map(r => (
154 <Box
155 key={tipScope(r.id)}
156 position="absolute"
157 top={1}
158 left={2}
159 right={2}
160 height={2}
161 overflow="hidden"
162 flexDirection="row"
163 alignItems="flex-start"
164 backgroundColor={c.bg}
165 display="none"
166 hover={{ scope: tipScope(r.id), display: 'flex' }}
167 >
168 <Text color={c.text} {...(two ? {} : { wrap: 'truncate' as const })}>
169 {r.help}
170 </Text>
171 </Box>
172 ))}
173 </Box>
174 )
175}
176