SLOPSHOPPER

usage-dollars

Shows subscription usage for the 5-hour window and the week as API-equivalent dollars: estimated allowance first, then used and left, with 90% ranges; spending…

newrowscommandtoaststatusprocess
v0.7.0no licenseupdated 2026-10-05hyvanmielenpelit/ClaudeCodeMods/usage-dollars
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-dollars
› fix the failing auth test and add an audit log call ● usage-dollars: usage-dollars: history: helper exited with 0 ● usage-dollars: usage-dollars: RangeError: Invalid Date ⏺ 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 › /usage-dollars ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ │ │ Waiting for the first usage reading · usage-dollars │ │ │ │ ✓ Everything is in order. The plugin is installed and running; it has simply not received a usage reading yet. │ │ This is expected right after the plugin is installed or updated, and on its first use on this machine. │ │ │ │ Why there are no figures yet │ │ Claude Code learns how much of the 5-hour window and the week has been used only from the API, and the API │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Command output
╭─────────────────────────────────────────────────────────────────────────────────────────────────── │ │ Waiting for the first usage reading · usage-dollars │ │ ✓ Everything is in order. The plugin is installed and running; it has simply not received a │ usage reading yet. This is expected right after the plugin is installed or updated, and on its │ first use on this machine. │ │ Why there are no figures yet │ Claude Code learns how much of the 5-hour window and the week has been used only from the API, │ and the API reports it alongside each model reply: the percent used and when each window │ resets. Every figure on this card is built on that reading, and this installation has not │ stored one yet. │ │ This sign-in has no subscription usage limits, so there is nothing to show; spending reports │ still work. │ │ If no figures appear after a reply, /usage-dollars check shows what is missing. │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────
README

Claude Code Mods

Mods for Claude Code by Hyvän Mielen Pelit, written as function-hook plugins. The repository is also a plugin marketplace.

ModWhat it does
usage-dollarsSubscription usage for the 5-hour window and the week as API-equivalent dollars: the estimated allowance first, then used and left, with 90% ranges; spending reports by date range.

Installing

From inside Claude Code:

/plugin marketplace add hyvanmielenpelit/ClaudeCodeMods
/plugin install usage-dollars@hyvanmielenpelit-claude-code-mods

To load a working copy for one session instead:

claude --plugin-dir C:\hmp\ClaudeCodeMods\usage-dollars

Sessions the desktop app starts take no flags; name the folder in CLAUDE_CODE_PLUGIN_DIRS in the env block of ~/.claude/settings.json instead.

Function-hook plugins are an early-access Claude Code surface and may change between releases. Check a mod with claude plugin validate <folder>.

usage-dollars

Shows on the status line, what is left of each window's estimated allowance:

usage-dollars  5h ~$850 left of $913 · Week ~$2.7k left of $2.8k

While a window has no estimate yet it reads 5h $40 used, estimating, and once nothing is left, 5h at limit. ⚠ Plan changed means a plan change was observed and the card has not been opened since.

Commands

CommandShows
/usage-dollarsThe window card: plan, notices, each window's allowance first, then used, left, the next tick, and spending per model
/usage-dollars helpThe quick start: setup, everyday commands, good habits
/usage-dollars help advancedThe full guide: every command, reading the card, calibration, the setup check, troubleshooting
/usage-dollars reportSpending in the last 24 hours, per day and per model
/usage-dollars 24h, /usage-dollars 7dSpending in the last N hours or days, up to 90 days
/usage-dollars todaySpending since local midnight
/usage-dollars 2026-10-01Spending on one local day
/usage-dollars 2026-10-01..2026-10-05Spending over local days, both inclusive
/usage-dollars resetRestarts the estimates for this subscription (see below)
/usage-dollars reset undoUndoes the latest reset
/usage-dollars checkA setup check: the helper, the subscription, other sessions' hooks and price table, this session's readings, the prices; each ✓, ✗ with the fix, or ℹ
/usage-dollars calibrateWhat each window's estimate rests on: percent levels and ticks read, whether each spread and the rounding rule are measured and from how many windows, the current range

The window card is allowance-first: a headline row gives each window's estimated allowance, its 90% range and a confidence label (good within about ±10%, fair within ±30%, rough beyond), followed by "calibrated" once both spreads and the rounding rule are measured. Each window then shows what is used and what is left, a bar of used dollars against the allowance range, the percent the estimate rests on ("Limit reports 10% · read 2 min ago"), and "next tick": about how many more dollars until the reported percent moves again. Before the first estimate a window reads "estimating…"; the first estimate follows the first reply that finds the window above 0%, or comes at once from past windows when there are any.

Until an installation has stored a reading, /usage-dollars and /usage-dollars calibrate show a "Waiting for the first usage reading" card instead. A reading comes only with a reply in the main conversation. Interactive terminal sessions rarely need to wait, because Claude Code sends a minimal request of its own at startup there. A new desktop session has no reading until its first reply. The card's button, "Send a short check message (uses one turn)", sends one self-describing message asking for a one-word reply and no tools. It is sent only when pressed and is not offered again until a turn completes. A plugin's own model request does not bring a reading, so nothing is sent automatically. A sign-in without a subscription never gets a reading, and the card says so.

A spending report covers only as far back as this machine's transcripts do. When the range starts earlier, the report says where the transcripts begin.

Getting rigorous estimates

The quick start (/usage-dollars help) has the short version. The full guide (/usage-dollars help advanced) covers these steps in detail, with what each line of check and calibrate means, how long calibration takes, and what to do when something changes.

  1. Load one copy, then restart every session. Sessions that started before an update keep running the old hooks and can lose readings.
  2. Run /usage-dollars check and fix every ✗.
  3. Use Claude Code as you would anyway. A reading is taken after every reply in any session of the subscription. The first ranges are wide, because both spreads are still assumptions; they narrow as the percent ticks over and as windows close.
  4. Run /usage-dollars calibrate to see what has been measured. Every closed window that reached 10% counts, weighted by how much it says; a window that hit the limit counts in full. Light use calibrates more slowly. A window reads "Calibrated" once the within-window spread rests on 2 closed windows, the between-window spread on about 3 past windows' worth of information, and the rounding rule on 5 closed windows.
  5. After a plan change or a promotion the card says so, or run /usage-dollars reset; calibration starts again from the next window. A change of the price table does the same.

The plan line and plan changes

The plan comes from the signed-in account profile in ~/.claude.json (or $CLAUDE_CONFIG_DIR/.claude.json), for example "Plan: Max 20x · profile as of Mon 5 Oct, 10:23". Claude Code refreshes that profile on its own schedule, sometimes a day or more apart, so the card always says how old it is and adds "(may be out of date)" after a week. The profile describes only the subscription Claude Code is signed in to now: a session that bills another subscription shows "Plan: unknown for this subscription", with the plan last seen for it. Plan values the mod does not know are shown verbatim, never guessed.

The mod tells two kinds of change apart:

  • Observed. The profile reported a different plan. The card says "Plan changed: Max 5x → Max 20x (between … and …)". The change is known only between the two profile fetches, and estimates restart from the later one, so no reading taken under the old plan is used.
  • Inferred. The current window no longer fits recent windows. The card says limits may have changed and suggests a reset, but changes nothing by itself. Usage on another device, or on claude.ai, raises the percent without raising this machine's dollars, and looks exactly like a lowered limit.

Promotions that Claude Code has cached for the signed-in subscription are shown too. A new promotion restarts the estimates for the window it names, and so does its end date passing.

Starting over: /usage-dollars reset

Use it after changing plans when the card does not show the change yet, when a promotion starts or ends that the card did not report, or after the inferred-change notice when you know the limits really changed.

DataAfter reset
Closed windows' readings and rate-limit rejections from before the resetNo longer feed the prior or the measured spreads. Both spreads fall back to their assumed values until new windows close.
The current window's readings taken before the resetExcluded. The estimate rebuilds from readings taken after it; the first scan after it takes one at once.
The current window's used dollarsUnchanged: they are a fact about spending, not about the limit.
The plan ledger, spending reports, the 24-hour figure, per-model tablesUnchanged.
Stored dataNothing is deleted. Readings stay until the normal 28-day pruning.
Other subscriptionsUnaffected.

Ranges widen to what the current readings alone support, then narrow again as the percent ticks over: under an hour of normal work on a 5-hour window, about a day on the week. /usage-dollars reset undo restores the earlier estimates, apart from readings taken in between. Two resets are undone one at a time. A reset followed by an observed plan or promotion change can no longer be undone.

How the figures are made

  • Used prices every request in this machine's transcripts since the window began at API list rates: input, output, cache reads, 5-minute and 1-hour cache writes, fast mode and web searches. Only requests billed to the subscription the session runs on count, matched through the bridge-session records the desktop app writes; a session with no record is taken to bill the signed-in subscription. Checked against Claude Code's own per-session costs it usually comes within a few percent, and up to a quarter low on long sessions, because some billed calls never reach a transcript.
  • Readings. The API reports the window's usage in whole percent, and the percent a response carries includes that response. A reading is taken when this session has just received the percent, and pairs it with the dollars counted up to the moment it arrived, not up to when the scan runs. Another session's request in the 30 seconds before that moment may or may not be in the percent yet; its dollars are kept with the reading as slack. Running sessions share the freshest percent between them, and a reading is also taken for a percent another session stored without one, with the dollars up to when that session received it. No reading is taken while more than 2% of a window's requests go to unpriced models, and readings priced with an older price table are dropped.
  • Allowance rests on the newest percent level. When the level below it was read too, the percent ticked over between those two readings, and the dollars at that moment divided by the share at the tick give the dollars per percent; otherwise the share anywhere in the level's interval does. Until closed windows show whether the API rounds or truncates, the tick is either rule's, and the range covers both. A level of 100% is used only through its crossing: spending can continue past the limit. A level of 0% is not used either, since it only says the share is under one tick; while it is the only level read, the estimate is the prior from past windows alone, when there is one, and the basis line says so.
  • The 90% range comes from a stated model. The dollars one percent costs vary within a window, because API prices weight tokens differently from the limit. Read at share s, the dollars per percent so far differ from the whole window's by a relative spread omega · √(1/s − 1/100), which vanishes as the window fills; so "left" narrows as it is used. Between windows the allowance itself varies by tau. Past windows act as a prior that is combined with the current reading by precision, unless the two disagree beyond chance; then the reading alone counts and the card says limits may have changed. The interval uses Student's t, so few past windows widen it.
  • Two spreads start as assumptions: omega = 0.5 within a window and tau = 10% between windows. Each is shrunk toward what this machine's own closed windows show: omega from how far the dollars per percent at each tick of a closed window strayed from its end, tau from the scatter of past allowances. The card's basis line says which is assumed and which measured. Past windows come from the mod's own readings of closed windows that reached 10%, each weighted by how much it says, and from rate-limit rejections in the last 14 days of transcripts, each a window seen exactly full; a window seen both ways counts once. Older windows count for less: a past 5-hour window loses half its weight every day, a past week every 14 days, so the estimate follows a change of limits quickly. The card's "history weight" is the effective number of past windows behind the prior.

How fast the allowance can be known, with no past windows, from the coverage simulation in tests/coverage.test.mjs (scenario S1: steady requests of about $0.60 against a $650 window). The first column holds once omega has been measured on 30 closed windows, the second with the assumed omega:

Window usedRange, spread measuredRange, spread assumed
3%±21%about a factor of two
10%±9%±39%
25%±5%±21%
50%±3%±11%
90%±1%±4%

Past windows narrow the early figures further. In the simulation the range holds the true allowance 89% to 96% of the time from 3% used on, when the rounding rule is known, and somewhat more often while it is not (scenarios S1 to S4). Past windows that closed short of their limit err on the wide side: closed at 10% to 60% used (S7), the range holds the truth 96% to 98% of the time; with an allowance that varies 20% between windows and past windows closed at 10% to 20% (S9), 88% at 3% used and 96% to 100% from 10% on.

Upgrading from 0.3.0 restarts the estimates once: the readings it kept cannot be attributed to a subscription. Upgrading from 0.4.0 drops the stored readings once: some paired a session's old percent with current dollars. Upgrading from 0.5.0 drops them once more: they paired the percent with the dollars at the time of the scan. Rate-limit history is kept.

Limits

  • Usage from other machines, or from claude.ai, on the same subscription is not counted, and makes the allowance look smaller than it is.
  • The model assumes the dollars per percent vary from request to request independently. When the mix of work stays expensive or cheap for long stretches (say, one model for an hour, then another), the range is too narrow: in the simulation, with the within-window spread measured, it holds the true allowance 85% to 94% of the time when past windows ran to their limit (S5), and 81% to 89% when they closed at 10% to 60% used (S8), rather than 90%; less while that spread is still assumed.
  • Before the within-window spread is measured, a subscription whose allowance varies between windows much more than 10% can see early ranges (at a few percent used) hold the truth about 80% of the time.
  • Model prices live in scripts/usage-cost.mjs. A model missing there is reported as unpriced, never guessed; add new models as they ship.
  • Requires Node.js on PATH; the transcript scan runs as a child process.
  • Without a subscription (an API key), there are no limits to read and no estimates.

Files

PathRole
.claude-plugin/plugin.jsonManifest
QUICKSTART.mdSetup, everyday commands and good habits in brief; shown by /usage-dollars help
GUIDE.mdCommands, the card, calibration, the setup check, best practices and troubleshooting; shown by /usage-dollars help advanced
hooks/register.tsxHooks: events, the /usage-dollars command forms, toasts
hooks/measure.tsThe scan, stored readings and plan history per subscription, the estimates
hooks/estimate.tsAllowance estimate, its 90% range, rounding inference, next tick
hooks/plan.tsPlan ledger, promotions, regimes, reset and undo
hooks/card.tsxThe window, report, check, calibration and guide cards, their Markdown fallbacks, the status line
scripts/usage-cost.mjsTranscript scan, pricing, the profile, date labels (Node)
types/index.d.tsType contract for the mod's session state
tests/*.test.tsUnit tests: claude plugin test usage-dollars (see below)
tests/helper.test.mjsHelper tests: node --test usage-dollars/tests/helper.test.mjs
tests/coverage.test.mjsCoverage of the 90% range in simulated windows: node --test usage-dollars/tests/coverage.test.mjs
tests/fixtures/Synthetic profiles and transcripts for the helper tests

claude plugin test and a claude plugin validate that knows every hook this mod uses need a recent Claude Code. The claude on PATH may be older than the build the desktop app bundles, under %APPDATA%\Claude\claude-code\<version>\<build>\claude.exe on Windows; run that one if plugin test is an unknown command.

node usage-dollars/scripts/usage-cost.mjs --check sets each session's computed cost beside the cost Claude Code recorded for it.

Source 7 files
hooks/register.tsx 240 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionRateLimit } from 'claude-code'
3
4import type { CalibrationReport, CheckReport, GuideReport, SpendReport, UsageReport, WaitingReport } from '../types'
5import {
6  calibrationCard,
7  checkCard,
8  guideCard,
9  guideOf,
10  markdownCalibration,
11  markdownCheck,
12  markdownGuide,
13  markdownSpend,
14  markdownWaiting,
15  markdownWindows,
16  spendCard,
17  waitingCard,
18  windowCard,
19} from './card'
20import {
21  checkReport,
22  markNoticesSeen,
23  refresh,
24  refreshHistory,
25  resetEstimates,
26  resolveSubscription,
27  spendReport,
28  statusOf,
29  takeToasts,
30  toCalibrationReport,
31  toReport,
32  undoReset,
33} from './measure'
34import type { Host } from './measure'
35import { CHECK_PROMPT, waitingReport } from './probe'
36
37const HOUR_MS = 60 * 60 * 1000
38const MAX_REPORT_HOURS = 90 * 24
39const KEPT_REPORTS = 20
40const MARK = /usage-dollars:report:(\d+)/
41
42const USAGE =
43  'Usage: /usage-dollars [help [advanced] | check | calibrate | report | <N>h | <N>d | today | YYYY-MM-DD | YYYY-MM-DD..YYYY-MM-DD | reset | reset undo]' +
44  ' · /usage-dollars help explains them.'
45
46/* The files `help` and `help advanced` show, in the plugin folder. */
47const GUIDE_FILES = new Map([
48  ['help', 'QUICKSTART.md'],
49  ['help advanced', 'GUIDE.md'],
50])
51
52const reports = atom({ plugin: 'usage-dollars', key: 'reports' } as const, {})
53
54/* From a press of the first-reading card's button until the next turn completes. */
55let isCheckInFlight = false
56
57/* $ never crosses an import, so the measure module reaches the engine through these. */
58function hostOf($: EngineInterface): Host {
59  return {
60    root: $.plugin.root,
61    now: () => $.clock.now(),
62    sessionId: () => $.session.id(),
63    rateLimits: async () => (await $.session.usage()).rateLimits,
64    run: (argv, options) => $.process.run(argv, options),
65    get: key => $.store.get(key),
66    set: (key, value) => $.store.set(key, value),
67    delete: key => $.store.delete(key),
68    status: text => $.ui.status(text),
69    log: text => $.ui.log(text, { to: 'debug' }),
70  }
71}
72
73async function toastNotices($: EngineInterface) {
74  for (const text of await takeToasts(hostOf($))) $.ui.toast(text, { timeoutMs: 10000 })
75}
76
77function measureThen($: EngineInterface, limits?: readonly SessionRateLimit[], isForced = false, isFresh = false, receivedAt?: number) {
78  return refresh(hostOf($), limits, isForced, isFresh, receivedAt).then(async summary => {
79    await toastNotices($)
80    return summary
81  })
82}
83
84async function keep($: EngineInterface, report: UsageReport | SpendReport | CheckReport | CalibrationReport | GuideReport | WaitingReport) {
85  const id = String(Date.now())
86  await update($, reports, all => {
87    const kept = Object.entries(all ?? {}).slice(-(KEPT_REPORTS - 1))
88    return { ...Object.fromEntries(kept), [id]: report }
89  })
90  return id
91}
92
93/* The first-reading card, while this installation holds no reading to show. */
94async function waiting($: EngineInterface, command: string) {
95  const report: WaitingReport = waitingReport(command, await resolveSubscription(hostOf($)), isCheckInFlight)
96  return { text: markdownWaiting(report, await keep($, report)) }
97}
98
99async function setCanSend($: EngineInterface, id: string, canSend: boolean) {
100  await update($, reports, all => {
101    const r = all?.[id]
102    return r?.type === 'waiting' ? { ...all, [id]: { ...r, canSend } } : (all ?? {})
103  })
104}
105
106/* The check message, sent once per press: the card stops offering it, and no card offers
107   it again until a turn completes or the message does not enter. */
108async function sendCheck($: EngineInterface, id: string) {
109  if (isCheckInFlight) return
110  isCheckInFlight = true
111  await setCanSend($, id, false)
112  const fail = async (reason: string) => {
113    isCheckInFlight = false
114    $.ui.log(`usage-dollars: check message: ${reason}`, { to: 'debug' })
115    await setCanSend($, id, true)
116  }
117  void $.prompt.submit({ text: CHECK_PROMPT }).then(
118    result => (result.drop !== undefined ? fail('dropped') : undefined),
119    error => fail(String(error)),
120  )
121}
122
123/* The helper's range arguments for a report form, or undefined when the form is not one. */
124function reportRange(args: string, now: number) {
125  const iso = (ms: number) => new Date(ms).toISOString()
126  if (args === 'report') return ['--report', `${iso(now - 24 * HOUR_MS)},${iso(now)}`]
127  const last = /^(\d+)([hd])$/.exec(args)
128  if (last) {
129    const hours = Number(last[1]) * (last[2] === 'd' ? 24 : 1)
130    if (hours < 1 || hours > MAX_REPORT_HOURS) return undefined
131    return ['--report', `${iso(now - hours * HOUR_MS)},${iso(now)}`]
132  }
133  if (args === 'today') return ['--report-local', 'today,today']
134  const day = /^(\d{4}-\d{2}-\d{2})$/.exec(args)
135  if (day) return ['--report-local', `${day[1]},${day[1]}`]
136  const range = /^(\d{4}-\d{2}-\d{2})\.\.(\d{4}-\d{2}-\d{2})$/.exec(args)
137  if (range) return ['--report-local', `${range[1]},${range[2]}`]
138  return undefined
139}
140
141export const register: Register = on => {
142  on('session.start', async ($, e, next) => {
143    await $.command.register({
144      name: 'usage-dollars',
145      description: 'Subscription usage in API-equivalent dollars: allowance, calibration, spending reports, reset; "help" for the guide',
146      argumentHint: '[help [advanced] | check | calibrate | report | 24h | 7d | today | YYYY-MM-DD[..YYYY-MM-DD] | reset [undo]]',
147    })
148    void measureThen($, undefined, true)
149    void refreshHistory(hostOf($)).catch(error => $.ui.log(`usage-dollars: history: ${String(error)}`, { to: 'debug' }))
150    return next(e)
151  })
152
153  /* Only here does the session hold a percent it has just received: a billed response, or
154     a window that moved a whole point. Its readings pair the percent with the dollars up
155     to the moment it arrived, not to when the scan runs. */
156  on('session.measure', async ($, e, next) => {
157    const receivedAt = await $.clock.now()
158    const isFresh = e.changed.includes('cost') || e.changed.includes('rateLimits')
159    void measureThen($, e.rateLimits, e.changed.includes('rateLimits'), isFresh, receivedAt)
160    return next(e)
161  })
162
163  on('turn.complete', async ($, e, next) => {
164    isCheckInFlight = false
165    return next(e)
166  })
167
168  on('command.run', { command: 'usage-dollars' }, async ($, e) => {
169    const args = e.args.trim().toLowerCase().replace(/\s+/g, ' ')
170
171    if (args === '') {
172      const summary = await measureThen($, undefined, true)
173      if (!summary) return waiting($, '/usage-dollars')
174      await markNoticesSeen(hostOf($))
175      $.ui.status(statusOf(summary, false))
176      const report = toReport(summary, await $.clock.now())
177      return { text: markdownWindows(report, await keep($, report)) }
178    }
179
180    if (args === 'reset') {
181      await resetEstimates(hostOf($))
182      await toastNotices($)
183      return {
184        text: 'Estimates restarted for this subscription. Ranges widen until new readings arrive. Undo with /usage-dollars reset undo.',
185      }
186    }
187    if (args === 'reset undo') {
188      const isUndone = await undoReset(hostOf($))
189      return { text: isUndone ? 'Reset undone.' : 'Nothing to undo.' }
190    }
191
192    const guideFile = GUIDE_FILES.get(args)
193    if (guideFile) {
194      let text: string
195      try {
196        text = await $.fs.read(`${$.plugin.root}/${guideFile}`)
197      } catch (error) {
198        $.ui.log(`usage-dollars: help: ${String(error)}`, { to: 'debug' })
199        return { text: `The guide could not be read: ${guideFile} is missing from the plugin folder.\n\n${USAGE}` }
200      }
201      const report: GuideReport = guideOf(text)
202      return { text: markdownGuide(report, await keep($, report)) }
203    }
204    if (args === 'check') {
205      const report = await checkReport(hostOf($))
206      await toastNotices($)
207      return { text: markdownCheck(report, await keep($, report)) }
208    }
209    if (args === 'calibrate') {
210      const summary = await measureThen($, undefined, true)
211      if (!summary) return waiting($, '/usage-dollars calibrate')
212      const report = toCalibrationReport(summary)
213      return { text: markdownCalibration(report, await keep($, report)) }
214    }
215
216    const range = reportRange(args, await $.clock.now())
217    if (!range) return { text: USAGE }
218    const report = await spendReport(hostOf($), range)
219    if ('error' in report) {
220      $.ui.log(`usage-dollars: report: ${report.error}`, { to: 'debug' })
221      return { text: USAGE }
222    }
223    return { text: markdownSpend(report, await keep($, report)) }
224  })
225
226  on('ui.render', { component: 'CommandOutput', props: { command: 'usage-dollars' } }, async ($, e, next) => {
227    const id = MARK.exec(e.props.text)?.[1]
228    const r = id ? (await read($, reports))?.[id] : undefined
229    const ui = $.ui.resolve(e)
230    const columns = e.viewport?.columns ?? 60
231    if (r?.type === 'windows' && Array.isArray(r.windows)) return windowCard(ui, r, columns)
232    if (r?.type === 'report' && Array.isArray(r.byDay)) return spendCard(ui, r, columns)
233    if (r?.type === 'check' && Array.isArray(r.items)) return checkCard(ui, r)
234    if (r?.type === 'calibration' && Array.isArray(r.windows)) return calibrationCard(ui, r)
235    if (r?.type === 'guide' && Array.isArray(r.sections)) return guideCard(ui, r)
236    if (r?.type === 'waiting' && id) return waitingCard(ui, r, () => void sendCheck($, id))
237    return next(e)
238  })
239}
240
hooks/card.tsx 691 lines
1/* Drawing: the allowance-first window card, the spending report card, the setup check,
2   calibration, guide and first-reading cards, their Markdown fallbacks, and the status
3   line. Every date shown arrives already labeled by the helper. */
4
5import type { Elements, RenderSurface } from 'claude-code'
6
7import type {
8  CalibrationReport,
9  CalibrationWindow,
10  CheckItem,
11  CheckReport,
12  GuideReport,
13  PlanReport,
14  RangeReport,
15  SpendReport,
16  UsageReport,
17  WaitingReport,
18  WindowReport,
19} from '../types'
20import { CHECK_LABEL } from './probe'
21
22type Ui = Elements[RenderSurface]
23
24export const money = (usd: number) =>
25  usd >= 100 ? `$${Math.round(usd).toLocaleString('en-US')}` : `$${usd.toFixed(2)}`
26
27/** $4.20, $913, $2.8k, $28k: the status line's figures. */
28export function compact(usd: number) {
29  if (usd < 10) return `$${usd.toFixed(2)}`
30  if (Math.round(usd) < 1000) return `$${Math.round(usd)}`
31  const k = usd / 1000
32  return Math.round(k * 10) < 100 ? `$${k.toFixed(1)}k` : `$${Math.round(k)}k`
33}
34
35const span = (r: RangeReport) => `${money(r.low)} – ${money(r.high)}`
36
37const count = (n: number) => n.toLocaleString('en-US')
38
39/* claude-opus-5-5 reads as "Opus 5.5"; a date suffix or [1m] tag is dropped. */
40export function modelName(id: string) {
41  const parts = id
42    .replace(/^claude-/, '')
43    .replace(/\[.*\]$/, '')
44    .split('-')
45    .filter(part => !/^\d{8}$/.test(part))
46  const words = parts.filter(part => !/^\d+$/.test(part)).map(w => w.charAt(0).toUpperCase() + w.slice(1))
47  const version = parts.filter(part => /^\d+$/.test(part)).join('.')
48  return [...words, version].filter(Boolean).join(' ')
49}
50
51const FOOTNOTE =
52  "Priced at API list rates from this machine's transcripts. Ranges are 90% intervals from a model of " +
53  'how far dollars per percent vary within and between windows; see the README.'
54
55const FOOTER =
56  '/usage-dollars 24h · 7d · YYYY-MM-DD..YYYY-MM-DD for a spending report · /usage-dollars reset after a plan change · ' +
57  '/usage-dollars help · check · calibrate'
58
59const NO_ESTIMATE = 'first estimate after the next reply'
60
61const confidenceLabel = (w: WindowReport) => (w.confidence ? `${w.confidence}${w.isCalibrated ? ' · calibrated' : ''}` : '')
62
63export type StatusWindow = { short: string; usedUsd: number; allowance?: RangeReport; left?: RangeReport }
64
65/* "5h ~$850 left of $913 · Week ~$2.7k left of $2.8k": one tilde per window, since both
66   figures come from one estimate. */
67export function statusLine(windows: readonly StatusWindow[], isPlanUnseen: boolean) {
68  const text = windows
69    .map(w =>
70      !w.allowance || !w.left
71        ? `${w.short} ${compact(w.usedUsd)} used, estimating`
72        : w.left.value <= 0
73          ? `${w.short} at limit`
74          : `${w.short} ~${compact(w.left.value)} left of ${compact(w.allowance.value)}`,
75    )
76    .join(' · ')
77  return `${isPlanUnseen ? '⚠ Plan changed · ' : ''}${text}`
78}
79
80export function planLine(p: PlanReport) {
81  if (p.isUnknown) {
82    const last = p.lastKnown ? `; last seen as ${p.lastKnown}` : ''
83    return p.unknownReason === 'other-subscription'
84      ? `Plan: unknown for this subscription — Claude Code is signed in to another${last}`
85      : `Plan: unknown — no account profile found${last}`
86  }
87  const asOf = p.asOfLabel ? ` · profile as of ${p.asOfLabel}` : ''
88  return `Plan: ${p.label ?? 'unknown'}${asOf}${p.isStale ? ' (may be out of date)' : ''}`
89}
90
91const headlineLabel = (w: WindowReport) => (w.short === '5h' ? '5-HOUR ALLOWANCE' : 'WEEKLY ALLOWANCE')
92
93const ageOf = (minutes: number) =>
94  minutes < 1 ? 'just now' : minutes < 60 ? `${minutes} min ago` : `${Math.floor(minutes / 60)} h ago`
95
96const percentLine = (w: WindowReport) =>
97  w.percent === undefined
98    ? undefined
99    : `Limit reports ${w.percent}%${w.percentAgeMinutes !== undefined ? ` · read ${ageOf(w.percentAgeMinutes)}` : ''}`
100
101const nextTickLine = (w: WindowReport) =>
102  w.nextTick ? `Next tick in about ${money(w.nextTick.value)} (between ${money(w.nextTick.low)} and ${money(w.nextTick.high)})` : undefined
103
104/* The row the model reads, and what the transcript shows where the card cannot be drawn. */
105export function markdownWindows(r: UsageReport, id: string) {
106  const lines = ['### Usage · API-equivalent dollars', '', planLine(r.plan), '']
107  if (r.notices.length > 0) lines.push(...r.notices.map(n => `- ${n}`), '')
108  lines.push(
109    '| Window | Allowance | 90% range | Confidence |',
110    '|:--|--:|--:|:--|',
111    ...r.windows.map(w =>
112      w.allowance
113        ? `| ${w.title} | ~${money(w.allowance.value)} | ${span(w.allowance)} | ${confidenceLabel(w)} |`
114        : `| ${w.title} | estimating… | | |`,
115    ),
116    '',
117  )
118  for (const w of r.windows) {
119    const reported = percentLine(w)
120    const tick = nextTickLine(w)
121    lines.push(
122      `**${w.title}** · resets ${w.resetLong}${w.resetIn ? ` (in ${w.resetIn})` : ''}`,
123      '',
124      '| | Estimate | 90% range |',
125      '|:--|--:|--:|',
126      `| Used | ${money(w.usedUsd)} | ${count(w.requests)} requests |`,
127      `| Left | ${w.left ? `~${money(w.left.value)}` : '—'} | ${w.left ? span(w.left) : ''} |`,
128      '',
129      ...(reported ? [reported, ''] : []),
130      ...(tick ? [tick, ''] : []),
131      `_${w.basis}_`,
132      '',
133    )
134  }
135  if (r.last24hUsd !== undefined) lines.push(`Last 24 hours: ${money(r.last24hUsd)}`, '')
136  lines.push(
137    `**${r.byModelTitle}**`,
138    '',
139    '| Model | Requests | Cost |',
140    '|:--|--:|--:|',
141    ...r.byModel.map(m => `| ${modelName(m.model)} | ${count(m.requests)} | ${money(m.usd)} |`),
142    '',
143    ...r.notes.map(line => `- ${line}`),
144    `_${FOOTNOTE}_`,
145    '',
146    `_${FOOTER}_`,
147    '',
148    `[//]: # (usage-dollars:report:${id})`,
149  )
150  return lines.join('\n')
151}
152
153export function markdownSpend(r: SpendReport, id: string) {
154  return [
155    `### Spending · ${r.fromLabel} – ${r.toLabel}`,
156    '',
157    `**${money(r.usedUsd)}** in ${count(r.requests)} requests, API-equivalent dollars`,
158    '',
159    '| Day | Requests | Cost |',
160    '|:--|--:|--:|',
161    ...r.byDay.map(d => `| ${d.label} | ${count(d.requests)} | ${money(d.usd)} |`),
162    '',
163    '| Model | Requests | Cost |',
164    '|:--|--:|--:|',
165    ...r.byModel.map(m => `| ${modelName(m.model)} | ${count(m.requests)} | ${money(m.usd)} |`),
166    '',
167    ...r.notes.map(line => `- ${line}`),
168    '',
169    `[//]: # (usage-dollars:report:${id})`,
170  ].join('\n')
171}
172
173/* Used dollars in solid blue, the 90% range of the allowance as a pale band, and the
174   allowance estimate as a tick, all on one scale ending at the range's top. */
175function meterSvg(w: WindowReport) {
176  const width = 600
177  const height = 12
178  const top = w.allowance?.high ?? w.usedUsd
179  const x = (usd: number) => Math.round((width * Math.min(usd, top)) / Math.max(top, 0.01))
180  const used = w.usedUsd > 0 ? Math.max(4, x(w.usedUsd)) : 0
181  const band = w.allowance ? `<rect x="${x(w.allowance.low)}" width="${x(w.allowance.high) - x(w.allowance.low)}" height="${height}" fill="#3b82f6" fill-opacity="0.2"/>` : ''
182  const tick = w.allowance ? `<rect x="${Math.min(width - 2, x(w.allowance.value))}" width="2" height="${height}" fill="#3b82f6" fill-opacity="0.7"/>` : ''
183  return (
184    `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">` +
185    `<rect width="${width}" height="${height}" rx="3" fill="#8a8a8a" fill-opacity="0.18"/>` +
186    band +
187    `<rect width="${used}" height="${height}" rx="3" fill="#3b82f6"/>` +
188    tick +
189    `</svg>`
190  )
191}
192
193function meterCells(w: WindowReport, cells: number) {
194  const top = w.allowance?.high ?? w.usedUsd
195  const at = (usd: number) => Math.round((cells * Math.min(usd, top)) / Math.max(top, 0.01))
196  const used = w.usedUsd > 0 ? Math.max(1, at(w.usedUsd)) : 0
197  const low = w.allowance ? Math.max(used, at(w.allowance.low)) : used
198  return { used, band: Math.max(0, cells - low), free: Math.max(0, low - used) }
199}
200
201/* One day's spending as a bar on the scale of the busiest day. */
202function daySvg(usd: number, top: number) {
203  const width = 240
204  const height = 10
205  const used = usd > 0 ? Math.max(2, Math.round((width * usd) / Math.max(top, 0.01))) : 0
206  return (
207    `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">` +
208    `<rect width="${width}" height="${height}" rx="2" fill="#8a8a8a" fill-opacity="0.18"/>` +
209    `<rect width="${used}" height="${height}" rx="2" fill="#3b82f6"/>` +
210    `</svg>`
211  )
212}
213
214function modelTable(ui: Ui, title: string, rows: UsageReport['byModel']) {
215  const { Box, Text } = ui
216  return (
217    <Box flexDirection="column">
218      <Text bold>{title}</Text>
219      <Box flexDirection="row">
220        <Box flexGrow={1}>
221          <Text dimColor>MODEL</Text>
222        </Box>
223        <Box width={12} justifyContent="flex-end">
224          <Text dimColor>REQUESTS</Text>
225        </Box>
226        <Box width={12} justifyContent="flex-end">
227          <Text dimColor>COST</Text>
228        </Box>
229      </Box>
230      {rows.map(m => (
231        <Box flexDirection="row">
232          <Box flexGrow={1}>
233            <Text>{modelName(m.model)}</Text>
234          </Box>
235          <Box width={12} justifyContent="flex-end">
236            <Text>{count(m.requests)}</Text>
237          </Box>
238          <Box width={12} justifyContent="flex-end">
239            <Text bold>{money(m.usd)}</Text>
240          </Box>
241        </Box>
242      ))}
243    </Box>
244  )
245}
246
247function notesBlock(ui: Ui, notes: readonly string[]) {
248  const { Box, Text } = ui
249  if (notes.length === 0) return undefined
250  return (
251    <Box flexDirection="column">
252      {notes.map(line => (
253        <Text>{line}</Text>
254      ))}
255    </Box>
256  )
257}
258
259export function windowCard(ui: Ui, r: UsageReport, columns: number) {
260  const { Box, Text } = ui
261  const cells = Math.max(10, Math.min(48, columns - 12))
262
263  const headline = (w: WindowReport) => (
264    <Box flexDirection="column" flexGrow={1} minWidth={22} borderStyle="round" borderDimColor paddingX={1}>
265      <Text dimColor>{headlineLabel(w)}</Text>
266      <Text bold>{w.allowance ? `~${money(w.allowance.value)}` : 'estimating…'}</Text>
267      <Text dimColor>{w.allowance ? span(w.allowance) : NO_ESTIMATE}</Text>
268      {w.allowance && w.confidence ? <Text dimColor>{confidenceLabel(w)}</Text> : undefined}
269    </Box>
270  )
271
272  const section = (w: WindowReport) => {
273    const tiles = [
274      { label: 'USED', value: money(w.usedUsd), note: `${count(w.requests)} requests` },
275      { label: 'LEFT', value: w.left ? `~${money(w.left.value)}` : '—', note: w.left ? span(w.left) : 'no estimate yet' },
276    ]
277    const bar = meterCells(w, cells)
278    const reported = percentLine(w)
279    const tick = nextTickLine(w)
280    return (
281      <Box flexDirection="column" rowGap={1}>
282        <Box flexDirection="row" justifyContent="space-between" flexWrap="wrap" columnGap={2}>
283          <Text bold>{w.title}</Text>
284          <Text dimColor>
285            Resets {w.resetLong}
286            {w.resetIn ? ` · in ${w.resetIn}` : ''}
287          </Text>
288        </Box>
289        <Box flexDirection="row" flexWrap="wrap" columnGap={4} rowGap={1}>
290          {tiles.map(tile => (
291            <Box flexDirection="column" flexGrow={1} minWidth={16}>
292              <Text dimColor>{tile.label}</Text>
293              <Text bold>{tile.value}</Text>
294              <Text dimColor>{tile.note}</Text>
295            </Box>
296          ))}
297        </Box>
298        {'Svg' in ui ? (
299          <ui.Svg source={meterSvg(w)} alt={`Used ${money(w.usedUsd)} of the allowance`} />
300        ) : (
301          <Text>
302            <Text color="blue">{'█'.repeat(bar.used)}</Text>
303            <Text dimColor>{'░'.repeat(bar.free)}</Text>
304            <Text color="blue" dimColor>
305              {'▒'.repeat(bar.band)}
306            </Text>
307          </Text>
308        )}
309        {reported ? <Text>{reported}</Text> : undefined}
310        {tick ? <Text>{tick}</Text> : undefined}
311        <Text dimColor>{w.basis}</Text>
312      </Box>
313    )
314  }
315
316  return (
317    <Box flexDirection="column" borderStyle="round" borderDimColor paddingX={2} paddingY={1} rowGap={2}>
318      <Box flexDirection="column">
319        <Box flexDirection="row" columnGap={1}>
320          <Text bold>Usage</Text>
321          <Text dimColor>· API-equivalent dollars</Text>
322        </Box>
323        <Text dimColor>{planLine(r.plan)}</Text>
324      </Box>
325
326      {r.notices.length > 0 ? (
327        <Box flexDirection="column">
328          {r.notices.map(line => (
329            <Text color="yellow">{line}</Text>
330          ))}
331        </Box>
332      ) : undefined}
333
334      <Box flexDirection="row" flexWrap="wrap" columnGap={2} rowGap={1}>
335        {r.windows.map(headline)}
336      </Box>
337
338      {r.windows.map(section)}
339
340      {r.last24hUsd !== undefined ? <Text>Last 24 hours: {money(r.last24hUsd)}</Text> : undefined}
341
342      {modelTable(ui, r.byModelTitle, r.byModel)}
343
344      {notesBlock(ui, r.notes)}
345      <Text dimColor italic>
346        {FOOTNOTE}
347      </Text>
348      <Text dimColor>{FOOTER}</Text>
349    </Box>
350  )
351}
352
353export function spendCard(ui: Ui, r: SpendReport, columns: number) {
354  const { Box, Text } = ui
355  const cells = Math.max(10, Math.min(40, columns - 34))
356  const top = Math.max(0, ...r.byDay.map(d => d.usd))
357  const cellsOf = (usd: number) => (usd > 0 ? Math.max(1, Math.round((cells * usd) / Math.max(top, 0.01))) : 0)
358
359  return (
360    <Box flexDirection="column" borderStyle="round" borderDimColor paddingX={2} paddingY={1} rowGap={2}>
361      <Box flexDirection="row" columnGap={1} flexWrap="wrap">
362        <Text bold>Spending</Text>
363        <Text dimColor>
364          · {r.fromLabel} – {r.toLabel}
365        </Text>
366      </Box>
367
368      <Box flexDirection="row" flexWrap="wrap" columnGap={4} rowGap={1}>
369        <Box flexDirection="column" minWidth={16}>
370          <Text dimColor>TOTAL</Text>
371          <Text bold>{money(r.usedUsd)}</Text>
372          <Text dimColor>API-equivalent dollars</Text>
373        </Box>
374        <Box flexDirection="column" minWidth={16}>
375          <Text dimColor>REQUESTS</Text>
376          <Text bold>{count(r.requests)}</Text>
377        </Box>
378      </Box>
379
380      <Box flexDirection="column">
381        <Text bold>By day</Text>
382        {r.byDay.map(d => (
383          <Box flexDirection="row" columnGap={2}>
384            <Box width={12}>
385              <Text>{d.label}</Text>
386            </Box>
387            <Box flexGrow={1}>
388              {'Svg' in ui ? (
389                <ui.Svg source={daySvg(d.usd, top)} alt={`${d.label}: ${money(d.usd)}`} />
390              ) : (
391                <Text color="blue">{'█'.repeat(cellsOf(d.usd)) || ' '}</Text>
392              )}
393            </Box>
394            <Box width={12} justifyContent="flex-end">
395              <Text bold={d.usd > 0} dimColor={d.usd === 0}>
396                {money(d.usd)}
397              </Text>
398            </Box>
399          </Box>
400        ))}
401      </Box>
402
403      {modelTable(ui, 'By model', r.byModel)}
404
405      {notesBlock(ui, r.notes)}
406    </Box>
407  )
408}
409
410const MARK_OF: Record<CheckItem['state'], string> = { ok: '✓', fail: '✗', info: 'ℹ' }
411
412const COLOR_OF: Record<CheckItem['state'], string | undefined> = { ok: 'green', fail: 'red', info: undefined }
413
414export function markdownCheck(r: CheckReport, id: string) {
415  return [
416    '### Setup check · usage-dollars',
417    '',
418    ...r.items.map(item => `- ${MARK_OF[item.state]} **${item.label}** · ${item.text}`),
419    '',
420    `[//]: # (usage-dollars:report:${id})`,
421  ].join('\n')
422}
423
424export function checkCard(ui: Ui, r: CheckReport) {
425  const { Box, Text } = ui
426  return (
427    <Box flexDirection="column" borderStyle="round" borderDimColor paddingX={2} paddingY={1} rowGap={1}>
428      <Box flexDirection="row" columnGap={1}>
429        <Text bold>Setup check</Text>
430        <Text dimColor>· usage-dollars</Text>
431      </Box>
432      {r.items.map(item => (
433        <Box flexDirection="row" columnGap={1}>
434          <Box width={2}>
435            <Text color={COLOR_OF[item.state]} dimColor={item.state === 'info'}>
436              {MARK_OF[item.state]}
437            </Text>
438          </Box>
439          <Box width={12}>
440            <Text bold>{item.label}</Text>
441          </Box>
442          <Box flexGrow={1} flexShrink={1}>
443            <Text>{item.text}</Text>
444          </Box>
445        </Box>
446      ))}
447    </Box>
448  )
449}
450
451const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`
452
453/* Facts about the data behind one window's estimate, never what to do about them. */
454function calibrationLines(w: CalibrationWindow) {
455  const lines = [`This window: ${plural(w.levels, 'percent level')} read, ${plural(w.ticks, 'tick')} seen`]
456  if (w.closedWindows === 0) lines.push('No closed window with readings yet')
457  lines.push(
458    w.isWithinMeasured
459      ? `Within-window spread: measured, from ${plural(w.closedForWithin, 'closed window')}`
460      : `Within-window spread: assumed; ${plural(w.closedForWithin, 'closed window')} with a tick, 2 needed`,
461    `Between-window spread: ${w.isBetweenMeasured ? 'measured' : 'assumed'}; ` +
462      `${plural(w.pastPoints, 'past window')} (rejections included), history weight ${w.pastWeight}`,
463    w.isRoundingKnown
464      ? `Rounding rule: known (the API ${w.rounding === 'round' ? 'rounds' : 'truncates'}), from ${plural(w.closedForRounding, 'closed window')}`
465      : `Rounding rule: not known; ${w.closedForRounding} of ${w.roundingNeeded} closed windows needed`,
466  )
467  if (w.halfWidth !== undefined) lines.push(`90% range now: ±${Math.round(w.halfWidth * 100)}%`)
468  return lines
469}
470
471const calibrationStatus = (w: CalibrationWindow) => (w.isCalibrated ? 'Calibrated' : `Calibrating: ${w.measured} of 3 measured`)
472
473export function markdownCalibration(r: CalibrationReport, id: string) {
474  const lines = ['### Calibration · usage-dollars', '']
475  for (const w of r.windows)
476    lines.push(`**${w.title}** · ${calibrationStatus(w)}`, '', ...calibrationLines(w).map(line => `- ${line}`), '')
477  lines.push(`[//]: # (usage-dollars:report:${id})`)
478  return lines.join('\n')
479}
480
481export function calibrationCard(ui: Ui, r: CalibrationReport) {
482  const { Box, Text } = ui
483  return (
484    <Box flexDirection="column" borderStyle="round" borderDimColor paddingX={2} paddingY={1} rowGap={2}>
485      <Box flexDirection="row" columnGap={1}>
486        <Text bold>Calibration</Text>
487        <Text dimColor>· usage-dollars</Text>
488      </Box>
489      {r.windows.map(w => (
490        <Box flexDirection="column">
491          <Box flexDirection="row" justifyContent="space-between" flexWrap="wrap" columnGap={2}>
492            <Text bold>{w.title}</Text>
493            <Text color={w.isCalibrated ? 'green' : undefined} dimColor={!w.isCalibrated}>
494              {calibrationStatus(w)}
495            </Text>
496          </Box>
497          {calibrationLines(w).map(line => (
498            <Text>{line}</Text>
499          ))}
500        </Box>
501      ))}
502    </Box>
503  )
504}
505
506/* The first run on an installation, before any reply has brought a reading. */
507const WAITING_TITLE = 'Waiting for the first usage reading'
508
509const WAITING_ALL_RIGHT =
510  'Everything is in order. The plugin is installed and running; it has simply not received a usage reading yet. ' +
511  'This is expected right after the plugin is installed or updated, and on its first use on this machine.'
512
513const WAITING_WHY =
514  'Claude Code learns how much of the 5-hour window and the week has been used only from the API, and the API ' +
515  'reports it alongside each model reply: the percent used and when each window resets. Every figure on this card ' +
516  'is built on that reading, and this installation has not stored one yet.'
517
518const waitingSteps = (command: string) => [
519  'Send Claude any message in this session. A short question is enough.',
520  `When the reply has finished, run ${command} again.`,
521  command.endsWith('calibrate')
522    ? 'The card then shows what each window’s estimate rests on.'
523    : 'The card then shows each window: the estimated allowance, what is used and what is left.',
524]
525
526const WAITING_UNSUBSCRIBED =
527  'This sign-in has no subscription usage limits, so there is nothing to show; spending reports still work.'
528
529const WAITING_SEND = 'Or let the plugin send one for you:'
530
531const checkSent = (command: string) => `A check message was sent. When its reply has finished, run ${command} again.`
532
533const WAITING_NEXT = [
534  'Every reply refreshes the reading, and the status line keeps showing what is left in each window.',
535  'Readings are stored for this installation, so later sessions show figures at once, before their first reply.',
536  'The first estimates are rough and narrow as readings accumulate over the coming windows; /usage-dollars calibrate shows how far that has come.',
537  'Spending reports read this machine’s transcripts and need no reading: /usage-dollars 24h, 7d or a date range work now.',
538]
539
540const WAITING_FOOTER = 'If no figures appear after a reply, /usage-dollars check shows what is missing.'
541
542export function markdownWaiting(r: WaitingReport, id: string) {
543  return [
544    `### ${WAITING_TITLE}`,
545    '',
546    `✓ ${WAITING_ALL_RIGHT}`,
547    '',
548    '**Why there are no figures yet**',
549    '',
550    WAITING_WHY,
551    '',
552    ...(r.isUnsubscribed
553      ? [WAITING_UNSUBSCRIBED, '']
554      : [
555          '**What to do**',
556          '',
557          ...waitingSteps(r.command).map((step, i) => `${i + 1}. ${step}`),
558          '',
559          ...(r.canSend === false ? [checkSent(r.command), ''] : []),
560          '**How it works from here**',
561          '',
562          ...WAITING_NEXT.map(line => `- ${line}`),
563          '',
564        ]),
565    `_${WAITING_FOOTER}_`,
566    '',
567    `[//]: # (usage-dollars:report:${id})`,
568  ].join('\n')
569}
570
571/* onSend sends the check message; without it the card offers no button. */
572export function waitingCard(ui: Ui, r: WaitingReport, onSend?: () => void) {
573  const { Box, Button, Text } = ui
574  const heading = (text: string) => <Text bold>{text}</Text>
575  return (
576    <Box flexDirection="column" borderStyle="round" borderDimColor paddingX={2} paddingY={1} rowGap={1}>
577      <Box flexDirection="row" columnGap={1} flexWrap="wrap">
578        <Text bold>{WAITING_TITLE}</Text>
579        <Text dimColor>· usage-dollars</Text>
580      </Box>
581
582      <Box flexDirection="row" columnGap={1}>
583        <Box width={2}>
584          <Text color="green">✓</Text>
585        </Box>
586        <Box flexGrow={1} flexShrink={1}>
587          <Text>{WAITING_ALL_RIGHT}</Text>
588        </Box>
589      </Box>
590
591      <Box flexDirection="column">
592        {heading('Why there are no figures yet')}
593        <Text>{WAITING_WHY}</Text>
594      </Box>
595
596      {r.isUnsubscribed ? (
597        <Text>{WAITING_UNSUBSCRIBED}</Text>
598      ) : (
599        <Box flexDirection="column">
600          {heading('What to do')}
601          {waitingSteps(r.command).map((step, i) => (
602            <Box flexDirection="row" columnGap={1}>
603              <Box width={3}>
604                <Text color="blue" bold>
605                  {`${i + 1}.`}
606                </Text>
607              </Box>
608              <Box flexGrow={1} flexShrink={1}>
609                <Text>{step}</Text>
610              </Box>
611            </Box>
612          ))}
613        </Box>
614      )}
615
616      {!r.isUnsubscribed && r.canSend && onSend ? (
617        <Box flexDirection="row" columnGap={1} flexWrap="wrap" alignItems="center">
618          <Text>{WAITING_SEND}</Text>
619          <Button key="send-check" label={CHECK_LABEL} onPress={() => onSend()} />
620        </Box>
621      ) : undefined}
622      {!r.isUnsubscribed && r.canSend === false ? <Text dimColor>{checkSent(r.command)}</Text> : undefined}
623
624      {r.isUnsubscribed ? undefined : (
625        <Box flexDirection="column">
626          {heading('How it works from here')}
627          {WAITING_NEXT.map(line => (
628            <Box flexDirection="row" columnGap={1}>
629              <Box width={2}>
630                <Text dimColor>•</Text>
631              </Box>
632              <Box flexGrow={1} flexShrink={1}>
633                <Text>{line}</Text>
634              </Box>
635            </Box>
636          ))}
637        </Box>
638      )}
639
640      <Text dimColor>{WAITING_FOOTER}</Text>
641    </Box>
642  )
643}
644
645/* What one Markdown element draws at most. */
646const MAX_MARKDOWN = 10000
647
648/** The guide's text in sections that each fit one Markdown element: split before every
649    level-2 heading, and a longer section between paragraphs. Carriage returns, which the
650    element refuses, are dropped. */
651export function guideSections(text: string) {
652  const sections: string[] = []
653  for (const section of text.replace(/\r/g, '').split(/\n(?=## )/)) {
654    let chunk = ''
655    for (const paragraph of section.split(/\n{2,}/)) {
656      const next = chunk ? `${chunk}\n\n${paragraph}` : paragraph
657      if (next.length <= MAX_MARKDOWN) chunk = next
658      else {
659        if (chunk) sections.push(chunk)
660        chunk = paragraph.slice(0, MAX_MARKDOWN)
661      }
662    }
663    if (chunk.trim()) sections.push(chunk.trim())
664  }
665  return sections
666}
667
668/** A guide's title, its leading level-1 heading, and the rest in sections. */
669export function guideOf(text: string): GuideReport {
670  const [first = '', ...rest] = guideSections(text)
671  const heading = /^# (.+)(?:\n+|$)/.exec(first)
672  const lead = heading ? first.slice(heading[0].length).trim() : first
673  return { type: 'guide', title: heading?.[1]?.trim() ?? 'usage-dollars', sections: lead ? [lead, ...rest] : rest }
674}
675
676export function markdownGuide(r: GuideReport, id: string) {
677  return [`# ${r.title}`, ...r.sections, `[//]: # (usage-dollars:report:${id})`].join('\n\n')
678}
679
680export function guideCard(ui: Ui, r: GuideReport) {
681  const { Box, Markdown, Text } = ui
682  return (
683    <Box flexDirection="column" borderStyle="round" borderDimColor paddingX={3} paddingY={1} rowGap={1}>
684      <Text bold>{r.title}</Text>
685      {r.sections.map(text => (
686        <Markdown text={text} />
687      ))}
688    </Box>
689  )
690}
691
hooks/measure.ts 987 lines
1/* Measurement: runs the transcript scan, keeps readings and plan history in $.store keyed
2   by subscription, and turns them into estimates. The engine reaches this module through
3   a Host that register.tsx builds, since $ itself never crosses an import.
4
5   $.store is one store shared by every running session, so each key is read immediately
6   before it is written, with no wait on the helper in between, and the plan, regime,
7   promotion and notice keys are written only when they changed. */
8
9import type { SessionRateLimit } from 'claude-code'
10
11import type { CalibrationReport, CheckItem, CheckReport, ModelSpend, PlanReport, SpendReport, UsageReport, WindowReport } from '../types'
12import { money, statusLine } from './card'
13import {
14  MIN_ROUNDING_WINDOWS,
15  calibrateOmega,
16  calibrationOf,
17  estimate,
18  pastPoints,
19  prior,
20  record,
21  regimeView,
22  resolutionOf,
23  roundingEvidence,
24} from './estimate'
25import type { Calibration, Estimate, Rounding, WindowReadings } from './estimate'
26import { activePromotions, addRegimes, currentRegime, observePlan, observePromotions, planLabel, regimeStart, startOver, undoStartOver } from './plan'
27import type { Kind, Ledger, NoticeDraft, PlanEntry, Profile, Promotions, Regime, Regimes } from './plan'
28
29const MINUTE_MS = 60 * 1000
30const HOUR_MS = 60 * MINUTE_MS
31const DAY_MS = 24 * HOUR_MS
32const MIN_GAP_MS = MINUTE_MS
33const FRESH_MS = 5 * MINUTE_MS
34const READINGS_KEPT_MS = 28 * DAY_MS
35const HISTORY_DAYS = 14
36const HISTORY_REFRESH_MS = 6 * HOUR_MS
37const NOTICE_SHOWN_MS = 7 * DAY_MS
38const STALE_PROFILE_MS = 7 * DAY_MS
39const KEPT_NOTICES = 20
40/* Above this share of a window's requests going to unpriced models, its dollars no longer
41   track the limit. */
42const MAX_UNPRICED_SHARE = 0.02
43const SCHEMA = 4
44/* What the limits and readings this module stores mean; raise it whenever that changes.
45   A stored limit without one is stamp 0. */
46const HOOKS_STAMP = 1
47const NONE = 'none'
48/* The suffixes of a window's further tallies: up to the moment this session received its
49   percent, and up to the moment the stored percent was received. */
50const AT_READ = '@read'
51const AT_STORED = '@stored'
52/* Median ratio of these dollars to Claude Code's own costs outside which the price table
53   is out of date. */
54const MIN_PRICE_RATIO = 0.9
55const MAX_PRICE_RATIO = 1.1
56
57const SCHEMA_KEY = 'schema'
58const LIMITS_KEY = 'limits'
59const READINGS_KEY = 'readings'
60const HISTORY_KEY = 'history'
61const PLANS_KEY = 'plans'
62const REGIMES_KEY = 'regimes'
63const PROMOTIONS_KEY = 'promotions'
64const NOTICES_KEY = 'notices'
65
66export const INFERRED_TEXT =
67  'Usage no longer matches recent windows: limits may have changed, or this subscription was used elsewhere ' +
68  '(inferred from usage, not from the plan). If you changed plans, run /usage-dollars reset.'
69
70const WINDOWS: readonly { kind: Kind; name: string; title: string; short: string; spanMs: number }[] = [
71  { kind: 'five_hour', name: 'session', title: '5-hour window', short: '5h', spanMs: 5 * HOUR_MS },
72  { kind: 'seven_day', name: 'week', title: 'This week', short: 'Week', spanMs: 7 * DAY_MS },
73]
74
75const SPAN_MS: Record<string, number> = { five_hour: 5 * HOUR_MS, seven_day: 7 * DAY_MS }
76
77/** What this module needs from the engine: the clock, the session, the helper process,
78    $.store, the status line and the debug log. */
79export type Host = {
80  root: string
81  now: () => Promise<number>
82  sessionId: () => Promise<string>
83  rateLimits: () => Promise<readonly SessionRateLimit[]>
84  run: (argv: string[], options: { timeoutMs: number }) => Promise<{ stdout: string; stderr: string; exitCode: number | null }>
85  get: (key: string) => Promise<unknown>
86  set: (key: string, value: unknown) => Promise<void>
87  delete: (key: string) => Promise<void>
88  status: (text: string) => void
89  log: (text: string) => void
90}
91
92type OrgSource = 'session' | 'profile' | 'most recent'
93
94/* observedAt: when a session last received this percent from the API; absent for a reset
95   rolled forward without one. */
96type Limit = { percentUsed?: number; resetsAt: string; observedAt?: number }
97
98/* sessionId: the session that received the percent; stamp: its HOOKS_STAMP. */
99type StoredLimit = { percentUsed?: number; resetsAt: string; observedAt: number; sessionId?: string; stamp?: number }
100
101type StoredLimits = Record<string, Record<string, StoredLimit>>
102
103type StoredReadings = Record<string, WindowReadings & { org: string }>
104
105type Observation = { kind: string; resetsAt: string; at: string; usd: number }
106
107type History = Record<string, { scannedAt: number; observations: Observation[] }>
108
109export type Notice = {
110  id: string
111  kind: NoticeDraft['kind']
112  org: string
113  text: string
114  at: number
115  isToasted: boolean
116  isSeen: boolean
117}
118
119type WindowTally = {
120  usd: number
121  requests: number
122  byModel: Record<string, { usd: number; requests: number }>
123  otherSubscriptionsUsd: number
124  unattributedUsd: number
125  unpricedRequests: number
126  unpricedModels: string[]
127  /* Other sessions' dollars just before the tally's end, which its percent may lack. */
128  slackUsd?: number
129  resetLabel?: string
130  resetLong?: string
131  resetIn?: string
132}
133
134type HelperBase = {
135  org?: string | null
136  orgSource?: OrgSource | null
137  profile?: Profile | null
138  labels?: Record<string, string>
139  error?: string
140}
141
142type WindowsOutput = HelperBase & { windows?: Record<string, WindowTally>; pricesId?: string; files: number; ms: number }
143
144type ReportOutput = HelperBase & {
145  usd: number
146  requests: number
147  byModel: Record<string, { usd: number; requests: number }>
148  byDay: { date: string; label: string; usd: number; requests: number }[]
149  otherSubscriptionsUsd: number
150  unattributedUsd: number
151  unpricedRequests: number
152  unpricedModels: string[]
153  fromLabel: string
154  toLabel: string
155  transcriptsBeginLabel?: string
156}
157
158/* closedWindows: closed windows of this kind and regime that hold readings. halfWidth: of
159   the current 90% range, in proportion. */
160type WindowCalibration = Calibration & { closedWindows: number; rounding: Rounding; halfWidth?: number }
161
162type WindowState = {
163  title: string
164  short: string
165  resetsAt: string
166  tally: WindowTally
167  estimate?: Estimate
168  isUnpriced: boolean
169  sinceResetLabel?: string
170  percent?: number
171  percentAt?: number
172  calibration: WindowCalibration
173}
174
175/* Why a percent a measure could have recorded was not: `missing` when nothing explains it. */
176type Outcome = 'recorded' | 'unpriced' | 'unattributed' | 'missing'
177
178/* A percent that may be recorded: this session's fresh one, or one stored by a session
179   whose reading of it may have been lost. sessionId: the session that received it. */
180type Candidate = { source: 'mine' | 'stored'; tally: string; percent: number; at: number; sessionId: string }
181
182export type Diagnostics = {
183  /* Another session stored a percent with other hooks since this module first measured:
184     older or newer than this one's. */
185  otherHooks?: 'older' | 'newer'
186  /* Stored windows dropped for another price table, over this session's measures. */
187  droppedForPrices: number
188  /* Per window title, what became of the percents the latest measure that had any could
189     record. */
190  outcomes: Record<string, { source: Candidate['source']; outcome: Outcome }[]>
191}
192
193export type Summary = {
194  subscription: string
195  orgSource?: OrgSource | null
196  windows: WindowState[]
197  plan: PlanReport
198  notices: string[]
199  last24hUsd?: number
200  sinceResetLabel?: string
201  files: number
202  ms: number
203}
204
205/* The session's subscription, once a helper run attributed it through the session's own
206   record; until then each measure asks the helper first. */
207let sessionOrg: string | undefined
208let lastSubscription: string | undefined
209let isMigrated = false
210let last: Summary | undefined
211let lastRunAt = 0
212let running: Promise<Summary | undefined> | undefined
213/* When this session last received its percents; their age decides between them and the
214   ones other sessions stored. */
215let lastFreshAt: number | undefined
216/* A fresh measure that arrived while another ran, with the time its percents arrived;
217   the latest wins. */
218let pendingFresh: { host: Host; limits?: readonly SessionRateLimit[]; receivedAt?: number } | undefined
219let firstMeasureAt: number | undefined
220const diagnostics: Diagnostics = { droppedForPrices: 0, outcomes: {} }
221
222const subscriptionOf = (org: string | null | undefined) => org ?? NONE
223
224const isSame = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b)
225
226const windowKey = (subscription: string, kind: string, resetsAt: string) =>
227  `${subscription}|${kind}@${new Date(Math.round(Date.parse(resetsAt) / MINUTE_MS) * MINUTE_MS).toISOString()}`
228
229const helperArgv = (host: Host) => ['node', `${host.root}/scripts/usage-cost.mjs`]
230
231async function runHelper<T extends HelperBase>(host: Host, extra: readonly string[], timeoutMs: number) {
232  return runScript<T>(host, ['--session', await host.sessionId(), ...extra], timeoutMs)
233}
234
235async function runScript<T extends { error?: string }>(host: Host, extra: readonly string[], timeoutMs: number) {
236  const run = await host.run([...helperArgv(host), ...extra], { timeoutMs })
237  let out: T | undefined
238  try {
239    out = JSON.parse(run.stdout || '{}') as T
240  } catch {
241    out = undefined
242  }
243  if (run.exitCode !== 0 || !out || out.error) {
244    const error = out?.error ?? run.stderr.slice(0, 200)
245    return { error: error || `helper exited with ${run.exitCode}` }
246  }
247  return { out }
248}
249
250/* v0.3.0 kept limits, readings and history without a subscription; they cannot be
251   attributed, so they are dropped once and the estimates start again.
252   Before schema 3 any session paired its own last percent, however old, with the dollars
253   of the moment and stored the pair; nothing tells those readings or limits from sound
254   ones, so both are dropped once more. Schema-3 readings paired the percent with the
255   dollars at the time of the scan and carry no slack or price table, so they are dropped
256   once again. History comes from rate-limit rejections in the transcripts, not from
257   percents, and is kept from schema 2 on. */
258async function migrate(host: Host) {
259  if (isMigrated) return
260  const was = await host.get(SCHEMA_KEY)
261  if (was !== SCHEMA) {
262    await host.delete(LIMITS_KEY)
263    await host.delete(READINGS_KEY)
264    if (was !== 2 && was !== 3) await host.delete(HISTORY_KEY)
265    await host.set(SCHEMA_KEY, SCHEMA)
266  }
267  isMigrated = true
268}
269
270/** The session's subscription key: its own record, else the signed-in one, else "none". */
271export async function resolveSubscription(host: Host) {
272  await migrate(host)
273  if (sessionOrg) return sessionOrg
274  const { out } = await runHelper<HelperBase>(host, ['--whoami'], 30_000)
275  if (out?.orgSource === 'session' && out.org) sessionOrg = out.org
276  return subscriptionOf(out?.org)
277}
278
279/* The limit as last stored for the subscription: a 7-day window rolls forward a week at a
280   time, without a percent; a 5-hour one only holds until its reset. */
281function storedLimitOf(kind: Kind, kept: StoredLimit | undefined, now: number): Limit | undefined {
282  if (!kept) return undefined
283  let reset = Date.parse(kept.resetsAt)
284  if (reset > now) return { percentUsed: kept.percentUsed, resetsAt: kept.resetsAt, observedAt: kept.observedAt }
285  if (kind !== 'seven_day') return undefined
286  while (reset <= now) reset += 7 * DAY_MS
287  return { resetsAt: new Date(reset).toISOString() }
288}
289
290/* The limit this session holds, received at liveAt, or the one stored by any session,
291   whichever was received later. A live limit of unknown age loses to a stored one. */
292function limitOf(kind: Kind, live: readonly SessionRateLimit[], liveAt: number | undefined, kept: StoredLimit | undefined, now: number) {
293  const found = live.find(l => l.kind === kind && l.resetsAt)
294  const stored = storedLimitOf(kind, kept, now)
295  if (!found) return stored
296  const mine: Limit = { percentUsed: found.percentUsed, resetsAt: found.resetsAt!, observedAt: liveAt }
297  if (!stored) return mine
298  if (liveAt === undefined) return stored
299  return stored.observedAt !== undefined && stored.observedAt > liveAt ? stored : mine
300}
301
302const latestEntry = (plans: Ledger, subscription: string): PlanEntry | undefined => {
303  const entries = plans[subscription] ?? []
304  return entries[entries.length - 1]
305}
306
307/* Every stored instant the card may show, labeled by the helper under its epoch ms. */
308function labelArgs(regimes: Regimes, plans: Ledger, subscription: string, now: number) {
309  const instants = new Set<number>([now])
310  for (const w of WINDOWS) {
311    const r = currentRegime(regimes, subscription, w.kind)
312    if (r && r.startedAt > 0) instants.add(r.startedAt)
313  }
314  const entry = latestEntry(plans, subscription)
315  if (entry?.profileFetchedAt) instants.add(entry.profileFetchedAt)
316  if (entry?.lastSeenAt) instants.add(entry.lastSeenAt)
317  return [...instants].flatMap(ms => ['--label', `${ms},${new Date(ms).toISOString()}`])
318}
319
320async function queueNotices(host: Host, subscription: string, drafts: readonly NoticeDraft[], now: number) {
321  if (drafts.length === 0) return
322  const queue = ((await host.get(NOTICES_KEY)) ?? []) as Notice[]
323  const ids = new Set(queue.map(n => n.id))
324  const added = drafts
325    .filter(d => !ids.has(d.id))
326    .map(d => ({ ...d, org: subscription, at: now, isToasted: false, isSeen: false }) as Notice)
327  if (added.length === 0) return
328  const next = [...queue, ...added]
329  await host.set(NOTICES_KEY, next.slice(Math.max(0, next.length - KEPT_NOTICES)))
330}
331
332function planChangedText(event: { from: PlanEntry; to: PlanEntry; between: [number, number] }, labels: Record<string, string>, profile: Profile) {
333  const from = labels[String(event.between[0])] ?? 'the previous check'
334  const to =
335    (profile.fetchedAt && Date.parse(profile.fetchedAt) === event.between[1] ? profile.fetchedLabel : undefined) ??
336    labels[String(event.between[1])] ??
337    'now'
338  return `Plan changed: ${event.from.label} → ${event.to.label} (between ${from} and ${to}). Estimates restarted.`
339}
340
341function planReport(
342  event: ReturnType<typeof observePlan>['event'],
343  profile: Profile | null | undefined,
344  labels: Record<string, string>,
345  now: number,
346): PlanReport {
347  if (event.kind === 'unknown') {
348    const known = event.lastKnown
349    const on = known ? labels[String(known.lastSeenAt)] : undefined
350    return {
351      isUnknown: true,
352      isStale: false,
353      unknownReason: event.reason,
354      lastKnown: known ? `${known.label}${on ? ` on ${on}` : ''}` : undefined,
355    }
356  }
357  const fetchedAt = profile?.fetchedAt ? Date.parse(profile.fetchedAt) : NaN
358  const label = event.kind === 'changed' ? event.to.label : undefined
359  return {
360    isUnknown: false,
361    label: label ?? (profile ? planLabel(profile) : undefined),
362    asOfLabel: profile?.fetchedLabel ?? undefined,
363    isStale: Number.isFinite(fetchedAt) && now - fetchedAt > STALE_PROFILE_MS,
364  }
365}
366
367/* A closed window that contains a regime start of its own subscription mixes two sets of
368   limits, so it cannot tell how the API rounds. */
369function isStraddling(r: WindowReadings & { org: string }, regimes: Regimes) {
370  const reset = Date.parse(r.resetsAt)
371  const start = reset - (SPAN_MS[r.kind] ?? 0)
372  return (regimes[r.org] ?? []).some(
373    (g: Regime) => !g.isUndone && (!g.kinds || g.kinds.includes(r.kind as Kind)) && g.startedAt > start && g.startedAt <= reset,
374  )
375}
376
377function basisOf(e: Estimate | undefined, isUnpriced: boolean, sinceResetLabel: string | undefined) {
378  const reset = sinceResetLabel ? ` · since reset ${sinceResetLabel}` : ''
379  if (isUnpriced) return `Unpriced models in use: no estimate${reset}.`
380  if (!e) return `No estimate yet: the limit has not reported a usable reading for this window${reset}.`
381  const readings = e.isPriorOnly
382    ? 'From past windows only: no tick read in this window yet'
383    : `${e.readings} percent level${e.readings === 1 ? '' : 's'} read`
384  const within = `within-window spread ${e.isWithinSpreadAssumed ? 'assumed' : 'measured'}`
385  const between = e.isSpreadAssumed
386    ? 'between-window spread assumed'
387    : `between-window spread from ${e.pastWindows} past window${e.pastWindows === 1 ? '' : 's'}`
388  return `${readings} · ${within} · ${between} · history weight ${e.pastWeight}${reset}`
389}
390
391const isUnpricedTally = (t: WindowTally) => t.unpricedRequests > MAX_UNPRICED_SHARE * (t.requests + t.unpricedRequests)
392
393/* The percents of the active window a measure may record: this session's, from `given`,
394   and the stored one when a session with these hooks stored it and it is not the same
395   percent. Whether the stored one has a reading already is decided against the readings
396   read just before they are written. */
397function candidatesOf(
398  w: (typeof WINDOWS)[number],
399  limit: Limit,
400  subscription: string,
401  given: readonly SessionRateLimit[] | undefined,
402  readAt: number,
403  sessionId: string,
404  kept: StoredLimit | undefined,
405): Candidate[] {
406  const key = windowKey(subscription, w.kind, limit.resetsAt)
407  const candidates: Candidate[] = []
408  const fresh = given?.find(l => l.kind === w.kind && l.resetsAt)
409  if (fresh && windowKey(subscription, w.kind, fresh.resetsAt!) === key)
410    candidates.push({ source: 'mine', tally: `${w.name}${AT_READ}`, percent: fresh.percentUsed, at: readAt, sessionId })
411  const isMine = candidates.some(c => c.at === kept?.observedAt && c.sessionId === kept?.sessionId)
412  if (
413    kept?.sessionId &&
414    kept.percentUsed !== undefined &&
415    (kept.stamp ?? 0) === HOOKS_STAMP &&
416    windowKey(subscription, w.kind, kept.resetsAt) === key &&
417    !isMine
418  )
419    candidates.push({ source: 'stored', tally: `${w.name}${AT_STORED}`, percent: kept.percentUsed, at: kept.observedAt, sessionId: kept.sessionId })
420  return candidates
421}
422
423/* Whether a level's bucket holds the reading at `at`, or a later one at that level that
424   took its side. */
425function isHeld(r: WindowReadings | undefined, percent: number, at: number) {
426  const b = r?.byPct[String(percent)]
427  return b !== undefined && (b.minAt === at || b.maxAt >= at)
428}
429
430const hasReadingAt = (r: WindowReadings | undefined, at: number) =>
431  r !== undefined && Object.values(r.byPct).some(b => b.minAt === at || b.maxAt === at)
432
433/* isFresh: `given` holds percents this session received at receivedAt, so they and the
434   dollars counted up to then describe the same moment. Only such a measure records its
435   own percents or shares its limits; every measure records a stored percent that has no
436   reading yet. */
437async function measure(
438  host: Host,
439  given: readonly SessionRateLimit[] | undefined,
440  isFresh: boolean,
441  receivedAt: number | undefined,
442): Promise<Summary | undefined> {
443  const now = await host.now()
444  if (firstMeasureAt === undefined) firstMeasureAt = now
445  const readAt = isFresh && receivedAt !== undefined ? receivedAt : now
446  const live = given ?? (await host.rateLimits())
447  if (isFresh) lastFreshAt = readAt
448  const sessionId = await host.sessionId()
449  let subscription = await resolveSubscription(host)
450
451  let active: { w: (typeof WINDOWS)[number]; limit: Limit; candidates: Candidate[] }[] = []
452  let storedLimits: StoredLimits = {}
453  let out: WindowsOutput | undefined
454  for (let attempt = 0; attempt < 2; attempt++) {
455    storedLimits = ((await host.get(LIMITS_KEY)) ?? {}) as StoredLimits
456    active = []
457    for (const w of WINDOWS) {
458      const kept = storedLimits[subscription]?.[w.kind]
459      const limit = limitOf(w.kind, live, lastFreshAt, kept, now)
460      if (limit) active.push({ w, limit, candidates: candidatesOf(w, limit, subscription, isFresh ? given : undefined, readAt, sessionId, kept) })
461    }
462    if (active.length === 0) {
463      host.status('waiting for the first reply')
464      return undefined
465    }
466
467    const regimes = ((await host.get(REGIMES_KEY)) ?? {}) as Regimes
468    const plans = ((await host.get(PLANS_KEY)) ?? {}) as Ledger
469    const extra: string[] = []
470    for (const { w, limit, candidates } of active) {
471      const since = new Date(Date.parse(limit.resetsAt) - w.spanMs).toISOString()
472      extra.push('--window', `${w.name},${since},${limit.resetsAt}`)
473      for (const c of candidates)
474        extra.push('--window', `${c.tally},${since},${limit.resetsAt},${new Date(c.at).toISOString()},${c.sessionId}`)
475    }
476    extra.push('--window', `last24h,${new Date(now - DAY_MS).toISOString()},${new Date(now).toISOString()}`)
477    extra.push(...labelArgs(regimes, plans, subscription, now))
478    const result = await runHelper<WindowsOutput>(host, extra, 120_000)
479    if (!result.out?.windows) {
480      host.status('unavailable')
481      host.log(`usage-dollars: ${result.error ?? 'no windows in the helper output'}`)
482      return undefined
483    }
484    out = result.out
485    if (out.orgSource === 'session' && out.org) sessionOrg = out.org
486    const reported = subscriptionOf(out.org)
487    if (reported === subscription) break
488    subscription = reported
489    if (attempt === 1) break
490  }
491  if (!out?.windows) return undefined
492  lastSubscription = subscription
493  const labels = out.labels ?? {}
494  const profile = out.profile ?? null
495
496  /* A stored limit gives way to one received later, or to the next window's. */
497  const liveLimits = isFresh ? live.filter(l => l.resetsAt && WINDOWS.some(w => w.kind === l.kind)) : []
498  if (liveLimits.length > 0) {
499    const stored = ((await host.get(LIMITS_KEY)) ?? {}) as StoredLimits
500    const mine = { ...stored[subscription] }
501    let isChanged = false
502    for (const l of liveLimits) {
503      const was = mine[l.kind] as StoredLimit | undefined
504      if (was && Date.parse(was.resetsAt) >= Date.parse(l.resetsAt!) && was.observedAt >= readAt) continue
505      mine[l.kind] = { percentUsed: l.percentUsed, resetsAt: l.resetsAt!, observedAt: readAt, sessionId, stamp: HOOKS_STAMP }
506      isChanged = true
507    }
508    if (isChanged) await host.set(LIMITS_KEY, { ...stored, [subscription]: mine })
509  }
510
511  /* Hooks of another stamp that stored a percent since this module first measured. */
512  for (const l of Object.values(storedLimits[subscription] ?? {})) {
513    const stamp = l.stamp ?? 0
514    if (stamp !== HOOKS_STAMP && l.observedAt > (firstMeasureAt ?? now)) diagnostics.otherHooks = stamp < HOOKS_STAMP ? 'older' : 'newer'
515  }
516
517  /* A guessed subscription must not write readings; with none at all every request is
518     counted, as on a machine without bridge-session records. A reading takes its percent
519     from a candidate, and only for the window the dollars were scanned for; its dollars
520     end where the percent was received. Readings priced with another table are dropped. */
521  const mayRecord = out.org === null || out.org === undefined || out.orgSource === 'session' || out.orgSource === 'profile'
522  const pricesId = out.pricesId
523  const storedReadings = ((await host.get(READINGS_KEY)) ?? {}) as StoredReadings
524  const readings = Object.fromEntries(Object.entries(storedReadings).filter(([, r]) => r.pricesId === pricesId)) as StoredReadings
525  diagnostics.droppedForPrices += Object.keys(storedReadings).length - Object.keys(readings).length
526  const attempts: { title: string; key: string; c: Candidate; outcome?: Outcome }[] = []
527  for (const { w, limit, candidates } of active) {
528    const key = windowKey(subscription, w.kind, limit.resetsAt)
529    for (const c of candidates) {
530      if (c.source === 'stored' && hasReadingAt(readings[key], c.at)) continue
531      const tally = out.windows[c.tally]
532      const attempt: (typeof attempts)[number] = { title: w.title, key, c }
533      attempts.push(attempt)
534      if (!mayRecord) attempt.outcome = 'unattributed'
535      else if (tally && isUnpricedTally(tally)) attempt.outcome = 'unpriced'
536      else if (tally) {
537        const was = readings[key] ?? { kind: w.kind, resetsAt: limit.resetsAt, byPct: {}, org: subscription, pricesId }
538        readings[key] = { ...record(was, c.percent, tally.usd, c.at, tally.slackUsd ?? 0), org: subscription, pricesId }
539      }
540    }
541  }
542  const kept = Object.fromEntries(
543    Object.entries(readings).filter(([, r]) => Date.parse(r.resetsAt) > now - READINGS_KEPT_MS),
544  ) as StoredReadings
545  await host.set(READINGS_KEY, kept)
546  const outcomes: Diagnostics['outcomes'] = {}
547  for (const a of attempts) {
548    const outcome = a.outcome ?? (isHeld(kept[a.key], a.c.percent, a.c.at) ? 'recorded' : 'missing')
549    outcomes[a.title] = [...(outcomes[a.title] ?? []), { source: a.c.source, outcome }]
550  }
551  for (const [title, list] of Object.entries(outcomes)) diagnostics.outcomes[title] = list
552
553  const plans = ((await host.get(PLANS_KEY)) ?? {}) as Ledger
554  const observedPlan = observePlan(plans, profile, subscription, now)
555  if (!isSame(observedPlan.ledger, plans)) await host.set(PLANS_KEY, observedPlan.ledger)
556
557  const promotions = ((await host.get(PROMOTIONS_KEY)) ?? {}) as Promotions
558  const observedPromos = observePromotions(promotions, profile, subscription, now)
559  if (!isSame(observedPromos.stored, promotions)) await host.set(PROMOTIONS_KEY, observedPromos.stored)
560
561  const added = [...(observedPlan.regime ? [observedPlan.regime] : []), ...observedPromos.regimes]
562  let regimes = ((await host.get(REGIMES_KEY)) ?? {}) as Regimes
563  if (added.length > 0) {
564    regimes = addRegimes(regimes, subscription, added)
565    await host.set(REGIMES_KEY, regimes)
566  }
567
568  const drafts: NoticeDraft[] = [...observedPromos.notices]
569  const event = observedPlan.event
570  if (event.kind === 'changed' && profile)
571    drafts.push({ id: `plan:${subscription}:${event.to.fingerprint}`, kind: 'plan-changed', text: planChangedText(event, labels, profile) })
572
573  const all = Object.values(kept)
574  const resolution = resolutionOf(all)
575  const roundingSeen = roundingEvidence(
576    all.filter(r => Date.parse(r.resetsAt) <= now && !isStraddling(r, regimes)),
577    resolution,
578  )
579  const rounding = roundingSeen.rule
580  const history = ((await host.get(HISTORY_KEY)) ?? {}) as History
581  const observations = history[subscription]?.observations ?? []
582
583  const windows: WindowState[] = []
584  for (const { w, limit } of active) {
585    const tally = out.windows[w.name]
586    if (!tally) continue
587    const startedAt = regimeStart(regimes, subscription, w.kind)
588    const regime = currentRegime(regimes, subscription, w.kind)
589    /* Closed windows of this subscription and regime: one past point each, and the
590       crossings that measure the within-window spread. */
591    const closed = all
592      .filter(r => r.org === subscription && r.kind === w.kind && Date.parse(r.resetsAt) <= now && Date.parse(r.resetsAt) >= startedAt)
593      .map(r => regimeView(r, startedAt))
594    const rejections = observations.filter(o => Date.parse(o.resetsAt) > now - HISTORY_DAYS * DAY_MS)
595    const spread = calibrateOmega(closed, w.kind, now, resolution, rounding)
596    const past = pastPoints(closed, rejections, w.kind, resolution, rounding, spread.omega).filter(p => p.at >= startedAt)
597    const key = windowKey(subscription, w.kind, limit.resetsAt)
598    const stored = kept[key]
599    const current = stored ? regimeView(stored, startedAt) : undefined
600    const isUnpriced = isUnpricedTally(tally)
601    const e =
602      current && !isUnpriced
603        ? estimate(current, tally.usd, {
604            resolution,
605            rounding,
606            past,
607            kind: w.kind,
608            now,
609            livePercent: limit.observedAt !== undefined && now - limit.observedAt <= FRESH_MS ? limit.percentUsed : undefined,
610            spread,
611          })
612        : undefined
613    if (e?.isPriorContradicted) drafts.push({ id: `inferred:${key}`, kind: 'inferred', text: INFERRED_TEXT })
614    windows.push({
615      title: w.title,
616      short: w.short,
617      resetsAt: limit.resetsAt,
618      tally,
619      estimate: e,
620      isUnpriced,
621      sinceResetLabel: regime?.reason === 'manual' ? labels[String(regime.startedAt)] : undefined,
622      percent: limit.percentUsed,
623      percentAt: limit.percentUsed !== undefined ? limit.observedAt : undefined,
624      calibration: {
625        ...calibrationOf({ current, resolution, spread, prior: prior(past, w.kind, now), rounding: roundingSeen }),
626        closedWindows: closed.filter(r => Object.keys(r.byPct).length > 0).length,
627        rounding,
628        halfWidth: e ? Math.sqrt(e.allowance.high / e.allowance.low) - 1 : undefined,
629      },
630    })
631  }
632  await queueNotices(host, subscription, drafts, now)
633
634  const queue = ((await host.get(NOTICES_KEY)) ?? []) as Notice[]
635  const mine = queue.filter(n => n.org === subscription)
636  const isPlanUnseen = mine.some(n => n.kind === 'plan-changed' && !n.isSeen)
637  host.status(statusOf({ windows }, isPlanUnseen))
638
639  const isShared = Object.keys(observedPlan.ledger).length > 1
640  const notices = [
641    ...mine
642      .filter(n => n.kind === 'plan-changed' && n.at > now - NOTICE_SHOWN_MS)
643      .map(n => n.text),
644    ...activePromotions(observedPromos.stored, subscription).map(t => (isShared ? `${t} Not attributable to one subscription.` : t)),
645    ...mine
646      .filter(n => n.kind === 'promotion' && /^promo-(end|gone):/.test(n.id) && n.at > now - NOTICE_SHOWN_MS)
647      .map(n => n.text),
648    ...(windows.some(s => s.estimate?.isPriorContradicted) ? [INFERRED_TEXT] : []),
649  ]
650
651  return {
652    subscription,
653    orgSource: out.orgSource,
654    windows,
655    plan: planReport(event, profile, labels, now),
656    notices,
657    last24hUsd: out.windows.last24h?.usd,
658    sinceResetLabel: windows.find(s => s.sinceResetLabel)?.sinceResetLabel,
659    files: out.files,
660    ms: out.ms,
661  }
662}
663
664/** The status line: what is left of each window's allowance. */
665export function statusOf(summary: Pick<Summary, 'windows'>, isPlanUnseen: boolean) {
666  return statusLine(
667    summary.windows.map(s => ({ short: s.short, usedUsd: s.tally.usd, allowance: s.estimate?.allowance, left: s.estimate?.left })),
668    isPlanUnseen,
669  )
670}
671
672/* One scan at a time, at most once a minute unless forced or fresh. A fresh call that
673   meets a running scan is queued and runs, forced, once that scan settles. */
674export function refresh(host: Host, limits?: readonly SessionRateLimit[], isForced = false, isFresh = false, receivedAt?: number) {
675  if (running) {
676    if (isFresh) pendingFresh = { host, limits, receivedAt }
677    return running
678  }
679  if (!isForced && !isFresh && Date.now() - lastRunAt < MIN_GAP_MS) return Promise.resolve(last)
680  lastRunAt = Date.now()
681  running = measure(host, limits, isFresh, receivedAt)
682    .then(summary => (last = summary ?? last))
683    .catch(error => {
684      host.log(`usage-dollars: ${String(error)}`)
685      return last
686    })
687    .finally(() => {
688      running = undefined
689      const queued = pendingFresh
690      pendingFresh = undefined
691      if (queued) void refresh(queued.host, queued.limits, true, true, queued.receivedAt)
692    })
693  return running
694}
695
696/** A forced measure that starts after any measure already running. */
697export async function remeasure(host: Host) {
698  if (running) await running
699  return refresh(host, undefined, true)
700}
701
702/* Past rate-limit rejections, each a window seen exactly full; rescanned every six hours. */
703export async function refreshHistory(host: Host) {
704  const subscription = await resolveSubscription(host)
705  const now = await host.now()
706  const kept = ((await host.get(HISTORY_KEY)) ?? {}) as History
707  if (kept[subscription] && now - kept[subscription].scannedAt < HISTORY_REFRESH_MS) return
708  const { out, error } = await runHelper<HelperBase & { observations?: Observation[] }>(
709    host,
710    ['--history', String(HISTORY_DAYS)],
711    180_000,
712  )
713  if (!out?.observations) {
714    host.log(`usage-dollars: history: ${error ?? 'no observations'}`)
715    return
716  }
717  const stored = ((await host.get(HISTORY_KEY)) ?? {}) as History
718  await host.set(HISTORY_KEY, { ...stored, [subscriptionOf(out.org)]: { scannedAt: now, observations: out.observations } })
719  void refresh(host, undefined, true)
720}
721
722/** Restarts the estimates of the session's subscription from now. */
723export async function resetEstimates(host: Host) {
724  const subscription = await resolveSubscription(host)
725  const now = await host.now()
726  const regimes = ((await host.get(REGIMES_KEY)) ?? {}) as Regimes
727  await host.set(REGIMES_KEY, startOver(regimes, subscription, now))
728  await remeasure(host)
729}
730
731/** Undoes the latest reset when nothing observed has followed it. */
732export async function undoReset(host: Host) {
733  const subscription = await resolveSubscription(host)
734  const regimes = ((await host.get(REGIMES_KEY)) ?? {}) as Regimes
735  const result = undoStartOver(regimes, subscription)
736  if (result.isUndone) await host.set(REGIMES_KEY, result.regimes)
737  await remeasure(host)
738  return result.isUndone
739}
740
741/** Marks every queued notice seen: the card has been shown. */
742export async function markNoticesSeen(host: Host) {
743  const queue = ((await host.get(NOTICES_KEY)) ?? []) as Notice[]
744  if (queue.every(n => n.isSeen)) return
745  await host.set(NOTICES_KEY, queue.map(n => ({ ...n, isSeen: true })))
746}
747
748/** The texts of this subscription's notices not toasted yet, marked toasted. */
749export async function takeToasts(host: Host) {
750  if (!lastSubscription) return []
751  const queue = ((await host.get(NOTICES_KEY)) ?? []) as Notice[]
752  const due = queue.filter(n => n.org === lastSubscription && !n.isToasted)
753  if (due.length === 0) return []
754  const ids = new Set(due.map(n => n.id))
755  await host.set(NOTICES_KEY, queue.map(n => (ids.has(n.id) ? { ...n, isToasted: true } : n)))
756  return due.map(n => n.text)
757}
758
759export function toReport(summary: Summary, now: number): UsageReport {
760  const windows: WindowReport[] = summary.windows.map(s => ({
761    title: s.title,
762    short: s.short,
763    usedUsd: s.tally.usd,
764    requests: s.tally.requests,
765    resetLong: s.tally.resetLong ?? s.resetsAt,
766    resetIn: s.tally.resetIn ?? '',
767    left: s.estimate
768      ? { value: Math.max(0, s.estimate.left.value), low: Math.max(0, s.estimate.left.low), high: Math.max(0, s.estimate.left.high) }
769      : undefined,
770    allowance: s.estimate?.allowance,
771    confidence: s.estimate?.confidence,
772    nextTick: s.estimate?.nextTick,
773    pastWeight: s.estimate?.pastWeight,
774    isPriorContradicted: s.estimate?.isPriorContradicted,
775    isSpreadAssumed: s.estimate?.isSpreadAssumed,
776    percent: s.percent,
777    percentAgeMinutes: s.percentAt !== undefined ? Math.max(0, Math.round((now - s.percentAt) / MINUTE_MS)) : undefined,
778    basis: basisOf(s.estimate, s.isUnpriced, s.sinceResetLabel),
779    isCalibrated: s.calibration.isCalibrated,
780  }))
781  const widest = summary.windows[summary.windows.length - 1]
782  const byModel: ModelSpend[] = Object.entries(widest?.tally.byModel ?? {})
783    .map(([model, spend]) => ({ model, ...spend }))
784    .sort((a, b) => b.usd - a.usd)
785  return {
786    type: 'windows',
787    plan: summary.plan,
788    notices: summary.notices,
789    last24hUsd: summary.last24hUsd,
790    sinceResetLabel: summary.sinceResetLabel,
791    windows,
792    byModel,
793    byModelTitle: `By model · ${(widest?.title ?? '').toLowerCase()}`,
794    notes: notesOf(widest?.tally),
795    files: summary.files,
796    ms: summary.ms,
797  }
798}
799
800function notesOf(t: Omit<WindowTally, 'byModel' | 'usd' | 'requests'> | undefined) {
801  const notes: string[] = []
802  if (t && t.otherSubscriptionsUsd > 0)
803    notes.push(`Other subscriptions spent ${money(t.otherSubscriptionsUsd)} in this period; not counted here.`)
804  if (t && t.unattributedUsd > 0)
805    notes.push(`${money(t.unattributedUsd)} came from sessions with no subscription record; not counted here.`)
806  if (t && t.unpricedRequests > 0)
807    notes.push(`${t.unpricedRequests} requests to unpriced models (${t.unpricedModels.join(', ')}); not counted here.`)
808  return notes
809}
810
811/** Spending in a range: `range` is the helper's `--report` or `--report-local` argument pair. */
812export async function spendReport(host: Host, range: readonly string[]): Promise<SpendReport | { error: string }> {
813  const { out, error } = await runHelper<ReportOutput>(host, range, 180_000)
814  if (!out) return { error: error ?? 'no report' }
815  const notes = notesOf(out)
816  if (out.transcriptsBeginLabel)
817    notes.push(`Transcripts on this machine begin ${out.transcriptsBeginLabel}; earlier spending is not included.`)
818  return {
819    type: 'report',
820    fromLabel: out.fromLabel,
821    toLabel: out.toLabel,
822    usedUsd: out.usd,
823    requests: out.requests,
824    byDay: out.byDay,
825    byModel: Object.entries(out.byModel)
826      .map(([model, spend]) => ({ model, ...spend }))
827      .sort((a, b) => b.usd - a.usd),
828    notes,
829  }
830}
831
832/** What this session has seen of other sessions and of its own readings. */
833export const diagnosticsOf = (): Readonly<Diagnostics> => diagnostics
834
835type CheckSummary = { sessions: number; medianRatio: number | null; lowRatio: number | null; highRatio: number | null; error?: string }
836
837const NOT_RECORDED: Record<'unpriced' | 'unattributed', string> = {
838  unpriced: 'too many of its requests go to unpriced models (see Prices)',
839  unattributed: 'the subscription is a guess (see Subscription)',
840}
841
842const titles = (list: readonly string[]) => list.join(' and ')
843
844function subscriptionItem(orgSource: OrgSource | null | undefined, hasOrg: boolean): CheckItem {
845  const label = 'Subscription'
846  if (orgSource === 'most recent')
847    return {
848      label,
849      state: 'fail',
850      text: 'Only guessed, from the most recently used one, so no readings are recorded. Sign in to Claude Code (/login).',
851    }
852  if (!hasOrg) return { label, state: 'info', text: 'None (an API key): there are no limits to read and no estimates.' }
853  return {
854    label,
855    state: 'ok',
856    text: orgSource === 'session' ? "Named by this session's own record." : 'Named by the signed-in profile.',
857  }
858}
859
860function readingsItem(outcomes: Diagnostics['outcomes']): CheckItem {
861  const label = 'Readings'
862  const entries = Object.entries(outcomes)
863  const missing = entries.filter(([, list]) => list.some(o => o.outcome === 'missing')).map(([title]) => title)
864  if (missing.length > 0)
865    return {
866      label,
867      state: 'fail',
868      text: `A percent of the ${titles(missing)} was not recorded and nothing explains it. Please report it, with the debug log.`,
869    }
870  const reasons = entries.flatMap(([title, list]) =>
871    list
872      .map(o => o.outcome)
873      .filter((o): o is 'unpriced' | 'unattributed' => o === 'unpriced' || o === 'unattributed')
874      .map(o => `${title}: not recorded, ${NOT_RECORDED[o]}`),
875  )
876  if (reasons.length > 0) return { label, state: 'info', text: `${[...new Set(reasons)].join('; ')}.` }
877  if (entries.length > 0) return { label, state: 'ok', text: `Recorded for the ${titles(entries.map(([title]) => title))}.` }
878  return { label, state: 'info', text: 'None received in this session yet: a reading is taken after every reply.' }
879}
880
881function pricesItem(check: CheckSummary, unpriced: readonly string[]): CheckItem {
882  const label = 'Prices'
883  const update = 'Update PRICES in scripts/usage-cost.mjs; doing so drops the stored readings and calibration starts again.'
884  const failures: string[] = []
885  if (unpriced.length > 0) failures.push(`More than 2% of the ${titles(unpriced)}'s requests go to unpriced models.`)
886  const { medianRatio: median, lowRatio: low, highRatio: high, sessions } = check
887  const over = `over ${sessions} session${sessions === 1 ? '' : 's'}`
888  if (median !== null && (median < MIN_PRICE_RATIO || median > MAX_PRICE_RATIO))
889    failures.push(`These dollars are ${median} times Claude Code's own costs (median ${over}).`)
890  if (failures.length > 0) return { label, state: 'fail', text: `${failures.join(' ')} ${update}` }
891  if (check.error) return { label, state: 'info', text: `Not compared with Claude Code's own costs: ${check.error}` }
892  if (median === null) return { label, state: 'info', text: "No session of at least $0.50 to compare with Claude Code's own costs." }
893  return {
894    label,
895    state: 'ok',
896    text: `These dollars are ${median} times Claude Code's own costs (median ${over}; 5th to 95th percentile ${low} to ${high}).`,
897  }
898}
899
900/** The setup check: a forced measure, then the helper, the subscription, other sessions'
901    hooks and price table, this session's readings, and the prices. */
902export async function checkReport(host: Host): Promise<CheckReport> {
903  const summary = await remeasure(host)
904  const who = await runHelper<HelperBase>(host, ['--whoami'], 30_000).catch(error => ({ out: undefined, error: String(error) }))
905  if (!who.out)
906    return {
907      type: 'check',
908      items: [
909        {
910          label: 'Helper',
911          state: 'fail',
912          text: `The transcript scan does not run (${who.error ?? 'no output'}). Install Node.js 18 or later on PATH, then restart Claude Code.`,
913        },
914      ],
915    }
916  const items: CheckItem[] = [{ label: 'Helper', state: 'ok', text: 'Node.js runs the transcript scan.' }]
917  items.push(subscriptionItem(summary ? summary.orgSource : who.out.orgSource, summary ? summary.subscription !== NONE : Boolean(who.out.org)))
918
919  const { otherHooks, droppedForPrices, outcomes } = diagnostics
920  items.push(
921    otherHooks === 'older'
922      ? {
923          label: 'Other hooks',
924          state: 'fail',
925          text: 'A session running older hooks stored a percent since this one started. Restart the other sessions, and load only one copy of the plugin.',
926        }
927      : otherHooks === 'newer'
928        ? {
929            label: 'Other hooks',
930            state: 'fail',
931            text: 'A session running newer hooks stored a percent since this one started. Restart this session, and load only one copy of the plugin.',
932          }
933        : { label: 'Other hooks', state: 'ok', text: 'No session with other hooks seen since this one started.' },
934  )
935  items.push(
936    droppedForPrices > 0
937      ? {
938          label: 'Price table',
939          state: 'fail',
940          text:
941            `${droppedForPrices} stored window${droppedForPrices === 1 ? ' was' : 's were'} dropped, priced with another table: ` +
942            'another session runs a different usage-cost.mjs, or the table was just updated.',
943        }
944      : { label: 'Price table', state: 'ok', text: 'Every stored reading is priced with this table.' },
945  )
946  items.push(readingsItem(outcomes))
947
948  const check = await runScript<CheckSummary>(host, ['--check-summary'], 120_000).catch(error => ({ out: undefined, error: String(error) }))
949  const unpriced = (summary?.windows ?? []).filter(s => s.isUnpriced).map(s => s.title)
950  items.push(
951    pricesItem(check.out ?? { sessions: 0, medianRatio: null, lowRatio: null, highRatio: null, error: check.error ?? 'no output' }, unpriced),
952  )
953  items.push({
954    label: 'Other usage',
955    state: 'info',
956    text: 'Usage on other machines and on claude.ai is not counted and makes the allowance look smaller; an "inferred change" notice is the sign of it.',
957  })
958  return { type: 'check', items }
959}
960
961export function toCalibrationReport(summary: Summary): CalibrationReport {
962  return {
963    type: 'calibration',
964    windows: summary.windows.map(s => {
965      const c = s.calibration
966      return {
967        title: s.title,
968        levels: c.levels,
969        ticks: c.ticks,
970        closedWindows: c.closedWindows,
971        closedForWithin: c.closedForWithin,
972        isWithinMeasured: c.isWithinMeasured,
973        pastPoints: c.pastPoints,
974        pastWeight: c.pastWeight,
975        isBetweenMeasured: c.isBetweenMeasured,
976        closedForRounding: c.closedForRounding,
977        roundingNeeded: MIN_ROUNDING_WINDOWS,
978        isRoundingKnown: c.isRoundingKnown,
979        rounding: c.rounding,
980        measured: c.measured,
981        isCalibrated: c.isCalibrated,
982        halfWidth: c.halfWidth,
983      }
984    }),
985  }
986}
987
hooks/probe.ts 20 lines
1/* The check message: a real turn sent from the first-reading card, only when the person
2   presses its button, since only a main-conversation reply brings a usage reading in a
3   headless session. Pure: register.tsx sends it and keeps the in-flight flag. */
4
5import type { WaitingReport } from '../types'
6
7/* Says what it is in the transcript, and keeps the turn as short as the session's model
8   allows. Carries nothing from the session. */
9export const CHECK_PROMPT =
10  'usage-dollars check: this message only fetches a usage reading. Reply with the single word OK. Do not use any tools.'
11
12export const CHECK_LABEL = 'Send a short check message (uses one turn)'
13
14/** The first-reading card for `command`: a subscription with no check in flight may send
15    one; a sign-in without a subscription never gets a reading to wait for. */
16export function waitingReport(command: string, subscription: string, isCheckInFlight: boolean): WaitingReport {
17  if (subscription === 'none') return { type: 'waiting', command, canSend: false, isUnsubscribed: true }
18  return { type: 'waiting', command, canSend: !isCheckInFlight }
19}
20
hooks/estimate.ts 489 lines
1/* Estimates a rate-limit window's allowance in dollars from readings of (used dollars,
2   reported percent) and gives a 90% range.
3
4   Model, in natural logs. A is the dollars counted when the window reaches 100%, so the
5   window runs at D = A / 100 dollars per percent. Between windows log A ~ N(mu, tau²).
6   Within a window the cumulative rate U(s) / s read at share s differs from D with
7   variance omega² · (1/s − 1/100): an average of s one-percent steps against the average
8   of all 100, which vanishes as s → 100.
9
10   A percent p reported at resolution r means the true share lies in an interval set by
11   how the source rounds: [p − r/2, p + r/2) when it rounds, [p, p + r) when it truncates,
12   and their union while that is not known. The newest level gives the evidence: where the
13   level below it was also read, the tick was crossed between the two readings, at the
14   lower edge of p's interval; otherwise anywhere in p's interval. Past windows give a
15   prior on log A that the evidence is combined with by precision, unless the two
16   contradict each other. tau and omega start from assumed values and are shrunk toward
17   what closed windows show. */
18
19import type { Confidence } from '../types'
20
21/* minSlack and maxSlack: dollars of other sessions' requests close to that side's
22   reading, which its percent may not include yet. */
23export type Bucket = { minUsd: number; minAt: number; maxUsd: number; maxAt: number; minSlack?: number; maxSlack?: number }
24
25/* pricesId: the price table the dollars were computed with. */
26export type WindowReadings = { kind: string; resetsAt: string; byPct: Record<string, Bucket>; pricesId?: string }
27
28export type RangeEstimate = { value: number; low: number; high: number }
29
30export type Rounding = 'round' | 'truncate' | 'union'
31
32/** A past allowance in logs: a closed window's, at its reset, or a rejection's, at its time. */
33export type PastPoint = { logA: number; variance: number; at: number }
34
35/** A rate-limit rejection: the window was exactly full at `at`, with `usd` counted. */
36export type Rejection = { kind: string; resetsAt: string; at: string; usd: number }
37
38/** log A from one window's newest level `pct`, read at share `share`. shift: an error of
39    up to that much either way, not random, from not knowing the rounding rule.
40    crossingUsd: the dollars at the tick into that level, when it was seen. */
41export type Evidence = { logA: number; variance: number; shift: number; share: number; pct: number; crossingUsd?: number }
42
43/** The within-window spread omega, the age weight of the closed-window crossings behind
44    it, and the number of closed windows that gave a crossing. It counts as measured from
45    two windows on. */
46export type Spread = { omega: number; weight: number; windows: number; isAssumed: boolean }
47
48export type Estimate = {
49  allowance: RangeEstimate
50  left: RangeEstimate
51  readings: number
52  pastWindows: number
53  pastWeight: number
54  /** The between-window spread is the assumed one: under 3 effective past windows. */
55  isSpreadAssumed: boolean
56  /** No closed window has measured the within-window spread yet. */
57  isWithinSpreadAssumed: boolean
58  isPriorContradicted: boolean
59  /** No level above 0 read in this window: the estimate is the prior alone. */
60  isPriorOnly: boolean
61  confidence: Confidence
62  nextTick?: RangeEstimate
63}
64
65export type EstimateOptions = {
66  resolution: number
67  rounding: Rounding
68  past: readonly PastPoint[]
69  kind: string
70  now: number
71  livePercent?: number
72  spread?: Spread
73}
74
75const NU0 = 4
76const TAU0 = 0.1
77const OMEGA0 = 0.5
78const REJECTION_SD = 0.02
79const MIN_PAST_SHARE = 10
80const MIN_CROSSING_SHARE = 5
81const MIN_SPREAD_WINDOWS = 2
82export const MIN_ROUNDING_WINDOWS = 5
83const MINUTE_MS = 60 * 1000
84const HOUR_MS = 60 * MINUTE_MS
85const FIVE_HOUR_HALF_LIFE_MS = 24 * HOUR_MS
86const SEVEN_DAY_HALF_LIFE_MS = 14 * 24 * HOUR_MS
87
88export const ASSUMED_SPREAD: Spread = { omega: OMEGA0, weight: 0, windows: 0, isAssumed: true }
89
90export function resolutionOf(all: readonly WindowReadings[]) {
91  const isFine = all.some(w => Object.keys(w.byPct).some(p => !Number.isInteger(Number(p))))
92  return isFine ? 0.1 : 1
93}
94
95/* Within a window dollars only grow, so a tie keeps the later time: the most recent
96   reading that still supports the bound. Each side keeps its reading's slack. */
97export function record(w: WindowReadings, pct: number, usd: number, at: number, slack = 0): WindowReadings {
98  const key = String(pct)
99  const was = w.byPct[key]
100  let bucket: Bucket = { minUsd: usd, minAt: at, maxUsd: usd, maxAt: at, minSlack: slack, maxSlack: slack }
101  if (was) {
102    const isMin = usd < was.minUsd || (usd === was.minUsd && at >= was.minAt)
103    const isMax = usd > was.maxUsd || (usd === was.maxUsd && at >= was.maxAt)
104    bucket = {
105      minUsd: isMin ? usd : was.minUsd,
106      minAt: isMin ? at : was.minAt,
107      maxUsd: isMax ? usd : was.maxUsd,
108      maxAt: isMax ? at : was.maxAt,
109      minSlack: isMin ? slack : (was.minSlack ?? 0),
110      maxSlack: isMax ? slack : (was.maxSlack ?? 0),
111    }
112  }
113  return { ...w, byPct: { ...w.byPct, [key]: bucket } }
114}
115
116/* The window as seen from a regime that started at startedAt: readings taken before it
117   are dropped. A straddling bucket keeps only its max side. */
118export function regimeView(w: WindowReadings, startedAt: number): WindowReadings {
119  const byPct: Record<string, Bucket> = {}
120  for (const [key, b] of Object.entries(w.byPct)) {
121    if (b.maxAt < startedAt) continue
122    byPct[key] =
123      b.minAt < startedAt
124        ? { minUsd: b.maxUsd, minAt: b.maxAt, maxUsd: b.maxUsd, maxAt: b.maxAt, minSlack: b.maxSlack, maxSlack: b.maxSlack }
125        : b
126  }
127  return { ...w, byPct }
128}
129
130function shareOf(pct: number, resolution: number, rounding: Rounding) {
131  if (rounding === 'round') return { lower: pct - resolution / 2, upper: pct + resolution / 2 }
132  if (rounding === 'truncate') return { lower: pct, upper: pct + resolution }
133  return { lower: pct - resolution / 2, upper: pct + resolution }
134}
135
136/* Where the share crosses into level p: one point, or for the union one of the two rules'
137   points, taken at their geometric center with `shift`, half the log distance between
138   them, as an error that is either rule's and not random. */
139function crossingShareOf(pct: number, resolution: number, rounding: Rounding) {
140  if (rounding === 'round') return { s: pct - resolution / 2, variance: 0, shift: 0 }
141  if (rounding === 'truncate') return { s: pct, variance: 0, shift: 0 }
142  const lower = pct - resolution / 2
143  return lower > 0
144    ? { s: Math.sqrt(lower * pct), variance: 0, shift: Math.log(pct / lower) / 2 }
145    : { s: pct - resolution / 4, variance: (resolution / 4) ** 2, shift: 0 }
146}
147
148/* A share uniform over a level's interval, never below 0. */
149function levelShareOf(pct: number, resolution: number, rounding: Rounding) {
150  const { lower, upper } = shareOf(pct, resolution, rounding)
151  const from = Math.max(0, lower)
152  return { s: (from + upper) / 2, variance: (upper - from) ** 2 / 12, shift: 0 }
153}
154
155export function bounds(w: WindowReadings, resolution: number, rounding: Rounding) {
156  let low = 0
157  let high = Infinity
158  for (const [key, bucket] of Object.entries(w.byPct)) {
159    const { lower, upper } = shareOf(Number(key), resolution, rounding)
160    low = Math.max(low, bucket.maxUsd / (upper / 100))
161    if (lower > 0) high = Math.min(high, bucket.minUsd / (lower / 100))
162  }
163  return { low, high }
164}
165
166type Level = { pct: number; bucket: Bucket }
167
168const levelsOf = (w: WindowReadings): Level[] => Object.entries(w.byPct).map(([key, bucket]) => ({ pct: Number(key), bucket }))
169
170/* The dollars at which the share crossed into `level`, when the level one step below was
171   read before it: the midpoint of the gap less half the slack, uniform over the gap plus
172   the slack. Keys of fine levels are not exact, hence the tolerance. */
173function crossingOf(levels: readonly Level[], level: Level, resolution: number) {
174  const lower = levels.find(l => Math.abs(l.pct - (level.pct - resolution)) < resolution / 1000)
175  if (!lower) return undefined
176  const gap = level.bucket.minUsd - lower.bucket.maxUsd
177  if (gap < 0) return undefined
178  const slack = Math.max(level.bucket.minSlack ?? 0, lower.bucket.maxSlack ?? 0)
179  return { usd: (lower.bucket.maxUsd + level.bucket.minUsd) / 2 - slack / 2, width: gap + slack }
180}
181
182/* Dollars uniform over usdWidth, the share at s with the given variance and shift. */
183function evidenceAt(pct: number, usd: number, usdWidth: number, share: { s: number; variance: number; shift: number }, omega: number) {
184  const { s } = share
185  if (!(s > 0) || !(usd > 0)) return undefined
186  const variance = share.variance / (s * s) + usdWidth ** 2 / (12 * usd * usd) + omega ** 2 * Math.max(0, 1 / s - 1 / 100)
187  return { logA: Math.log((100 * usd) / s), variance, shift: share.shift, share: s, pct }
188}
189
190/** log A and its variance from the newest level of a window, the level with the latest
191    reading. A level at or above 100 is used only through its crossing, since spending
192    can continue past the limit; without one the next newest level is used. Level 0 is
193    never used, since it bounds the share only from above. */
194export function evidenceOf(w: WindowReadings, resolution: number, rounding: Rounding, omega = 0): Evidence | undefined {
195  const levels = levelsOf(w)
196  const newest = [...levels].sort((a, b) => b.bucket.maxAt - a.bucket.maxAt)
197  for (const level of newest) {
198    const crossing = crossingOf(levels, level, resolution)
199    if (crossing) {
200      const e = evidenceAt(level.pct, crossing.usd, crossing.width, crossingShareOf(level.pct, resolution, rounding), omega)
201      return e && { ...e, crossingUsd: crossing.usd }
202    }
203    if (level.pct >= 100 || level.pct <= 0) continue
204    const slack = level.bucket.maxSlack ?? 0
205    return evidenceAt(level.pct, level.bucket.maxUsd - slack / 2, slack, levelShareOf(level.pct, resolution, rounding), omega)
206  }
207  return undefined
208}
209
210/** A closed window as a past allowance: its final evidence, with the within-window term
211    of where it stopped, once it reached 10%. */
212export function pastOf(w: WindowReadings, resolution: number, rounding: Rounding, omega: number): PastPoint | undefined {
213  const e = evidenceOf(w, resolution, rounding, omega)
214  if (!e || e.pct < MIN_PAST_SHARE) return undefined
215  return { logA: e.logA, variance: e.variance + e.shift ** 2, at: Date.parse(w.resetsAt) }
216}
217
218const minuteOf = (iso: string) => Math.round(Date.parse(iso) / MINUTE_MS)
219
220/** One past point per window of `kind`, keyed by its reset to the minute: a rejection
221    wins over the window's own readings, the earliest rejection over later ones. */
222export function pastPoints(
223  closed: readonly WindowReadings[],
224  rejections: readonly Rejection[],
225  kind: string,
226  resolution: number,
227  rounding: Rounding,
228  omega: number,
229): PastPoint[] {
230  const byWindow = new Map<number, PastPoint>()
231  for (const w of closed) {
232    if (w.kind !== kind) continue
233    const p = pastOf(w, resolution, rounding, omega)
234    if (p) byWindow.set(minuteOf(w.resetsAt), p)
235  }
236  const rejected = new Map<number, PastPoint>()
237  for (const r of rejections) {
238    if (r.kind !== kind || !(r.usd > 0)) continue
239    const key = minuteOf(r.resetsAt)
240    const at = Date.parse(r.at)
241    const was = rejected.get(key)
242    if (!was || at < was.at) rejected.set(key, { logA: Math.log(r.usd), variance: REJECTION_SD ** 2, at })
243  }
244  for (const [key, p] of rejected) byWindow.set(key, p)
245  return [...byWindow.values()]
246}
247
248export function weightOf(kind: string, ageMs: number) {
249  const halfLife = kind === 'seven_day' ? SEVEN_DAY_HALF_LIFE_MS : FIVE_HOUR_HALF_LIFE_MS
250  return 0.5 ** (Math.max(0, ageMs) / halfLife)
251}
252
253/** The weighted mean of past log allowances; tau², their spread beyond their own
254    measurement variance, shrunk toward the assumed TAU0 with NU0 degrees of freedom; and
255    the predictive variance of the next window's log allowance. A point weighs by its age
256    and by its information q = TAU0² / (TAU0² + v): one whose own variance v is large
257    beside the assumed tau² says little about the allowance or about tau. */
258export function prior(past: readonly PastPoint[], kind: string, now: number) {
259  const points = past.map(p => {
260    const q = TAU0 ** 2 / (TAU0 ** 2 + p.variance)
261    return { x: p.logA, v: p.variance, q, w: weightOf(kind, now - p.at) * q }
262  })
263  let sw = 0
264  let sw2 = 0
265  let swx = 0
266  let swq2 = 0
267  for (const p of points) {
268    sw += p.w
269    sw2 += p.w * p.w
270    swx += p.w * p.x
271    swq2 += p.w * p.q * p.q
272  }
273  const nEff = sw2 > 0 ? (sw * sw) / sw2 : 0
274  const mean = sw > 0 ? swx / sw : 0
275  let raw = 0
276  const denominator = sw > 0 ? sw - sw2 / sw : 0
277  if (denominator > 0) {
278    let ss = 0
279    let noise = 0
280    for (const p of points) {
281      ss += p.w * (p.x - mean) ** 2
282      noise += p.w * p.v * (1 - p.w / sw)
283    }
284    raw = (ss - noise) / denominator
285  }
286  const extra = sw > 0 ? Math.max(0, nEff - 1) * (swq2 / sw) : 0
287  const tau2 = (NU0 * TAU0 ** 2 + extra * Math.max(0, raw)) / (NU0 + extra)
288  let spread = 0
289  for (const p of points) spread += p.w * p.w * (tau2 + p.v)
290  const variance = sw > 0 ? tau2 + spread / (sw * sw) : Infinity
291  return { nEff, mean, tau2, variance, df: NU0 + extra, isUsable: nEff >= 1, isSpreadAssumed: 1 + extra < 3, points: past.length }
292}
293
294/** omega from closed windows that reached 10%: each tick crossed at share s in
295    [5, sEnd/2] gives e = log(U/s) − log(U_end/sEnd), with E[e²] = omega² · (1/s − 1/sEnd).
296    Age-weighted, and shrunk toward OMEGA0 with the weight of NU0 crossings. */
297export function calibrateOmega(
298  closed: readonly WindowReadings[],
299  kind: string,
300  now: number,
301  resolution: number,
302  rounding: Rounding,
303): Spread {
304  let sum = 0
305  let weight = 0
306  let windows = 0
307  for (const w of closed) {
308    const end = evidenceOf(w, resolution, rounding)
309    if (!end || end.pct < MIN_PAST_SHARE) continue
310    const age = weightOf(kind, now - Date.parse(w.resetsAt))
311    const levels = levelsOf(w)
312    let isCounted = false
313    for (const level of levels) {
314      const crossing = crossingOf(levels, level, resolution)
315      if (!crossing || !(crossing.usd > 0)) continue
316      const { s } = crossingShareOf(level.pct, resolution, rounding)
317      if (s < MIN_CROSSING_SHARE || s > end.share / 2) continue
318      const e = Math.log(crossing.usd / s) - (end.logA - Math.log(100))
319      sum += (age * e * e) / (1 / s - 1 / end.share)
320      weight += age
321      isCounted = true
322    }
323    if (isCounted) windows++
324  }
325  return { omega: Math.sqrt((NU0 * OMEGA0 ** 2 + sum) / (NU0 + weight)), weight, windows, isAssumed: windows < MIN_SPREAD_WINDOWS }
326}
327
328/** Student's t quantile by the Cornish-Fisher expansion in 1/df to third order. */
329export function tQuantile(p: number, df: number) {
330  const z = normalQuantile(p)
331  const z3 = z ** 3
332  const z5 = z ** 5
333  const z7 = z ** 7
334  const g1 = (z3 + z) / 4
335  const g2 = (5 * z5 + 16 * z3 + 3 * z) / 96
336  const g3 = (3 * z7 + 19 * z5 + 17 * z3 - 15 * z) / 384
337  return z + g1 / df + g2 / df ** 2 + g3 / df ** 3
338}
339
340/* Acklam's rational approximation; relative error under 1.2e-9. */
341function normalQuantile(p: number) {
342  const a = [-39.69683028665376, 220.9460984245205, -275.9285104469687, 138.357751867269, -30.66479806614716, 2.506628277459239]
343  const b = [-54.47609879822406, 161.5858368580409, -155.6989798598866, 66.80131188771972, -13.28068155288572]
344  const c = [-0.007784894002430293, -0.3223964580411365, -2.400758277161838, -2.549732539343734, 4.374664141464968, 2.938163982698783]
345  const d = [0.007784695709041462, 0.3224671290700398, 2.445134137142996, 3.754408661907416]
346  const tail = (q: number) =>
347    (((((c[0] * q + c[1]) * q + c[2]) * q + c[3]) * q + c[4]) * q + c[5]) / ((((d[0] * q + d[1]) * q + d[2]) * q + d[3]) * q + 1)
348  if (p < 0.02425) return tail(Math.sqrt(-2 * Math.log(p)))
349  if (p > 1 - 0.02425) return -tail(Math.sqrt(-2 * Math.log(1 - p)))
350  const q = p - 0.5
351  const r = q * q
352  return (
353    ((((((a[0] * r + a[1]) * r + a[2]) * r + a[3]) * r + a[4]) * r + a[5]) * q) /
354    (((((b[0] * r + b[1]) * r + b[2]) * r + b[3]) * r + b[4]) * r + 1)
355  )
356}
357
358/* Which rounding the API uses, from closed windows of every subscription: a mode
359   contradicted in at most 10% of at least 5 windows while the other is contradicted in at
360   least half of them. The caller leaves out windows that straddle a regime start; a
361   window that is inconsistent even under the union is ignored here. */
362export function roundingEvidence(windows: readonly WindowReadings[], resolution: number) {
363  /* A reading exactly on a boundary makes low equal high, up to floating-point noise. */
364  const isEmpty = (b: { low: number; high: number }) => b.low > b.high * (1 + 1e-9)
365  let considered = 0
366  let roundConflicts = 0
367  let truncateConflicts = 0
368  for (const w of windows) {
369    if (Object.keys(w.byPct).length < 3) continue
370    const union = bounds(w, resolution, 'union')
371    if (isEmpty(union)) continue
372    considered++
373    const round = bounds(w, resolution, 'round')
374    const truncate = bounds(w, resolution, 'truncate')
375    if (isEmpty(round)) roundConflicts++
376    if (isEmpty(truncate)) truncateConflicts++
377  }
378  let rule: Rounding = 'union'
379  if (considered >= MIN_ROUNDING_WINDOWS) {
380    if (roundConflicts <= 0.1 * considered && truncateConflicts >= 0.5 * considered) rule = 'round'
381    else if (truncateConflicts <= 0.1 * considered && roundConflicts >= 0.5 * considered) rule = 'truncate'
382  }
383  return { considered, roundConflicts, truncateConflicts, rule }
384}
385
386export function inferRounding(windows: readonly WindowReadings[], resolution: number): Rounding {
387  return roundingEvidence(windows, resolution).rule
388}
389
390/** What has been measured for one window: the current window's levels and ticks, and
391    whether each of the within-window spread, the between-window spread and the rounding
392    rule rests on closed windows rather than on an assumption. */
393export function calibrationOf(input: {
394  current?: WindowReadings
395  resolution: number
396  spread: Spread
397  prior: ReturnType<typeof prior>
398  rounding: ReturnType<typeof roundingEvidence>
399}) {
400  const levels = input.current ? levelsOf(input.current) : []
401  const ticks = levels.filter(level => crossingOf(levels, level, input.resolution) !== undefined).length
402  const isWithinMeasured = !input.spread.isAssumed
403  const isBetweenMeasured = !input.prior.isSpreadAssumed
404  const isRoundingKnown = input.rounding.rule !== 'union'
405  const measured = [isWithinMeasured, isBetweenMeasured, isRoundingKnown].filter(Boolean).length
406  return {
407    levels: levels.length,
408    ticks,
409    closedForWithin: input.spread.windows,
410    isWithinMeasured,
411    pastPoints: input.prior.points,
412    pastWeight: Math.round(input.prior.nEff * 10) / 10,
413    isBetweenMeasured,
414    closedForRounding: input.rounding.considered,
415    isRoundingKnown,
416    measured,
417    isCalibrated: measured === 3,
418  }
419}
420
421export type Calibration = ReturnType<typeof calibrationOf>
422
423export function confidenceOf(r: RangeEstimate): Confidence {
424  const h = Math.sqrt(r.high / r.low) - 1
425  return h <= 0.1 ? 'good' : h <= 0.3 ? 'fair' : 'rough'
426}
427
428/* The spend until the reported percent next moves: one tick's dollars less what was spent
429   since the crossing into the current level, or without a crossing, up to one tick. */
430function nextTickOf(a: RangeEstimate, usedUsd: number, resolution: number, crossingUsd: number | undefined) {
431  const tick = (allowance: number) => (allowance / 100) * resolution
432  if (crossingUsd === undefined) return { value: tick(a.value) / 2, low: 0, high: tick(a.high) }
433  const at = (allowance: number) => Math.max(0, tick(allowance) - (usedUsd - crossingUsd))
434  return { value: at(a.value), low: at(a.low), high: at(a.high) }
435}
436
437export function estimate(w: WindowReadings, usedUsd: number, options: EstimateOptions): Estimate | undefined {
438  const { resolution, rounding, past, kind, now, livePercent } = options
439  const spread = options.spread ?? ASSUMED_SPREAD
440  const ev = evidenceOf(w, resolution, rounding, spread.omega)
441  const p = prior(past, kind, now)
442  const isAtLimit = livePercent !== undefined && livePercent >= 100
443  /* Only level 0 read: the prior alone stands in for the evidence. */
444  const isUnticked = Object.keys(w.byPct).every(key => Number(key) <= 0)
445  if (!ev && !(isUnticked && p.isUsable && !isAtLimit)) return undefined
446
447  let m = ev ? ev.logA : p.mean
448  let v = ev ? ev.variance : p.variance
449  let shift = ev ? ev.shift : 0
450  let df = ev ? NU0 + spread.weight : p.df
451  let isPriorContradicted = false
452  if (ev && p.isUsable && !isAtLimit) {
453    const z = Math.max(0, Math.abs(ev.logA - p.mean) - ev.shift) / Math.sqrt(ev.variance + p.variance)
454    isPriorContradicted = z > tQuantile(0.995, p.df)
455    if (!isPriorContradicted) {
456      v = 1 / (1 / p.variance + 1 / ev.variance)
457      m = v * (p.mean / p.variance + ev.logA / ev.variance)
458      shift = (ev.shift * v) / ev.variance
459      df = p.df
460    }
461  }
462  /* The union of the intervals either rounding rule gives. */
463  const h = tQuantile(0.95, df) * Math.sqrt(v) + shift
464  const floor = isAtLimit ? 0 : usedUsd
465  const allowance = {
466    value: Math.max(floor, Math.exp(m)),
467    low: Math.max(floor, Math.exp(m - h)),
468    high: Math.max(floor, Math.exp(m + h)),
469  }
470  const left = isAtLimit
471    ? { value: 0, low: 0, high: 0 }
472    : { value: allowance.value - usedUsd, low: allowance.low - usedUsd, high: allowance.high - usedUsd }
473  const isLive = livePercent !== undefined && !isAtLimit
474  const isSameLevel = ev !== undefined && livePercent !== undefined && Math.abs(ev.pct - livePercent) < resolution / 1000
475  return {
476    allowance,
477    left,
478    readings: Object.keys(w.byPct).length,
479    pastWindows: p.points,
480    pastWeight: Math.round(p.nEff * 10) / 10,
481    isSpreadAssumed: p.isSpreadAssumed,
482    isWithinSpreadAssumed: spread.isAssumed,
483    isPriorContradicted,
484    isPriorOnly: !ev,
485    confidence: confidenceOf(allowance),
486    nextTick: isLive ? nextTickOf(allowance, usedUsd, resolution, isSameLevel ? ev?.crossingUsd : undefined) : undefined,
487  }
488}
489
hooks/plan.ts 238 lines
1/* Plan tracking per subscription: a ledger of the plans the signed-in profile reported,
2   the promotions its cache listed, and the regimes (stretches of constant limits) that
3   decide which readings feed an estimate. Pure: no engine calls, every instant in epoch
4   milliseconds.
5
6   The profile describes only the subscription Claude Code is signed in to now, and is
7   refreshed on Claude Code's own schedule, so a plan change is known only as a range
8   between two profile fetches, and a profile for another subscription says nothing about
9   this one. */
10
11export type Kind = 'five_hour' | 'seven_day'
12
13export type ProfilePromotion = { limit: string | null; text: string; endsAt: string | null; endsLabel: string | null }
14
15/** The helper's `profile` block. */
16export type Profile = {
17  org: string
18  organizationType: string | null
19  rateLimitTier: string | null
20  userRateLimitTier: string | null
21  seatTier: string | null
22  billingType: string | null
23  subscriptionCreatedAt: string | null
24  fetchedAt: string | null
25  fetchedLabel: string | null
26  promotions: ProfilePromotion[]
27}
28
29export type PlanEntry = {
30  fingerprint: string
31  label: string
32  firstSeenAt: number
33  lastSeenAt: number
34  profileFetchedAt?: number
35}
36
37export type Ledger = Record<string, PlanEntry[]>
38
39export type PlanEvent =
40  | { kind: 'unknown'; reason: 'no-profile' | 'other-subscription'; lastKnown?: PlanEntry }
41  | { kind: 'first-seen' }
42  | { kind: 'same' }
43  | { kind: 'changed'; from: PlanEntry; to: PlanEntry; between: [number, number] }
44
45export type RegimeReason = 'first-seen' | 'plan' | 'promotion-start' | 'promotion-end' | 'manual'
46
47export type Regime = { startedAt: number; reason: RegimeReason; kinds?: Kind[]; isUndone?: boolean }
48
49export type Regimes = Record<string, Regime[]>
50
51export type Promotion = {
52  text: string
53  limit: string | null
54  endsAt: number | null
55  endsLabel: string | null
56  firstSeenAt: number
57  isEnded: boolean
58}
59
60export type Promotions = Record<string, Promotion[]>
61
62export type NoticeDraft = { id: string; kind: 'plan-changed' | 'promotion' | 'inferred'; text: string }
63
64const KEPT_ENTRIES = 20
65const KEPT_REGIMES = 50
66
67const timeOf = (iso: string | null) => {
68  const ms = iso ? Date.parse(iso) : NaN
69  return Number.isFinite(ms) ? ms : undefined
70}
71
72export function fingerprint(p: Profile) {
73  return [p.organizationType, p.rateLimitTier, p.userRateLimitTier, p.seatTier, p.subscriptionCreatedAt]
74    .map(v => v ?? '')
75    .join('|')
76}
77
78/* Display names for known values only; anything else is shown verbatim, never guessed. */
79export function planLabel(p: Profile) {
80  const tier = p.rateLimitTier
81  switch (p.organizationType) {
82    case 'claude_max':
83      if (tier && /_max_5x$/.test(tier)) return 'Max 5x'
84      if (tier && /_max_20x$/.test(tier)) return 'Max 20x'
85      return tier ? `Max (${tier})` : 'Max'
86    case 'claude_pro':
87      return 'Pro'
88    case 'claude_team':
89      return p.seatTier ? `Team · ${p.seatTier} seat` : 'Team'
90    case 'claude_enterprise':
91      return 'Enterprise'
92    case 'claude_free':
93      return 'Free'
94    default: {
95      const name = p.organizationType ?? 'unknown plan'
96      return tier ? `${name} (${tier})` : name
97    }
98  }
99}
100
101const capped = <T>(list: readonly T[], max: number) => list.slice(Math.max(0, list.length - max))
102
103export function observePlan(ledger: Ledger, profile: Profile | null | undefined, sessionOrg: string, now: number) {
104  const entries = ledger[sessionOrg] ?? []
105  const prev = entries[entries.length - 1]
106  if (!profile || profile.org !== sessionOrg) {
107    const event: PlanEvent = { kind: 'unknown', reason: profile ? 'other-subscription' : 'no-profile', lastKnown: prev }
108    return { ledger, event }
109  }
110
111  const fetchedAt = timeOf(profile.fetchedAt)
112  const entry: PlanEntry = {
113    fingerprint: fingerprint(profile),
114    label: planLabel(profile),
115    firstSeenAt: now,
116    lastSeenAt: now,
117    profileFetchedAt: fetchedAt,
118  }
119  if (!prev) {
120    const event: PlanEvent = { kind: 'first-seen' }
121    const regime: Regime = { startedAt: timeOf(profile.subscriptionCreatedAt) ?? 0, reason: 'first-seen' }
122    return { ledger: { ...ledger, [sessionOrg]: [entry] }, event, regime }
123  }
124  if (prev.fingerprint === entry.fingerprint) {
125    const raised = fetchedAt !== undefined && (prev.profileFetchedAt === undefined || fetchedAt > prev.profileFetchedAt)
126    const updated: PlanEntry = { ...prev, lastSeenAt: now, profileFetchedAt: raised ? fetchedAt : prev.profileFetchedAt }
127    const event: PlanEvent = { kind: 'same' }
128    return { ledger: { ...ledger, [sessionOrg]: [...entries.slice(0, -1), updated] }, event }
129  }
130
131  /* The last moment the server confirmed the old plan, and the first it reported the new. */
132  const isOrdered =
133    prev.profileFetchedAt !== undefined && fetchedAt !== undefined && prev.profileFetchedAt <= fetchedAt
134  const between: [number, number] = isOrdered ? [prev.profileFetchedAt!, fetchedAt!] : [prev.lastSeenAt, now]
135  const event: PlanEvent = { kind: 'changed', from: prev, to: entry, between }
136  const regime: Regime = { startedAt: between[1], reason: 'plan' }
137  return { ledger: { ...ledger, [sessionOrg]: capped([...entries, entry], KEPT_ENTRIES) }, event, regime }
138}
139
140export function addRegimes(regimes: Regimes, org: string, added: readonly Regime[]): Regimes {
141  if (added.length === 0) return regimes
142  return { ...regimes, [org]: capped([...(regimes[org] ?? []), ...added], KEPT_REGIMES) }
143}
144
145const appliesTo = (r: Regime, kind: Kind) => !r.isUndone && (!r.kinds || r.kinds.includes(kind))
146
147/** The regime in force for one window kind: the latest start that is not undone. */
148export function currentRegime(regimes: Regimes, org: string, kind: Kind) {
149  let found: Regime | undefined
150  for (const r of regimes[org] ?? []) if (appliesTo(r, kind) && (!found || r.startedAt > found.startedAt)) found = r
151  return found
152}
153
154export function regimeStart(regimes: Regimes, org: string, kind: Kind) {
155  return currentRegime(regimes, org, kind)?.startedAt ?? 0
156}
157
158export function startOver(regimes: Regimes, org: string, now: number) {
159  return addRegimes(regimes, org, [{ startedAt: now, reason: 'manual' }])
160}
161
162/* Only a reset that is still the latest regime can be undone: plan and promotion regimes
163   are observations, not choices. */
164export function undoStartOver(regimes: Regimes, org: string) {
165  const list = regimes[org] ?? []
166  let at = list.length - 1
167  while (at >= 0 && list[at]?.isUndone) at--
168  if (list[at]?.reason !== 'manual') return { regimes, isUndone: false }
169  const next = list.map((r, i) => (i === at ? { ...r, isUndone: true } : r))
170  return { regimes: { ...regimes, [org]: next }, isUndone: true }
171}
172
173const promoKinds = (limit: string | null): Kind[] | undefined =>
174  limit === 'five_hour' || limit === 'seven_day' ? [limit] : undefined
175
176const promoText = (p: { text: string; endsLabel: string | null }) =>
177  `Promotion in effect: ${p.text}${p.endsLabel ? ` — ends ${p.endsLabel}` : ''}.`
178
179export function observePromotions(stored: Promotions, profile: Profile | null | undefined, sessionOrg: string, now: number) {
180  const regimes: Regime[] = []
181  const notices: NoticeDraft[] = []
182  /* The cache belongs to whichever subscription is signed in. */
183  if (!profile || profile.org !== sessionOrg) return { stored, regimes, notices }
184
185  const cached = profile.promotions ?? []
186  const toPromotion = (p: ProfilePromotion): Promotion => ({
187    text: p.text,
188    limit: p.limit,
189    endsAt: timeOf(p.endsAt) ?? null,
190    endsLabel: p.endsLabel,
191    firstSeenAt: now,
192    isEnded: false,
193  })
194  const was = stored[sessionOrg]
195  if (!was) {
196    const list = cached.map(toPromotion)
197    for (const p of list) notices.push({ id: `promo:${sessionOrg}:${p.text}`, kind: 'promotion', text: promoText(p) })
198    return { stored: { ...stored, [sessionOrg]: list }, regimes, notices }
199  }
200
201  const next: Promotion[] = []
202  const texts = new Set(cached.map(p => p.text))
203  for (const p of was) {
204    let kept: Promotion | undefined = p
205    if (!p.isEnded && p.endsAt !== null && p.endsAt <= now) {
206      kept = { ...p, isEnded: true }
207      regimes.push({ startedAt: p.endsAt, reason: 'promotion-end', kinds: promoKinds(p.limit) })
208      notices.push({ id: `promo-end:${sessionOrg}:${p.text}`, kind: 'promotion', text: `Promotion ended: ${p.text}.` })
209    }
210    if (!texts.has(p.text)) {
211      /* Missing from the cache is no evidence that it ended: keep it until its end date. */
212      if (p.endsAt === null) {
213        kept = undefined
214        notices.push({
215          id: `promo-gone:${sessionOrg}:${p.text}`,
216          kind: 'promotion',
217          text: 'A promotion is no longer listed. If your limits changed, run /usage-dollars reset.',
218        })
219      } else if (kept!.isEnded) kept = undefined
220    }
221    if (kept) next.push(kept)
222  }
223  const known = new Set(was.map(p => p.text))
224  for (const c of cached) {
225    if (known.has(c.text)) continue
226    const p = toPromotion(c)
227    next.push(p)
228    regimes.push({ startedAt: now, reason: 'promotion-start', kinds: promoKinds(p.limit) })
229    notices.push({ id: `promo:${sessionOrg}:${p.text}`, kind: 'promotion', text: promoText(p) })
230  }
231  return { stored: { ...stored, [sessionOrg]: next }, regimes, notices }
232}
233
234/** The "in effect" lines for a subscription's stored promotions that have not ended. */
235export function activePromotions(stored: Promotions, org: string) {
236  return (stored[org] ?? []).filter(p => !p.isEnded).map(promoText)
237}
238
types/index.d.ts 130 lines
1export type ModelSpend = { model: string; usd: number; requests: number }
2
3export type RangeReport = { value: number; low: number; high: number }
4
5export type Confidence = 'good' | 'fair' | 'rough'
6
7export type WindowReport = {
8  title: string
9  short: string
10  usedUsd: number
11  requests: number
12  resetLong: string
13  resetIn: string
14  left?: RangeReport
15  allowance?: RangeReport
16  confidence?: Confidence
17  nextTick?: RangeReport
18  pastWeight?: number
19  isPriorContradicted?: boolean
20  /** The spread of allowances between windows is assumed: under 3 effective past windows. */
21  isSpreadAssumed?: boolean
22  /** The percent the limit reported, the freshest any session received. */
23  percent?: number
24  /** Minutes since that percent was received. */
25  percentAgeMinutes?: number
26  basis: string
27  /** Both spreads and the rounding rule rest on closed windows. */
28  isCalibrated?: boolean
29}
30
31export type PlanReport = {
32  label?: string
33  asOfLabel?: string
34  isStale: boolean
35  isUnknown: boolean
36  /** "Max 5x on Mon 5 Oct, 10:23": the plan last seen for this subscription. */
37  lastKnown?: string
38  /** Set only while the plan is unknown: whether any profile was found at all. */
39  unknownReason?: 'no-profile' | 'other-subscription'
40}
41
42export type UsageReport = {
43  type: 'windows'
44  plan: PlanReport
45  notices: string[]
46  last24hUsd?: number
47  sinceResetLabel?: string
48  windows: WindowReport[]
49  byModel: ModelSpend[]
50  byModelTitle: string
51  notes: string[]
52  files: number
53  ms: number
54}
55
56export type DaySpend = { date: string; label: string; usd: number; requests: number }
57
58export type SpendReport = {
59  type: 'report'
60  fromLabel: string
61  toLabel: string
62  usedUsd: number
63  requests: number
64  byDay: DaySpend[]
65  byModel: ModelSpend[]
66  notes: string[]
67}
68
69export type CheckItem = { label: string; state: 'ok' | 'fail' | 'info'; text: string }
70
71export type CheckReport = {
72  type: 'check'
73  items: CheckItem[]
74}
75
76/** What has been measured for one window. */
77export type CalibrationWindow = {
78  title: string
79  /** Percent levels read in the current window, and how many of them with a tick. */
80  levels: number
81  ticks: number
82  /** Closed windows of this kind, since the latest restart, that hold readings. */
83  closedWindows: number
84  /** Closed windows that gave the within-window spread a crossing; measured from 2. */
85  closedForWithin: number
86  isWithinMeasured: boolean
87  /** Past windows behind the prior, rejections included, and their effective weight. */
88  pastPoints: number
89  pastWeight: number
90  isBetweenMeasured: boolean
91  /** Closed windows of every subscription that could show the rounding rule. */
92  closedForRounding: number
93  roundingNeeded: number
94  isRoundingKnown: boolean
95  rounding: 'round' | 'truncate' | 'union'
96  /** Of the three above. */
97  measured: number
98  isCalibrated: boolean
99  /** Of the current 90% range, in proportion: 0.12 for ±12%. */
100  halfWidth?: number
101}
102
103export type CalibrationReport = {
104  type: 'calibration'
105  windows: CalibrationWindow[]
106}
107
108/** A guide: its title, and its Markdown in sections that each fit one Markdown element. */
109export type GuideReport = {
110  type: 'guide'
111  title: string
112  sections: string[]
113}
114
115/** No reading stored yet: what the first run explains, and the command to run again. */
116export type WaitingReport = {
117  type: 'waiting'
118  command: string
119  /** The card offers to send the check message: no check is in flight in this session. */
120  canSend: boolean
121  /** Signed in without a subscription: no reading will ever arrive. */
122  isUnsubscribed?: boolean
123}
124
125declare module 'claude-code' {
126  interface PluginState {
127    'usage-dollars': { reports: Record<string, UsageReport | SpendReport | CheckReport | CalibrationReport | GuideReport | WaitingReport> }
128  }
129}
130