acpmux: forward a Claude Code chat's plan usage limits to clients
Summary
Leo asked for a fuller Context card in the session sidebar: "i feel like having a teensy bit more of the card type thing with fuller info in the session sidebar would be nice? like just make our context circle have a bit more stuff". A sibling PR draws the account's usage limits in that card. This PR gives it the data. acpmux now forwards a Claude Code chat's plan usage limits (the 5-hour session and the weekly windows, with their reset times) to clients.
Leo-ask: ask-af4ecbf18f
Claude Code (stream-json, 2.1.30x) writes {"type": "rate_limit_event", "rate_limit_info": {...}} lines. acpmux turns each one into:
- a
session_info_updatewith_meta.acpmux.usageLimits, and - the session detail field
usageLimits(next tousageandcontextBreakdown), saved in the session meta the same waycontextBreakdownis.
Both have this shape: { "windows": [{ "id": "session" | "weekly" | "weekly_opus" | "weekly_sonnet", "usedPercent": 0-100, "resetsAt"?: unix seconds }] }.
- Window names.
five_hourmaps tosession,seven_daytoweekly,seven_day_opustoweekly_opusandseven_day_sonnettoweekly_sonnet. The overage windows (overage,seven_day_overage_included) are extra paid usage, not a plan limit, so they are left out. - Scale.
utilizationis a 0-1 fraction. Claude Code 2.1.307's own schema says so: "utilization is the fraction of the window used (usually 0-1 ...; values above 1 occur when usage legitimately runs past a window's cap)". acpmux multiplies it by 100, rounds to two decimals and caps it at 100. - Which windows an event carries. An event can name its limiting window (
rateLimitType+utilization+resetsAt) and can also carryunifiedWindows. When both name the same window,unifiedWindowswins. - Newest value per window. A later event for one window updates only that window. The adapter keeps what this Claude Code process has reported so far. The hub merges each update into the saved value by window id, so a respawned Claude Code does not erase the windows it has not reported yet. A fork copies its parent's limits, because they belong to the account.
- Which session gets it. The update always goes to the chat's own session, even when a subagent's stream reported the limits.
- Failover. A harness failover clears the chat's
usageLimits, because they belonged to the old account. It is handled like #24223'scontextBreakdown: a retired agent's lateusageLimitsupdate is dropped before the tap logs it (agent::process_state, checked by base's retire fenceChildAgent::tap_liveand the hub's generation check under the child lock). - No
plan. The stream does not name a plan reliably: neithersystem/initnorrate_limit_eventcarries one. Soplanis never set, though the merge keeps one if a future source adds it. - Codex: follow-up, not mapped here. cmux-codex-acp forwards Codex's
account/rateLimits/updatedas_meta.codexAppServer.rateLimits(primary/secondarywindows withusedPercent,windowDurationMins,resetsAt, plusplanType). It can be mapped. But the adapter merges sparse updates at the top level regardless oflimitId, so a snapshot for another quota bucket can overwriteprimary. That makes the mapping unreliable until the adapter keys windows by limit.
The protocol doc docs/acpmux-schema.json (SessionDetail) now describes usageLimits. No webview change: the UI is the sibling's job, and no generated type changes.
#24223 (context breakdown) and #24272 (MCP reconnect) have landed, so this branch now carries only the usage limits (+341/-18).
Testing
- Unit tests in
claude_stdio/usage_limits_tests.rs:- fraction to percent conversion, including a value above 1 capped at 100
- the limiting window and
unifiedWindowsin one event, with overage left out - a later event updating only its own window
- malformed and overage-only events sending nothing
- the hub's merge by window
- Hub integration test
tests/hub_integration/usage_limits.rs:fake_claude.pyrate-limits-probesends tworate_limit_eventlines, and the test checks_acpmux/infousageLimitsand the twosession_info_updates. claude_failover.rs: the limits are there before a failover and gone after it. A replaced process's laterate_limit_eventis neither applied nor logged.
Observed / Not run
- Observed at e7d55c5:
cmux-ci compile-check --apppassed.- The fleet Rust tests (4 shards) ran. All 9 usage-limits and failover tests pass. The red tests that also fail alone fail the same way in an unrelated lane's run (base reds).
- PR CI's clippy and rustfmt for the changed cmux-tui packages passed. rustfmt also passes locally, and
prepush-gate.pyreports no new violations.
- Not run: a live Claude Code session on a subscription account. The line shape comes from the Claude Code 2.1.307 bundle's own builder and zod schema, not from a captured stream.
Changelog
Added: Claude Code chats report the account's session and weekly usage limits, with reset times, to the agent pane
Proof
No capture: acpmux data only. The sibling UI PR shows the card.
Adopted and rebased (2026-10-25)
The stale-pr-reconciler, word for word: "These PRs in your area have no live owner (Leo, 2026-10-25: "these should never have been this stale for this long"). Adopt each: pr-owner claim N (in that repo), rebase onto its base, an independent review at the new head, then land it".
- Re-applied only this PR's own net diff onto feat-cmux-next (it was 3,086 commits behind and carried #24223). Base had hardened the retired-agent fence for /context answers (a retire lock in
tap_live, and a generation check under the child lock in the hub). I kept base's fence and widened both checks fromcontextBreakdowntoagent::process_state, which coverscontextBreakdownorusageLimits. - A change to the merged limits now marks the meta for saving, as a breakdown change does (
breakdown_changed |= m.usage_limits != merged). An identical update writes nothing. Claude Code sendsrate_limit_eventonly when a window's rounded percent or reset moves. - Review: Codex gpt-6-astra xhigh, SHIP at 2ccfca34234 with no findings ("The reapplication preserves the base's retirement fencing ... Late retired-agent usage updates are excluded without changing context-breakdown behavior").
Tests on the rebased branch (fast-checks on Blacksmith)
- Red at the test commit 8a6497e0d33:
usage_limits::a_claude_chat_keeps_its_accounts_usage_limits,claude_failover::a_failover_drops_the_last_harnesss_context_breakdownanda_replaced_harnesss_late_context_breakdown_is_not_keptfail. - Green at f4d4f569519:
--lib usage_limits4 pass;hub_integrationusage_limits/claude_failover/fork_through/context_window 96 pass. In the fullcargo test -p acpmux, everything passes exceptdaemon_chat_open::a_long_chat_opens_on_its_newest_page...("opening the chat read 5597930 bytes (budget 4000000)"), which fails the same way on base 7bd097da98a. clippy-D warningsand fmt are clean at 2ccfca34234. - PR CI reds that aren't from this PR:
Script tests(fails on base),Package dependency lists sorted(this PR touches no Swift package), and GitHub API rate limits or 403s in fast-gates status, merge-tree publish, same-tree cmux-tui and the Windows R2 attestation.
No timers or observers added.
Agent session: big-red
š¤ Generated with Claude Code