Follow-ups to the delegating statusline shim from discussion #405, answering the four design questions and the Docker one raised there. Docker cases: the injected command is now a self-selecting shell guard, `if [ -x <node> ] && [ -f <shim> ]; then exec <node> <shim>; fi;` followed by the inline curl exporter. A Docker case bind-mounts the workspace, and with it settings.local.json, at the same absolute path inside the container, but neither the host's node nor ~/.codeman exists there, so a bare shim command would have rendered a broken statusline in every container session. The same string now runs the shim on the host and the curl inside the container. The settings-save injection loop needs no docker guard for that reason; it does skip remote attaches now, whose workingDir is a user@host pseudo-path. Settings precedence: the shim reads exactly the three files Claude Code documents, .claude/settings.local.json and .claude/settings.json under workspace.project_dir (the launch directory), then ~/.claude/settings.json. No ancestor walk and no user-level settings.local.json: delegating to a command Claude Code would have ignored is the original failure in a new coat. The bare word: all three paths that produced `codeman` are gone. The route answers an unknown session with an empty body, formatSessionStatusText() returns '' with nothing to show, and the inline fallback ends in `|| true` (plus `curl -f`, so an HTTP error body never renders as the statusline). The shim also treats a literal `codeman` from an older server as no telemetry and prints nothing rather than a brand word when it has neither a delegate nor a footer, which is what Claude Code shows a user with no statusline of their own. Removal: turning the plan-usage chip OFF now takes the exporter out of the workspaces of the caller's live Claude sessions. statusLineTelemetry:false is sent only by the save that flips the chip off on that device (statusLineTelemetryAction() in settings-ui.js), so a phone whose chip was never on cannot strip the exporter a desktop's chip depends on; a second device with the chip still on re-injects on its next save or session create. Nothing in src/ called applyStatusLineConfig(dir, false) before. Tests run the generated shim AND the injected command as real subprocesses (the fallback half with the shim path pointed at nothing, the container's view), plus both directions of the action field through PUT /api/settings. Measured against the live server: a render costs ~85 ms through the shim versus ~26 ms for the old inline curl (node start ~33 ms, the rest TLS to the loopback HTTPS server plus the delegate spawn), off the input path. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
16 KiB
Plan Usage Limits Display — Design & As-Built
Status: SHIPPED. Deployed to prod + pushed to master, not yet released (2026-06-14). App Settings → Display → Plan Usage Limits (
showPlanUsageLimits). Default changed in 1.9.3: desktop now defaults ON, handhelds stay OFF, resolved viaplanUsageChipEnabled(). The per-device notes further down describing it as opt-in/synced record the original 2026-06-14 shape, not current behavior. The exporter changed shape in 1.28 (discussion #405): it is now a self-selecting shell guard that runs a generated, delegating shim (src/statusline-shim.ts) where the shim exists and the inline curl below where it does not (inside a Docker case's container). The shim prints the statusline it shadows and falls back to the footer below only when there is nothing to shadow; with neither it prints nothing, and the route now answers an unknown session with an empty body, so the bare wordcodemannever renders. Turning the chip OFF now also removes the exporter from live workspaces (statusLineTelemetry:false, sent only on the save that flips the chip off on a device), so the "removal only via the toggle" sentences below are current again and the "never remove" ones record the 1.9-1.27 shape. Seedocs/architecture-invariants.md#plan-usage-chip-statusline-telemetry. 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).