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 { try { const raw = await readFile(join(claudeDir(), '.credentials.json'), 'utf-8'); const login = (JSON.parse(raw) as Record).claudeAiOauth as Record | 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 | 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 { try { const raw = await readFile(join(getOwnerHomeDir(''), '.claude.json'), 'utf-8'); const account = (JSON.parse(raw) as Record).oauthAccount as Record | 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(); 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, percentScale: boolean): UsageLimit[] { const entries = payload.limits; if (!Array.isArray(entries)) return []; const out: UsageLimit[] = []; const seen = new Set(); for (const entry of entries as Record[]) { const scope = entry?.scope as Record | undefined; const model = scope?.model as Record | 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}${kind}`; if (!name || seen.has(key)) continue; const percent = normalise(entry.percent, percentScale); if (percent < 0) continue; seen.add(key); const window = scopedWindow(kind); out.push({ label: window ? `${name} ${window}` : name, percent, resetsAt: resetAt(entry.resets_at) }); } return out; } /** * The limits in a usage payload, in display order: session, weekly, then anything model-scoped. * * Exported for its own sake — this is where every subtlety in the file lives (the per-payload percent scale, * `seven_day_oauth_apps` shadowing `seven_day`, and the scoped windows that exist only in `limits[]`), and * none of it is reachable through `readClaudeUsage` without a network call and a signed-in CLI. */ export function limitsFromPayload(payload: Record): UsageLimit[] { const bucket = (key: string): Record | null => { const value = payload[key]; return value && typeof value === 'object' ? (value as Record) : null; }; const session = bucket('five_hour'); const weekly = bucket('seven_day_oauth_apps') ?? bucket('seven_day'); const entries = Array.isArray(payload.limits) ? (payload.limits as Record[]) : []; const percentScale = [session?.utilization, weekly?.utilization, ...entries.map((e) => e?.percent)].some( (v) => asPercent(v) >= 1, ); const limits: UsageLimit[] = []; const flat = (source: Record | null, label: string) => { if (!source) return; const percent = normalise(source.utilization, percentScale); if (percent >= 0) limits.push({ label, percent, resetsAt: resetAt(source.resets_at) }); }; flat(session, 'Session'); flat(weekly, 'Weekly'); limits.push(...scopedLimits(payload, percentScale)); return limits; } async function probeLimits(accessToken: string): Promise { let payload: Record; try { const response = await fetch(USAGE_ENDPOINT, { headers: { Authorization: `Bearer ${accessToken}`, 'anthropic-beta': 'oauth-2025-04-20', Accept: 'application/json', }, signal: AbortSignal.timeout(10_000), }); if (!response.ok) { const retry = response.headers.get('retry-after'); return { limits: [], problem: response.status === 429 ? `Anthropic is rate limiting usage checks${retry ? ` (retry after ${retry}s)` : ''}.` : `Anthropic's usage endpoint returned ${response.status}.`, }; } payload = (await response.json()) as Record; } catch { return { limits: [], problem: "Couldn't reach Anthropic's usage endpoint." }; } const limits = limitsFromPayload(payload); if (!limits.length) return { limits: [], problem: "Anthropic's usage endpoint reported no limits." }; return { limits }; } /** * A cached percentage outlives the probe that measured it — but only until its window rolls over. * * Once a window has reset, the figure describes a period that is over, and a stale 78% would misreport an * allowance that is now untouched. A window with no reset time, or one that will not parse, is kept: an * unreadable timestamp is no reason to throw away a real number. */ const windowStillOpen = (limit: UsageLimit, now: number): boolean => { if (!limit.resetsAt) return true; const at = Date.parse(limit.resetsAt); return Number.isNaN(at) || at > now; }; /** * `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 { const login = await readOauthLogin(); const now = Date.now(); if (!login) { return { limits: [], plan: null, measuredAt: now, problem: 'Claude Code is not signed in on this machine.' }; } // 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) { if (account) lastGoodLimits.set(account, { at: now, result: fresh }); return { ...fresh, plan: login.plan, measuredAt: now }; } // 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 ── type Buckets = { input: number; output: number; cacheRead: number; cacheWrite: number }; type ScanResult = { days: UsageDay[]; models: UsageModel[] }; let scanCache: { at: number; result: ScanResult } | null = null; const localDate = (at: Date): string => `${at.getFullYear()}-${String(at.getMonth() + 1).padStart(2, '0')}-${String(at.getDate()).padStart(2, '0')}`; /** The last seven local dates, oldest first, with today last. */ function recentDates(): string[] { const out: string[] = []; for (let offset = 6; offset >= 0; offset--) { const day = new Date(); day.setDate(day.getDate() - offset); out.push(localDate(day)); } return out; } /** A transcript timestamp as a LOCAL date. The file writes UTC ISO; the panel is about the owner's days. */ function dateOfEntry(value: unknown): string { if (typeof value !== 'string' || !value) return localDate(new Date()); const at = new Date(value); return Number.isNaN(at.getTime()) ? localDate(new Date()) : localDate(at); } const count = (usage: Record, snake: string, camel: string): number => { const n = Number(usage[snake] ?? usage[camel] ?? 0); return Number.isFinite(n) ? Math.round(n) : 0; }; /** * `claude-opus-4-8` → `Opus 4.8`. * * Ids arrive hyphenated with the version split across segments and sometimes a date suffix, so the numeric * run is rejoined into one version and the words around it title-cased. */ export function friendlyModelName(id: string): string { if (!id) return 'Unknown'; const name = id.replace(/^claude-/, '').replace(/-\d{8}$/, ''); const words: string[] = []; let version: string[] = []; for (const part of name.split('-')) { if (!part) continue; if (/^\d/.test(part)) { version.push(part); continue; } if (version.length) { words.push(version.join('.')); version = []; } words.push(part === 'gpt' ? 'GPT' : part[0]!.toUpperCase() + part.slice(1)); } if (version.length) words.push(version.join('.')); return words.length ? words.join(' ') : 'Unknown'; } /** Every `*.jsonl` under `~/.claude/projects`, one level of project directories deep. */ async function transcriptFiles(root: string): Promise { let projects: string[]; try { projects = await readdir(root); } catch { return []; } const files = await Promise.all( projects.map(async (name) => { const dir = join(root, name); try { if (!(await stat(dir)).isDirectory()) return []; return (await readdir(dir)).filter((f) => f.endsWith('.jsonl')).map((f) => join(dir, f)); } catch { return []; } }), ); return files.flat(); } async function scanTranscripts(): Promise { const dates = recentDates(); const byDay = new Map(dates.map((d) => [d, 0])); const byModel = new Map(); /* Dedup by the ANTHROPIC message id, and this is the one detail the whole scan turns on. The same assistant message is written to more than one transcript: a `/clear` chain splices, a resume replays, and compaction rewrites. Summing the files naively double-counts, and it does so unevenly — the long-running conversations, which are exactly the ones the numbers are about, are the worst affected. */ const seen = new Set(); for (const path of await transcriptFiles(join(claudeDir(), 'projects'))) { let text: string; try { text = await readFile(path, 'utf-8'); } catch { continue; } let lineNumber = 0; for (const line of text.split('\n')) { lineNumber++; // A cheap substring test before `JSON.parse` — most lines in a transcript are tool traffic, and // parsing ~90MB of them to discard the result costs seconds. if (!line.includes('"usage":')) continue; let entry: Record; try { entry = JSON.parse(line) as Record; } catch { continue; } const message = (entry.message ?? {}) as Record; if (entry.type !== 'assistant' && message.role !== 'assistant') continue; const usage = (message.usage ?? entry.usage) as Record | undefined; if (!usage || typeof usage !== 'object') continue; const id = message.id ?? entry.messageId ?? ''; const key = id ? String(id) : `${path}:${entry.uuid ?? entry.requestId ?? lineNumber}`; if (seen.has(key)) continue; seen.add(key); const input = count(usage, 'input_tokens', 'inputTokens'); const output = count(usage, 'output_tokens', 'outputTokens'); const cacheRead = count(usage, 'cache_read_input_tokens', 'cacheReadInputTokens'); const cacheWrite = count(usage, 'cache_creation_input_tokens', 'cacheCreationInputTokens'); const total = input + output + cacheRead + cacheWrite; if (total <= 0) continue; const model = String(message.model ?? entry.model ?? 'claude'); const bucket = byModel.get(model) ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }; bucket.input += input; bucket.output += output; bucket.cacheRead += cacheRead; bucket.cacheWrite += cacheWrite; byModel.set(model, bucket); const day = dateOfEntry(entry.timestamp ?? message.timestamp); if (byDay.has(day)) byDay.set(day, byDay.get(day)! + total); } } const models = [...byModel.entries()] .map(([id, b]) => ({ name: friendlyModelName(id), total: b.input + b.output + b.cacheRead + b.cacheWrite, ...b })) .sort((a, b) => b.total - a.total); return { days: dates.map((date) => ({ date, tokens: byDay.get(date) ?? 0 })), models }; } async function collectScan(): Promise { const now = Date.now(); if (scanCache && now - scanCache.at < SCAN_TTL_MS) return { ...scanCache.result, measuredAt: scanCache.at }; const result = await scanTranscripts(); scanCache = { at: now, result }; return { ...result, measuredAt: now }; } /** * The whole reading. * * The two halves run concurrently and neither can fail the other: a signed-out CLI still gets token history, * and an unreadable transcript directory still gets limits. */ export async function readClaudeUsage(): Promise { const [limits, scan, email] = await Promise.all([ 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: [], measuredAt: Date.now() }; }), readAccountEmail(), ]); return { account: { email, plan: limits.plan }, limits: limits.limits, ...(limits.problem ? { limitsProblem: limits.problem } : {}), days: scan.days, models: scan.models, // 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(), }; }