cache usage per ai account, so a switched-away account still answers

half a usage reading cannot be taken for an account you are not signed into.
the limits come from anthropic's oauth endpoint using the credential the cli
holds for the CURRENT login, and the tokens come from that login's
transcripts — switch accounts and the previous one is not slow to read, it
is unreadable.

so: `ai_provider_usage`, keyed (user_id, ai_provider, email_account), with a
snapshot taken at the end of every turn. fire-and-forget off the `result`
message, owner-gated, capped by the reader's own 60s cache so a burst of
short turns is not a burst of 150MB scans.

the panel gains tabs, one per account. GET /chat/usage now answers from
postgres alone — instant, and it carries the accounts that cannot be
measured — and ?refresh=1 re-measures the signed-in one and writes it back.
the client fires both and swaps.

the reason a stale row is worth showing rather than apologising for is that
`resetsAt` is an ABSOLUTE instant. a row from six hours ago has an as-of
percentage and an exactly correct "resets in 14h 20m", ticked client-side
with no network. and where a window has rolled over since, `currentLimit`
reads it as 0% — the stored figure is not merely stale there, it is wrong in
the direction that matters, since the account is actually free. weekly rolls
forward to its next boundary (the length is in the kind, so that is
arithmetic); session does not, because the five-hour window starts on first
use rather than on a grid and there is no clock to count down.

fetchedAt is now the older of the two halves rather than a stamp at the end,
so a cached reading does not report itself as current.

needs `bun db:push`. degrades to no tabs without it rather than a 500.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 04:46:31 +00:00
co-authored by Claude Opus 5
parent dc68a289ff
commit 9745f54fa1
14 changed files with 704 additions and 63 deletions
+23 -13
View File
@@ -127,7 +127,7 @@ function planLabel(login: Record<string, unknown>): string | null {
* account and the limits belong to exactly one of them; a panel of percentages that does not say whose is
* a panel you cannot act on.
*/
async function readAccountEmail(): Promise<string | null> {
export async function readAccountEmail(): Promise<string | null> {
try {
const raw = await readFile(join(getOwnerHomeDir(''), '.claude.json'), 'utf-8');
const account = (JSON.parse(raw) as Record<string, unknown>).oauthAccount as Record<string, unknown> | undefined;
@@ -289,26 +289,34 @@ const windowStillOpen = (limit: UsageLimit, now: number): boolean => {
return Number.isNaN(at) || at > now;
};
async function collectLimits(): Promise<LimitsResult & { plan: string | null }> {
/**
* `measuredAt` travels with the reading rather than being stamped at the end.
*
* The panel shows "as of N minutes ago", and a reading served from a cache was measured when the CACHE was
* filled, not when it was handed out. Stamping on the way out would report a minute-old figure as current —
* which is the one thing this whole feature exists to avoid.
*/
async function collectLimits(): Promise<LimitsResult & { plan: string | null; measuredAt: number }> {
const login = await readOauthLogin();
const now = Date.now();
if (!login) {
return { limits: [], plan: null, problem: 'Claude Code is not signed in on this machine.' };
return { limits: [], plan: null, measuredAt: now, problem: 'Claude Code is not signed in on this machine.' };
}
const now = Date.now();
const cached = limitsCache && now - limitsCache.at < LIMITS_TTL_MS ? limitsCache.result : null;
if (cached) return { ...cached, plan: login.plan };
if (limitsCache && now - limitsCache.at < LIMITS_TTL_MS) {
return { ...limitsCache.result, plan: login.plan, measuredAt: limitsCache.at };
}
const fresh = await probeLimits(login.accessToken);
if (fresh.limits.length) {
limitsCache = { at: now, result: fresh };
return { ...fresh, plan: login.plan };
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, problem: fresh.problem };
return { limits: stale, plan: login.plan, measuredAt: limitsCache?.at ?? now, problem: fresh.problem };
}
// ── Local transcript scan ──
@@ -468,12 +476,12 @@ async function scanTranscripts(): Promise<ScanResult> {
return { days: dates.map((date) => ({ date, tokens: byDay.get(date) ?? 0 })), models };
}
async function collectScan(): Promise<ScanResult> {
async function collectScan(): Promise<ScanResult & { measuredAt: number }> {
const now = Date.now();
if (scanCache && now - scanCache.at < SCAN_TTL_MS) return scanCache.result;
if (scanCache && now - scanCache.at < SCAN_TTL_MS) return { ...scanCache.result, measuredAt: scanCache.at };
const result = await scanTranscripts();
scanCache = { at: now, result };
return result;
return { ...result, measuredAt: now };
}
/**
@@ -487,7 +495,7 @@ export async function readClaudeUsage(): Promise<ClaudeUsage> {
collectLimits(),
collectScan().catch((err: unknown) => {
logger.warn('Could not scan Claude transcripts for usage', { error: String(err) });
return { days: recentDates().map((date) => ({ date, tokens: 0 })), models: [] };
return { days: recentDates().map((date) => ({ date, tokens: 0 })), models: [], measuredAt: Date.now() };
}),
readAccountEmail(),
]);
@@ -498,6 +506,8 @@ export async function readClaudeUsage(): Promise<ClaudeUsage> {
...(limits.problem ? { limitsProblem: limits.problem } : {}),
days: scan.days,
models: scan.models,
fetchedAt: new Date().toISOString(),
// The OLDER of the two halves, because that is how old the reading is. Claiming the newer one would
// describe a panel as fresher than its stalest number.
fetchedAt: new Date(Math.min(limits.measuredAt, scan.measuredAt)).toISOString(),
};
}