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…

Mods for Claude Code by Hyvän Mielen Pelit, written as function-hook plugins. The repository is also a plugin marketplace.
| Mod | What it does |
|---|---|
| usage-dollars | Subscription 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. |
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>.
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.
| Command | Shows |
|---|---|
/usage-dollars | The window card: plan, notices, each window's allowance first, then used, left, the next tick, and spending per model |
/usage-dollars help | The quick start: setup, everyday commands, good habits |
/usage-dollars help advanced | The full guide: every command, reading the card, calibration, the setup check, troubleshooting |
/usage-dollars report | Spending in the last 24 hours, per day and per model |
/usage-dollars 24h, /usage-dollars 7d | Spending in the last N hours or days, up to 90 days |
/usage-dollars today | Spending since local midnight |
/usage-dollars 2026-10-01 | Spending on one local day |
/usage-dollars 2026-10-01..2026-10-05 | Spending over local days, both inclusive |
/usage-dollars reset | Restarts the estimates for this subscription (see below) |
/usage-dollars reset undo | Undoes the latest reset |
/usage-dollars check | A 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 calibrate | What 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.
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.
/usage-dollars check and fix every ✗./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./usage-dollars reset; calibration starts again from the next window. A change of the price table does the same.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:
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.
/usage-dollars resetUse 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.
| Data | After reset |
|---|---|
| Closed windows' readings and rate-limit rejections from before the reset | No 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 reset | Excluded. The estimate rebuilds from readings taken after it; the first scan after it takes one at once. |
| The current window's used dollars | Unchanged: they are a fact about spending, not about the limit. |
| The plan ledger, spending reports, the 24-hour figure, per-model tables | Unchanged. |
| Stored data | Nothing is deleted. Readings stay until the normal 28-day pruning. |
| Other subscriptions | Unaffected. |
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.
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.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.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 used | Range, spread measured | Range, 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.
scripts/usage-cost.mjs. A model missing there is reported as unpriced, never guessed; add new models as they ship.PATH; the transcript scan runs as a child process.| Path | Role |
|---|---|
.claude-plugin/plugin.json | Manifest |
QUICKSTART.md | Setup, everyday commands and good habits in brief; shown by /usage-dollars help |
GUIDE.md | Commands, the card, calibration, the setup check, best practices and troubleshooting; shown by /usage-dollars help advanced |
hooks/register.tsx | Hooks: events, the /usage-dollars command forms, toasts |
hooks/measure.ts | The scan, stored readings and plan history per subscription, the estimates |
hooks/estimate.ts | Allowance estimate, its 90% range, rounding inference, next tick |
hooks/plan.ts | Plan ledger, promotions, regimes, reset and undo |
hooks/card.tsx | The window, report, check, calibration and guide cards, their Markdown fallbacks, the status line |
scripts/usage-cost.mjs | Transcript scan, pricing, the profile, date labels (Node) |
types/index.d.ts | Type contract for the mod's session state |
tests/*.test.ts | Unit tests: claude plugin test usage-dollars (see below) |
tests/helper.test.mjs | Helper tests: node --test usage-dollars/tests/helper.test.mjs |
tests/coverage.test.mjs | Coverage 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.
hooks/register.tsx 240 lines1import { 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}
240hooks/card.tsx 691 lines1/* 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}
691hooks/measure.ts 987 lines1/* 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}
987hooks/probe.ts 20 lines1/* 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}
20hooks/estimate.ts 489 lines1/* 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}
489hooks/plan.ts 238 lines1/* 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}
238types/index.d.ts 130 lines1export 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