usage limits are read live, and a cached reading belongs to one account

`limitsCache` was keyed on nothing. Switching account changes which credential
`readOauthLogin` returns, and the cached entry was served anyway — so the panel
showed the previous account's percentages under the new account's name for a
minute, and on a failed probe for as long as the probe kept failing. Reported
maxed out on one account, switched, and the numbers did not move.

So the limits have no TTL now: every request probes. They are per-account and they
are the number somebody opens the panel to read, so a stale one is worse than a
slow one.

The store stays, for the failure path only, and keyed on the account it was
measured for. A flaky request degrades to that account's own last reading rather
than to a blank panel or — much worse — to somebody else's numbers. A reading that
cannot be attributed gets no fallback at all rather than a shared one.

The client's five-minute stale window went with it: that query is only the instant
first paint, and holding it meant painting the old account's stored row until the
refresh landed.

The transcript scan keeps its cache. It reads ~90MB and its answer is the same
whichever account is signed in — it counts what this machine wrote, not what an
account is allowed — so a switch does not invalidate it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-23 01:54:05 +00:00
co-authored by Claude Opus 5
parent 854f957058
commit aa35428dde
2 changed files with 38 additions and 16 deletions
+33 -13
View File
@@ -78,11 +78,15 @@ const USAGE_ENDPOINT = 'https://api.anthropic.com/api/oauth/usage';
/**
* How long a reading is reused.
*
* Both halves are expensive in different ways — the endpoint rate-limits, and the scan reads ~90MB of JSONL —
* and neither changes meaningfully inside a minute. Cached SEPARATELY so a network failure does not throw
* away a good scan, and so opening and shutting the popover is not a request per flick.
* The transcript scan reads ~90MB of JSONL and its answer does not change meaningfully inside a minute, so
* it is still served from cache. It is also the same answer whichever account is signed in — it counts what
* this machine wrote, not what an account is allowed — which is why switching account does not need to
* invalidate it.
*
* **The limits have no TTL and are never served from cache.** They are per-account and they are the number
* somebody opens this panel to read, so a stale one is worse than a slow one. The store is kept for the
* failure path only — see `lastGoodLimits`.
*/
const LIMITS_TTL_MS = 60_000;
const SCAN_TTL_MS = 60_000;
const claudeDir = (): string => process.env.CLAUDE_CONFIG_DIR?.trim() || join(getOwnerHomeDir(''), '.claude');
@@ -142,7 +146,21 @@ export async function readAccountEmail(): Promise<string | null> {
type LimitsResult = { limits: UsageLimit[]; problem?: string };
let limitsCache: { at: number; result: LimitsResult } | null = null;
/*
Last good reading PER ACCOUNT, and used only when a probe fails.
This was a TTL cache keyed on nothing, and the consequence was the whole feature lying. Switching account
changes which credential `readOauthLogin` returns, but a cached entry was served regardless — so the panel
showed the previous account's percentages under the new account's name for the length of the TTL, and the
failure fallback served them for as long as the probe kept failing. Reported by the owner: maxed out on one
account, switched, and the numbers did not move.
So there is no TTL any more. Every request probes, because these percentages are the entire point of the
panel and a stale one is worse than a slow one. What survives is the fallback, now keyed on the account it
was measured for, so a flaky request degrades to that account's own last reading rather than to a blank
panel or — far worse — to somebody else's numbers.
*/
const lastGoodLimits = new Map<string, { at: number; result: LimitsResult }>();
const asPercent = (value: unknown): number => {
const n = Number(
@@ -303,20 +321,22 @@ async function collectLimits(): Promise<LimitsResult & { plan: string | null; me
return { limits: [], plan: null, measuredAt: now, problem: 'Claude Code is not signed in on this machine.' };
}
if (limitsCache && now - limitsCache.at < LIMITS_TTL_MS) {
return { ...limitsCache.result, plan: login.plan, measuredAt: limitsCache.at };
}
// Which account this reading belongs to. Null would make every account share one entry, which is the bug
// this key exists to prevent, so an unattributable reading gets no fallback rather than a shared one.
const account = await readAccountEmail();
const fresh = await probeLimits(login.accessToken);
if (fresh.limits.length) {
limitsCache = { at: now, result: fresh };
if (account) lastGoodLimits.set(account, { at: now, result: fresh });
return { ...fresh, plan: login.plan, measuredAt: now };
}
// A failed probe falls back to the last good reading rather than to nothing — the numbers are minutes old
// at worst, and a panel that goes blank on one flaky request is worse than one that is slightly behind.
const stale = limitsCache?.result.limits.filter((l) => windowStillOpen(l, now)) ?? [];
return { limits: stale, plan: login.plan, measuredAt: limitsCache?.at ?? now, problem: fresh.problem };
// A failed probe falls back to THIS account's last good reading rather than to nothing — minutes old at
// worst, and a panel that blanks on one flaky request is worse than one slightly behind. Windows that have
// since reset are dropped: a stale 78% would misreport an allowance that is now untouched.
const previous = account ? lastGoodLimits.get(account) : undefined;
const stale = previous?.result.limits.filter((l) => windowStillOpen(l, now)) ?? [];
return { limits: stale, plan: login.plan, measuredAt: previous?.at ?? now, problem: fresh.problem };
}
// ── Local transcript scan ──
@@ -51,9 +51,11 @@ export function useClaudeUsage(enabled: boolean) {
queryKey: KEY,
queryFn: () => client.get<UsageResponse>('/chat/usage'),
enabled,
// The stored rows only change when a turn ends, and a turn ending does not happen while you are reading
// this panel. The refresh below is what keeps it honest, not a stale timer.
staleTime: 5 * 60_000,
// No stale window. This query is only the instant first paint — the refresh below is the real answer
// — and a five-minute one meant that after switching account the panel painted the OLD account's
// stored row and held it until the refresh landed, which is exactly the moment somebody decides the
// numbers are wrong.
staleTime: 0,
refetchOnWindowFocus: false,
retry: false,
});