SLOPSHOPPER

usage-hp

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

newbandcommandtimer
v0.26.0no licenseupdated 2026-10-20jquezada19/claude-mods/usage-hp
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-hp
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /hp ⎿ usage-hp: Usage: /hp <spent> <limit>, the two figures Settings › Usage shows, as in /hp 12.21 300. At the very start of ⟨Claude Code's own drawing⟩ ♥ 5H ██████████████░░░░░░ 69% left · resets in NaNm $0.42 this session ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ ♥ 5H ██████████████░░░░░░ 69% left · resets in NaNm $0.42 this session
README

usage-hp

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)

What the bars show

  • One bar per limit: five_hour and seven_day (Pro, Max), a gateway's spend_limit, or the SPEND estimate on Enterprise
  • Green above 50% left, yellow above 20% up to 50%, red at 20% or below. A bar turns worse the moment it crosses a line, but better only once it is two points back over it, so one sitting on 50% does not flicker
  • On desktop the heart drains a quarter per 25% used, like a Legend of Zelda heart; the terminal shows ♥ 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 spent
  • Pace: once five minutes of readings are in hand and the bar has dropped, a bar says when it empties if the last hour's rate holds (empty 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
  • Beneath the bars: this session's cost in bold ($0.30 this session), then as of 14m ago once 5H/7D figures are ten minutes old
  • Idle reset: a window that ended while nothing was sent reads 100% left · window reset, with reset windows refill on your next reply beneath
  • No limit yet: a magenta ♥ 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
  • The row hides while a survey shows
  • The desktop app draws the heart and bar as vector images; the terminal shows █ 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.

Enterprise setup (once)

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.

Reading the SPEND row

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:

PartMeaning
$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 pacePace, 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 12dWhen 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

How the estimate works

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.

Priced by model

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:

ModelInputOutputCache readCache 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.

The range

What came since the last figure is two things, estimated apart:

  • What the sessions counted, scaled by a learned ratio (the bill per counted dollar: cache writes the mod prices at the one-hour rate make it a little under 1).
  • Spend the mod cannot see (the desktop or web chat on the same account, a session that never loaded the mod), learned as dollars a day, since it adds whatever the sessions count.

The high end allows for unseen spend coming in bursts, not a steady trickle:

  • Its rate is the worst any interval of six hours or more showed, with a multi-day interval's excess charged as one day's burst rather than spread thin over the gap.
  • From the moment after a figure, a whole day of that rate is allowed at once. Each further day adds another, building over that day's first ten hours; a busy morning of counted work moves the clock on too. What counts as a day's work is the average over every interval between figures, the short daytime ones included: read from the overnight gaps alone (the only intervals long enough to teach a daily rate), a day looked nearly idle and an afternoon's work moved the clock on by days, which asked for a re-sync within an hour of a fresh figure.
  • Until two such intervals are in hand it allows 2% of the limit a day (1% with one). A stranger who never re-syncs keeps that allowance, so their range widens by about that much a day and, once the figure is a week old, the row asks for a new one on every turn. That is the honest answer when nothing has been measured: a narrower guess (one growing as the square root of the days was tried) left a stranger with heavy desktop chat late most of the time.

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.

Calibration

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.

When it asks for a re-sync

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.

Tested by simulation

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.

Several sessions at once

Run as many sessions side by side as you like; each one counts only itself, and every bar shows the total.

  • One key per session. Each session writes what it has spent since the last entry under a key of its own, so sessions finishing turns at the same moment never overwrite each other. Within a session, the main thread and each subagent price their calls under keys of their own too, so parallel subagents never lose an update.
  • Every bar is the machine total: the entered figure plus all sessions' keys. Line two's this session is that session's own cost only.
  • It keeps up while idle. A session reads the others' keys after its own turns, and after two quiet minutes again each time it redraws (at least once a minute), so a session you are not using catches up within a few minutes.
  • /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.
  • What it cannot see: sessions on other machines (the keys live in ~/.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.

Sync file (optional)

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.

Styles

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.

All commands

/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

What it cannot know

  • A turn in flight when a figure is entered, or when the UTC month turns, lands wholly on the new side (at most one turn per open session).
  • The dashboard's header updates within about a minute, but a slice of spend can land a little later; the daily chart under it lags much more, so take figures from the header.
  • The price table is fixed in the mod (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.
  • One budget per Claude config folder. Claude Code tells a mod nothing of which account a session is signed in to, so every session that reports no limits of its own adds to the one budget entered. A session on a Pro or Max seat reports its limits and is left out (anything it counted before they showed is taken back out); two Enterprise accounts sharing one config folder would be added together.
  • Months turn in UTC, as Claude Code's own usage does; an organization billed on another cycle will see the reset on the wrong day.
  • A session that bills an API key, Bedrock, Vertex or Foundry directly has no Settings › Usage page, so no setup line is drawn for it unless a limit is entered.
  • What it keeps, and where: dollar totals, timestamps and session ids, in Claude Code's local plugin store on this machine, plus the sync file if you use one. Nothing is sent anywhere. /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.

Install

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

  1. Unzip it so the folder sits at ~/.claude/mods/usage-hp (the folder holding this README).
  2. Load it in every session, desktop app included: in ~/.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.

  1. Check: 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 ledger and the resync log

The mod keeps a small database of its own in the background; nothing shows it and there is no command for it.

  • Ledger: each API call, the main thread's and each subagent's, is added to that session's total for the local day: one key per session and day, so sessions never overwrite each other. An entry from the dashboard leaves it alone. A week is kept.
  • Resync log: each figure entered, with when the dashboard was read, when the mod entered it, whether it came from /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.
  • What reads them: the SPEND pace (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.
  • Seats: only a session the engine reports no limits for spends the entered budget, so only such a session writes the ledger. A session on a subscription seat (Pro, Max) is never written, and any calls it made before the engine first reported its limits are taken back out.

Both are pruned when a session starts.

0.26.0 (2026-10-19)

  • The dollars say which way they count. In the bar, hearts and text styles the SPEND row's dollars are what is left, but $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.

0.25.0 (2026-10-16)

  • Limits move mid-turn. The engine raises 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.
  • One refresh at a time. A turn end, a measure, an /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.
  • A quiet band stops rescanning. After two quiet minutes the SPEND row re-reads the other sessions' counts; that re-read (several full store scans) now runs at most once per two minutes instead of on every redraw, redraws that arrive while it reads share it, and it is redone at once when the entered figure changes or the month turns. /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.
Source 2 files
hooks/register.tsx 1717 lines
1import 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 lines
1export 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