SLOPSHOPPER

loopkeeper Token Budget

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…

newpanebandguardcommandtoast
★ 1v0.4.1MITupdated 2026-10-12connectwithub/loopkeeper/plugins/lk-token-budget
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · lk-token-budget
│ ┃ Token budget ✕ › fix the failing auth test and add an audit log call │ ┃ ┌──────────────────────────────────────────┐ │ ┃ │ Context │ ⏺ Read(src/auth.ts) │ ┃ │ 97k · 49% │ ⎿ Read 6 lines │ ┃ └──────────────────────────────────────────┘ ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ Budget enforcement on ⏺ Bash(bun test) │ ┃ Limit 2.0M · action warn · warn at 80% · pe… ⎿ 3 pass, 1 fail │ ┃ ● ▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱ Used 0 (0%) · remaining … │ ┃ 0 requests · 1 turns · cache reads 0 (not c… ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ Prompt cache pings on · auto keep-warm off ✻ Worked for 42s · done 4:20 PM │ ┃ Lifetime 60 min (assumed) · pings at 15, 5,… │ ┃ Last request none yet › /budget │ ┃ Auto keep-warm off │ ┃ │ ┃ By model 0 │ ┃ nothing counted yet │ ┃ │ ┃ Agents main loop 0 │ ┃ no subagents yet │ ┃ │ ┃ Projection │ ┃ Average 0 per turn │ ┃ Lifetime in this project 0 │ ┃ budget ● ▱▱▱▱▱▱▱▱ 0 / 2.0M 0% ● cache: no request yet main 0 · agents 0 · ctx 49% ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
budget ● ▱▱▱▱▱▱▱▱ 0 / 2.0M 0% ● cache: no request yet main 0 · agents 0 · ctx 49% ⟨Claude Code's own drawing⟩
Pane · Token budget
┌──────────────────────────────────────────────────────────┐ │ Context │ │ 97k · 49% │ └──────────────────────────────────────────────────────────┘ Budget enforcement on Limit 2.0M · action warn · warn at 80% · per agent 0.4M ● ▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱ Used 0 (0%) · remaining 2.0M 0 requests · 1 turns · cache reads 0 (not counted) Prompt cache pings on · auto keep-warm off Lifetime 60 min (assumed) · pings at 15, 5, 1 min Last request none yet Auto keep-warm off By model 0 nothing counted yet Agents main loop 0 no subagents yet Projection Average 0 per turn Lifetime in this project 0 w: Keep warm r: Reset c: Close
README

lk-token-budget

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

  • You run fan-out work with many subagents and want a hard ceiling on what one session can spend.
  • One runaway subagent should hit its own limit without the rest of the run being stopped.
  • You step away mid-session and want to know whether the prompt cache will still be warm when you come back, or keep it warm while you wait.

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.

Install

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 .

Commands

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

Actions

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.

Configuration

Settings merge in this order, later wins:

  1. Defaults: { "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 }
  2. ~/.claude/token-budget.json, the same fields for every project (the natural home for prices and the cache settings)
  3. What you saved with /budget commands, kept in the plugin's $.store and shared by every session on the machine
  4. <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.

Prompt cache

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:

  • Last request: every answered request of the main loop (turn.step with no agentId) stamps the time and the model. Subagents' requests have caches of their own and don't count.
  • Context size: read from $.session.usage().context.tokens after each main turn, which is what a cold resend re-writes.
  • Cold: decided from the plugin's own clock: 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.
  • Timer: $.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

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.

Auto keep-warm

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:

  • while a turn is running, from 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.
  • after a compaction, until the next request, because there is nothing to re-warm
  • once the cache is already cold, because that would pay the full write
  • once enforcement is on and the session budget is spent (/budget shows paused: session budget spent)
  • after an automatic fork that got no reply or failed. It toasts and logs auto keep-warm got no reply (…); no retry before the cache goes cold (or auto keep-warm failed: …) and tries once per warm window.
  • while a keep-warm fork is still running. A fork that is still running when a turn starts counts toward the stretch that ended, not the new one, and its line reads like a manual keep-warm: line.
  • when 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.

Prices

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.

Resumed sessions

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.

How counting works

  • Where: only in 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.
  • What counts: 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.
  • Breakdowns: by model (from 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.
  • Context: 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.
  • Lifetime: counted tokens are also added to a lifetime total for each project directory, kept in $.store under lifetime:<cwd>. It's shown for information only, never enforced, and /budget reset leaves it alone.
  • Where counts live: in $.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.

Limits

  • Usage arrives after the request. A request is counted when its response finishes, so the spend that crosses the budget is never refused itself. A long final request, or several subagents running at once, can overshoot by a request each.
  • A request without usage isn't counted. If a response reports no usage, for example because the request was interrupted or failed before any response arrived, the plugin counts nothing for it.
  • Subagents finishing one request. The per-agent limit refuses a subagent's tool calls, which stops its work, but the subagent still makes the request in which it writes its final answer.
  • 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.
  • Hooks fail open. If a hook throws, Claude Code skips it and the call goes ahead, so a bug in the plugin never blocks your session. claude plugin validate lists the gating hooks as having no .catch; that's deliberate.
  • Shared store. Settings and lifetime totals are shared by every session on the machine. Two sessions that write at the same moment can lose one lifetime increment.
  • Budgets are in tokens, not dollars. The dollar figures are for the prompt cache only, from a dated price table you can override; they are estimates, not a bill.
  • The cache lifetime is assumed, not reported. The plugin can't see the API's cache; if the real TTL differs from 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.
  • Auto keep-warm needs the session open. It runs on the session's own timer, so a closed or suspended Claude Code sends nothing, and a machine that slept past the window wakes to a cold cache (no fork is sent once cold). Its requests aren't counted against the token budget.

What it runs

claude plugin validate --strict . lists the footprint:

  • Hooks: 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.
  • Processes: none. It runs no shell command and starts no program.
  • Files: reads ~/.claude/token-budget.json (found through HOME, the only environment variable it reads) and <project>/.claude/token-budget.json. It writes no file.
  • Stored data: settings saved with /budget and the lifetime totals per project, in the plugin's $.store; the session's counts in $.state.
  • Model calls: $.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.
  • Timers: one 30-second $.clock.every for the cache countdown.

Notes for directory reviewers

The directory's validator listed these items for this plugin. Each bullet says what the code does and where.

  • The 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.
  • "Uses a credential from the user's machine" (policy hold 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 30
Source 6 files
hooks/register.ts 922 lines
1// 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}
922
hooks/budget.ts 304 lines
1// 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.'
304
hooks/cache.ts 275 lines
1// 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}
275
hooks/kit.ts 955 lines
1// 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
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}
955
hooks/prices.ts 113 lines
1// 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}
113
types/index.d.ts 92 lines
1export 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