acpmux: forward a Claude Code chat's plan usage limits to clients

Merged
TE
10d ago
+346-18

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_update with _meta.acpmux.usageLimits, and
  • the session detail field usageLimits (next to usage and contextBreakdown), saved in the session meta the same way contextBreakdown is.

Both have this shape: { "windows": [{ "id": "session" | "weekly" | "weekly_opus" | "weekly_sonnet", "usedPercent": 0-100, "resetsAt"?: unix seconds }] }.

  • Window names. five_hour maps to session, seven_day to weekly, seven_day_opus to weekly_opus and seven_day_sonnet to weekly_sonnet. The overage windows (overage, seven_day_overage_included) are extra paid usage, not a plan limit, so they are left out.
  • Scale. utilization is 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 carry unifiedWindows. When both name the same window, unifiedWindows wins.
  • 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's contextBreakdown: a retired agent's late usageLimits update is dropped before the tap logs it (agent::process_state, checked by base's retire fence ChildAgent::tap_live and the hub's generation check under the child lock).
  • No plan. The stream does not name a plan reliably: neither system/init nor rate_limit_event carries one. So plan is 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/updated as _meta.codexAppServer.rateLimits (primary/secondary windows with usedPercent, windowDurationMins, resetsAt, plus planType). It can be mapped. But the adapter merges sparse updates at the top level regardless of limitId, so a snapshot for another quota bucket can overwrite primary. 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 unifiedWindows in 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.py rate-limits-probe sends two rate_limit_event lines, and the test checks _acpmux/info usageLimits and the two session_info_updates.
  • claude_failover.rs: the limits are there before a failover and gone after it. A replaced process's late rate_limit_event is neither applied nor logged.

Observed / Not run

  • Observed at e7d55c5:
    • cmux-ci compile-check --app passed.
    • 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.py reports 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 from contextBreakdown to agent::process_state, which covers contextBreakdown or usageLimits.
  • 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 sends rate_limit_event only 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_breakdown and a_replaced_harnesss_late_context_breakdown_is_not_kept fail.
  • Green at f4d4f569519: --lib usage_limits 4 pass; hub_integration usage_limits/claude_failover/fork_through/context_window 96 pass. In the full cargo test -p acpmux, everything passes except daemon_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 warnings and 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