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:
@@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user