feat(quota): Claude Code quota + per-session cost in the header and statusline #80

Merged
lz merged 27 commits from feat/quota into main 2026-07-17 18:10:37 +02:00
Owner

What

Surfaces Claude Code quota (5-hour / 7-day rate-limit windows) and per-session cost/context in two places:

  • Nexus header badge — the account's 5h/7d utilisation. Single line on wide screens, two compact circular gauges below 900px. Lucide History icon for reset times, warn (>75%) / danger (>90%) bands.
  • Claude Code terminal statusline◆ feat/quota | 5h 31% | 7d 8% | ctx 24% | $0.42 inside each session.
  • Per-session cost/context on each session row ($0.42 · 12k ctx).

Where the data comes from (not what you'd expect)

GET /api/oauth/usage — the obvious endpoint — returns 429 unconditionally, even for unauthenticated requests, so it is not account-scoped and cannot be used (see claude-code#31021). Instead, Claude Code reads the anthropic-ratelimit-unified-* headers off its own /v1/messages responses and hands them to the statusLine command on stdin. So the worker's statusline script is the source: it renders the terminal line and reports the numbers to Nexus. No extra API call, no token handling, and it can't be rate-limited.

Architecture

claude (per render) → stdin JSON → /usr/local/bin/nexus-statusline
   ├─ stdout: the terminal line (always prints, always exits 0, never blocks)
   └─ throttled 30s, backgrounded POST → /api/agent/quota (bridge-IP guarded)
                                            → in-memory store (no migration)
                                            ← GET /api/quota → header badge + session rows
  • Quota is account-global (the fleet shares one OAuth token) → stored once, per-window last-write-wins. Cost/context are per-session.
  • In-memory store, no migration (approach A). Quota is ephemeral and self-healing — any live session repopulates it within one render; a restart shows "unknown" (an em-dash), never a stale or zeroed value.
  • statusLine is seeded into the fleet-shared settings.json once, flag-guarded (statusline_seeded) exactly like the agent-instructions seeder, so operator deletion is honoured.

The invariant this feature is built around: unknown ≠ zero

A session that has spent nothing and a session we know nothing about are different facts, and the payload signals "unknown" three different ways that disagree — so the code never infers unknown from a falsy value:

  • rate_limits — an absent key until the session's first inference call
  • context_window.used_percentagenull, while its sibling total_input_tokens is 0 for the same state
  • per-session cost after a Nexus restart — simply missing from the store

All three render as , never 0% / $0.00.

Testing

  • Unit (vitest): band thresholds (incl. the ×100-double-scale trap), store (last-write-wins + partial-report merge + null-vs-0), the bridge-guarded callback (403 paths, float-noise clamp at 100%, no cross-session clobber), the boot seeder (deletion honoured, no clobber, corrupt-file bail without setting the flag), eviction on the orphan-reaper path.
  • Shell round-trip (worker/nexus-statusline.test.sh, 20 cases): render for every state, the em-dash unknown states, the per-session throttle stamp (3 sessions → 3 POSTs), the POSTed wire body preserving null, the jq-absent fallback, and always-exit-0.
  • Browser-verified (real Chromium, measured offsetHeight): the badge switches line↔circles correctly, the circle switch does not grow the header, warn/danger colours and the dashed unknown state render as designed.

753 vitest + 20 shell tests, lint + typecheck clean.

Notable during review

Built task-by-task with per-task verification, then a 4-lens review pass (tests / errors / types / comments). Verification caught several issues that a green suite missed — most notably a self-referential container query that made the single-line badge dead code at every width (fixed to a viewport @media at 900px), and the statusline POST body's unknown-as-null guarantee being untested at the one layer that mattered. See docs/superpowers/specs/2026-07-16-quota-statusline-design.md (design + verified findings) and docs/superpowers/plans/2026-07-17-quota-statusline.md (implementation plan) for the full record.

Docs

AGENTS.md gains fact #19 documenting the above, and a correction to the Tests section (the SQLite test helper is openTestDb/sqliteAvailable in nexus/src/lib/server/testing/db.ts, not the previously-stated makeTestDb).

  • Contrast a11y issue found along the way: #72 (--muted-2 text fails WCAG) — deliberately out of scope here; this feature uses --muted matching existing convention.
## What Surfaces Claude Code quota (5-hour / 7-day rate-limit windows) and per-session cost/context in two places: - **Nexus header badge** — the account's 5h/7d utilisation. Single line on wide screens, two compact circular gauges below 900px. Lucide `History` icon for reset times, warn (>75%) / danger (>90%) bands. - **Claude Code terminal statusline** — `◆ feat/quota | 5h 31% | 7d 8% | ctx 24% | $0.42` inside each session. - **Per-session cost/context** on each session row (`$0.42 · 12k ctx`). ## Where the data comes from (not what you'd expect) `GET /api/oauth/usage` — the obvious endpoint — returns `429` unconditionally, even for unauthenticated requests, so it is **not** account-scoped and cannot be used (see [claude-code#31021](https://github.com/anthropics/claude-code/issues/31021)). Instead, Claude Code reads the `anthropic-ratelimit-unified-*` headers off its own `/v1/messages` responses and hands them to the `statusLine` command on stdin. So the worker's statusline script is the source: it renders the terminal line **and** reports the numbers to Nexus. No extra API call, no token handling, and it can't be rate-limited. ## Architecture ``` claude (per render) → stdin JSON → /usr/local/bin/nexus-statusline ├─ stdout: the terminal line (always prints, always exits 0, never blocks) └─ throttled 30s, backgrounded POST → /api/agent/quota (bridge-IP guarded) → in-memory store (no migration) ← GET /api/quota → header badge + session rows ``` - **Quota is account-global** (the fleet shares one OAuth token) → stored once, per-window last-write-wins. **Cost/context are per-session.** - **In-memory store, no migration** (approach A). Quota is ephemeral and self-healing — any live session repopulates it within one render; a restart shows "unknown" (an em-dash), never a stale or zeroed value. - **`statusLine` is seeded** into the fleet-shared `settings.json` once, flag-guarded (`statusline_seeded`) exactly like the agent-instructions seeder, so operator deletion is honoured. ## The invariant this feature is built around: unknown ≠ zero A session that has spent nothing and a session we know nothing about are different facts, and the payload signals "unknown" three different ways that **disagree** — so the code never infers unknown from a falsy value: - `rate_limits` — an **absent key** until the session's first inference call - `context_window.used_percentage` — **`null`**, while its sibling `total_input_tokens` is `0` for the same state - per-session cost after a Nexus restart — simply **missing** from the store All three render as `—`, never `0%` / `$0.00`. ## Testing - Unit (vitest): band thresholds (incl. the ×100-double-scale trap), store (last-write-wins + partial-report merge + null-vs-0), the bridge-guarded callback (403 paths, float-noise clamp at 100%, no cross-session clobber), the boot seeder (deletion honoured, no clobber, corrupt-file bail without setting the flag), eviction on the orphan-reaper path. - Shell round-trip (`worker/nexus-statusline.test.sh`, 20 cases): render for every state, the em-dash unknown states, the per-session throttle stamp (3 sessions → 3 POSTs), the **POSTed wire body** preserving `null`, the jq-absent fallback, and always-exit-0. - **Browser-verified** (real Chromium, measured `offsetHeight`): the badge switches line↔circles correctly, the circle switch does not grow the header, warn/danger colours and the dashed unknown state render as designed. `753 vitest + 20 shell tests, lint + typecheck clean.` ## Notable during review Built task-by-task with per-task verification, then a 4-lens review pass (tests / errors / types / comments). Verification caught several issues that a green suite missed — most notably a self-referential container query that made the single-line badge dead code at every width (fixed to a viewport `@media` at 900px), and the statusline POST body's unknown-as-null guarantee being untested at the one layer that mattered. See `docs/superpowers/specs/2026-07-16-quota-statusline-design.md` (design + verified findings) and `docs/superpowers/plans/2026-07-17-quota-statusline.md` (implementation plan) for the full record. ## Docs AGENTS.md gains fact #19 documenting the above, and a correction to the Tests section (the SQLite test helper is `openTestDb`/`sqliteAvailable` in `nexus/src/lib/server/testing/db.ts`, not the previously-stated `makeTestDb`). ## Related - Contrast a11y issue found along the way: #72 (`--muted-2` text fails WCAG) — deliberately out of scope here; this feature uses `--muted` matching existing convention.
lz added 27 commits 2026-07-17 18:07:47 +02:00
Quota rides on anthropic-ratelimit-unified-* headers of ordinary inference
responses, and Claude Code already forwards it to the statusline as
rate_limits.{five_hour,seven_day}. No API call, no token, no rate limit —
/api/oauth/usage (claude-code#31021) is not needed.

Approach A: in-memory store, no migration. Account-global quota in a header
badge (single line -> 20px circles under 640px, header height unchanged);
per-session cost on session rows.
context_window is always present in the statusline payload (unlike
rate_limits) and used_percentage is already 0-100, pre-rounded/clamped.

But used_percentage is null when there is no usage yet, while its sibling
total_input_tokens reports 0 for the same state -- so tokens cannot be used
as the unknown signal. Third distinct unknown-vs-zero sentinel in this
design; documented as a table with an explicit signal column.

Statusline shows ctx as a percentage (headroom is the actionable question);
Nexus session rows keep tokens (for comparing sessions).
13 tasks, TDD throughout. Task 1 is blocking: a throwaway statusLine that
dumps a real payload, settling resets_at units, NEXUS_SESSION_ID inheritance,
and render cadence before anything is built on them.

Self-review caught that AGENTS.md misstates the test-db helper: it is
openTestDb from lib/server/testing/db.ts, not makeTestDb from src/testing/db.ts,
and sqlite-backed tests need an explicit sqliteAvailable() skip guard.
docker-compose.yml does not set WORKERS_BRIDGE_SUBNET, so Nexus uses the
172.30.0.0/16 default from config.ts. This worker's own default route IS
172.30.0.0/16 via eth0 (gateway 172.30.0.1) -- a DinD Nexus would install a
competing route over its own lifeline and lose internet mid-task (issue #59).

Task 12 now pins WORKERS_BRIDGE_SUBNET=172.28.0.0/16, documents how to
re-derive the free range per spawn, and asserts connectivity survives.
Captured a real statusline payload by temporarily pointing statusLine at a
dumper. The three flagged assumptions held (resets_at = epoch seconds,
NEXUS_SESSION_ID inherits, cadence ~1.6/s peak confirms the 30s throttle).
Four things it corrected:

- settings.json is LIVE-RELOADED; 4 already-running sessions picked the hook
  up within seconds. No restart needed -- and a bad statusLine reaches the
  whole fleet instantly, so the flag-guard matters more, not less.
- /tmp is shared by all N sessions in a worker (4 distinct NEXUS_SESSION_IDs
  observed writing the same files). A single stamp would starve every session
  but the first; the stamp is now keyed by NEXUS_SESSION_ID, with a test.
- session_name is claude-code's auto conversation title ('Check API quota
  endpoint for Claude Code'), not the Nexus name. Derive from cwd instead.
- used_percentage carries float noise (28.999999999999996 observed). zod
  max(100) would 400 the report exactly when quota is exhausted -- clamp.

Also fixed a test that could never fail: grep -q has no lookahead, so the
'auto title absent' assertion needed a real run_not helper.
vitest writes a transient vitest.config.ts.timestamp-*.mjs beside its config
while running and it is not gitignored, so a blanket add racing a test run
would commit it. Stage explicit paths instead.
Renders '◆ feat/quota | 5h 31% | 7d 29% | ctx 24% | $0.42' and reports to
Nexus at most once per 30s per session, backgrounded so a render never waits
on the network. Always prints, always exits 0.

Carries the Task 1 findings: name derives from cwd (session_name is
claude-code's auto conversation title); the throttle stamp is keyed by
NEXUS_SESSION_ID because N sessions share one container /tmp; float noise
(28.999999999999996) rounds to 29%; absent rate_limits and null
context_window.used_percentage both render an em-dash, never 0%.

Fixes two bugs in the plan's own tests: they compared against raw output
while the script emits ANSI (escapes interleave between every token, so no
human-readable substring could ever match), and the no-env case inherited
NEXUS_SESSION_ID/NEXUS_URL from the surrounding session instead of unsetting
them, so it asserted the opposite of what it meant to.
Plan said 6 tests, the file has 8 — my miscount, nothing missing.

Record that zod .default(null) collapses an omitted context_percentage into
an explicit null. Both mean unknown so it is consistent, but the design has
been bitten three times by unknown-vs-zero and a future consumer could not
distinguish 'script never sent it' from 'claude reported no usage'. Accepted
rather than fixed: tightening it would 400 reports the script legitimately
sends.
Task 8 (89f57b0) evicted only in deleteSession, but sessions rows are
hard-deleted at three sites. The two missed:

- convergeSessions (the orphan reaper, runs ~every 10s) — the PRIMARY
  cleanup path. Leaking here defeats the eviction entirely: the sessionStats
  Map would grow by one entry per dead session on every poll.
- closeWorkspaceSession — the workspace shell reports quota too, so its
  stats need dropping when it closes.

Both already had a previewProxy.stopAllForSession cleanup right before the
row delete; eviction joins it. Regression test on the converge path is
mutation-checked: commenting the evict makes it fail.
The plan told the executor to 'export WORKERS_BRIDGE_SUBNET=172.28.0.0/16'
before docker compose up. That export is INERT: the nexus service's
environment block neither sets nor interpolates the var, so it never reaches
the container and Nexus uses the 172.30 default -- exactly the collision that
kills this worker's internet. Compose now requires an override file that
injects the var into the service; and pnpm dev (no docker networking, zero
subnet risk) is the preferred path for UI verification.
The badge switched line<->circles via @container (max-width: 640px) with
container-type on its own root .q. A container query measures the element's
OWN inline size, and the badge is only ~130px wide — so it was ALWAYS below
640px and ALWAYS rendered circles; the single-line layout never appeared at
any viewport width. Browser verification caught it (node tests can't see CSS).

Switch to a viewport @media query, which correctly implements the intent
'single line when the screen has room, circles when it doesn't'. Verified in
a real browser: line at >=640px, circles below; the circle switch does not
grow the header (circles are the more compact mode).
Measured in a real browser: alongside the full nav cluster the single-line
badge stops fitting on one row at ~900px (fits at 901, wraps at 890), not
640px. The mockup's header had less chrome so 640 looked right there. At the
old breakpoint there was a 640-899px window where the line rendered but pushed
Settings/Lock onto a second row (header 55->81px). 900px switches to the
compact circles exactly when the line would stop fitting, so the header stays
a single 55px row across the whole desktop/tablet range; it only wraps below
~500px (genuine mobile), in circles mode. Faithful to 'single line only if
there is space.'
Adds fact #19 documenting the quota feature: statusline payload as source (not
/api/oauth/usage), the three disagreeing unknown sentinels, per-session-cwd
naming, the session-keyed throttle, live-reloaded settings.json + flag-guarded
seed, eviction at all three delete sites, and the 900px viewport breakpoint.

Also fixes the Tests section: the SQLite helper is openTestDb/sqliteAvailable
in nexus/src/lib/server/testing/db.ts, not 'makeTestDb' in 'src/testing/db.ts'
-- the old text sent every seeder task chasing a nonexistent file.
PR review (test coverage) found the shell script's headline invariant
unprotected at the one layer that isn't checked: the POST body is built by a
jq filter SEPARATE from the render path, and the curl shim discarded --data.
Rendering 'ctx —' never proved the wire carries context_percentage:null
rather than 0 — a one-char '// 0' on the body filter would reintroduce the
unknown-as-zero regression with every test still green.

The shim now captures --data; four assertions pin the body (rate_limits null,
context_percentage null for the no-rate-limits payload, session_id set,
populated %% passes through). Mutation-checked: adding '// 0' now fails the
null assertion. Also exercises the jq-absent fallback (harness otherwise skips
wholesale when jq is missing) via a jq-free PATH, asserting it still prints and
exits 0.
Comment review flagged it named only deleteSession; it's called from three
delete sites (matches AGENTS.md fact #19). Pure doc correction.
fix(quota): merge partial rate_limits reports; de-dupe band thresholds
All checks were successful
ci / nexus (pull_request) Successful in 6m10s
pr-image-cleanup / delete-pr-images (pull_request) Successful in 8s
ci / images (pull_request) Successful in 12m55s
4e4a5c304c
Two PR-review findings (each flagged by two independent reviewers):

1. setAccountQuota replaced the whole window set, so a partial rate_limits
   report (one window present, the other absent — structurally possible since
   claude-code's builder makes each window independently conditional) silently
   dropped the omitted window from the badge until re-supplied. Now merges
   per-window: { ...accountQuota, ...windows, updatedAt: now }. Test added
   (mutation-confirmed: fails against the old replace-semantics).

2. The 75/90 band thresholds were hardcoded in three places (format.ts,
   QuotaBadge.svelte, the shell script); the Svelte copy was the one NOT
   unit-tested and could drift from the server. Extracted WARN_ABOVE/
   DANGER_ABOVE into $lib/quota-bands.ts (client-importable, unlike
   $lib/server) and imported into both TS sites. Shell stays separate (bash)
   but is pinned by its own test.
lz merged commit 718c3eb362 into main 2026-07-17 18:10:37 +02:00
lz deleted branch feat/quota 2026-07-17 18:10:37 +02:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
lz/agent-nexus!80
No description provided.