cache usage per ai account, so a switched-away account still answers

half a usage reading cannot be taken for an account you are not signed into.
the limits come from anthropic's oauth endpoint using the credential the cli
holds for the CURRENT login, and the tokens come from that login's
transcripts — switch accounts and the previous one is not slow to read, it
is unreadable.

so: `ai_provider_usage`, keyed (user_id, ai_provider, email_account), with a
snapshot taken at the end of every turn. fire-and-forget off the `result`
message, owner-gated, capped by the reader's own 60s cache so a burst of
short turns is not a burst of 150MB scans.

the panel gains tabs, one per account. GET /chat/usage now answers from
postgres alone — instant, and it carries the accounts that cannot be
measured — and ?refresh=1 re-measures the signed-in one and writes it back.
the client fires both and swaps.

the reason a stale row is worth showing rather than apologising for is that
`resetsAt` is an ABSOLUTE instant. a row from six hours ago has an as-of
percentage and an exactly correct "resets in 14h 20m", ticked client-side
with no network. and where a window has rolled over since, `currentLimit`
reads it as 0% — the stored figure is not merely stale there, it is wrong in
the direction that matters, since the account is actually free. weekly rolls
forward to its next boundary (the length is in the kind, so that is
arithmetic); session does not, because the five-hour window starts on first
use rather than on a grid and there is no clock to count down.

fetchedAt is now the older of the two halves rather than a stamp at the end,
so a cached reading does not report itself as current.

needs `bun db:push`. degrades to no tabs without it rather than a 500.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 04:46:31 +00:00
co-authored by Claude Opus 5
parent dc68a289ff
commit 9745f54fa1
14 changed files with 704 additions and 63 deletions
@@ -0,0 +1,2 @@
export { recordProviderUsage, listProviderUsage, forgetProviderUsage } from './queries';
export type { UsageSnapshotRow } from './queries';
@@ -0,0 +1,84 @@
import { and, desc, eq } from 'drizzle-orm';
import { db } from '../db';
import { aiProviderUsage } from './schema';
export type UsageSnapshotRow = {
aiProvider: string;
emailAccount: string;
plan: string | null;
payload: unknown;
updatedAt: Date;
};
type RecordParams = {
userId: number;
aiProvider: string;
emailAccount: string;
plan: string | null;
payload: unknown;
};
/**
* Store a reading. Upsert on the account — one row per account, last reading wins.
*
* `updatedAt` is set explicitly rather than left to the default, because on the UPDATE half a default only
* applies to inserts: without it a refreshed row would keep saying it was measured whenever it was first
* created, which is the one field this whole feature rests on.
*/
export async function recordProviderUsage({
userId,
aiProvider,
emailAccount,
plan,
payload,
}: RecordParams): Promise<void> {
await db
.insert(aiProviderUsage)
.values({ userId, aiProvider, emailAccount, plan, payload })
.onConflictDoUpdate({
target: [aiProviderUsage.userId, aiProviderUsage.aiProvider, aiProviderUsage.emailAccount],
set: { plan, payload, updatedAt: new Date() },
});
}
/**
* Every account this user has a reading for, newest first.
*
* Newest first because that is tab order: the account you used most recently is the one you are most likely
* to be looking for, and it is usually the one signed in.
*/
export async function listProviderUsage(userId: number, aiProvider?: string): Promise<UsageSnapshotRow[]> {
return db
.select({
aiProvider: aiProviderUsage.aiProvider,
emailAccount: aiProviderUsage.emailAccount,
plan: aiProviderUsage.plan,
payload: aiProviderUsage.payload,
updatedAt: aiProviderUsage.updatedAt,
})
.from(aiProviderUsage)
.where(
aiProvider
? and(eq(aiProviderUsage.userId, userId), eq(aiProviderUsage.aiProvider, aiProvider))
: eq(aiProviderUsage.userId, userId),
)
.orderBy(desc(aiProviderUsage.updatedAt));
}
/**
* Forget one account's reading.
*
* Exists because a tab for an account you no longer use is clutter, and the row is display data — deleting
* it loses nothing that cannot be measured again by signing in.
*/
export async function forgetProviderUsage(userId: number, aiProvider: string, emailAccount: string): Promise<void> {
await db
.delete(aiProviderUsage)
.where(
and(
eq(aiProviderUsage.userId, userId),
eq(aiProviderUsage.aiProvider, aiProvider),
eq(aiProviderUsage.emailAccount, emailAccount),
),
);
}
@@ -0,0 +1,88 @@
import { sql } from 'drizzle-orm';
import { check, integer, jsonb, pgTable, serial, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
import { users } from '../auth/schema';
const AI_PROVIDERS = ['claude-code', 'opencode'] as const;
/**
* The last usage reading for each AI subscription this machine has seen — one row per account, kept.
*
* ── Why store it, when the live reading takes half a second ──
*
* Because half of it cannot be read at all for an account you are not signed into. The limits come from
* Anthropic's OAuth usage endpoint using the credential the CLI holds for the CURRENT login, and the token
* counts come from that login's transcripts. Switch to another account and the previous one becomes
* unreadable — not slow, unreadable.
*
* That is the whole feature. The owner switches between a personal account and a company one, and the
* question worth answering at a glance is "which of my accounts has room right now". Only a stored reading
* per account can answer it.
*
* ── Why the account is in the key ──
*
* `(user_id, ai_provider, email_account)`, so switching logins accumulates rather than overwrites. Keyed on
* the OFFICER user as well as the provider account because they are different axes: one Officer account may
* hold several Anthropic logins, and eventually several Officer accounts will each hold their own.
*
* ── Why the reading itself is jsonb ──
*
* The three sections — limits, tokens by day, tokens by model — are arrays whose shape is provider-specific.
* A prepaid provider reports a credit balance and no windows at all; a subscription reports windows and no
* balance. Flattening that into columns produces a table that is mostly null for every provider, and a
* migration every time one is added.
*
* So the columns here are only what is true of EVERY provider and worth querying on — who, which, whose,
* when — and the reading rides along as one document. It is display data with a single writer and a single
* reader; nothing joins to it, and nothing needs to filter inside it.
*
* A stored reading is a SNAPSHOT and is never edited: a fresh one replaces it whole. There is no partial
* update path, which is what makes a document safe here.
*/
export const aiProviderUsage = pgTable(
'ai_provider_usage',
{
id: serial('id').primaryKey(),
userId: integer('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
/** Which harness. Constrained, so a provider the code stopped supporting cannot sit here unnoticed. */
aiProvider: text('ai_provider').notNull(),
/**
* Which account WITHIN that provider — `jrg.conceicao@gmail.com`.
*
* From the CLI's own config (`~/.claude.json` → `oauthAccount.emailAddress`), not from the Officer
* account: they are unrelated, and the whole point of this table is that one Officer user has several
* of these.
*/
emailAccount: text('email_account').notNull(),
/** `Max 20x`, `Pro`. Display only, and a column rather than payload because it labels the tab. */
plan: text('plan'),
/**
* The reading. See `ClaudeUsageSnapshot` in `types.ts` for the Claude shape.
*
* Deliberately not validated by the database. It is written by one function and read by one component,
* both in this repository, and a check constraint on a document would fail a push the day a provider
* adds a field.
*/
payload: jsonb('payload').notNull(),
/**
* When the reading was taken — NOT when the row was touched.
*
* This is the field the panel renders as "as of 20 minutes ago", and it is the only thing standing
* between a glanceable reminder and a confidently wrong number. Every write sets it explicitly.
*/
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [
uniqueIndex('uq_ai_provider_usage_account').on(table.userId, table.aiProvider, table.emailAccount),
check(
'ck_ai_provider_usage_provider',
// `sql.raw`, never an interpolated string — a bound parameter is emitted as `IN ($1, $2)`, which
// Postgres refuses, and that breaks push for the whole schema rather than for this table.
sql`${table.aiProvider} IN (${sql.join(
AI_PROVIDERS.map((p) => sql.raw(`'${p}'`)),
sql`, `,
)})`,
),
],
);
+1
View File
@@ -34,6 +34,7 @@ export * from './auth';
export * from './permissions';
export * from './chat-events';
export * from './chat-session-models';
export * from './ai-provider-usage';
export * from './dashboards';
export * from './integrations';
export * from './pipeline-jobs';
+1
View File
@@ -32,6 +32,7 @@ export * from './server/schema'; // server_config (SMTP lives here), server_inte
export * from './pipeline-jobs/schema'; // pipeline_jobs
export * from './chat-events/schema'; // chat_session_events
export * from './chat-session-models/schema'; // chat_session_models
export * from './ai-provider-usage/schema'; // ai_provider_usage — last usage reading per AI account
export * from './agent-panels/schema'; // agent_panels
// Core because the tailnet is the perimeter — a security model resting on it cannot treat administering
+5
View File
@@ -81,3 +81,8 @@ export type PushDeviceInsert = typeof NotifySchema.pushDevices.$inferInsert;
export type ApiKeySelect = typeof Schema.apiKeys.$inferSelect;
export type ApiKeyInsert = typeof Schema.apiKeys.$inferInsert;
// ── AI provider usage ──
export type AiProviderUsageSelect = typeof Schema.aiProviderUsage.$inferSelect;
export type AiProviderUsageInsert = typeof Schema.aiProviderUsage.$inferInsert;
+22 -2
View File
@@ -31,7 +31,8 @@ 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 { readAccountEmail } from './claude-usage';
import { listStoredUsage, mergeUsage, snapshotClaudeUsage } from './usage-store';
import { readSttConfig } from '../server-settings/stt';
/**
@@ -408,8 +409,27 @@ chatRouter.post('/tasks/:id/pause', async (ctx) => {
*/
chatRouter.get('/usage', async (ctx) => {
const who = await chatIdentity(ctx.get('user'));
const user = ctx.get('user');
if (!who.isOwner) throw errors.FORBIDDEN('Usage belongs to the account that owns this machine');
return ctx.json(await readClaudeUsage());
const currentEmail = await readAccountEmail();
const stored = await listStoredUsage(user.id, currentEmail);
/*
Two modes, and the split is the whole point of the cache.
Without `?refresh=1` this answers from Postgres alone — no transcript scan, no network — so the panel
paints instantly with every account, including the ones that CANNOT be read live because the CLI is not
signed in as them. The client then asks again with the flag and swaps in the fresh tab.
The exception is a first open, where answering instantly with nothing would be a worse kind of fast: if
the signed-in account has no stored row, measure it now rather than show an empty panel.
*/
const refresh = ctx.req.query('refresh') === '1';
const missing = !!currentEmail && !stored.some((a) => a.current);
if (!refresh && !missing) return ctx.json({ accounts: stored });
return ctx.json({ accounts: mergeUsage(stored, await snapshotClaudeUsage(user.id)) });
});
chatRouter.get('/models', async (ctx: Context) => {
+23 -13
View File
@@ -127,7 +127,7 @@ function planLabel(login: Record<string, unknown>): string | null {
* account and the limits belong to exactly one of them; a panel of percentages that does not say whose is
* a panel you cannot act on.
*/
async function readAccountEmail(): Promise<string | null> {
export async function readAccountEmail(): Promise<string | null> {
try {
const raw = await readFile(join(getOwnerHomeDir(''), '.claude.json'), 'utf-8');
const account = (JSON.parse(raw) as Record<string, unknown>).oauthAccount as Record<string, unknown> | undefined;
@@ -289,26 +289,34 @@ const windowStillOpen = (limit: UsageLimit, now: number): boolean => {
return Number.isNaN(at) || at > now;
};
async function collectLimits(): Promise<LimitsResult & { plan: string | null }> {
/**
* `measuredAt` travels with the reading rather than being stamped at the end.
*
* The panel shows "as of N minutes ago", and a reading served from a cache was measured when the CACHE was
* filled, not when it was handed out. Stamping on the way out would report a minute-old figure as current —
* which is the one thing this whole feature exists to avoid.
*/
async function collectLimits(): Promise<LimitsResult & { plan: string | null; measuredAt: number }> {
const login = await readOauthLogin();
const now = Date.now();
if (!login) {
return { limits: [], plan: null, problem: 'Claude Code is not signed in on this machine.' };
return { limits: [], plan: null, measuredAt: now, problem: 'Claude Code is not signed in on this machine.' };
}
const now = Date.now();
const cached = limitsCache && now - limitsCache.at < LIMITS_TTL_MS ? limitsCache.result : null;
if (cached) return { ...cached, plan: login.plan };
if (limitsCache && now - limitsCache.at < LIMITS_TTL_MS) {
return { ...limitsCache.result, plan: login.plan, measuredAt: limitsCache.at };
}
const fresh = await probeLimits(login.accessToken);
if (fresh.limits.length) {
limitsCache = { at: now, result: fresh };
return { ...fresh, plan: login.plan };
return { ...fresh, plan: login.plan, measuredAt: now };
}
// A failed probe falls back to the last good reading rather than to nothing — the numbers are minutes old
// at worst, and a panel that goes blank on one flaky request is worse than one that is slightly behind.
const stale = limitsCache?.result.limits.filter((l) => windowStillOpen(l, now)) ?? [];
return { limits: stale, plan: login.plan, problem: fresh.problem };
return { limits: stale, plan: login.plan, measuredAt: limitsCache?.at ?? now, problem: fresh.problem };
}
// ── Local transcript scan ──
@@ -468,12 +476,12 @@ async function scanTranscripts(): Promise<ScanResult> {
return { days: dates.map((date) => ({ date, tokens: byDay.get(date) ?? 0 })), models };
}
async function collectScan(): Promise<ScanResult> {
async function collectScan(): Promise<ScanResult & { measuredAt: number }> {
const now = Date.now();
if (scanCache && now - scanCache.at < SCAN_TTL_MS) return scanCache.result;
if (scanCache && now - scanCache.at < SCAN_TTL_MS) return { ...scanCache.result, measuredAt: scanCache.at };
const result = await scanTranscripts();
scanCache = { at: now, result };
return result;
return { ...result, measuredAt: now };
}
/**
@@ -487,7 +495,7 @@ export async function readClaudeUsage(): Promise<ClaudeUsage> {
collectLimits(),
collectScan().catch((err: unknown) => {
logger.warn('Could not scan Claude transcripts for usage', { error: String(err) });
return { days: recentDates().map((date) => ({ date, tokens: 0 })), models: [] };
return { days: recentDates().map((date) => ({ date, tokens: 0 })), models: [], measuredAt: Date.now() };
}),
readAccountEmail(),
]);
@@ -498,6 +506,8 @@ export async function readClaudeUsage(): Promise<ClaudeUsage> {
...(limits.problem ? { limitsProblem: limits.problem } : {}),
days: scan.days,
models: scan.models,
fetchedAt: new Date().toISOString(),
// The OLDER of the two halves, because that is how old the reading is. Claiming the newer one would
// describe a panel as fresher than its stalest number.
fetchedAt: new Date(Math.min(limits.measuredAt, scan.measuredAt)).toISOString(),
};
}
+47
View File
@@ -0,0 +1,47 @@
import { describe, expect, test } from 'bun:test';
import { mergeUsage, type UsageAccount } from './usage-store';
const account = (email: string, over: Partial<UsageAccount> = {}): UsageAccount => ({
provider: 'claude-code',
email,
plan: 'Max 20x',
current: false,
measuredAt: '2026-08-18T04:00:00.000Z',
limits: [],
days: [],
models: [],
...over,
});
describe('mergeUsage', () => {
test('a fresh reading replaces its own account and leaves the others alone', () => {
// The contract the panel rests on: one tab is live, the rest are as-of. A refresh must not touch a
// stored account it could not have measured — the CLI is not signed in as it.
const stored = [account('personal@x.com', { current: true }), account('work@y.com')];
const fresh = account('personal@x.com', { current: true, measuredAt: '2026-08-18T05:00:00.000Z' });
const merged = mergeUsage(stored, fresh);
expect(merged).toHaveLength(2);
expect(merged[0]!.measuredAt).toBe('2026-08-18T05:00:00.000Z');
expect(merged[1]).toEqual(stored[1]!);
});
test('signing into a new account adds it rather than replacing the list', () => {
const merged = mergeUsage([account('personal@x.com', { current: true })], account('work@y.com', { current: true }));
expect(merged.map((a) => a.email)).toEqual(['work@y.com', 'personal@x.com']);
});
test('only one account is ever current', () => {
// `current` drives the "not signed in · as of" line. Two of them would claim two live accounts.
const stored = [account('personal@x.com', { current: true }), account('work@y.com', { current: true })];
const merged = mergeUsage(stored, account('third@z.com', { current: true }));
expect(merged.filter((a) => a.current)).toHaveLength(1);
expect(merged[0]!.email).toBe('third@z.com');
});
test('a failed reading leaves the stored list untouched', () => {
// A signed-out CLI, or a scan that threw. The cached accounts are still the best answer available.
const stored = [account('personal@x.com', { current: true })];
expect(mergeUsage(stored, null)).toEqual(stored);
});
});
+143
View File
@@ -0,0 +1,143 @@
import { listProviderUsage, recordProviderUsage } from 'officerdb';
import { readClaudeUsage, type UsageDay, type UsageLimit, type UsageModel } from './claude-usage';
import { logger } from './logger';
/**
* Usage readings across every AI account this machine has seen — not just the one signed in.
*
* ── The problem this exists for ──
*
* Half a usage reading cannot be taken at all for an account you are not signed into. The limits come from
* Anthropic's OAuth endpoint using the credential the CLI holds for the CURRENT login, and the token counts
* come from that login's transcripts. Switch accounts and the previous one is not slow to read — it is
* unreadable.
*
* The owner switches between a personal account and a company one, and the question worth answering is
* "which of my accounts has room right now". Only a stored reading per account can answer it, so a snapshot
* is taken at the end of every turn and kept in `ai_provider_usage`.
*
* ── Why the countdowns still work on a stale row ──
*
* Because `resetsAt` is an ABSOLUTE instant, not a duration. A row measured six hours ago has a percentage
* that is as-of, and a "resets in 14h 20m" that is exactly right — the client ticks it off the stored
* timestamp with no network and no signed-in session. That asymmetry is the whole reason a cached row is
* worth showing rather than apologising for.
*/
export const CLAUDE_PROVIDER = 'claude-code';
/** The reading as stored — the three display sections, without the account identity wrapped around them. */
export type UsageSnapshot = {
limits: UsageLimit[];
days: UsageDay[];
models: UsageModel[];
limitsProblem?: string;
};
export type UsageAccount = {
provider: string;
email: string;
plan: string | null;
/** Whether this is the account the CLI is signed in as right now. Exactly one row is, or none. */
current: boolean;
/** When the reading was MEASURED. Drives "as of N minutes ago" and nothing else. */
measuredAt: string;
} & UsageSnapshot;
/**
* Take a reading for whoever is signed in, and store it.
*
* Best-effort by design and it never throws: the caller is a turn ending, and a chat turn must not fail
* because a display row would not write. A missed snapshot costs nothing — the next turn takes another.
*
* Returns the account it stored, or null when there was nothing to store (no login, so no account to key a
* row on).
*/
export async function snapshotClaudeUsage(userId: number): Promise<UsageAccount | null> {
try {
const usage = await readClaudeUsage();
const email = usage.account.email;
// No account means no key. A reading that cannot be attributed is worse than no reading: it would land
// under whichever account happened to be signed in last.
if (!email) return null;
const snapshot: UsageSnapshot = {
limits: usage.limits,
days: usage.days,
models: usage.models,
...(usage.limitsProblem ? { limitsProblem: usage.limitsProblem } : {}),
};
await recordProviderUsage({
userId,
aiProvider: CLAUDE_PROVIDER,
emailAccount: email,
plan: usage.account.plan,
payload: snapshot,
});
return {
provider: CLAUDE_PROVIDER,
email,
plan: usage.account.plan,
current: true,
measuredAt: usage.fetchedAt,
...snapshot,
};
} catch (err) {
logger.warn('Could not snapshot Claude usage', { userId, error: String(err) });
return null;
}
}
/**
* Every account with a stored reading, current one first.
*
* Reads only Postgres — no transcript scan, no network — so this is what the panel renders the instant it
* opens. The fresh reading arrives separately and replaces one tab's worth.
*
* A missing table degrades to an empty list rather than a 500. `ai_provider_usage` arrived after this route
* did, and a machine that has not run `db:push` should lose the account tabs, not the panel.
*/
export async function listStoredUsage(userId: number, currentEmail: string | null): Promise<UsageAccount[]> {
let rows: Awaited<ReturnType<typeof listProviderUsage>>;
try {
rows = await listProviderUsage(userId, CLAUDE_PROVIDER);
} catch (err) {
logger.warn('Could not read stored usage', { userId, error: String(err) });
return [];
}
const accounts = rows.map((row) => {
// The payload is a document written by an earlier version of this code, so its shape is whatever was
// current the day the row was written. Defaulted rather than trusted: the three sections are mapped over
// directly in the panel, and one missing array is a crash in the composer rather than a blank section.
const payload = (row.payload ?? {}) as Partial<UsageSnapshot>;
return {
provider: row.aiProvider,
email: row.emailAccount,
plan: row.plan,
current: row.emailAccount === currentEmail,
measuredAt: row.updatedAt.toISOString(),
limits: payload.limits ?? [],
days: payload.days ?? [],
models: payload.models ?? [],
...(payload.limitsProblem ? { limitsProblem: payload.limitsProblem } : {}),
};
});
// Current account first — it is the one the panel opens on, and the only one whose numbers are live.
return accounts.sort((a, b) => Number(b.current) - Number(a.current));
}
/**
* Fold a fresh reading into the stored list.
*
* The fresh one wins for its own account and the others are left exactly as they were, which is the whole
* contract of the panel: one tab is live, the rest are as-of.
*/
export function mergeUsage(stored: UsageAccount[], fresh: UsageAccount | null): UsageAccount[] {
if (!fresh) return stored;
const rest = stored.filter((a) => !(a.provider === fresh.provider && a.email === fresh.email));
return [fresh, ...rest.map((a) => ({ ...a, current: false }))];
}
+22
View File
@@ -29,6 +29,8 @@ import { resolveHomeDir } from '@@/user-home';
import { claudeLoginState } from '@@/os-user-claude';
import { mkdirSync } from 'node:fs';
import { logger } from './logger';
import { isSuperAdmin } from '@@/super-admin';
import { snapshotClaudeUsage } from './usage-store';
// Default model when no user preference is set
const DEFAULT_MODEL = 'claude-code';
@@ -456,6 +458,26 @@ function createMessageHandler(sessionId: string, model: string, resumeSessionId?
record();
}
/*
A turn ended — take a usage snapshot while the person is reading the answer.
Here rather than on a timer because this is the moment the numbers have just moved AND nobody is
waiting: the turn is over, the composer is idle, and whatever this costs lands in the gap before the
next prompt. `void`, so nothing about the turn depends on it.
Best-effort in every direction. It is capped by the reader's own 60s cache, so a burst of short turns
does not become a burst of transcript scans; it stores nothing when the CLI is signed out; and it
swallows its own failures. A missed snapshot costs one stale row until the next turn.
Owner-gated because the reading is structurally the owner's — `getOwnerHomeDir` ignores the email it
is passed, so a member's turn would snapshot the OWNER'S usage under the MEMBER'S user id. That is not
a permissions slip, it is a wrong row, and it would be invisible.
*/
if (msg.type === 'result' && session.userId) {
const userId = session.userId;
void isSuperAdmin({ id: userId }).then((owner) => (owner ? snapshotClaudeUsage(userId) : null));
}
foldIntoSession(session, msg, effective);
for (const socket of session.sockets) sendToClient(socket as ServerWebSocket<WSData>, msg, seq);
};
@@ -1,20 +1,26 @@
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';
import type { UsageAccount } from '../useClaudeUsage';
import { currentLimit, formatAge, formatReset, formatTokens, useClaudeUsage, weekdayLabel } from '../useClaudeUsage';
/**
* Claude Code's own usage display, on the harness pill.
* Claude Code's own usage display, on the harness pill — for every account, not just the one signed in.
*
* 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`.
* 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.
*
* ── Why there are tabs ──
*
* The owner holds several Anthropic logins (a personal one, a company one) and switches between them. Only
* the signed-in one can be MEASURED, so the others are served from `ai_provider_usage` — a snapshot taken at
* the end of every turn. The percentages on those tabs are as-of, and the countdowns are exact, because
* `resetsAt` is an absolute instant rather than a duration. See `usage-store.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.
* these numbers in that shape on this machine, and two 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. */
@@ -34,10 +40,13 @@ const Section = ({ title, children }: { title: string; children: React.ReactNode
</div>
);
const Limits = ({ usage, now }: { usage: ClaudeUsage; now: number }) => (
const Limits = ({ account, now }: { account: UsageAccount; now: number }) => (
<Section title="Limits">
<div className="flex flex-col gap-3">
{usage.limits.map((limit) => {
{account.limits.map((stored) => {
// What the stored figure means NOW — a window that has rolled over since it was measured reads as
// empty rather than as whatever it was when the account was last used.
const limit = currentLimit(stored, now);
const resets = formatReset(limit.resetsAt, now);
return (
<div key={limit.label} className="flex flex-col gap-1">
@@ -54,16 +63,16 @@ const Limits = ({ usage, now }: { usage: ClaudeUsage; now: number }) => (
</Section>
);
const Days = ({ usage }: { usage: ClaudeUsage }) => {
const Days = ({ account }: { account: UsageAccount }) => {
// 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));
const peak = Math.max(1, ...account.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;
{account.days.map((day, index) => {
const today = index === account.days.length - 1;
return (
<div key={day.date} className="flex items-center gap-3">
<span
@@ -87,10 +96,10 @@ const Days = ({ usage }: { usage: ClaudeUsage }) => {
);
};
const Models = ({ usage }: { usage: ClaudeUsage }) => {
const Models = ({ account }: { account: UsageAccount }) => {
// 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 models = account.models.slice(0, 4);
const peak = Math.max(1, ...models.map((m) => m.total));
return (
@@ -119,6 +128,42 @@ const Models = ({ usage }: { usage: ClaudeUsage }) => {
);
};
/**
* One chip per account, and only when there is more than one.
*
* A tab strip over a single account is chrome that never does anything — the same reasoning that collapses
* the provider strip to a label when there is one harness on offer.
*/
const AccountTabs = ({
accounts,
selected,
onSelect,
}: {
accounts: UsageAccount[];
selected: string;
onSelect: (email: string) => void;
}) => (
<div className="flex items-center gap-1 overflow-x-auto border-t border-border px-2 py-2">
{accounts.map((account) => (
<button
key={account.email}
type="button"
onClick={() => onSelect(account.email)}
// The local part alone: the domain is what the accounts have in common and the part before it is
// what tells them apart, and the panel is 320px wide.
title={account.email}
className={`shrink-0 cursor-pointer rounded-md px-2 py-1 text-xs font-medium transition-colors ${
account.email === selected
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:bg-foreground/10 hover:text-foreground'
}`}
>
{account.email.split('@')[0]}
</button>
))}
</div>
);
type UsagePopoverProps = {
/** What the pill reads — the provider display name, unchanged. */
label: string;
@@ -148,7 +193,19 @@ export function UsagePopover({ label, harness }: UsagePopoverProps) {
const enabled = isOwner && harness === 'claude-code';
const [open, setOpen] = useState(false);
const { data, isLoading, isError } = useClaudeUsage(enabled && open);
const { accounts, isLoading, isRefreshing, isError } = useClaudeUsage(enabled && open);
/*
Which tab is showing.
Held as an EMAIL rather than an index, because the list is re-sorted when a refresh lands (the signed-in
account moves to the front) and an index would silently point at a different account afterwards.
Null means "whichever is current", resolved at render — so the panel opens on the signed-in account
without needing an effect to select it once the data arrives.
*/
const [selectedEmail, setSelectedEmail] = useState<string | null>(null);
const selected = accounts.find((a) => a.email === selectedEmail) ?? accounts[0] ?? null;
// 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.
@@ -171,15 +228,37 @@ export function UsagePopover({ label, harness }: UsagePopoverProps) {
{label}
</button>
</PopoverTrigger>
<PopoverContent align="start" side="top" className="z-[900] w-80 p-0">
<PopoverContent align="start" side="top" className="z-[900] max-h-[70vh] w-80 overflow-y-auto 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')}
{selected
? [selected.plan, selected.email].filter(Boolean).join(' · ')
: isLoading
? 'Reading usage…'
: 'Not signed in'}
</div>
{/*
How old this reading is, and it earns its line only when it is not live.
The signed-in account is being re-measured as you look at it, so saying "just now" under it is
noise. Every other tab is a stored snapshot, and its age is the difference between a reminder
and a confidently wrong number.
*/}
{selected && !selected.current && (
<div className="mt-0.5 text-[10px] text-muted-foreground">
Not signed in · as of {formatAge(selected.measuredAt, now)}
</div>
)}
{selected?.current && isRefreshing && (
<div className="mt-0.5 text-[10px] text-muted-foreground">Updating…</div>
)}
</div>
{accounts.length > 1 && (
<AccountTabs accounts={accounts} selected={selected?.email ?? ''} onSelect={setSelectedEmail} />
)}
{/* 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 && (
@@ -187,19 +266,19 @@ export function UsagePopover({ label, harness }: UsagePopoverProps) {
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>
{selected?.limitsProblem && selected.current && (
<div className="border-t border-border px-4 py-3 text-xs text-muted-foreground">{selected.limitsProblem}</div>
)}
{isLoading && !data && (
{isLoading && !selected && (
<div className="border-t border-border px-4 py-6 text-center text-xs text-muted-foreground">Loading…</div>
)}
{data && (
{selected && (
<>
{data.limits.length > 0 && <Limits usage={data} now={now} />}
<Days usage={data} />
{data.models.length > 0 && <Models usage={data} />}
{selected.limits.length > 0 && <Limits account={selected} now={now} />}
<Days account={selected} />
{selected.models.length > 0 && <Models account={selected} />}
</>
)}
</PopoverContent>
@@ -1,5 +1,5 @@
import { describe, expect, test } from 'bun:test';
import { formatReset, formatTokens, weekdayLabel } from './useClaudeUsage';
import { currentLimit, formatAge, formatReset, formatTokens, weekdayLabel } from './useClaudeUsage';
describe('formatTokens', () => {
test('scales to the unit the number deserves', () => {
@@ -55,3 +55,63 @@ describe('weekdayLabel', () => {
expect(weekdayLabel('nonsense')).toBe('nonsense');
});
});
describe('currentLimit', () => {
const now = Date.parse('2026-08-18T04:14:00Z');
test('an open window is left exactly as it was measured', () => {
const limit = { label: 'Weekly', percent: 22, resetsAt: '2026-08-19T01:00:00Z' };
expect(currentLimit(limit, now)).toEqual(limit);
});
test('a lapsed weekly window reads as empty, and rolls forward to its next boundary', () => {
/*
The case the whole cache exists for: a company account snapshotted at 78% whose weekly window has
since rolled over. The stored figure is not merely stale, it is wrong in the direction that matters —
the account is actually free. And the next boundary is arithmetic rather than a guess, because the
window length is in the name.
*/
const stored = { label: 'Weekly', percent: 78, resetsAt: '2026-08-15T01:00:00Z' };
expect(currentLimit(stored, now)).toEqual({
label: 'Weekly',
percent: 0,
resetsAt: '2026-08-22T01:00:00.000Z',
});
});
test('a window several weeks stale still lands in the future', () => {
// One roll is not enough for an account nobody has touched in a month.
const stored = { label: 'Fable Weekly', percent: 40, resetsAt: '2026-07-04T01:00:00Z' };
const next = currentLimit(stored, now).resetsAt;
expect(Date.parse(next!)).toBeGreaterThan(now);
expect(Date.parse(next!) - now).toBeLessThan(7 * 24 * 60 * 60 * 1000);
});
test('a lapsed session window reads as empty with NO countdown', () => {
// The five-hour window starts on first use rather than on a grid, so once it lapses there is no clock
// running. Projecting one would invent a countdown for a window that is dormant.
const stored = { label: 'Session', percent: 15, resetsAt: '2026-08-18T02:00:00Z' };
expect(currentLimit(stored, now)).toEqual({ label: 'Session', percent: 0, resetsAt: null });
});
test('a limit with no reset time is never rewritten', () => {
const limit = { label: 'Session', percent: 15, resetsAt: null };
expect(currentLimit(limit, now)).toEqual(limit);
});
});
describe('formatAge', () => {
const now = Date.parse('2026-08-18T04:14:00Z');
test('says how old a stored reading is', () => {
expect(formatAge('2026-08-18T04:14:00Z', now)).toBe('just now');
expect(formatAge('2026-08-18T03:54:00Z', now)).toBe('20m ago');
expect(formatAge('2026-08-18T01:14:00Z', now)).toBe('3h ago');
expect(formatAge('2026-08-16T04:14:00Z', now)).toBe('2d ago');
});
test('a reading from the future does not read as negative', () => {
// Clock skew between the server that stamped it and the browser reading it.
expect(formatAge('2026-08-18T05:00:00Z', now)).toBe('just now');
});
});
@@ -1,4 +1,5 @@
import { useQuery } from '@tanstack/react-query';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect } from 'react';
import { useClient } from 'hooks/useClient';
export type UsageLimit = { label: string; percent: number; resetsAt: string | null };
@@ -12,39 +13,77 @@ export type UsageModel = {
cacheWrite: number;
};
export type ClaudeUsage = {
account: { email: string | null; plan: string | null };
export type UsageAccount = {
provider: string;
email: string;
plan: string | null;
/** Whether the CLI is signed in as this account right now. At most one is. */
current: boolean;
/** When the reading was measured, not when it was fetched. */
measuredAt: string;
limits: UsageLimit[];
limitsProblem?: string;
days: UsageDay[];
models: UsageModel[];
fetchedAt: string;
};
type UsageResponse = { accounts: UsageAccount[] };
const KEY = ['chat', 'usage'];
/**
* The owner's Claude Code usage — limits, tokens by day, tokens by model.
* Usage for every AI account this machine has seen — cached first, fresh second.
*
* `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.
* Two requests, and the split is the feature rather than an optimisation:
*
* 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.
* 1. The plain call answers from Postgres alone. It is instant, and it carries accounts that CANNOT be
* read live at all — the ones you are not signed in as, whose limits no longer have a credential.
* 2. `?refresh=1` re-measures the signed-in account and writes the result back over the cached list.
*
* So the panel paints immediately with everything and one tab sharpens a moment later, instead of spinning
* for half a second and then showing exactly one account.
*/
export function useClaudeUsage(enabled: boolean) {
const client = useClient();
const queryClient = useQueryClient();
return useQuery<ClaudeUsage>({
queryKey: ['chat', 'usage'],
queryFn: () => client.get<ClaudeUsage>('/chat/usage'),
const cached = useQuery<UsageResponse>({
queryKey: KEY,
queryFn: () => client.get<UsageResponse>('/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.
// The stored rows only change when a turn ends, and a turn ending does not happen while you are reading
// this panel. The refresh below is what keeps it honest, not a stale timer.
staleTime: 5 * 60_000,
refetchOnWindowFocus: false,
retry: false,
});
const fresh = useQuery<UsageResponse>({
queryKey: [...KEY, 'fresh'],
queryFn: () => client.get<UsageResponse>('/chat/usage?refresh=1'),
enabled,
// Every open re-measures. This is the one request that costs a transcript scan and a round trip, and it
// is the only one whose answer is worth waiting for.
staleTime: 0,
gcTime: 0,
refetchOnWindowFocus: false,
retry: false,
});
// The fresh answer becomes the cached one, so closing and reopening paints the newest numbers instantly
// rather than the ones from before the last refresh.
useEffect(() => {
if (fresh.data) queryClient.setQueryData(KEY, fresh.data);
}, [fresh.data, queryClient]);
return {
accounts: fresh.data?.accounts ?? cached.data?.accounts ?? [],
/** Only true when there is nothing at all to show. A stale list is not a loading state. */
isLoading: cached.isLoading && fresh.isLoading,
/** A refresh in flight over an already-painted panel. Worth a hint, never a spinner over the numbers. */
isRefreshing: fresh.isFetching,
isError: cached.isError && fresh.isError,
};
}
/** `1.9M`, `248.5M`, `1.2B`. Bare integers below a thousand. */
@@ -59,8 +98,8 @@ export function formatTokens(value: number): string {
/**
* 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.
* Null when there is no reset time or it has already passed — see `rollForward` for why a passed weekly
* window does not simply disappear.
*/
export function formatReset(resetsAt: string | null, now: number): string | null {
if (!resetsAt) return null;
@@ -73,9 +112,49 @@ export function formatReset(resetsAt: string | null, now: number): string | null
return hours > 0 ? `${hours}h ${minutes % 60}m` : `${minutes}m`;
}
const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
/**
* What a stored limit means NOW, which is not always what it meant when it was measured.
*
* A row for an account you are not signed in as can be hours or days old, and a window that has since rolled
* over makes its percentage not merely stale but wrong in the dangerous direction — a company account
* snapshotted at 78% would still read 78% under an expired countdown, when in fact it is empty and
* available. So:
*
* - **Past its reset, the percentage is zero.** The window ended; nothing has been spent in the new one
* since, because the account has not been used.
* - **A weekly window rolls forward.** Its length is in the name, so the next boundary is arithmetic, not
* a guess: add seven days until it lands in the future. That is exactly the glance this feature is for —
* "when does my other account free up".
* - **A session window does not.** The five-hour window starts on first use rather than on a grid, so
* after it lapses there is no clock running and no countdown to show. Zero, and no line.
*/
export function currentLimit(limit: UsageLimit, now: number): UsageLimit {
const at = limit.resetsAt ? Date.parse(limit.resetsAt) : Number.NaN;
if (Number.isNaN(at) || at > now) return limit;
if (!/week/i.test(limit.label)) return { ...limit, percent: 0, resetsAt: null };
const elapsed = Math.ceil((now - at) / WEEK_MS);
return { ...limit, percent: 0, resetsAt: new Date(at + elapsed * WEEK_MS).toISOString() };
}
/** `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' });
}
/** `20m ago`, `3h ago`, `2d ago`. The honesty label under a cached account. */
export function formatAge(measuredAt: string, now: number): string {
const at = Date.parse(measuredAt);
if (Number.isNaN(at)) return 'unknown';
const minutes = Math.max(0, Math.floor((now - at) / 60_000));
if (minutes < 1) return 'just now';
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
return `${Math.floor(hours / 24)}d ago`;
}