Rewrite to the as-built design: header chip (account limits, green/yellow/red) + session-status footer split; fixed /api/status-telemetry endpoint; curl -sk; add-only create injection + settings-toggle reconcile; no CASES_DIR gate; chip robustness (live SSE + init-snapshot replay + localStorage); and the E2E bugs that earlier builds hid. Status: shipped/pushed, not released. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
14 KiB
Plan Usage Limits Display — Design & As-Built
Status: SHIPPED — deployed to prod + pushed to master, not yet released (2026-06-14). Opt-in via App Settings → Display → Plan Usage Limits (
showPlanUsageLimits, default OFF). Commitsc82f6c8(feature) →4d9d93d(end-to-end fixes) →eae225b(per-user reconcile) →95fb5fc(init-snapshot replay). Full suite green (2869), CI green. No changeset/version bump yet.Two surfaces from one
statusLinecallback:
- Header chip (top-right) — account-wide plan limits:
5h 35% · 7d 38%, per-window green/yellow/red.- In-terminal statusline footer — the current session's status:
Opus 4.8 (1M context) in:562,411 out:1,188 ctx:56%.The
rate_limitsJSON schema below was empirically confirmed against Claude Code 2.1.177 on a Claude Max account; see the Verification appendix to reproduce.
Problem
Codeman had no proactive view of how much of the Claude subscription is left. It only learned about limits reactively: usage-limit-patterns.ts regex-scrapes ANSI-stripped terminal output for footer strings like 5-hour limit reached ∙ resets 8pm, extracting only the reset time, and only after Claude has already stalled. There was no "73% of your 5-hour limit used" anywhere.
We wanted a live, always-visible gauge so the operator can see a wall coming and pace overnight/autonomous runs — without hijacking the in-terminal statusline, which should keep showing the current session's status.
Data source: the statusline rate_limits JSON
Claude Code (v2.1.80+; prod box runs 2.1.177) pipes a JSON blob to a configured statusLine.command on stdin after each render. On Pro/Max subscriptions that blob includes rate_limits. This is the only channel that exposes plan-limit data (see rejected alternatives) — so the feature must set a statusLine command, which is why the footer is also reconstructed by it (below).
Confirmed schema (real captured payload)
"rate_limits": {
"five_hour": { "used_percentage": 15, "resets_at": 1781409000 }, // → 2026-06-14T03:50:00Z
"seven_day": { "used_percentage": 34, "resets_at": 1781827200 } // → 2026-06-19T00:00:00Z
}
| Field | Type | Notes |
|---|---|---|
rate_limits.five_hour.used_percentage |
number 0–100 |
Integer-valued in practice; treat as number, don't assume decimals. |
rate_limits.five_hour.resets_at |
number |
Epoch SECONDS (10 digits). ×1000 for a JS Date. |
rate_limits.seven_day.{used_percentage,resets_at} |
same |
Confirmed facts & gotchas:
- Only two windows exist:
five_hourandseven_day. There is no separate Opus-weekly field, even on a Max/Opus account. rate_limitsis absent on the first render, present after the first API response. UI degrades to "no chip yet."- statusLine fires only in interactive TUI mode, never
--print. Fine — Codeman sessions are interactive TUIs (and so are Codeman-spawned ones in tmux). - Subscriber-gated. Absent for API-key / non-subscriber auth.
Bonus telemetry in the same payload — used for the footer
The same stdin object also carries model.display_name, context_window.{used_percentage, total_input_tokens, total_output_tokens, …}, cost.total_cost_usd, effort.level, etc. The shipped feature uses model + token totals + context % to build the in-terminal footer (so the statusline stays useful even though we own it). The endpoint also broadcasts contextUsedPercentage/costUsd/modelDisplayName alongside the limits for future chip tooltips.
Alternatives considered & rejected
| Source | Why not |
|---|---|
OAuth endpoint api.anthropic.com/api/oauth/usage |
Undocumented, aggressively rate-limited, needs the encrypted OAuth token. Only worth it for dollar spend. |
/usage slash command |
Interactive-only, no programmatic output. |
On-disk ~/.claude/ files |
No usage state persisted (only daemon.status.json = auto-updater supervisor). |
CLI flag (claude usage / --check-usage) |
Does not exist. |
StopFailure hook |
Carries only an error_type on failure — no live percentages. |
As-built architecture
Claude TUI (any Claude session, incl. linked-case/real-repo sessions)
│ renders statusline after each assistant msg (+ /compact, mode change)
▼
statusLine.command (settings.local.json) ──reads stdin JSON──▶
curl -sk POST $CODEMAN_API_URL/api/status-telemetry {sessionId, data}
(X-Codeman-Hook-Secret: $(cat $CODEMAN_HOOK_SECRET_FILE))
│ ◀── HTTP 200 text/plain = current-SESSION status string ──┘
▼
printf '%s' "$body" → in-terminal footer: "Opus 4.8 (1M context) in:… out:… ctx:…%"
server (status-telemetry-routes.ts):
parse rate_limits → (if changed) store last-known + broadcast SSE session:statusTelemetry → header chip
parse model/tokens/ctx → return the session-status footer string
▼
app.js: _onSessionStatusTelemetry → chip (per-window colors) + localStorage save
handleInit → chip from init-snapshot planUsage (fresh-load replay)
1. The exporter — generateStatusLineCommand() in hooks-config.ts
Mirrors the hook curlCmd(). Reads the stdin JSON, POSTs {sessionId, data} to a fixed loopback path, and prints the response body back to stdout (print-through, so the footer stays useful). The managed-session env carries $CODEMAN_SESSION_ID / $CODEMAN_API_URL / $CODEMAN_HOOK_SECRET_FILE (from tmux-manager.buildEnvExports()).
INPUT=$(cat 2>/dev/null || echo '{}'); \
printf '{"sessionId":"%s","data":%s}' "$CODEMAN_SESSION_ID" "$INPUT" | \
curl -sk -X POST "$CODEMAN_API_URL/api/status-telemetry" \
-H 'Content-Type: application/json' \
-H "X-Codeman-Hook-Secret: $(cat "$CODEMAN_HOOK_SECRET_FILE" 2>/dev/null)" \
--data @- 2>/dev/null || echo codeman
⚠️ curl -sk, not curl -s. Prod is loopback HTTPS with a self-signed cert; without -k, curl returns 000 and the statusline silently shows nothing. -k is safe (loopback only). (The existing hook curls use -s without -k and have the same latent issue on HTTPS installs — a known, separate follow-up.)
2. Endpoint — POST /api/status-telemetry (status-telemetry-routes.ts)
Fixed path (sessionId in the body, not the URL) so the auth exemption is an exact-match like /api/hook-event (middleware/auth.ts: loopback-only; X-Codeman-Hook-Secret-gated while a tunnel runs). Schema StatusTelemetrySchema in schemas.ts validates the subset; unknown keys are stripped. Pure parsing/formatting in usage-telemetry.ts:
parseStatusTelemetry(data)→{ fiveHour, sevenDay, … }ornull. On change (signature dedup; statusline fires often), store last-known (plan-usage-latest.ts) andbroadcast('session:statusTelemetry', { sessionId, …telemetry }).parseSessionStatus(data)+formatSessionStatusText()→ the footer stringOpus 4.8 (1M context) in:562,411 out:1,188 ctx:56%(returned astext/plain). Available from the first render, even beforerate_limitsappears.
3. SSE + frontend chip
session:statusTelemetry registered in sse-events.ts + constants.js. app.js:
_onSessionStatusTelemetry→updatePlanUsageChip(data)+ save tolocalStorage['codeman:planUsage'].updatePlanUsageChiprenders two5h/7dwindows; per-window color by usage — green<60%, yellow60–84%, red≥85%(pu-green/pu-yellow/pu-red); bold labels/values; reset times in the tooltip.resets_at*1000 → Date.- Chip element ships hidden (
header-plan-usage--hidden);applyHeaderVisibilitySettings()reveals it client-side when the setting is on (response-viewer pattern — norenderIndexHtmlstrip, which kept the "title-only" render contract intact).
4. Chip data robustness — three layers
- Live:
session:statusTelemetrySSE on every distinct render. - Fresh load / reconnect: server stores the latest in
plan-usage-latest.ts;getLightState()includes it asplanUsage; the per-connection init snapshot replays it;handleInitpaints the chip immediately (authoritative over localStorage). Null until the first telemetry of the process. - Offline / cross-restart:
restorePlanUsageChip()readslocalStorageon load (12h freshness guard).
5. Injection lifecycle — works for any user, never self-destructs
The setting showPlanUsageLimits is synced (in settings.json, not a per-device displayKey).
- On toggle (
PUT /api/settings,system-routes.ts): reconcile the exporter across all active Claude sessions' working dirs — inject on enable, remove on disable. Server-side and authoritative, so existing sessions get the footer + feed the chip immediately, no new session needed, no dependency on a client's synced localStorage. - On session create (
session-routes.ts): ADD-ONLY — inject whenstatusLineTelemetryis true; never remove. Sessions in a repo share onesettings.local.json, so a single create-with-false (e.g. a client whose synced setting hadn't loaded) must not yank the statusLine out from under other live sessions. Removal happens only via the explicit toggle. applyStatusLineConfig()isisOurs-guarded (matches/api/status-telemetry), so a user's own hand-authored statusLine is never touched, and it updates an out-of-date ours-command so fixes (e.g.-k) propagate. NoCASES_DIRgate — runs for linked cases / real repos (where sessions actually run), mirroringupdateCaseModel.
Codeman-specific considerations
- Account-global limits. The 5h/7d pools are shared across all sessions on the account → one shared header chip (freshest sample wins), not a per-tab bar.
- The footer is owned, by necessity. A statusLine command always replaces Claude's default footer. Since
rate_limitsonly arrives via statusLine, we reconstruct a useful session-status footer (model · tokens · ctx %) from the same payload rather than showing the limits there. isOurs-guarded. Never removes/overwrites a user's own statusLine on disable; only manages the Codeman exporter.- Security envelope unchanged. The exporter runs arbitrary shell every render — same trust model as the hook curls (localhost +
$CODEMAN_HOOK_SECRET_FILE); reuses the hook-secret gate. - Claude-only. OpenCode/Codex emit no
rate_limitsJSON; injection is gated tomode === 'claude'. - Future — auto-resume synergy. Live percentages would let
SessionAutoOpspre-arm before the wall instead of reacting to the stall footer. Not built.
Files shipped
src/usage-telemetry.ts— pure parse/format (parseStatusTelemetry,parseSessionStatus,formatSessionStatusText,telemetrySignature) +test/usage-telemetry.test.ts.src/hooks-config.ts—generateStatusLineCommand()(curl -sk),applyStatusLineConfig()(add/update/remove,isOurs-guarded).src/web/routes/status-telemetry-routes.ts—POST /api/status-telemetry.src/web/plan-usage-latest.ts— process-wide last-known store for init replay.src/web/schemas.ts—StatusTelemetrySchema+showPlanUsageLimits+ create-payloadstatusLineTelemetry.src/web/middleware/auth.ts— exemption extended to/api/status-telemetry.src/web/routes/session-routes.ts— add-only create-time injection.src/web/routes/system-routes.ts— settings-toggle reconcile.src/web/server.ts—getLightState().planUsage(init snapshot).src/web/sse-events.ts+constants.js—session:statusTelemetry.- Frontend:
app.js(_onSessionStatusTelemetry,updatePlanUsageChip,restorePlanUsageChip,handleInit),settings-ui.js(toggle +applyHeaderVisibilitySettings),index.html(chip + toggle row),styles.css(chip + colors),session-ui.js(create payload).
Bugs E2E testing caught (that unit tests didn't)
The first "shipped" build passed every test and was broken in practice. End-to-end testing on the real install (the lesson: drive a REAL session, observe the REAL output) surfaced:
CASES_DIRinjection gate excluded the user's whole workflow — sessions run in linked cases / real repos, not under~/codeman-cases. → dropped the gate.curl -s→000on the loopback self-signed HTTPS cert; statusline silently empty. →curl -sk.- Remove-on-create-false + shared
settings.local.jsonlet a single stale client yank the statusLine out from under all sessions in a repo. → add-only on create; removal only via the toggle reconcile. - Chip blank after reload (localStorage-only, lost on restart/fresh browser). → server-side last-known in the init snapshot.
Open questions / future
- Schema stability.
rate_limitsis officially shipped but undocumented in exact shape; the parser is tolerant (renders whatever windows exist, ignores unknown). - Hook
curl -sparity. Hooks share the no--kissue on HTTPS installs — worth fixing the hook curl too (separate change; covered bycod54tests). - Disable cleanliness. Disabling removes the statusLine from active sessions; a brand-new session created by a stale client could re-add it (chip still hidden, footer benign). Fully server-authoritative create-time injection (read the setting server-side instead of the payload flag) would close this — deferred.
Verification appendix — how the schema was captured (reproducible)
Captured without touching global settings or any real session:
- Throwaway dir
/tmp/sl-capturewith an exporterdump.shthat appends stdin topayloads.jsonland printscap; asettings.jsonpointingstatusLine.commandat it. --printmode does not render a statusline → no capture (confirms TUI-only). Must use interactive.- Launch interactive Claude in an isolated tmux socket (
tmux -L slcap, never-L codeman) inside the temp dir,--settings /tmp/sl-capture/settings.json(no global mutation). Confirm the workspace-trust dialog (appears even with--dangerously-skip-permissions), then send a one-line prompt (literal text + Enter separately, Ink-style). - After the first response,
rate_limitsappears in the second captured record (absent in the first). Inspect withjq '.rate_limits'. - Tear down:
tmux -L slcap kill-server+rm -rf /tmp/sl-capture; verify thecodemansocket is untouched.
Related: docs/claude-code-hooks-reference.md (hook callback pattern), src/usage-limit-patterns.ts (reactive fallback), docs/respawn-state-machine.md (auto-resume interplay).