diff --git a/src/databases/officer_db/src/ai-provider-usage/index.ts b/src/databases/officer_db/src/ai-provider-usage/index.ts new file mode 100644 index 00000000..24523bab --- /dev/null +++ b/src/databases/officer_db/src/ai-provider-usage/index.ts @@ -0,0 +1,2 @@ +export { recordProviderUsage, listProviderUsage, forgetProviderUsage } from './queries'; +export type { UsageSnapshotRow } from './queries'; diff --git a/src/databases/officer_db/src/ai-provider-usage/queries.ts b/src/databases/officer_db/src/ai-provider-usage/queries.ts new file mode 100644 index 00000000..69083611 --- /dev/null +++ b/src/databases/officer_db/src/ai-provider-usage/queries.ts @@ -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 { + 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 { + 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 { + await db + .delete(aiProviderUsage) + .where( + and( + eq(aiProviderUsage.userId, userId), + eq(aiProviderUsage.aiProvider, aiProvider), + eq(aiProviderUsage.emailAccount, emailAccount), + ), + ); +} diff --git a/src/databases/officer_db/src/ai-provider-usage/schema.ts b/src/databases/officer_db/src/ai-provider-usage/schema.ts new file mode 100644 index 00000000..7ab1c32b --- /dev/null +++ b/src/databases/officer_db/src/ai-provider-usage/schema.ts @@ -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`, `, + )})`, + ), + ], +); diff --git a/src/databases/officer_db/src/index.ts b/src/databases/officer_db/src/index.ts index 24b16013..437c3a50 100644 --- a/src/databases/officer_db/src/index.ts +++ b/src/databases/officer_db/src/index.ts @@ -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'; diff --git a/src/databases/officer_db/src/schema.ts b/src/databases/officer_db/src/schema.ts index 7e4eef93..40359609 100644 --- a/src/databases/officer_db/src/schema.ts +++ b/src/databases/officer_db/src/schema.ts @@ -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 diff --git a/src/databases/officer_db/src/types.ts b/src/databases/officer_db/src/types.ts index 537de279..97548aef 100644 --- a/src/databases/officer_db/src/types.ts +++ b/src/databases/officer_db/src/types.ts @@ -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; diff --git a/src/servers/api/chat/chat.ts b/src/servers/api/chat/chat.ts index 528e3330..467bc2a9 100644 --- a/src/servers/api/chat/chat.ts +++ b/src/servers/api/chat/chat.ts @@ -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) => { diff --git a/src/servers/api/chat/claude-usage.ts b/src/servers/api/chat/claude-usage.ts index 683be085..f0421d47 100644 --- a/src/servers/api/chat/claude-usage.ts +++ b/src/servers/api/chat/claude-usage.ts @@ -127,7 +127,7 @@ function planLabel(login: Record): 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 { +export async function readAccountEmail(): Promise { try { const raw = await readFile(join(getOwnerHomeDir(''), '.claude.json'), 'utf-8'); const account = (JSON.parse(raw) as Record).oauthAccount as Record | undefined; @@ -289,26 +289,34 @@ const windowStillOpen = (limit: UsageLimit, now: number): boolean => { return Number.isNaN(at) || at > now; }; -async function collectLimits(): Promise { +/** + * `measuredAt` travels with the reading rather than being stamped at the end. + * + * The panel shows "as of N minutes ago", and a reading served from a cache was measured when the CACHE was + * filled, not when it was handed out. Stamping on the way out would report a minute-old figure as current — + * which is the one thing this whole feature exists to avoid. + */ +async function collectLimits(): Promise { const login = await readOauthLogin(); + const now = Date.now(); if (!login) { - return { limits: [], plan: null, 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 { return { days: dates.map((date) => ({ date, tokens: byDay.get(date) ?? 0 })), models }; } -async function collectScan(): Promise { +async function collectScan(): Promise { 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 { 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 { ...(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(), }; } diff --git a/src/servers/api/chat/usage-store.test.ts b/src/servers/api/chat/usage-store.test.ts new file mode 100644 index 00000000..669ca045 --- /dev/null +++ b/src/servers/api/chat/usage-store.test.ts @@ -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 => ({ + 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); + }); +}); diff --git a/src/servers/api/chat/usage-store.ts b/src/servers/api/chat/usage-store.ts new file mode 100644 index 00000000..a265f8b0 --- /dev/null +++ b/src/servers/api/chat/usage-store.ts @@ -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 { + 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 { + let rows: Awaited>; + 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; + 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 }))]; +} diff --git a/src/servers/api/chat/websocket.ts b/src/servers/api/chat/websocket.ts index 7e7f7f61..32b6eeea 100644 --- a/src/servers/api/chat/websocket.ts +++ b/src/servers/api/chat/websocket.ts @@ -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, msg, seq); }; diff --git a/src/workspaces/officerdev/src/apps/Chat/components/UsagePopover.tsx b/src/workspaces/officerdev/src/apps/Chat/components/UsagePopover.tsx index 88f490ac..9dbeb33e 100644 --- a/src/workspaces/officerdev/src/apps/Chat/components/UsagePopover.tsx +++ b/src/workspaces/officerdev/src/apps/Chat/components/UsagePopover.tsx @@ -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 ); -const Limits = ({ usage, now }: { usage: ClaudeUsage; now: number }) => ( +const Limits = ({ account, now }: { account: UsageAccount; now: number }) => (
- {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 (
@@ -54,16 +63,16 @@ const Limits = ({ usage, now }: { usage: ClaudeUsage; now: number }) => (
); -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 (
- {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 (
{ ); }; -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; +}) => ( +
+ {accounts.map((account) => ( + + ))} +
+); + 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(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} - +
{label}
- {[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'}
+ {/* + 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 && ( +
+ Not signed in · as of {formatAge(selected.measuredAt, now)} +
+ )} + {selected?.current && isRefreshing && ( +
Updating…
+ )}
+ {accounts.length > 1 && ( + + )} + {/* 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.
)} - {data?.limitsProblem && ( -
{data.limitsProblem}
+ {selected?.limitsProblem && selected.current && ( +
{selected.limitsProblem}
)} - {isLoading && !data && ( + {isLoading && !selected && (
Loading…
)} - {data && ( + {selected && ( <> - {data.limits.length > 0 && } - - {data.models.length > 0 && } + {selected.limits.length > 0 && } + + {selected.models.length > 0 && } )} diff --git a/src/workspaces/officerdev/src/apps/Chat/useClaudeUsage.test.ts b/src/workspaces/officerdev/src/apps/Chat/useClaudeUsage.test.ts index cf46396c..79d8d975 100644 --- a/src/workspaces/officerdev/src/apps/Chat/useClaudeUsage.test.ts +++ b/src/workspaces/officerdev/src/apps/Chat/useClaudeUsage.test.ts @@ -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'); + }); +}); diff --git a/src/workspaces/officerdev/src/apps/Chat/useClaudeUsage.ts b/src/workspaces/officerdev/src/apps/Chat/useClaudeUsage.ts index 2af679b4..a61d0578 100644 --- a/src/workspaces/officerdev/src/apps/Chat/useClaudeUsage.ts +++ b/src/workspaces/officerdev/src/apps/Chat/useClaudeUsage.ts @@ -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({ - queryKey: ['chat', 'usage'], - queryFn: () => client.get('/chat/usage'), + const cached = useQuery({ + queryKey: KEY, + queryFn: () => client.get('/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({ + queryKey: [...KEY, 'fresh'], + queryFn: () => client.get('/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`; +}