`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>
534 lines
22 KiB
TypeScript
534 lines
22 KiB
TypeScript
import { readdir, readFile, stat } from 'node:fs/promises';
|
|
import { join } from 'node:path';
|
|
import { getOwnerHomeDir } from '@@/data-path';
|
|
import { logger } from './logger';
|
|
|
|
/**
|
|
* What Claude Code's own usage display shows, for the OWNER of this machine.
|
|
*
|
|
* ── Why this reads the CLI's state instead of counting its own ──
|
|
*
|
|
* Officer has none of these numbers and cannot get them by watching its own traffic. Per-session cost is
|
|
* accumulated in memory in `websocket.ts` and dies with `pm2 restart officer`; nothing aggregates it across
|
|
* sessions, days or models; and the rate-limit windows are a property of the ANTHROPIC ACCOUNT, spent by
|
|
* every `claude` on the machine — this chat, a terminal, a task runner, a pipeline step. A number derived
|
|
* from what Officer happened to see would be lower than the truth by however much was spent elsewhere, and
|
|
* silently so.
|
|
*
|
|
* So both halves come from the harness's own record:
|
|
*
|
|
* - **Limits** from Anthropic's OAuth usage endpoint, with the token the CLI stored at login. This is the
|
|
* only source; there is no local file holding the window percentages.
|
|
* - **Tokens** by scanning the transcripts under `~/.claude/projects`, which is what every turn writes and
|
|
* what the CLI reads back for the same display.
|
|
*
|
|
* The shape is lifted from omarchy's `omarchy-agent-usage-claude` collector (basecamp/omarchy, quattro), and
|
|
* the correctness details that are not obvious were taken from it rather than rediscovered: dedup by message
|
|
* id, the four token counters, the percent-versus-fraction detection, and reading the model-scoped window out
|
|
* of `limits[]` rather than the flat buckets.
|
|
*
|
|
* ── Owner only, and structurally so ──
|
|
*
|
|
* These are the owner's subscription limits and the owner's transcripts. `getOwnerHomeDir` ignores the email
|
|
* it is passed and always answers the owner, which is the one thing that must not happen by accident here —
|
|
* so nothing in this file takes an identity at all. The caller checks `isOwner`; this module cannot be asked
|
|
* about anyone else.
|
|
*/
|
|
|
|
export type UsageLimit = {
|
|
/** `Session`, `Weekly`, `Fable Weekly` — already the label to render. */
|
|
label: string;
|
|
percent: number;
|
|
resetsAt: string | null;
|
|
};
|
|
|
|
export type UsageDay = {
|
|
/** `YYYY-MM-DD`, local. The client turns it into a weekday. */
|
|
date: string;
|
|
tokens: number;
|
|
};
|
|
|
|
export type UsageModel = {
|
|
/** `Opus 5`, not `claude-opus-5`. */
|
|
name: string;
|
|
total: number;
|
|
input: number;
|
|
output: number;
|
|
cacheRead: number;
|
|
cacheWrite: number;
|
|
};
|
|
|
|
export type ClaudeUsage = {
|
|
account: { email: string | null; plan: string | null };
|
|
limits: UsageLimit[];
|
|
/**
|
|
* Why the limits are missing or stale. Local token stats are unaffected and still shown — which is the
|
|
* whole reason this is a field rather than an error response.
|
|
*/
|
|
limitsProblem?: string;
|
|
/** Seven entries, oldest first. The last one is today, including when it is zero. */
|
|
days: UsageDay[];
|
|
models: UsageModel[];
|
|
/** When this was measured, so the panel can say how old it is rather than implying it is live. */
|
|
fetchedAt: string;
|
|
};
|
|
|
|
const USAGE_ENDPOINT = 'https://api.anthropic.com/api/oauth/usage';
|
|
|
|
/**
|
|
* How long a reading is reused.
|
|
*
|
|
* 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 SCAN_TTL_MS = 60_000;
|
|
|
|
const claudeDir = (): string => process.env.CLAUDE_CONFIG_DIR?.trim() || join(getOwnerHomeDir(''), '.claude');
|
|
|
|
// ── Credentials and account ──
|
|
|
|
type OauthLogin = { accessToken: string; plan: string | null };
|
|
|
|
/**
|
|
* The access token and the display-safe plan label.
|
|
*
|
|
* Nothing else leaves the credential file: the token goes into one Authorization header and is never
|
|
* returned, logged or stored. `refreshToken` is deliberately not read — Officer does not refresh the CLI's
|
|
* login, and a token it never holds is one it cannot leak.
|
|
*/
|
|
async function readOauthLogin(): Promise<OauthLogin | null> {
|
|
try {
|
|
const raw = await readFile(join(claudeDir(), '.credentials.json'), 'utf-8');
|
|
const login = (JSON.parse(raw) as Record<string, unknown>).claudeAiOauth as Record<string, unknown> | undefined;
|
|
const token = typeof login?.accessToken === 'string' ? login.accessToken : '';
|
|
if (!login || !token) return null;
|
|
return { accessToken: token, plan: planLabel(login) };
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** `default_claude_max_20x` → `Max 20x`; else the subscription word, capitalised; else nothing. */
|
|
function planLabel(login: Record<string, unknown>): string | null {
|
|
const tier = typeof login.rateLimitTier === 'string' ? login.rateLimitTier : '';
|
|
const match = /max_(\d+x)/i.exec(tier);
|
|
if (match) return `Max ${match[1]}`;
|
|
const sub = typeof login.subscriptionType === 'string' ? login.subscriptionType : '';
|
|
return sub ? sub[0]!.toUpperCase() + sub.slice(1) : null;
|
|
}
|
|
|
|
/**
|
|
* Which account the CLI is logged in as.
|
|
*
|
|
* Not in the credential file and not in the usage payload — it lives in `~/.claude.json`, beside the config
|
|
* rather than inside `.claude/`. Worth showing because this machine's owner has more than one Anthropic
|
|
* 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.
|
|
*/
|
|
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;
|
|
const email = account?.emailAddress;
|
|
return typeof email === 'string' && email ? email : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// ── Limits ──
|
|
|
|
type LimitsResult = { limits: UsageLimit[]; problem?: string };
|
|
|
|
/*
|
|
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(
|
|
String(value ?? '')
|
|
.trim()
|
|
.replace('%', ''),
|
|
);
|
|
return Number.isFinite(n) ? n : -1;
|
|
};
|
|
|
|
/**
|
|
* Normalise a utilization figure to a percentage.
|
|
*
|
|
* The endpoint currently reports percentages (`15.0`), and older payloads reported fractions (`0.15`). The
|
|
* two are indistinguishable one value at a time — `1.0` is either 1% or 100% — so the SCALE IS DECIDED PER
|
|
* PAYLOAD: any value at or above 1 anywhere in it means the whole payload is percent-scaled.
|
|
*/
|
|
const normalise = (value: unknown, percentScale: boolean): number => {
|
|
const n = asPercent(value);
|
|
if (n < 0) return -1;
|
|
return percentScale ? n : n * 100;
|
|
};
|
|
|
|
const resetAt = (value: unknown): string | null => (typeof value === 'string' && value ? value : null);
|
|
|
|
/** `weekly_scoped` → `Weekly`. Read from the KIND, never from the model name — see `scopedLimits`. */
|
|
function scopedWindow(kind: string): string {
|
|
const text = kind.toLowerCase();
|
|
if (text.includes('month')) return 'Monthly';
|
|
if (text.includes('week') || text.includes('day')) return 'Weekly';
|
|
if (text.includes('hour') || text.includes('session')) return 'Session';
|
|
return '';
|
|
}
|
|
|
|
/**
|
|
* The model-scoped allowances, which live ONLY in the `limits` array.
|
|
*
|
|
* The matching flat buckets (`seven_day_opus`, `seven_day_sonnet`, …) are all null on this account while
|
|
* `limits[]` carries a real `weekly_scoped` entry for Fable — so a reader that trusts the buckets alone
|
|
* silently drops a window the account is actually spending against. Verified against the live payload,
|
|
* 2026-08-18.
|
|
*
|
|
* The window comes from `kind` rather than from parsing the title, because a display name like
|
|
* `Opus 5 (1M context)` puts "1M" in the text and that reads as a one-minute window.
|
|
*/
|
|
function scopedLimits(payload: Record<string, unknown>, percentScale: boolean): UsageLimit[] {
|
|
const entries = payload.limits;
|
|
if (!Array.isArray(entries)) return [];
|
|
const out: UsageLimit[] = [];
|
|
const seen = new Set<string>();
|
|
|
|
for (const entry of entries as Record<string, unknown>[]) {
|
|
const scope = entry?.scope as Record<string, unknown> | undefined;
|
|
const model = scope?.model as Record<string, unknown> | undefined;
|
|
if (!model) continue;
|
|
// An entry carrying only an id still names a window worth showing.
|
|
const name = String(model.display_name ?? model.id ?? '').trim();
|
|
const kind = String(entry.kind ?? '').trim();
|
|
// A model can hold more than one window, so only the pair tells them apart.
|
|
const key = `${name} |