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
@@ -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`;
}