diff --git a/NEXT-SESSION.md b/NEXT-SESSION.md deleted file mode 100644 index 289e47e0..00000000 --- a/NEXT-SESSION.md +++ /dev/null @@ -1,113 +0,0 @@ -# Next session — two chat fixes - -**Disposable.** Written 2026-08-18 ~05:00 as a handoff across a compaction. Delete it when both are done. - -Both are in `/chat`. Do them in order — the first is small and well understood, the second is a design -question wearing a UI job's clothes. - ---- - -## 1. The model dropdown says `claude-code` instead of the model that is answering - -### Symptom - -In the composer, bottom right, the model select renders the literal string **`claude-code`**. It should -name a model — the owner expects "Opus". - -### Why, exactly - -`DEFAULT_MODEL` in `src/servers/api/chat/websocket.ts` is the bare harness string `claude-code`, used when -the client sends no model and the account has no default. It routes correctly — `isClaudeModel` is a prefix -test and `model.split('/')[1]` is `undefined`, so no `--model` is passed and **the CLI picks for itself**. - -It names no catalogue entry, so Radix's `SelectValue` matches no item and renders nothing. `773f0e1c` added -a fallback in `ModelSelector.tsx` so a blank trigger became the raw string's last segment -(`displayModel.split('/').pop()`), which for `claude-code` is `claude-code`. Honest, and useless. - -### The fix, and the distinction it turns on - -**The server does not know which model answered** — it deliberately did not choose one. The transcript does: -every assistant entry carries `message.model` (`claude-opus-5`), and `officerModelForApiModel()` in -`src/servers/api/chat/model-aliases.ts` already maps that back to `claude-code/opus`. - -So: when the effective model is the bare harness, display **what answered**, not what was asked for. That is -the same `effective`-vs-requested distinction `createMessageHandler` already makes for tier switches. - -Options, cheapest first: - -1. **Client-side.** The turn's `result`/assistant messages already reach `useChat`. If a message carries a - model, prefer it over the bare harness for display only — do not write it back to `selectedModel`, or the - next turn will request a tier the user never picked. -2. **Server-side.** `GET /chat/sessions/:id` already returns `officerModel`, resolved from the stored row - with a family-guess fallback. A live session could report the same thing on `session:init`. - -Option 1 is smaller. Option 2 is more consistent with how a resumed session already works. - -### Traps - -- **Do not store the resolved value.** `record()` in `websocket.ts` refuses to persist a model without a `/` - precisely because a bare harness overwrote a correctly backfilled `claude-code/opus` and made the picker - render blank. Read that comment before touching either end. -- **This picker has been broken once already today.** Verify: a session with no explicit model, a session on - `opus[1m]`, and a resumed session all show something sensible. -- `chat_session_models.model` holds Officer ids (`claude-code/opus[1m]`), not API ids. Do not mix them. - ---- - -## 2. Usage popover on the "Claude Code" pill - -### What is wanted - -Clicking the harness pill — the left-hand label in the composer's control row, which reads **Claude Code** -and is inert once a conversation has started — should open a panel showing what Claude Code's own usage -display shows: - -- **Limits**: Session %, Weekly %, Fable Weekly %, each with a reset countdown -- **Tokens by day**, last seven days, as bars -- **Tokens by model**: Opus 5, Opus 4.8, and any others, with totals - -The owner supplied a screenshot of the target and a reference implementation: -**https://github.com/basecamp/omarchy/tree/quattro/shell/plugins/agents** - -### Read the reference FIRST — this is a data question, not a UI one - -**Officer does not have any of these numbers.** Per-session cost is accumulated in memory in -`websocket.ts` (search `session.meta.cost`), it dies with `pm2 restart officer`, and nothing aggregates it -across sessions, days or models. There is no usage table. - -So before writing any component, answer: **where does omarchy get this?** Two possibilities, and they are -completely different jobs: - -- **It reads local Claude Code state** — something under `~/.claude`. Then this is a display job: a sidecar - route reads the file, the panel renders it, and the numbers are the same ones the CLI shows. -- **It calls an Anthropic endpoint** — then it needs the OAuth credential, which lives with - `officer-anthropic-proxy` and never leaves it, and the route belongs there rather than in the chat socket. - -Do not build a schema for this until that is known. The wrong answer is inventing Officer's own token -accounting to reproduce a number the CLI already computes. - -### Where the pill is - -`src/workspaces/officerdev/src/apps/Chat/components/ModelSelector.tsx` — the provider label rendered when -`providerStripLocked` is true (a `` with `bg-muted`, currently not a button). It sits left of the -background-job button and the model select. - -Note the owner's framing: _"in a running chat that is not responding to harness change anymore"_ — the pill -is already inert in that state, which is exactly why it is free to become a button. - -### Constraints - -- **Owner-only data.** These are the owner's subscription limits. A member must not see them — gate it. -- **No palette classes.** Semantic tokens only (`bg-muted`, `text-muted-foreground`, `bg-primary/15`); the - ratchet is `bun run check:theme`. -- **Do not block the composer.** If the source is unreachable the popover says so; it must not spin. - ---- - -## Before you start - -`bunx tsgo` is clean and `bun test` is at **7 pre-existing failures** (registry/totality drift and a cliamp -socket — none yours). Compare against that baseline, not against zero. - -Everything else from tonight is committed and pushed across four repositories: `platform`, `officer-oas`, -`plugins/email`, `plugins/kiosk`. diff --git a/src/servers/api/chat/chat.ts b/src/servers/api/chat/chat.ts index a7f8b2f6..528e3330 100644 --- a/src/servers/api/chat/chat.ts +++ b/src/servers/api/chat/chat.ts @@ -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 { diff --git a/src/servers/api/chat/claude-usage.test.ts b/src/servers/api/chat/claude-usage.test.ts new file mode 100644 index 00000000..031cfd4a --- /dev/null +++ b/src/servers/api/chat/claude-usage.test.ts @@ -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'); + }); +}); diff --git a/src/servers/api/chat/claude-usage.ts b/src/servers/api/chat/claude-usage.ts new file mode 100644 index 00000000..683be085 --- /dev/null +++ b/src/servers/api/chat/claude-usage.ts @@ -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 { + try { + const raw = await readFile(join(claudeDir(), '.credentials.json'), 'utf-8'); + const login = (JSON.parse(raw) as Record).claudeAiOauth as Record | undefined; + const token = typeof login?.accessToken === 'string' ? login.accessToken : ''; + if (!login || !token) return null; + return { accessToken: token, plan: planLabel(login) }; + } catch { + return null; + } +} + +/** `default_claude_max_20x` → `Max 20x`; else the subscription word, capitalised; else nothing. */ +function planLabel(login: Record): string | null { + const tier = typeof login.rateLimitTier === 'string' ? login.rateLimitTier : ''; + const match = /max_(\d+x)/i.exec(tier); + if (match) return `Max ${match[1]}`; + const sub = typeof login.subscriptionType === 'string' ? login.subscriptionType : ''; + return sub ? sub[0]!.toUpperCase() + sub.slice(1) : null; +} + +/** + * Which account the CLI is logged in as. + * + * Not in the credential file and not in the usage payload — it lives in `~/.claude.json`, beside the config + * rather than inside `.claude/`. Worth showing because this machine's owner has more than one Anthropic + * account and the limits belong to exactly one of them; a panel of percentages that does not say whose is + * a panel you cannot act on. + */ +async function readAccountEmail(): Promise { + try { + const raw = await readFile(join(getOwnerHomeDir(''), '.claude.json'), 'utf-8'); + const account = (JSON.parse(raw) as Record).oauthAccount as Record | undefined; + const email = account?.emailAddress; + return typeof email === 'string' && email ? email : null; + } catch { + return null; + } +} + +// ── Limits ── + +type LimitsResult = { limits: UsageLimit[]; problem?: string }; + +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, percentScale: boolean): UsageLimit[] { + const entries = payload.limits; + if (!Array.isArray(entries)) return []; + const out: UsageLimit[] = []; + const seen = new Set(); + + for (const entry of entries as Record[]) { + const scope = entry?.scope as Record | undefined; + const model = scope?.model as Record | undefined; + if (!model) continue; + // An entry carrying only an id still names a window worth showing. + const name = String(model.display_name ?? model.id ?? '').trim(); + const kind = String(entry.kind ?? '').trim(); + // A model can hold more than one window, so only the pair tells them apart. + const key = `${name}${kind}`; + if (!name || seen.has(key)) continue; + const percent = normalise(entry.percent, percentScale); + if (percent < 0) continue; + seen.add(key); + const window = scopedWindow(kind); + out.push({ label: window ? `${name} ${window}` : name, percent, resetsAt: resetAt(entry.resets_at) }); + } + return out; +} + +/** + * The limits in a usage payload, in display order: session, weekly, then anything model-scoped. + * + * Exported for its own sake — this is where every subtlety in the file lives (the per-payload percent scale, + * `seven_day_oauth_apps` shadowing `seven_day`, and the scoped windows that exist only in `limits[]`), and + * none of it is reachable through `readClaudeUsage` without a network call and a signed-in CLI. + */ +export function limitsFromPayload(payload: Record): UsageLimit[] { + const bucket = (key: string): Record | null => { + const value = payload[key]; + return value && typeof value === 'object' ? (value as Record) : null; + }; + + const session = bucket('five_hour'); + const weekly = bucket('seven_day_oauth_apps') ?? bucket('seven_day'); + const entries = Array.isArray(payload.limits) ? (payload.limits as Record[]) : []; + const percentScale = [session?.utilization, weekly?.utilization, ...entries.map((e) => e?.percent)].some( + (v) => asPercent(v) >= 1, + ); + + const limits: UsageLimit[] = []; + const flat = (source: Record | null, label: string) => { + if (!source) return; + const percent = normalise(source.utilization, percentScale); + if (percent >= 0) limits.push({ label, percent, resetsAt: resetAt(source.resets_at) }); + }; + flat(session, 'Session'); + flat(weekly, 'Weekly'); + limits.push(...scopedLimits(payload, percentScale)); + return limits; +} + +async function probeLimits(accessToken: string): Promise { + let payload: Record; + try { + const response = await fetch(USAGE_ENDPOINT, { + headers: { + Authorization: `Bearer ${accessToken}`, + 'anthropic-beta': 'oauth-2025-04-20', + Accept: 'application/json', + }, + signal: AbortSignal.timeout(10_000), + }); + if (!response.ok) { + const retry = response.headers.get('retry-after'); + return { + limits: [], + problem: + response.status === 429 + ? `Anthropic is rate limiting usage checks${retry ? ` (retry after ${retry}s)` : ''}.` + : `Anthropic's usage endpoint returned ${response.status}.`, + }; + } + payload = (await response.json()) as Record; + } catch { + return { limits: [], problem: "Couldn't reach Anthropic's usage endpoint." }; + } + + const limits = limitsFromPayload(payload); + if (!limits.length) return { limits: [], problem: "Anthropic's usage endpoint reported no limits." }; + return { limits }; +} + +/** + * A cached percentage outlives the probe that measured it — but only until its window rolls over. + * + * Once a window has reset, the figure describes a period that is over, and a stale 78% would misreport an + * allowance that is now untouched. A window with no reset time, or one that will not parse, is kept: an + * unreadable timestamp is no reason to throw away a real number. + */ +const windowStillOpen = (limit: UsageLimit, now: number): boolean => { + if (!limit.resetsAt) return true; + const at = Date.parse(limit.resetsAt); + return Number.isNaN(at) || at > now; +}; + +async function collectLimits(): Promise { + 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, snake: string, camel: string): number => { + const n = Number(usage[snake] ?? usage[camel] ?? 0); + return Number.isFinite(n) ? Math.round(n) : 0; +}; + +/** + * `claude-opus-4-8` → `Opus 4.8`. + * + * Ids arrive hyphenated with the version split across segments and sometimes a date suffix, so the numeric + * run is rejoined into one version and the words around it title-cased. + */ +export function friendlyModelName(id: string): string { + if (!id) return 'Unknown'; + const name = id.replace(/^claude-/, '').replace(/-\d{8}$/, ''); + const words: string[] = []; + let version: string[] = []; + + for (const part of name.split('-')) { + if (!part) continue; + if (/^\d/.test(part)) { + version.push(part); + continue; + } + if (version.length) { + words.push(version.join('.')); + version = []; + } + words.push(part === 'gpt' ? 'GPT' : part[0]!.toUpperCase() + part.slice(1)); + } + if (version.length) words.push(version.join('.')); + return words.length ? words.join(' ') : 'Unknown'; +} + +/** Every `*.jsonl` under `~/.claude/projects`, one level of project directories deep. */ +async function transcriptFiles(root: string): Promise { + let projects: string[]; + try { + projects = await readdir(root); + } catch { + return []; + } + + const files = await Promise.all( + projects.map(async (name) => { + const dir = join(root, name); + try { + if (!(await stat(dir)).isDirectory()) return []; + return (await readdir(dir)).filter((f) => f.endsWith('.jsonl')).map((f) => join(dir, f)); + } catch { + return []; + } + }), + ); + return files.flat(); +} + +async function scanTranscripts(): Promise { + const dates = recentDates(); + const byDay = new Map(dates.map((d) => [d, 0])); + const byModel = new Map(); + + /* + Dedup by the ANTHROPIC message id, and this is the one detail the whole scan turns on. + + The same assistant message is written to more than one transcript: a `/clear` chain splices, a resume + replays, and compaction rewrites. Summing the files naively double-counts, and it does so unevenly — the + long-running conversations, which are exactly the ones the numbers are about, are the worst affected. + */ + const seen = new Set(); + + for (const path of await transcriptFiles(join(claudeDir(), 'projects'))) { + let text: string; + try { + text = await readFile(path, 'utf-8'); + } catch { + continue; + } + + let lineNumber = 0; + for (const line of text.split('\n')) { + lineNumber++; + // A cheap substring test before `JSON.parse` — most lines in a transcript are tool traffic, and + // parsing ~90MB of them to discard the result costs seconds. + if (!line.includes('"usage":')) continue; + + let entry: Record; + try { + entry = JSON.parse(line) as Record; + } catch { + continue; + } + + const message = (entry.message ?? {}) as Record; + if (entry.type !== 'assistant' && message.role !== 'assistant') continue; + const usage = (message.usage ?? entry.usage) as Record | undefined; + if (!usage || typeof usage !== 'object') continue; + + const id = message.id ?? entry.messageId ?? ''; + const key = id ? String(id) : `${path}:${entry.uuid ?? entry.requestId ?? lineNumber}`; + if (seen.has(key)) continue; + seen.add(key); + + const input = count(usage, 'input_tokens', 'inputTokens'); + const output = count(usage, 'output_tokens', 'outputTokens'); + const cacheRead = count(usage, 'cache_read_input_tokens', 'cacheReadInputTokens'); + const cacheWrite = count(usage, 'cache_creation_input_tokens', 'cacheCreationInputTokens'); + const total = input + output + cacheRead + cacheWrite; + if (total <= 0) continue; + + const model = String(message.model ?? entry.model ?? 'claude'); + const bucket = byModel.get(model) ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }; + bucket.input += input; + bucket.output += output; + bucket.cacheRead += cacheRead; + bucket.cacheWrite += cacheWrite; + byModel.set(model, bucket); + + const day = dateOfEntry(entry.timestamp ?? message.timestamp); + if (byDay.has(day)) byDay.set(day, byDay.get(day)! + total); + } + } + + const models = [...byModel.entries()] + .map(([id, b]) => ({ name: friendlyModelName(id), total: b.input + b.output + b.cacheRead + b.cacheWrite, ...b })) + .sort((a, b) => b.total - a.total); + + return { days: dates.map((date) => ({ date, tokens: byDay.get(date) ?? 0 })), models }; +} + +async function collectScan(): Promise { + const now = Date.now(); + if (scanCache && now - scanCache.at < SCAN_TTL_MS) return scanCache.result; + 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 { + 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(), + }; +} diff --git a/src/workspaces/officerdev/src/apps/Chat/components/ModelSelector.test.tsx b/src/workspaces/officerdev/src/apps/Chat/components/ModelSelector.test.tsx index c84c2081..51a0a164 100644 --- a/src/workspaces/officerdev/src/apps/Chat/components/ModelSelector.test.tsx +++ b/src/workspaces/officerdev/src/apps/Chat/components/ModelSelector.test.tsx @@ -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[0]> = {}) => render( - {}} - model={null} - isConnected - isGenerating={false} - hasStarted - {...over} - />, + + {}} + model={null} + isConnected + isGenerating={false} + hasStarted + {...over} + /> + , ); const providerButton = (name: RegExp) => screen.getByRole('button', { name }); diff --git a/src/workspaces/officerdev/src/apps/Chat/components/ModelSelector.tsx b/src/workspaces/officerdev/src/apps/Chat/components/ModelSelector.tsx index 1eb521f8..34a021fd 100644 --- a/src/workspaces/officerdev/src/apps/Chat/components/ModelSelector.tsx +++ b/src/workspaces/officerdev/src/apps/Chat/components/ModelSelector.tsx @@ -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 = { anthropic: 'Anthropic', @@ -150,12 +151,20 @@ export function ModelSelector({ return (
{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. - - {activeProvider ? displayName(activeProvider) : 'Chat'} - + /* + 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. + */ + ) : ( // 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. diff --git a/src/workspaces/officerdev/src/apps/Chat/components/UsagePopover.tsx b/src/workspaces/officerdev/src/apps/Chat/components/UsagePopover.tsx new file mode 100644 index 00000000..88f490ac --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Chat/components/UsagePopover.tsx @@ -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 }) => ( +
+
+
+); + +const Section = ({ title, children }: { title: string; children: React.ReactNode }) => ( +
+
{title}
+ {children} +
+); + +const Limits = ({ usage, now }: { usage: ClaudeUsage; now: number }) => ( +
+
+ {usage.limits.map((limit) => { + const resets = formatReset(limit.resetsAt, now); + return ( +
+
+ {limit.label} + {Math.round(limit.percent)}% +
+ + {resets && Resets in {resets}} +
+ ); + })} +
+
+); + +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 ( +
+
+ {usage.days.map((day, index) => { + const today = index === usage.days.length - 1; + return ( +
+ + {today ? 'Today' : weekdayLabel(day.date)} + +
+ +
+ + {formatTokens(day.tokens)} + +
+ ); + })} +
+
+ ); +}; + +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 ( +
+
+ {models.map((model) => ( +
+ {/* 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. */} +
+ {model.name} + + {formatTokens(model.total)} + +
+ ))} +
+
+ ); +}; + +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 {label}; + + return ( + + + + + +
+
{label}
+
+ {[data?.account.plan, data?.account.email].filter(Boolean).join(' · ') || + (isLoading ? 'Reading usage…' : 'Not signed in')} +
+
+ + {/* 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 && ( +
+ Couldn't read usage on this machine. +
+ )} + {data?.limitsProblem && ( +
{data.limitsProblem}
+ )} + + {isLoading && !data && ( +
Loading…
+ )} + + {data && ( + <> + {data.limits.length > 0 && } + + {data.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 new file mode 100644 index 00000000..cf46396c --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Chat/useClaudeUsage.test.ts @@ -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'); + }); +}); diff --git a/src/workspaces/officerdev/src/apps/Chat/useClaudeUsage.ts b/src/workspaces/officerdev/src/apps/Chat/useClaudeUsage.ts new file mode 100644 index 00000000..2af679b4 --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Chat/useClaudeUsage.ts @@ -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({ + queryKey: ['chat', 'usage'], + 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. + 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' }); +}