Stop runaway token spend: budgets per session and per subagent with warn, deny-agents or stop actions, plus a prompt-cache countdown, cold-cache pings and…

A loopkeeper add-on for Claude Code that puts a token budget on the session, so an orchestrated multi-agent run can't quietly use up the plan. You set the number once; the band shows where you stand, and the action you chose takes over when the budget is spent.
Use it when
Install: /plugin install lk-token-budget@loopkeeper, after /plugin marketplace add connectwithub/loopkeeper (more under Install). Requires Claude Code 2.1.294 or later.
Data handling: it keeps the settings you save with /budget and a lifetime token total per project folder in its own plugin store, and the session's counts in session state. It reads ~/.claude/token-budget.json and <project>/.claude/token-budget.json, runs no process and writes no file. Its only model requests are for Keep warm: one small request per press of Keep warm or /budget keepwarm, and, only after /budget autowarm on, a capped number of automatic ones while the session is idle. Each one spends tokens on your account. Nothing else is sent anywhere. It collects no personal data; the saved settings and totals stay until you delete them. Details under What it runs.
Commands: /budget and its options are listed under Commands.
It counts the tokens in every model request, from the main loop and from each subagent. It shows the running total in a one-line band above the prompt. At a threshold you set, it warns you. Once the budget is spent, it warns, refuses new subagents, or stops tool calls, depending on the action you choose. Each subagent also has its own limit.
It also times the main conversation's prompt cache: how long until it goes cold, what a cold resend would cost, a toast before that happens, a note when you send a prompt into a cold cache, a Keep warm button, and an opt-in automatic keep-warm for long idle waits (off by default). See Prompt cache.
The band (all figures in this README are examples):
budget ● ▰▰▰▰▱▱▱▱ 1.2M / 2.0M 60% ● cache warm 54:30 left main 0.9M · agents 0.3M · ctx 40% · cold resend ≈ $1.60 w: Keep warm
The bold label, then two chips. The budget chip is in the ok tone under the warn level, the warn tone from the warn level, and the danger tone once the budget is spent. The cache chip has its own tones (below). The right side is quiet text, drawn in the default text color once the cache is cold or the budget is spent. The band is a kit line: colors are theme keys, nothing is filled, and the bands of other plugins stay below it.
Needs Claude Code 2.1.294 or later.
lk-token-budget is one plugin in the loopkeeper marketplace. Add the marketplace once, then install it, inside a Claude Code session:
/plugin marketplace add connectwithub/loopkeeper
/plugin install lk-token-budget@loopkeeper
Turn it off and on again with /plugin disable lk-token-budget@loopkeeper and /plugin enable lk-token-budget@loopkeeper. When the loopkeeper core is on, its hub links to the add-ons that are on.
To try a local checkout for one session:
claude --plugin-dir /path/to/loopkeeper/plugins/lk-token-budget
Check it from its folder:
claude plugin validate --strict .
claude plugin test .
| Command | What it does | | :- | :- | | /budget | Opens the Token budget pane: tiles for Context (tokens and fill), Cache (warm with time left, cold, or no request yet), Cold resend (≈ cost) and Session cost (what Claude Code reports); then the budget and action, used and remaining with a bar, the prompt-cache settings and last request, tokens by model, the top 10 subagents, the average per turn and projected turns left, and the lifetime total for this project. Keep warm (w), Reset (r) and Close (c) buttons. | | /budget set <n> | Sets the session budget. Accepts 500k, 2m, 1.5m, or 1500000. 0 removes the session limit. | | /budget agent <n> | Sets the per-subagent budget. 0 turns the per-agent limit off. | | /budget action warn\|deny-agents\|stop | Sets what happens once the session budget is spent | | /budget warn <0-1> | Sets the warn level as a fraction of the budget, such as 0.8 (or 80%) | | /budget reset | Sets this session's counts back to zero. The lifetime total stays. | | /budget off / /budget on | off keeps counting and drawing but never refuses anything or shows the over-budget reminders | | /budget band on\|off | Shows or hides the band above the prompt | | /budget keepwarm | Sends one tiny request to refresh the prompt cache (see Keep warm) | | /budget autowarm on\|off | Turns the automatic keep-warm on or off for every session on this machine. /budget autowarm alone prints its state. Off by default. | | /budget pings on\|off | Turns the cache toasts on or off. The band keeps showing the cache either way. | | /budget text | Prints a plain-text summary in the transcript, which Claude also reads, with the cache state on its first line |
/budget is registered as immediate, so you can raise the budget or change the action while Claude is working, including while stop is refusing tool calls.
When counted tokens reach warnAt × session, the band's budget chip turns to the warn tone and one toast appears. When they reach the session budget, the chip turns to the danger tone and the action applies:
| Action | Once the budget is spent | | :- | :- | | warn (default) | A toast at the end of each turn, the main loop's and each subagent's. Nothing is refused. | | deny-agents | New subagents are refused (agent.spawn returns deny), so Claude finishes with what it has. Tool calls still run. | | stop | Every tool call is refused, and Claude is told to stop and report. Exempt: the plugin's own tools (mcp__lk-token-budget__*; it registers none today) and calls raised by your own press in the interface. Slash commands such as /budget are commands, not tool calls, so they always run. |
The per-agent limit applies whatever the action is. Once a subagent's counted tokens reach perAgent, its further tool calls are refused with a message telling it to wrap up and return its result. Other subagents and the main loop aren't affected. /budget off and perAgent: 0 both turn it off.
Settings merge in this order, later wins:
{ "session": 2000000, "perAgent": 400000, "warnAt": 0.8, "action": "warn", "countCacheReads": false, "cacheTtlMinutes": 60, "cacheWarnMinutes": [15, 5, 1], "cachePings": true, "autoKeepWarm": false, "autoKeepWarmMinutes": 3, "autoKeepWarmMax": 6 }~/.claude/token-budget.json, the same fields for every project (the natural home for prices and the cache settings)/budget commands, kept in the plugin's $.store and shared by every session on the machine<project>/.claude/token-budget.json, a project override:{
"session": "1.5m",
"perAgent": "300k",
"warnAt": 0.75,
"action": "deny-agents",
"countCacheReads": false
}
Token fields take a number or the same 500k / 2m strings the commands take. prices merges by model family across the files rather than replacing the whole table. Malformed fields are ignored. Because the project file wins, a /budget set in a project that has the file says so and shows the value in force.
The one exception is auto keep-warm, which spends money on its own: a project file can turn it off ("autoKeepWarm": false) but not on, and its autoKeepWarmMinutes and autoKeepWarmMax are ignored, so a repository you clone can't start background requests. Set those keys in ~/.claude/token-budget.json or with /budget autowarm.
The Claude API caches the start of each request, so the next request in the conversation reads it at a tenth of the input price instead of paying for it again. The cache entry lapses some time after its last use. Once it has, the next prompt re-writes the whole context at the cache-write price, which on a long conversation is the most expensive request of the session.
The lifetime is an assumption. Claude Code doesn't report when the API drops the main conversation's cache entry. The plugin assumes 60 minutes after the main loop's last request (the 1-hour cache TTL), and cacheTtlMinutes changes it. If your setup uses the 5-minute TTL, set "cacheTtlMinutes": 5 and thresholds under it, such as [3, 1].
How it works:
turn.step with no agentId) stamps the time and the model. Subagents' requests have caches of their own and don't count.$.session.usage().context.tokens after each main turn, which is what a cold resend re-writes.now − last request ≥ cacheTtlMinutes. After a compaction there is nothing to re-warm, because the next request starts a new cache, so the band shows ● cache compacted and the plugin stays quiet until that request.$.clock.every(30 s), started in session.start and again after /clear, /resume and /branch.What you see:
| Where | When | What it says | | :- | :- | :- | | Band | warm | the chip ● cache warm 54:30 left in the ok tone, the warn tone under 5 minutes, and cold resend ≈ $1.60 on the quiet right side. The countdown moves in 30-second steps. | | Band | cold | the chip ● cache cold in the danger tone (a chip, never a fill), and resend ≈ $1.60 on the right side in the default text color | | Band | before the first request, or after a compaction | a muted chip, ● cache: no request yet or ● cache compacted with next request starts fresh on the right | | Toast | at each of cacheWarnMinutes (15, 5 and 1 by default), once per warm window | Prompt cache goes cold in 5 min; a resend after that costs ≈ $1.60 (200k tokens at $8/MTok). Keep warm: /budget keepwarm, or w on the band. | | Toast | once, when the timer sees it go cold | Prompt cache went cold. The next send re-writes the context, ≈ $1.60 (200k tokens at $8/MTok). | | Transcript line and toast | you send a prompt while it's cold | Cache cold: this prompt re-writes 200k tokens of context at the cache-write price, ≈ $1.60. The prompt is never held back. Slash commands don't count. | | Transcript line (dim) | each automatic keep-warm, when it's on | auto keep-warm 1/6: cache refreshed for 60 min · read 200k · wrote 30 · ≈ $0.04 |
A toast lasts 4 seconds, so the band carries the same state for as long as it holds. /budget pings off turns the toasts off. While auto keep-warm is on and can still act in the current window, the pings are skipped, because it will refresh the cache itself; they come back when it can't (see below).
Keep warm on the band (focus it and press w), on the pane (w), or /budget keepwarm sends one $.model.fork of the conversation with the prompt Reply with the single word ok.; the fork takes only a prompt, so the short prompt is what keeps the reply tiny. The fork repeats the main conversation's last request, so the API reads the cached prefix and its lifetime starts over. It costs one tiny request: the prefix at the cache-read price plus a few tokens, logged as a dim transcript line such as keep-warm: cache refreshed for 60 min · read 200k · wrote 30 · ≈ $0.04.
Unless you turn on auto keep-warm, the plugin sends nothing on a timer. A keep-warm pressed after the cache has already gone cold pays the full cold write itself (the logged cost shows it). If the fork has nothing to fork yet, fails, or is refused, a toast says keep-warm got no reply with the reason, and the timer is left alone.
Off by default. /budget autowarm on (or "autoKeepWarm": true in ~/.claude/token-budget.json) lets the 30-second cache timer send the same keep-warm fork on its own when autoKeepWarmMinutes (3) are left, so the cache stays warm while you're away. Each refresh starts a new warm window, so with the 60-minute lifetime it fires every 57 minutes.
Caps. At most autoKeepWarmMax (6) automatic refreshes per idle stretch. An idle stretch runs from the start of one turn to the start of the next, so an abandoned session stops refreshing: with the defaults, 6 refreshes keep the cache warm for about 6.7 hours after your last turn (6 × 57 minutes, plus the last 60), then it goes cold as usual. Your next turn starts a new stretch with a fresh count. A manual Keep warm doesn't reset the count. With a 5-minute lifetime, autoKeepWarmMinutes must be under it (such as 1, which refreshes every 4 minutes); raise autoKeepWarmMax to cover a longer wait.
Cost. One refresh is one tiny request: the context at the cache-read price plus a few tokens, such as 200k tokens on opus-5-5 at $0.20/MTok ≈ $0.04. Six refreshes cost ≈ $0.24, against ≈ $1.60 for one cold resend of the same context. Each refresh logs a dim transcript line with what it read, wrote and cost, and the 6th says the cap is reached. /budget autowarm on prints the estimate per refresh at your current context. These requests go through $.model.fork, not the main loop, so they aren't counted against the token budget; the logged costs are the record.
Worst case. The lifetime is only assumed. If the real cache lapsed sooner, or /model changed the model, an automatic fork pays the full cache write, the same as a cold resend. When a fork writes more than it reads, the plugin logs and toasts auto keep-warm 1/6: the fork re-wrote the cache instead of reading it (…); auto keep-warm stops until the next turn. So it pays at most one such write per idle stretch.
When it doesn't fire:
turn.start until its turn.complete. A turn waiting on a long tool call or a permission prompt counts as running, so the cache can go cold during that wait./budget shows paused: session budget spent)auto keep-warm got no reply (…); no retry before the cache goes cold (or auto keep-warm failed: …) and tries once per warm window.keep-warm: line.autoKeepWarmMinutes isn't under cacheTtlMinutes. /budget autowarm says so.When it can't act in the current window (any of the cases above, or the cap reached), the 15/5/1-minute pings come back, so you still hear before the cache goes cold.
Machine-wide. /budget autowarm on is saved in the plugin's $.store, so every idle session on this machine refreshes, each up to its own cap. The pane's Prompt cache section shows auto keep-warm on|off and a line like on · at 3 min left · 1 of 6 this idle stretch. /budget text and /budget autowarm print the same line.
Costs come from a dated table in hooks/prices.ts: USD per million tokens for input, output, cache read and the 1-hour cache write, matched by the first model family the model id contains, longest family first (claude-opus-5-5 is opus-5-5, not opus-5):
| Family | Input | Output | Cache read | Cache write (1 h) | | :- | -: | -: | -: | -: | | fable-5-1 | 10 | 50 | 1.00 | 20 | | opus-5-5 | 4 | 20 | 0.20 | 8 | | opus-5 | 5 | 25 | 0.50 | 10 | | opus-4 | 5 | 25 | 0.50 | 10 | | sonnet-5-5 | 2 | 10 | 0.20 | 4 | | sonnet-5 | 2 | 10 | 0.20 | 4 | | sonnet | 3 | 15 | 0.30 | 6 | | haiku-5-5 | 1 | 5 | 0.10 | 2 | | haiku | 1 | 5 | 0.10 | 2 |
These are list prices as of 2026-10 and only partly confirmed. A cold resend is priced as context tokens × the cache-write rate. Override or add families with prices, as [input, output, cacheRead, cacheWrite1h] or an object with those names:
{ "prices": { "opus-5-5": [4, 20, 0.2, 8], "my-gateway-model": { "input": 3, "output": 15, "cacheRead": 0.3, "cacheWrite1h": 6 } } }
A model that matches no family has no price: the band and toasts leave the cost out instead of guessing. Session cost on the pane is Claude Code's own figure ($.session.usage().cost), not this table.
On /resume and /branch, the SessionStart payload can carry seconds_since_last_response, prompt_cache_likely_expired, context_tokens and model. These fields are in Claude Code's generated types but not in its documentation, so the plugin reads each one defensively to seed the timer: the last request is placed that many seconds ago, and a payload that says the cache likely expired is shown cold even if the 60-minute assumption says otherwise. When the fields are absent, the cache state stays unknown (● cache: no request yet on the band) until the first request.
turn.step, which fires once for each request to the model. Its result's usage holds input_tokens, output_tokens, cache_creation_input_tokens, cache_read_input_tokens, and model. e.agentId is set for a subagent's request. turn.complete's e.usage repeats the same responses' totals, so it isn't added; counting both would double the count.input_tokens + output_tokens + cache_creation_input_tokens, plus cache_read_input_tokens only when countCacheReads is true. Cache reads are tracked and shown either way.usage.model, falling back to the request's model) and by agent (agentId, or main). Requests are counted, and so are main-loop turns. A main-loop turn's tokens go into a history of the last 50 turns, which gives the average per turn and the projected turns left.ctx in the band is the context window fill from $.session.usage(), read after each main-loop turn. It's left out when Claude Code doesn't report it.$.store under lifetime:<cwd>. It's shown for information only, never enforced, and /budget reset leaves it alone.$.state, so they survive a hot reload of the plugin. A new session (a different session id at session.start) starts from zero, and so do /clear, /resume, and /branch, because Claude Code resets $.state for those.usage, for example because the request was interrupted or failed before any response arrived, the plugin counts nothing for it.stop refuses tool calls, not text. Claude can still answer in text, which is the point: it reports back to you./clear, /resume, and /branch start the count again, because Claude Code resets $.state for them. The lifetime total isn't affected.claude plugin validate lists the gating hooks as having no .catch; that's deliberate.cacheTtlMinutes, its countdown is wrong by the difference. A request the plugin doesn't see (another client on the same conversation) doesn't move its clock.claude plugin validate --strict . lists the footprint:
session.start, classic.SessionStart (/clear, /resume, /branch), session.compact, prompt.submit, session.end, turn.step, turn.start, turn.complete, agent.spawn, tool.call, command.run (/budget), and ui.render for the band and the pane.~/.claude/token-budget.json (found through HOME, the only environment variable it reads) and <project>/.claude/token-budget.json. It writes no file./budget and the lifetime totals per project, in the plugin's $.store; the session's counts in $.state.$.model.fork for Keep warm only: one tiny request per press of Keep warm or /budget keepwarm, and, only after /budget autowarm on, up to autoKeepWarmMax automatic ones per idle stretch. Each one spends tokens (the context at the cache-read price, or the full cache write if the cache already lapsed) and is logged with its cost. Nothing else is sent anywhere.$.clock.every for the cache countdown.The directory's validator listed these items for this plugin. Each bullet says what the code does and where.
agent.spawn hook (policy hold MOD_ANSWERS_PERMISSION). The hook in hooks/register.ts (line 688) refuses a new subagent only when the user has turned enforcement on with the action deny-agents and the session budget is spent: it then returns { deny: … } with a message that says how to raise the budget. In every other case it returns next(e) unchanged. It never allows a spawn on the user's behalf, never changes the agent, and never sets a permission mode. The tool.call hook (line 703) works the same way for the action stop, and for a subagent past the per-agent limit; calls of the plugin's own tools and calls that carry a consent value (a call the user started with a press) are never refused. The declaration the finding describes is const next in flushLifetime() (line 194): a local number holding the new lifetime total, unrelated to the hook's next.MCP_FORWARDS_CREDENTIAL_ENV). No credential is read or sent; the two names the check paired are not one. tokensText in hooks/cache.ts (line 175) formats a token count for display (240k, 1.2M). http://www.w3.org/2000/svg in hooks/kit.ts is the SVG namespace inside the markup the UI kit draws, not a request. The plugin starts no programs and opens no connection. The one request it makes is Keep warm, $.model.fork({ prompt: 'Reply with the single word ok.' }) (line 397): a fork of the user's own conversation through Claude Code, sent when the user presses Keep warm or runs /budget keepwarm, and on the 30hooks/register.ts 922 lines1// token-budget: enforce a token budget for the session and for each subagent.
2//
3// turn.step counts every request's usage (main loop and subagents) into $.state
4// turn.start marks a main-loop turn running and starts a new idle stretch (auto keep-warm)
5// turn.complete closes a main-loop turn (history for the projection), flushes the
6// per-project lifetime total, reads context fill, nags in "warn" mode
7// agent.spawn refuses new subagents once the budget is spent ("deny-agents")
8// tool.call refuses tool calls once the budget is spent ("stop"), and a
9// subagent's calls once it passes the per-agent limit
10// command.run /budget and its subcommands
11// ui.render the one-line band above the prompt and the /budget pane
12//
13// The prompt cache (cache.ts): turn.step of the main loop stamps the last request, a
14// 30-second $.clock.every timer started from session.start (and again on /clear, /resume,
15// /branch) pings before the assumed TTL ends and once when it has, prompt.submit notes a cold
16// send with its cost, and Keep warm (band button, pane button, /budget keepwarm) sends one
17// tiny $.model.fork. With /budget autowarm on (off by default) the timer also sends that fork on
18// its own when autoKeepWarmMinutes are left, at most autoKeepWarmMax times per idle stretch
19// (turn.start to turn.start), one try per warm window, and never while a turn runs, after a
20// compaction, once cold, after a fork that re-wrote the cache, or once the budget is spent.
21//
22// Usage is counted from turn.step only; turn.complete's e.usage repeats the same
23// responses, so adding it would double count.
24//
25// Helpers that take $ are top-level functions so static analysis can read them.
26
27import { atom, read, update } from 'claude-code'
28import type { EngineInterface, On, RenderElement } from 'claude-code'
29
30import type { CacheState, Config, Tally } from '../types'
31import {
32 DEFAULTS,
33 HELP,
34 MAIN,
35 ACTIONS,
36 agentDenyText,
37 applyStep,
38 applyTurnComplete,
39 averagePerTurn,
40 bandParts,
41 barOf,
42 emptyTally,
43 fmt,
44 levelOf,
45 mergeConfig,
46 modelsOf,
47 num,
48 parseFraction,
49 parseTokens,
50 percentOf,
51 sanitize,
52 sessionDenyText,
53 summaryText,
54 topAgents,
55 toneOfLevel,
56 turnsLeft,
57} from './budget'
58import {
59 afterKeepWarm,
60 afterRequest,
61 autoArmed,
62 autoWarmDue,
63 autoWarmStatus,
64 bandCache,
65 bandChip,
66 cacheTile,
67 coldSendText,
68 coldText,
69 coldUsd,
70 duePing,
71 emptyCache,
72 isCold,
73 keepWarmText,
74 pingText,
75 refreshUsd,
76 rewroteCache,
77 timeLeftMs,
78 tokensText,
79 ttlOf,
80 usdText,
81} from './cache'
82import { TONES, makeKit } from './kit'
83import type { Elements, Kit, Tone } from './kit'
84
85const PANE = 'token-budget'
86const TOOL_PREFIX = 'mcp__lk-token-budget__'
87const SETTINGS_KEY = 'settings'
88const PROJECT_FILE = '.claude/token-budget.json'
89/** The quiet theme color (the kit's): secondary text in the pane. */
90const QUIET = 'inactive'
91const TICK_MS = 30_000
92const KEEP_WARM_PROMPT = 'Reply with the single word ok.'
93
94const tallyAtom = atom({ plugin: 'lk-token-budget', key: 'tally' } as const, emptyTally())
95const configAtom = atom({ plugin: 'lk-token-budget', key: 'config' } as const, DEFAULTS)
96const warnedAtom = atom({ plugin: 'lk-token-budget', key: 'warned' } as const, false)
97const lifetimeAtom = atom({ plugin: 'lk-token-budget', key: 'lifetime' } as const, 0)
98const ctxAtom = atom({ plugin: 'lk-token-budget', key: 'ctx' } as const, null as number | null)
99const cwdAtom = atom({ plugin: 'lk-token-budget', key: 'cwd' } as const, '')
100const cacheAtom = atom({ plugin: 'lk-token-budget', key: 'cache' } as const, emptyCache())
101
102type Api = EngineInterface
103
104// The cache timer. Timers stop when the module reloads, so this is module state on purpose:
105// session.start (and classic.SessionStart) start a fresh one and cancel any older one.
106let cacheTimer: { cancel(): void } | null = null
107// The band's cache text at the last tick, so the timer redraws only when it changes.
108let lastBand = ''
109// A keep-warm fork in flight: a second press waits for it instead of sending another.
110let warming = false
111// Bumped at each turn.start, so an automatic fork still in flight when a turn starts isn't
112// counted (or halted) in the new idle stretch.
113let stretch = 0
114
115function lifetimeKey(cwd: string): string {
116 return 'lifetime:' + (cwd || '(unknown)')
117}
118
119function projectPath(cwd: string): string {
120 return cwd ? cwd.replace(/\/+$/, '') + '/' + PROJECT_FILE : PROJECT_FILE
121}
122
123/** Reads <cwd>/.claude/token-budget.json, or null when it is missing or not JSON. */
124async function readProject($: Api, cwd: string): Promise<unknown> {
125 try {
126 const path = projectPath(cwd)
127 if (!(await $.fs.exists(path))) return null
128 return JSON.parse(String(await $.fs.read(path)))
129 } catch {
130 return null
131 }
132}
133
134/** Reads ~/.claude/token-budget.json (cache settings and prices for every project), or null. */
135async function readUser($: Api): Promise<unknown> {
136 try {
137 const home = String((await $.env.get('HOME')) ?? '').replace(/\/+$/, '')
138 if (!home) return null
139 const path = home + '/' + PROJECT_FILE
140 if (!(await $.fs.exists(path))) return null
141 return JSON.parse(String(await $.fs.read(path)))
142 } catch {
143 return null
144 }
145}
146
147/** Merges defaults, the user file, saved settings and the project file into $.state. */
148async function loadConfig($: Api): Promise<Config> {
149 let saved: unknown = null
150 try {
151 saved = await $.store.get(SETTINGS_KEY)
152 } catch {
153 saved = null
154 }
155 const cwd = await read($, cwdAtom)
156 const cfg = mergeConfig(saved, await readProject($, cwd), await readUser($))
157 await update($, configAtom, () => cfg)
158 // A raised budget re-arms the one-time warning.
159 const tally = await read($, tallyAtom)
160 if (levelOf(tally.total, cfg) === 'ok') await update($, warnedAtom, () => false)
161 return cfg
162}
163
164/** Saves a settings change in $.store, then reloads the merged config. */
165async function saveSetting($: Api, patch: Partial<Config>): Promise<Config> {
166 let saved: unknown = null
167 try {
168 saved = await $.store.get(SETTINGS_KEY)
169 } catch {
170 saved = null
171 }
172 await $.store.set(SETTINGS_KEY, { ...sanitize(saved), ...patch })
173 return loadConfig($)
174}
175
176async function loadLifetime($: Api): Promise<void> {
177 try {
178 const cwd = await read($, cwdAtom)
179 const stored = num(await $.store.get(lifetimeKey(cwd)))
180 await update($, lifetimeAtom, () => stored)
181 } catch {
182 // Display only: keep what the band had.
183 }
184}
185
186/** Adds counted tokens not yet saved to the per-project lifetime total in $.store. */
187async function flushLifetime($: Api): Promise<void> {
188 try {
189 const tally = await read($, tallyAtom)
190 const amount = tally.unflushed
191 if (amount <= 0) return
192 const key = lifetimeKey(await read($, cwdAtom))
193 // Read again right before the write: other sessions in this project add to it too.
194 const next = num(await $.store.get(key)) + amount
195 await $.store.set(key, next)
196 await update($, lifetimeAtom, () => next)
197 await update($, tallyAtom, (t: Tally) => ({ ...t, unflushed: Math.max(0, t.unflushed - amount) }))
198 } catch {
199 // Try again at the next turn.
200 }
201}
202
203async function resetTally($: Api): Promise<void> {
204 await flushLifetime($)
205 await update($, tallyAtom, (t: Tally) => emptyTally(t.sessionId))
206 await update($, warnedAtom, () => false)
207}
208
209/** Reads context fill for the band; leaves the last reading when usage() has none. */
210async function readContext($: Api): Promise<void> {
211 try {
212 const u = await $.session.usage()
213 const c = u && u.context
214 if (!c) return
215 const pct =
216 typeof c.percent === 'number'
217 ? c.percent
218 : typeof c.tokens === 'number' && c.window > 0
219 ? (c.tokens / c.window) * 100
220 : null
221 if (pct !== null && Number.isFinite(pct)) await update($, ctxAtom, () => Math.round(pct))
222 const tokens = c.tokens
223 if (typeof tokens === 'number' && Number.isFinite(tokens) && tokens > 0) {
224 await update($, cacheAtom, (s: CacheState) => ({ ...s, contextTokens: tokens }))
225 }
226 } catch {
227 // No reading: the band omits ctx.
228 }
229}
230
231/** Counts one request, then shows the one-time warning when the tally crosses warnAt. */
232async function recordStep($: Api, usage: unknown, agentId: string | undefined, model: string): Promise<void> {
233 const cfg = await read($, configAtom)
234 const before = await read($, tallyAtom)
235 const after = applyStep(before, usage as never, agentId, cfg.countCacheReads, model)
236 if (after === before) return
237 await update($, tallyAtom, () => after)
238 const level = levelOf(after.total, cfg)
239 if (level !== 'ok' && !(await read($, warnedAtom))) {
240 await update($, warnedAtom, () => true)
241 $.ui.toast(
242 level === 'over'
243 ? `Session budget spent: ${fmt(after.total)} of ${fmt(cfg.session)}.`
244 : `${percentOf(after.total, cfg)}% of the session budget used (${fmt(after.total)} of ${fmt(cfg.session)}).`,
245 )
246 }
247}
248
249/** $.clock.now(), or the wall clock when no clock answers. */
250async function nowOf($: Api): Promise<number> {
251 try {
252 const t = await $.clock.now()
253 return typeof t === 'number' && Number.isFinite(t) ? t : Date.now()
254 } catch {
255 return Date.now()
256 }
257}
258
259/** Shows a toast, never throwing. */
260function toast($: Api, text: string): void {
261 try {
262 const r: unknown = $.ui.toast(text)
263 if (r && typeof (r as Promise<unknown>).then === 'function') (r as Promise<unknown>).then(undefined, () => {})
264 } catch {
265 // nothing to show it with
266 }
267}
268
269/** Logs a dim transcript line Claude doesn't read, never throwing. */
270function note($: Api, text: string): void {
271 try {
272 const r: unknown = $.ui.log(text)
273 if (r && typeof (r as Promise<unknown>).then === 'function') (r as Promise<unknown>).then(undefined, () => {})
274 } catch {
275 // nothing to log with
276 }
277}
278
279function redraw($: Api): void {
280 try {
281 $.ui.invalidate('ui.render')
282 } catch {
283 // the next event redraws
284 }
285}
286
287/** A main-loop request was answered: a new warm window; its usage approximates the context until turn.complete reads it. */
288async function markRequest($: Api, usage: Record<string, unknown>, model: string): Promise<void> {
289 const now = await nowOf($)
290 const ctx = num(usage.input_tokens) + num(usage.cache_read_input_tokens) + num(usage.cache_creation_input_tokens) + num(usage.output_tokens)
291 await update($, cacheAtom, (s: CacheState) => ({
292 ...afterRequest(s, now, model || null),
293 contextTokens: ctx > 0 ? ctx : s.contextTokens,
294 }))
295}
296
297/** Enforcement is on and the session budget is spent: auto keep-warm pauses. */
298async function budgetSpent($: Api, cfg: Config): Promise<boolean> {
299 if (!cfg.autoKeepWarm || !cfg.enforce) return false
300 return levelOf((await read($, tallyAtom)).total, cfg) === 'over'
301}
302
303/** The auto keep-warm line for the pane, /budget autowarm and /budget text. */
304async function autoStatus($: Api): Promise<string> {
305 const cfg = await read($, configAtom)
306 return autoWarmStatus(await read($, cacheAtom), ttlOf(cfg), cfg, await budgetSpent($, cfg))
307}
308
309/**
310 * One timer tick: the automatic keep-warm when it is due, else a ping at a threshold (skipped
311 * while auto keep-warm is armed), one toast when the cache turns cold, a redraw when the band's
312 * text changed.
313 */
314async function cacheTick($: Api): Promise<void> {
315 const cfg = await read($, configAtom)
316 const now = await nowOf($)
317 const ttl = ttlOf(cfg)
318 const auto = (await budgetSpent($, cfg)) ? { ...cfg, autoKeepWarm: false } : cfg
319 let s = await read($, cacheAtom)
320 if (isCold(s, now, ttl)) {
321 if (!s.cold) {
322 s = { ...s, cold: true }
323 await update($, cacheAtom, (c: CacheState) => ({ ...c, cold: true }))
324 if (cfg.cachePings) toast($, coldText(s, coldUsd(s, cfg)))
325 }
326 } else if (!warming && autoWarmDue(s, now, ttl, auto)) {
327 // Stamp the window first: a fork that fails is not tried again before the cache goes cold.
328 const tried = s.lastRequestAt
329 await update($, cacheAtom, (c: CacheState) => ({ ...c, autoTriedAt: tried }))
330 await keepWarm($, true)
331 s = await read($, cacheAtom)
332 } else if (cfg.cachePings && !warming && !autoArmed(s, ttl, auto)) {
333 const due = duePing(s, now, ttl, cfg.cacheWarnMinutes)
334 if (due !== null) {
335 s = { ...s, lastPing: due }
336 await update($, cacheAtom, (c: CacheState) => ({ ...c, lastPing: due }))
337 toast($, pingText(s, now, due, coldUsd(s, cfg), ttl))
338 }
339 }
340 // The pane draws the same countdown, so redraw whether or not the band is shown.
341 const text = bandCache(s, now, cfg).text
342 if (text !== lastBand) {
343 lastBand = text
344 redraw($)
345 }
346}
347
348/** (Re)starts the 30-second cache timer, cancelling an older one. */
349function startCacheTimer($: Api): void {
350 try {
351 cacheTimer?.cancel()
352 } catch {
353 // an old timer from before a reload is already gone
354 }
355 cacheTimer = null
356 try {
357 cacheTimer = $.clock.every(TICK_MS, () => {
358 void cacheTick($).catch(() => {})
359 })
360 } catch {
361 cacheTimer = null
362 }
363}
364
365/**
366 * Seeds the cache state of a resumed or forked session from the SessionStart payload. The fields
367 * are in the generated types but not the docs, so each is read defensively; when none is there
368 * the state stays unknown until the first request.
369 */
370async function seedFromResume($: Api, e: Record<string, unknown>): Promise<void> {
371 if (e.source !== 'resume' && e.source !== 'fork') return
372 const cfg = await read($, configAtom)
373 const now = await nowOf($)
374 const ttl = ttlOf(cfg)
375 const secs = e.seconds_since_last_response
376 const expired = e.prompt_cache_likely_expired
377 let at = 0
378 if (typeof secs === 'number' && Number.isFinite(secs) && secs >= 0) at = now - secs * 1000
379 // The engine knows its own TTL: when it says expired, trust it over our assumption.
380 if (expired === true) at = at > 0 ? Math.min(at, now - ttl) : now - ttl
381 if (!(at > 0)) return
382 const tokens = e.context_tokens
383 const model = e.model
384 await update($, cacheAtom, (s: CacheState) => ({
385 ...s,
386 lastRequestAt: at,
387 model: typeof model === 'string' && model ? model : s.model,
388 contextTokens: typeof tokens === 'number' && Number.isFinite(tokens) && tokens > 0 ? tokens : s.contextTokens,
389 compacted: false,
390 lastPing: null,
391 cold: false,
392 }))
393}
394
395/** One fork of the conversation. `$.model.fork` takes only `prompt`; the short prompt keeps the reply tiny. */
396async function forkOnce($: Api): Promise<unknown> {
397 return await $.model.fork({ prompt: KEEP_WARM_PROMPT })
398}
399
400/** A keep-warm that got no reply: a toast, and a log line too for the automatic one. The timer is left alone. */
401function keepWarmFailed($: Api, auto: boolean, what: string): string {
402 const msg = auto ? `auto keep-warm ${what}; no retry before the cache goes cold` : 'keep-warm ' + what
403 toast($, msg)
404 if (auto) note($, msg)
405 return msg
406}
407
408/**
409 * Sends one tiny fork of the conversation to refresh the cache: on a press, on /budget keepwarm,
410 * or (auto) from the timer once /budget autowarm is on. Logs a dim line with its cost.
411 */
412async function keepWarm($: Api, auto = false): Promise<string> {
413 if (warming) return 'Keep-warm already running.'
414 warming = true
415 const startedIn = stretch
416 try {
417 let reply: unknown
418 try {
419 reply = await forkOnce($)
420 } catch (err) {
421 return keepWarmFailed($, auto, 'failed: ' + (err instanceof Error ? err.message : String(err)))
422 }
423 const r = reply as { isAnswered?: unknown; reason?: unknown; usage?: Record<string, unknown> } | null
424 if (!r || r.isAnswered !== true) {
425 const why = r && typeof r.reason === 'string' && r.reason ? ` (${r.reason})` : ''
426 return keepWarmFailed($, auto, 'got no reply' + why)
427 }
428 const now = await nowOf($)
429 const u = r.usage && typeof r.usage === 'object' ? r.usage : {}
430 // A fork that wrote more than it read paid for the prefix afresh: the cache had lapsed early
431 // or the model changed. The cache is warm again either way, but auto keep-warm stops.
432 const rewrote = auto && rewroteCache(u)
433 // A turn started while it ran: it belongs to the stretch that ended, so it doesn't count in the new one.
434 const counts = auto && startedIn === stretch
435 await update($, cacheAtom, (s: CacheState) => afterKeepWarm(s, now, counts, counts && rewrote))
436 const s = await read($, cacheAtom)
437 const cfg = await read($, configAtom)
438 const msg = keepWarmText(s, cfg, u, counts, counts && rewrote)
439 note($, msg)
440 if (rewrote) toast($, msg)
441 lastBand = ''
442 redraw($)
443 return msg
444 } finally {
445 warming = false
446 }
447}
448
449/** The band's or summary's cache text right now. */
450async function cacheText($: Api): Promise<{ text: string; tone: Tone }> {
451 const cfg = await read($, configAtom)
452 const s = await read($, cacheAtom)
453 return bandCache(s, await nowOf($), cfg)
454}
455
456async function onSessionStart($: Api, cwd: string): Promise<void> {
457 if (cwd) await update($, cwdAtom, () => cwd)
458 let id = ''
459 try {
460 id = String((await $.session.id()) ?? '')
461 } catch {
462 id = ''
463 }
464 const tally = await read($, tallyAtom)
465 // session.start also fires after a hot reload; $.state survived it, so keep the counts.
466 if (!id || tally.sessionId !== id) {
467 await flushLifetime($)
468 await update($, tallyAtom, () => emptyTally(id))
469 await update($, warnedAtom, () => false)
470 await update($, ctxAtom, () => null)
471 }
472 await loadConfig($)
473 await loadLifetime($)
474}
475
476/** /budget and its subcommands. */
477async function runCommand($: Api, args: string): Promise<{ text?: string }> {
478 const words = args.trim().split(/\s+/).filter(Boolean)
479 const sub = (words[0] ?? '').toLowerCase()
480 const arg = words.slice(1).join(' ')
481 if (sub === '') {
482 await $.ui.open({ id: PANE, title: 'Token budget', focus: true, closeOnEscape: true })
483 return {}
484 }
485 if (sub === 'text') {
486 const autoNow = await autoStatus($)
487 return {
488 text: summaryText(
489 await read($, tallyAtom),
490 await read($, configAtom),
491 await read($, ctxAtom),
492 await read($, lifetimeAtom),
493 (await cacheText($)).text,
494 ) + `\nAuto keep-warm: ${autoNow}`,
495 }
496 }
497 if (sub === 'keepwarm') {
498 return { text: await keepWarm($) }
499 }
500 if (sub === 'autowarm') {
501 const v = arg.toLowerCase()
502 if (v === '') {
503 const autoNow = await autoStatus($)
504 return { text: `Auto keep-warm: ${autoNow}` }
505 }
506 if (v !== 'on' && v !== 'off') return { text: 'Use /budget autowarm on or /budget autowarm off.' }
507 const cfg = await saveSetting($, { autoKeepWarm: v === 'on' })
508 if (v === 'off') return { text: 'Auto keep-warm off: the cache is refreshed only when you press Keep warm or run /budget keepwarm.' }
509 if (!cfg.autoKeepWarm) return { text: `Auto keep-warm saved as on, but ${PROJECT_FILE} turns it off in this project.` }
510 const s = await read($, cacheAtom)
511 const usd = refreshUsd(s, cfg)
512 const each = usd === null ? '' : `, each ≈ ${usdText(usd)} at the current context (${tokensText(s.contextTokens)} tokens)`
513 const never =
514 cfg.autoKeepWarmMinutes < cfg.cacheTtlMinutes
515 ? ''
516 : ` It never fires while autoKeepWarmMinutes (${cfg.autoKeepWarmMinutes}) is not under cacheTtlMinutes (${cfg.cacheTtlMinutes}).`
517 return {
518 text:
519 `Auto keep-warm on: one tiny request when ${cfg.autoKeepWarmMinutes} min are left, at most ${cfg.autoKeepWarmMax} per idle stretch${each}. ` +
520 `Saved for every session on this machine.${never}`,
521 }
522 }
523 if (sub === 'pings') {
524 const v = arg.toLowerCase()
525 if (v !== 'on' && v !== 'off') return { text: 'Use /budget pings on or /budget pings off.' }
526 const cfg = await saveSetting($, { cachePings: v === 'on' })
527 return { text: cfg.cachePings ? 'Cache pings on.' : 'Cache pings off: the band still shows the cache.' }
528 }
529 if (sub === 'set' || sub === 'agent') {
530 const n = parseTokens(arg)
531 if (n === null) return { text: `Not a token count: "${arg}". Try 500k, 2m, 1.5m or 1500000.` }
532 const cfg = await saveSetting($, sub === 'set' ? { session: n } : { perAgent: n })
533 const got = sub === 'set' ? cfg.session : cfg.perAgent
534 const label = sub === 'set' ? 'Session budget' : 'Per-agent budget'
535 const shown = got > 0 ? fmt(got) : 'off'
536 return { text: got === n ? `${label} set to ${shown}.` : `${label} saved as ${fmt(n)}, but ${PROJECT_FILE} overrides it: ${shown}.` }
537 }
538 if (sub === 'action') {
539 const a = arg.toLowerCase()
540 if (!(ACTIONS as readonly string[]).includes(a)) return { text: 'Action must be warn, deny-agents or stop.' }
541 const cfg = await saveSetting($, { action: a as Config['action'] })
542 return { text: cfg.action === a ? `Action set to ${a}.` : `Action saved as ${a}, but ${PROJECT_FILE} overrides it: ${cfg.action}.` }
543 }
544 if (sub === 'warn') {
545 const f = parseFraction(arg)
546 if (f === null) return { text: 'Warn level must be a fraction between 0 and 1, such as 0.8.' }
547 const cfg = await saveSetting($, { warnAt: f })
548 return { text: `Warning at ${Math.round(cfg.warnAt * 100)}% of the session budget.` }
549 }
550 if (sub === 'reset') {
551 await resetTally($)
552 return { text: 'Token budget counts reset for this session.' }
553 }
554 if (sub === 'on' || sub === 'off') {
555 const cfg = await saveSetting($, { enforce: sub === 'on' })
556 return { text: cfg.enforce ? 'Enforcement on.' : 'Enforcement off: tokens are still counted, nothing is denied.' }
557 }
558 if (sub === 'band') {
559 const v = arg.toLowerCase()
560 if (v !== 'on' && v !== 'off') return { text: 'Use /budget band on or /budget band off.' }
561 const cfg = await saveSetting($, { band: v === 'on' })
562 return { text: cfg.band ? 'Band shown above the prompt.' : 'Band hidden.' }
563 }
564 return { text: HELP }
565}
566
567export function register(on: On) {
568 on('session.start', async ($, e, next) => {
569 try {
570 await onSessionStart($, typeof e.cwd === 'string' ? e.cwd : '')
571 } catch {
572 // Counting starts from the defaults.
573 }
574 try {
575 await $.command.register({
576 name: 'budget',
577 description: 'Token budget and prompt cache: open the pane, or set, agent, action, warn, reset, on, off, band, keepwarm, autowarm, pings, text',
578 argumentHint:
579 '[set <n> | agent <n> | action <warn|deny-agents|stop> | warn <0-1> | reset | on | off | band on|off | keepwarm | autowarm on|off | pings on|off | text]',
580 immediate: true,
581 })
582 } catch {
583 // The name is taken: the rest of the mod still counts and enforces.
584 }
585 startCacheTimer($)
586 return next(e)
587 })
588
589 // /clear, /resume and /branch reset $.state without a session.start: load config again.
590 on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
591 try {
592 const cwd = typeof e.cwd === 'string' ? e.cwd : ''
593 await onSessionStart($, cwd)
594 } catch {
595 // Defaults stay in place.
596 }
597 try {
598 await seedFromResume($, e as unknown as Record<string, unknown>)
599 } catch {
600 // The cache state stays unknown until the first request.
601 }
602 lastBand = ''
603 startCacheTimer($)
604 return next(e)
605 })
606
607 // A compaction of the main conversation: the next request writes a new cache, nothing to re-warm.
608 on('session.compact', async ($, e, next) => {
609 const result = await next(e)
610 try {
611 const skipped = !!result && typeof (result as { skip?: unknown }).skip === 'string'
612 if (!e.agentId && e.trigger !== 'precompute' && !skipped) {
613 await update($, cacheAtom, (s: CacheState) => ({ ...s, compacted: true, lastPing: null, cold: false }))
614 }
615 } catch {
616 // Observing only.
617 }
618 return result
619 })
620
621 // A prompt sent while the cache is cold (by our own clock) re-writes the context: say what it costs. Never dropped.
622 on('prompt.submit', async ($, e, next) => {
623 try {
624 const text = typeof e.text === 'string' ? e.text : ''
625 if (!e.turnId && !text.trimStart().startsWith('/')) {
626 const cfg = await read($, configAtom)
627 const s = await read($, cacheAtom)
628 if (isCold(s, await nowOf($), ttlOf(cfg))) {
629 const msg = coldSendText(s, coldUsd(s, cfg))
630 note($, msg)
631 toast($, msg)
632 }
633 }
634 } catch {
635 // A note only: the prompt goes ahead either way.
636 }
637 return next(e)
638 })
639
640 on('session.end', async ($, e, next) => {
641 await flushLifetime($)
642 return next(e)
643 })
644
645 on('turn.step', async function* ($, e, next) {
646 const result = yield* next(e)
647 try {
648 if (result && result.usage) {
649 await recordStep($, result.usage, e.agentId, typeof e.model === 'string' ? e.model : '')
650 if (!e.agentId) {
651 const u = result.usage as unknown as Record<string, unknown>
652 const model = typeof u.model === 'string' && u.model ? u.model : typeof e.model === 'string' ? e.model : ''
653 await markRequest($, u, model)
654 }
655 }
656 } catch {
657 // A miscount never breaks the request.
658 }
659 return result
660 })
661
662 // A main-loop turn starts (subagents' turns and keep-warm forks raise none): no automatic
663 // keep-warm while it runs, and a new idle stretch, so the cap and a halt start over.
664 on('turn.start', async ($, e, next) => {
665 stretch++
666 try {
667 await update($, cacheAtom, (s: CacheState) => ({ ...s, turnRunning: true, autoWarms: 0, autoHalted: false }))
668 } catch {
669 // Observing only.
670 }
671 return next(e)
672 })
673
674 on('turn.complete', async ($, e, next) => {
675 const result = await next(e)
676 try {
677 if (!e.agentId) await update($, cacheAtom, (s: CacheState) => ({ ...s, turnRunning: false }))
678 await update($, tallyAtom, (t: Tally) => applyTurnComplete(t, e.agentId))
679 if (!e.agentId) await readContext($)
680 await flushLifetime($)
681 const cfg = await read($, configAtom)
682 const tally = await read($, tallyAtom)
683 if (!e.agentId && cfg.enforce && cfg.action === 'warn' && levelOf(tally.total, cfg) === 'over') {
684 $.ui.toast(`Session budget spent: ${fmt(tally.total)} of ${fmt(cfg.session)}. Raise it with /budget set <n>.`)
685 }
686 } catch {
687 // Observing only.
688 }
689 return result
690 })
691
692 on('agent.spawn', async ($, e, next) => {
693 let deny: string | null = null
694 try {
695 const cfg = await read($, configAtom)
696 const tally = await read($, tallyAtom)
697 if (cfg.enforce && cfg.action === 'deny-agents' && levelOf(tally.total, cfg) === 'over') {
698 deny = sessionDenyText(tally, cfg, 'deny-agents')
699 }
700 } catch {
701 deny = null
702 }
703 if (deny) return { deny }
704 return next(e)
705 })
706
707 on('tool.call', async ($, e, next) => {
708 let deny: string | null = null
709 try {
710 const tool = typeof e.tool === 'string' ? e.tool : ''
711 // The mod's own tools and calls the user raised with a press stay open. `consent` rides on
712 // every tool.call input (ToolCallReserved) but the built-in tools' types don't declare it.
713 const exempt = tool.startsWith(TOOL_PREFIX) || ('consent' in e && typeof e.consent === 'string' && e.consent !== '')
714 const cfg = await read($, configAtom)
715 if (!exempt && cfg.enforce) {
716 const tally = await read($, tallyAtom)
717 if (cfg.action === 'stop' && levelOf(tally.total, cfg) === 'over') {
718 deny = sessionDenyText(tally, cfg, 'stop')
719 } else if (cfg.perAgent > 0 && e.agentId) {
720 const used = tally.byAgent[e.agentId] ?? 0
721 if (used >= cfg.perAgent) deny = agentDenyText(used, cfg)
722 }
723 }
724 } catch {
725 deny = null
726 }
727 if (deny) return { deny }
728 return next(e)
729 })
730
731 on('command.run', { command: 'budget' }, async ($, e) => {
732 try {
733 return await runCommand($, typeof e.args === 'string' ? e.args : '')
734 } catch (err) {
735 return { text: 'token-budget: ' + (err instanceof Error ? err.message : String(err)) }
736 }
737 })
738
739 // The band: `budget ● ▰▱▱▱ 2k / 2.0M 0% ● cache warm 54:12 left main 2k · agents 500 · … [Keep warm]`,
740 // one kit line stacked above the bands after it. The right side is quiet, in default text once the
741 // cache is cold or the budget is spent.
742 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
743 const cfg = await read($, configAtom)
744 if (!cfg.band || (e.props && e.props.hasSurvey)) return next(e)
745 const tally = await read($, tallyAtom)
746 const ctx = await read($, ctxAtom)
747 const cache = await read($, cacheAtom)
748 const now = await nowOf($)
749 const ui = $.ui.resolve(e)
750 const { Box } = ui
751 const k = makeKit(ui as unknown as Elements, e.surface)
752 const level = levelOf(tally.total, cfg)
753 const { used, info } = bandParts(tally, cfg, ctx)
754 lastBand = bandCache(cache, now, cfg).text
755 const c = bandChip(cache, now, cfg)
756 const chips = [
757 { text: used, tone: toneOfLevel(level) },
758 { text: c.chip, tone: c.tone },
759 ]
760 const right = c.note ? [...info, c.note] : info
761 const urgent = c.tone === 'danger' || level === 'over'
762 // The kit returns its trees as unknown (it compiles without the engine's types): this is
763 // where they meet the engine's element types.
764 const drawn = (
765 urgent
766 ? Box({
767 flexDirection: 'row',
768 justifyContent: 'space-between',
769 columnGap: 2,
770 children: [k.line('budget', chips) as RenderElement, k.text(right.join(' · '), { wrap: 'truncate' }) as RenderElement],
771 })
772 : k.line('budget', chips, right)
773 ) as RenderElement
774 // Keep warm only once there is a cache to warm (after a request, before a compaction).
775 const warmable = cache.lastRequestAt > 0 && !cache.compacted
776 const line = Box({
777 key: 'budget-band',
778 flexDirection: 'row',
779 justifyContent: 'space-between',
780 columnGap: 2,
781 children: [
782 Box({ flexDirection: 'column', flexShrink: 1, flexGrow: 1, children: [drawn] }),
783 warmable
784 ? (k.buttons([{ key: 'keepwarm', label: 'Keep warm', hotkey: 'w', plain: true, onPress: () => keepWarm($) }]) as RenderElement)
785 : null,
786 ],
787 })
788 const theirs = await next(e)
789 if (!theirs) return line
790 return Box({ flexDirection: 'column', children: [line, theirs] })
791 })
792
793 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
794 const cfg = await read($, configAtom)
795 const tally = await read($, tallyAtom)
796 const lifetime = await read($, lifetimeAtom)
797 const ctx = await read($, ctxAtom)
798 const cache = await read($, cacheAtom)
799 const now = await nowOf($)
800 let sessionUsd: number | null = null
801 try {
802 const u = await $.session.usage()
803 const usd = u && u.cost ? u.cost.usd : undefined
804 if (typeof usd === 'number' && Number.isFinite(usd)) sessionUsd = usd
805 } catch {
806 sessionUsd = null
807 }
808 const ui = $.ui.resolve(e)
809 const { Text } = ui
810 const k: Kit = makeKit(ui as unknown as Elements, e.surface)
811 const level = levelOf(tally.total, cfg)
812 const color = TONES[toneOfLevel(level)].fg
813
814 const tile = cacheTile(cache, now, cfg)
815 const cold = coldUsd(cache, cfg)
816 const left = timeLeftMs(cache, now, ttlOf(cfg))
817 const ctxValue =
818 cache.contextTokens > 0
819 ? tokensText(cache.contextTokens) + (ctx !== null ? ` · ${ctx}%` : '')
820 : ctx !== null
821 ? `${ctx}%`
822 : '—'
823
824 // The pane is a page of blocks one row apart: the tiles, one tight block per section (its header
825 // and its rows) and the buttons.
826 const tiles = k.tiles([
827 { label: 'Context', value: ctxValue },
828 { label: 'Cache', value: tile.value, tone: tile.tone },
829 { label: 'Cold resend', value: cold === null ? '—' : '≈ ' + usdText(cold), tone: tile.tone === 'danger' ? 'danger' : undefined },
830 { label: 'Session cost', value: sessionUsd === null ? '—' : usdText(sessionUsd), tone: 'accent' },
831 ])
832
833 const budget: unknown[] = [k.section('Budget', `enforcement ${cfg.enforce ? 'on' : 'off'}`)]
834 budget.push(
835 k.kv(
836 'Limit',
837 `${cfg.session > 0 ? fmt(cfg.session) : 'none'} · action ${cfg.action} · warn at ${Math.round(cfg.warnAt * 100)}% · per agent ${cfg.perAgent > 0 ? fmt(cfg.perAgent) : 'off'}`,
838 ),
839 )
840 const remaining = cfg.session > 0 ? fmt(Math.max(0, cfg.session - tally.total)) : 'no limit'
841 budget.push(
842 Text({
843 wrap: 'truncate',
844 children: [
845 Text({ color, children: [`${level === 'over' ? '✗' : '●'} ${barOf(tally.total, cfg, 16)} `] }),
846 `Used ${fmt(tally.total)} (${percentOf(tally.total, cfg)}%) · remaining ${remaining}`,
847 ],
848 }),
849 )
850 budget.push(
851 k.text(
852 `${tally.requests} requests · ${tally.turns} turns · cache reads ${fmt(tally.cacheRead)} (${cfg.countCacheReads ? 'counted' : 'not counted'})`,
853 { color: QUIET, wrap: 'truncate' },
854 ),
855 )
856
857 const prompt: unknown[] = [k.section('Prompt cache', `pings ${cfg.cachePings ? 'on' : 'off'} · auto keep-warm ${cfg.autoKeepWarm ? 'on' : 'off'}`)]
858 prompt.push(
859 k.kv(
860 'Lifetime',
861 `${cfg.cacheTtlMinutes} min (assumed) · pings at ${cfg.cacheWarnMinutes.length ? cfg.cacheWarnMinutes.join(', ') + ' min' : 'none'}`,
862 ),
863 )
864 prompt.push(
865 k.kv(
866 'Last request',
867 cache.lastRequestAt > 0
868 ? `${cache.model ?? 'unknown model'}${left !== null ? ` · ${tile.value}` : ''}`
869 : 'none yet',
870 tile.tone,
871 ),
872 )
873 prompt.push(k.kv('Auto keep-warm', autoWarmStatus(cache, ttlOf(cfg), cfg, await budgetSpent($, cfg))))
874
875 const models = modelsOf(tally)
876 const byModel: unknown[] = [k.section('By model', String(models.length))]
877 if (models.length === 0) byModel.push(k.text('nothing counted yet', { color: QUIET }))
878 for (const [m, t] of models) byModel.push(k.row({ key: 'm:' + m, left: m, right: fmt(t) }))
879
880 const agents = topAgents(tally)
881 const byAgent: unknown[] = [k.section('Agents', `main loop ${fmt(tally.byAgent[MAIN] ?? 0)}`)]
882 if (agents.length === 0) byAgent.push(k.text('no subagents yet', { color: QUIET }))
883 for (const [a, t] of agents) {
884 const capped = cfg.perAgent > 0 && t >= cfg.perAgent
885 byAgent.push(
886 k.row({
887 key: 'a:' + a,
888 left: [k.text(capped ? '✗' : '○', { color: capped ? TONES.danger.fg : QUIET }), a],
889 right: fmt(t),
890 }),
891 )
892 }
893
894 const avg = averagePerTurn(tally)
895 const turns = turnsLeft(tally, cfg)
896 const projection: unknown[] = [k.section('Projection')]
897 projection.push(
898 k.text(
899 avg === null
900 ? 'No completed turns yet: no projection.'
901 : `Average ${fmt(avg)} per turn${turns !== null ? ` · about ${turns} turns left at this pace` : ''}`,
902 { wrap: 'truncate' },
903 ),
904 )
905 projection.push(k.kv('Lifetime in this project', fmt(lifetime + tally.unflushed)))
906 const buttons = k.buttons([
907 { key: 'keepwarm', label: 'Keep warm', hotkey: 'w', plain: true, onPress: () => keepWarm($) },
908 { key: 'reset', label: 'Reset', hotkey: 'r', plain: true, onPress: () => resetTally($) },
909 { key: 'close', label: 'Close', hotkey: 'c', plain: true, onPress: () => $.ui.close({ id: PANE }) },
910 ])
911 return k.page([
912 tiles,
913 k.stack(budget),
914 k.stack(prompt),
915 k.stack(byModel),
916 k.stack(byAgent),
917 k.stack(projection),
918 buttons,
919 ]) as RenderElement
920 })
921}
922hooks/budget.ts 304 lines1// Pure helpers for token-budget: parsing, config merging, counting, formatting.
2// Nothing here touches the mods API, so tests can call every function directly.
3
4import type { Action, Config, Tally } from '../types'
5import { sanitizePrices } from './prices'
6
7export const ACTIONS: readonly Action[] = ['warn', 'deny-agents', 'stop']
8
9export const DEFAULTS: Config = {
10 session: 2_000_000,
11 perAgent: 400_000,
12 warnAt: 0.8,
13 action: 'warn',
14 countCacheReads: false,
15 enforce: true,
16 band: true,
17 cacheTtlMinutes: 60,
18 cacheWarnMinutes: [15, 5, 1],
19 cachePings: true,
20 autoKeepWarm: false,
21 autoKeepWarmMinutes: 3,
22 autoKeepWarmMax: 6,
23 prices: {},
24}
25
26export const HISTORY = 50
27export const TOP_AGENTS = 10
28export const BAR_CELLS = 8
29export const MAIN = 'main'
30
31export function emptyTally(sessionId = ''): Tally {
32 return {
33 sessionId,
34 total: 0,
35 cacheRead: 0,
36 byModel: {},
37 byAgent: {},
38 requests: 0,
39 turns: 0,
40 perTurn: [],
41 turnMark: 0,
42 unflushed: 0,
43 }
44}
45
46/** A finite, non-negative number, or 0. */
47export function num(v: unknown): number {
48 return typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : 0
49}
50
51/**
52 * Parses a token count: `500k`, `2m`, `1.5m`, `1500000`, `1,500,000`, `2M`.
53 * Returns null for anything else, including negatives.
54 */
55export function parseTokens(text: unknown): number | null {
56 if (typeof text === 'number') return Number.isFinite(text) && text >= 0 ? Math.round(text) : null
57 if (typeof text !== 'string') return null
58 const s = text.trim().toLowerCase().replace(/[,_\s]/g, '')
59 const m = /^(\d+(?:\.\d+)?|\.\d+)([km]?)$/.exec(s)
60 if (!m) return null
61 const scale = m[2] === 'k' ? 1_000 : m[2] === 'm' ? 1_000_000 : 1
62 const n = Math.round(Number(m[1]) * scale)
63 return Number.isFinite(n) ? n : null
64}
65
66/** Parses a warn fraction in (0, 1]; also takes `80%`. */
67export function parseFraction(text: unknown): number | null {
68 if (typeof text !== 'string' && typeof text !== 'number') return null
69 const s = String(text).trim()
70 const pct = s.endsWith('%')
71 const n = Number(pct ? s.slice(0, -1) : s)
72 if (!Number.isFinite(n)) return null
73 const f = pct ? n / 100 : n
74 return f > 0 && f <= 1 ? f : null
75}
76
77/** Keeps only the well-formed fields of a partial config from the store or a file. */
78export function sanitize(raw: unknown): Partial<Config> {
79 if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {}
80 const r = raw as Record<string, unknown>
81 const out: Partial<Config> = {}
82 const session = parseTokens(r.session)
83 if (session !== null) out.session = session
84 const perAgent = parseTokens(r.perAgent)
85 if (perAgent !== null) out.perAgent = perAgent
86 const warnAt = parseFraction(r.warnAt)
87 if (warnAt !== null) out.warnAt = warnAt
88 if (typeof r.action === 'string' && (ACTIONS as readonly string[]).includes(r.action)) out.action = r.action as Action
89 if (typeof r.countCacheReads === 'boolean') out.countCacheReads = r.countCacheReads
90 if (typeof r.enforce === 'boolean') out.enforce = r.enforce
91 if (typeof r.band === 'boolean') out.band = r.band
92 const ttl = typeof r.cacheTtlMinutes === 'string' ? Number(r.cacheTtlMinutes) : r.cacheTtlMinutes
93 if (typeof ttl === 'number' && Number.isFinite(ttl) && ttl > 0 && ttl <= 24 * 60) out.cacheTtlMinutes = ttl
94 if (Array.isArray(r.cacheWarnMinutes)) {
95 const mins = r.cacheWarnMinutes.filter((m): m is number => typeof m === 'number' && Number.isFinite(m) && m > 0)
96 if (mins.length === r.cacheWarnMinutes.length) out.cacheWarnMinutes = [...new Set(mins)].sort((a, b) => b - a)
97 }
98 if (typeof r.cachePings === 'boolean') out.cachePings = r.cachePings
99 if (typeof r.autoKeepWarm === 'boolean') out.autoKeepWarm = r.autoKeepWarm
100 const autoMin = typeof r.autoKeepWarmMinutes === 'string' ? Number(r.autoKeepWarmMinutes) : r.autoKeepWarmMinutes
101 if (typeof autoMin === 'number' && Number.isFinite(autoMin) && autoMin >= 1 && autoMin <= 24 * 60) out.autoKeepWarmMinutes = autoMin
102 const autoMax = typeof r.autoKeepWarmMax === 'string' ? Number(r.autoKeepWarmMax) : r.autoKeepWarmMax
103 if (typeof autoMax === 'number' && Number.isInteger(autoMax) && autoMax >= 1 && autoMax <= 1000) out.autoKeepWarmMax = autoMax
104 const prices = sanitizePrices(r.prices)
105 if (prices) out.prices = prices
106 return out
107}
108
109/** A project file may turn auto keep-warm off, never on or up: a cloned repo can't start spending in the background. */
110function projectOnly(p: Partial<Config>): Partial<Config> {
111 const out = { ...p }
112 delete out.autoKeepWarmMinutes
113 delete out.autoKeepWarmMax
114 if (out.autoKeepWarm !== false) delete out.autoKeepWarm
115 return out
116}
117
118/** defaults, then ~/.claude/token-budget.json, then saved settings, then the project file: later wins. Prices merge by family. */
119export function mergeConfig(saved: unknown, project: unknown, user?: unknown): Config {
120 const u = sanitize(user)
121 const s = sanitize(saved)
122 const p = projectOnly(sanitize(project))
123 return { ...DEFAULTS, ...u, ...s, ...p, prices: { ...DEFAULTS.prices, ...u.prices, ...s.prices, ...p.prices } }
124}
125
126/** The fields of a turn.step result's usage that this mod reads. */
127export type StepUsage = {
128 input_tokens?: unknown
129 output_tokens?: unknown
130 cache_read_input_tokens?: unknown
131 cache_creation_input_tokens?: unknown
132 model?: unknown
133}
134
135/** Counted tokens for one request: input + output + cache writes (+ cache reads when counted). */
136export function countedOf(usage: StepUsage | null | undefined, countCacheReads: boolean): number {
137 if (!usage || typeof usage !== 'object') return 0
138 return (
139 num(usage.input_tokens) +
140 num(usage.output_tokens) +
141 num(usage.cache_creation_input_tokens) +
142 (countCacheReads ? num(usage.cache_read_input_tokens) : 0)
143 )
144}
145
146/** Adds one request's usage to the tally. Returns the same tally when usage is missing. */
147export function applyStep(
148 tally: Tally,
149 usage: StepUsage | null | undefined,
150 agentId: string | undefined,
151 countCacheReads: boolean,
152 fallbackModel: string,
153): Tally {
154 if (!usage || typeof usage !== 'object') return tally
155 const counted = countedOf(usage, countCacheReads)
156 const model = typeof usage.model === 'string' && usage.model ? usage.model : fallbackModel || 'unknown'
157 const who = agentId || MAIN
158 return {
159 ...tally,
160 total: tally.total + counted,
161 cacheRead: tally.cacheRead + num(usage.cache_read_input_tokens),
162 byModel: { ...tally.byModel, [model]: (tally.byModel[model] ?? 0) + counted },
163 byAgent: { ...tally.byAgent, [who]: (tally.byAgent[who] ?? 0) + counted },
164 requests: tally.requests + 1,
165 unflushed: tally.unflushed + counted,
166 }
167}
168
169/** Closes a main-loop turn: counts it and records what it spent. Subagent turns change nothing. */
170export function applyTurnComplete(tally: Tally, agentId: string | undefined): Tally {
171 if (agentId) return tally
172 const spent = Math.max(0, tally.total - tally.turnMark)
173 return {
174 ...tally,
175 turns: tally.turns + 1,
176 perTurn: [...tally.perTurn, spent].slice(-HISTORY),
177 turnMark: tally.total,
178 }
179}
180
181export type Level = 'ok' | 'warn' | 'over'
182
183export function levelOf(total: number, cfg: Config): Level {
184 if (cfg.session <= 0) return 'ok'
185 if (total >= cfg.session) return 'over'
186 if (total >= cfg.warnAt * cfg.session) return 'warn'
187 return 'ok'
188}
189
190/** The kit tone of a budget level: ok, warn, or danger once the budget is spent. */
191export function toneOfLevel(level: Level): 'ok' | 'warn' | 'danger' {
192 return level === 'over' ? 'danger' : level === 'warn' ? 'warn' : 'ok'
193}
194
195/** Short token figure: 1.2M, 0.9M, 45k, 812. */
196export function fmt(n: number): string {
197 const v = num(n)
198 if (v >= 100_000) return (v / 1_000_000).toFixed(1) + 'M'
199 if (v >= 1_000) return Math.round(v / 1_000) + 'k'
200 return String(Math.round(v))
201}
202
203export function percentOf(total: number, cfg: Config): number {
204 return cfg.session > 0 ? Math.round((total / cfg.session) * 100) : 0
205}
206
207export function barOf(total: number, cfg: Config, cells = BAR_CELLS): string {
208 const frac = cfg.session > 0 ? Math.min(1, total / cfg.session) : 0
209 const full = Math.min(cells, Math.floor(frac * cells))
210 return '▰'.repeat(full) + '▱'.repeat(cells - full)
211}
212
213export function mainOf(tally: Tally): number {
214 return tally.byAgent[MAIN] ?? 0
215}
216
217export function agentsOf(tally: Tally): number {
218 return Math.max(0, tally.total - mainOf(tally))
219}
220
221/**
222 * The band's pieces: `used` (bar, used, percent: the band's budget chip), `info` (main, agents, ctx,
223 * enforcement: its quiet right side), and `head` and `rest` for the plain-text line.
224 */
225export function bandParts(
226 tally: Tally,
227 cfg: Config,
228 ctx: number | null,
229): { head: string; rest: string; used: string; info: string[] } {
230 const used =
231 cfg.session > 0
232 ? `${barOf(tally.total, cfg)} ${fmt(tally.total)} / ${fmt(cfg.session)} ${percentOf(tally.total, cfg)}%`
233 : `${fmt(tally.total)} (no limit)`
234 const info = [`main ${fmt(mainOf(tally))}`, `agents ${fmt(agentsOf(tally))}`]
235 if (typeof ctx === 'number' && Number.isFinite(ctx)) info.push(`ctx ${Math.round(ctx)}%`)
236 if (!cfg.enforce) info.push('enforcement off')
237 return { head: 'budget ' + used, rest: ' · ' + info.join(' · '), used, info }
238}
239
240/** The band's one line, without colors; `cache` is the cache part (cache.ts bandCache) when known. */
241export function bandText(tally: Tally, cfg: Config, ctx: number | null, cache?: string): string {
242 const { head, rest } = bandParts(tally, cfg, ctx)
243 return head + rest + (cache ? ' · ' + cache : '')
244}
245
246export function sessionDenyText(tally: Tally, cfg: Config, action: Action): string {
247 const spent = `token-budget: session budget spent (${fmt(tally.total)} of ${fmt(cfg.session)}).`
248 return action === 'stop'
249 ? `${spent} Stop and report to the user; they can run /budget set <n> or /budget action warn to continue.`
250 : `${spent} No new subagents; finish with what you have, or the user can raise it with /budget set <n>.`
251}
252
253export function agentDenyText(used: number, cfg: Config): string {
254 return `token-budget: this subagent used ${fmt(used)} tokens (limit ${fmt(cfg.perAgent)}); wrap up and return your result now.`
255}
256
257export function averagePerTurn(tally: Tally): number | null {
258 if (tally.perTurn.length === 0) return null
259 return tally.perTurn.reduce((a, b) => a + b, 0) / tally.perTurn.length
260}
261
262export function turnsLeft(tally: Tally, cfg: Config): number | null {
263 const avg = averagePerTurn(tally)
264 if (avg === null || avg <= 0 || cfg.session <= 0) return null
265 return Math.max(0, Math.floor((cfg.session - tally.total) / avg))
266}
267
268export function topAgents(tally: Tally, n = TOP_AGENTS): Array<[string, number]> {
269 return Object.entries(tally.byAgent)
270 .filter(([id]) => id !== MAIN)
271 .sort((a, b) => b[1] - a[1])
272 .slice(0, n)
273}
274
275export function modelsOf(tally: Tally): Array<[string, number]> {
276 return Object.entries(tally.byModel).sort((a, b) => b[1] - a[1])
277}
278
279/** The plain-text summary /budget text prints. */
280export function summaryText(tally: Tally, cfg: Config, ctx: number | null, lifetime: number, cache?: string): string {
281 const lines: string[] = []
282 lines.push(bandText(tally, cfg, ctx, cache))
283 const remaining = cfg.session > 0 ? fmt(Math.max(0, cfg.session - tally.total)) : 'no limit'
284 lines.push(
285 `Budget ${cfg.session > 0 ? fmt(cfg.session) : 'none'} · action ${cfg.action} · warn at ${Math.round(cfg.warnAt * 100)}% · per agent ${cfg.perAgent > 0 ? fmt(cfg.perAgent) : 'off'} · enforcement ${cfg.enforce ? 'on' : 'off'}`,
286 )
287 lines.push(
288 `Used ${fmt(tally.total)} · remaining ${remaining} · ${tally.requests} requests · ${tally.turns} turns · cache reads ${fmt(tally.cacheRead)} (${cfg.countCacheReads ? 'counted' : 'not counted'})`,
289 )
290 const models = modelsOf(tally)
291 if (models.length) lines.push('By model: ' + models.map(([m, t]) => `${m} ${fmt(t)}`).join(', '))
292 const agents = topAgents(tally)
293 if (agents.length) lines.push('Top agents: ' + agents.map(([a, t]) => `${a} ${fmt(t)}`).join(', '))
294 const avg = averagePerTurn(tally)
295 const left = turnsLeft(tally, cfg)
296 if (avg !== null) lines.push(`Average ${fmt(avg)} per turn${left !== null ? ` · about ${left} turns left` : ''}`)
297 lines.push(`Lifetime in this project: ${fmt(lifetime)}`)
298 return lines.join('\n')
299}
300
301export const HELP =
302 'Usage: /budget [set <n> | agent <n> | action warn|deny-agents|stop | warn <0-1> | reset | on | off | band on|off | keepwarm | autowarm on|off | pings on|off | text]. ' +
303 'Numbers take 500k, 2m, 1.5m or 1500000.'
304hooks/cache.ts 275 lines1// Pure helpers for the prompt-cache timer: time left, cold, which ping is due, and the
2// band, toast and log texts. Nothing here touches the mods API.
3//
4// The cache lifetime is an assumption (cacheTtlMinutes, 60 by default): Claude Code doesn't
5// report when the API drops the main session's cache entry. Cold is decided from the mod's own
6// clock: now − lastRequestAt ≥ TTL, unless a compaction came since (then the next request starts
7// a new cache anyway and there is nothing to re-warm).
8//
9// Auto keep-warm (off by default) is decided here too: autoArmed says it can act in this warm
10// window, autoWarmDue says the timer should send it now.
11
12import type { CacheState, Config } from '../types'
13import { coldWriteUsd, requestUsd } from './prices'
14import type { UsageLike } from './prices'
15
16export type { CacheState }
17
18export type CacheTone = 'ok' | 'warn' | 'danger' | 'muted'
19
20/** Below this much time left the cache chip turns to the warn tone. */
21export const WARN_MS = 5 * 60_000
22
23/** The cache settings and prices bandCache and coldUsd read. */
24export type CacheConfig = Pick<Config, 'cacheTtlMinutes' | 'prices'>
25
26/** The auto keep-warm settings autoArmed, autoWarmDue and autoWarmStatus read. */
27export type AutoConfig = Pick<Config, 'autoKeepWarm' | 'autoKeepWarmMinutes' | 'autoKeepWarmMax'>
28
29export function emptyCache(): CacheState {
30 return {
31 lastRequestAt: 0,
32 model: null,
33 contextTokens: 0,
34 compacted: false,
35 lastPing: null,
36 cold: false,
37 autoWarms: 0,
38 autoTriedAt: 0,
39 autoHalted: false,
40 turnRunning: false,
41 }
42}
43
44/** A finite, non-negative count, or 0 (state saved before a field existed reads as 0). */
45function count(v: unknown): number {
46 return typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : 0
47}
48
49export function ttlOf(cfg: Pick<Config, 'cacheTtlMinutes'>): number {
50 const m = cfg.cacheTtlMinutes
51 return (typeof m === 'number' && Number.isFinite(m) && m > 0 ? m : 60) * 60_000
52}
53
54/** A request answered now: a new warm window, with no ping fired in it yet. */
55export function afterRequest(state: CacheState, now: number, model?: string | null): CacheState {
56 return {
57 ...state,
58 lastRequestAt: now,
59 model: model || state.model,
60 compacted: false,
61 lastPing: null,
62 cold: false,
63 }
64}
65
66/**
67 * A keep-warm answered now: a new warm window, like a request. An automatic one counts toward the
68 * cap; one that re-wrote the cache instead of reading it halts auto keep-warm until the next turn.
69 */
70export function afterKeepWarm(state: CacheState, now: number, auto: boolean, rewrote = false): CacheState {
71 return {
72 ...afterRequest(state, now, state.model),
73 autoWarms: count(state.autoWarms) + (auto ? 1 : 0),
74 autoHalted: state.autoHalted === true || (auto && rewrote),
75 }
76}
77
78/** The fork wrote more to the cache than it read from it: the prefix was billed afresh, not refreshed. */
79export function rewroteCache(usage: UsageLike | null | undefined): boolean {
80 if (!usage || typeof usage !== 'object') return false
81 return count(usage.cache_creation_input_tokens) > count(usage.cache_read_input_tokens)
82}
83
84/** Milliseconds until the cache goes cold, 0 once it has; null before the first request. */
85export function timeLeftMs(state: CacheState, now: number, ttlMs: number): number | null {
86 if (!(state.lastRequestAt > 0)) return null
87 return Math.max(0, state.lastRequestAt + ttlMs - now)
88}
89
90/** Cold by the mod's own clock: a request was made, no compaction since, and the TTL has passed. */
91export function isCold(state: CacheState, now: number, ttlMs: number): boolean {
92 return state.lastRequestAt > 0 && !state.compacted && now - state.lastRequestAt >= ttlMs
93}
94
95/**
96 * The warn threshold (minutes) to ping for now, or null. The due threshold is the smallest one
97 * the time left has reached; it fires when no threshold at or below it fired in this warm window,
98 * so each fires once, and a late tick fires only the most urgent one. A new request clears lastPing.
99 */
100export function duePing(state: CacheState, now: number, ttlMs: number, thresholdsMin: number[]): number | null {
101 if (state.compacted || isCold(state, now, ttlMs)) return null
102 const left = timeLeftMs(state, now, ttlMs)
103 if (left === null) return null
104 const reached = (Array.isArray(thresholdsMin) ? thresholdsMin : [])
105 .filter(t => typeof t === 'number' && Number.isFinite(t) && t > 0 && t * 60_000 < ttlMs && left <= t * 60_000)
106 .sort((a, b) => a - b)
107 const due = reached[0]
108 if (due === undefined) return null
109 if (state.lastPing !== null && due >= state.lastPing) return null
110 return due
111}
112
113/**
114 * Auto keep-warm can act in this warm window: on, firing under the TTL, a request seen and no
115 * compaction since, no turn running, not halted, under the cap, and not tried in this window yet.
116 * While it is armed the timer skips the pings, because it will refresh the cache itself.
117 */
118export function autoArmed(state: CacheState, ttlMs: number, cfg: AutoConfig): boolean {
119 if (!cfg.autoKeepWarm || state.turnRunning === true || state.autoHalted === true) return false
120 if (!(state.lastRequestAt > 0) || state.compacted) return false
121 if (!(cfg.autoKeepWarmMinutes * 60_000 < ttlMs)) return false
122 if (count(state.autoWarms) >= cfg.autoKeepWarmMax) return false
123 return state.autoTriedAt !== state.lastRequestAt
124}
125
126/** The timer should send the automatic keep-warm now: armed, still warm, and autoKeepWarmMinutes or less left. */
127export function autoWarmDue(state: CacheState, now: number, ttlMs: number, cfg: AutoConfig): boolean {
128 if (!autoArmed(state, ttlMs, cfg) || isCold(state, now, ttlMs)) return false
129 const left = timeLeftMs(state, now, ttlMs)
130 return left !== null && left <= cfg.autoKeepWarmMinutes * 60_000
131}
132
133/** One line on the auto keep-warm for the pane, /budget autowarm and /budget text. */
134export function autoWarmStatus(state: CacheState, ttlMs: number, cfg: AutoConfig, overBudget = false): string {
135 if (!cfg.autoKeepWarm) return 'off'
136 const min = cfg.autoKeepWarmMinutes
137 if (!(min * 60_000 < ttlMs)) return `on, but ${min} min is not under the ${ttlMs / 60_000} min cache lifetime, so it never fires`
138 const n = Math.min(count(state.autoWarms), cfg.autoKeepWarmMax)
139 const parts = [`on · at ${min} min left · ${n} of ${cfg.autoKeepWarmMax} this idle stretch`]
140 if (overBudget) parts.push('paused: session budget spent')
141 else if (state.autoHalted === true) parts.push('stopped: the last fork re-wrote the cache')
142 else if (n >= cfg.autoKeepWarmMax) parts.push('cap reached until the next turn')
143 else if (state.turnRunning === true) parts.push('waits while a turn runs')
144 return parts.join(' · ')
145}
146
147/** About what one keep-warm costs: the context at the cache-read price; null without a price or a context size. */
148export function refreshUsd(state: CacheState, cfg: Pick<Config, 'prices'>): number | null {
149 if (!state.model || !(state.contextTokens > 0)) return null
150 return requestUsd({ cache_read_input_tokens: state.contextTokens }, state.model, cfg.prices)
151}
152
153/** What a cold resend of the context costs, or null when the model or the context size is unknown. */
154export function coldUsd(state: CacheState, cfg: Pick<Config, 'prices'>): number | null {
155 if (!state.model || !(state.contextTokens > 0)) return null
156 return coldWriteUsd(state.contextTokens, state.model, cfg.prices)
157}
158
159/** '$1.23', '<$0.01', '$124'. */
160export function usdText(usd: number): string {
161 if (!Number.isFinite(usd) || usd <= 0) return '$0.00'
162 if (usd < 0.005) return '<$0.01'
163 if (usd >= 100) return '$' + String(Math.round(usd))
164 return '$' + usd.toFixed(2)
165}
166
167/** '54:12', '60:00', '0:05': minutes keep counting past 59. */
168export function mmss(ms: number): string {
169 const total = Number.isFinite(ms) && ms > 0 ? Math.floor(ms / 1000) : 0
170 const s = total % 60
171 return `${Math.floor(total / 60)}:${s < 10 ? '0' + s : s}`
172}
173
174/** '240k', '1.2M', '950'. */
175export function tokensText(n: number): string {
176 if (!Number.isFinite(n) || n <= 0) return '0'
177 if (n >= 1_000_000) return (Math.round(n / 100_000) / 10).toString() + 'M'
178 if (n >= 1_000) return String(Math.round(n / 1_000)) + 'k'
179 return String(Math.round(n))
180}
181
182/** The band's cache part and its tone. */
183export function bandCache(state: CacheState, now: number, cfg: CacheConfig): { text: string; tone: CacheTone } {
184 const ttl = ttlOf(cfg)
185 if (!(state.lastRequestAt > 0)) return { text: 'cache · no request yet', tone: 'muted' }
186 if (state.compacted) return { text: 'cache · compacted, next request starts fresh', tone: 'muted' }
187 const usd = coldUsd(state, cfg)
188 if (isCold(state, now, ttl)) {
189 return { text: usd === null ? 'cache cold' : `cache cold · resend ≈ ${usdText(usd)}`, tone: 'danger' }
190 }
191 const left = timeLeftMs(state, now, ttl) ?? 0
192 const cost = usd === null ? '' : ` · cold resend ≈ ${usdText(usd)}`
193 return { text: `cache warm · ${mmss(left)} left${cost}`, tone: left < WARN_MS ? 'warn' : 'ok' }
194}
195
196/**
197 * The band's cache chip and the note for its quiet right side: `● cache warm 54:12 left` (ok, warn
198 * in the last five minutes) with `cold resend ≈ $1.92`, `● cache cold` (danger) with `resend ≈ $1.92`,
199 * or a muted chip before the first request and after a compaction. `note` is '' when there is none.
200 */
201export function bandChip(state: CacheState, now: number, cfg: CacheConfig): { chip: string; tone: CacheTone; note: string } {
202 const ttl = ttlOf(cfg)
203 if (!(state.lastRequestAt > 0)) return { chip: 'cache: no request yet', tone: 'muted', note: '' }
204 if (state.compacted) return { chip: 'cache compacted', tone: 'muted', note: 'next request starts fresh' }
205 const usd = coldUsd(state, cfg)
206 if (isCold(state, now, ttl)) return { chip: 'cache cold', tone: 'danger', note: usd === null ? '' : `resend ≈ ${usdText(usd)}` }
207 const left = timeLeftMs(state, now, ttl) ?? 0
208 return {
209 chip: `cache warm ${mmss(left)} left`,
210 tone: left < WARN_MS ? 'warn' : 'ok',
211 note: usd === null ? '' : `cold resend ≈ ${usdText(usd)}`,
212 }
213}
214
215/** The pane tile's short cache state. */
216export function cacheTile(state: CacheState, now: number, cfg: CacheConfig): { value: string; tone: CacheTone } {
217 const ttl = ttlOf(cfg)
218 if (!(state.lastRequestAt > 0)) return { value: 'no request yet', tone: 'muted' }
219 if (state.compacted) return { value: 'compacted', tone: 'muted' }
220 if (isCold(state, now, ttl)) return { value: 'cold', tone: 'danger' }
221 const left = timeLeftMs(state, now, ttl) ?? 0
222 return { value: `warm ${mmss(left)}`, tone: left < WARN_MS ? 'warn' : 'ok' }
223}
224
225function rateText(state: CacheState, usd: number | null): string {
226 if (usd === null || !(state.contextTokens > 0)) return ''
227 const perM = Math.round(((usd * 1e6) / state.contextTokens) * 100) / 100
228 return ` (${tokensText(state.contextTokens)} tokens at $${perM}/MTok)`
229}
230
231/** The toast at a warn threshold; with ttlMs, a late tick says the real minutes left. */
232export function pingText(state: CacheState, now: number, thresholdMin: number, usd: number | null, ttlMs?: number): string {
233 let min = thresholdMin
234 const left = typeof ttlMs === 'number' ? timeLeftMs(state, now, ttlMs) : null
235 if (left !== null && left > 0) min = Math.min(thresholdMin, Math.ceil(left / 60_000))
236 const cost = usd === null ? '' : `; a resend after that costs ≈ ${usdText(usd)}${rateText(state, usd)}`
237 return `Prompt cache goes cold in ${min} min${cost}. Keep warm: /budget keepwarm, or w on the band.`
238}
239
240/** The toast once the timer sees the cache go cold. */
241export function coldText(state: CacheState, usd: number | null): string {
242 const cost = usd === null ? '' : ` The next send re-writes the context, ≈ ${usdText(usd)}${rateText(state, usd)}.`
243 return `Prompt cache went cold.${cost}`
244}
245
246/** The log line and toast when a prompt is sent while the cache is cold. */
247export function coldSendText(state: CacheState, usd: number | null): string {
248 const what = state.contextTokens > 0 ? `${tokensText(state.contextTokens)} tokens of context` : 'the context'
249 const cost = usd === null ? '' : ` at the cache-write price, ≈ ${usdText(usd)}`
250 return `Cache cold: this prompt re-writes ${what}${cost}.`
251}
252
253/**
254 * The dim log line after a keep-warm answered, with what it read, wrote and cost. `state` is the
255 * state after afterKeepWarm, so an automatic one's count already includes it.
256 */
257export function keepWarmText(
258 state: CacheState,
259 cfg: Pick<Config, 'cacheTtlMinutes' | 'prices' | 'autoKeepWarmMax'>,
260 usage: UsageLike,
261 auto: boolean,
262 rewrote = false,
263): string {
264 const usd = requestUsd(usage, state.model, cfg.prices)
265 const figures =
266 `read ${tokensText(count(usage.cache_read_input_tokens))} · wrote ${tokensText(count(usage.cache_creation_input_tokens))}` +
267 (usd === null ? '' : ` · ≈ ${usdText(usd)}`)
268 if (!auto) return `keep-warm: cache refreshed for ${cfg.cacheTtlMinutes} min · ${figures}`
269 const n = count(state.autoWarms)
270 const head = `auto keep-warm ${n}/${cfg.autoKeepWarmMax}`
271 if (rewrote) return `${head}: the fork re-wrote the cache instead of reading it (${figures}); auto keep-warm stops until the next turn.`
272 const last = n >= cfg.autoKeepWarmMax ? ' · cap reached, no more until the next turn' : ''
273 return `${head}: cache refreshed for ${cfg.cacheTtlMinutes} min · ${figures}${last}`
274}
275hooks/kit.ts 955 lines1// kit.ts: the shared UI kit (kit v3).
2//
3// The module has no imports and touches no mods API: every helper takes the elements a hook got
4// from `$.ui.resolve(e)` and the surface (`e.surface`) and returns an element tree.
5//
6// const k = makeKit($.ui.resolve(e) as unknown as Elements, e.surface)
7// return k.stack([k.hero({ kicker: 'right now', title: 'Reading client.test.js' }), k.tiles([...])], 1)
8//
9// What v2 changed on purpose (v1 painted grey fills and dimmed grey text, unreadable on the desktop):
10// - colors are theme keys (suggestion, success, warning, error, claude, inactive) that the host
11// tunes for light and dark; PALETTE below swaps them for named colors in one line;
12// - no dimColor, no backgroundColor, no yellow, white, gray, black or subtle on content: secondary
13// text is `inactive`, state is a bold `● word` chip (glyph and word, so color is never the only cue);
14// - hero and card are one round frame (paddingX 1), tiles are single-border boxes with the label
15// over a bold value; terminal and desktop draw the same structure (the Svg doodle is desktop-only);
16// - kicker, section and tile labels keep the caller's case (v1 upper-cased kicker and section);
17// - additive: line(label, chips, right?) draws one band line.
18//
19// Calibration (claude plugin test, Claude Code 2.1.294): the test harness validates the tree a
20// ui.render hook returns (a Text with a bogus prop makes `$.ui.mount` reject on both surfaces), but
21// not how the desktop paints a theme key. A Button given `plain: false` throws when built (plain and
22// autoFocus take only `true`), so the kit leaves false and undefined props out; a Box inside a Text
23// is refused. doodle() draws its Svg on the desktop only (a pane holding only an Svg opens empty in
24// the terminal).
25//
26// What v3 adds (strictly additive: every v2 export above renders as before). The desktop paints
27// Svg, and its light theme made the v1 fills unreadable, so v3 draws small pictures as Svg wherever
28// the element table has one (desktop, mobile, vscode) and as text on the terminal:
29// - pill(text, tone?): a fully rounded Svg chip (PILL 'svg', the default); PILL 'fill' draws a
30// theme-key backgroundColor under `inverseText` instead (the only fill in the kit); the v2
31// `● text` chip on the terminal;
32// - header(label, right?): an upper-cased `inactive` section label with a right-hand count;
33// - icon(name, tone?), typeBadge(fileName) and progress(ratio, opts?): Svg icons, file-type badges
34// and a rounded bar; a glyph, a bold label and the v2 bar on the terminal;
35// - buttons() items take an optional `variant` ('primary' | 'secondary');
36// - nav(items, currentKey): a pane's nav bar, the current item bold Text, the others plain Buttons;
37// - pure helpers: pillSvg, shortPath and extOf; TONE_HEX, the Svg colors of icons and bars, and
38// CHIP_HEX, the darker pill fills that keep white text at 4.5:1 or more.
39// Svg markup cannot read theme keys, so it uses fixed mid-tone hexes that read on light and dark
40// grounds (never a color word), and each distinct drawing is built once and cached.
41//
42// What v3.1 adds (spacing; additive): on the desktop, blocks stacked with no gap touch border to
43// border and wrapped rows sit line on line. So every wrapping row (buttons, tiles, nav) gets rowGap 1,
44// buttons get columnGap 2, and two new columns space a pane out:
45// - page(children): a pane's top-level column, one row between blocks;
46// - list(children): a column of repeated cards or blocks, one row between them.
47// Rows inside one card or block stay tight (stack with no gap); stack() keeps its props. Bands that
48// draw buttons() are one row, so the new gaps change nothing there. buttons() items also take
49// `autoFocus` (passed only when true).
50
51export const KIT_VERSION = '3.1.0'
52
53/** How pill() draws on a surface with Svg: 'svg' (a fully rounded chip) or 'fill' (a theme-key
54 * background). makeKit's `pill` option overrides it for one kit. */
55export const PILL: 'fill' | 'svg' = 'svg'
56
57/** kit v3: makeKit's options. `pill` overrides PILL for this kit. */
58export type KitOptions = { pill?: 'fill' | 'svg' }
59
60/** 'theme': theme keys that follow the person's light or dark theme. 'named': named colors, for a
61 * host that does not paint theme keys (never yellow: warn is red plus its glyph). One-line switch. */
62export const PALETTE: 'theme' | 'named' = 'theme'
63
64export type Surface = 'terminal' | 'desktop' | (string & {})
65export type Tone = 'info' | 'ok' | 'warn' | 'danger' | 'muted' | 'accent'
66export type Role = 'main' | 'recon' | 'execute' | 'verify' | 'hard' | 'reviewer' | 'other'
67export type DoodleState = 'running' | 'done' | 'failed' | 'waiting' | 'idle'
68/** The tones of pill() and icon(): the six tones plus violet. */
69export type PillTone = Tone | 'violet'
70export type IconName =
71 | 'eye'
72 | 'pencil'
73 | 'plus'
74 | 'branch'
75 | 'search'
76 | 'folder'
77 | 'folder-open'
78 | 'file'
79 | 'shield'
80 | 'flag'
81 | 'check'
82 | 'dot'
83 | 'clock'
84/** What `$.ui.resolve(e)` returns, loosely typed so the kit compiles without the engine's types. */
85export type Elements = Record<string, (props: Record<string, unknown>) => unknown>
86
87export type Kit = {
88 hero(p: { kicker?: string; title: string; sub?: string; icon?: string; tone?: Tone }): unknown
89 card(p: { title?: string; right?: string; tone?: Tone; children: unknown[] }): unknown
90 section(label: string, right?: string): unknown
91 tiles(items: { label: string; value: string; tone?: Tone }[]): unknown
92 badge(text: string, tone?: Tone): unknown
93 legend(items: { tone: Tone; label: string }[]): unknown
94 bar(ratio: number, opts?: { width?: number; tone?: Tone }): unknown
95 /** One row, left side truncating, right side muted. `align: 'center'` (kit v3) centers a row holding a pill. */
96 row(p: { left: unknown[] | string; right?: unknown[] | string; key?: string; align?: 'center' }): unknown
97 kv(label: string, value: string, tone?: Tone): unknown
98 buttons(
99 items: {
100 key: string
101 label: string
102 hotkey?: string
103 onPress: (e?: unknown) => unknown
104 plain?: boolean
105 /** kit v3: the Button's variant, passed only when set. */
106 variant?: 'primary' | 'secondary'
107 /** kit 3.1: focus this Button when the pane opens; passed only when true. */
108 autoFocus?: boolean
109 }[],
110 ): unknown
111 doodle(role: Role, state?: DoodleState): unknown
112 stack(children: unknown[], gap?: number): unknown
113 /** kit 3.1: a pane's top-level column, one row between its blocks; null children are dropped. */
114 page(children: unknown[]): unknown
115 /** kit 3.1: a column of repeated cards or blocks, one row between them; null children are dropped. */
116 list(children: unknown[]): unknown
117 text(s: string, props?: Record<string, unknown>): unknown
118 /**
119 * One band line: `label ● a ● b right · text`. A chip is `{ text, tone? }`, a string (a muted
120 * chip) or an element such as badge(); at most four are drawn. `right` is a string or a list
121 * joined with ` · `, drawn `inactive` and truncated. One row high.
122 */
123 line(label: string, chips: unknown[], right?: string | string[]): unknown
124 /** kit v3: a rounded chip (Svg surfaces) or the v2 `● text` chip (terminal). */
125 pill(text: string, tone?: Tone | 'violet'): unknown
126 /** kit v3: one row, the label upper-cased, bold and `inactive`; `right` upper-cased, right-aligned. */
127 header(label: string, right?: string): unknown
128 /** kit v3: a 16×16 stroke icon (Svg surfaces) or one glyph in the tone (terminal). */
129 icon(name: IconName, tone?: Tone | 'violet'): unknown
130 /** kit v3: a file-type badge ('JS', 'PDF', …) for a file name; null when the name has no extension. */
131 typeBadge(fileName: string): unknown
132 /** kit v3: a rounded bar (Svg surfaces; `width` in CSS pixels, absent: the slot's width) or bar(). */
133 progress(ratio: number, opts?: { width?: number; tone?: Tone }): unknown
134 /**
135 * kit v3: a pane's nav bar, one row keyed 'nav': the item whose key is `currentKey` as bold Text in the accent
136 * tone (not pressable), the others plain Buttons with their hotkeys (`1: Stage`); one without onPress quiet Text.
137 */
138 nav(items: { key: string; label: string; hotkey?: string; onPress?: () => unknown }[], currentKey: string): unknown
139 surface: Surface
140}
141
142/** The quiet color: secondary text, labels, units, timestamps, frame and tile borders. */
143const QUIET = 'inactive'
144
145const THEME_TONES: Record<Tone, string> = {
146 info: 'suggestion',
147 ok: 'success',
148 warn: 'warning',
149 danger: 'error',
150 muted: QUIET,
151 accent: 'claude',
152}
153
154const NAMED_TONES: Record<Tone, string> = {
155 info: 'blue',
156 ok: 'green',
157 warn: 'red',
158 danger: 'red',
159 muted: QUIET,
160 accent: 'magenta',
161}
162
163const TONE_COLORS = PALETTE === 'named' ? NAMED_TONES : THEME_TONES
164
165/** fg: the color of the tone, a theme key (a named color when PALETTE is 'named'). bg equals fg: v2
166 * draws no fills; the field stays so v1 callers compile. */
167export const TONES: Record<Tone, { fg: string; bg: string }> = {
168 info: { fg: TONE_COLORS.info, bg: TONE_COLORS.info },
169 ok: { fg: TONE_COLORS.ok, bg: TONE_COLORS.ok },
170 warn: { fg: TONE_COLORS.warn, bg: TONE_COLORS.warn },
171 danger: { fg: TONE_COLORS.danger, bg: TONE_COLORS.danger },
172 muted: { fg: TONE_COLORS.muted, bg: TONE_COLORS.muted },
173 accent: { fg: TONE_COLORS.accent, bg: TONE_COLORS.accent },
174}
175
176/**
177 * kit v3: the Svg colors of icons and bars, a mid-tone hex per tone that reads on light and dark grounds
178 * (Svg markup cannot read theme keys). warn and accent share the Claude orange: the theme's warning
179 * color is unreadable as a fill. A pill fills with CHIP_HEX instead.
180 *
181 * info #3B82F6 ok #22A35A warn #D97757 danger #E5484D accent #D97757 muted #8A8F98
182 * violet #8B5CF6
183 */
184export const TONE_HEX: Record<PillTone, string> = {
185 info: '#3B82F6',
186 ok: '#22A35A',
187 warn: '#D97757',
188 danger: '#E5484D',
189 accent: '#D97757',
190 muted: '#8A8F98',
191 violet: '#8B5CF6',
192}
193
194/**
195 * kit v3: a pill's fill per tone, darker than TONE_HEX so its white text has a contrast ratio of at
196 * least 4.5:1 (WCAG AA for small text). The ratios against white:
197 *
198 * info #2563EB 5.17 ok #15803D 5.02 warn #C2410C 5.18 danger #B91C1C 6.47 accent #C2410C 5.18
199 * muted #5B6170 6.20 violet #7C3AED 5.70
200 */
201export const CHIP_HEX: Record<PillTone, string> = {
202 info: '#2563EB',
203 ok: '#15803D',
204 warn: '#C2410C',
205 danger: '#B91C1C',
206 accent: '#C2410C',
207 muted: '#5B6170',
208 violet: '#7C3AED',
209}
210
211/** kit v3: the theme key a pill fills with, per tone (warn is 'claude': a 'warning' fill is unreadable). */
212const PILL_FILLS: Record<PillTone, string> = {
213 info: 'suggestion',
214 ok: 'success',
215 warn: 'claude',
216 danger: 'error',
217 accent: 'claude',
218 muted: QUIET,
219 violet: 'permission',
220}
221
222/** kit v3: the stroke of an icon drawn without a tone. */
223const ICON_STROKE = '#6B7280'
224/** kit v3: the track of progress(): the muted hex at quarter opacity. */
225const TRACK_HEX = '#8A8F98'
226/** kit v3: the text on a light fill (the amber JS badge); light text everywhere else. */
227const INK_DARK = '#1A1A1A'
228const INK_LIGHT = '#FFFFFF'
229
230/** kit v3: icon markup on a 16×16 grid, stroked with `currentColor`'s stand-in `S`. */
231const ICONS: Record<IconName, string> = {
232 eye: '<path d="M1.5 8S4 3.5 8 3.5 14.5 8 14.5 8 12 12.5 8 12.5 1.5 8 1.5 8z"/><circle cx="8" cy="8" r="2"/>',
233 pencil: '<path d="M11 2.5 13.5 5 5.5 13H3v-2.5z"/><path d="M9.5 4 12 6.5"/>',
234 plus: '<path d="M8 3v10M3 8h10"/>',
235 branch:
236 '<circle cx="4.5" cy="3.5" r="1.5"/><circle cx="4.5" cy="12.5" r="1.5"/><circle cx="11.5" cy="5" r="1.5"/>' +
237 '<path d="M4.5 5v6M11.5 6.5c0 2.5-2 3.5-4 3.8-1.6.2-3 .7-3 .7"/>',
238 search: '<circle cx="7" cy="7" r="4.5"/><path d="M10.5 10.5 14 14"/>',
239 folder: '<path d="M1.75 3.25h4.5l1.5 1.75h6.5v7.75H1.75z"/>',
240 'folder-open': '<path d="M1.75 12.75v-9.5h4.5l1.5 1.75h5v2"/><path d="M1.75 12.75 3.75 7h10.5l-2 5.75z"/>',
241 file: '<path d="M9 1.75H3v12.5h10v-8.5z"/><path d="M9 1.75v4h4"/>',
242 shield: '<path d="M8 1.75 13 3.75v4c0 3.2-2.2 5.4-5 6.5-2.8-1.1-5-3.3-5-6.5v-4z"/>',
243 flag: '<path d="M3 14.5V2"/><path d="M3 2.5h8.5L9.75 5.5l1.75 3H3"/>',
244 check: '<path d="M3 8.5 6 11.5 13 4.5"/>',
245 dot: '<circle cx="8" cy="8" r="3" fill="S"/>',
246 clock: '<circle cx="8" cy="8" r="6"/><path d="M8 4.5V8l2.5 1.5"/>',
247}
248
249/** kit v3: the terminal glyph of each icon. */
250const ICON_GLYPHS: Record<IconName, string> = {
251 eye: '◉',
252 pencil: '✎',
253 plus: '+',
254 branch: '⎇',
255 search: '⌕',
256 folder: '▸',
257 'folder-open': '▾',
258 file: '·',
259 shield: '⛨',
260 flag: '⚑',
261 check: '✓',
262 dot: '●',
263 clock: '◷',
264}
265
266/** kit v3: file-type badges by extension: the label, its Svg fill and its terminal tone. */
267const FILE_TYPES: { exts: string[]; label: string; fill: string; tone: Tone }[] = [
268 { exts: ['js', 'jsx', 'mjs', 'cjs'], label: 'JS', fill: '#F0C419', tone: 'accent' },
269 { exts: ['ts', 'tsx'], label: 'TS', fill: '#3178C6', tone: 'info' },
270 { exts: ['pdf'], label: 'PDF', fill: '#E5484D', tone: 'danger' },
271 { exts: ['md', 'mdx'], label: 'MD', fill: '#4A7BD0', tone: 'info' },
272 { exts: ['doc', 'docx'], label: 'DOC', fill: '#2B579A', tone: 'info' },
273 { exts: ['csv'], label: 'CSV', fill: '#22A35A', tone: 'ok' },
274 { exts: ['json'], label: 'JSON', fill: '#64748B', tone: 'muted' },
275 { exts: ['yml', 'yaml'], label: 'YML', fill: '#64748B', tone: 'muted' },
276 { exts: ['html'], label: 'HTML', fill: '#E8743B', tone: 'accent' },
277 { exts: ['css'], label: 'CSS', fill: '#4A7BD0', tone: 'info' },
278 { exts: ['py'], label: 'PY', fill: '#3776AB', tone: 'info' },
279 { exts: ['sh'], label: 'SH', fill: '#64748B', tone: 'muted' },
280 { exts: ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp'], label: 'IMG', fill: '#8B5CF6', tone: 'accent' },
281 { exts: ['txt'], label: 'TXT', fill: '#64748B', tone: 'muted' },
282]
283const OTHER_FILL = '#64748B'
284
285const GLYPHS: Record<Role, string> = {
286 main: '◉',
287 recon: '◈',
288 execute: '▣',
289 verify: '✓',
290 hard: '◆',
291 reviewer: '◎',
292 other: '●',
293}
294
295const STATE_TONES: Record<DoodleState, Tone> = {
296 running: 'info',
297 done: 'ok',
298 failed: 'danger',
299 waiting: 'warn',
300 idle: 'muted',
301}
302
303const STATE_COLORS: Record<DoodleState, string> = {
304 running: TONES[STATE_TONES.running].fg,
305 done: TONES[STATE_TONES.done].fg,
306 failed: TONES[STATE_TONES.failed].fg,
307 waiting: TONES[STATE_TONES.waiting].fg,
308 idle: TONES[STATE_TONES.idle].fg,
309}
310
311// Desktop doodles (svg fills are not text colors, so they stay hex): 12×12 cells, 3 px each. B = body (by role), A = accent (by state), . = empty.
312const BODY_FILL: Record<Role, string> = {
313 main: '#6c63ff',
314 recon: '#1f9eaa',
315 execute: '#3b7ddd',
316 verify: '#2f9e55',
317 hard: '#b5523b',
318 reviewer: '#c08a1e',
319 other: '#7d8590',
320}
321
322const ACCENT_FILL: Record<DoodleState, string> = {
323 running: '#22d3ee',
324 done: '#4ade80',
325 failed: '#f87171',
326 waiting: '#facc15',
327 idle: '#e5e7eb',
328}
329
330const SPRITES: Record<Role, string[]> = {
331 // a crowned blob
332 main: [
333 '............',
334 '..A..AA..A..',
335 '..AAAAAAAA..',
336 '..BBBBBBBB..',
337 '.BBBBBBBBBB.',
338 '.BBAABBAABB.',
339 '.BBAABBAABB.',
340 '.BBBBBBBBBB.',
341 '.BBBAAAABBB.',
342 '.BBBBBBBBBB.',
343 '..BB....BB..',
344 '..BB....BB..',
345 ],
346 // a one-eyed scout drone
347 recon: [
348 '....BBBB....',
349 '..BBBBBBBB..',
350 '.BBBBBBBBBB.',
351 'BBBBAAAABBBB',
352 'BBBAAAAAABBB',
353 'BBBAA..AABBB',
354 'BBBAAAAAABBB',
355 'BBBBAAAABBBB',
356 '.BBBBBBBBBB.',
357 '..BBBBBBBB..',
358 '...B.BB.B...',
359 '..B..BB..B..',
360 ],
361 // a boxy robot with an antenna
362 execute: [
363 '.....AA.....',
364 '.....BB.....',
365 '.BBBBBBBBBB.',
366 '.BBBBBBBBBB.',
367 '.BBAABBAABB.',
368 '.BBAABBAABB.',
369 '.BBBBBBBBBB.',
370 '.BBAAAAAABB.',
371 '.BBBBBBBBBB.',
372 '...BBBBBB...',
373 '..BBB..BBB..',
374 '..BB....BB..',
375 ],
376 // a shield with a check mark
377 verify: [
378 '.BBBBBBBBBB.',
379 'BBBBBBBBBBBB',
380 'BBBBBBBBBBBB',
381 'BBBBBBBBBAAB',
382 'BBBBBBBBAABB',
383 'BAABBBBAABBB',
384 'BBAABBAABBBB',
385 '.BBAAAABBBB.',
386 '.BBBAABBBBB.',
387 '..BBBBBBBB..',
388 '...BBBBBB...',
389 '.....BB.....',
390 ],
391 // a gem golem
392 hard: [
393 '.....BB.....',
394 '....BBBB....',
395 '...BBBBBB...',
396 '..BBBBBBBB..',
397 '.BBAABBAABB.',
398 'BBBAABBAABBB',
399 'BBBBBBBBBBBB',
400 '.BBBBAABBBB.',
401 '..BBBBBBBB..',
402 '...BBBBBB...',
403 '....BBBB....',
404 '.....BB.....',
405 ],
406 // a round face with glasses
407 reviewer: [
408 '...BBBBBB...',
409 '..BBBBBBBB..',
410 '.BBBBBBBBBB.',
411 'BBBBBBBBBBBB',
412 'BAAAABBAAAAB',
413 'BA..AAAA..AB',
414 'BAAAABBAAAAB',
415 'BBBBBBBBBBBB',
416 'BBBBAAAABBBB',
417 '.BBBBBBBBBB.',
418 '..BBBBBBBB..',
419 '...BBBBBB...',
420 ],
421 // a ghost
422 other: [
423 '....BBBB....',
424 '..BBBBBBBB..',
425 '.BBBBBBBBBB.',
426 '.BBBBBBBBBB.',
427 'BBBAABBAABBB',
428 'BBBAABBAABBB',
429 'BBBBBBBBBBBB',
430 'BBBBBBBBBBBB',
431 'BBBBBBBBBBBB',
432 'BBBBBBBBBBBB',
433 'BBB.BBBB.BBB',
434 'B....BB....B',
435 ],
436}
437
438const CELL = 3
439const GRID = 12
440
441// ---------------------------------------------------------------- formatters
442
443/** '≈$0.08', '≈$1.2', '≈$12'; '$0' for zero, '<$0.01' below a cent, '—' when unknown. */
444export function money(usd: number | null | undefined): string {
445 if (typeof usd !== 'number' || !Number.isFinite(usd)) return '—'
446 const v = Math.max(0, usd)
447 if (v === 0) return '$0'
448 if (v < 0.005) return '<$0.01'
449 const cents = Math.round(v * 100) / 100
450 if (cents < 1) return '≈$' + cents.toFixed(2)
451 const dimes = Math.round(v * 10) / 10
452 if (dimes < 10) return '≈$' + dimes.toFixed(1)
453 return '≈$' + String(Math.round(v))
454}
455
456/** '0', '950', '1.5k', '37k', '1.2M', '15M'. */
457export function tokensShort(n: number): string {
458 if (typeof n !== 'number' || !Number.isFinite(n) || n <= 0) return '0'
459 if (n < 1000) return String(Math.round(n))
460 const k = n / 1000
461 if (k < 10) {
462 const r = Math.round(k * 10) / 10
463 if (r < 10) return trimZero(r.toFixed(1)) + 'k'
464 }
465 if (Math.round(k) < 1000) return String(Math.round(k)) + 'k'
466 const m = n / 1_000_000
467 const r = Math.round(m * 10) / 10
468 if (r < 10) return trimZero(r.toFixed(1)) + 'M'
469 return String(Math.round(m)) + 'M'
470}
471
472/** '0:40', '10:05', '1:02:03'. */
473export function clock(ms: number): string {
474 const total = typeof ms === 'number' && Number.isFinite(ms) && ms > 0 ? Math.floor(ms / 1000) : 0
475 const h = Math.floor(total / 3600)
476 const m = Math.floor((total % 3600) / 60)
477 const s = total % 60
478 if (h > 0) return `${h}:${pad2(m)}:${pad2(s)}`
479 return `${m}:${pad2(s)}`
480}
481
482/** '4%', '100%'. Not capped at 100 (a budget can run over); negative and NaN read 0%. */
483export function pct(ratio: number): string {
484 if (typeof ratio !== 'number' || !Number.isFinite(ratio) || ratio <= 0) return '0%'
485 return String(Math.round(ratio * 100)) + '%'
486}
487
488/** '━━━━╌╌╌╌╌╌' for 0.4 at width 10; the ratio is clamped to 0..1. */
489export function barText(ratio: number, width: number = 10): string {
490 const w = typeof width === 'number' && Number.isFinite(width) && width >= 1 ? Math.floor(width) : 10
491 const filled = Math.round(clamp01(ratio) * w)
492 return '━'.repeat(filled) + '╌'.repeat(w - filled)
493}
494
495function clamp01(ratio: number): number {
496 if (typeof ratio !== 'number' || !Number.isFinite(ratio)) return 0
497 return Math.min(1, Math.max(0, ratio))
498}
499
500function pad2(n: number): string {
501 return n < 10 ? '0' + n : String(n)
502}
503
504function trimZero(s: string): string {
505 return s.endsWith('.0') ? s.slice(0, -2) : s
506}
507
508function toneOf(tone: unknown, fallback: Tone): Tone {
509 return typeof tone === 'string' && Object.prototype.hasOwnProperty.call(TONES, tone) ? (tone as Tone) : fallback
510}
511
512function roleOf(role: unknown): Role {
513 return typeof role === 'string' && Object.prototype.hasOwnProperty.call(GLYPHS, role) ? (role as Role) : 'other'
514}
515
516function stateOf(state: unknown): DoodleState {
517 return typeof state === 'string' && Object.prototype.hasOwnProperty.call(STATE_COLORS, state)
518 ? (state as DoodleState)
519 : 'idle'
520}
521
522/** Drops undefined and false props: some props (Button plain, autoFocus) take only `true`. */
523function clean(props: Record<string, unknown>): Record<string, unknown> {
524 const out: Record<string, unknown> = {}
525 for (const k of Object.keys(props)) {
526 const v = props[k]
527 if (v === undefined || v === false) continue
528 out[k] = v
529 }
530 return out
531}
532
533function present(children: unknown[]): unknown[] {
534 return children.filter(c => c !== null && c !== undefined && c !== false && c !== '')
535}
536
537/** The SVG document of one doodle: a 12×12 sprite at 3 px per cell, runs merged per row. */
538export function doodleSvg(role: Role, state: DoodleState = 'idle'): string {
539 const r = roleOf(role)
540 const s = stateOf(state)
541 const fills: Record<string, string> = { B: BODY_FILL[r], A: ACCENT_FILL[s] }
542 const size = GRID * CELL
543 let rects = ''
544 SPRITES[r].forEach((line, y) => {
545 let x = 0
546 while (x < GRID) {
547 const ch = line[x] ?? '.'
548 let end = x + 1
549 while (end < GRID && line[end] === ch) end++
550 const fill = fills[ch]
551 if (fill) {
552 rects += `<rect x="${x * CELL}" y="${y * CELL}" width="${(end - x) * CELL}" height="${CELL}" fill="${fill}"/>`
553 }
554 x = end
555 }
556 })
557 return (
558 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${size} ${size}" width="${size}" height="${size}" ` +
559 `shape-rendering="crispEdges">${rects}</svg>`
560 )
561}
562
563/** The terminal glyph of a role, as doodle() draws it and as the desktop doodle's alt text. */
564export function glyph(role: Role): string {
565 return GLYPHS[roleOf(role)]
566}
567
568// ---------------------------------------------------------------- kit v3: pure helpers and Svg
569
570/** 'ts' for 'src/a.test.TS', '' for 'Makefile', '.gitignore' or 'notes.'. Lower case, no dot. */
571export function extOf(name: string): string {
572 const base = String(name ?? '').split(/[\\/]/).pop() ?? ''
573 const dot = base.lastIndexOf('.')
574 return dot > 0 && dot < base.length - 1 ? base.slice(dot + 1).toLowerCase() : ''
575}
576
577/**
578 * A path for display: inside `root` it is relative ('.' for the root itself), inside `home` it is
579 * '~/' and the rest, anywhere else '…/' and its last two segments, so no absolute path from outside
580 * the project is drawn. A relative path is already short and comes back as given. Trailing slashes
581 * are dropped.
582 */
583export function shortPath(path: string, root: string, home: string): string {
584 const p = trimSlash(path)
585 const r = trimSlash(root)
586 const h = trimSlash(home)
587 if (!p.startsWith('/')) return p
588 if (r && p === r) return '.'
589 if (r && r !== '/' && p.startsWith(r + '/')) return p.slice(r.length + 1)
590 if (h && p === h) return '~'
591 if (h && h !== '/' && p.startsWith(h + '/')) return '~/' + p.slice(h.length + 1)
592 return '…/' + p.split('/').filter(Boolean).slice(-2).join('/')
593}
594
595function trimSlash(s: string): string {
596 const t = String(s ?? '')
597 return t.length > 1 ? t.replace(/\/+$/, '') || '/' : t
598}
599
600const SVG_CACHE = new Map<string, string>()
601const SVG_CACHE_MAX = 512
602
603/** Builds each distinct drawing once; the cache is cleared when it grows past its bound. */
604function cached(key: string, build: () => string): string {
605 const hit = SVG_CACHE.get(key)
606 if (hit !== undefined) return hit
607 if (SVG_CACHE.size >= SVG_CACHE_MAX) SVG_CACHE.clear()
608 const svg = build()
609 SVG_CACHE.set(key, svg)
610 return svg
611}
612
613function xml(s: string): string {
614 return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
615}
616
617/** Dark text on a light fill (relative luminance above one half), light text otherwise. */
618function inkOn(hex: string): string {
619 const n = parseInt(hex.slice(1), 16)
620 const lin = (c: number) => {
621 const v = c / 255
622 return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4)
623 }
624 const l = 0.2126 * lin((n >> 16) & 255) + 0.7152 * lin((n >> 8) & 255) + 0.0722 * lin(n & 255)
625 return l > 0.5 ? INK_DARK : INK_LIGHT
626}
627
628function pillToneOf(tone: unknown, fallback: PillTone): PillTone {
629 return typeof tone === 'string' && Object.prototype.hasOwnProperty.call(TONE_HEX, tone) ? (tone as PillTone) : fallback
630}
631
632function iconOf(name: unknown): IconName {
633 return typeof name === 'string' && Object.prototype.hasOwnProperty.call(ICONS, name) ? (name as IconName) : 'dot'
634}
635
636const FONT = 'font-family="system-ui,-apple-system,Segoe UI,sans-serif"'
637
638/** A label in a rounded rect: the shape of a pill (height 18) and of a type badge (height 16). */
639function labelSvg(text: string, fill: string, width: number, height: number, rx: number, size: number, ink = inkOn(fill)): string {
640 return (
641 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}">` +
642 `<rect width="${width}" height="${height}" rx="${rx}" fill="${fill}"/>` +
643 `<text x="${width / 2}" y="${height / 2}" text-anchor="middle" dominant-baseline="central" ${FONT} ` +
644 `font-size="${size}" font-weight="700" fill="${ink}">${xml(text)}</text></svg>`
645 )
646}
647
648/** A pill's height in CSS pixels; its corners are fully rounded (rx = height / 2). */
649const PILL_HEIGHT = 18
650
651/** A pill's width in CSS pixels: about 6.5 per character of 11 px bold text plus 7 each side. */
652function pillWidth(text: string): number {
653 return Math.max(PILL_HEIGHT, Math.round(text.length * 6.5 + 14))
654}
655
656/** The Svg of a pill: a fully rounded rect in the tone's CHIP_HEX fill, white text. */
657export function pillSvg(text: string, tone: Tone | 'violet' = 'muted'): string {
658 const t = pillToneOf(tone, 'muted')
659 const s = String(text)
660 return cached(`pill|${t}|${s}`, () => labelSvg(s, CHIP_HEX[t], pillWidth(s), PILL_HEIGHT, PILL_HEIGHT / 2, 11, INK_LIGHT))
661}
662
663function iconSvg(name: IconName, hex: string): string {
664 return cached(`icon|${name}|${hex}`, () =>
665 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" fill="none" ` +
666 `stroke="${hex}" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">` +
667 `${ICONS[name].replace(/"S"/g, `"${hex}"`)}</svg>`,
668 )
669}
670
671function fileType(fileName: string): { label: string; fill: string; tone: Tone } | null {
672 const ext = extOf(fileName)
673 if (!ext) return null
674 const known = FILE_TYPES.find(t => t.exts.includes(ext))
675 return known ?? { label: ext.slice(0, 3).toUpperCase(), fill: OTHER_FILL, tone: 'muted' }
676}
677
678/** A type badge is 16 high and about 7 px per letter plus 8 wide. */
679function badgeWidth(label: string): number {
680 return Math.max(16, label.length * 7 + 8)
681}
682
683function badgeSvg(label: string, fill: string): string {
684 return cached(`badge|${label}|${fill}`, () => labelSvg(label, fill, badgeWidth(label), 16, 3, 10))
685}
686
687/** A track and a fill, 6 high. Without a width the markup is 100% wide and stretches to the slot. */
688function progressSvg(ratio: number, hex: string, width: number | undefined): string {
689 const full = width ?? 100
690 const fill = Math.round(clamp01(ratio) * full * 10) / 10
691 return cached(`progress|${fill}|${hex}|${width ?? ''}`, () => {
692 const size = width === undefined ? 'width="100%" height="6" preserveAspectRatio="none"' : `width="${width}" height="6"`
693 return (
694 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${full} 6" ${size}>` +
695 `<rect width="${full}" height="6" rx="3" fill="${TRACK_HEX}" opacity="0.25"/>` +
696 (fill > 0 ? `<rect width="${fill}" height="6" rx="3" fill="${hex}"/>` : '') +
697 `</svg>`
698 )
699 })
700}
701
702// ---------------------------------------------------------------- the kit
703
704const MAX_CHIPS = 4
705
706export function makeKit(ui: Elements, surface: Surface, opts: KitOptions = {}): Kit {
707 const desktop = surface === 'desktop'
708 const pillMode = opts.pill ?? PILL
709
710 const el = (type: string, props: Record<string, unknown>, children?: unknown[]): unknown => {
711 const make = ui[type]
712 const p = clean(props)
713 if (children !== undefined) p.children = present(children)
714 return make(p)
715 }
716 const T = (s: string, props: Record<string, unknown> = {}): unknown => el('Text', props, [s])
717 const color = (tone: Tone | undefined): string | undefined => (tone ? TONES[tone].fg : undefined)
718 const pieces = (part: unknown[] | string, right: boolean): unknown[] =>
719 (typeof part === 'string' ? [part] : part).map(c =>
720 typeof c === 'string' || typeof c === 'number'
721 ? T(String(c), { color: right ? QUIET : undefined, wrap: 'truncate' })
722 : c,
723 )
724 const frame = (children: unknown[], tone: Tone | undefined): unknown =>
725 el('Box', { flexDirection: 'column', borderStyle: 'round', borderColor: color(tone) ?? QUIET, paddingX: 1 }, children)
726 const chip = (text: string, tone: Tone): unknown =>
727 T('● ' + text, { color: TONES[tone].fg, bold: tone !== 'muted', wrap: 'truncate' })
728 // kit v3: Svg wherever the table has it (desktop, mobile, vscode); the terminal draws text.
729 const svgOk = surface !== 'terminal' && typeof ui.Svg === 'function'
730 const svg = (source: string, alt: string, width?: number, height?: number): unknown =>
731 el('Svg', { source, alt: alt || ' ', width, height })
732 /** The theme key of a v3 tone on the terminal: the v2 tone's color, violet 'permission'. */
733 const fg = (tone: PillTone): string => (tone === 'violet' ? PILL_FILLS.violet : TONES[tone].fg)
734
735 const kit: Kit = {
736 surface,
737
738 hero(p) {
739 const tone = p.tone ? toneOf(p.tone, 'info') : undefined
740 const title = (p.icon ? p.icon + ' ' : '') + p.title
741 return frame(
742 [
743 p.kicker ? T(p.kicker, { color: QUIET, wrap: 'truncate' }) : null,
744 T(title, { bold: true, color: color(tone), wrap: 'truncate' }),
745 p.sub ? T(p.sub, { color: QUIET, wrap: 'wrap' }) : null,
746 ],
747 tone,
748 )
749 },
750
751 card(p) {
752 const tone = p.tone ? toneOf(p.tone, 'info') : undefined
753 const head =
754 p.title || p.right
755 ? el('Box', { flexDirection: 'row', justifyContent: 'space-between', columnGap: 1 }, [
756 p.title ? T(p.title, { bold: true, wrap: 'truncate' }) : null,
757 p.right ? T(p.right, { color: QUIET, wrap: 'truncate' }) : null,
758 ])
759 : null
760 return frame([head, ...(Array.isArray(p.children) ? p.children : [])], tone)
761 },
762
763 section(label, right) {
764 return el('Box', { flexDirection: 'row', justifyContent: 'space-between', columnGap: 1 }, [
765 T(String(label), { bold: true, wrap: 'truncate' }),
766 right ? T(right, { color: QUIET, wrap: 'truncate' }) : null,
767 ])
768 },
769
770 tiles(items) {
771 return el(
772 'Box',
773 { flexDirection: 'row', flexWrap: 'wrap', columnGap: 1, rowGap: 1 },
774 items.map(it => {
775 const tone = it.tone ? toneOf(it.tone, 'info') : undefined
776 return el(
777 'Box',
778 { flexDirection: 'column', flexGrow: 1, borderStyle: 'single', borderColor: QUIET, paddingX: 1 },
779 [
780 T(it.label, { color: QUIET, wrap: 'truncate' }),
781 T(it.value, { bold: true, color: color(tone), wrap: 'truncate' }),
782 ],
783 )
784 }),
785 )
786 },
787
788 badge(text, tone) {
789 return chip(text, toneOf(tone, 'muted'))
790 },
791
792 legend(items) {
793 const parts: unknown[] = []
794 items.forEach((it, i) => {
795 if (i > 0) parts.push(' ')
796 parts.push(T('●', { color: TONES[toneOf(it.tone, 'muted')].fg }))
797 parts.push(' ' + it.label)
798 })
799 return el('Text', { wrap: 'wrap' }, parts)
800 },
801
802 bar(ratio, opts = {}) {
803 const t = toneOf(opts.tone, 'info')
804 return el('Text', { wrap: 'truncate' }, [
805 T(barText(ratio, opts.width ?? 10), { color: TONES[t].fg }),
806 T(' ' + pct(ratio), { color: QUIET }),
807 ])
808 },
809
810 row(p) {
811 const left = pieces(p.left, false)
812 const right = p.right === undefined || p.right === '' ? [] : pieces(p.right, true)
813 const alignItems = p.align === 'center' ? 'center' : undefined
814 return el('Box', { key: p.key, flexDirection: 'row', justifyContent: 'space-between', alignItems, columnGap: 1 }, [
815 el('Box', { flexDirection: 'row', flexShrink: 1, alignItems, columnGap: 1 }, left),
816 right.length ? el('Box', { flexDirection: 'row', alignItems, columnGap: 1 }, right) : null,
817 ])
818 },
819
820 kv(label, value, tone) {
821 const t = tone ? toneOf(tone, 'info') : undefined
822 return el('Text', { wrap: 'truncate' }, [
823 T(label + ' ', { color: QUIET }),
824 T(value, { bold: true, color: color(t) }),
825 ])
826 },
827
828 buttons(items) {
829 return el(
830 'Box',
831 { flexDirection: 'row', flexWrap: 'wrap', columnGap: 2, rowGap: 1 },
832 items.map(b =>
833 el('Button', {
834 key: b.key,
835 label: b.label,
836 hotkey: b.hotkey,
837 onPress: b.onPress,
838 plain: b.plain === true ? true : undefined,
839 variant: b.variant === 'primary' || b.variant === 'secondary' ? b.variant : undefined,
840 autoFocus: b.autoFocus === true ? true : undefined,
841 }),
842 ),
843 )
844 },
845
846 doodle(role, state = 'idle') {
847 const r = roleOf(role)
848 const s = stateOf(state)
849 if (desktop && typeof ui.Svg === 'function') {
850 return el('Svg', { alt: GLYPHS[r], width: GRID * CELL, height: GRID * CELL, source: doodleSvg(r, s) })
851 }
852 return T(GLYPHS[r], { color: STATE_COLORS[s], bold: s === 'running' })
853 },
854
855 stack(children, gap = 0) {
856 return el('Box', { flexDirection: 'column', rowGap: gap > 0 ? Math.floor(gap) : undefined }, children)
857 },
858
859 page(children) {
860 return el('Box', { flexDirection: 'column', rowGap: 1 }, Array.isArray(children) ? children : [])
861 },
862
863 list(children) {
864 return el('Box', { flexDirection: 'column', rowGap: 1 }, Array.isArray(children) ? children : [])
865 },
866
867 text(s, props = {}) {
868 return el('Text', props, [String(s)])
869 },
870
871 line(label, chips, right) {
872 const drawn = (Array.isArray(chips) ? chips : [])
873 .filter(c => c !== null && c !== undefined && c !== false && c !== '')
874 .slice(0, MAX_CHIPS)
875 .map(c => {
876 if (typeof c === 'string') return chip(c, 'muted')
877 if (isChipSpec(c)) return chip(c.text, toneOf(c.tone, 'muted'))
878 return c
879 })
880 const quiet = (Array.isArray(right) ? right.filter(Boolean).join(' · ') : right ?? '').trim()
881 return el('Box', { flexDirection: 'row', justifyContent: 'space-between', columnGap: 2 }, [
882 el('Box', { flexDirection: 'row', flexShrink: 1, columnGap: 2 }, [
883 T(String(label), { bold: true, wrap: 'truncate' }),
884 ...drawn,
885 ]),
886 quiet ? T(quiet, { color: QUIET, wrap: 'truncate' }) : null,
887 ])
888 },
889
890 // ------------------------------------------------------------ kit v3
891
892 pill(text, tone) {
893 const t = pillToneOf(tone, 'muted')
894 const s = String(text)
895 if (!svgOk) return t === 'violet' ? T('● ' + s, { color: fg(t), bold: true, wrap: 'truncate' }) : chip(s, t)
896 if (pillMode === 'svg') return svg(pillSvg(s, t), s, pillWidth(s), PILL_HEIGHT)
897 return T(` ${s} `, { backgroundColor: PILL_FILLS[t], color: 'inverseText', bold: true, wrap: 'truncate' })
898 },
899
900 header(label, right) {
901 return el('Box', { flexDirection: 'row', justifyContent: 'space-between', columnGap: 1 }, [
902 T(String(label).toUpperCase(), { bold: true, color: QUIET, wrap: 'truncate' }),
903 right ? T(String(right).toUpperCase(), { color: QUIET, wrap: 'truncate' }) : null,
904 ])
905 },
906
907 icon(name, tone) {
908 const n = iconOf(name)
909 const t = tone === undefined ? undefined : pillToneOf(tone, 'muted')
910 if (svgOk) return svg(iconSvg(n, t ? TONE_HEX[t] : ICON_STROKE), n, 16, 16)
911 return T(ICON_GLYPHS[n], { color: t ? fg(t) : QUIET })
912 },
913
914 typeBadge(fileName) {
915 const type = fileType(fileName)
916 if (!type) return null
917 if (svgOk) {
918 return svg(badgeSvg(type.label, type.fill), type.label, badgeWidth(type.label), 16)
919 }
920 return T(type.label, { bold: true, color: TONES[type.tone].fg })
921 },
922
923 progress(ratio, opts = {}) {
924 if (!svgOk) return kit.bar(ratio, opts)
925 const t = toneOf(opts.tone, 'info')
926 const width = typeof opts.width === 'number' && Number.isFinite(opts.width) && opts.width >= 1 ? Math.floor(opts.width) : undefined
927 // Without a width the Svg gets no width prop and markup 100% wide: the user's look confirms the
928 // desktop draws it the full width of its slot.
929 return svg(progressSvg(ratio, TONE_HEX[t], width), pct(ratio), width, 6)
930 },
931
932 nav(items, currentKey) {
933 return el(
934 'Box',
935 { key: 'nav', flexDirection: 'row', flexWrap: 'wrap', columnGap: 2, rowGap: 1 },
936 (Array.isArray(items) ? items : []).map(it => {
937 if (it.key === currentKey) return T(String(it.label), { bold: true, color: TONES.accent.fg, wrap: 'truncate' })
938 if (typeof it.onPress !== 'function') return T(String(it.label), { color: QUIET, wrap: 'truncate' })
939 return el('Button', { key: it.key, label: String(it.label), hotkey: it.hotkey, onPress: it.onPress, plain: true })
940 }),
941 )
942 },
943 }
944 return kit
945}
946
947function isChipSpec(c: unknown): c is { text: string; tone?: Tone } {
948 return (
949 typeof c === 'object' &&
950 c !== null &&
951 !('type' in c) &&
952 typeof (c as { text?: unknown }).text === 'string'
953 )
954}
955hooks/prices.ts 113 lines1// Model prices for token-budget's cache figures. Pure: no mods API.
2//
3// A model id is matched to the first family it contains, longest family first, so
4// 'claude-opus-5-5' is opus-5-5 and not opus-5. An unknown model has no price, and the
5// figures that need one are left out rather than guessed.
6
7// USD per million tokens, list prices as of 2026-10 (partly confirmed; override with "prices" in ~/.claude/token-budget.json): [family, input, output, cacheRead, cacheWrite1h]
8export const PRICES: Array<[string, number, number, number, number]> = [
9 ['fable-5-1', 10, 50, 1.0, 20],
10 ['opus-5-5', 4, 20, 0.2, 8],
11 ['opus-5', 5, 25, 0.5, 10],
12 ['opus-4', 5, 25, 0.5, 10],
13 ['sonnet-5-5', 2, 10, 0.2, 4],
14 ['sonnet-5', 2, 10, 0.2, 4],
15 ['sonnet', 3, 15, 0.3, 6],
16 ['haiku-5-5', 1, 5, 0.1, 2],
17 ['haiku', 1, 5, 0.1, 2],
18]
19
20/** One family's prices, USD per million tokens: [input, output, cacheRead, cacheWrite1h]. */
21export type PriceRow = [number, number, number, number]
22/** Families from the config's "prices", replacing or adding to the table. */
23export type PriceOverrides = Record<string, PriceRow>
24
25export type Price = { family: string; input: number; output: number; cacheRead: number; cacheWrite1h: number }
26
27/** The table with the overrides applied: a known family is replaced, a new one added. */
28export function priceTable(overrides?: PriceOverrides | null): Array<[string, number, number, number, number]> {
29 const rows = new Map<string, PriceRow>()
30 for (const [family, a, b, c, d] of PRICES) rows.set(family, [a, b, c, d])
31 if (overrides && typeof overrides === 'object') {
32 for (const family of Object.keys(overrides)) {
33 const row = overrides[family]
34 if (Array.isArray(row) && row.length === 4) rows.set(family.toLowerCase(), row)
35 }
36 }
37 return [...rows.entries()].map(([family, [a, b, c, d]]) => [family, a, b, c, d])
38}
39
40/** The prices of the first family the model id contains, longest family first; null when none matches. */
41export function priceOf(model: string | null | undefined, overrides?: PriceOverrides | null): Price | null {
42 const id = typeof model === 'string' ? model.toLowerCase() : ''
43 if (!id) return null
44 const rows = priceTable(overrides).sort((x, y) => y[0].length - x[0].length)
45 const row = rows.find(r => id.includes(r[0]))
46 if (!row) return null
47 return { family: row[0], input: row[1], output: row[2], cacheRead: row[3], cacheWrite1h: row[4] }
48}
49
50/** What re-writing `contextTokens` to the cache costs at the model's 1-hour cache-write price; null without a price. */
51export function coldWriteUsd(
52 contextTokens: number,
53 model: string | null | undefined,
54 overrides?: PriceOverrides | null,
55): number | null {
56 const p = priceOf(model, overrides)
57 if (!p) return null
58 const tokens = typeof contextTokens === 'number' && Number.isFinite(contextTokens) && contextTokens > 0 ? contextTokens : 0
59 return (tokens * p.cacheWrite1h) / 1e6
60}
61
62/** The fields of a usage object (turn.step's or a model call's) that a price applies to. */
63export type UsageLike = {
64 input_tokens?: unknown
65 output_tokens?: unknown
66 cache_read_input_tokens?: unknown
67 cache_creation_input_tokens?: unknown
68}
69
70function n(v: unknown): number {
71 return typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : 0
72}
73
74/** What one request cost: input, output, cache reads and cache writes (at the 1-hour rate); null without a price. */
75export function requestUsd(
76 usage: UsageLike | null | undefined,
77 model: string | null | undefined,
78 overrides?: PriceOverrides | null,
79): number | null {
80 const p = priceOf(model, overrides)
81 if (!p || !usage || typeof usage !== 'object') return null
82 return (
83 (n(usage.input_tokens) * p.input +
84 n(usage.output_tokens) * p.output +
85 n(usage.cache_read_input_tokens) * p.cacheRead +
86 n(usage.cache_creation_input_tokens) * p.cacheWrite1h) /
87 1e6
88 )
89}
90
91/** Keeps the well-formed rows of a "prices" config value: `{ family: [in, out, read, write1h] }` or the same as an object. */
92export function sanitizePrices(raw: unknown): PriceOverrides | null {
93 if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null
94 const out: PriceOverrides = {}
95 let any = false
96 for (const key of Object.keys(raw as Record<string, unknown>)) {
97 const family = key.trim().toLowerCase()
98 if (!family) continue
99 const v = (raw as Record<string, unknown>)[key]
100 let row: unknown[] | null = null
101 if (Array.isArray(v)) row = v
102 else if (v && typeof v === 'object') {
103 const o = v as Record<string, unknown>
104 row = [o.input, o.output, o.cacheRead, o.cacheWrite1h]
105 }
106 if (!row || row.length !== 4) continue
107 if (!row.every(x => typeof x === 'number' && Number.isFinite(x) && x >= 0)) continue
108 out[family] = row as PriceRow
109 any = true
110 }
111 return any ? out : null
112}
113types/index.d.ts 92 lines1export type Action = 'warn' | 'deny-agents' | 'stop'
2
3/** The merged settings: defaults, then /budget settings in $.store, then the project file. */
4export type Config = {
5 /** Session budget in tokens; 0 means no session limit. */
6 session: number
7 /** Per-subagent budget in tokens; 0 turns the per-agent limit off. */
8 perAgent: number
9 /** Fraction of the session budget where the band's budget chip turns to the warn tone, in (0, 1]. */
10 warnAt: number
11 action: Action
12 countCacheReads: boolean
13 /** false: count and draw, never deny or nag (/budget off). */
14 enforce: boolean
15 /** Draw the band above the prompt (/budget band on|off). */
16 band: boolean
17 /** Assumed prompt-cache lifetime after the main session's last request, in minutes. */
18 cacheTtlMinutes: number
19 /** Minutes before the cache goes cold at which a toast pings, largest first. */
20 cacheWarnMinutes: number[]
21 /** Toast at each warn threshold and once when the cache goes cold (/budget pings on|off). */
22 cachePings: boolean
23 /** Send the keep-warm on its own when autoKeepWarmMinutes are left (/budget autowarm on|off). Off by default; a project file can only turn it off. */
24 autoKeepWarm: boolean
25 /** Minutes before the cache goes cold at which the automatic keep-warm fires: at least 1, and under cacheTtlMinutes or it never fires. */
26 autoKeepWarmMinutes: number
27 /** Most automatic refreshes per idle stretch (from one turn's start to the next). */
28 autoKeepWarmMax: number
29 /** Price overrides by model family, USD per million tokens: [input, output, cacheRead, cacheWrite1h]. */
30 prices: Record<string, [number, number, number, number]>
31}
32
33/** The main session's prompt cache, as the mod's own clock sees it. */
34export type CacheState = {
35 /** $.clock.now() of the main loop's last answered request (or keep-warm); 0 before the first. */
36 lastRequestAt: number
37 /** The model that answered it, for the price of a cold resend. */
38 model: string | null
39 /** Context tokens a cold resend re-writes: $.session.usage() after each main turn. */
40 contextTokens: number
41 /** A compaction since the last request: the next request starts a new cache, nothing to re-warm. */
42 compacted: boolean
43 /** The warn threshold (minutes) that last pinged in this warm window. */
44 lastPing: number | null
45 /** The timer saw the cache go cold (and toasted once). */
46 cold: boolean
47 /** Automatic keep-warm refreshes in this idle stretch; turn.start sets it back to 0. */
48 autoWarms: number
49 /** lastRequestAt of the warm window an automatic keep-warm was tried in: one try per window. */
50 autoTriedAt: number
51 /** An automatic fork re-wrote the cache instead of reading it: no more until the next turn. */
52 autoHalted: boolean
53 /** A main-loop turn is running (turn.start until its turn.complete): no automatic keep-warm. */
54 turnRunning: boolean
55}
56
57/** What the session has spent, as counted from turn.step usage. */
58export type Tally = {
59 /** The session these counts belong to, so a hot reload keeps them. */
60 sessionId: string
61 /** Counted tokens (see countedOf). */
62 total: number
63 /** Cache reads, tracked for display whether or not they count. */
64 cacheRead: number
65 byModel: Record<string, number>
66 /** Keyed by agentId, or "main" for the main loop. */
67 byAgent: Record<string, number>
68 requests: number
69 /** Main-loop turns completed. */
70 turns: number
71 /** Counted tokens per main-loop turn, last 50. */
72 perTurn: number[]
73 /** total at the last main-loop turn.complete. */
74 turnMark: number
75 /** Counted tokens not yet added to the per-project lifetime total. */
76 unflushed: number
77}
78
79declare module 'claude-code' {
80 interface PluginState {
81 'lk-token-budget': {
82 tally: Tally
83 config: Config
84 warned: boolean
85 lifetime: number
86 ctx: number | null
87 cwd: string
88 cache: CacheState
89 }
90 }
91}
92