A video-game health bar above the prompt showing how much usage is left

A Claude Code mod that draws a video-game health bar above the prompt for each usage limit your account has.
In the terminal:
♥ 5H ████████████░░░░░░░░ 62% left · resets in 2h 14m · lasts past reset ♡ 7D ███████░░░░░░░░░░░░░ 34% left · resets in 4d · empty in ~2d 9h at this pace $0.30 this session
On an Enterprise seat with a monthly spend limit it draws a SPEND bar instead, an estimate shown as a range:
♥ SPEND ████████████████▒░░░ 82–85% left · $246–255 left of $300 $0.27 this session (~$0.26 billed) · lasts past reset · resets in 12d · synced 5d 2h ago (Oct 14 08:43)
five_hour and seven_day (Pro, Max), a gateway's spend_limit, or the SPEND estimate on Enterprise♥ above 50% left and ♡ below. Both become a skull (☠) at 0%; on SPEND the skull waits until even the hopeful end of the range is spentempty in ~1h 10m at this pace), or lasts past reset when it will not run out first. On SPEND it comes from the last week's billed spend when there is one, and sits on the line beneath$0.30 this session), then as of 14m ago once 5H/7D figures are ten minutes old100% left · window reset, with reset windows refill on your next reply beneath♥ HP waiting for the first reply, or ♥ HP no usage limit reported · … enter what Settings › Usage shows: /hp <spent> <limit>; an API-key, Bedrock, Vertex or Foundry session with no limit entered draws no row█ cells and ♥/♡On Pro, Max and gateway accounts Claude Code reports the limits itself and the bars just work. Everything below is about the Enterprise SPEND bar.
Claude Code does not tell plugins what an Enterprise seat has really spent; that figure lives only in Settings › Usage of the Claude desktop app (or claude.ai), which reads like $47.53 of $300.00 spent. Type those two figures:
/hp 47.53 300 what Settings › Usage says you have spent, and your limit /hp 300 the limit alone, only at the very start of a month (assumes $0 spent) /hp off clear the limit (see "What it keeps" below)
/hp also takes $47.53, 47,53 and 1,234.50. The first entry's reply also points you to the sync file below, which lets a figure arrive without typing.
That is the whole setup. From then on every session adds what it costs, and each re-entry of /hp tightens the estimate. To have the figure arrive by itself, see the sync file.
Line one is the money: the bar, the percent and the dollar range against your limit. The default style counts what is left (82–85% left · $246–255 left of $300); /hp style used counts what is spent (15–18% used · $45–54 / $300), the way Settings › Usage reads. Cells the range is sure of are lit; the ones it runs across are half-lit (▒). A range under $1 wide, which happens only at the moment of an entry (the next refresh widens it), shows the figure itself ($252.47 left of $300, or $47.53 / $300 when counting used).
Line two, in order:
| Part | Meaning |
|---|---|
$0.27 this session (bold) | This session's cost as Claude Code counts it, the same figure /usage shows |
(~$0.26 billed) | The same session at the official per-model prices, scaled by the calibration: what it most likely adds to the bill |
lasts past reset / empty in ~9d 4h at this pace | Pace, from the pessimistic end of the range |
re-sync: /hp <spent> 300 (yellow) | A fresh figure would change what the bar says: the range crosses a colour line, is wider than 5% of the limit, or is over a week old |
resets in 12d | When the month turns (UTC; days only when more than two days off) |
synced 5d 2h ago (Oct 14 08:43) | How old the dashboard figure is, and when it was read, once it is an hour old; the range widens with it |
The figure you enter with /hp is exact. What sessions spend after it is counted by the mod and is not, so it is shown as a range around that count.
Claude Code's own session cost over-counts some models: it charges Sonnet 5.5 cache reads at $0.20 per million tokens, while the official price is $0.10. So the mod prices every API call itself, the main thread's and each subagent's, by the model that answered it, at the official rates:
| Model | Input | Output | Cache read | Cache write (1 h) |
|---|---|---|---|---|
| Fable 5.1, Mythos 5.1 | $10 | $50 | $0.25 | $20 |
| Fable 5, Mythos 5 | $10 | $50 | $1.00 | $20 |
| Opus 5.5 | $4 | $20 | $0.20 | $8 |
| Opus 5, 4.8, 4.7, 4.6, 4.5 | $5 | $25 | $0.50 | $10 |
| Sonnet 5.5 | $2 | $10 | $0.10 | $4 |
| Sonnet 5 | $2 | $10 | $0.20 | $4 |
| Sonnet 4.6, 4.5 | $3 | $15 | $0.30 | $6 |
| Haiku 5.5, prompt up to 100K tokens | $0.10 | $0.50 | $0.01 | $0.20 |
| Haiku 5.5, prompt over 100K tokens | $0.50 | $2.50 | $0.05 | $1 |
| Haiku 4.5 | $1 | $5 | $0.10 | $2 |
US dollars per million tokens, from Anthropic's pricing page (platform.claude.com/docs/en/about-claude/pricing) as of 2026-10-13. Claude Code writes the 1-hour cache, so writes are priced at that rate. The only difference measured against Claude Code's own table is the Sonnet 5.5 cache read in bold; Opus and Haiku came out the same, and the bill matched them.
Calls no turn shows (session titles, summaries) and calls to a model not in the table still count: the mod also prices every call at Claude Code's own rates, and whatever Claude Code's total holds beyond that is added as Claude Code priced it. A session the mod joins after it has already spent (a reload, an upgrade) keeps Claude Code's figure for the rest of its life.
What came since the last figure is two things, estimated apart:
The high end allows for unseen spend coming in bursts, not a steady trickle:
Before calibration the count itself is trusted to a tenth above (each call is priced at the official rates; only cache writes it cannot tell apart are in doubt) and a fifth below. The low end counts only what the sessions saw, less their typical shortfall: unseen spend may be none. Colour, heart and pace all follow the pessimistic end: it warns early, and is late as rarely as it can manage. Spend the mod has never seen the like of can still outrun it; only a fresh figure fixes that.
Each time a figure is entered again in the same month (by /hp or a sync-file update), the mod keeps what the sessions counted since the last one beside what the dashboard says was really spent, and the reply says so:
Sessions counted $1.87 since the last entry against $2.06 on the dashboard; from 1 re-entry, the estimate now scales counted spend by 1.00 and adds $0.19 a day the sessions do not see.
The last eight intervals are fitted together as billed = ratio × counted + unseen × days, held toward a ratio of 1 and no unseen spend while few are in hand. Where the two cannot be told apart (one interval, or every interval a day of steady work), the fit leans on the ratio being 1: the sessions price each call at the official rates, so a bill above their count is taken as spend they did not see. An interval under six hours long says nothing of a daily rate, so it informs the ratio alone. The ratio is held between 0.5 and 3. One the sessions counted under $1 for teaches nothing.
The row adds re-sync: /hp <spent> <limit> only when a fresh figure would change what the bar tells you: the range runs across a colour line (50% or 20% left, or the limit itself), it is wider than 5% of the limit, or the figure is over a week old. A range a few dollars wide and clear of the lines is left alone. Once even the low end, which counts only spend the sessions saw, is past a line, the bar is past it, and a figure would not change that.
A figure left in the sync file is entered at the end of a session's first finished turn, not when it opens: until a reply, Claude Code has not said whether the session has limits of its own.
tests/sim.test.tsx drives the mod through forty simulated days (across a month's turn) in sixteen scenarios, each on three seeds (48 runs): steady work, heavy desktop chat, chat on any day, chat at any hour (before the morning sync included), over-priced cache writes, a bill above the count, missed and broken syncs, a sync file updated mid-afternoon, a spiky week, a person who types /hp every few days, a stranger who set it up once, and several sessions at once (a long one, a fresh one each afternoon priced call by call with a subagent, and a subscription seat whose spend must never count), with and without a sync file. One scenario also scores the bar as drawn in idle hours, evenings and weekends. At every turn it holds the range against the true bill and scores coverage, how often and how far the bill ran past the high end, the width, the centre's miss, how often a re-sync was asked for, and the pace's error, and in idle hours how often the bar as drawn was late. Each scenario has floors the estimate must keep (FLOORS: lateness, miss, re-sync rate, coverage, width, pace error): a change that makes any of them worse fails the tests.
What it does not cover yet: billing lag on the dashboard, a person who answers the re-sync prompt (it measures how often it is asked, not what happens next), and real usage, which no simulation stands in for.
Run as many sessions side by side as you like; each one counts only itself, and every bar shows the total.
this session is that session's own cost only./hp in any session resets the count for all of them; the others pick it up on their next turn or redraw. A session still holding an earlier entry never sweeps away what a newer one has started.~/.claude on this one), sessions not running the mod (an older Claude Code, a headless claude -p without it, claude.ai on the web), and sessions that report their own 5H/7D limits, which neither show nor add to SPEND. The next /hp or sync file brings everything back in line, since the dashboard figure includes it all.The mod reads one file, and enters what it finds there as if typed with /hp, calibration included: ~/.claude/state/usage-hp-sync.json (under $CLAUDE_CONFIG_DIR when that is set).
{"spentUsd": 47.69, "limitUsd": 300, "at": 1791914147525}
at is when the figure was read, in epoch milliseconds. The file is used only after you have set a limit with /hp once (so /hp off stays off), only once, and only when it is newer than the figure held and from this month. A figure read at 7:00 and applied at 10:00 loses nothing in between: each session keeps a short trail of its count, so it carries on from where it stood at 7:00, and the calibration compares the dashboard with what had been counted by then.
Anything you trust can write that file, from a figure you copy by hand to a script of your own. The mod only reads it; it never contacts claude.ai or any other service itself. Feed it only from sources your account's terms allow.
Pick how the bars look; the choice is kept for every session on the machine.
/hp style bar a bar that drains from full: 94% left · $282.02 left of $300 (the default) /hp style used a bar that fills as you spend: 6% used · $17.98 / $300 /hp style hearts ten hearts at 2.5% a quarter (the desktop draws quarters; the terminal dims a part-full heart) /hp style text the figures alone, no bar
An open session picks up a new style after its next reply. /hp hide turns the row off and /hp show brings it back; usage is still tracked while hidden.
/hp <spent> <limit> enter the dashboard figure (e.g. /hp 47.53 300) /hp <limit> the limit alone, spend starting at 0 /hp off stop tracking (calibration is kept) /hp style bar|used|hearts|text /hp hide | /hp show
PRICES in hooks/register.tsx, dated 2026-10-13). When Anthropic changes a price or ships a model, update it there; until then a new model is counted through Claude Code's own figure./hp off clears the limit and the sessions' counts and stops the daily ledger; the calibration (eight intervals) and eight days of the resync log stay for the next limit, and each call's price is still kept for the session's own cost line. The ledger and log are pruned when a session starts, so a session left open for weeks holds older entries until the next one opens.Requires a Claude Code version with mods: the docs say v2.1.287 or later; seen working in the desktop app on 2.1.284 through 2.1.293.
From the zip (no repository needed):
~/.claude/mods/usage-hp (the folder holding this README).~/.claude/settings.json add "env": { "CLAUDE_CODE_PLUGIN_DIRS": "~/.claude/mods/usage-hp" }
(several paths are separated by :, or ; on Windows), then start a new session. For one terminal session only: claude --plugin-dir ~/.claude/mods/usage-hp.
claude plugin validate ~/.claude/mods/usage-hp, then on Enterprise type /hp <spent> <limit> once (see Enterprise setup).From a clone of the repository holding this folder: copy or link the cloned usage-hp folder to ~/.claude/mods/usage-hp and follow steps 2 and 3 above, or see the top-level README's install section, which loads several mods at once. The .claude-plugin/marketplace.json lives in this usage-hp folder, not at the repository root, so a marketplace added from the repository root will not find it.
It stacks with other mods that draw in the same band, in CLAUDE_CODE_PLUGIN_DIRS order: the first mod listed draws at the bottom of the band, the last at the top.
The mod keeps a small database of its own in the background; nothing shows it and there is no command for it.
/hp or the sync file, and (for a same-month re-entry) what the sessions counted against what the dashboard said. Eight days are kept; /hp off leaves it.empty in ~12d at this pace) comes, first, from the resync log: the first and last dashboard figures this month within the week, at least a day apart. Those are billed dollars, spend the mod never sees included. Without such a pair it comes from the ledger's week, scaled by the calibration, once the ledger holds a day before today. Failing both, it comes from the last hour.Both are pruned when a session starts.
$127–129 / $300 beside the SPEND label read as spent. They now read $127–129 left of $300; /hp style used keeps $171–173 / $300, which counts the spend.session.measure when a plan limit's percent moves a whole point (or a window appears or leaves); the 5H/7D bars now re-read then, not only when this session's turn ends. A measure of cost or context alone waits for the turn end./hp entry and each step's ledger write run one after the other, so a refresh that finds a subscription seat mid-turn clears the ledger before or after a step's write, never in the middle. A refresh that fails hands on to the next. A measure that reports the limits the last refresh already read is skipped, so a turn end adds one pace sample, not two./hp off in another session still clears the row on the next redraw.tests/mutate.py (new): a clean baseline first, then one mutant per guard above.hooks/register.tsx 1717 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import type {
4 UsageHpBudget,
5 UsageHpBasis,
6 UsageHpCalibration,
7 UsageHpCost,
8 UsageHpPriced,
9 UsageHpLedger,
10 UsageHpOthers,
11 UsageHpReading,
12 UsageHpSample,
13 UsageHpStyle,
14 UsageHpSync,
15} from '../types'
16
17const CELLS = 20
18const HEART =
19 'M8 14.6 L1.7 8.3 A3.85 3.85 0 0 1 8 3.4 A3.85 3.85 0 0 1 14.3 8.3 Z'
20// The quadrants a heart keeps as it drains: top-right goes first, top-left last.
21const QUADRANTS = [
22 '<rect x="0" y="0" width="8" height="8"/>',
23 '<rect x="0" y="8" width="8" height="8"/>',
24 '<rect x="8" y="8" width="8" height="8"/>',
25 '<rect x="8" y="0" width="8" height="8"/>',
26]
27const TICK = 60_000
28const READING = { plugin: 'usage-hp', key: 'reading' } as const
29const COUNTED = { plugin: 'usage-hp', key: 'counted' } as const
30const COSTS = { plugin: 'usage-hp', key: 'costs' } as const
31const BASIS = { plugin: 'usage-hp', key: 'basis' } as const
32const COLORS = { plugin: 'usage-hp', key: 'colors' } as const
33const SAMPLES = { plugin: 'usage-hp', key: 'samples' } as const
34const SEEN = { plugin: 'usage-hp', key: 'seen' } as const
35const LEDGER = { plugin: 'usage-hp', key: 'ledger' } as const
36const OTHERS = { plugin: 'usage-hp', key: 'others' } as const
37const SHOWN = { plugin: 'usage-hp', key: 'style' } as const
38// The style chosen with /hp style, kept in $.store for every session.
39const STYLE = 'style'
40const STYLES: readonly UsageHpStyle[] = ['bar', 'used', 'hearts', 'text']
41const HIDDEN = { plugin: 'usage-hp', key: 'hidden' } as const
42// Whether the session bills an API key or a cloud provider directly: no
43// Settings › Usage page to enter, so no setup line is drawn.
44const METERED = { plugin: 'usage-hp', key: 'metered' } as const
45// Whether /hp hide stands, kept in $.store for every session.
46const HIDE = 'hidden'
47// The hearts style: ten hearts of four quarters, a quarter is 2.5%.
48const HEARTS = 10
49// How long the other sessions' total is trusted before the store is read again.
50const OTHERS_TTL = 2 * 60_000
51// The pace is read over the last hour, once five minutes of it are in hand.
52const PACE_WINDOW = 60 * 60_000
53const PACE_MIN = 5 * 60_000
54// A reading older than this says how old it is.
55const STALE = 10 * 60_000
56// What $.store keeps across sessions: the limit as entered, under `budget`,
57// and beside it one key per session holding what that session has added.
58// Each session writes its own key alone, so two finishing at once lose nothing.
59const BUDGET = 'budget'
60const SPEND = 'spend:'
61// Beside each session's key, its count at recent turns: what it had added by
62// the moment a dashboard figure was read, which a sync file applies later.
63const TRAIL = 'trail:'
64// Each re-entry of /hp keeps what the sessions counted beside what the
65// dashboard said, one key per interval: the spend range learns from them.
66const CALIB = 'calib:'
67const CALIB_KEEP = 8
68// An interval the sessions counted less than this for says nothing of a ratio.
69const CALIB_MIN = 1
70// The range's half-width, as a share of what the sessions counted since the
71// entry: a guess until three re-entries are in hand, then what they showed.
72// Before calibration, what the sessions count is still priced call by call
73// at the official rates: a tenth above covers the cache writes it cannot
74// tell apart (the rest of the doubt is unseen spend's), a fifth below the
75// writes it prices at the hour's rate that were the five minutes'.
76const SPREAD = 0.1
77const SPREAD_BELOW = 0.2
78const SPREAD_MIN = 0.15
79// Spend the mod never sees (the desktop chat, a session without the mod) only
80// ever adds: the high end grows by a rate a day learned from the re-entries,
81// never less than this.
82const DRIFT_PER_DAY = 0.25
83// An interval shorter than this says too little of a daily rate to learn from.
84const UNSEEN_MIN_SPAN = 6 * 60 * 60_000
85// The hours a day's unseen spend is taken to land in.
86const UNSEEN_HOURS = 10
87// The unseen allowance a day, as a share of the limit, with 0 and 1 re-entries in hand.
88const COLD_SHARE = [0.02, 0.01]
89// How far the learned ratio may run: past 1.5 was flattened silently before.
90const RATIO_MIN = 0.5
91const RATIO_MAX = 3
92const DAY = 24 * 60 * 60_000
93// When to ask for a fresh figure: an entry this old, or a range this wide.
94const RESYNC_AGE = 7 * DAY
95const RESYNC_WIDTH = 0.05
96
97/**
98 * Whether a fresh figure is worth asking for: it is over a week old, the
99 * range runs across a colour line (a figure would say which side), or it is
100 * wider than a twentieth of the limit. A range that is merely a few dollars
101 * wide and clear of the lines changes nothing the bar says.
102 */
103const needsResync = (budget: UsageHpBudget, age: number): boolean => {
104 // The low end counts only what the sessions saw: once it is past a line
105 // the bar really is past it, and a fresh figure would not change that.
106 // While the range runs across one (the limit itself included), it would.
107 const most = ((budget.limitUsd - budget.lowUsd) / budget.limitUsd) * 100
108 const least = ((budget.limitUsd - budget.highUsd) / budget.limitUsd) * 100
109 const across = [50, 20, 0].some(line => least <= line && most > line)
110
111 return age > RESYNC_AGE || across || budget.highUsd - budget.lowUsd > budget.limitUsd * RESYNC_WIDTH
112}
113// Each agent's calls priced at the official rates, one key per agent of a
114// session: an agent's calls come one after another, so no write is lost.
115const PRICED = 'priced:'
116// Keys of sessions that have not written for this long are pruned.
117const PRICED_TTL = 3 * DAY
118// The ledger, the mod's own and never shown: what each session's calls cost
119// on each local day, one key per session and day (`day:<YYYY-MM-DD>:<session>`),
120// so no two sessions share a write. An entry from the dashboard leaves it
121// alone. A week is kept: the spend pace reads it.
122const DAY_KEY = 'day:'
123const LEDGER_DAYS = 7
124// The resync log: one key per figure entered (`sync:<at>`), a week of them.
125const SYNC_KEY = 'sync:'
126const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
127
128/** The local calendar day of `ms`, as `YYYY-MM-DD`. */
129const dayOf = (ms: number): string => {
130 const d = new Date(ms)
131
132 return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
133}
134
135/** `08:43` on the same local day as `now`, else `Oct 13 08:43`; no `now`, always the date. */
136const stamp = (ms: number, now?: number): string => {
137 const d = new Date(ms)
138 const time = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
139
140 return now !== undefined && dayOf(ms) === dayOf(now) ? time : `${MONTHS[d.getMonth()]} ${d.getDate()} ${time}`
141}
142
143/** Local midnight at the start of `day` (`YYYY-MM-DD`), ms. */
144const midnightOf = (day: string): number =>
145 new Date(Number(day.slice(0, 4)), Number(day.slice(5, 7)) - 1, Number(day.slice(8, 10))).getTime()
146
147/**
148 * The official prices, US dollars per million tokens, from Anthropic's pricing
149 * page (platform.claude.com/docs/en/about-claude/pricing, 2026-10-13):
150 * input, output, cache read, and a 1-hour cache write (the TTL Claude Code
151 * writes). First match wins, so a longer id comes before its prefix.
152 */
153type Price = { input: number; output: number; read: number; write: number }
154
155const PRICES: readonly (readonly [string, Price, Price?])[] = [
156 ['fable-5-1', { input: 10, output: 50, read: 0.25, write: 20 }],
157 ['mythos-5-1', { input: 10, output: 50, read: 0.25, write: 20 }],
158 ['fable-5', { input: 10, output: 50, read: 1, write: 20 }],
159 ['mythos-5', { input: 10, output: 50, read: 1, write: 20 }],
160 ['opus-5-5', { input: 4, output: 20, read: 0.2, write: 8 }],
161 ['opus-5', { input: 5, output: 25, read: 0.5, write: 10 }],
162 ['opus-4-8', { input: 5, output: 25, read: 0.5, write: 10 }],
163 ['opus-4-7', { input: 5, output: 25, read: 0.5, write: 10 }],
164 ['opus-4-6', { input: 5, output: 25, read: 0.5, write: 10 }],
165 ['opus-4-5', { input: 5, output: 25, read: 0.5, write: 10 }],
166 ['sonnet-5-5', { input: 2, output: 10, read: 0.1, write: 4 }],
167 ['sonnet-5', { input: 2, output: 10, read: 0.2, write: 4 }],
168 ['sonnet-4-6', { input: 3, output: 15, read: 0.3, write: 6 }],
169 ['sonnet-4-5', { input: 3, output: 15, read: 0.3, write: 6 }],
170 // Haiku 5.5 has two rate cards: a prompt over 100,000 tokens pays the second.
171 [
172 'haiku-5-5',
173 { input: 0.1, output: 0.5, read: 0.01, write: 0.2 },
174 { input: 0.5, output: 2.5, read: 0.05, write: 1 },
175 ],
176 ['haiku-4-5', { input: 1, output: 5, read: 0.1, write: 2 }],
177]
178
179// Where Claude Code's own price table differs from the official one, as
180// measured on 2026-10-13: it charges Sonnet 5.5 cache reads at $0.20.
181const ENGINE_PRICES: readonly (readonly [string, Price, Price?])[] = PRICES.map(row =>
182 row[0] === 'sonnet-5-5' ? ([row[0], { ...row[1], read: 0.2 }] as const) : row,
183)
184
185/** What one call cost at the official prices, or null for a model not listed. */
186const priceOf = (
187 usage: {
188 model: string
189 input_tokens: number
190 output_tokens: number
191 cache_read_input_tokens: number
192 cache_creation_input_tokens: number
193 },
194 table: readonly (readonly [string, Price, Price?])[] = PRICES,
195): number | null => {
196 const found = table.find(([id]) => usage.model.includes(id))
197
198 if (found === undefined) {
199 return null
200 }
201
202 const prompt =
203 usage.input_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens
204 const rate = prompt > 100_000 && found[2] !== undefined ? found[2] : found[1]
205
206 return (
207 (usage.input_tokens * rate.input +
208 usage.output_tokens * rate.output +
209 usage.cache_read_input_tokens * rate.read +
210 usage.cache_creation_input_tokens * rate.write) /
211 1_000_000
212 )
213}
214
215const isPriced = (value: unknown): value is UsageHpPriced =>
216 typeof value === 'object' &&
217 value !== null &&
218 Number.isFinite((value as UsageHpPriced).usd) &&
219 Number.isFinite((value as UsageHpPriced).at)
220
221/**
222 * What this session's agents have cost at the official prices; other
223 * sessions' keys gone quiet for days are pruned on the way.
224 */
225const pricedOf = async (
226 $: EngineInterface,
227 session: string,
228 now: number,
229): Promise<{ usd: number; engine: number }> => {
230 let usd = 0
231 let engine = 0
232
233 for (const key of await $.store.keys()) {
234 if (!key.startsWith(PRICED)) {
235 continue
236 }
237
238 const value = await $.store.get(key)
239
240 if (key.startsWith(`${PRICED}${session}:`)) {
241 usd += isPriced(value) ? value.usd : 0
242 engine += isPriced(value) ? (value.engine ?? 0) : 0
243 } else if (!isPriced(value) || now - value.at > PRICED_TTL) {
244 await $.store.delete(key)
245 }
246 }
247
248 return { usd, engine }
249}
250
251// A figure read off the dashboard by something else (a scheduled task) and
252// left here as `{ spentUsd, limitUsd, at }`, under the Claude config folder:
253// the next turn enters it as if typed with /hp.
254const SYNC_FILE = 'state/usage-hp-sync.json'
255// How far ahead of this clock a sync file's time may be.
256const SKEW = 10 * 60_000
257
258type StoredBudget = {
259 limitUsd: number
260 /** Spend as entered, for `month`; the sessions' keys add to it. */
261 spentUsd: number
262 month: string
263 /** When it was entered, ms: a new entry starts every session's count over. */
264 epoch?: number
265 /** A sync file's own time, when it ran ahead of this clock: what the next file is held against. */
266 readAt?: number
267}
268
269/** What moved a reading: the session starting, a turn ending, or /hp. */
270type Cause = 'start' | 'turn' | 'command'
271
272const LABELS: Record<string, string> = {
273 five_hour: '5H',
274 seven_day: '7D',
275 spend_limit: 'SPEND',
276}
277
278const until = (ms: number): string => {
279 const minutes = Math.max(0, Math.round(ms / 60_000))
280 const days = Math.floor(minutes / 1440)
281 const hours = Math.floor((minutes % 1440) / 60)
282
283 if (days > 0) {
284 return `${days}d ${hours}h`
285 }
286
287 return hours > 0 ? `${hours}h ${minutes % 60}m` : `${minutes}m`
288}
289
290type Color = 'green' | 'yellow' | 'red'
291const RANK: Record<Color, number> = { green: 0, yellow: 1, red: 2 }
292// How far past a line a bar must climb back before its colour improves.
293const HYSTERESIS = 2
294
295/**
296 * A bar's colour from what is left: worse at once when a line is crossed (warn
297 * early), better only once it is HYSTERESIS points back over it, so a bar
298 * sitting on 50% or 20% does not flicker turn to turn.
299 */
300const colorOf = (left: number, prior?: Color): Color => {
301 const raw: Color = left > 50 ? 'green' : left > 20 ? 'yellow' : 'red'
302
303 if (prior === undefined || RANK[raw] >= RANK[prior]) {
304 return raw
305 }
306
307 const line = raw === 'green' ? 50 : 20
308
309 return left > line + HYSTERESIS ? raw : prior
310}
311
312/**
313 * The spend range read afresh from the store, for a session that has not taken
314 * a turn in a while: what the others counted since moves without it.
315 */
316const freshRange = async ($: EngineInterface, now: number): Promise<UsageHpBudget | null> => {
317 const stored = await $.store.get(BUDGET)
318
319 if (!isStored(stored)) {
320 return null
321 }
322
323 const prefix = `${SPEND}${stored.epoch ?? 0}:${monthOf(now)}:`
324 let tally = 0
325
326 for (const key of await $.store.keys()) {
327 const value = key.startsWith(prefix) ? await $.store.get(key) : null
328 tally += typeof value === 'number' && Number.isFinite(value) ? value : 0
329 }
330
331 return withBurn($, rangeOf(stored, tally, calibrate(await calibrationsOf($)), now), now)
332}
333
334/** A reset far off reads in days alone: hours are noise two days out. */
335const untilReset = (ms: number): string =>
336 ms > 2 * DAY ? `${Math.round(ms / DAY)}d` : until(ms)
337
338const isStyle = (value: unknown): value is UsageHpStyle =>
339 STYLES.includes(value as UsageHpStyle)
340
341/** How many quarter-hearts of the row are left; any health keeps one. */
342const rowQuartersOf = (left: number): number =>
343 left <= 0 ? 0 : Math.max(1, Math.min(HEARTS * 4, Math.ceil(left / 2.5)))
344
345/** The row of hearts with `quarters` quarter-hearts filled, as one SVG. */
346export const heartsSvg = (quarters: number): string => {
347 const hearts = Array.from({ length: HEARTS }, (_, index) => {
348 const kept = Math.max(0, Math.min(4, quarters - index * 4))
349 const empty = `<path d="${HEART}" fill="#3b1d1d" stroke="#7f2a2a" stroke-width="1"/>`
350 const full =
351 kept === 0
352 ? ''
353 : // Named by what it holds: two rows in one document never share an
354 // id with different quarters.
355 `<clipPath id="hq${kept}">${QUADRANTS.slice(0, kept).join('')}</clipPath>` +
356 `<path d="${HEART}" fill="#ef3b36" stroke="#ef3b36" stroke-width="1" clip-path="url(#hq${kept})"/>`
357
358 return `<g transform="translate(${index * 18} 0)">${empty}${full}</g>`
359 })
360
361 return `<svg xmlns="http://www.w3.org/2000/svg" width="${HEARTS * 18}" height="18" viewBox="0 0 ${HEARTS * 18} 18">${hearts.join('')}</svg>`
362}
363
364export const FILLS = { green: '#4fb36b', yellow: '#e0b93a', red: '#ef3b36' }
365
366// The desktop bar: twenty rounded cells, each 6px wide with a 1px gap.
367const CELL_W = 6
368const CELL_GAP = 1
369const BAR_W = CELLS * (CELL_W + CELL_GAP) - CELL_GAP
370const BAR_H = 10
371
372/**
373 * The bar as one SVG: `full` cells lit in `fill`, then `ghost` half-lit cells
374 * where a range is unsure, the rest a faint track.
375 */
376export const barSvg = (full: number, fill: string, ghost = 0): string => {
377 const cells = Array.from({ length: CELLS }, (_, index) => {
378 const x = index * (CELL_W + CELL_GAP)
379
380 if (index < full) {
381 return (
382 `<rect x="${x}" y="0" width="${CELL_W}" height="${BAR_H}" rx="1.5" fill="${fill}"/>` +
383 `<rect x="${x}" y="1" width="${CELL_W}" height="3" rx="1" fill="#fff" fill-opacity="0.22"/>`
384 )
385 }
386
387 const opacity = index < full + ghost ? 0.45 : 0.14
388
389 return `<rect x="${x}" y="0" width="${CELL_W}" height="${BAR_H}" rx="1.5" fill="${fill}" fill-opacity="${opacity}"/>`
390 })
391
392 return `<svg xmlns="http://www.w3.org/2000/svg" width="${BAR_W}" height="${BAR_H}" viewBox="0 0 ${BAR_W} ${BAR_H}">${cells.join('')}</svg>`
393}
394
395/** How many quarters of the heart are left: one per 25%, any health keeps one. */
396const quartersOf = (left: number): number =>
397 left <= 0 ? 0 : Math.max(1, Math.min(4, Math.ceil(left / 25)))
398
399/** The heart with `kept` of its quarters filled, as one SVG document. */
400export const heartSvg = (kept: number, fill: string): string =>
401 '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 18">' +
402 `<path d="${HEART}" fill="none" stroke="${fill}" stroke-opacity="0.55" stroke-width="1.2"/>` +
403 // An id of its own per fill: two rows' hearts may share one document.
404 `<clipPath id="hp${kept}">${QUADRANTS.slice(0, kept).join('')}</clipPath>` +
405 `<path d="${HEART}" fill="${fill}" clip-path="url(#hp${kept})"/>` +
406 '</svg>'
407
408/**
409 * Adds a reading to a bar's samples: a bar that went up started over, and
410 * readings older than the pace window fall away.
411 */
412const sampled = (
413 samples: UsageHpSample[],
414 left: number,
415 now: number,
416): UsageHpSample[] => {
417 const last = samples[samples.length - 1]
418 const kept =
419 last !== undefined && left > last.left
420 ? []
421 : samples.filter(one => now - one.at <= PACE_WINDOW)
422
423 return [...kept, { at: now, left }].slice(-120)
424}
425
426/**
427 * What the pace says: when the bar empties if the last hour's rate holds, or
428 * that it outlasts its reset. Nothing while too little is known, or it is idle.
429 */
430const paceOf = (
431 samples: UsageHpSample[],
432 left: number,
433 now: number,
434 resetIn: number | null,
435): string => {
436 const first = samples[0]
437 const last = samples[samples.length - 1]
438
439 if (first === undefined || last === undefined || left <= 0) {
440 return ''
441 }
442
443 // The rate is the samples' own; `left` may be a range's pessimistic end.
444 const elapsed = now - first.at
445 const drop = first.left - last.left
446
447 if (elapsed < PACE_MIN || drop <= 0) {
448 return ''
449 }
450
451 const emptyIn = (left / drop) * elapsed
452
453 return resetIn !== null && emptyIn >= resetIn
454 ? ' · lasts past reset'
455 : ` · empty in ~${until(emptyIn)} at this pace`
456}
457
458/** A dollar figure for the row: whole dollars as entered, cents when it has them. */
459const dollars = (usd: number): string =>
460 Number.isInteger(usd) ? usd.toFixed(0) : usd.toFixed(2)
461
462const monthOf = (now: number): string => new Date(now).toISOString().slice(0, 7)
463
464const monthEnd = (now: number): number => {
465 const date = new Date(now)
466
467 return Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 1)
468}
469
470/**
471 * The spend range: the dashboard figure is exact, and only what came since is
472 * estimated: the sessions' count scaled by the calibration, plus spend they
473 * never saw at a rate a day. Time runs on an activity clock: a busy morning
474 * has used up more of a typical day than the wall clock says.
475 */
476const rangeOf = (
477 stored: StoredBudget,
478 tally: number,
479 { ratio, unseen, unseenHigh, spreadLow, spreadHigh, perDay, n, long }: Calibration,
480 now: number,
481): UsageHpBudget => {
482 const month = monthOf(now)
483 const isSameMonth = stored.month === month
484 const base = isSameMonth ? stored.spentUsd : 0
485 // A store from before entries kept their time counts from the month's start.
486 const since = Math.max(stored.epoch ?? 0, Date.parse(`${month}-01T00:00:00Z`))
487 // A day's spend lands in its working hours, not spread over all 24: the
488 // allowance for it builds over UNSEEN_HOURS, then waits for the next day.
489 const wall = Math.max(0, now - since) / DAY
490 // Each whole day since, plus the day under way: its allowance builds over
491 // its first UNSEEN_HOURS. Busy counted work moves the clock on too; a binge
492 // day may count as more than one: wider, but warning early is the rule.
493 const whole = Math.floor(wall)
494 const days = Math.max(
495 whole + Math.min(1, ((wall - whole) * 24) / UNSEEN_HOURS),
496 perDay > 0 ? tally / perDay : 0,
497 )
498 const center = base + ratio * tally + unseen * days
499
500 return {
501 limitUsd: stored.limitUsd,
502 baseUsd: base,
503 spentUsd: center,
504 // Unseen spend may be none: the low end counts only what the sessions saw.
505 lowUsd: Math.max(base, base + (ratio - spreadLow) * tally),
506 // Before two re-entries have shown what this seat spends unseen, allow a
507 // share of the limit a day: a stranger's desktop chat is a guess.
508 // Unseen spend comes in bursts, not a steady trickle: from the moment
509 // after a figure, the high end allows at least a whole day's worst. With
510 // nothing measured yet, a share of the limit a day stands in for it: by
511 // days, not their square root, since unseen spend adds up by days.
512 highUsd:
513 base +
514 (ratio + spreadHigh) * tally +
515 Math.max(unseenHigh, (COLD_SHARE[long] ?? 0) * stored.limitUsd) * (now > since ? Math.max(1, days) : 0),
516 resetsAt: monthEnd(now),
517 // When the figure was really read, though the count runs from the month's turn.
518 syncedAt: stored.epoch ?? since,
519 syncs: n,
520 ratio,
521 }
522}
523
524const isStored = (value: unknown): value is StoredBudget =>
525 typeof value === 'object' &&
526 value !== null &&
527 typeof (value as StoredBudget).limitUsd === 'number' &&
528 Number.isFinite((value as StoredBudget).limitUsd) &&
529 (value as StoredBudget).limitUsd > 0 &&
530 typeof (value as StoredBudget).spentUsd === 'number' &&
531 typeof (value as StoredBudget).month === 'string'
532
533/** Removes every session's key: the limit was entered again, or cleared. */
534const forget = async ($: EngineInterface, keep?: string): Promise<void> => {
535 for (const key of await $.store.keys()) {
536 const prefix = key.startsWith(SPEND) ? SPEND : key.startsWith(TRAIL) ? TRAIL : null
537
538 // A session that entered the same figure a moment ago keeps what it wrote.
539 if (prefix !== null && (keep === undefined || !key.startsWith(`${prefix}${keep}`))) {
540 await $.store.delete(key)
541 }
542 }
543}
544
545/** Whether a key's `<epoch>:<month>:…` belongs to an entry or month before `tag`'s. */
546const isOlderTag = (rest: string, tag: string): boolean => {
547 const [epoch = '', month = ''] = rest.split(':')
548 const [nowEpoch = '', nowMonth = ''] = tag.split(':')
549
550 return month < nowMonth || (month === nowMonth && Number(epoch) < Number(nowEpoch))
551}
552
553/**
554 * What the other sessions have added under `tag`, from the store; keys of an
555 * earlier entry or month are removed on the way.
556 */
557const othersOf = async (
558 $: EngineInterface,
559 tag: string,
560 own: string,
561): Promise<number> => {
562 let usd = 0
563
564 for (const key of await $.store.keys()) {
565 const prefix = key.startsWith(SPEND) ? SPEND : key.startsWith(TRAIL) ? TRAIL : null
566
567 if (prefix === null || key === own) {
568 continue
569 }
570
571 if (!key.startsWith(`${prefix}${tag}:`)) {
572 // Only an older entry's or month's keys go: a session still holding an
573 // earlier entry must not sweep away what a newer one has started.
574 if (isOlderTag(key.slice(prefix.length), tag)) {
575 await $.store.delete(key)
576 }
577
578 continue
579 }
580
581 if (prefix === TRAIL) {
582 continue
583 }
584
585 const value = await $.store.get(key)
586 usd += typeof value === 'number' && Number.isFinite(value) ? value : 0
587 }
588
589 return usd
590}
591
592const isCalibration = (value: unknown): value is UsageHpCalibration =>
593 typeof value === 'object' &&
594 value !== null &&
595 Number.isFinite((value as UsageHpCalibration).est) &&
596 Number.isFinite((value as UsageHpCalibration).real)
597
598/** The intervals kept from earlier re-entries, oldest first. */
599const calibrationsOf = async ($: EngineInterface): Promise<UsageHpCalibration[]> => {
600 const kept: UsageHpCalibration[] = []
601
602 for (const key of await $.store.keys()) {
603 if (key.startsWith(CALIB)) {
604 const value = await $.store.get(key)
605
606 // Intervals counted at Claude Code's own prices measured a different
607 // thing: only those counted at the official prices are kept.
608 if (isCalibration(value) && value.basis === 'priced') {
609 kept.push(value)
610 }
611 }
612 }
613
614 return kept.sort((a, b) => a.at - b.at).slice(-CALIB_KEEP)
615}
616
617/**
618 * What the kept intervals say. Each is fitted as `real = ratio × counted +
619 * unseen × days`: billing scales what the sessions count, while spend they
620 * never saw (the desktop chat, a session without the mod) adds dollars a day.
621 * A least-squares fit of both, held toward a ratio of 1 and no unseen spend
622 * while few intervals are in hand. The high end allows the worst excess any
623 * interval showed, as dollars a day; the low end the typical shortfall.
624 */
625type Calibration = {
626 ratio: number
627 unseen: number
628 unseenHigh: number
629 spreadLow: number
630 spreadHigh: number
631 /** What the sessions count on a typical day, for the activity clock; 0 unknown. */
632 perDay: number
633 n: number
634 /** How many intervals were long enough to say a daily rate of unseen spend. */
635 long: number
636}
637
638const calibrate = (intervals: UsageHpCalibration[]): Calibration => {
639 const n = intervals.length
640 const ordered = [...intervals].sort((a, b) => a.at - b.at)
641 // Each interval's length in days, where its start is known.
642 // An interval shorter than UNSEEN_MIN_SPAN says nothing of a daily rate: it
643 // is fitted as all counted spend (no days), so it informs the ratio alone.
644 const spans = ordered.map((one, index) => {
645 const length = one.at - (one.from ?? ordered[index - 1]?.at ?? one.at)
646
647 return { ...one, days: length >= UNSEEN_MIN_SPAN ? length / DAY : 0, wall: length / DAY }
648 })
649
650 if (n === 0) {
651 return { ratio: 1, unseen: 0, unseenHigh: DRIFT_PER_DAY, spreadLow: SPREAD_BELOW, spreadHigh: SPREAD, perDay: 0, n, long: 0 }
652 }
653
654 // The fit, with two make-believe intervals at a ratio of 1 holding it there
655 // while few are real: sums of squares and cross products.
656 const pull = 2 / Math.max(1, n)
657 const ee = spans.reduce((sum, one) => sum + one.est ** 2, 0)
658 const prior = pull * Math.max(ee, 1)
659 const es = spans.reduce((sum, one) => sum + one.est * one.days, 0)
660 const ss = spans.reduce((sum, one) => sum + one.days ** 2, 0)
661 const er = spans.reduce((sum, one) => sum + one.est * one.real, 0)
662 const sr = spans.reduce((sum, one) => sum + one.days * one.real, 0)
663 const det = (ee + prior) * ss - es * es
664 let ratio = 1
665 let unseen = 0
666
667 if (ss > 0 && det > 1e-9) {
668 ratio = ((er + prior) * ss - es * sr) / det
669 unseen = ((ee + prior) * sr - es * (er + prior)) / det
670 }
671
672 // No negative unseen spend, and a ratio within bounds: refit the other.
673 if (unseen < 0 || ss === 0 || det <= 1e-9) {
674 unseen = 0
675 const all = ordered.reduce((sum, one) => sum + one.est * one.real, 0)
676 const squares = ordered.reduce((sum, one) => sum + one.est ** 2, 0)
677 ratio = (all + pull * Math.max(squares, 1)) / (squares + pull * Math.max(squares, 1))
678 }
679
680 if (ratio < RATIO_MIN || ratio > RATIO_MAX) {
681 ratio = Math.min(RATIO_MAX, Math.max(RATIO_MIN, ratio))
682 unseen = ss > 0 ? Math.max(0, spans.reduce((sum, one) => sum + one.days * (one.real - ratio * one.est), 0) / ss) : 0
683 }
684
685 // What the fit left: past it per day for the high end (the worst interval
686 // long enough to say), short of it as a share of the count for the low.
687 const excess = spans
688 .filter(one => one.days > 0)
689 // A gap of several days is charged as one day's burst, not a rate spread
690 // thin over all of them: unseen spend comes in lumps.
691 .map(one => Math.max(0, one.real - ratio * one.est - unseen * one.days) / Math.min(one.days, 1))
692 const unseenHigh = Math.max(DRIFT_PER_DAY, unseen + Math.max(0, ...excess))
693 const shortfall = ordered
694 .filter(one => one.est > 0)
695 .map(one => ({ miss: Math.max(0, ratio * one.est - one.real) / one.est, weight: one.est }))
696 .sort((a, b) => a.miss - b.miss)
697 const half = shortfall.reduce((sum, one) => sum + one.weight, 0) / 2
698 let seen = 0
699 const typical = shortfall.find(one => (seen += one.weight) >= half)?.miss ?? 0
700 // A typical day's count averages over every interval's whole length, the
701 // short ones included: those are the working hours between two entries on
702 // one day, and leaving them out left only the overnight gaps, which made a
703 // day look idle and an afternoon's work look like days of it.
704 const spanned = spans.reduce((sum, one) => sum + one.wall, 0)
705
706 return {
707 ratio,
708 unseen,
709 unseenHigh,
710 spreadLow: Math.max(n < 3 ? SPREAD_BELOW : SPREAD_MIN, typical),
711 // What the sessions count is priced call by call: above it, only the
712 // ratio's own uncertainty while it is young.
713 spreadHigh: n < 3 ? SPREAD : SPREAD_MIN / 3,
714 perDay: spanned >= 1 ? spans.filter(one => one.wall > 0).reduce((sum, one) => sum + one.est, 0) / spanned : 0,
715 n,
716 long: spans.filter(one => one.days > 0).length,
717 }
718}
719
720/** A dollar range for the row: whole dollars outward, or the figure when it is narrow. */
721const dollarRange = (low: number, high: number, point: number): string => {
722 // Past the limit, what is left reads as none rather than a negative.
723 const [from, to, at] = [low, high, point].map(one => Math.max(0, one)) as [number, number, number]
724
725 return to - from < 1 ? at.toFixed(2) : `${Math.floor(from)}–${Math.ceil(to)}`
726}
727
728/**
729 * Reads the engine's figures and the entered limit, writes what this session
730 * has spent toward that limit under the session's own key, and publishes it
731 * all to draw.
732 */
733/**
734 * `isRead` marks an entry of a figure read before now (the routine's sync
735 * file): this session's spend after it was read is not in it.
736 */
737const refresh = async ($: EngineInterface, cause: Cause, isRead = false): Promise<void> => {
738 const usage = await $.session.usage()
739 const now = await $.clock.now()
740 // Claude Code's own figure, as /usage shows it; and, for a session priced
741 // call by call, the same at the official prices, which is what it counts.
742 const engineUsd = usage.cost?.usd ?? null
743 const { value: basis = 'engine' } = await $.state.get(BASIS)
744 // Claude Code's total holds calls no turn shows (titles, summaries): what
745 // it counted beyond the turns, at its own prices, is added as it stands.
746 // Every session's calls are priced both ways as they come, whichever
747 // basis it counts on.
748 const both = await pricedOf($, await $.session.id(), now)
749 const priced = basis === 'priced' ? both : null
750 const pricedUsd =
751 priced === null
752 ? null
753 : priced.usd + Math.max(0, (engineUsd ?? priced.engine) - priced.engine)
754 const usd = pricedUsd ?? engineUsd
755 const windows = usage.rateLimits.map(one => ({ ...one }))
756 const { value: before } = await $.state.get(READING)
757 const { value: counted } = await $.state.get(COUNTED)
758 const { value: wasSeen = false } = await $.state.get(SEEN)
759 const isSeen = wasSeen || windows.length > 0
760 const at = cause === 'start' ? (before?.at ?? null) : now
761 const stored = await $.store.get(BUDGET)
762 let budget: UsageHpBudget | null = null
763
764 if (isSeen !== wasSeen) {
765 await $.state.set(SEEN, isSeen)
766
767 // Calls written before the engine first reported this session's limits
768 // were a subscription seat's: they leave the ledger.
769 // So do its spend keys, which every other session would add up.
770 if (isSeen) {
771 const session = await $.session.id()
772
773 for (const key of await $.store.keys()) {
774 const isDay = key.startsWith(DAY_KEY) && key.split(':')[2] === session
775 const isSpend = (key.startsWith(SPEND) || key.startsWith(TRAIL)) && key.endsWith(`:${session}`)
776
777 if (isDay || isSpend) {
778 await $.store.delete(key)
779 }
780 }
781 }
782 }
783
784 // The entered limit is for sessions the engine reports no limit for, and
785 // which one that is shows only once a reply has come back.
786 if (isStored(stored) && !isSeen && at !== null) {
787 const month = monthOf(now)
788 const epoch = stored.epoch ?? 0
789 const tag = `${epoch}:${month}`
790 const own = `${SPEND}${tag}:${await $.session.id()}`
791 const { value: held = null } = await $.state.get(LEDGER)
792 let ledger: UsageHpLedger | null = held
793 let mine = 0
794
795 if (usd !== null) {
796 // An entry made just now holds this session's cost so far, whatever the tag.
797 if (ledger === null || ledger.tag !== tag || cause === 'command') {
798 // What the session cost before this entry or month is not its to add.
799 // A figure read off the dashboard a while ago (a sync file) holds only
800 // what was spent by then: when a reading came after it, the count
801 // starts from the last reading at or before it. Otherwise from the
802 // cost now for an entry made here, or from the last reading (all of
803 // it for a session older than the entry that kept none).
804 // In a month after the entry's, the count starts at the month's turn:
805 // what the session spent before it belongs to the month before.
806 const cut = Math.max(epoch, Date.parse(`${month}-01T00:00:00Z`))
807 const { value: costs = [] } = await $.state.get(COSTS)
808 const isPast = costs.some(one => one.at > cut)
809 const atEntry = [...costs].reverse().find(one => one.at <= cut)
810 // A figure read before now (the routine's, picked up at the end of a
811 // later turn) holds nothing this session spent after it was read:
812 // that turn's spend counts from the last reading at or before it.
813 const isEarlier = cause === 'command' && isRead
814 const baseUsd = isPast || isEarlier
815 ? (atEntry?.usd ??
816 // Every reading this session kept came after the cut: it had
817 // counted nothing before it (a turn under way then lands after).
818 (usage.startedAt >= cut || (costs[0] !== undefined && costs[0].at > cut) ? 0 : (counted ?? usd)))
819 : cause === 'command'
820 ? usd
821 : (counted ?? (usage.startedAt >= cut ? 0 : usd))
822 // A key this session already wrote (before a restart lost its
823 // state) carries on from where it stood.
824 const written = await $.store.get(own)
825 const already = typeof written === 'number' ? written : 0
826 ledger = {
827 tag,
828 baseUsd:
829 cause === 'command'
830 ? baseUsd
831 : Math.min(baseUsd, (counted ?? usd) - already),
832 }
833 await $.state.set(LEDGER, ledger)
834 }
835
836 // A session counting at Claude Code's own prices adds in official
837 // dollars too, scaled by what its calls cost each way: every key the
838 // calibration sums is then in the same money.
839 const official = basis === 'engine' && both.engine > 0 ? Math.min(1, Math.max(0.5, both.usd / both.engine)) : 1
840 mine = Math.max(0, usd - ledger.baseUsd) * official
841
842 if ((await $.store.get(own)) !== mine) {
843 await $.store.set(own, mine)
844
845 const trailKey = `${TRAIL}${own.slice(SPEND.length)}`
846 const trail = await $.store.get(trailKey)
847 // Two days, and the newest reading before them: a figure read after a
848 // quiet weekend still finds what this session had added by then.
849 // The anchor has a slot of its own: the cap trims the recent ones.
850 const held = Array.isArray(trail) ? (trail as UsageHpCost[]) : []
851 const anchor = held.filter(one => now - one.at >= 2 * DAY).slice(-1)
852 const recent = [...held.filter(one => now - one.at < 2 * DAY), { at: now, usd: mine }]
853 await $.store.set(trailKey, [...anchor, ...recent.slice(-99)])
854 }
855 }
856
857 const { value: cached = null } = await $.state.get(OTHERS)
858 let others: UsageHpOthers | null = cached
859
860 if (
861 others === null ||
862 others.tag !== tag ||
863 now - others.at >= OTHERS_TTL
864 ) {
865 others = { tag, usd: await othersOf($, tag, own), at: now }
866 await $.state.set(OTHERS, others)
867 }
868
869 budget = await withBurn(
870 $,
871 rangeOf(stored, others.usd + mine, calibrate(await calibrationsOf($)), now),
872 now,
873 )
874 }
875
876 if (usd !== null) {
877 await $.state.set(COUNTED, usd)
878 const { value: costs = [] } = await $.state.get(COSTS)
879
880 if (costs[costs.length - 1]?.usd !== usd) {
881 // Two days are kept, and the newest reading before them: a figure read
882 // after a quiet weekend still finds where this session's count stood.
883 const recent = costs.filter(one => now - one.at < 2 * DAY)
884 const before = costs.filter(one => now - one.at >= 2 * DAY).slice(-1)
885 await $.state.set(COSTS, [...before, ...[...recent, { at: now, usd }].slice(-199)])
886 }
887 }
888
889 const { value: samples = {} } = await $.state.get(SAMPLES)
890 const lefts: Record<string, number> = {}
891
892 for (const one of windows) {
893 lefts[one.kind] = 100 - one.percentUsed
894 }
895
896 if (windows.length === 0 && budget !== null) {
897 lefts[BUDGET] = ((budget.limitUsd - budget.spentUsd) / budget.limitUsd) * 100
898 }
899
900 const kept: Record<string, UsageHpSample[]> = {}
901
902 for (const kind of Object.keys(lefts)) {
903 // An entry made just now is no drop to read a pace from: it starts over.
904 const past = cause === 'command' && kind === BUDGET ? [] : samples[kind]
905 kept[kind] = sampled(past ?? [], lefts[kind] ?? 0, now)
906 }
907
908 await $.state.set(SAMPLES, kept)
909
910 // Each bar's colour, carried from one reading to the next for the hysteresis.
911 const { value: colors = {} } = await $.state.get(COLORS)
912 const worst: Record<string, number> = { ...lefts }
913
914 if (lefts[BUDGET] !== undefined && budget !== null) {
915 worst[BUDGET] = ((budget.limitUsd - budget.highUsd) / budget.limitUsd) * 100
916 }
917
918 await $.state.set(
919 COLORS,
920 Object.fromEntries(
921 Object.entries(worst).map(([kind, left]) => [kind, colorOf(left, colors[kind] as Color | undefined)]),
922 ),
923 )
924
925 const style = await $.store.get(STYLE)
926 await $.state.set(SHOWN, isStyle(style) ? style : 'bar')
927 await $.state.set(HIDDEN, (await $.store.get(HIDE)) === true)
928
929 const reading: UsageHpReading = { windows, usd: engineUsd, pricedUsd, budget, at }
930 await $.state.set(READING, reading)
931}
932
933/**
934 * Drops what the ledger and the resync log hold from before the last
935 * LEDGER_DAYS days: the mod keeps a week, no more.
936 */
937const pruneLedger = async ($: EngineInterface, now: number): Promise<void> => {
938 const oldest = dayOf(now - LEDGER_DAYS * DAY)
939
940 for (const key of await $.store.keys()) {
941 if (
942 (key.startsWith(DAY_KEY) && key.slice(DAY_KEY.length, DAY_KEY.length + 10) < oldest) ||
943 (key.startsWith(SYNC_KEY) && Number(key.slice(SYNC_KEY.length)) < now - (LEDGER_DAYS + 1) * DAY)
944 ) {
945 await $.store.delete(key)
946 }
947 }
948}
949
950/**
951 * The budget with its daily burn in dashboard dollars: the resync log's where
952 * it has a day's span, else the ledger's week scaled by the calibration.
953 */
954const withBurn = async (
955 $: EngineInterface,
956 budget: UsageHpBudget,
957 now: number,
958): Promise<UsageHpBudget> => {
959 const fromSyncs = await syncBurnOf($, now)
960 const fromLedger = fromSyncs === null ? await burnOf($, now) : null
961
962 return {
963 ...budget,
964 burnUsd: fromSyncs ?? (fromLedger === null ? null : fromLedger * budget.ratio),
965 }
966}
967
968/**
969 * What the dashboard says is spent a day: the resync log's first and last
970 * figures this month within the week, read at least a day apart. It holds
971 * everything, spend the mod never sees included. Null without such a pair.
972 */
973const syncBurnOf = async ($: EngineInterface, now: number): Promise<number | null> => {
974 const month = monthOf(now)
975 const held: UsageHpSync[] = []
976
977 for (const key of await $.store.keys()) {
978 const value = key.startsWith(SYNC_KEY) ? await $.store.get(key) : null
979
980 if (
981 typeof value === 'object' &&
982 value !== null &&
983 Number.isFinite((value as UsageHpSync).at) &&
984 Number.isFinite((value as UsageHpSync).spentUsd) &&
985 now - (value as UsageHpSync).at <= (LEDGER_DAYS + 1) * DAY &&
986 monthOf((value as UsageHpSync).at) === month
987 ) {
988 held.push(value as UsageHpSync)
989 }
990 }
991
992 held.sort((a, b) => a.at - b.at)
993 const first = held[0]
994 const last = held[held.length - 1]
995
996 return first === undefined || last === undefined || last.at - first.at < DAY || last.spentUsd <= first.spentUsd
997 ? null
998 : ((last.spentUsd - first.spentUsd) / (last.at - first.at)) * DAY
999}
1000
1001/**
1002 * What the sessions spend a day, from the ledger: its week's total over the
1003 * time from the first day it holds to now. Null until it holds a full day
1004 * before today, when an hour's pace is all there is.
1005 */
1006const burnOf = async ($: EngineInterface, now: number): Promise<number | null> => {
1007 const today = dayOf(now)
1008 const oldest = dayOf(now - (LEDGER_DAYS - 1) * DAY)
1009 let first = today
1010 let total = 0
1011
1012 for (const key of await $.store.keys()) {
1013 const day = key.startsWith(DAY_KEY) ? key.slice(DAY_KEY.length, DAY_KEY.length + 10) : null
1014
1015 if (day === null || day < oldest) {
1016 continue
1017 }
1018
1019 const value = await $.store.get(key)
1020 total += typeof value === 'number' && Number.isFinite(value) ? value : 0
1021 first = day < first ? day : first
1022 }
1023
1024 return first === today ? null : (total / (now - midnightOf(first))) * DAY
1025}
1026
1027/** Keeps one figure entered in the resync log, with when it was read and entered. */
1028const logSync = async ($: EngineInterface, sync: UsageHpSync): Promise<void> => {
1029 await $.store.set(`${SYNC_KEY}${sync.at}`, sync)
1030}
1031
1032/**
1033 * Enters a figure from the dashboard, read at `at`: a re-entry in the same
1034 * month first keeps what the sessions counted against it, then every session
1035 * counts afresh from it. Says what the calibration learned, if anything.
1036 */
1037const enter = async (
1038 $: EngineInterface,
1039 spent: number,
1040 limit: number,
1041 at: number,
1042 source: UsageHpSync['source'],
1043 readAt = at,
1044): Promise<string> => {
1045 const prior = await $.store.get(BUDGET)
1046 let learned = ''
1047 let measured: { est: number; real: number } | null = null
1048
1049 // A re-entry in the same month is a measurement: what the sessions
1050 // counted since the last entry, against what the dashboard now says.
1051 if (isStored(prior) && prior.month === monthOf(at) && prior.epoch !== undefined) {
1052 // This session's key first, so the count holds its latest turn.
1053 await queueRefresh($, 'turn')
1054 // What each session had added by `at`: from its trail where it keeps
1055 // one, else its key as it stands.
1056 const tag = `${prior.epoch}:${prior.month}:`
1057 const counts = new Map<string, number>()
1058
1059 for (const key of await $.store.keys()) {
1060 if (key.startsWith(`${SPEND}${tag}`)) {
1061 const value = await $.store.get(key)
1062
1063 if (typeof value === 'number' && Number.isFinite(value)) {
1064 counts.set(key.slice(`${SPEND}${tag}`.length), value)
1065 }
1066 }
1067 }
1068
1069 for (const id of counts.keys()) {
1070 const trail = await $.store.get(`${TRAIL}${tag}${id}`)
1071
1072 if (Array.isArray(trail) && trail.length > 0) {
1073 const byThen = [...(trail as UsageHpCost[])].reverse().find(one => one.at <= at)
1074 counts.set(id, byThen?.usd ?? 0)
1075 }
1076 }
1077
1078 const est = [...counts.values()].reduce((sum, one) => sum + one, 0)
1079
1080 const real = spent - prior.spentUsd
1081 measured = { est, real }
1082
1083 if (est >= CALIB_MIN && real >= 0) {
1084 await $.store.set(`${CALIB}${at}`, { est, real, at, from: prior.epoch, basis: 'priced' })
1085 const intervals = await calibrationsOf($)
1086
1087 for (const key of await $.store.keys()) {
1088 const value = key.startsWith(CALIB) ? await $.store.get(key) : null
1089
1090 // Only the most recent intervals are kept: billing and habits drift.
1091 if (isCalibration(value) && value.at < intervals[0]!.at) {
1092 await $.store.delete(key)
1093 }
1094 }
1095
1096 const { ratio, unseen, n } = calibrate(intervals)
1097 learned = ` Sessions counted $${est.toFixed(2)} since the last entry against $${real.toFixed(2)} on the dashboard; from ${n} re-entr${n === 1 ? 'y' : 'ies'}, the estimate now scales counted spend by ${ratio.toFixed(2)} and adds $${unseen.toFixed(2)} a day the sessions do not see.`
1098 }
1099 }
1100
1101 const entered: StoredBudget = {
1102 limitUsd: limit,
1103 spentUsd: spent,
1104 month: monthOf(at),
1105 epoch: at,
1106 ...(readAt > at ? { readAt } : {}),
1107 }
1108 // Another session may have entered a newer figure while this one worked:
1109 // the newer stands.
1110 const latest = await $.store.get(BUDGET)
1111
1112 if (isStored(latest) && (latest.epoch ?? 0) > at) {
1113 return learned
1114 }
1115
1116 // The figure entered holds everything spent so far: the sessions' own
1117 // counts start over from it.
1118 await forget($, `${at}:`)
1119 await $.store.set(BUDGET, entered)
1120 await $.state.set(OTHERS, null)
1121 await logSync($, { at, enteredAt: await $.clock.now(), spentUsd: spent, limitUsd: limit, source, ...measured })
1122 await queueRefresh($, 'command', source === 'file')
1123
1124 return learned
1125}
1126
1127type SyncFile = { spentUsd: number; limitUsd: number; at: number }
1128
1129const isSyncFile = (value: unknown): value is SyncFile =>
1130 typeof value === 'object' &&
1131 value !== null &&
1132 Number.isFinite((value as SyncFile).spentUsd) &&
1133 (value as SyncFile).spentUsd >= 0 &&
1134 Number.isFinite((value as SyncFile).limitUsd) &&
1135 (value as SyncFile).limitUsd > 0 &&
1136 Number.isFinite((value as SyncFile).at)
1137
1138/**
1139 * Enters a dashboard figure left in the sync file, once: only where a limit
1140 * was set with /hp (so /hp off stays off), only a figure newer than the one
1141 * held, and only one from this month.
1142 */
1143const synced = async ($: EngineInterface): Promise<void> => {
1144 let file: unknown
1145
1146 // Any refusal on the way (no HOME, a filesystem hook, a bad file) leaves the
1147 // bar as it is: the sync is a convenience, never a reason to stop counting.
1148 try {
1149 const configDir = await $.env.get('CLAUDE_CONFIG_DIR')
1150 const home = await $.env.get('HOME')
1151 const dir = configDir ?? (home === undefined ? undefined : `${home}/.claude`)
1152
1153 if (dir === undefined || !(await $.fs.exists(`${dir}/${SYNC_FILE}`))) {
1154 return
1155 }
1156
1157 file = JSON.parse(String(await $.fs.read(`${dir}/${SYNC_FILE}`)))
1158 } catch {
1159 return
1160 }
1161
1162 const prior = await $.store.get(BUDGET)
1163 const now = await $.clock.now()
1164
1165 if (
1166 !isSyncFile(file) ||
1167 !isStored(prior) ||
1168 file.at <= (prior.readAt ?? prior.epoch ?? 0) ||
1169 // A few minutes ahead is the other machine's clock, not the future.
1170 file.at > now + SKEW ||
1171 monthOf(file.at) !== monthOf(now)
1172 ) {
1173 return
1174 }
1175
1176 await enter($, file.spentUsd, file.limitUsd, Math.min(file.at, now), 'file', file.at)
1177}
1178
1179/** The quiet-session re-read the band last drew, and when; reused between redraws. */
1180let quietRange: { at: number; ident: string; budget: Promise<UsageHpBudget | null> } | null = null
1181
1182/**
1183 * Work that reads and writes the spend figures (a refresh at a turn end, a measure, a
1184 * command; a step's ledger write) runs one piece at a time: a refresh that finds a
1185 * subscription seat clears the ledger, and no step's write may land in the middle.
1186 * A piece that fails hands the chain on to the next.
1187 */
1188let refreshing: Promise<void> = Promise.resolve()
1189
1190const inTurn = ($: EngineInterface, work: () => Promise<void>): Promise<void> => {
1191 const run = refreshing.then(work)
1192 refreshing = run.catch(() => undefined)
1193
1194 return run
1195}
1196
1197const queueRefresh = ($: EngineInterface, cause: Cause, isRead = false): Promise<void> =>
1198 inTurn($, () => refresh($, cause, isRead))
1199
1200export const register: Register = on => {types/index.d.ts 146 lines1export type UsageHpWindow = {
2 /** Which window: five_hour, seven_day, or a gateway's spend_limit. */
3 kind: string
4 /** How much of the window is used, 0 to 100 (past 100 when exceeded). */
5 percentUsed: number
6 /** When the window resets, ISO 8601; absent when the engine has none. */
7 resetsAt?: string
8}
9
10/** A monthly spend limit the person entered with /hp, tracked locally. */
11export type UsageHpBudget = {
12 limitUsd: number
13 /** The exact figure the range builds on: the one entered, or 0 in a month after it. */
14 baseUsd?: number
15 /** Spend so far this month: the figure entered plus what sessions added, calibrated. */
16 spentUsd: number
17 /** The range the spend is likely in; equal to spentUsd right after an entry. */
18 lowUsd: number
19 highUsd: number
20 /** When the figure was last entered from the dashboard, ms. */
21 syncedAt: number
22 /** How many earlier re-entries the calibration learned from. */
23 syncs: number
24 /** What the dashboard bills per dollar Claude Code counts, as calibrated; 1 before any re-entry. */
25 ratio: number
26 /** When the month ends, ms since the epoch (months turn over in UTC). */
27 resetsAt: number
28 /** Dollars billed a day: from the resync log's week, else the ledger's scaled by `ratio`; null without either. */
29 burnUsd?: number | null
30}
31
32export type UsageHpReading = {
33 windows: UsageHpWindow[]
34 /** What the session has cost in US dollars; null where no ledger is kept. */
35 usd: number | null
36 /** The same at the official per-model prices, where the session is priced so; else null. */
37 pricedUsd?: number | null
38 budget: UsageHpBudget | null
39 /** When a finished turn last gave fresh figures, ms; null before the first. */
40 at: number | null
41}
42
43/**
44 * One interval between two /hp entries: what the sessions counted, and what
45 * the dashboard says was really spent. Kept in $.store under `calib:<at>`.
46 */
47export type UsageHpCalibration = {
48 est: number
49 real: number
50 at: number
51 /** When the entry it measures from was made, ms; absent on intervals kept before 0.20. */
52 from?: number
53 /** `priced` when counted at official prices; earlier intervals counted Claude Code's own cost. */
54 basis?: UsageHpBasis
55}
56
57/** What the session had cost at a reading: where its count starts for a figure read then. */
58export type UsageHpCost = { at: number; usd: number }
59
60/**
61 * What one agent of a session (main, or a subagent) has cost at the official
62 * per-model prices, kept in $.store under `priced:<session>:<agent>`.
63 */
64export type UsageHpPriced = {
65 usd: number
66 /** The same calls at Claude Code's own prices: its total beyond this is calls no turn shows. */
67 engine?: number
68 /** Calls whose model has no price here: counted through Claude Code's total instead. */
69 unknown: number
70 /** When it was last written, ms: stale keys of ended sessions are pruned. */
71 at: number
72}
73
74/**
75 * Where a session's dollars come from: each call priced at the official
76 * per-model rates (`priced`), or Claude Code's own session cost (`engine`) for
77 * a session the mod joined after it had already spent.
78 */
79export type UsageHpBasis = 'priced' | 'engine'
80
81/**
82 * One dashboard figure entered, by /hp or the sync file, kept in $.store under
83 * `sync:<at>`: the resync log. `est` and `real` are the interval it measured,
84 * where it was a same-month re-entry.
85 */
86export type UsageHpSync = {
87 /** When the dashboard figure was read, ms. */
88 at: number
89 /** When the mod entered it, ms: later than `at` for a sync file picked up on a later turn. */
90 enteredAt: number
91 spentUsd: number
92 limitUsd: number
93 /** `command` for /hp, `file` for the sync file the scheduled routine writes. */
94 source: 'command' | 'file'
95 est?: number
96 real?: number
97}
98
99/** One past reading of a bar: how much was left, and when. */
100export type UsageHpSample = { at: number; left: number }
101
102/** Where this session's cost starts counting toward the entered limit. */
103export type UsageHpLedger = {
104 /** The limit's entry and month this base belongs to; a new one starts over. */
105 tag: string
106 /** The session's cost that the entered figure, or an earlier month, holds. */
107 baseUsd: number
108}
109
110/** What the other sessions have added, read from the store now and then. */
111export type UsageHpOthers = { tag: string; usd: number; at: number }
112
113/**
114 * How a bar is drawn: `bar` drains from full, `used` fills as the limit is
115 * spent, `hearts` is a row of ten hearts losing a quarter at a time, `text`
116 * is the figures alone.
117 */
118export type UsageHpStyle = 'bar' | 'used' | 'hearts' | 'text'
119
120declare module 'claude-code' {
121 interface PluginState {
122 'usage-hp': {
123 reading: UsageHpReading | null
124 /** The session's cost at the last reading. */
125 counted: number
126 /** The session's cost at recent readings, oldest first, two days of them. */
127 costs: UsageHpCost[]
128 /** Where this session's dollars come from, settled when it starts. */
129 basis: UsageHpBasis
130 /** Each bar's colour at the last reading, by kind: what the hysteresis holds to. */
131 colors: Record<string, 'green' | 'yellow' | 'red'>
132 /** Recent readings of each bar by kind, oldest first: the pace's basis. */
133 samples: Record<string, UsageHpSample[]>
134 /** True once the engine reported a limit: such a session has its own bars. */
135 seen: boolean
136 ledger: UsageHpLedger | null
137 others: UsageHpOthers | null
138 style: UsageHpStyle
139 /** True while /hp hide stands: nothing is drawn, the figures still kept. */
140 hidden: boolean
141 /** True when the session bills an API key or a cloud provider: no setup line is drawn. */
142 metered: boolean
143 }
144 }
145}
146