Skip to content

fix(account-email): show the account the live credentials belong to - #724

Merged
sirmalloc merged 3 commits into
sirmalloc:mainfrom
efim0v:fix/538-account-email-live-login
Oct 29, 2026
Merged

sirmalloc merged 3 commits into
sirmalloc:mainfrom
efim0v:fix/538-account-email-live-login

Conversation

@efim0v

@efim0v efim0v commented Oct 13, 2026

Copy link
Copy Markdown
Contributor

Fixes #538

Problem

The Claude Account Email widget reads oauthAccount.emailAddress from .claude.json. Claude Code writes that record at login and does not rewrite it when the credentials change underneath, so after an account switch the widget can keep naming the previous account. The usage widgets read the live credentials (getUsageCredentials()), so one status line can show one account's email next to another account's usage.

Fix

  • fetchAccountEmail() in usage-fetch.ts gets the email from GET /api/oauth/profile (account.email) with the usage credentials. It goes through the existing usage request code (same host, anthropic-beta header, HTTPS_PROXY/NO_PROXY handling and five-second deadline); fetchFromUsageApi() gained an apiPath parameter for this, and the usage request itself is unchanged.
  • Answers are cached in ~/.cache/ccstatusline/account-email.json (mode 0600) under the usage cache's login fingerprint (getUsageCacheIdentity().preferredHash). An access-token refresh reuses the entry; only a login change triggers another request. Up to 10 logins are kept, so profiles used side by side don't evict each other.
  • A failed lookup (request error, non-200 status, or a response without an email) writes account-email.lock for that login only: 30 seconds, or after a 429 the server's Retry-After (five minutes without one, as for usage requests), with the same 24-hour bound as usage.lock.
  • ccstatusline.ts runs the lookup next to the other prefetches, only when a claude-account-email widget is configured, and passes the result as RenderContext.accountEmail.
  • The widget prefers context.accountEmail and falls back to .claude.json when there is no OAuth login or no successful lookup yet, so API-key users and offline renders behave as before. Preview output is unchanged.
  • docs/DEVELOPMENT.md lists the new cache files and the widget's data source. docs/USAGE.md describes the lookup in the widget list, under Usage Credentials and Cache, and in the proxy settings.

Notes for review:

  • The widget now makes a network request: once per login, plus retries after a failed lookup's backoff. It does not request on every render.
  • On macOS, each render with this widget reads the credentials from the keychain, the same lookup the usage widgets use. When the cached email is used, the widget no longer reads .claude.json.
  • Not exercised against the live endpoint. The tests and the end-to-end check below stub it with the response shape given in claude-account-email reads a login-time snapshot, so it can name a different account than the usage widgets #538.

Tests

src/utils/__tests__/usage-fetch.test.ts: the subprocess probe harness can now run fetchAccountEmail instead of fetchUsageData, and it records the request path and Authorization header. One existing usage test now also checks that usage requests still go to /api/oauth/usage. New fetchAccountEmail cases:

  • looks up /api/oauth/profile with the live access token, and serves the second call from the cache
  • writes the cache owner-only
  • keeps the entry across an access-token refresh (same refresh token), with no request
  • looks up again after a login change and keeps the other login's entry
  • request error, 401, and a response without an email: returns null and holds off for 30 s
  • a failed login is held off while another login is still looked up, and is retried after 30 s
  • 429 holds off for Retry-After
  • a lock more than 24 h ahead is ignored
  • no OAuth login: no request

src/widgets/__tests__/ClaudeAccountEmail.test.ts: the live email wins over .claude.json (on main this fails with Expected: "Account: current@example.com", Received: "Account: previous@example.com"), .claude.json is still used when the live email is null, and the live email shows when .claude.json has none.

Run on macOS with Bun 1.4.2, without network access:

  • bun run lint: clean
  • bun run build: OK
  • bun test --timeout 20000: 3124 pass, 2 fail. The two failures are the "behind a proxy that never answers CONNECT" tests, which need to listen on a local socket; my environment does not allow that, and they fail the same way on unmodified main here (3110 pass, 2 fail).
  • With the default 5 s timeout, preserves root errors within a process... also times out here. That is not new: run on its own, it takes about 9 s both on main and on this branch, and it passes on both with a longer timeout.

End to end, with .claude.json naming previous@example.com, credentials for another account, and https.request stubbed to answer the profile request with current@example.com:

  • main: prints Account: previous@example.com and makes no request
  • this branch, via both bun src/ccstatusline.ts and node dist/ccstatusline.js: the first render makes one GET /api/oauth/profile with the live token and prints Account: current@example.com; the second render uses the cache and makes no request
  • without the stub (network blocked): prints Account: previous@example.com and writes the 30 s backoff

Related

#638 also changes ClaudeAccountEmail.ts (it caches the .claude.json read by mtime). The two overlap in render(); with this change .claude.json is read only on the fallback path. Whichever lands second will need a small rebase.

The Claude Account Email widget read oauthAccount.emailAddress from
.claude.json. Claude Code writes that record at login and does not
rewrite it when the credentials change underneath, so after an account
switch the widget could keep naming the previous account, next to usage
widgets that read the live credentials and report on the new one.

The email now comes from GET /api/oauth/profile, requested with the
usage credentials and the usage request's headers, proxy rules and
deadline. Answers are cached in ~/.cache/ccstatusline/account-email.json
(owner-only) under the usage cache's login fingerprint, so an
access-token refresh reuses them and only a login change triggers
another request. Up to ten logins are kept, so profiles used side by
side do not evict each other. A failed lookup backs off for that login
only: 30 seconds, or after a 429 the server's Retry-After. Without an
OAuth login, or until a lookup succeeds, the widget falls back to
.claude.json as before.

Fixes sirmalloc#538
@sirmalloc
sirmalloc merged commit bca0baf into sirmalloc:main Oct 29, 2026
3 checks passed
pcvelz added a commit to pcvelz/ccstatusline-usage that referenced this pull request Nov 2, 2026
Upstream: weekly limit timers (sirmalloc#768), prompt cache miss widgets (sirmalloc#770), context window size env override (sirmalloc#352, sirmalloc#407), windowed speed fixes (sirmalloc#769, sirmalloc#771), separator/renderer fixes (sirmalloc#773), custom-command styling (sirmalloc#775), clickable cwd links (sirmalloc#744), account email from live credentials (sirmalloc#724), cache timer reported expiry + short toggle (sirmalloc#576, sirmalloc#592), claude-status lock (sirmalloc#635, sirmalloc#772), dedup token metrics (sirmalloc#622, sirmalloc#725, sirmalloc#735).
Not adopted: sirmalloc#765 terminal width from COLUMNS with fixed reserve - the fork keeps flexMode/compactThreshold and tmux pane priority.

Hand edits outside conflicts:
- README.md: kept fork Configure flex separator behavior bullet and older-updates details wrapper; dropped upstream Related Projects additions (claude-eta)
- AGENTS.md: kept fork terminal width wording (sirmalloc#765 not adopted)
- src/types/FlexMode.ts, src/tui/components/TerminalWidthMenu.tsx, src/tui/components/__tests__/TerminalWidthMenu.test.ts, src/tui/components/TerminalOptionsMenu.tsx, src/tui/components/__tests__/TerminalOptionsMenu.test.ts: restored, fork keeps terminal width settings
- src/tui/App.tsx, src/tui/components/index.ts, src/types/index.ts: re-wired terminal width menu and FlexMode export
- src/types/StatusJSON.ts: dropped fork duplicate prompt_cache key, upstream schema is a superset
- src/utils/terminal.ts: COLUMNS source gated on non-tmux so tmux pane width keeps priority
- src/widgets/WeeklyLimitTimer.ts, src/utils/usage-prefetch.ts, src/widgets/__tests__/WeeklyLimitTimer.test.ts: compat fix - fableUsage/fableResetAt -> weeklyFableUsage/weeklyFableResetAt
- src/widgets/ApiUsage.tsx: compat fix - Context Bar honors effective context window (override, clamp, 1.0M format); Weekly renders a valid value despite a session error
- src/widgets/__tests__/ApiUsage.localModelFallback.test.ts: output tokens no longer counted in context (upstream sirmalloc#730)
- src/utils/__tests__/renderer-flex-width.test.ts, src/utils/__tests__/renderer-ansi.test.ts, src/utils/__tests__/gradient.test.ts, src/utils/__tests__/renderer-separator-collapse.test.ts, src/utils/__tests__/config.test.ts, src/tui/__tests__/App.test.ts: fork deviation - fork width model (flexMode, 6-column full reserve, kept settings, Terminal Width menu entry)
- src/utils/__tests__/default-colors.test.ts: fork deviation - accept ansi256:/hex: defaults
- src/utils/__tests__/usage-prefetch.test.ts: fork deviation - weekly usage comes from the API, fork widget imports from ApiUsage
- src/widgets/__tests__/CurrentWorkingDir.test.ts, src/widgets/__tests__/CustomCommand.test.ts: Settings literal gains flexMode/compactThreshold
- src/utils/__tests__/context-window.test.ts, src/utils/__tests__/jsonl-metrics.test.ts: restored fork tests upstream dropped
- docs/test-retirements.md: ledger for the dev-reload snapshot-path and RemoteControlStatus glyph (sirmalloc#605) tests upstream replaced
eric-engberg added a commit to eric-engberg/ccstatusline that referenced this pull request Nov 5, 2026
…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.
sirmalloc added a commit that referenced this pull request Nov 6, 2026
* feat: label Extra Usage as spend on accounts without plan limits

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.

* refactor: share the cache file lock

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.

* feat: add a daily state file for per-day usage tracking

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.

* feat: track each day's starting extra usage spend

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.

* feat: work out today's extra usage spend during the usage prefetch

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.

* feat: add an Extra Usage Today widget

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.

* fix: count a cached extra usage total for the day it was fetched

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.

* test: check fetchUsageData reports the login and fetch time

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.

* test: cover the Spend label with an edited label

Upstream's label editor (#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.

* refactor: keep the Enterprise wording, drop the Spend label detection

With the label editor (#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.

* test: check the picker lists the Extra Usage widgets first, however many there are

* test: look for the Extra Usage widgets among the widgets that mention the word

* refactor(widgets): share Extra Usage Used and Remaining's code

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.

* fix: drop extra usage times stamped while the clock ran ahead

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.

* fix(usage): remove daily.json's temp files when a write fails

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.

* fix(usage): keep today's extra usage spend across a token refresh

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.

* refactor(usage): tidy the account lookup and the record merge

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.

* refactor(usage): ask the profile endpoint once for the account email and the account key

The Claude Account Email widget (#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.

* docs(development): the account email and Extra Usage Today share one profile lookup

* refactor(usage): name the account id before fingerprinting it

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.

* fix: enable label editing for Extra Usage Today

---------

Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
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.

claude-account-email reads a login-time snapshot, so it can name a different account than the usage widgets

2 participants