Skip to content

feat: add an Extra Usage Today widget - #811

Merged
sirmalloc merged 32 commits into
sirmalloc:mainfrom
eric-engberg:feat/extra-usage-today
Nov 6, 2026
Merged

sirmalloc merged 32 commits into
sirmalloc:mainfrom
eric-engberg:feat/extra-usage-today

Conversation

@eric-engberg

Copy link
Copy Markdown
Contributor

What

A new Extra Usage Today widget shows the extra usage spent since the UTC day began, including claude.ai and other machines: Overage Today: $23.45.

Why

The usage API reports extra usage spent month to date only, so there's no way to see what today cost. That's the number that matters day to day, especially on usage-based plans where every request is billed.

How

  • A small state file. ~/.cache/ccstatusline/daily.json keeps each login's month-to-date total at the start of the UTC day. It's modeled on the terminal width cache (terminal-width-cache.ts): one JSON file, a best-effort O_EXCL lock around each read-modify-write (a writer that finds it held skips that write; a lock older than 2s is stolen), an atomic temp-file rename, pruning to today and yesterday, and injected file and clock dependencies so tests never touch the real home directory. That lock moves into a shared cache-lock.ts used by both caches instead of being copied (first commit, no behavior change). A temp file is deleted when its write or rename fails (for example when Windows antivirus holds the new file open), and each write also removes the state file's other temp files once they are a minute old, while a write in progress in another process is left alone.
  • Where today starts. ccstatusline only sees the total while Claude Code is running, so the start of the day is the last total it saw the previous UTC day (at most the total at midnight). On the 1st it's zero, which is exact: extra usage resets at 00:00 UTC. After a day with nothing seen, it's the first total seen today, which misses spend from before Claude Code was opened. A total that drops mid-day (a refund) never makes today's spend negative.
  • Per login, across token refreshes. Claude Code saves a new refresh token each time it refreshes its access token, roughly every 8 hours, so the token fingerprint the usage cache uses would reset Today to $0.00 several times a day. Records are keyed instead by a hash of the organization and account uuids that /api/oauth/profile names for the login. That's the request Claude Account Email (fix(account-email): show the account the live credentials belong to #724) already makes, and one request per login now answers both: it runs under that widget's per-login lease and backoff, and each keeps its part of the answer in its own cache (the hashed account key in ~/.cache/ccstatusline/usage-account.json). The lookup only runs while a widget that shows today's spend is on a line. Until the account is known, the record stays under the token fingerprint; once it is, records under the account's fingerprints are folded into the account's record, keeping the day's starting total. A record under a token that was never looked up can't be tied to an account, so on that day the figure starts over once.
  • Counted for the day it was fetched. The usage cache replays a fetch for up to three minutes, and serves a stale copy while the API is unreachable. So each total is recorded at the time the API returned it (getUsageFetchedAtMs, feat(usage): forecast the 5-hour block (Block Forecast, Block Limit Timer) #699), not the time it was rendered. A total fetched at 23:58 and replayed at 00:01 updates the previous day, so today's spend shows nothing until a total fetched today arrives. In particular, the 1st never shows last month's total. A fetch older than the one already recorded is ignored. A time more than a minute later than now was stamped while the system clock ran ahead: a record last seen then is dropped and the day starts again, and such a fetch isn't recorded, so one render with a wrong clock can't hide Today for the rest of the day or carry yesterday's spend into the next.
  • When. The usage prefetch records the total and adds extraUsageUsedToday (cents) to the usage data, only when a line shows the widget. It's worked out on every render, cache hits included, and never stored in the usage cache. It's derived, so it's not a fetchable UsageDataField.
  • Widget. Labeled like its siblings (fix(widgets): describe the Extra Usage widgets for Enterprise accounts too #670's helper), in the reported currency and the cost number format, with the disabled / no-data hide options. It shows nothing until a usage fetch has run since the UTC day began, and n/a when extra usage is off. The preview sample is $46.10.
  • One side effect on Claude Account Email: a rate-limited profile lookup's Retry-After is now kept within the day the backoff reader trusts. A longer one used to be read back as no backoff at all, so every render asked again.

Demo

Rendered through the real pipeline from a scratch home: a fake login whose fresh usage cache is tagged with its fingerprint (so no API call), and a state file holding yesterday's last total. Enterprise: $123.45 this month, $100.00 by the end of yesterday. Max: $12.50 of overage, $10.00 by the end of yesterday.

Powerline

Extra Usage Today for an Enterprise and a Max account, Powerline mode

Plain

Extra Usage Today for an Enterprise and a Max account, plain mode

Why the record is keyed by the account: one fake login's day, with Extra Usage Used and Remaining next to it. It starts at $100.00 this month and is $15.00 at $115.00, then Claude Code saves a new refresh token. Keyed by the token fingerprint (first), the next renders show $0.00 and $10.00. Keyed by the account (second), they show $35.00 and $45.00. The data is fake, and the usage cache and the profile lookup's answer are seeded, so nothing is fetched.

Powerline, by fingerprint and by account:

Keyed by fingerprint, Powerline

Keyed by account, Powerline

Plain, by fingerprint and by account:

Keyed by fingerprint, Plain

Keyed by account, Plain

Testing

  • On main as of fix(powerline): keep the Custom theme when Powerline is enabled again #793 (a0bedc0), which this branch already includes: bun run lint is clean, and bun test gives 4210 pass, 0 fail (4147 on main). The built CLI under Bun 1.4.4 and Node 26.13.0 agrees across runtimes in plain and Powerline mode and differs from main only by the new widget; the TUI opens and exits cleanly under both. It adds no JavaScript or Node API newer than Node 14, the build's target.
  • State file (injected deps): UTC day keys across midnight and a month boundary, missing / corrupt / other-version files and invalid records, the temp-file write, no write when nothing changed, pruning, a held lock skipping the update, a temp file removed when the rename or a partial write fails, and leftover temp files removed once they're 60 seconds old while younger ones and other files are kept.
  • Day rules with hand-checked values: no earlier record, yesterday seen, a gap, the 1st (after last month and with no record), a rising total, a falling total, an older fetch replayed after a newer one; separate logins; no write when the same fetch is observed again; a held lock still reporting today's spend; a total fetched before midnight and replayed after it counting for the day before; the 1st of the month never showing last month's total; records under an account's former fingerprints carried into its record; a record from a clock that ran ahead dropped (3500 cents today, where merging it gave 0).
  • fetchUsageData and the profile lookup in the probe harness (a child process with a scratch HOME, the network mocked): the login fingerprint and fetch time the usage cache is written with; one profile request per fingerprint; the account key kept across a refresh-token rotation; other organizations and other accounts told apart; the 30-second hold-off after a failure, a 401 or a response with no organization, per login; the 429 hold-off, at most a day; the lookup lease taken before the request; a poisoned far-future backoff ignored; one profile request answering both the account key and Claude Account Email; the account looked up when only the email is cached; an email with no organization kept, holding off only the account lookup. Claude Account Email's existing tests pass unchanged.
  • Prefetch: today's spend is worked out for the widget with the login and fetch time, kept under the account the lookup names, left out without a known login or fetch time, and skipped (with no lookup) when no widget shows it; the widget fetches exactly extraUsageEnabled and extraUsageUsed.
  • Widget: both labels, raw value mode, currency, the cost style, nothing before today's spend is known, n/a and its hidden state, errors and no-data, the preview.
  • Earlier, before the profile lookup was shared with Claude Account Email: end to end in a scratch HOME with the keychain and HTTPS stubbed, under Bun and the built dist on Node v26.10.0, a refresh-token rotation kept Extra Usage Today at $35.00 and $45.00 with one profile request per token, and with Retry-After: 172800 four renders 5 seconds apart sent 1 profile request. Under Node (Vitest) the daily-state, daily-spend and usage-prefetch tests passed 86/86.

On usage-based plans (Claude Enterprise) the usage API reports every
rate-limit window as null and no limits[] entries: there's no plan limit
to go over, and extra usage is the account's whole spend. The Extra
Usage widgets still called it overage ("Overage Used: $123.45"), and
their picker descriptions only mentioned pay-as-you-go overage, so
Enterprise users had no reason to think these widgets were theirs.
Searching the picker for "enterprise" or "spend" didn't find them.

The parser now flags a response that explicitly reports no plan limits
(`noPlanLimits`). The flag is kept in the usage cache and through the
merge with statusline rate_limits, and the three widgets label their
value "Spend Used", "Spend Left" and "Spend" when it's set. Only an
explicit null counts and any limits[] entry counts as a limit, so every
uncertain case keeps the "Overage" label. A cache written before this
change keeps the old label until its next refresh.

The widget descriptions now mention both Pro/Max overage and Enterprise
spend, which also makes the picker's search find them.
Move the terminal width cache's best-effort O_EXCL lock into its own
module so other cache files can use the same read-modify-write
protection instead of copying it. No behavior change.
Some usage figures are per UTC day, but ccstatusline only ever sees the
current month-to-date totals. Keep a small state file next to the other
caches that records what was seen and when, modeled on the terminal
width cache: one JSON file, the shared lock around each
read-modify-write, an atomic temp-file rename, pruning to today and
yesterday, and injected file and clock dependencies.
The usage API reports extra usage spent month to date only. Record the
total per login in the daily state file and work out today's spend from
where it stood when the UTC day began: the last value seen the previous
day, zero on the 1st (extra usage resets at 00:00 UTC), or otherwise the
first value seen today. A total that drops mid-day never makes today's
spend negative, and an observation that loses the state lock to another
process still reports today's spend.
fetchUsageData now remembers the token fingerprint of the login it
used. When a line shows today's spend, the prefetch records the
month-to-date total for that login in the daily state file and adds
extraUsageUsedToday (cents) to the usage data. It's worked out on every
render, cache hits included, and never stored in the usage cache.
Shows the extra usage spent since 00:00 UTC, including claude.ai and
other machines, labeled like its siblings: "Spend Today" on accounts
without plan limits, "Overage Today" otherwise. It shows nothing until a
usage fetch has run since the UTC day began, and n/a when extra usage
is off, with the same hide options, currency and cost number format as
the other Extra Usage widgets.
Today's spend recorded every usage prefetch as an observation at render
time, cache hits and stale fallbacks included. A total fetched at 23:58
UTC on the last day of the month and replayed from the cache at 00:01
became the 1st's first observation, so Spend Today showed the whole
previous month for up to three minutes, or for as long as the API stayed
unreachable.

fetchUsageData now reports when its data came from the API
(getUsageFetchedAt: now for a fresh fetch, the cache file's write time
for a cache hit or stale fallback), and the observation is recorded at
that time. A total fetched before midnight updates the previous day's
record, so today's spend shows nothing until a total fetched today
arrives, and an older fetch replayed after a newer one is ignored.
Today's extra usage spend depends on fetchUsageData recording which
login and which fetch its data came from, and every other test replaces
that link with a spy. Run the real fetch in the probe harness and check
it reports the login fingerprint the usage cache is written with and the
API fetch time, and neither when there are no credentials.
# Conflicts:
#	src/widgets/ExtraUsageRemaining.ts
#	src/widgets/ExtraUsageUsed.ts
#	src/widgets/ExtraUsageUtilization.ts
Upstream's label editor (sirmalloc#601) gives each widget one default label,
while Extra Usage Used, Remaining and Utilization pick "Overage" or
"Spend" by account. The editor offers the Overage label, since it has
no usage data, and an edited label replaces whichever one the account
would show.
With the label editor (sirmalloc#601) in main, anyone on a usage-based Enterprise
plan can relabel the Extra Usage widgets' "Overage" to "Spend" in a few
keys, so detecting those accounts from the usage API's response shape
isn't worth its cache field and heuristics.

What stays is the wording: the three widgets' descriptions say they
cover Pro/Max overage and Enterprise spend, the docs say the same and
point at the label editor, and picker searches for "enterprise" or
"spend" list the three widgets first, which a new test checks.
…to feat/extra-usage-today

#32 no longer detects accounts without plan limits, so Extra Usage Today
keeps a fixed "Overage Today: " label, as the other Extra Usage widgets
do; the label editor can change it.
The two widgets were the same class apart from how the amount is worked
out, and their label, name, description and preview sample. They now
extend ExtraUsageAmountWidget, the way the Tokens widgets share
TokenCountWidget. Rendering is unchanged.
Extra Usage Today ignores a fetch older than the one it last recorded,
so a cached fetch a concurrent render replays late can't move the day
back. A single render made while the system clock ran ahead defeats
that: it records a fetch stamped at a time that hasn't come yet. Once
the clock is put right, every real fetch looks older and is ignored, so
Today disappears for the rest of the day; and when the clock reaches the
stamped time, that record is taken as the day's, with the previous
day's total as its start. With $10 seen at 09:00, one render a day
ahead, then $25 and $40 later that day, Today was hidden for the rest of
the day and showed $40 the next day for at most $10 of real spend.

The usage cache that render wrote is stamped by the same clock and is
served as fresh until the clock catches up with it, so its fetch keeps
arriving with the future time and would store the same record again.

A record last seen more than a minute ahead of now is now dropped and
the day starts afresh, and a fetch stamped more than a minute ahead
isn't recorded. The minute's leeway keeps a small step back of the
clock, such as an NTP correction just after a fetch, from dropping the
day's starting point. Now is read after the state file, so a fetch
another render has just recorded is never taken for one from the
future and the replay guard still holds.
# Conflicts:
#	docs/USAGE.md
#	src/utils/usage-prefetch.ts
updateDailyState writes daily.json through a temp file named
daily.json.<pid>.tmp and renames it into place. When the write or the
rename failed, for example a write cut short by a full disk or a rename
refused with EPERM while a Windows virus scanner or sync client holds
daily.json, the error went to the catch-all and the temp file stayed
behind. Every status line render is a new process with a new pid, so
each failing render left another orphan in ~/.cache/ccstatusline, and
nothing ever deleted them.

A failed write or rename now unlinks the temp file. A failed unlink is
ignored; as before, the state stays best-effort and the render goes on.

The same handle can be held on the temp file itself, and then it
refuses the unlink as well as the rename. So each write also removes
the other daily.json.<digits>.tmp files next to the state file once
they are a minute or more old, which no running write can still own.
While a handle stays held, only the last minute's temp files remain,
and orphans an older build left behind are cleared as well. The temp
name stays per process, so two renders never write through the same
file.
Extra Usage Today keeps each login's month-to-date total at the start of
the UTC day in daily.json, keyed by the usage cache's token fingerprint,
a hash of the refresh token. Claude Code saves a new refresh token each
time it refreshes the access token, which happens several times on a
working day. The key then changed mid-day, the new key had no record,
and today's spend started over from that moment: $15.00 at 13:00, then
$0.00 at 17:00 instead of $35.00 and $10.00 at 18:00 instead of $45.00.
The old record stayed behind until it was pruned.

The record is now kept under the account and organization the
credentials belong to. While a widget shows today's spend, ccstatusline
asks /api/oauth/profile through the usage fetch's own request, proxy
and credential code, once per token fingerprint, and keeps the answers
in ~/.cache/ccstatusline/usage-account.json. A failed lookup holds off
that fingerprint alone, for 30 seconds or a 429's Retry-After (at most
a day), and the hold-off is written before the request goes out, so
renders running side by side send one request between them.

Until the lookup answers, the record stays under the fingerprint as
before. Once the account is known, the records under its fingerprints,
written by an earlier build or while the lookup was failing, are folded
into the account's record once, so the day keeps its starting total.
A record seen while the clock ran ahead is dropped instead, as it would
be under its own key. A fingerprint that was never looked up can't be
placed with an account, so today's spend still starts over once when
an earlier build kept the record under a refresh token that has since
changed, or when a lookup outage spans a refresh.

The organization is part of the key because one account can sign in to
a personal organization and a work one, each with its own extra usage
total. The key is a hash, so neither cache file names the account.
mergeSpendRecord goes through an arrow, so reduce's index and array
arguments never reach it, and the account id is built before it's
fingerprinted instead of inside a nested template literal.
Upstream sirmalloc#699 added getUsageFetchedAtMs, which records the same fetch time
as this branch's getUsageFetchedAt, so Extra Usage Today now reads that one.
Upstream sirmalloc#724's account email lookup also asks the profile endpoint; this
branch's account lookup keeps its own schema (ProfileAccountIdsSchema) and
cache next to it.
…and the account key

The Claude Account Email widget (sirmalloc#724) and Extra Usage Today each asked
/api/oauth/profile about the same login, each with its own cache and backoff.
One request now answers both. lookUpProfile runs under the email lookup's
per-login lease and backoff files, and each caller keeps its part of the
answer in its own cache: the email in account-email-<hash>.json, the account
key in usage-account.json, which now holds only named logins. An answer that
leaves out the caller's part holds that login off as before, and the other
part is still kept.

A rate-limited lookup's Retry-After is kept within the day the backoff reader
trusts, as the account lookup did, so the email lookup no longer reads a
Retry-After further off as no backoff at all.
eric-engberg and others added 2 commits November 5, 2026 23:56
The account key nested the id's template inside its own on one line. Build
the id first, as the account lookup did before it shared the profile
request.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants