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
+19
View File
@@ -31,6 +31,7 @@ import { logger } from './logger';
import { resolveHomeDir } from '@@/user-home';
import type { ChatIdentity } from './claude-sessions';
import { officerModelForApiModel } from './model-aliases';
import { readClaudeUsage } from './claude-usage';
import { readSttConfig } from '../server-settings/stt';
/**
@@ -393,6 +394,24 @@ chatRouter.post('/tasks/:id/pause', async (ctx) => {
});
// GET /chat/models — Claude tiers only (the runner is the `claude` CLI).
/*
GET /chat/usage — what Claude Code's own usage display shows.
OWNER ONLY, and not because of the registry: `chat` is a `confined` permission, so a member holding it
reaches every route in this router. These are the OWNER'S subscription windows and the OWNER'S transcripts,
spent by every `claude` on the machine — a member seeing them would be reading someone else's account, and
a member seeing them go red would be reading a bill they cannot act on.
A 403 rather than an empty reading: there is no per-member version of this to fall back to. Members run
`claude` as their own Linux account with their own credential, so the honest answer to "what is my usage"
for them is a different query against a different login, and inventing it here would answer the wrong one.
*/
chatRouter.get('/usage', async (ctx) => {
const who = await chatIdentity(ctx.get('user'));
if (!who.isOwner) throw errors.FORBIDDEN('Usage belongs to the account that owns this machine');
return ctx.json(await readClaudeUsage());
});
chatRouter.get('/models', async (ctx: Context) => {
const who = await chatIdentity(ctx.get('user'));
try {
+105
View File
@@ -0,0 +1,105 @@
import { describe, expect, test } from 'bun:test';
import { friendlyModelName, limitsFromPayload } from './claude-usage';
/**
* The live payload, 2026-08-18, trimmed to the fields this reads.
*
* Kept verbatim rather than minimised because the two things that are easy to get wrong are both visible
* only in the whole shape: every model-scoped BUCKET is null while `limits[]` carries a real scoped entry,
* and the flat buckets and the array report the same windows twice.
*/
const LIVE = {
five_hour: { utilization: 15.0, resets_at: '2026-08-18T08:00:00.434323+00:00' },
seven_day: { utilization: 22.0, resets_at: '2026-08-19T01:00:00.434348+00:00' },
seven_day_oauth_apps: null,
seven_day_opus: null,
seven_day_sonnet: null,
limits: [
{ kind: 'session', group: 'session', percent: 15, resets_at: '2026-08-18T08:00:00.434323+00:00', scope: null },
{ kind: 'weekly_all', group: 'weekly', percent: 22, resets_at: '2026-08-19T01:00:00.434348+00:00', scope: null },
{
kind: 'weekly_scoped',
group: 'weekly',
percent: 6,
resets_at: '2026-08-19T01:00:00.434561+00:00',
scope: { model: { id: null, display_name: 'Fable' }, surface: null },
},
],
};
describe('limitsFromPayload', () => {
test('reads the live payload the way Claude Code displays it', () => {
expect(limitsFromPayload(LIVE)).toEqual([
{ label: 'Session', percent: 15, resetsAt: '2026-08-18T08:00:00.434323+00:00' },
{ label: 'Weekly', percent: 22, resetsAt: '2026-08-19T01:00:00.434348+00:00' },
{ label: 'Fable Weekly', percent: 6, resetsAt: '2026-08-19T01:00:00.434561+00:00' },
]);
});
test('a scoped window survives its flat bucket being null', () => {
// `seven_day_opus` and friends stayed behind at null when the scoped entries moved into `limits[]`, so
// a reader that trusts the buckets alone drops an allowance the account is actually spending against.
const scoped = limitsFromPayload(LIVE).find((l) => l.label === 'Fable Weekly');
expect(scoped?.percent).toBe(6);
expect(LIVE.seven_day_opus).toBeNull();
});
test('the unscoped entries in limits[] are not repeated as their own rows', () => {
// `session` and `weekly_all` restate the flat buckets. Reading both would print Session and Weekly twice.
expect(limitsFromPayload(LIVE).map((l) => l.label)).toHaveLength(3);
});
test('the oauth-apps bucket shadows the plain weekly one when it is populated', () => {
const limits = limitsFromPayload({
...LIVE,
seven_day_oauth_apps: { utilization: 40, resets_at: null },
});
expect(limits.find((l) => l.label === 'Weekly')?.percent).toBe(40);
});
test('a fractional payload is scaled up, and a percent payload is left alone', () => {
/*
The two conventions are indistinguishable one value at a time — `1.0` is either 1% or 100% — so the
scale is decided by the WHOLE payload: any value at or above 1 means it is already percentages.
*/
const fractions = { five_hour: { utilization: 0.37, resets_at: null }, limits: [] };
expect(limitsFromPayload(fractions)[0]?.percent).toBeCloseTo(37);
const percents = { five_hour: { utilization: 1.0, resets_at: null }, seven_day: { utilization: 22 }, limits: [] };
expect(limitsFromPayload(percents)[0]?.percent).toBe(1);
});
test('a scoped window is named from its kind, never from the model name', () => {
// "Opus 5 (1M context)" contains "1M", and reading a window out of the title turns that into a minute.
const limits = limitsFromPayload({
limits: [
{
kind: 'weekly_scoped',
percent: 12,
resets_at: null,
scope: { model: { display_name: 'Opus 5 (1M context)' } },
},
],
});
expect(limits).toEqual([{ label: 'Opus 5 (1M context) Weekly', percent: 12, resetsAt: null }]);
});
test('an empty or malformed payload produces no limits rather than throwing', () => {
expect(limitsFromPayload({})).toEqual([]);
expect(limitsFromPayload({ limits: 'not an array', five_hour: 7 })).toEqual([]);
});
});
describe('friendlyModelName', () => {
test('rejoins the version and drops the date suffix', () => {
expect(friendlyModelName('claude-opus-5')).toBe('Opus 5');
expect(friendlyModelName('claude-opus-4-8')).toBe('Opus 4.8');
expect(friendlyModelName('claude-haiku-4-5-20251001')).toBe('Haiku 4.5');
expect(friendlyModelName('claude-fable-5')).toBe('Fable 5');
});
test('handles ids that are not Anthropic ids', () => {
expect(friendlyModelName('kimi-for-coding')).toBe('Kimi For Coding');
expect(friendlyModelName('')).toBe('Unknown');
});
});
+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(),
};
}
@@ -1,5 +1,6 @@
import { describe, expect, test } from 'bun:test';
import { render, screen } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type { ModelOption } from 'state/useModels';
import { ModelSelector } from './ModelSelector';
@@ -24,19 +25,28 @@ const MODELS: ModelOption[] = [
{ id: 'openai/gpt-5', name: 'gpt-5', provider: 'openai' },
] as ModelOption[];
/*
A client, because the harness pill is a usage popover now and it asks who you are.
`usePermissions` answers from React Query, so without a provider the whole selector throws before any of
these assertions get near it. The query never resolves here — `isOwner` stays false and the pill renders as
the plain label it always was, which is the state every test below was written against.
*/
const mount = (over: Partial<Parameters<typeof ModelSelector>[0]> = {}) =>
render(
<ModelSelector
messages={[]}
availableModels={MODELS}
selectedModel="claude-code/opus"
onModelChange={() => {}}
model={null}
isConnected
isGenerating={false}
hasStarted
{...over}
/>,
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
<ModelSelector
messages={[]}
availableModels={MODELS}
selectedModel="claude-code/opus"
onModelChange={() => {}}
model={null}
isConnected
isGenerating={false}
hasStarted
{...over}
/>
</QueryClientProvider>,
);
const providerButton = (name: RegExp) => screen.getByRole('button', { name });
@@ -5,6 +5,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
import type { ModelOption } from 'state/useModels';
import { getProviderDisplayName } from 'state/useModels';
import type { ChatMessage } from '../types';
import { UsagePopover } from './UsagePopover';
const PROVIDER_DISPLAY: Record<string, string> = {
anthropic: 'Anthropic',
@@ -150,12 +151,20 @@ export function ModelSelector({
return (
<div className="mt-2 flex items-center justify-between gap-2">
{providerStripLocked ? (
// A static label, not an action — `bg-muted` rather than a filled accent. It used to be
// `bg-duck-dark/80 text-white`, and --duck-dark is near-white in dark mode, so this was white
// on white for every session that had already started, i.e. almost always.
<span className="shrink-0 truncate rounded-md bg-muted px-3 py-1 text-xs font-medium text-foreground">
{activeProvider ? displayName(activeProvider) : 'Chat'}
</span>
/*
The collapsed strip: a label, and for the owner on Claude Code, a button that opens the usage panel.
It used to be `bg-duck-dark/80 text-white`, and --duck-dark is near-white in dark mode, so this was
white on white for every session that had already started, i.e. almost always. `bg-muted` since.
Only interactive where there is something behind it. The numbers are the OWNER'S subscription
windows — the route refuses anyone else — and OpenCode has no equivalent, so both cases fall back
to exactly the label that was here before.
*/
<UsagePopover
label={activeProvider ? displayName(activeProvider) : 'Chat'}
harness={activeProvider === 'claude-code' ? 'claude-code' : 'other'}
/>
) : (
// Fifteen providers can be configured and the names are long ("Google Vertex AI"), so the strip
// scrolls rather than crushing the model select beside it.
@@ -0,0 +1,208 @@
import { useEffect, useState } from 'react';
import { usePermissions } from 'hooks/usePermissions';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import type { ClaudeUsage } from '../useClaudeUsage';
import { formatReset, formatTokens, useClaudeUsage, weekdayLabel } from '../useClaudeUsage';
/**
* Claude Code's own usage display, on the harness pill.
*
* Everything here comes from the CLI's own record — Anthropic's OAuth usage endpoint for the windows, the
* transcripts under `~/.claude/projects` for the tokens. Officer counts none of it itself, and could not:
* the windows are spent by every `claude` on the machine, not only by this chat. See
* `servers/api/chat/claude-usage.ts`.
*
* The layout follows omarchy's agents panel (basecamp/omarchy, quattro) because the owner already reads
* these numbers in that shape on this machine, and two different arrangements of the same three sections is
* a tax paid every time you look at either.
*/
/** A bar. `value` is 0–1; anything outside is clamped rather than allowed to overflow its track. */
const Meter = ({ value, strong = false }: { value: number; strong?: boolean }) => (
<div className="h-1 w-full overflow-hidden rounded-full bg-muted">
<div
className={`h-full rounded-full ${strong ? 'bg-foreground' : 'bg-foreground/50'}`}
style={{ width: `${Math.min(100, Math.max(0, value * 100))}%` }}
/>
</div>
);
const Section = ({ title, children }: { title: string; children: React.ReactNode }) => (
<div className="border-t border-border px-4 py-3">
<div className="mb-2 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">{title}</div>
{children}
</div>
);
const Limits = ({ usage, now }: { usage: ClaudeUsage; now: number }) => (
<Section title="Limits">
<div className="flex flex-col gap-3">
{usage.limits.map((limit) => {
const resets = formatReset(limit.resetsAt, now);
return (
<div key={limit.label} className="flex flex-col gap-1">
<div className="flex items-baseline justify-between gap-2">
<span className="truncate text-sm font-medium text-foreground">{limit.label}</span>
<span className="shrink-0 text-sm tabular-nums text-foreground">{Math.round(limit.percent)}%</span>
</div>
<Meter value={limit.percent / 100} strong />
{resets && <span className="text-xs text-muted-foreground">Resets in {resets}</span>}
</div>
);
})}
</div>
</Section>
);
const Days = ({ usage }: { usage: ClaudeUsage }) => {
// Every bar is scaled to the busiest day, so the shape of the week is readable even in a quiet one. A
// fixed scale would flatten a whole week of ordinary work into nothing.
const peak = Math.max(1, ...usage.days.map((d) => d.tokens));
return (
<Section title="Tokens by day">
<div className="flex flex-col gap-1.5">
{usage.days.map((day, index) => {
const today = index === usage.days.length - 1;
return (
<div key={day.date} className="flex items-center gap-3">
<span
className={`w-10 shrink-0 text-xs ${today ? 'font-semibold text-foreground' : 'text-muted-foreground'}`}
>
{today ? 'Today' : weekdayLabel(day.date)}
</span>
<div className="min-w-0 flex-1">
<Meter value={day.tokens / peak} strong={today} />
</div>
<span
className={`w-14 shrink-0 text-right text-xs tabular-nums ${today ? 'font-semibold text-foreground' : 'text-muted-foreground'}`}
>
{formatTokens(day.tokens)}
</span>
</div>
);
})}
</div>
</Section>
);
};
const Models = ({ usage }: { usage: ClaudeUsage }) => {
// Four, because the tail is a long list of models that answered once. The heaviest is the scale, the same
// way the week scales to its busiest day.
const models = usage.models.slice(0, 4);
const peak = Math.max(1, ...models.map((m) => m.total));
return (
<Section title="Tokens by model">
<div className="flex flex-col gap-1">
{models.map((model) => (
<div
key={model.name}
className="relative flex items-center justify-between gap-2 overflow-hidden rounded-md px-2 py-1.5"
title={`In ${formatTokens(model.input)} · out ${formatTokens(model.output)} · cache read ${formatTokens(model.cacheRead)} · cache write ${formatTokens(model.cacheWrite)}`}
>
{/* The bar sits BEHIND the row rather than beside it — the model names are long and a separate
track would leave no room for either. */}
<div
className="absolute inset-y-0 left-0 rounded-md bg-muted"
style={{ width: `${(model.total / peak) * 100}%` }}
/>
<span className="relative truncate text-sm text-foreground">{model.name}</span>
<span className="relative shrink-0 text-sm tabular-nums text-muted-foreground">
{formatTokens(model.total)}
</span>
</div>
))}
</div>
</Section>
);
};
type UsagePopoverProps = {
/** What the pill reads — the provider display name, unchanged. */
label: string;
/** Only Claude Code has any of this. OpenCode reports no windows and writes no transcripts we can read. */
harness: 'claude-code' | 'other';
};
/**
* The harness pill, and what it opens.
*
* The pill is inert once a conversation has started — there is only one harness left to choose, so the
* provider strip has already collapsed to a label. That is exactly why it is free to become a button: it
* costs nothing that was working, and it is the one piece of chrome in the composer that names the thing
* these numbers are about.
*/
export function UsagePopover({ label, harness }: UsagePopoverProps) {
/*
The owner check lives HERE rather than in the caller, so the composer does not have to know that a pill
has anything to do with permissions.
`usePermissions` fails open on grants by design — hiding a dock icon is a courtesy and the 403 is the
lock — but `isOwner` itself defaults to FALSE, which is the direction that matters here: a transient
failure leaves the pill as the plain label it has always been, rather than offering a member a button
whose route will refuse them.
*/
const { isOwner } = usePermissions();
const enabled = isOwner && harness === 'claude-code';
const [open, setOpen] = useState(false);
const { data, isLoading, isError } = useClaudeUsage(enabled && open);
// The countdowns tick. Only while the panel is open — an interval running behind a closed popover is a
// re-render a minute for something nobody is looking at.
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
if (!open) return;
setNow(Date.now());
const timer = setInterval(() => setNow(Date.now()), 30_000);
return () => clearInterval(timer);
}, [open]);
const pill = 'shrink-0 truncate rounded-md bg-muted px-3 py-1 text-xs font-medium text-foreground';
if (!enabled) return <span className={pill}>{label}</span>;
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button type="button" className={`${pill} cursor-pointer hover:bg-foreground/10`} title="Usage and limits">
{label}
</button>
</PopoverTrigger>
<PopoverContent align="start" side="top" className="z-[900] w-80 p-0">
<div className="px-4 py-3">
<div className="text-sm font-semibold text-foreground">{label}</div>
<div className="truncate text-xs text-muted-foreground">
{[data?.account.plan, data?.account.email].filter(Boolean).join(' · ') ||
(isLoading ? 'Reading usage…' : 'Not signed in')}
</div>
</div>
{/* A failure never blanks the panel: the two halves are independent server-side, so a limits problem
still leaves real token history below it. */}
{isError && (
<div className="border-t border-border px-4 py-3 text-xs text-muted-foreground">
Couldn't read usage on this machine.
</div>
)}
{data?.limitsProblem && (
<div className="border-t border-border px-4 py-3 text-xs text-muted-foreground">{data.limitsProblem}</div>
)}
{isLoading && !data && (
<div className="border-t border-border px-4 py-6 text-center text-xs text-muted-foreground">Loading…</div>
)}
{data && (
<>
{data.limits.length > 0 && <Limits usage={data} now={now} />}
<Days usage={data} />
{data.models.length > 0 && <Models usage={data} />}
</>
)}
</PopoverContent>
</Popover>
);
}
@@ -0,0 +1,57 @@
import { describe, expect, test } from 'bun:test';
import { formatReset, formatTokens, weekdayLabel } from './useClaudeUsage';
describe('formatTokens', () => {
test('scales to the unit the number deserves', () => {
expect(formatTokens(0)).toBe('0');
expect(formatTokens(842)).toBe('842');
expect(formatTokens(25_600)).toBe('25.6K');
expect(formatTokens(1_900_000)).toBe('1.9M');
expect(formatTokens(248_500_000)).toBe('248.5M');
expect(formatTokens(1_200_000_000)).toBe('1.2B');
});
test('a day with no work reads as zero, not as an empty cell', () => {
// The week always shows seven rows, and a quiet day is information — a blank one looks like a bug.
expect(formatTokens(-1)).toBe('0');
expect(formatTokens(Number.NaN)).toBe('0');
});
});
describe('formatReset', () => {
const now = Date.parse('2026-08-18T04:14:00Z');
test('hours and minutes, the way Claude Code says it', () => {
expect(formatReset('2026-08-18T08:00:00Z', now)).toBe('3h 46m');
expect(formatReset('2026-08-19T01:00:00Z', now)).toBe('20h 46m');
});
test('under an hour drops the hours', () => {
expect(formatReset('2026-08-18T04:40:00Z', now)).toBe('26m');
});
test('past a day it says days, rather than a three-digit hour count', () => {
expect(formatReset('2026-08-20T06:14:00Z', now)).toBe('2d 2h');
});
test('a window that has already reset says nothing at all', () => {
// A countdown at zero is worse than no countdown: the next reading carries the new window, and until it
// arrives "Resets in 0m" reads as a limit that is stuck.
expect(formatReset('2026-08-18T04:00:00Z', now)).toBeNull();
expect(formatReset(null, now)).toBeNull();
expect(formatReset('not a date', now)).toBeNull();
});
});
describe('weekdayLabel', () => {
test('names the local day', () => {
// Parsed field by field rather than through `new Date(string)`, which reads a bare `YYYY-MM-DD` as UTC
// midnight — so anywhere west of Greenwich every label slid back a day.
expect(weekdayLabel('2026-08-17')).toBe('Mon');
expect(weekdayLabel('2026-08-18')).toBe('Tue');
});
test('an unparseable date falls back to itself rather than to Invalid Date', () => {
expect(weekdayLabel('nonsense')).toBe('nonsense');
});
});
@@ -0,0 +1,81 @@
import { useQuery } from '@tanstack/react-query';
import { useClient } from 'hooks/useClient';
export type UsageLimit = { label: string; percent: number; resetsAt: string | null };
export type UsageDay = { date: string; tokens: number };
export type UsageModel = {
name: string;
total: number;
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
};
export type ClaudeUsage = {
account: { email: string | null; plan: string | null };
limits: UsageLimit[];
limitsProblem?: string;
days: UsageDay[];
models: UsageModel[];
fetchedAt: string;
};
/**
* The owner's Claude Code usage — limits, tokens by day, tokens by model.
*
* `enabled` rather than an always-on query, and that is the whole design of this hook: the server side reads
* ~90MB of transcripts and probes an endpoint that rate-limits, so it must not run because a chat panel
* mounted. It runs when the popover opens.
*
* Owner-only at the route (a member gets a 403), so pass `enabled: isOwner` from the caller rather than
* letting every member's composer fire a request that is always refused.
*/
export function useClaudeUsage(enabled: boolean) {
const client = useClient();
return useQuery<ClaudeUsage>({
queryKey: ['chat', 'usage'],
queryFn: () => client.get<ClaudeUsage>('/chat/usage'),
enabled,
// The server caches for a minute; asking more often than that gets the same answer back.
staleTime: 60_000,
// The numbers move while you watch — a turn finishing pushes the session window up — but not fast, and
// a popover that reloads under the cursor is worse than one that is a minute behind.
refetchOnWindowFocus: false,
retry: false,
});
}
/** `1.9M`, `248.5M`, `1.2B`. Bare integers below a thousand. */
export function formatTokens(value: number): string {
if (!Number.isFinite(value) || value <= 0) return '0';
if (value >= 1e9) return `${(value / 1e9).toFixed(1)}B`;
if (value >= 1e6) return `${(value / 1e6).toFixed(1)}M`;
if (value >= 1e3) return `${(value / 1e3).toFixed(1)}K`;
return String(Math.round(value));
}
/**
* How long until a window resets, as `3h 46m`.
*
* Null when there is no reset time or it has already passed — a countdown that has run out says nothing
* useful, and the next reading will carry the new window anyway.
*/
export function formatReset(resetsAt: string | null, now: number): string | null {
if (!resetsAt) return null;
const at = Date.parse(resetsAt);
if (Number.isNaN(at)) return null;
const minutes = Math.floor((at - now) / 60_000);
if (minutes <= 0) return null;
const hours = Math.floor(minutes / 60);
if (hours >= 24) return `${Math.floor(hours / 24)}d ${hours % 24}h`;
return hours > 0 ? `${hours}h ${minutes % 60}m` : `${minutes}m`;
}
/** `2026-08-18` → `Mon`. Parsed as a LOCAL date, so the label cannot slide a day west of UTC. */
export function weekdayLabel(date: string): string {
const [year, month, day] = date.split('-').map(Number);
if (!year || !month || !day) return date;
return new Date(year, month - 1, day).toLocaleDateString(undefined, { weekday: 'short' });
}