usage panel on the claude code pill

clicking the harness pill opens what claude code's own usage display shows:
session/weekly/scoped limits with reset countdowns, tokens by day for the
last week, tokens by model.

officer has none of these numbers and could not derive them. per-session
cost is in-memory in websocket.ts and dies on restart, and the rate-limit
windows belong to the ANTHROPIC ACCOUNT — spent by every `claude` on the
box, not only by this chat. so both halves come from the cli's own record:
the oauth usage endpoint for the windows, ~/.claude/projects for the tokens,
~/.claude.json for which account is signed in.

shape and the non-obvious correctness details are lifted from omarchy's
agents collector (basecamp/omarchy quattro, via pastilhas.agents which adds
the account email): dedup by message id, the four token counters, the
per-payload percent-vs-fraction scale, and reading the model-scoped window
out of limits[] rather than the flat buckets — every seven_day_* bucket is
null on this account while limits[] carries a real fable window. verified
against the live payload today; limitsFromPayload is tested against it.

owner-only in the handler, not the registry: `chat` is confined, so a member
holding it reaches every route here, and these are someone else's windows.
the pill falls back to the plain label it always was for anyone else and for
opencode.

442ms cold, 1ms warm on 87MB of transcripts; cached 60s server-side, and the
query only runs when the popover opens.

not exercised through http — the route needs an officer restart.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 04:23:12 +00:00
co-authored by Claude Opus 5
parent 77f90a91f0
commit dc68a289ff
9 changed files with 1009 additions and 130 deletions
+503
View File
@@ -0,0 +1,503 @@
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.
*
* 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.
*/
const LIMITS_TTL_MS = 60_000;
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.
*/
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 };
let limitsCache: { at: number; result: LimitsResult } | null = null;
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}${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<string, unknown>): UsageLimit[] {
const bucket = (key: string): Record<string, unknown> | null => {
const value = payload[key];
return value && typeof value === 'object' ? (value as Record<string, unknown>) : 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<string, unknown>[]) : [];
const percentScale = [session?.utilization, weekly?.utilization, ...entries.map((e) => e?.percent)].some(
(v) => asPercent(v) >= 1,
);
const limits: UsageLimit[] = [];
const flat = (source: Record<string, unknown> | 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<LimitsResult> {
let payload: Record<string, unknown>;
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<string, unknown>;
} 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;
};
async function collectLimits(): Promise<LimitsResult & { plan: string | null }> {
const login = await readOauthLogin();
if (!login) {
return { limits: [], plan: null, 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 };
const fresh = await probeLimits(login.accessToken);
if (fresh.limits.length) {
limitsCache = { at: now, result: fresh };
return { ...fresh, plan: login.plan };
}
// 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 };
}
// ── 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<string, unknown>, 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<string[]> {
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<ScanResult> {
const dates = recentDates();
const byDay = new Map(dates.map((d) => [d, 0]));
const byModel = new Map<string, Buckets>();
/*
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<string>();
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<string, unknown>;
try {
entry = JSON.parse(line) as Record<string, unknown>;
} catch {
continue;
}
const message = (entry.message ?? {}) as Record<string, unknown>;
if (entry.type !== 'assistant' && message.role !== 'assistant') continue;
const usage = (message.usage ?? entry.usage) as Record<string, unknown> | 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<ScanResult> {
const now = Date.now();
if (scanCache && now - scanCache.at < SCAN_TTL_MS) return scanCache.result;
const result = await scanTranscripts();
scanCache = { at: now, result };
return result;
}
/**
* 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<ClaudeUsage> {
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: [] };
}),
readAccountEmail(),
]);
return {
account: { email, plan: limits.plan },
limits: limits.limits,
...(limits.problem ? { limitsProblem: limits.problem } : {}),
days: scan.days,
models: scan.models,
fetchedAt: new Date().toISOString(),
};
}