SLOPSHOPPER

cache-keeper

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

newpanebandguardcommandtoast
v0.4.3MITupdated 2026-10-16parab-aditya/cache-keeper
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cache-keeper
│ ┃ cache-keeper ✕ › fix the failing auth test and add an audit log call │ ┃ No savings yet — they show when you return │ ┃ to a warm cache. ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ Keep warm for: Off ▾ ⏺ Update(src/auth.ts) │ ┃ Refresh before it expires: 5 min ▾ ⎿ Added 2 lines, removed 1 line │ ┃ Skip chats smaller than: 30k ▾ ⏺ Bash(bun test) │ ┃ Stop at weekly usage: 90% ▾ ⎿ 3 pass, 1 fail │ ┃ 1-hour cache for API keys: off ▾ │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ [ Save as default ] [ Close ] │ ✻ Worked for 42s · done 4:20 PM │ │ › /cache │ ⎿ cache-keeper: **○ Auto-refresh off** │ ⎿ cache-keeper: - TTL: 5m (no subscription seen) │ ⎿ cache-keeper: - No request yet in this conversation. │ ⎿ cache-keeper: - Settings: keep off, lead 5m, min 30k, weekly gua │ ⎿ cache-keeper: - Limits: 5h 31% │ │ ○ Auto-refresh off [ off ] [ settings ] ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
○ Auto-refresh off [ off ] [ settings ] ⟨Claude Code's own drawing⟩
Pane · cache-keeper
No savings yet — they show when you return to a warm cache. Keep warm for: Off ▾ Refresh before it expires: 5 min ▾ Skip chats smaller than: 30k ▾ Stop at weekly usage: 90% ▾ 1-hour cache for API keys: off ▾ [ Save as default ] [ Close ]
README

cache-keeper

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 band above the prompt cycling through its states: Warm with embers rising and the next refresh counting down, Warm with the ember chip counting 2 other warming sessions, Refreshing with a shimmer, Cooling in an amber alert when auto-refresh is off, and Cold with frost and snowflakes

The settings sheet, dark then light: keep warm for, refresh before it expires, skip chats smaller than, stop at weekly usage, with each setting's help shown as the pointer moves over it

Why you'd want this

The problem

  • Claude keeps your chat in a memory called the cache. Reusing it is very cheap.
  • In the Claude Code app and CLI (Pro and Max plans), that memory is wiped after 1 hour without a message.
  • After that, your next message sends the whole chat again, at more than full price.

When it bites

  • You step away. A meeting, lunch, or you just forget to reply for an hour.
  • Long background tasks. Claude starts a build, a test run or some agents that take over an hour. The main chat sits idle. When the task finishes, the whole chat is sent again.

The prices (from Anthropic's pricing and prompt caching pages, per 1 million tokens)

Opus 5.5Sonnet 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

  • A little before the hour runs out, it sends one tiny "keep this alive" request.
  • That costs about one warm read: a few cents.
  • Your memory stays warm. Your next message is cheap.

How much it saves

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

WithoutWith cache-keeper
What happensthe whole chat is rebuilt3 tiny refreshes
Cost$3.60$0.27

A heavy day: the memory would expire 4 times, about 2 hours each, on a 600k chat

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

Who it's for

  • ✅ Claude Code desktop app or CLI users with long chats (100k+ tokens).
  • ✅ People who take breaks, or run long background tasks.
  • ⚠️ API keys: the memory lasts only 5 minutes there. cache-keeper keeps it warm only while Claude is working, not while you're idle. (There's an option to switch to the 1-hour cache.)
  • ❌ Not needed for short chats, or if you always reply within the hour.

Install

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.

How to use it

  1. It's off by default. Every new session starts off, so it never spends anything unless you turn it on.
  2. Turn it on with the switch on the band, or /cache on.
  3. Pick how long to keep it warm with the chip: 2h, 4h, 8h (default), 12h or ∞.
  4. That's it. It refreshes until that time has passed since your last message, then stops.

The band above the prompt shows what's happening:

StateMeaning
🔥 warmmemory is kept warm; shows the next refresh
✨ refreshinga refresh is being sent
⏸ offwarming is off for this session
🟠 coolingoff or finished, and the memory expires within 10 minutes
❄️ coldmemory expired; shows what your next message will cost
⏹ stoppedit 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.

Forgot a session was warming?

  • A small 🔥 with a number on the band means other sessions are warming too. Press it to see them.
  • Each one shows its sidebar name, how long it's been idle and what it has cost. Press Stop on any of them.
  • A session you archive or delete stops warming on its own.

Commands

CommandWhat it does
/cachestatus
/cache on / offturn warming on or off for this session
/cache keep 2hkeep warm for 2h, 4h, 8h, 12h or forever (add --save to make it your default)
/cache statswhat it spent and what it saved: this session and all time
/cache sessionsyour other sessions that are warming
/cache stop 2 / stop allstop one of them, or all
/cache settingsopen settings
/cache refresh-nowrefresh right now
/cache debugdetails, for troubleshooting

Settings

Change them in the settings sheet, or with /config (rows named cache-keeper.*).

SettingDefaultWhat it does
Keep warm for8hhow long after your last message it keeps warming, once you turn it on
Refresh before it expires5 minhow early it refreshes before the hour is up
Skip chats smaller than30k tokenssmall chats are cheap to rebuild, so they're skipped
Stop at weekly usage90%stops warming when your weekly plan usage hits this
Bandfullfull, minimal (one line) or off
1-hour cache for API keysoffswitch API-key sessions to the 1-hour cache

Good to know

  • Background tasks still running keep it warming for up to 24 hours, so Claude finds a warm memory when the task wakes it.
  • It stops on its own when the time runs out, at your weekly limit, on a rate limit or error, or when the chat is compacted, cleared or switches model.
  • A sleeping Mac misses refreshes. The band tells you when that happens. There's no keep-awake.
  • One refresh is one cheap read plus about 4 output tokens. We measured about $0.08 on a 400k Opus chat.
  • All dollar figures are estimates at list prices.

What it touches

  • Sends: only the refresh requests, to the same model your session uses. A refresh adds one hidden line asking Claude to reply "ok". The reply is thrown away, no tools can run, and nothing is added to your chat.
  • Adds to your chat: one dim note when warming stops (Claude never sees it).
  • Reads: your session's token usage, plan limits and cache settings; the theme; whether the session uses a plan or an API key (never the key itself); and when background tasks run or Claude is asking you something. To match light and dark, the desktop app's appearance setting, or macOS's (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.
  • Writes, all on your machine:
  • a small log per session in ~/.claude/cache-keeper/ (numbers only, never your chat);
  • one status file per session in ~/.claude/cache-keeper/live/, which your other sessions read to show who's warming (a Stop writes a small .stop.json there);
  • totals and saved settings in the plugin's own store.
  • Sets 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.

Development

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

Credits and licence

  • The refresh technique and test setup are adapted from claude-code-cache-warmer by Paul B. Kim (MIT).
  • The band's look and controls are adapted from effortless by HeyCubit (MIT).

Licence texts: THIRD_PARTY.md. cache-keeper is MIT-licensed: LICENSE.

Source 15 files
hooks/register.tsx 293 lines
1// 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}
293
hooks/band.tsx 295 lines
1// 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
295
hooks/command.ts 216 lines
1// 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}
216
hooks/keeper.ts 1158 lines
1// 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}
1158
hooks/io.ts 49 lines
1// 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}
49
hooks/live.ts 288 lines
1// 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'}`
288
hooks/log.ts 134 lines
1// 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}
134
hooks/pane.tsx 163 lines
1// 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}
163
hooks/policy.ts 588 lines
1// 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}
588
hooks/art.ts 504 lines
1// 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}
504
hooks/hit.tsx 48 lines
1// 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}
48
hooks/sheet.tsx 176 lines
1// 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