SLOPSHOPPER

onbudget

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…

newguardcommandstatusprompttimer
v0.1.2MITupdated 2026-10-22sebastiandrazsky/onbudget
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · onbudget
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /onbudget ⎿ onbudget: no budget is set. usage: /onbudget <$X | N% 5h | N% week | 5h | week> <task> · /onbudget (status) · /o ⎿ onbudget: onbudget 0.1.2, tested on Claude Code 2.1.288 to 2.1.306 ⎿ onbudget: calibration, 5-hour window: not enough data yet (needs 3 points of movement observed) ⎿ onbudget: calibration, weekly window: not enough data yet (needs 3 points of movement observed) ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

onbudget

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.

  • Reads the session's cost ledger and usage-window readings, its id and model, three environment variables (none a secret), and its own store.
  • Writes its own store (~/.claude/plugins/store/onbudget_*.json), a status line, and, only with ONBUDGET_LOG=1, an event log in its own folder. Nothing else.
  • Sends nothing anywhere. No network calls, no commands, no credentials. With no budget set it changes no request.

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.

What it guarantees, and in which units

  • The stop comes before the request. onbudget sits on Claude Code's model-request hook. When the spend so far, plus what the dearest request of the task has cost, would reach the budget, the next request is answered by the plugin with a notice and never reaches the API. Requests already running are not cancelled, and each one holds back its own reserve.
  • Two documented ways past it. One request that costs more than any before it, because nothing can price a request before it is sent; and Claude Code's own requests that do not pass through that hook, the next-prompt suggestion between turns and a compaction. Both are counted, and both are under Limitations.
  • Dollars are list-price dollars as /cost counts them, read from Claude Code's own ledger, subagents included. onbudget does no pricing of its own.
  • Window budgets are in whole points of the window, for the whole account, as Claude Code reports them. 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.
  • Every budget is also bounded by what the account has left. While a budget is open, both windows count as limits, and onbudget stops short of either one with its own notice (at 99%, or about 97% when it counts the window's points), before Claude Code's limit message would end the session in the middle of a request.
  • It lands early. The model reads where the stop is and wraps up before it. In the second round the three onbudget runs ended by themselves at tier LOW with 19 to 25% of the budget unspent.
  • Idle means idle. With no budget set, onbudget adds nothing to Claude's context and changes no request.

Results

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

nTasks complete, of 7Within $2.47Ended by itself, with a reportCost, each run
onbudget35, 5, 53 of 33 of 3$2.00, $1.86, $1.97
Plain, --max-budget-usd 2.4725, 62 of 22 of 2$2.06, $2.25
Budget sentence in the prompt25, 52 of 2, one only because a safety cap refused its next request1 of 2$2.38, $2.10
  • No onbudget run has gone over its budget: 12 runs with the real model, on Claude Code 2.1.289 and 2.1.304 (10 seven-task sessions and 2 single tasks). 11 of the 12 ended by themselves with a report.
  • On 2.1.304 it is parity: onbudget finished no more tasks than plain Claude Code under its own cap or a sentence in the prompt. With n of 2 or 3, a difference of a task or less is noise. The six-task --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.
  • The build that ships is not byte for byte the one round two ran (git tag 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.
  • On 2.1.289, with an earlier build of onbudget (git tag 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.
  • Every run had a one-line ~/CLAUDE.md in context, a pointer to a writing-style skill.

How it relates to what Claude Code already does

  • --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.
  • The <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).
  • The warning at 85% of the session limit tells you; onbudget acts on the model's requests.
  • Auto-continue at a reset (2.1.234 and later) carries on after the limit resets; a budget does not grow at a reset.
  • The Wrap-Up Allowance lets a response in progress finish at the 5-hour limit. It is rolling out gradually and does not apply to API keys. onbudget stops before either limit, including the weekly one, where the allowance does not apply.
  • A near-limit note ("Claude will wrap up the current step") has been in the binaries since 2.1.250 behind a flag; onbudget's meter tells the model what is left throughout.
  • The hidden --task-budget flag and the SDK's taskBudget (alpha) set an API-side budget in tokens; onbudget does not use them.
  • None of these gives a task a budget in an interactive session, and none acts at the weekly limit.

Other plugins

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.

Install

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.

When onbudget is not running, there is no budget

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.

Use

A dollar budget

/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:

  • A status line under the prompt shows the tier, what is left before the stop and the budget, for example 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.
  • After each tool result Claude is shown the meter, a longer line with the same figures: [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.
  • Reasoning effort is capped at medium while the budget is set. Your setting comes back when the budget is removed.
  • When Claude ends the task with its STATUS line, the plugin adds a line of its own in a terminal session: how the task ended, the spend against the budget, how many whole points each usage window moved, and that the budget is still open. Under a budget counted in points it gives the points the window moved against the points budgeted, and the task's own spend in dollars apart.

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.

A share of a usage window

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

What a window has left

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

Every budget is also bounded by what the account has left

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.

Headless runs

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.

How it works

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.

What it reads, writes and changes

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

What it reads

  • The session's cost ledger and the usage-window readings Claude Code reports, the session's id and model, Claude Code's version, and its own store.
  • Three environment variables. None is a secret, and none holds a credential:
  • 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.

What it writes

  • Its store. One JSON file under your Claude config directory, 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.
  • The event log, off by default. With 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.
  • Display only. The status line under the prompt, and lines in the transcript that Claude does not read: the line under Claude's STATUS line, the note when another plugin raises the effort, and the answers to /onbudget.

It writes nothing else: no build, start-up, settings or instructions file, and nothing in your project.

What it adds to the prompts it submits

<!-- readme.py: prompts --> Only while a budget is set. These texts are rendered

Source 2 files
hooks/register.ts 734 lines
1// 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}
734
hooks/core.ts 634 lines
1// 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