Give a Claude Code task a spending limit, in dollars or as a share of your 5-hour or weekly usage; Claude sees what's left as it works, and onbudget stops it…

Give a Claude Code task a spending limit, in dollars or as a share of your 5-hour or weekly usage; Claude sees what's left as it works, and onbudget stops it before the limit runs out.
/onbudget $3 Implement authentication
/onbudget 10% 5h Build this application
/onbudget week Write the migration
Install (Claude Code 2.1.287 or later):
claude plugin marketplace add sebastiandrazsky/onbudget
claude plugin install onbudget@onbudget
What you see, rendered by Claude Code with a scripted model: Claude Code 2.1.306 in an 80-column terminal, with the test suite's scripted model answering in place of Claude, at no cost. The three passages are copied from the screen as drawn, and nothing in them is edited.
⚠ onbudget: tier MEDIUM · $0.21 left · budget $0.50
⏺ [onbudget] Stopped: $0.07 of $0.12 is used, and the next request could cost up
to $0.07, so it was not sent.
This notice comes from the onbudget plugin, not from Claude. The work was
interrupted: treat anything Claude has not reported as complete and verified
as unfinished.
To go on: /onbudget off (no budget), or /onbudget <budget> <instructions> (a
new budget).
⏺ onbudget: Claude ended the task with STATUS: COMPLETE, at $0.20 of
$0.50. The budget is still open: a follow-up prompt draws on it, and
/onbudget off removes it.
The first line is Claude Code's status line under the prompt: the tier, what is left before the stop, and the budget. The second passage is a stop, under a $0.12 budget. The third ends a task.
~/.claude/plugins/store/onbudget_*.json), a status line, and, only with ONBUDGET_LOG=1, an event log in its own folder. Nothing else.When you don't need it: in claude -p, Claude Code's own --max-budget-usd did as well as onbudget in our runs (Claude Code 2.1.304, n = 2 against 3). And at the 5-hour limit, Claude Code may let a response in progress finish under its Wrap-Up Allowance.
/cost counts them, read from Claude Code's own ledger, subagents included. onbudget does no pricing of its own.10% 5h is ten points of the 5-hour window, not ten percent of what is left. 5h is what the 5-hour window has left below 99% when you set the budget. A weekly budget (week, N% week) is counted in the weekly window's own points unless you set usdPerPointWeek, and is good to about a point.The benchmark: seven real bug fixes in Python libraries (boltons, falcon, markdown-it-py, pluggy, pydantic-settings and two in werkzeug), given in one prompt to one headless session and scored by upstream tests the model never saw. eval/TASKS.md names each task's repository, commits and hidden tests. Claude Opus 5.5 at medium effort; REPORT.md has the method, both rounds' full tables and the exceptions, and eval/ the raw data. That evidence, the harness included, is at the tag v0.1.0: from 0.1.1 it is not on main, so an install does not carry it.
At $2.47 on Claude Code 2.1.304 (the release current on 2026-10-20):
| n | Tasks complete, of 7 | Within $2.47 | Ended by itself, with a report | Cost, each run | |
|---|---|---|---|---|---|
| onbudget | 3 | 5, 5, 5 | 3 of 3 | 3 of 3 | $2.00, $1.86, $1.97 |
Plain, --max-budget-usd 2.47 | 2 | 5, 6 | 2 of 2 | 2 of 2 | $2.06, $2.25 |
| Budget sentence in the prompt | 2 | 5, 5 | 2 of 2, one only because a safety cap refused its next request | 1 of 2 | $2.38, $2.10 |
--max-budget-usd run did most of its work in seven subagents that Claude Code ran on Claude Sonnet 5.5 ($1.32 of its $2.25); the other, Opus only, finished 5 of 7.eval-freeze-3). git diff eval-freeze-3 -- hooks .claude-plugin shows these changes: weekly sizing (a weekly budget counts the window's points unless usdPerPointWeek is set); display text (the command's answers, the status line, the end-of-task line and the manifest's descriptions); the event log, which is off unless ONBUDGET_LOG=1 and goes only to log/events.jsonl in the plugin's own folder, where round two's build wrote it to a file the variable named; the variable ONBUDGET_EFFORT, removed, so that the effort option alone sets the cap (default medium, as before); an icon; from 0.1.1, the version number and the manifest's listing fields; and, from 0.1.2, other sessions' expired records gone through once a day instead of at every session start. Rendered side by side with tests/e2e/modeltext.py, what the model receives is byte for byte the same under dollar and 5-hour budgets, which is what round two ran, and differs only under weekly budgets.eval-freeze), plain Claude Code under a cap split the work across subagents in six of eight runs and was cut off mid-work; that round's table is in REPORT.md.~/CLAUDE.md in context, a pointer to a writing-style skill.--max-budget-usd works in print mode and the SDK only and is checked after each response, and its documentation says spend can pass it. onbudget checks before each request and also works in a terminal session.<total_tokens> countdown is undocumented and set by the server, padded to 15 million tokens. It has been in Claude Code builds since at least 2.1.200, on by default since at least 2.1.235, and 2.1.304 sends it only for models with a certain capability. It is not a task budget; while a budget is open onbudget leaves it out of new requests (see How it works).--task-budget flag and the SDK's taskBudget (alpha) set an API-side budget in tokens; onbudget does not use them.Budgets on the weekly window already exist: redline (sankalpgunturi/redline, 80% 7d), markwpearce/rainy-day-plugin, nikhilsitaram/claude-caliper's usage-guard (--window 7d, stopping at 99% by default), MohammadMD1383/claude-usage-plugin and michael-L-i/agent-budget. HeyCubit/effortless lowers effort near the limit. ccdwyer/budget-governor (a mod), cost-guard and budgetclaw cap dollars in interactive sessions. They enforce by locking tools, blocking prompts, ending the process (Keesan12/martin-loop) or proxying the API (metahumansworld/SIwithin). As far as both Anthropic marketplaces and a GitHub search showed on 2026-10-21, onbudget is the only one that refuses the individual model request from inside Claude Code, and the only one that publishes an evaluation with its raw data.
onbudget needs Claude Code 2.1.287 or later: from that release the mods API it is built on (function hooks in plugins) is on by default. Its test suites passed on Claude Code 2.1.288, 2.1.304 and 2.1.306 on macOS; GitHub Actions runs the validations, the in-engine tests, run.py and wire.py on Linux with the release current at each push.
claude plugin marketplace add sebastiandrazsky/onbudget
claude plugin install onbudget@onbudget
Inside a Claude Code session the same two commands start with /plugin instead of claude plugin. To stay on this release until you choose to move, add the marketplace pinned to its tag instead:
claude plugin marketplace add sebastiandrazsky/onbudget#v0.1.2
From a local checkout, for one session: claude --plugin-dir /path/to/onbudget.
Type /onbudget. It should answer onbudget: no budget is set and name its version and the Claude Code builds it was tested on.
Nothing outside the plugin enforces a budget. If the plugin does not run, requests are neither metered nor refused, and ONBUDGET is ignored, so a headless task runs with no budget at all. That happens when Claude Code starts in safe mode (--safe-mode) or --bare mode, which load no plugins; when an organisation's managed settings turn plugins or mods off; when Anthropic switches the mods API off; and when Claude Code unloads the plugin (see "The hooks worker" under Limitations). A plugin that runs before onbudget can also skip its hooks; from 2.1.304 the transcript names the plugin and the event when it does. A request whose hook does not run is not checked, though its cost is in the ledger the next check reads. A hook that runs and fails, or runs out of time, is different: while a budget is set, the request is not sent, and the plugin says so ([onbudget] Not sent: the budget check could not be completed).
How to tell. In a terminal session, /onbudget is offered when you type /onb, and once a budget is set a status line under the prompt shows the tier and what is left. Without the plugin, /onbudget is not offered and, typed anyway, is sent to Claude as an ordinary prompt. In a headless run, claude -p "/onbudget" prints onbudget: no budget is set without a model request when the plugin is running. To check one particular run, start it with ONBUDGET_LOG=1: as the session starts, the plugin writes log/events.jsonl in its own folder, beginning with a session.start row. For an install from the marketplace that folder is plugins/cache/onbudget/onbudget/0.1.2 under your Claude config directory. Delete the file before the run; if the run does not write it again, the plugin did not run.
/onbudget $3 Implement authentication
The task is sent with a short protocol appended, which you can read in the transcript: the budget, how to work under it, and how to report at the end. From then on:
onbudget: tier MEDIUM · $1.60 left · budget $3.00. When one of the account's windows is closer than the budget it is named, as in tier HIGH · $61.16 left (5h limit) · budget $500.00. Under a budget counted in points the figures are points, as in tier HIGH · 78 pts left · budget 81 pts (week), and a budget that has stopped reads STOPPED · $2.91 of $3.00 · /onbudget off. /onbudget gives the full reading.[budget] spent $1.23 of $3.00 | left before the stop $1.60 (53%) | tier MEDIUM. "Left before the stop" is exactly what the stop rule still allows, so the meter and the status line read $0.00 when the next request will be refused.medium while the budget is set. Your setting comes back when the budget is removed.Follow-up prompts draw on the same budget. Once the budget has stopped the session, a further prompt is turned away with the reason, and nothing is spent on it. /onbudget off removes the budget, and /onbudget <budget> <instructions> replaces it with a new one.
/onbudget 10% 5h Build this application
/onbudget 5% week Fix these bugs
A 5-hour point is worth a different number of dollars on every plan, so onbudget learns the rate from your own usage: in every session where the plugin is loaded it records how far each window moved and how much the session cost. A 5-hour percentage budget set after it has seen three points of movement is converted into dollars at that rate, and the dollars are enforced as a dollar budget is. Before a rate is known, the budget is enforced on the window reading itself, in whole points, with a 3% minimum. /onbudget shows the rate.
A weekly percentage budget is always enforced on the weekly window's own reading, in whole points, with a 3% minimum, unless you set usdPerPointWeek (see Configuration): the weekly rate onbudget learns errs far low (see Limitations), and converting with it would have given at most about two fifths of the share named.
/onbudget 5h Refactor the parser
/onbudget week Write the migration
The budget is the points between the window's reading and 99% when you give the command. For 5h they are converted to dollars when a rate is known; for week, only when you have set usdPerPointWeek. Otherwise the budget is counted in the window's own points, and onbudget stops about two points short of 99%, because the reading may not yet show the last requests. onbudget keeps one point back from the account's limit because a reading can be up to a point below the true figure. A session with no reading yet (a new session, or a headless run) sizes the budget from the first one, which arrives with Claude's first answer, so that first request goes out before the budget is known. If the window resets while the task runs, the budget stays what it was when you set it.
The meter follows whichever comes first, the budget or one of the windows, and names the window when it is the closer one: left before the stop $0.40 (17%), set by the account's 5-hour limit. The status line marks it (5h limit). A budget larger than what a window has left says so when you set it and runs against the window. A window with nothing left refuses the budget in one line, and a new session that has no reading of its own uses the last reading seen on this machine for that check, as long as that window has not reset since.
Accounts that report no usage window (an API key, a gateway) have no such limits: dollar budgets work as described, and budgets counted in a window cannot be enforced. A gateway's own spend limit, which Claude Code also reports, is not used.
ONBUDGET='$3' claude -p "Implement authentication"
ONBUDGET='5h' claude -p "Build this application"
ONBUDGET applies to the first prompt of the session, once. If the value cannot be used, the prompt is dropped with the reason; the task is not run without its budget. When the budget stops a headless run, the run's result is the plugin's notice, which begins [onbudget] Stopped:. In claude -p, set the budget with ONBUDGET: the form claude -p "/onbudget <budget> <task>" sets the budget but can exit before the task is sent (exit code 0, nothing run, nothing spent), a known issue in 0.1.0 to 0.1.2.
The ledger. Spend is the growth of the session's own cost ledger since the budget was set. Each request is charged the ledger's growth since the previous request completed, and no more than its growth since the request was sent, so a request is not charged for the ones that ran beside it or for what was spent between requests. Two requests that finish at the same instant cannot be told apart: the first to read the ledger is charged both, the total stays exact, and the reserve errs high.
The stop. A request cannot be priced before it is sent, so the plugin holds back what the dearest request so far has cost: refuse when spent + reserve × (requests in flight + 1) ≥ budget. A window of the account is a limit of the same shape. With a rate, its room is converted into the task's dollars from the reading that came with the latest answer; without one, each request is reckoned to move the window by a point, or by the largest step seen between two readings.
The meter. Claude is told the budget once, with the task, and shown the meter after every tool result. The tier is HIGH from 70% left, MEDIUM from 30%, LOW from 10%, and CRITICAL below that or when two requests' worth or less is left at the cost of the dearest so far (two points, for a budget counted in points). Claude has therefore read CRITICAL at least twice before a request can be refused, on one condition: requests one at a time, none costing more than the dearest before it. At small budgets that condition often fails. In the acceptance of 2026-10-21, the three live runs that came near the stop ($0.12, $0.15 and $0.25, on Claude Code 2.1.304 with Claude Opus 5.5) each read CRITICAL once, not twice, because a request cost more than any before it; none passed its budget. The protocol asks Claude to stop adding scope at LOW, to leave the tree in its best verified state at CRITICAL, to verify before it claims anything, and to end with what is complete, what is unfinished and what was not verified. After a compaction the protocol is sent again.
The status line. It is for you; Claude reads the meter, not this line. Claude Code cuts a plugin's status line at the terminal's width: after onbudget: it shows the width less 17 characters, 63 in an 80-column terminal (measured on 2.1.304 and 2.1.306). onbudget's line is at most 60 characters, its fields in order of importance (the tier, what is left before the stop, the budget, and the account limit when one is closer), so it shows whole from 77 columns, and in a narrower terminal the budget is cut first.
Claude Code's countdown. While a budget is open, onbudget leaves Claude Code's <total_tokens> countdown out of new requests, because it is not the task's budget. This is partial: the copy in the system prompt stays. Its effect on the model was never measured. An attachment already sent keeps the answer it was sent with, and a resumed session gets the same answers again, so the prompt cache's prefix does not change. A countdown under a million tokens could be a budget you set yourself, and is kept; so is any countdown when CLAUDE_CODE_TOTAL_TOKENS_REMINDER is set.
Effort. Each request's effort is lowered to the cap and never raised. Nothing in your settings is changed, so there is nothing to restore.
PRIVACY.md says the same as a privacy statement, with what the event log records.
claude plugin validate --strict .claude-plugin/plugin.json lists every engine call the plugin can make:
./register.ts calls: $.clock.after (via submitLater), $.clock.now, $.command.register, $.env.get (via load), $.fs.exists (via log), $.fs.write (via log), $.prompt.submit (via submitLater), $.session.id, $.session.model (via calModel, rateFor), $.session.usage, $.session.version, $.store.delete, $.store.get (via calModel, load, otherRecords, prune, startReadings), $.store.keys (via otherRecords, prune), $.store.set, $.ui.log (via say), $.ui.status (via showStatus)
./register.ts env writes: nothing
./register.ts env reads: CLAUDE_CODE_TOTAL_TOKENS_REMINDER, ONBUDGET, ONBUDGET_LOG
ONBUDGET: a budget for the first prompt of a headless run (ONBUDGET='$3' claude -p "..."). A budget for one run is not something to save as a setting, so it stays a variable.ONBUDGET_LOG: 1 turns on the event log (below); off when unset, and any other value leaves it off.CLAUDE_CODE_TOTAL_TOKENS_REMINDER: Claude Code's own setting, read only to respect it. When you have set it, onbudget keeps Claude Code's token countdown in requests instead of leaving it out.The effort cap and the dollars per point are options (see Configuration), not variables.
plugins/store/onbudget_*.json: the budget of each session that has one, with the first line of its task (at most 120 characters, shown by /onbudget), calibration records (dollars spent and points moved per session, window and model, nothing else), the newest reading of each window with its reset time, and, per budgeted session, which countdowns it left out, with the day it last went through other sessions' records. Records older than 30 days are deleted when a session starts: calibration records at every start, other sessions' budgets and flags at the first start of each day.ONBUDGET_LOG=1, one JSON-lines file in the plugin's own folder, log/events.jsonl, continued in log/events.1.jsonl, log/events.2.jsonl, ... every 200 rows. Each Claude Code process that logs starts it again. The path is fixed in the code: no variable or setting can point it anywhere else. It stays until you delete it./onbudget.It writes nothing else: no build, start-up, settings or instructions file, and nothing in your project.
<!-- readme.py: prompts --> Only while a budget is set. These texts are rendered
hooks/register.ts 734 lines1// onbudget: the Claude Code side. Hooks only; every decision is made in core.ts.
2//
3// Everything this module does:
4// reads the session's cost ledger and rate-limit readings, its id and model, the environment variables
5// ONBUDGET, ONBUDGET_LOG and CLAUDE_CODE_TOTAL_TOKENS_REMINDER, and its own store;
6// writes its own store (each session's budget; calibration records of dollars spent and points moved; the
7// last reading of each usage window), its status line, and, only when ONBUDGET_LOG is 1, an event log
8// in its own folder (log/events.jsonl under the plugin's directory);
9// changes only while a budget is set: the effort of each model request (lowered to the cap, never raised),
10// one line of context after each tool result and with each later prompt, the refusal of requests the
11// budget cannot cover, and Claude Code's own token countdown, which is left out of new requests.
12// It runs no commands and makes no model or network calls of its own. With no budget set, every hook passes
13// its event through unchanged; the only work left is the calibration record kept at session.measure.
14import type { EngineInterface, PromptOrigin, Register, SessionUsage, TurnStepInput, TurnStepResult } from 'claude-code'
15import {
16 type Budget, type CalMark, type CalRecord, type Effort, type Rate, type Reading, type Spec, type WindowKind,
17 BOOTSTRAP_RESERVE_USD, COUNTDOWN_TYPE, DAY_MS, MAX_AGE_DAYS, MIN_OBSERVED_POINTS, NAME, WALL_AT, WINDOWS,
18 binding, cappedEffort, dropStaleWalls, effortBeneath, endText, isDefaultCountdown, plural, extendCalibration, failClosedText, fold, isEffortCap, isStale, meterLine, money, observeWalls, observeWindow,
19 parseCommand, poolRate, protocol, refusalText, reminder, reserveOf, resetsText, round4, shown, sizeRest, statusLine, statusText, stepCostOf, stopCheck,
20 stoppedText, trim, windowName,
21} from './core.ts'
22
23type Engine = EngineInterface
24const TYPED = new RegExp(`^\\s*/${NAME}(?:\\s+([\\s\\S]*))?$`) // the command arriving as prompt text (slash commands off, SDK hosts)
25const OWN_NAME = new RegExp(`^${NAME}: `)
26const LOG_CHUNK = 200
27
28// Session state. The store is the record: a reload, a resumed session or a switch to another session reads it back.
29// Store keys: budget:<session>, env:<session> (ONBUDGET was used or cancelled there),
30// cal:<window>:<day>:<session>:<model> (one calibration record; the day lets old ones be deleted unread), and
31// seen:<window> (the newest reading of that window on this machine, for a budget set before a session has its own), and
32// hid:<session> (which countdowns were left out of that session's requests, by text and by the order in which that text
33// appeared, so that a resumed session leaves out the same ones).
34let boot: Promise<void> | null = null
35let sid = ''
36let budget: Budget | null = null
37let envBudget: string | undefined // ONBUDGET: the budget for this session's first prompt, until it is used
38let effortCap: Effort | 'keep' = 'medium'
39let fixedRate: Partial<Record<WindowKind, number>> = {}
40let stepMark = 0 // the ledger when the last request completed
41let inFlight = 0 // requests sent and not yet answered, over all loops
42let switched = false // the process moved to another session (/clear, /resume) since the state was read
43let reads: Promise<unknown> = Promise.resolve()
44const sent = new Set<string>() // requests handed to the engine, for the handler that runs when the step hook fails
45const calRecs = new Map<string, CalRecord>() // this process's calibration records, by store key
46let calMarks: Partial<Record<WindowKind, CalMark & { model: string }>> = {}
47let calDay = 0
48let others: CalRecord[] | null = null
49const seenMark: Partial<Record<WindowKind, string>> = {}
50let interactive = false
51let countdownSet = false // CLAUDE_CODE_TOTAL_TOKENS_REMINDER is set: the person chose the countdown, so it is kept
52let history = false // a resumed session before this process's first request: the engine asks again about attachments it sent
53let hidden: Record<string, number[]> = {} // countdowns left out of this session's requests: text -> occurrences (hid:<session>)
54const asked = new Map<string, number>() // how often each countdown text has been asked about in this process
55const HIDDEN_KEEP = 2000
56const LOADED = Date.now() // when this module loaded: a session that began well before it was resumed
57let logOn = false // ONBUDGET_LOG=1: the event log is written, to log/events.jsonl in the plugin's own folder
58let logCleared = false // the chunks an earlier process left there were emptied
59let logRows: string[] = []
60let logChunk = 0
61let logWrites: Promise<unknown> = Promise.resolve()
62let t0 = 0
63
64const stepKey = (e: TurnStepInput) => `${e.agentId ?? ''}|${e.turnId}|${e.index}`
65const errText = (err: unknown) => String((err as Error)?.message ?? err).slice(0, 300)
66const isRecord = (x: unknown): x is Record<string, unknown> => typeof x === 'object' && x !== null
67const isBudget = (x: unknown): x is Budget => isRecord(x) && x.v === 1 && isRecord(x.spec) && typeof x.spent === 'number' && typeof x.ledger === 'number'
68const isCalRecord = (x: unknown): x is CalRecord => isRecord(x) && typeof x.usd === 'number' && typeof x.points === 'number' && typeof x.at === 'number'
69const fromPerson = (origin: PromptOrigin) => origin.kind === 'composer' || origin.kind === 'sdk' || origin.kind === 'bridge'
70const calKey = (kind: WindowKind, model: string) => `cal:${kind}:${calDay}:${sid}:${model}`
71
72function ledgerOf(u: SessionUsage): number {
73 const usd = u.cost?.usd
74 if (typeof usd !== 'number' || !Number.isFinite(usd)) throw new Error('this session reports no cost')
75 return usd
76}
77
78/**
79 * The event log, for evaluation and debugging. Off unless ONBUDGET_LOG is 1; never changes a run. It is one fixed file
80 * in the plugin's own folder, `log/events.jsonl`, continued in `log/events.1.jsonl`, `log/events.2.jsonl`, ... every
81 * LOG_CHUNK rows. Each process that logs starts it again. No other path is ever written: without an absolute plugin
82 * folder there is no log.
83 */
84async function log($: Engine, kind: string, data: unknown): Promise<void> {
85 if (!logOn) return
86 try {
87 const root = $.plugin.root
88 if (typeof root !== 'string' || !root.startsWith('/')) return
89 if (!logCleared) {
90 logCleared = true
91 for (let n = 1; n <= 1000 && (await $.fs.exists(`${root}/log/events.${n}.jsonl`)); n++) await $.fs.write(`${root}/log/events.${n}.jsonl`, '')
92 }
93 if (logRows.length >= LOG_CHUNK) { logRows = []; logChunk += 1 }
94 logRows.push(JSON.stringify({ t: (await $.clock.now()) - t0, kind, data }))
95 const chunk = logChunk
96 const body = logRows.join('\n') + '\n'
97 logWrites = logWrites.then(() => $.fs.write(chunk === 0 ? `${root}/log/events.jsonl` : `${root}/log/events.${chunk}.jsonl`, body)).catch(() => undefined)
98 await logWrites
99 } catch { /* ignore */ }
100}
101
102// The engine starts a log line with the plugin's name (mods API, $.ui.log), so the text does not repeat it.
103const say = ($: Engine, text: string) => { try { $.ui.log(text.startsWith(`${NAME}: `) ? text.slice(NAME.length + 2) : text) } catch { /* a host with no transcript */ } }
104
105/** Reads this session's saved state: once per load of the module, and again after the process changes session. */
106const ready = ($: Engine) => (boot ??= load($).catch(err => { boot = null; throw err }))
107
108async function load($: Engine): Promise<void> {
109 const now = await $.clock.now()
110 if (t0 === 0) t0 = now
111 calDay = Math.floor(now / DAY_MS)
112 sid = await $.session.id()
113 // The engine wants each variable read by its literal name, so that what a module reads can be listed.
114 const env = (read: Promise<string | undefined>) => read.then(v => v?.trim() || undefined, () => undefined)
115 logOn = (await env($.env.get('ONBUDGET_LOG'))) === '1'
116 envBudget = (await $.store.get(`env:${sid}`)) === undefined ? await env($.env.get('ONBUDGET')) : undefined
117 countdownSet = (await env($.env.get('CLAUDE_CODE_TOTAL_TOKENS_REMINDER'))) !== undefined
118 const kept = await $.store.get(`hid:${sid}`)
119 hidden = {}
120 if (isRecord(kept) && isRecord(kept.hidden)) for (const [text, at] of Object.entries(kept.hidden)) if (Array.isArray(at)) hidden[text] = at.filter(n => typeof n === 'number')
121 // A session that began before this process (a resume, a switch with /resume) has attachments the engine will ask
122 // about again before the first request goes out; until then they are answered as they were when first sent.
123 const began = (await $.session.usage().catch(() => null))?.startedAt
124 history = typeof began === 'number' && (switched || began < LOADED - 10_000)
125 await prune($, now)
126 const saved = await $.store.get(`budget:${sid}`)
127 if (isBudget(saved)) {
128 budget = saved
129 envBudget = undefined
130 stepMark = ledgerOf(await $.session.usage())
131 // Counting goes on from the ledger as it stands. In a new process it may have started over, and after a
132 // switch inside one process it belongs to the process, not the session: either way the saved mark is not it.
133 if (switched || stepMark < budget.ledger) budget.ledger = stepMark
134 // A converted budget uses the window only as a backstop on what happens while it runs, so it starts a new
135 // baseline. An unconverted one counts the window's movement whoever caused it, so its baseline is kept.
136 if (budget.window && budget.usd !== null) { budget.window.last = null; budget.window.blind = 0 }
137 dropStaleWalls(budget, now)
138 }
139 switched = false
140}
141
142/** The store key holding the day `prune` last went through other sessions' budgets and flags. */
143const PRUNED = 'pruned'
144
145/**
146 * Deletes what has expired: calibration records by the day in their key, at every load; other sessions' budgets and
147 * flags by age, at most once a day. Telling their age takes a read of the store for each, and a read takes longer the
148 * larger the store, so going through them at every session start made every start slower as the store grew.
149 */
150async function prune($: Engine, now: number): Promise<void> {
151 try {
152 const keys = await $.store.keys()
153 for (const key of keys) {
154 if (!key.startsWith('cal:')) continue
155 const day = Number(key.split(':')[2])
156 if (!(calDay - day <= MAX_AGE_DAYS)) await $.store.delete(key)
157 }
158 if ((await $.store.get(PRUNED)) === calDay) return
159 for (const key of keys) {
160 if (!(key.startsWith('budget:') || key.startsWith('env:') || key.startsWith('hid:')) || key.endsWith(`:${sid}`)) continue
161 const value = await $.store.get(key)
162 const at = isBudget(value) ? (value.savedAt ?? value.startedAt) : isRecord(value) && typeof value.at === 'number' ? value.at : 0
163 if (now - at >= MAX_AGE_DAYS * DAY_MS) await $.store.delete(key)
164 }
165 await $.store.set(PRUNED, calDay)
166 } catch { /* housekeeping never stops a session */ }
167}
168
169/** Forgets the session the process is leaving (/clear, /resume): the next hook reads the new one's state. */
170function leaveSession(): void {
171 budget = null
172 boot = null
173 others = null
174 envBudget = undefined
175 switched = true
176 calRecs.clear()
177 calMarks = {}
178 hidden = {}
179 asked.clear()
180}
181
182/** Reads the ledger and folds it into the budget. Reads are queued, so readings are folded in the order taken. */
183function sync<T = void>($: Engine, then?: (ledger: number, usage: SessionUsage) => T): Promise<T> {
184 const run = async () => {
185 const usage = await $.session.usage()
186 const ledger = ledgerOf(usage)
187 if (budget) fold(budget, ledger)
188 return then ? then(ledger, usage) : (undefined as T)
189 }
190 const p = reads.then(run, run)
191 reads = p.catch(() => undefined)
192 return p
193}
194
195async function save($: Engine): Promise<void> {
196 if (!budget) return
197 budget.savedAt = await $.clock.now()
198 await $.store.set(`budget:${sid}`, budget)
199}
200
201function showStatus($: Engine): void {
202 try {
203 // The engine shows the line under the plugin's name, so the text does not repeat it.
204 $.ui.status(budget ? statusLine(budget, inFlight) : undefined)
205 } catch { /* a host with no status line */ }
206}
207
208/** Calibration records of the account's other sessions and earlier processes, read once. */
209async function otherRecords($: Engine): Promise<CalRecord[]> {
210 if (others) return others
211 const found: CalRecord[] = []
212 for (const key of await $.store.keys()) {
213 if (!key.startsWith('cal:') || calRecs.has(key)) continue
214 const value = await $.store.get(key)
215 if (isCalRecord(value)) found.push(value)
216 }
217 return (others = found)
218}
219
220/** Dollars per point of a window on this account: the user's own figure when configured, else the learned one. */
221async function rateFor($: Engine, kind: WindowKind): Promise<Rate | null> {
222 const fixed = fixedRate[kind]
223 if (fixed !== undefined && fixed > 0) return { usdPerPoint: fixed, points: 0, intervals: 0, source: 'config' }
224 return poolRate([...(await otherRecords($)), ...calRecs.values()], kind, await $.session.model(), await $.clock.now())
225}
226
227/** The session's model, with this process's calibration records for it ready to be extended. */
228async function calModel($: Engine): Promise<string> {
229 const model = await $.session.model()
230 for (const kind of WINDOWS) {
231 const key = calKey(kind, model)
232 if (calRecs.has(key)) continue
233 const kept = await $.store.get(key) // this process's own record, from before a reload
234 calRecs.set(key, isCalRecord(kept) ? { ...kept, segments: kept.segments || 1 } : { kind, model, usd: 0, points: 0, segments: 0, at: 0 })
235 }
236 return model
237}
238
239/** Extends the calibration records with readings and the ledger beside them; the keys of those that grew. */
240function noteCalibration(readings: readonly Reading[], ledger: number, model: string): string[] {
241 const grew: string[] = []
242 for (const kind of WINDOWS) {
243 const r = readings.find(x => x.kind === kind)
244 const rec = calRecs.get(calKey(kind, model))
245 if (!r || !rec || !Number.isFinite(r.percentUsed)) continue
246 const mark = calMarks[kind]
247 const before = rec.usd + rec.points
248 // A span that began on another model is not this model's: it only sets the mark.
249 calMarks[kind] = { ...extendCalibration(rec, mark && mark.model === model ? mark : null, r, ledger), model }
250 if (rec.usd + rec.points !== before) grew.push(calKey(kind, model))
251 }
252 return grew
253}
254
255async function saveCalibration($: Engine, keys: readonly string[]): Promise<void> {
256 for (const key of keys) {
257 const rec = calRecs.get(key)
258 if (!rec) continue
259 rec.at = await $.clock.now()
260 await $.store.set(key, rec)
261 }
262}
263
264/** The rates the walls convert with, per window: the same as a percentage budget would use. */
265async function wallRates($: Engine): Promise<Record<WindowKind, Rate | null>> {
266 return { five_hour: await rateFor($, 'five_hour'), seven_day: await rateFor($, 'seven_day') }
267}
268
269/**
270 * The rates a budget converts with and its walls use. A weekly budget (`week`, `N% week`) takes a weekly rate only from
271 * the user's setting: the learned one errs far low for that window (README, "Window readings"), which would stop the
272 * task well short of the share it names, so without a setting the budget counts the window's own points.
273 */
274async function budgetRates($: Engine, spec: Spec): Promise<Record<WindowKind, Rate | null>> {
275 const rates = await wallRates($)
276 if (spec.kind !== 'usd' && spec.window === 'seven_day' && rates.seven_day?.source !== 'config') rates.seven_day = null
277 return rates
278}
279
280/** Keeps the newest reading of each window in the store when it changes, for a budget set before a session has one. */
281async function noteSeen($: Engine, readings: readonly Reading[]): Promise<void> {
282 for (const kind of WINDOWS) {
283 const r = readings.find(x => x.kind === kind)
284 if (!r || !Number.isFinite(r.percentUsed) || typeof r.resetsAt !== 'string') continue
285 const mark = `${r.percentUsed}|${r.resetsAt}`
286 if (seenMark[kind] === mark) continue
287 seenMark[kind] = mark
288 await $.store.set(`seen:${kind}`, { percentUsed: r.percentUsed, resetsAt: r.resetsAt, at: await $.clock.now() })
289 }
290}
291
292/** The readings a new budget starts from: the session's own where they are current, else the newest seen on this machine. */
293async function startReadings($: Engine, usage: SessionUsage, now: number): Promise<{ live: Reading[]; known: Reading[] }> {
294 const live = usage.rateLimits.filter(r => (WINDOWS as readonly string[]).includes(r.kind) && Number.isFinite(r.percentUsed) && !isStale(r, now))
295 const known = [...live]
296 for (const kind of WINDOWS) {
297 if (live.some(r => r.kind === kind)) continue
298 const v = await $.store.get(`seen:${kind}`).catch(() => undefined)
299 if (isRecord(v) && typeof v.percentUsed === 'number' && typeof v.resetsAt === 'string' && !isStale({ kind, percentUsed: v.percentUsed, resetsAt: v.resetsAt }, now)) {
300 known.push({ kind, percentUsed: v.percentUsed, resetsAt: v.resetsAt })
301 }
302 }
303 return { live, known }
304}
305
306/** Marks ONBUDGET as dealt with for this session, so that a reload does not arm it again. */
307async function retireEnv($: Engine): Promise<void> {
308 envBudget = undefined
309 await $.store.set(`env:${sid}`, { at: await $.clock.now() })
310}
311
312/** Runs `/onbudget <args>`: what to tell the person, and, for a new budget, the prompt to send. */
313async function command($: Engine, args: string): Promise<{ say: string; prompt?: string }> {
314 const c = parseCommand(args)
315 if (c.kind === 'error') return { say: c.message }
316 // The latest spend, when the ledger can be read; status and off must work even when it cannot.
317 const refresh = () => (budget ? sync($).catch(() => undefined) : Promise.resolve())
318 const spendOf = (b: Budget) => `${money(b.spent)} of ${shown(b)} was spent` + (b.state === 'stopped' ? ' and the budget had stopped the session' : '')
319 if (c.kind === 'status') {
320 await refresh()
321 const pending = !budget && envBudget ? `\n pending: ONBUDGET=${envBudget} applies to the next prompt (/${NAME} off cancels it)` : ''
322 const readings = (await $.session.usage()).rateLimits.some(r => (WINDOWS as readonly string[]).includes(r.kind))
323 return { say: statusText(budget, await wallRates($), inFlight, readings) + pending }
324 }
325 if (c.kind === 'off') {
326 if (!budget) {
327 if (!envBudget) return { say: `${NAME}: no budget is set.` }
328 await retireEnv($)
329 return { say: `${NAME}: the budget in ONBUDGET was cancelled for this session. Requests are not metered or refused.` }
330 }
331 await refresh()
332 const b = budget
333 await $.store.delete(`budget:${sid}`)
334 budget = null
335 showStatus($)
336 await log($, 'off', { spent: b.spent, budget: b.usd, state: b.state })
337 return { say: `${NAME}: budget removed (${spendOf(b)}). Requests are no longer metered or refused.` }
338 }
339
340 let usd: number | null = null
341 let rate: Rate | null = null
342 let allowance: number | undefined
343 let pending = false
344 let restAt: Reading | undefined // `5h`/`week` counted in points: the reading it was sized from, its baseline
345 const notes: string[] = []
346 const weeklyPoints = 'A weekly budget is counted in the weekly window\'s own points unless usdPerPointWeek is set (the rate learned for that window errs far low), so the budget'
347 const startedAt = await $.clock.now()
348 const rates = await budgetRates($, c.spec)
349 const { live, known } = await startReadings($, await $.session.usage(), startedAt)
350 const spec = c.spec
351 if (spec.kind === 'usd') usd = spec.usd
352 else if (spec.kind === 'pct') {
353 const kind = spec.window
354 rate = rates[kind]
355 if (rate) {
356 usd = round4(spec.points * rate.usdPerPoint)
357 notes.push(`Converted at 1 point ≈ ${money(rate.usdPerPoint)} (${rate.source === 'config' ? 'your setting' : `learned from ${Math.round(rate.points)} points of movement seen on this machine; it errs low, so the budget errs small`}).`)
358 } else if (spec.points < MIN_OBSERVED_POINTS && kind === 'seven_day') {
359 return { say: `${NAME}: cannot enforce ${c.raw}. Weekly budgets are counted in the weekly window's own whole points unless usdPerPointWeek is set, so the smallest is ${MIN_OBSERVED_POINTS}%; a dollar budget works now.` }
360 } else if (spec.points < MIN_OBSERVED_POINTS) {
361 return { say: `${NAME}: cannot enforce ${c.raw} yet. The ${windowName(kind)} window is reported in whole points for the whole account, and ${NAME} has not yet seen enough usage to convert a point into dollars. Until it has, the smallest percentage budget is ${MIN_OBSERVED_POINTS}%; a dollar budget works now.` }
362 } else {
363 notes.push(`${kind === 'seven_day' ? weeklyPoints : 'No conversion to dollars is known yet, so the budget'} is enforced on the window's own reading: whole points, account-wide. It stops when the window has moved ${spec.points} points, whoever used them, and can be off by a point or by one request.`)
364 }
365 } else {
366 // `5h` or `week`: what the window has left below the wall, read now when the session has a current reading.
367 const kind = spec.window
368 const r = live.find(x => x.kind === kind)
369 if (!r) {
370 pending = true
371 const seen = known.find(x => x.kind === kind)
372 notes.push(`This session has no current reading of the ${windowName(kind)} window yet${seen ? ` (it was at ${trim(seen.percentUsed)}% when last seen on this machine)` : ''}. The budget is sized from the first reading, which arrives with Claude's first answer.`)
373 } else {
374 const points = Math.max(0, WALL_AT - r.percentUsed)
375 rate = rates[kind]
376 if (rate) {
377 usd = round4(points * rate.usdPerPoint)
378 notes.push(`The window is at ${trim(r.percentUsed)}%: ${trim(points)} points are left below ${WALL_AT}%, about ${money(usd)} at 1 point ≈ ${money(rate.usdPerPoint)} (${rate.source === 'config' ? 'your setting' : 'learned on this machine; it errs low'}).`)
379 } else {
380 allowance = points
381 restAt = r
382 notes.push(kind === 'seven_day'
383 ? `The window is at ${trim(r.percentUsed)}%. ${weeklyPoints} is the window's own reading: ${trim(points)} points are left below ${WALL_AT}%, and it stops about two points before that, at ${WALL_AT - 2}%, because the reading may not yet show the last requests.`
384 : `The window is at ${trim(r.percentUsed)}% and no conversion to dollars is known yet, so the budget is the window's own reading: it stops when the window has moved ${trim(points)} points, whoever used them, or a little before.`)
385 }
386 }
387 }
388 if (usd !== null && usd <= BOOTSTRAP_RESERVE_USD && spec.kind !== 'rest') return { say: `${NAME}: ${shown({ spec, usd })} is too small to send a single request under (a ${money(BOOTSTRAP_RESERVE_USD)} reserve is held back). Nothing was sent.` }
389
390 // The account's windows as walls, from the readings at hand; a window with nothing left refuses the budget.
391 const fresh = (): Budget => ({
392 v: 1, raw: c.raw, spec, task: c.task.split('\n')[0].slice(0, 120), usd, rate, allowance, pending: pending || undefined,
393 spent: 0, ledger: 0, maxStep: 0, steps: 0, refused: 0, state: 'active',
394 window: spec.kind === 'usd' ? undefined : { kind: spec.window, last: restAt?.percentUsed ?? null, resetsAt: restAt?.resetsAt, moved: 0, blind: 0 },
395 effortCap, remind: false, startedAt,
396 })
397 const probe = fresh()
398 observeWalls(probe, known, 0, rates, startedAt, true)
399 const blocked = stopCheck(probe, 0)
400 if (blocked && (blocked.event === 'wall-stop' || spec.kind === 'rest')) {
401 const kind = blocked.event === 'wall-stop' ? blocked.window : (spec as { window: WindowKind }).window
402 const r = known.find(x => x.kind === kind)
403 return { say: `${NAME}: the ${windowName(kind)} usage window is at ${trim(r?.percentUsed ?? WALL_AT)}%${resetsText(r?.resetsAt)}, so nothing is left to run a budget on before it resets. Nothing was sent.` }
404 }
405 const first = binding(probe, 0)
406 if (first && first.by !== 'budget' && !(spec.kind === 'rest' && spec.window === first.by)) {
407 const w = probe.walls![first.by]!
408 notes.push(`The account's ${windowName(first.by)} window is at ${trim(w.last)}%, so less is left in it than the budget: about ${first.unit === 'usd' ? money(Math.max(0, first.room)) : plural(Math.max(0, first.room), 'point')} before ${NAME} stops for it. The task runs against that.`)
409 } else {
410 for (const kind of WINDOWS) {
411 const w = probe.walls?.[kind]
412 if (w && !w.rate && w.last >= 90) notes.push(`The account's ${windowName(kind)} window is at ${trim(w.last)}% and no conversion to dollars is known for it: ${NAME} stops for it at about ${WALL_AT - 2}%, which may come before the budget is spent.`)
413 }
414 }
415
416 const prev = budget
417 const prevMark = stepMark
418 // The old budget's last reading and the new budget's first are one queued read, so nothing spent in between
419 // is counted twice or lost.
420 const { closed, usage } = await sync($, (ledger, usage) => {
421 const closed = prev ? spendOf(prev) : undefined
422 budget = { ...fresh(), ledger, walls: probe.walls }
423 stepMark = ledger
424 return { closed, usage }
425 })
426 const b = budget!
427 try {
428 await save($)
429 if (envBudget) await retireEnv($)
430 } catch (err) {
431 budget = prev
432 stepMark = prevMark
433 throw err
434 }
435 showStatus($)
436 await log($, 'budget', { raw: c.raw, spec, usd, rate, allowance: allowance ?? null, pending, walls: b.walls ?? null, effortCap, task: b.task, base: b.ledger, replaced: closed ?? null })
437 if (closed) notes.unshift(`The previous budget was closed (${closed}).`)
438 if ((usage.context?.tokens ?? 0) > 100_000) notes.push('Note: this session already holds a large conversation; if it has been idle, the first request re-reads all of it and can be costly.')
439 if (effortCap !== 'keep') notes.push(`Effort is capped at ${effortCap} while the budget is set.`)
440 return {
441 say: [`${NAME}: budget ${shown(b)} set.`, ...notes, `/${NAME} shows the status, /${NAME} off removes the budget.`].join(' '),
442 prompt: `${c.task}\n\n${protocol(shown(b))}`,
443 }
444}
445
446/**
447 * Submits the task once the command's own hook has returned: the engine refuses a prompt submitted from inside
448 * it (the prompt would wait on the command). In a headless run the session may not be ready for a moment after
449 * the command, so a refused submit is tried again a few times before the person is told.
450 */
451function submitLater($: Engine, text: string, attempt: number): void {
452 const failed = (why: string) => {
453 say($, `${NAME}: the budget is set, but the task could not be submitted (${why}). Send the task yourself: it will run under the budget.`)
454 void log($, 'hookfail', { where: 'prompt.submit.call', error: why })
455 }
456 $.clock.after(attempt === 0 ? 1 : 50 * 2 ** attempt, () => {
457 $.prompt.submit({ text, asUser: true }).then(
458 r => { if (r && typeof r.drop === 'string') failed(r.drop) },
459 (err: unknown) => (attempt < 5 ? submitLater($, text, attempt + 1) : failed(errText(err))),
460 )
461 })
462}
463
464/** Saves which countdowns were left out of this session's requests (at most HIDDEN_KEEP texts, the newest). */
465async function saveHidden($: Engine): Promise<void> {
466 const texts = Object.keys(hidden)
467 for (const text of texts.slice(0, Math.max(0, texts.length - HIDDEN_KEEP))) delete hidden[text]
468 await $.store.set(`hid:${sid}`, { at: await $.clock.now(), hidden })
469}
470
471/** The made-up response that stands in for a request that is not sent. */
472async function* refuse(e: TurnStepInput, text: string): AsyncGenerator<any, TurnStepResult> {
473 yield { kind: 'text', index: 0, text }
474 yield { kind: 'stop', stopReason: 'end_turn', usage: null }
475 return { turnId: e.turnId, index: e.index, answer: text, toolUses: [], stopReason: 'end_turn', usage: null }
476}
477
478export const register: Register = (on, options) => {
479 if (isEffortCap(options?.effort)) effortCap = options.effort
480 fixedRate = { five_hour: Number(options?.usdPerPoint5h) || undefined, seven_day: Number(options?.usdPerPointWeek) || undefined }
481
482 on('session.start', async ($, e, next) => {
483 try {
484 // The name is written out here and in the command.run matcher below (the engine reads both as literals); core.ts has NAME.
485 await $.command.register({ name: 'onbudget', description: 'Run a task under a usage budget', argumentHint: '<$X | N% 5h | N% week | 5h | week> <task> · off' })
486 } catch (err) { await log($, 'hookfail', { where: 'command.register', error: errText(err) }) }
487 interactive = e.isInteractive === true
488 try {
489 await ready($)
490 showStatus($)
491 await log($, 'session.start', { sid, budget: budget?.raw ?? envBudget ?? null, restored: budget !== null, effortCap, history, version: await $.session.version().catch(() => null) })
492 } catch (err) { await log($, 'hookfail', { where: 'session.start', error: errText(err) }) }
493 return next(e)
494 }).catch(($, e, next) => next(e))
495
496 // /onbudget, typed in a session.
497 on('command.run', { command: 'onbudget' }, async ($, e) => {
498 try {
499 await ready($)
500 const out = await command($, e.args)
501 if (out.prompt !== undefined) submitLater($, out.prompt, 0)
502 return { text: out.say.replace(OWN_NAME, '') } // the engine prints a command's answer under the plugin's name
503 } catch (err) {
504 await log($, 'hookfail', { where: 'command.run', error: errText(err) })
505 return { text: `could not run (${errText(err)}). No budget was changed.` }
506 }
507 })
508
509 // The same command arriving as prompt text, the ONBUDGET variable applied to the first prompt, and a reading
510 // attached to each later prompt while a budget is set.
511 on('prompt.submit', async ($, e, next) => {
512 const person = fromPerson(e.origin)
513 const typed = person ? TYPED.exec(e.text) : null
514 let ev = e
515 try {
516 await ready($)
517 if (person && (await $.session.id()) !== sid) {
518 // The process is in another session than the one whose state is held, and no session.end said so.
519 if (budget) await save($).catch(() => undefined)
520 leaveSession()
521 await ready($)
522 }
523 if (typed || (envBudget && !budget && person)) {
524 const out = await command($, typed ? (typed[1] ?? '') : `${envBudget} ${e.text}`)
525 if (out.prompt === undefined) return { drop: out.say }
526 say($, out.say)
527 ev = { ...e, text: out.prompt }
528 } else if (budget?.state === 'stopped' && person) {
529 // Nothing more can be sent under this budget, and a turn that only gets refused still lets Claude Code
530 // make requests of its own afterwards (its prompt suggestion), so the prompt is turned away here.
531 return { drop: stoppedText(budget) }
532 } else if (budget?.state === 'active' && person) {
533 const b = budget
534 await sync($)
535 const note = `${meterLine(b, inFlight)}\nThe budget set earlier for this task still applies to this message.` + (b.remind ? `\n${reminder(b)}` : '')
536 b.remind = false
537 ev = { ...e, context: [...(e.context ?? []), note] }
538 }
539 } catch (err) {
540 await log($, 'hookfail', { where: 'prompt.submit', error: errText(err) })
541 // A budget that was asked for and could not be set: the prompt is not sent without it.
542 if (typed) return { drop: `${NAME}: the command could not be run (${errText(err)}), so nothing was sent.` }
543 if (envBudget && !budget) return { drop: `${NAME}: the budget in ONBUDGET could not be started (${errText(err)}), so the prompt was not sent.` }
544 }
545 return next(ev)
546 }).catch(($, e, next) => {
547 if (next.called) return next(e)
548 if (fromPerson(e.origin) && TYPED.test(e.text)) return { drop: `${NAME}: the command could not be run, so nothing was sent.` }
549 if (envBudget && !budget) return { drop: `${NAME}: the budget in ONBUDGET could not be started, so the prompt was not sent.` }
550 return next(e)
551 })
552
553 // The stop, the effort cap and the ledger: every model request of the session, main thread and subagents.
554 on('turn.step', async function* ($, e, next) {
555 await ready($)
556 if (!budget) {
557 if (envBudget) return yield* refuse(e, failClosedText('the budget in ONBUDGET did not start'))
558 return yield* next(e)
559 }
560 const b = budget
561 const sentAt = await sync($, ledger => ledger)
562 const stop = stopCheck(b, inFlight)
563 if (stop) {
564 b.state = 'stopped'; b.stop = stop; b.refused += 1
565 await save($).catch(() => undefined)
566 showStatus($)
567 await log($, 'stop', { ...stop, spent: b.spent, reserve: reserveOf(b), budget: b.usd, inFlight, agentId: e.agentId ?? null, index: e.index })
568 return yield* refuse(e, refusalText(b, stop, e.agentId !== undefined))
569 }
570 const effort = cappedEffort(e.effort, b.effortCap)
571 const ev = effort === e.effort ? e : { ...e, effort }
572 const beside = inFlight
573 sent.add(stepKey(e))
574 inFlight += 1
575 let r: TurnStepResult
576 // The engine builds the request beneath this hook, asking about its attachments then: a resumed session's history
577 // is asked about during its first request, which therefore keeps every countdown as it was.
578 try { r = yield* next(ev) } finally { inFlight -= 1; history = false }
579 const over = effortBeneath(next.trace, ev.effort, b.effortCap)
580 try {
581 const model = await calModel($).catch(() => '')
582 const now = await $.clock.now()
583 const rates = await budgetRates($, b.spec)
584 // A converted budget's own window keeps the rate it was converted with when none is learned now.
585 if (b.rate && b.spec.kind !== 'usd' && !rates[b.spec.window]) rates[b.spec.window] = b.rate
586 const sizing = b.pending === true
587 const { ledger, usage, stepCost, grew } = await sync($, (ledger, usage) => {
588 const stepCost = stepCostOf(ledger, stepMark, sentAt)
589 stepMark = ledger
590 if (budget === b) {
591 if (stepCost > b.maxStep) b.maxStep = stepCost
592 b.steps += 1
593 if (r?.stopReason != null) {
594 // The readings that came with this answer: the budget's own window, and every window as a wall.
595 const before = b.spent - stepCost
596 const own = b.pending && b.window ? usage.rateLimits.find(x => x.kind === b.window!.kind) : undefined
597 if (own && Number.isFinite(own.percentUsed) && !isStale(own, now)) sizeRest(b, own, rates[b.window!.kind], before)
598 observeWindow(b, usage.rateLimits)
599 observeWalls(b, usage.rateLimits, before, rates, now)
600 }
601 }
602 return { ledger, usage, stepCost, grew: noteCalibration(usage.rateLimits, ledger, model) }
603 })
604 if (budget === b) {
605 await save($)
606 showStatus($)
607 if (sizing && !b.pending) await log($, 'sized', { usd: b.usd, rate: b.rate, allowance: b.allowance ?? null, spent: b.spent })
608 const status = e.agentId === undefined && r?.stopReason === 'end_turn' ? /STATUS:\s*\**\s*(COMPLETE|INCOMPLETE)/.exec(r.answer ?? '') : null
609 if (status && interactive) say($, endText(b, status[1]))
610 if (over && !b.effortOver) {
611 b.effortOver = over
612 await save($)
613 say($, `${NAME}: the plugin ${over.by} sent a request at effort ${over.effort}, above the budget's cap of ${b.effortCap}. The budget's stop still holds; the effort cap does not while ${over.by} sets effort.`)
614 }
615 }
616 await log($, 'step', {
617 agentId: e.agentId ?? null, index: e.index, model: r?.usage?.model ?? e.model, effort: ev.effort ?? null, callerEffort: e.effort ?? null,
618 stopReason: r?.stopReason ?? null, usage: r?.usage ?? null, tools: (r?.toolUses ?? []).map(t => t.name), costUsd: ledger, rateLimits: usage.rateLimits,
619 spent: b.spent, reserve: reserveOf(b), stepCost, maxStep: b.maxStep, inFlight, beside, budget: b.usd, window: b.window ?? null,
620 effortSent: over?.effort ?? ev.effort ?? null, effortOverBy: over?.by ?? null,
621 walls: b.walls ?? null, binding: binding(b, inFlight),
622 })
623 await saveCalibration($, grew)
624 await noteSeen($, usage.rateLimits)
625 } catch (err) { await log($, 'hookfail', { where: 'step.after', error: errText(err) }) }
626 sent.delete(stepKey(e))
627 return r
628 }).catch(async function* ($, e, next) {
629 // The hook failed. If the request had already gone out, let the engine's own outcome through. If it had not,
630 // the stop check never finished: with a budget set (or asked for), the request is not sent.
631 if (sent.has(stepKey(e)) || (!budget && !envBudget)) return yield* next(e)
632 try { await log($, 'stopfail', { where: 'turn.step', agentId: e.agentId ?? null, index: e.index }) } catch { /* ignore */ }
633 return yield* refuse(e, failClosedText('its hook failed or timed out'))
634 })
635
636 // The meter: one line after each tool result, for the model to pace itself by.
637 on('tool.call', async ($, e, next) => {
638 try { await ready($) } catch { /* ignore */ }
639 const r = await next(e)
640 try {
641 const b = budget
642 if (!b || b.state !== 'active' || !r || r.deny !== undefined) return r
643 await sync($)
644 const line = meterLine(b, inFlight)
645 const reminded = b.remind && e.agentId === undefined
646 const lines = reminded ? [reminder(b), line] : [line]
647 if (reminded) b.remind = false
648 await log($, 'tool', { tool: e.tool, agentId: e.agentId ?? null, isError: r.isError ?? false, injected: line, inFlight, reminded })
649 return { ...r, context: [...(r.context ?? []), ...lines] }
650 } catch (err) {
651 await log($, 'hookfail', { where: 'tool.call', error: errText(err) })
652 return r
653 }
654 }).catch(($, e, next) => next(e))
655
656 // Claude Code's own token countdown is not this task's budget: while one is open it is left out of new requests. An
657 // attachment already sent keeps the answer it was sent with (the engine holds an answer per attachment for the
658 // process, and after a resume onbudget gives the same one again), so the prompt cache's prefix never changes.
659 on('prompt.attachment', { type: COUNTDOWN_TYPE }, async ($, e, next) => {
660 const r = await next(e)
661 try {
662 await ready($)
663 if (r?.text == null) return r
664 // The same text recurs (each prompt's countdown starts at the same figure), so a countdown is known by its text
665 // and by how many times that text came before it: the order in which the engine asks again after a resume.
666 const nth = asked.get(r.text) ?? 0
667 asked.set(r.text, nth + 1)
668 let leave = hidden[r.text]?.includes(nth) === true
669 if (!leave && !history && budget !== null && !countdownSet && isDefaultCountdown(r.text)) {
670 leave = true
671 ;(hidden[r.text] ??= []).push(nth)
672 await saveHidden($)
673 }
674 await log($, 'countdown', { agentId: e.agentId ?? null, text: r.text.slice(0, 60), nth, left: leave, history, budget: budget !== null })
675 return leave ? { text: null } : r
676 } catch (err) {
677 await log($, 'hookfail', { where: 'prompt.attachment', error: errText(err) })
678 return r
679 }
680 }).catch(($, e, next) => next(e))
681
682 // Readings as the engine reports them: the calibration record, in every session.
683 on('session.measure', async ($, e, next) => {
684 try {
685 await ready($)
686 const model = await calModel($)
687 const { grew, readings } = await sync($, (ledger, usage) => ({ grew: noteCalibration(usage.rateLimits, ledger, model), readings: usage.rateLimits }))
688 await saveCalibration($, grew)
689 await noteSeen($, readings)
690 if (budget) { await save($); showStatus($) }
691 await log($, 'measure', { rateLimits: e.rateLimits, cost: e.cost ?? null, context: e.context, changed: e.changed, spent: budget?.spent ?? null })
692 } catch (err) { await log($, 'hookfail', { where: 'session.measure', error: errText(err) }) }
693 return next(e)
694 }).catch(($, e, next) => next(e))
695
696 // After a compaction the prompt that carried the protocol may be gone: send it again with the next reading.
697 on('session.compact', async ($, e, next) => {
698 const r = await next(e)
699 try {
700 if (e.agentId === undefined && e.trigger !== 'precompute' && !('skip' in r && r.skip)) {
701 // The conversation starts again from the summary: so does the numbering of the countdowns in it.
702 asked.clear()
703 if (Object.keys(hidden).length) { hidden = {}; await $.store.delete(`hid:${sid}`) }
704 if (budget) {
705 budget.remind = true
706 await save($)
707 await log($, 'compact', { trigger: e.trigger })
708 }
709 }
710 } catch { /* ignore */ }
711 return r
712 }).catch(($, e, next) => next(e))
713
714 on('session.end', async ($, e, next) => {
715 // /clear and /resume leave the session while the process goes on, with no session.start for what follows.
716 const b = budget
717 const id = sid
718 const leaving = e.reason === 'clear' || e.reason === 'resume'
719 if (leaving) leaveSession() // first, and without waiting: the hooks that follow must not see the old session
720 try {
721 const usage = await $.session.usage()
722 if (b) {
723 try { fold(b, ledgerOf(usage)) } catch { /* no ledger */ }
724 // /clear ends the conversation the budget belonged to; any other ending keeps it for a resume.
725 if (e.reason === 'clear') { await $.store.delete(`budget:${id}`); await $.store.delete(`hid:${id}`) }
726 else { b.savedAt = await $.clock.now(); await $.store.set(`budget:${id}`, b) }
727 if (leaving) showStatus($)
728 }
729 await log($, 'end', { reason: e.reason, ...usage, spent: b?.spent ?? null, state: b?.state ?? null })
730 } catch { /* ignore */ }
731 return next(e)
732 }).catch(($, e, next) => next(e))
733}
734hooks/core.ts 634 lines1// onbudget: the budget logic. Pure functions over plain data: no engine calls, no clock, no I/O.
2// register.ts is the only file that talks to Claude Code; everything it decides, it decides here.
3
4export const NAME = 'onbudget' // command and plugin name (see README, "Renaming")
5export const VERSION = '0.1.2' // as in .claude-plugin/plugin.json (a test compares them)
6export const TESTED = 'Claude Code 2.1.288 to 2.1.306' // the builds every suite passed on
7
8/**
9 * Claude Code's own countdown, `<total_tokens>N tokens left</total_tokens>`, which 2.1.304 attaches to requests (type
10 * `total_tokens_reminder`) counting down from a server-set 15,000,000 that starts again at each prompt. While a budget is
11 * open it is left out: it is not this task's budget. A countdown under a million tokens could be a budget the person set
12 * themselves (or the context window), so it is kept, and so is any countdown when CLAUDE_CODE_TOTAL_TOKENS_REMINDER is set.
13 */
14export const COUNTDOWN_TYPE = 'total_tokens_reminder'
15export const COUNTDOWN_MIN = 1_000_000
16export function isDefaultCountdown(text: string): boolean {
17 const m = /^\s*<total_tokens>\s*([0-9]+|Infinite)\s+tokens left\s*<\/total_tokens>\s*$/.exec(text)
18 return m !== null && (m[1] === 'Infinite' || Number(m[1]) >= COUNTDOWN_MIN)
19}
20
21/** Reserve used before any request has been priced under a budget. A heuristic, not a bound. */
22export const BOOTSTRAP_RESERVE_USD = 0.05
23/** Smallest percentage budget that can be enforced on whole-point window readings alone. */
24export const MIN_OBSERVED_POINTS = 3
25/** Window movement that must have been observed before a learned dollars-per-point rate is used. */
26export const MIN_CAL_POINTS = 3
27/** Calibration records and saved budgets older than this are ignored and deleted. */
28export const MAX_AGE_DAYS = 30
29export const DAY_MS = 24 * 3600 * 1000
30/**
31 * The reading at which onbudget treats a usage window as full: one point short of the account's limit, because
32 * readings are whole points and the true figure can be up to a point above the reading. Every window the account
33 * reports is a wall at this reading while a budget is open, and `5h` and `week` budgets are what is left below it.
34 */
35export const WALL_AT = 99
36/** A window this full is left out of calibration: at the wall, spend no longer moves the reading. */
37const CAL_WALL = 99
38/** Answered requests in a row with no reading of the window, after which it counts as not reported. */
39const BLIND_LIMIT = 3
40
41export type WindowKind = 'five_hour' | 'seven_day'
42export const WINDOWS: readonly WindowKind[] = ['five_hour', 'seven_day']
43/** `rest`: the budget `5h` or `week`, what that window has left below the wall when the budget is set. */
44export type Spec = { kind: 'usd'; usd: number } | { kind: 'pct'; window: WindowKind; points: number } | { kind: 'rest'; window: WindowKind }
45export type Reading = { kind: string; percentUsed: number; resetsAt?: string }
46/** Dollars of list-price usage per percentage point of a window, as measured on this account. */
47export type Rate = { usdPerPoint: number; points: number; intervals: number; source: 'learned' | 'config' }
48/**
49 * One process's contribution to the calibration of one window on one model: the dollars its ledger grew by
50 * and the points the window moved, summed over `segments` unbroken runs of readings.
51 */
52export type CalRecord = { kind: WindowKind; model: string; usd: number; points: number; segments: number; at: number }
53/** The last reading of a window and the ledger beside it. `open`: the span that ended here was counted. */
54export type CalMark = { percent: number; ledger: number; resetsAt?: string; open: boolean }
55/**
56 * A usage window as a wall while a budget is open. `last` is its newest reading and `at` the task's spend before the
57 * request whose answer brought it, so a reading that lags its own request is not trusted for that request. `rate`
58 * converts its points into dollars when one is known. `first` is the first reading under the budget, and `stepPts`
59 * the largest move between two readings seen, at least one point: what one request is reckoned to move the window
60 * by when there is no rate. `seed`: made from a reading taken before the budget, whose gap to the first answer is not
61 * one request's movement. `moved`: whole points the window has moved since the first reading under the budget, across
62 * resets, whoever moved them (the receipt).
63 */
64export type Wall = { last: number; resetsAt?: string; at: number; first: number; rate: number | null; stepPts: number; seed?: boolean; moved?: number }
65
66export type Budget = {
67 v: 1
68 raw: string // the budget as typed: "$3", "10% 5h", "5h"
69 spec: Spec
70 task: string // the task's first line, for the status display
71 usd: number | null // the dollar budget; null for a budget counted in window points, or not sized yet
72 rate: Rate | null // the calibration `usd` was converted with
73 allowance?: number // `5h` and `week` sized without a rate: the points the window had left below the wall
74 pending?: boolean // `5h` and `week` set before any reading of the window: sized from the first one
75 spent: number // task-attributable spend so far, in list-price dollars
76 ledger: number // the session-ledger reading already folded into `spent`
77 maxStep: number // the dearest single request seen under this budget: the stop's reserve
78 steps: number // requests answered
79 refused: number // requests refused
80 state: 'active' | 'stopped' // stopped: a request was refused; stays so until `off` or a new budget
81 stop?: Stop
82 // Budgets in a window's points (and `5h`/`week` until sized): the account-wide reading. Readings arrive with the
83 // model's answers, so the window is looked at only after an answered request. `last` is null until the first such
84 // reading, which is the baseline: a stale reading from before the budget cannot count as movement. `blind` counts
85 // answers in a row with none.
86 window?: { kind: WindowKind; last: number | null; resetsAt?: string; moved: number; blind: number }
87 walls?: Partial<Record<WindowKind, Wall>>
88 effortCap: Effort | 'keep'
89 effortOver?: { effort: string; by: string } // a request went out above the cap: the effort and the plugin that set it
90 remind: boolean // send the protocol again with the next meter line (after a compaction)
91 startedAt: number
92 savedAt?: number
93}
94
95export type Effort = 'low' | 'medium' | 'high' | 'xhigh' | 'max'
96const EFFORTS: readonly string[] = ['low', 'medium', 'high', 'xhigh', 'max']
97
98export type Stop =
99 | { event: 'reserve-stop'; spent: number; reserve: number; budget: number; inFlight: number }
100 | { event: 'window-stop'; moved: number; points: number; calibrated: boolean }
101 | { event: 'wall-stop'; window: WindowKind; percent: number; resetsAt?: string; inFlight: number }
102 | { event: 'window-full' } // written by builds before the walls; kept so that a saved budget still reads
103 | { event: 'no-reading' }
104
105export type Command =
106 | { kind: 'status' }
107 | { kind: 'off' }
108 | { kind: 'start'; raw: string; spec: Spec; task: string }
109 | { kind: 'error'; message: string }
110
111export const USAGE = `usage: /${NAME} <$X | N% 5h | N% week | 5h | week> <task> · /${NAME} (status) · /${NAME} off`
112
113const BUDGET_RE = /^(?:\$\s*(\d+(?:\.\d+)?)|(\d+(?:\.\d+)?)\s*%\s*(5h|week)|(5h|week))(?=\s|$)/i
114const windowOf = (word: string): WindowKind => (word.toLowerCase() === '5h' ? 'five_hour' : 'seven_day')
115
116/** `$X`, `N% 5h`, `N% week`, `5h` or `week` at the start of `text`: the budget and how many characters it took. */
117export function parseBudget(text: string): { spec: Spec; raw: string; length: number } | { error: string } | null {
118 const m = BUDGET_RE.exec(text)
119 if (!m) return null
120 if (m[1] !== undefined) {
121 const usd = Number(m[1])
122 if (!(usd > 0)) return { error: `${NAME}: a dollar budget must be more than $0` }
123 return { spec: { kind: 'usd', usd }, raw: `$${m[1]}`, length: m[0].length }
124 }
125 if (m[4] !== undefined) return { spec: { kind: 'rest', window: windowOf(m[4]) }, raw: m[4].toLowerCase(), length: m[0].length }
126 const points = Number(m[2])
127 if (!(points > 0) || points > 100) return { error: `${NAME}: a percentage budget must be more than 0% and at most 100%` }
128 return { spec: { kind: 'pct', window: windowOf(m[3]), points }, raw: `${m[2]}% ${m[3].toLowerCase()}`, length: m[0].length }
129}
130
131/** What follows `/onbudget`: nothing (status), `off`, or a budget and the task. */
132export function parseCommand(args: string): Command {
133 const text = args.trim()
134 if (text === '') return { kind: 'status' }
135 if (/^off$/i.test(text)) return { kind: 'off' }
136 const b = parseBudget(text)
137 if (b === null) return { kind: 'error', message: `${NAME}: "${text.split(/\s+/)[0]}" is not a budget. ${USAGE}` }
138 if ('error' in b) return { kind: 'error', message: b.error }
139 const task = text.slice(b.length).trim()
140 if (task === '') return { kind: 'error', message: `${NAME}: add the task after the budget. ${USAGE}` }
141 return { kind: 'start', raw: b.raw, spec: b.spec, task }
142}
143
144export const windowName = (kind: WindowKind) => (kind === 'five_hour' ? '5-hour' : 'weekly')
145export const money = (usd: number) => `$${usd.toFixed(2)}`
146export const plural = (n: number, word: string) => `${trim(n)} ${word}${trim(n) === '1' ? '' : 's'}`
147export const trim = (n: number) => String(Math.round(n * 10) / 10)
148/** A reset time as the readings give it, in words: " (resets 2026-10-20 14:05 UTC)". */
149export const resetsText = (resetsAt?: string) => (resetsAt && /^\d{4}-\d\d-\d\dT\d\d:\d\d/.test(resetsAt) ? ` (resets ${resetsAt.slice(0, 10)} ${resetsAt.slice(11, 16)} UTC)` : '')
150
151/** The budget in the words every message uses for it. */
152export function shown(b: Pick<Budget, 'spec' | 'usd' | 'allowance'>): string {
153 if (b.spec.kind === 'usd') return money(b.spec.usd)
154 const name = windowName(b.spec.window)
155 if (b.spec.kind === 'rest') {
156 if (b.usd !== null) return `${money(b.usd)} (what the ${name} usage window had left)`
157 if (b.allowance !== undefined) return `${plural(b.allowance, 'point')} of the ${name} usage window (what it had left)`
158 return `what the ${name} usage window has left`
159 }
160 const share = `${trim(b.spec.points)}% of the ${name} usage window`
161 return b.usd === null ? share : `${money(b.usd)} (${share})`
162}
163
164/** Whether a reading belongs to a window that has since reset: its reset time has passed. */
165export function isStale(r: Reading, now: number): boolean {
166 const t = Date.parse(r.resetsAt ?? '')
167 return Number.isFinite(t) && t <= now
168}
169
170// ---------------------------------------------------------------- what is left, and where the stop is
171
172/**
173 * One limit on the task: the budget, or a window of the account. `room` is what can still be spent before the stop
174 * refuses a request, with the reserve already taken off; `span` is the whole of it, for the share left; `step` is what
175 * one request is reckoned to cost. In dollars (`usd`), or in points of the window named by `unit`.
176 */
177export type Limit = { by: 'budget' | WindowKind; unit: 'usd' | WindowKind; room: number; span: number; step: number }
178export type Tier = 'HIGH' | 'MEDIUM' | 'LOW' | 'CRITICAL'
179const RANK: Record<Tier, number> = { HIGH: 0, MEDIUM: 1, LOW: 2, CRITICAL: 3 }
180
181/** What the next request may cost: the dearest request seen so far, or the bootstrap before any was. */
182export const reserveOf = (b: Budget) => (b.maxStep > 0 ? b.maxStep : BOOTSTRAP_RESERVE_USD)
183
184/** The points a budget counted in a window allows: the percentage, or what `5h`/`week` was given. Null in dollars. */
185function budgetPoints(b: Budget): number | null {
186 if (b.usd !== null || !b.window) return null
187 if (b.spec.kind === 'pct') return b.spec.points
188 return b.allowance ?? null
189}
190
191/**
192 * Every limit on the task, as the stop rule applies them. `inFlight` is the number of requests already sent and not
193 * yet answered (subagents run beside the main thread): each of them may still cost a reserve.
194 *
195 * The budget in dollars: refuse when `spent + reserve × (in flight + 1) ≥ budget`. In points (no rate known): refuse
196 * once the window has moved the budgeted points. A window with a rate: refuse when that sum would carry the window
197 * past WALL_AT. A window without one: refuse when the reading leaves no room for the request it may still lag, the
198 * requests in flight and the next, each reckoned at `stepPts`.
199 */
200export function limits(b: Budget, inFlight: number): Limit[] {
201 const reserve = reserveOf(b)
202 const held = reserve * (inFlight + 1)
203 const out: Limit[] = []
204 const points = budgetPoints(b)
205 if (b.usd !== null) out.push({ by: 'budget', unit: 'usd', room: b.usd - (b.spent + held), span: b.usd, step: reserve })
206 else if (points !== null) out.push({ by: 'budget', unit: b.window!.kind, room: points - b.window!.moved, span: points, step: b.walls?.[b.window!.kind]?.stepPts ?? 1 })
207 for (const kind of WINDOWS) {
208 const w = b.walls?.[kind]
209 if (!w) continue
210 if (w.rate) {
211 const ceiling = w.at + (WALL_AT - w.last) * w.rate // the task's spend at which the window would reach the wall
212 out.push({ by: kind, unit: 'usd', room: ceiling - (b.spent + held), span: ceiling, step: reserve })
213 } else {
214 out.push({ by: kind, unit: kind, room: WALL_AT - w.last - w.stepPts * (inFlight + 2), span: WALL_AT - w.first - 2 * w.stepPts, step: w.stepPts })
215 }
216 }
217 return out
218}
219
220export const tierOf = (left: number): Tier => (left >= 0.7 ? 'HIGH' : left >= 0.3 ? 'MEDIUM' : left >= 0.1 ? 'LOW' : 'CRITICAL')
221
222/**
223 * A limit's share left, 0 to 1, and its tier. CRITICAL also when two requests' worth or less is left: with requests
224 * one at a time, each costing no more than the dearest before it, the meter has then read CRITICAL for at least two
225 * requests before the stop can refuse one.
226 */
227export function grade(l: Limit): { left: number; tier: Tier } {
228 const left = l.span > 0 ? Math.min(1, Math.max(0, l.room / l.span)) : 0
229 return { left, tier: l.room <= 2 * l.step ? 'CRITICAL' : tierOf(left) }
230}
231
232/** The limit that binds: the most severe tier; between two at one tier, the less room left in one unit, else the smaller share. */
233export function binding(b: Budget, inFlight: number): (Limit & { left: number; tier: Tier }) | null {
234 let best: (Limit & { left: number; tier: Tier }) | null = null
235 for (const l of limits(b, inFlight)) {
236 const g = { ...l, ...grade(l) }
237 if (!best) { best = g; continue }
238 const d = RANK[g.tier] - RANK[best.tier]
239 if (d > 0 || (d === 0 && (g.unit === best.unit ? g.room < best.room : g.left < best.left))) best = g
240 }
241 return best
242}
243
244/** Whether the next request must be refused, and why. Every limit applies; the budget's own is looked at first. */
245export function stopCheck(b: Budget, inFlight: number): Stop | null {
246 if (b.state === 'stopped') return b.stop ?? { event: 'reserve-stop', spent: b.spent, reserve: reserveOf(b), budget: b.usd ?? b.spent, inFlight: 0 }
247 const w = b.window
248 // Counted in points, or not sized yet: the window's own reading has to be there.
249 if (w && b.usd === null && w.blind >= BLIND_LIMIT) return { event: 'no-reading' }
250 for (const l of limits(b, inFlight)) {
251 if (l.room > 0) continue
252 if (l.by === 'budget') {
253 return l.unit === 'usd'
254 ? { event: 'reserve-stop', spent: b.spent, reserve: reserveOf(b), budget: b.usd!, inFlight }
255 : { event: 'window-stop', moved: w!.moved, points: l.span, calibrated: false }
256 }
257 const wall = b.walls![l.by]!
258 return { event: 'wall-stop', window: l.by, percent: wall.last, resetsAt: wall.resetsAt, inFlight }
259 }
260 // A converted percentage: the dollar rule is the budget and its own window's reading only a backstop against a
261 // conversion rate that has gone wrong. It trips when the window has moved more than twice the budget.
262 if (b.spec.kind === 'pct' && b.usd !== null && w && w.moved >= 2 * b.spec.points + 1) return { event: 'window-stop', moved: w.moved, points: b.spec.points, calibrated: true }
263 return null
264}
265
266/** ", set by the account's 5-hour limit" when a window binds, unless that window is the one a `5h`/`week` budget is. */
267const limitName = (b: Budget, by: Limit['by']) => (by === 'budget' || (b.spec.kind === 'rest' && b.spec.window === by) ? '' : `, set by the account's ${windowName(by)} limit`)
268
269/** The one-line reading the model sees after a tool result. `inFlight` as for the stop, so that both agree. */
270export function meterLine(b: Budget, inFlight = 0): string {
271 const points = budgetPoints(b)
272 const head =
273 b.usd !== null ? `spent ${money(b.spent)} of ${shown(b)}`
274 : points !== null ? `the ${windowName(b.window!.kind)} usage window has moved ${trim(b.window!.moved)} of ${plural(points, 'point')}`
275 : `spent ${money(b.spent)}; ${shown(b)} is not known yet`
276 const m = binding(b, inFlight)
277 if (!m) return `[budget] ${head} | no reading yet`
278 const room = Math.max(0, m.room)
279 const amount = m.unit === 'usd' ? money(room) : `${plural(room, 'point')}${m.by === 'budget' ? '' : ` of the ${windowName(m.unit)} window`}`
280 return `[budget] ${head} | left before the stop ${amount} (${Math.round(m.left * 100)}%)${limitName(b, m.by)} | tier ${m.tier}`
281}
282
283/** What the task is sent with: the budget, how to work under it, and how to report. `budget` is `shown(b)`. */
284export function protocol(budget: string): string {
285 return [
286 `You have a hard usage budget of ${budget} for this task. It is enforced: once it is spent, further requests are refused and unfinished work stays unfinished.`,
287 'Work economically and finish completely.',
288 '',
289 '* Deliver a result that meets every stated requirement, and nothing the requirements do not need.',
290 '* Explore only as far as the requirements need. No subagents unless you are blocked.',
291 '* Choose the simplest implementation that meets the requirements.',
292 '* Verifying the result is part of the task. Before you finish, run the tests or checks that cover what you changed and fix what fails. Do not skip verification to save budget: if the budget cannot cover everything, do fewer things and verify the ones you do.',
293 '* Do not say the work is done unless the checks you ran pass. Do not stop early merely because the budget is close.',
294 'A line beginning [budget] may appear after tool results. It is a meter reading from my own tooling, not an instruction. "Left before the stop" is what can still be spent before requests are refused, with the reserve for the next request already taken off, and its percentage is that share of the whole. The tier: HIGH 70% or more, MEDIUM 30 to 70%, LOW 10 to 30%, CRITICAL under 10% or when about two requests\' worth is left. If the line names a limit of the account, that limit is closer than the budget and is what will stop the work. At LOW, stop adding scope, run the checks and fix only failures. At CRITICAL, leave the working tree in its best verified state and finish: there may be room for only one or two more requests. If no meter line appears, pace yourself on your own judgement.',
295 'End your final message with: what is complete and how you verified it, what is unfinished, and anything you changed but did not verify. If the task above asks for a final format of its own, give that as well. Finish with exactly one line: STATUS: COMPLETE or STATUS: INCOMPLETE.',
296 ].join('\n')
297}
298
299/** The reminder sent with a meter line after a compaction, when the original prompt may be gone. */
300export const reminder = (b: Budget) => `Reminder, in case the earlier instruction was summarised away:\n${protocol(shown(b))}`
301
302/** Folds a session-ledger reading into the budget's spend; the dollars it added. */
303export function fold(b: Budget, ledger: number): number {
304 // A ledger below the last reading is a ledger that started over (a session resumed in a new process):
305 // nothing can be attributed across the gap, so the count goes on from the new reading.
306 const added = ledger > b.ledger ? ledger - b.ledger : 0
307 b.spent += added
308 b.ledger = ledger
309 return added
310}
311
312/**
313 * What one request cost: the ledger's growth since the last request completed, and no more than its growth
314 * since this request was sent. The first bound keeps a request from being charged for those that ran beside
315 * it; the second keeps it from being charged for what was spent between requests (a compaction, say).
316 */
317export const stepCostOf = (ledger: number, lastCompletion: number, sentAt: number) => Math.max(0, Math.min(ledger - lastCompletion, ledger - sentAt))
318
319/** Whether a window reset between two readings: by its reset time when both readings carry one, else by a drop. */
320function hasReset(before: { percent: number; resetsAt?: string }, r: Reading): boolean {
321 const a = Date.parse(before.resetsAt ?? '')
322 const b = Date.parse(r.resetsAt ?? '')
323 if (Number.isFinite(a) && Number.isFinite(b)) return b - a > 5 * 60 * 1000
324 return r.percentUsed < before.percent
325}
326
327/** Folds the readings that came with an answered request into the window a budget is counted in. */
328export function observeWindow(b: Budget, readings: readonly Reading[]): void {
329 const w = b.window
330 if (!w) return
331 const r = readings.find(x => x.kind === w.kind)
332 if (!r || !Number.isFinite(r.percentUsed)) {
333 w.blind += 1
334 return
335 }
336 // After a reset, what the new reading shows was all used since. A reading that only dips is not movement.
337 if (w.last !== null) w.moved += hasReset({ percent: w.last, resetsAt: w.resetsAt }, r) ? r.percentUsed : Math.max(0, r.percentUsed - w.last)
338 w.last = r.percentUsed
339 w.resetsAt = r.resetsAt
340 w.blind = 0
341}
342
343/**
344 * Folds readings into the walls: every window the account reports is one while the budget is open. `spentBefore` is
345 * the task's spend before the request whose answer brought the readings. A window with no new reading keeps its last;
346 * a reading whose window has already reset is ignored. After a reset the wall is simply far away again.
347 */
348export function observeWalls(b: Budget, readings: readonly Reading[], spentBefore: number, rates: Partial<Record<WindowKind, Rate | null>>, now: number, seed = false): void {
349 for (const kind of WINDOWS) {
350 const r = readings.find(x => x.kind === kind)
351 if (!r || !Number.isFinite(r.percentUsed) || isStale(r, now)) continue
352 const walls = (b.walls ??= {})
353 const w = walls[kind]
354 const rate = rates[kind]?.usdPerPoint ?? null
355 if (!w) {
356 walls[kind] = { last: r.percentUsed, resetsAt: r.resetsAt, at: spentBefore, first: r.percentUsed, rate, stepPts: 1, moved: 0, ...(seed ? { seed } : {}) }
357 continue
358 }
359 const reset = hasReset({ percent: w.last, resetsAt: w.resetsAt }, r)
360 if (!w.seed) {
361 if (!reset) w.stepPts = Math.max(w.stepPts, r.percentUsed - w.last)
362 w.moved = (w.moved ?? 0) + (reset ? r.percentUsed : Math.max(0, r.percentUsed - w.last))
363 }
364 delete w.seed
365 Object.assign(w, { last: r.percentUsed, resetsAt: r.resetsAt, at: spentBefore, rate })
366 }
367}
368
369/** Drops walls whose window has reset since their last reading (a session that was away): the next answer brings new ones. */
370export function dropStaleWalls(b: Budget, now: number): void {
371 for (const kind of WINDOWS) {
372 const w = b.walls?.[kind]
373 if (w && isStale({ kind, percentUsed: w.last, resetsAt: w.resetsAt }, now)) delete b.walls![kind]
374 }
375}
376
377/**
378 * Sizes a `5h` or `week` budget from a reading of its window: the points left below the wall, in dollars when a rate
379 * is known (added to the spend before the request that brought the reading), else in points from that reading on.
380 */
381export function sizeRest(b: Budget, r: Reading, rate: Rate | null, spentBefore: number): void {
382 const points = Math.max(0, WALL_AT - r.percentUsed)
383 if (rate) {
384 b.usd = round4(spentBefore + points * rate.usdPerPoint)
385 b.rate = rate
386 } else {
387 b.allowance = points
388 if (b.window) Object.assign(b.window, { last: r.percentUsed, resetsAt: r.resetsAt, moved: 0, blind: 0 })
389 }
390 b.pending = false
391}
392
393/**
394 * What the task moved the account's windows by, as far as they were read: "the 5-hour window moved 3 points, the weekly
395 * window moved 0 points". `lead` goes first, and `except` is a window it already gives.
396 */
397export function movedText(b: Budget, lead?: string, except?: WindowKind): string | null {
398 const parts = WINDOWS.flatMap(kind => {
399 const w = b.walls?.[kind]
400 return w && !w.seed && kind !== except ? [`${kind === 'five_hour' ? 'the 5-hour window' : 'the weekly window'} moved ${plural(w.moved ?? 0, 'point')}`] : []
401 })
402 if (lead) parts.unshift(lead)
403 return parts.length ? `${parts.join(', ')} (whole points, whole account)` : null
404}
405
406/**
407 * The line the plugin writes (to the person, not the model) when Claude ends the task with its STATUS line. Under a
408 * budget counted in points: the points its window moved against those budgeted, and the task's own spend apart.
409 */
410export function endText(b: Budget, status: string): string {
411 const open = `The budget is still open: a follow-up prompt draws on it, and /${NAME} off removes it.`
412 const points = budgetPoints(b)
413 if (points === null) {
414 const moved = movedText(b)
415 return `${NAME}: Claude ended the task with STATUS: ${status}, at ${money(b.spent)} of ${shown(b)}${moved ? `; ${moved}` : ''}. ${open}`
416 }
417 const own = b.window!.kind
418 const moved = movedText(b, `the ${windowName(own)} window moved ${trim(b.window!.moved)} of the ${plural(points, 'point')} budgeted`, own)
419 return `${NAME}: Claude ended the task with STATUS: ${status}: ${moved}; the task's own spend was ${money(b.spent)}. ${open}`
420}
421
422/** Why a stopped budget stopped, in a few words. */
423export function stopLabel(stop: Stop | undefined): string {
424 if (!stop || stop.event === 'reserve-stop') return 'the budget cannot cover another request'
425 if (stop.event === 'wall-stop') return `the account's ${windowName(stop.window)} limit is close`
426 if (stop.event === 'window-full') return 'the usage window is full'
427 if (stop.event === 'no-reading') return 'the usage window is not being reported'
428 return stop.calibrated ? 'the usage window moved far past the budget' : 'the usage window moved its budgeted share'
429}
430
431// ---------------------------------------------------------------- the status line (shown to the person, never sent)
432
433/**
434 * The longest status line: Claude Code (2.1.304 to 2.1.306) shows 63 characters of a mod's status text after the mod's
435 * name and cuts the rest, so the line is built to fit in 60, its most important fields first.
436 */
437export const STATUS_MAX = 60
438const SHORT: Record<WindowKind, string> = { five_hour: '5h', seven_day: 'week' }
439
440/** Dollars in at most 7 characters: cents below $1,000, whole dollars below a million, then millions. */
441export function cash(usd: number): string {
442 const x = Math.max(0, usd)
443 if (x < 999.995) return `$${x.toFixed(2)}`
444 if (x < 999_999.5) return `$${Math.round(x)}`
445 if (x < 999.5e6) return `$${Math.round(x / 1e6)}M`
446 return '>$999M'
447}
448const pts = (n: number) => `${trim(n)} pt${trim(n) === '1' ? '' : 's'}`
449
450/** The budget in a few characters; `short` leaves out what a percentage or `5h`/`week` budget came to. */
451function budgetLabel(b: Budget, short: boolean): string {
452 if (b.spec.kind === 'usd') return cash(b.spec.usd)
453 const w = SHORT[b.spec.window]
454 const points = budgetPoints(b)
455 if (b.spec.kind === 'pct') return b.usd === null ? `${pts(b.spec.points)} (${w})` : short ? `${trim(b.spec.points)}% ${w}` : `${cash(b.usd)} (${trim(b.spec.points)}% ${w})`
456 if (b.usd !== null) return short ? w : `${cash(b.usd)} (${w})`
457 if (points !== null) return `${pts(points)} (${w})`
458 return short ? w : `${w} (not sized yet)`
459}
460
461/** Why a stopped budget stopped, in a few characters; empty when the spend against the budget says it. */
462function stopShort(b: Budget, stop: Stop | undefined): string {
463 if (!stop || stop.event === 'reserve-stop') return ''
464 if (stop.event === 'wall-stop') return b.spec.kind === 'rest' && b.spec.window === stop.window ? '' : `${SHORT[stop.window]} limit`
465 if (stop.event === 'window-stop') return stop.calibrated && b.window ? `${SHORT[b.window.kind]} moved ${pts(stop.moved)}` : ''
466 return stop.event === 'no-reading' ? 'no reading' : 'window full'
467}
468
469/** The first of the forms that fits in STATUS_MAX, its fields joined; the last one cut to fit when none does. */
470function fit(forms: string[][]): string {
471 const lines = forms.map(f => f.filter(Boolean).join(' · '))
472 const line = lines.find(l => l.length <= STATUS_MAX) ?? lines[lines.length - 1]
473 return line.length <= STATUS_MAX ? line : `${line.slice(0, STATUS_MAX - 1)}…`
474}
475
476/**
477 * The status line under the prompt, for the person: the tier, what is left before the stop and in what unit, the
478 * budget, and which account limit sets the stop when one does rather than the budget. In points under a budget counted
479 * in points. A stopped budget: that it stopped, the spend against the budget, and how to go on. Full detail is in
480 * `/onbudget`. The model never sees this line; it reads `meterLine`.
481 */
482export function statusLine(b: Budget, inFlight = 0): string {
483 const points = budgetPoints(b)
484 const budget = (short: boolean) => `budget ${budgetLabel(b, short)}`
485 if (b.state === 'stopped') {
486 const why = stopShort(b, b.stop)
487 const head = why ? `STOPPED (${why})` : 'STOPPED'
488 const spend = (short: boolean) => `${points !== null ? trim(b.window!.moved) : cash(b.spent)} of ${budgetLabel(b, short)}`
489 const off = `/${NAME} off`
490 return fit([[head, spend(false), off], [head, spend(true), off], ['STOPPED', spend(false), off], ['STOPPED', spend(true), off]])
491 }
492 const m = binding(b, inFlight)
493 if (!m) return fit([['no reading yet', budget(false)], ['no reading yet', budget(true)]])
494 const room = Math.max(0, m.room)
495 const wall = m.by === 'budget' ? undefined : b.walls?.[m.by]
496 // Under a budget counted in points, a limit that converts to dollars is shown in its window's points.
497 const amount = m.unit !== 'usd' ? pts(room) : b.usd === null && wall?.rate ? pts(Math.floor((room / wall.rate) * 10) / 10) : cash(room)
498 const by = m.by !== 'budget' && !(b.spec.kind === 'rest' && b.spec.window === m.by) ? ` (${SHORT[m.by]} limit)` : ''
499 const left = `${amount} left${by}`
500 return fit([[`tier ${m.tier}`, left, budget(false)], [`tier ${m.tier}`, left, budget(true)], [m.tier, left, budget(true)]])
501}
502
503/** Text of the made-up response that stands in for a refused request. */
504export function refusalText(b: Budget, stop: Stop, isSubagent: boolean): string {
505 if (isSubagent) return `[${NAME}] This subagent was stopped before it finished: the task's usage budget does not allow another request (${stopLabel(stop)}). Its work is incomplete and unverified.`
506 const w = b.spec.kind !== 'usd' ? windowName(b.spec.window) : ''
507 let why: string
508 if (stop.event === 'reserve-stop') {
509 const beside = stop.inFlight > 0 ? `, as could each of the ${plural(stop.inFlight, 'request')} still running` : ''
510 why = `${money(b.spent)} of ${shown(b)} is used, and the next request could cost up to ${money(stop.reserve)}${beside}, so it was not sent.`
511 } else if (stop.event === 'wall-stop') {
512 why = `the account's ${windowName(stop.window)} usage limit is close: the window is at ${trim(stop.percent)}%${resetsText(stop.resetsAt)}. ${NAME} stops here so that Claude Code's own limit does not end the session in the middle of a request. This task had spent ${money(b.spent)} of ${shown(b)}.`
513 } else if (stop.event === 'no-reading') {
514 why = `the ${w} usage window is not being reported in this session, so a budget counted in its points cannot be checked. Use a dollar budget instead.`
515 } else if (stop.event === 'window-full') {
516 why = `the ${w} usage window is full, so its reading can no longer show what this task uses.`
517 } else if (stop.calibrated) {
518 why = `the ${w} usage window has moved ${plural(stop.moved, 'point')} since the budget started, more than twice the ${trim(stop.points)} budgeted, although this task's own spend is only ${money(b.spent)} of ${shown(b)}. Other activity on the account, or an out-of-date conversion rate, is the likely cause.`
519 } else {
520 why = `the ${w} usage window has moved ${plural(stop.moved, 'point')} since the budget started (${trim(stop.points)} budgeted). The reading is account-wide and in whole points.`
521 }
522 return [
523 `[${NAME}] Stopped: ${why}`,
524 `This notice comes from the ${NAME} plugin, not from Claude. The work was interrupted: treat anything Claude has not reported as complete and verified as unfinished.`,
525 `To go on: /${NAME} off (no budget), or /${NAME} <budget> <instructions> (a new budget).`,
526 ].join('\n')
527}
528
529/** Why a prompt sent after the budget stopped is not passed on. */
530export const stoppedText = (b: Budget) =>
531 `${NAME}: the budget has stopped this session (${money(b.spent)} of ${shown(b)} used: ${stopLabel(b.stop)}), so the prompt was not sent. /${NAME} off removes the budget; /${NAME} <budget> <instructions> sets a new one.`
532
533/** The made-up response when the stop check itself could not run. */
534export const failClosedText = (reason: string) =>
535 `[${NAME}] Not sent: the budget check could not be completed (${reason}), and ${NAME} does not send a request it cannot check. /${NAME} off removes the budget.`
536
537/**
538 * Whether a hook beneath this one sent the request above the effort cap, and which: `trace` lists each link beneath,
539 * nearest first and the engine last, with what it received. The effort the last one received is what was sent.
540 */
541export function effortBeneath(trace: readonly { plugin: string; received: unknown }[] | undefined, mine: unknown, cap: Effort | 'keep'): { effort: string; by: string } | null {
542 if (cap === 'keep' || !trace?.length) return null
543 const effortOf = (x: unknown) => (typeof x === 'object' && x !== null ? (x as { effort?: unknown }).effort : undefined)
544 const sentWith = effortOf(trace[trace.length - 1].received)
545 if (typeof sentWith !== 'string' || cappedEffort(sentWith, cap) === sentWith) return null
546 let by = trace[0].plugin
547 for (let i = 1; i < trace.length; i++) if (effortOf(trace[i].received) !== effortOf(trace[i - 1].received)) { by = trace[i - 1].plugin; break }
548 return effortOf(trace[0].received) === mine ? { effort: sentWith, by } : null
549}
550
551/** The effort to send: the caller's, lowered to the cap when it is above it. Never raised. A numeric effort is left alone. */
552export function cappedEffort<E>(effort: E, cap: Effort | 'keep'): E | Effort {
553 if (cap === 'keep' || typeof effort !== 'string') return effort
554 const have = EFFORTS.indexOf(effort)
555 return have > EFFORTS.indexOf(cap) ? cap : effort
556}
557
558export const isEffortCap = (x: unknown): x is Effort | 'keep' => x === 'keep' || (typeof x === 'string' && EFFORTS.includes(x))
559
560/**
561 * Extends a calibration record with the span between the previous reading of a window and this one.
562 * A span across a reset, at the wall, or over a ledger that started over is left out, and so is the first
563 * reading after nothing (`mark` null). Each unbroken run of counted spans is one segment.
564 */
565export function extendCalibration(rec: CalRecord, mark: CalMark | null, r: Reading, ledger: number): CalMark {
566 const counted = mark !== null && !hasReset(mark, r) && r.percentUsed >= mark.percent && ledger >= mark.ledger && r.percentUsed < CAL_WALL
567 if (counted) {
568 if (!mark.open) rec.segments += 1
569 rec.points += r.percentUsed - mark.percent
570 rec.usd += ledger - mark.ledger
571 }
572 return { percent: r.percentUsed, ledger, resetsAt: r.resetsAt, open: counted }
573}
574
575/**
576 * Dollars per point of `kind` on `model`, pooled over the records: total spend over total movement.
577 * Readings are whole points, so every segment of readings carries a rounding error at each end (about 0.41
578 * points standard deviation per segment); two standard deviations of the pooled error are added to the
579 * movement, which makes the rate err low, and a low rate makes a percentage budget buy fewer dollars, never
580 * more. Other sessions on the account push the same way: they move the window without adding to this ledger.
581 */
582export function poolRate(records: readonly CalRecord[], kind: WindowKind, model: string, now: number): Rate | null {
583 const mine = records.filter(r => r.kind === kind && r.model === model && now - r.at < MAX_AGE_DAYS * DAY_MS && (r.usd > 0 || r.points > 0))
584 const usd = mine.reduce((s, r) => s + r.usd, 0)
585 const points = mine.reduce((s, r) => s + r.points, 0)
586 const segments = mine.reduce((s, r) => s + Math.max(1, r.segments || 0), 0)
587 if (points < MIN_CAL_POINTS || !(usd > 0)) return null
588 return { usdPerPoint: usd / (points + 2 * 0.41 * Math.sqrt(segments)), points, intervals: mine.length, source: 'learned' }
589}
590
591export const round4 = (x: number) => Math.round(x * 10000) / 10000
592
593/** The `/onbudget` reading of one wall: where the window is and where onbudget stops for it. */
594function wallText(b: Budget, kind: WindowKind, w: Wall): string {
595 const where = `at ${trim(w.last)}%${resetsText(w.resetsAt)}${w.seed ? '' : `, moved ${plural(w.moved ?? 0, 'point')} since the budget started (whole points, whole account)`}`
596 if (w.rate) return `${where}; ${NAME} stops for it after about ${money(Math.max(0, w.at + (WALL_AT - w.last) * w.rate - b.spent))} more (1 point ≈ ${money(w.rate)})`
597 return `${where}; no conversion rate is known, so ${NAME} stops for it at about ${trim(WALL_AT - 2 * w.stepPts)}%`
598}
599
600/** The multi-line answer of `/onbudget` with no arguments. */
601export function statusText(b: Budget | null, rates: Partial<Record<WindowKind, Rate | null>>, inFlight = 0, readings = true): string {
602 const lines: string[] = []
603 if (!b) {
604 lines.push(`${NAME}: no budget is set. ${USAGE}`)
605 if (!readings) lines.push(` No usage-window reading has arrived in this session yet: the first comes with Claude's first answer. A dollar budget works now; 5h, week and percentage budgets are sized or checked from that reading.`)
606 }
607 else {
608 const m = binding(b, inFlight)
609 const points = budgetPoints(b)
610 const head =
611 b.usd !== null ? `${money(b.spent)} of ${shown(b)} spent`
612 : points !== null ? `${shown(b)}: moved ${plural(b.window?.moved ?? 0, 'point')} so far, own spend ${money(b.spent)}`
613 : `${shown(b)}: not known yet, own spend ${money(b.spent)}`
614 const left = m ? ` · left before the stop ${m.unit === 'usd' ? money(Math.max(0, m.room)) : plural(Math.max(0, m.room), 'point')} (${Math.round(m.left * 100)}%)${limitName(b, m.by)}` : ''
615 lines.push(`${NAME}: ${head}${left} · ${b.state === 'stopped' ? `STOPPED (${stopLabel(b.stop)})` : m ? `tier ${m.tier}` : 'no reading yet'}`)
616 lines.push(` task: ${b.task}`)
617 lines.push(` requests: ${b.steps} answered, ${b.refused} refused · dearest so far ${money(b.maxStep)} (held back as reserve) · effort ${b.effortCap === 'keep' ? 'unchanged' : `capped at ${b.effortCap}`}`)
618 if (b.effortOver) lines.push(` effort: the plugin ${b.effortOver.by} sent requests at ${b.effortOver.effort}, above the cap; the stop still holds, the cap does not`)
619 if (b.window && b.spec.kind === 'pct') lines.push(` ${windowName(b.window.kind)} window under the budget: ${b.window.last === null ? 'no reading yet' : `moved ${plural(b.window.moved, 'point')} since the budget started (account-wide)`}`)
620 for (const kind of WINDOWS) {
621 const w = b.walls?.[kind]
622 if (w) lines.push(` account limit, ${windowName(kind)} window: ${wallText(b, kind, w)}`)
623 }
624 if (b.rate) lines.push(` conversion: 1 point ≈ ${money(b.rate.usdPerPoint)} (${b.rate.source === 'config' ? 'from your settings' : `learned from ${plural(b.rate.points, 'point')} of movement; errs low`})`)
625 }
626 lines.push(` ${NAME} ${VERSION}, tested on ${TESTED}`)
627 for (const kind of WINDOWS) {
628 const r = rates[kind]
629 if (r === undefined) continue
630 lines.push(` calibration, ${windowName(kind)} window: ${r ? `1 point ≈ ${money(r.usdPerPoint)} (${r.source === 'config' ? 'from your settings' : `${plural(r.points, 'point')} observed in ${plural(r.intervals, 'session')}${kind === 'seven_day' ? '; used only for the weekly limit under other budgets, since weekly budgets count points unless usdPerPointWeek is set' : ''}`})` : `not enough data yet (needs ${MIN_CAL_POINTS} points of movement observed)`}`)
631 }
632 return lines.join('\n')
633}
634