diff --git a/cmux-tui/crates/acpmux/docs/acpmux-schema.json b/cmux-tui/crates/acpmux/docs/acpmux-schema.json index 4d9654904069..306f47f886fb 100644 --- a/cmux-tui/crates/acpmux/docs/acpmux-schema.json +++ b/cmux-tui/crates/acpmux/docs/acpmux-schema.json @@ -123,7 +123,7 @@ "SessionDefaults": {"model": "string?", "effort": "string?", "policy": "string?", "prefer": "string[]? (profiles to use for a family, in order; first installed wins)", "env": "{key: value}? (${cwd}, ${home}, ${model}, ~/ expand)", "note": "keyed by family or profile"}, "HarnessProfile": {"kind": "acp|claude-stdio", "argv": "string[] (${model} = the requested model at spawn)", "env": "{key: value}", "family": "string? (derived from argv when absent)", "models": "(string | {id, name})[] declared catalog, shown ahead of the reported one", "model": "string?", "effort": "string?", "policy": "string?", "fallback": "string? (profile to move to on limit or auth errors)", "description": "string?"}, "SessionSummary": {"sessionId": "string", "name": "string", "harness": "string", "family": "string|null", "cwd": "string", "status": "idle|ready|running|waiting|disconnected|closed", "peer": "string? (host name for remote sessions)", "stateSeq": "number (bumps on status, permission and turn changes)", "unread": "boolean (a turn ended while no attached client showed the chat; cleared when one shows it)", "shown": "boolean (some attached client shows the chat: _acpmux/attach visible, _acpmux/view)", "indicator": "input|working|failed|lost|done|null (the status dot every client draws; first match wins: input = a permission or question waits, working = a turn runs, failed = the last turn failed, lost = the agent process is gone (disconnected or unreachable) and the last turn did not answer, done = the last turn answered (seen is client-side), null = nothing to show)", "lastTurn": "{turnId, promptId, status, stopReason?, errorText?, errorSource?, detail?, endedAt} | null", "lane": "{state: working|input|idle|failed|lost, since: number|null, error: string|null, step: string|null, title: string|null, stalledSince: number|null} | null (a coordinator's view of the session; null when closed: working since its turn started (stalledSince: since when a working turn has had no tool call in flight and no sign from its model for stalledTurnMs, default 10 minutes, 0 off), input since it began waiting, idle since its last turn ended, failed with that turn's errorText, lost when the agent process or peer is gone; step is the running turn's prompt, else the agent's last reply line, one line of at most 240 characters; title is the session's title, else its latest prompt, cut the same way; error, step and title have secrets masked, e.g. [GitHub Personal Access Token], and error is cut to 3072 characters; the summary's other text (title, lastPrompt, preview, lastMessage.text, the turn's and queued prompts, lastTurn.errorText) is raw for every client except a proven peer daemon (peer token), which reads it masked and cut to 16384 characters and may not rewrite a queued prompt that holds a secret)", "inputSince": "number|null (when the session began waiting on the person)", "attached": "number (clients attached now)", "pendingPermissions": "number", "queued": "number", "turnCount": "number", "lastSeq": "number", "lastPrompt": "string|null", "preview": "string|null", "model": "string|null", "currentModeId": "string|null", "policy": "string|null", "rules": "boolean", "tools": "ToolRow[] (the chat's tools: cmux's own, then what the harness reported)", "toolsMutable": "boolean (_acpmux/set_tools can turn its MCP servers off: Claude Code only)", "tags": "{key: value}", "createdAt": "number", "updatedAt": "number", "queue": "[{promptId, turnId, client, prompt, queuedAt, held, images: [{id, mimeType, name: string|null}]}] prompts waiting for the running turn (images: what each sends, in order; _acpmux/attachment reads each by id before it runs)", "turn": "{startedAt, client, prompt, turnId, promptId, turnSeq, stalledSince} | null (the running turn; stalledSince as lane.stalledSince)", "enforcement": "Enforcement", "gitBranch": "string|null (the branch the session's cwd has checked out now; null on a detached HEAD or outside a repository. Live while a client holds the chat (a git record on each change); otherwise the last branch this daemon read, else the one the session started on)"}, - "SessionDetail": "SessionSummary plus modes, configOptions, models, agentInfo, agentCapabilities, usage, contextBreakdown: {categories: [{id?, name, tokens, kind: used|free|buffer|deferred}], memoryFiles: [{path, type, tokens}], mcpTools: [{name, serverName, tokens}], skills?: [{name, tokens}], totalTokens, maxTokens}|null (Claude Code's /context after the chat's last turn, also sent then as session_info_update _meta.acpmux.contextBreakdown; each list at most 50 rows, the largest; null for any other harness, after a failover, or until Claude Code first answers get_context_usage; a turn whose get_context_usage Claude Code refuses keeps the last breakdown), pending: [{permissionId, request, groupId, turnId}] (groupId and turnId are string|null), rulesJson, gitHead: string|null (the cwd's full HEAD commit when the session started or forked; null outside a repository; gitBranch is SessionSummary's, the branch checked out now), totals: TurnUsage|null (the sum of this session's own turns; null until a harness reports any; a fork starts its own)", + "SessionDetail": "SessionSummary plus modes, configOptions, models, agentInfo, agentCapabilities, usage, contextBreakdown: {categories: [{id?, name, tokens, kind: used|free|buffer|deferred}], memoryFiles: [{path, type, tokens}], mcpTools: [{name, serverName, tokens}], skills?: [{name, tokens}], totalTokens, maxTokens}|null (Claude Code's /context after the chat's last turn, also sent then as session_info_update _meta.acpmux.contextBreakdown; each list at most 50 rows, the largest; null for any other harness, after a failover, or until Claude Code first answers get_context_usage; a turn whose get_context_usage Claude Code refuses keeps the last breakdown), usageLimits: {plan?: string, windows: [{id: session|weekly|weekly_opus|weekly_sonnet, usedPercent: number 0-100, resetsAt?: unix seconds}]}|null (the account's plan usage limits, from Claude Code's rate_limit_event (five_hour is session, seven_day weekly, seven_day_opus weekly_opus, seven_day_sonnet weekly_sonnet; overage windows are left out), each window its newest value, a window past its cap 100; also sent as session_info_update _meta.acpmux.usageLimits with every window that Claude Code process reported; plan is not set yet, as Claude Code's stream names none; null for any other harness, before the first event, or after a harness failover), pending: [{permissionId, request, groupId, turnId}] (groupId and turnId are string|null), rulesJson, gitHead: string|null (the cwd's full HEAD commit when the session started or forked; null outside a repository; gitBranch is SessionSummary's, the branch checked out now), totals: TurnUsage|null (the sum of this session's own turns; null until a harness reports any; a fork starts its own)", "TurnUsage": {"costUsd": "number? (USD, the harness's estimate)", "inputTokens": "number?", "outputTokens": "number?", "cacheReadTokens": "number?", "cacheWriteTokens": "number?", "note": "Only what the harness reported: Claude Code reports all of them (its tokens cover the main loop, its cost includes subagents); other harnesses report none, so the fields are absent, never 0. A turn's costUsd is the rise in Claude Code's running total, and is absent when that cannot be told: a failed turn, a total that went down, the first turn of a fork through an earlier turn or of a session older than this field."}, "Process": {"id": "string (/: Claude's task id, Codex's processId)", "sessionId": "string", "subagentSessionId": "string|null (the subagent whose call started it)", "toolCallId": "string (Codex: the commandExecution item id)", "taskId": "string (Codex: the processId)", "harness": "string (the session's harness family: claude, codex)", "command": "string", "cwd": "string", "startedAt": "number (unix ms)", "endedAt": "number|null", "state": "running|exited|failed|killed|lost (lost: it ended with its harness, unseen)", "exitCode": "number|null (Codex reports it when the process exits on its own; Claude Code does not)", "signal": "null", "reason": "person|agent|harness_exit|null (why it stopped)", "detail": "string|null (the harness's own words on its end)", "origin": "background|auto_backgrounded|user_backgrounded (user_backgrounded: moved there by _acpmux/process/background, Ctrl-B)", "outputBytes": "number|null (its output file's length when listed; null while the harness named no file, and for Codex)", "outputFile": "string? (the file the harness writes its output to; read it with _acpmux/process/tail)"}, "process_update": {"process": "Process", "note": "Logged when a process starts or ends; a daemon rebuilds the registry from these and the harness updates."}, diff --git a/cmux-tui/crates/acpmux/src/agent.rs b/cmux-tui/crates/acpmux/src/agent.rs index 5932109853d3..01da08c1e104 100644 --- a/cmux-tui/crates/acpmux/src/agent.rs +++ b/cmux-tui/crates/acpmux/src/agent.rs @@ -195,6 +195,14 @@ struct CodexLink { order: Mutex<()>, } +/// A session update whose news is only the agent process's own to give: Claude Code's /context +/// breakdown, its account's usage limits. One a replaced process sends is not the chat's. +pub fn process_state(params: &Value) -> bool { + params + .pointer("/update/_meta/acpmux") + .is_some_and(|m| m.get("contextBreakdown").is_some() || m.get("usageLimits").is_some()) +} + impl ChildAgent { /// Ends every process in the agent's job (Windows; killpg on Unix). #[cfg(windows)] @@ -517,8 +525,8 @@ impl ChildAgent { None } - /// Mark the agent replaced (a failover) or stopping: a /context breakdown it still sends, - /// answering a turn that ended before, is dropped (`tap_live`). Waits for a breakdown being + /// Mark the agent replaced (a failover) or stopping: a /context breakdown or usage limits it + /// still sends, answering a turn that ended before, are dropped (`tap_live`). Waits for a breakdown being /// logged right now, so what the caller logs next comes after it. pub fn retire(&self) { *self.retired.lock().unwrap_or_else(|e| e.into_inner()) = true; @@ -529,14 +537,13 @@ impl ChildAgent { *self.retired.lock().unwrap_or_else(|e| e.into_inner()) } - /// Runs `log` for `m`, unless `m` is a /context breakdown update from a retired agent (None). + /// Runs `log` for `m`, unless `m` is a /context breakdown or usage limits update + /// (`process_state`) from a retired agent (None). /// For such an update the retire lock is held across the check and `log`. fn tap_live(&self, m: &Message, log: impl FnOnce() -> R) -> Option { let context = matches!(m, Message::Notification { method, params } if method == crate::rpc::method::SESSION_UPDATE - && params - .as_ref() - .is_some_and(|p| p.pointer("/update/_meta/acpmux/contextBreakdown").is_some())); + && params.as_ref().is_some_and(process_state)); if !context { return Some(log()); } diff --git a/cmux-tui/crates/acpmux/src/claude_stdio/inbound.rs b/cmux-tui/crates/acpmux/src/claude_stdio/inbound.rs index aefe0112f927..b50fef44f2c0 100644 --- a/cmux-tui/crates/acpmux/src/claude_stdio/inbound.rs +++ b/cmux-tui/crates/acpmux/src/claude_stdio/inbound.rs @@ -729,6 +729,8 @@ impl Translator { _ => {} } } + // The account's plan usage limits, newest per window (`usage_limits.rs`). + "rate_limit_event" => out.extend(self.usage_limits_update(line).await), "system" if sub.starts_with("task_") => { if let Some(update) = self.background_shell_task(sub, line, &sid).await { out.push(update); diff --git a/cmux-tui/crates/acpmux/src/claude_stdio/mod.rs b/cmux-tui/crates/acpmux/src/claude_stdio/mod.rs index e05edae992bc..c631c828b6ed 100644 --- a/cmux-tui/crates/acpmux/src/claude_stdio/mod.rs +++ b/cmux-tui/crates/acpmux/src/claude_stdio/mod.rs @@ -244,9 +244,13 @@ mod subagent_route; mod subagent_tests; #[cfg(test)] mod tests; +mod usage_limits; +#[cfg(test)] +mod usage_limits_tests; #[cfg(test)] mod workflow_tests; mod workflows; +pub use usage_limits::merge_usage_limits; /// The internal ACP request that stops one of Claude Code's background /// tasks (`hub/processes.rs`), `{sessionId, taskId}`: Claude Code's @@ -468,6 +472,9 @@ pub struct Translator { /// and those in the background (`run_in_background`, or moved there) until /// their `task_notification` or Claude's exit ends them. background_shells: Mutex>, + /// The plan usage limit windows Claude Code reported, newest each, by id + /// (`usage_limits.rs`). + usage_limits: Mutex>, } /// A running shell: the session its Bash call ran in, its command, Claude's @@ -555,6 +562,7 @@ impl Translator { streamed_subagents: Mutex::new(HashSet::new()), edits: Mutex::new(HashMap::new()), announced: Mutex::new(HashMap::new()), + usage_limits: Mutex::new(Default::default()), }) } diff --git a/cmux-tui/crates/acpmux/src/claude_stdio/usage_limits.rs b/cmux-tui/crates/acpmux/src/claude_stdio/usage_limits.rs new file mode 100644 index 000000000000..aa315be030c8 --- /dev/null +++ b/cmux-tui/crates/acpmux/src/claude_stdio/usage_limits.rs @@ -0,0 +1,105 @@ +//! The account's plan usage limits, from Claude Code's `rate_limit_event` +//! lines: how much of each limit window (the 5-hour session, the week) is +//! used and when it resets, for the pane's Context card. Sent as a +//! `session_info_update` with `_meta.acpmux.usageLimits`, and kept as the +//! session detail's `usageLimits` (`hub/views.rs`). +//! +//! Claude Code 2.1.307 writes `{"type": "rate_limit_event", "rate_limit_info": +//! {status, rateLimitType?, utilization?, resetsAt?, unifiedWindows?: {five_hour?, +//! seven_day?, seven_day_overage_included?: {utilization, resetsAt}}, ...}}` +//! when a window's rounded percentage or reset time moves. `utilization` is the +//! fraction of the window used (0-1; above 1 once usage runs past the cap) and +//! `resetsAt` unix seconds. The stream names no plan, so `plan` is never set. + +use super::*; +use std::collections::BTreeMap; + +/// Claude Code's limit windows and the ids acpmux names them by. The overage +/// windows (`overage`, `seven_day_overage_included`) are extra paid usage, not +/// a plan limit, and are left out. +const WINDOWS: &[(&str, &str)] = &[ + ("five_hour", "session"), + ("seven_day", "weekly"), + ("seven_day_opus", "weekly_opus"), + ("seven_day_sonnet", "weekly_sonnet"), +]; + +fn window_id(claude: &str) -> Option<&'static str> { + WINDOWS.iter().find(|(name, _)| *name == claude).map(|(_, id)| *id) +} + +/// One window as acpmux sends it: `usedPercent` (0-100, two decimals, a window +/// past its cap reads 100) and `resetsAt` when Claude Code gave one. None +/// without a usable utilization. +fn window(id: &str, utilization: Option<&Value>, resets_at: Option<&Value>) -> Option { + let used = utilization?.as_f64().filter(|u| u.is_finite())?; + let percent = ((used * 100.0).clamp(0.0, 100.0) * 100.0).round() / 100.0; + let mut w = json!({"id": id, "usedPercent": percent}); + if let Some(at) = resets_at.and_then(Value::as_i64).filter(|at| *at > 0) { + w["resetsAt"] = json!(at); + } + Some(w) +} + +/// The windows one `rate_limit_info` reports, by id: the limiting window it +/// names (`rateLimitType` with `utilization`), then each of `unifiedWindows`, +/// which wins for a window both name. Empty for anything malformed. +pub(super) fn usage_windows(info: &Value) -> BTreeMap<&'static str, Value> { + let mut found = BTreeMap::new(); + if let Some(id) = info.get("rateLimitType").and_then(Value::as_str).and_then(window_id) + && let Some(w) = window(id, info.get("utilization"), info.get("resetsAt")) + { + found.insert(id, w); + } + for (name, w) in info.get("unifiedWindows").and_then(Value::as_object).into_iter().flatten() { + if let Some(id) = window_id(name) + && let Some(w) = window(id, w.get("utilization"), w.get("resetsAt")) + { + found.insert(id, w); + } + } + found +} + +/// The saved `usageLimits` with a newer one's windows in it: each window the +/// newer one names replaces the saved one of its id, and the others stay (a +/// restarted Claude Code knows only what it saw since). Its `plan`, if any, +/// wins. +pub fn merge_usage_limits(saved: Option<&Value>, newer: &Value) -> Value { + let mut windows: Vec = + saved.and_then(|s| s.get("windows")).and_then(Value::as_array).cloned().unwrap_or_default(); + for w in newer.get("windows").and_then(Value::as_array).into_iter().flatten() { + let Some(id) = w.get("id").and_then(Value::as_str) else { continue }; + match windows.iter_mut().find(|old| old.get("id").and_then(Value::as_str) == Some(id)) { + Some(old) => *old = w.clone(), + None => windows.push(w.clone()), + } + } + let mut merged = json!({"windows": windows}); + if let Some(plan) = newer.get("plan").or_else(|| saved?.get("plan")).filter(|p| p.is_string()) { + merged["plan"] = plan.clone(); + } + merged +} + +impl Translator { + /// A `rate_limit_event` as a `session_info_update` carrying every window + /// this Claude Code reported so far, each its newest value. None when the + /// event names no window acpmux shows. Always the chat's own session: the + /// limits are the account's, whichever stream (a subagent's) reported them. + pub(super) async fn usage_limits_update(&self, line: &Value) -> Option { + let found = usage_windows(line.get("rate_limit_info")?); + if found.is_empty() { + return None; + } + let mut kept = self.usage_limits.lock().await; + kept.extend(found); + let windows: Vec = kept.values().cloned().collect(); + let update = json!({ + "sessionUpdate": "session_info_update", + "_meta": {"acpmux": {"usageLimits": {"windows": windows}}}, + }); + let params = json!({"sessionId": self.acp_session_id, "update": update}); + Some(Message::notification(method::SESSION_UPDATE, params)) + } +} diff --git a/cmux-tui/crates/acpmux/src/claude_stdio/usage_limits_tests.rs b/cmux-tui/crates/acpmux/src/claude_stdio/usage_limits_tests.rs new file mode 100644 index 000000000000..2d3318c85b0c --- /dev/null +++ b/cmux-tui/crates/acpmux/src/claude_stdio/usage_limits_tests.rs @@ -0,0 +1,123 @@ +//! The plan usage limits from Claude Code's `rate_limit_event` (`usage_limits.rs`). Lines as +//! Claude Code 2.1.307 writes them: `utilization` is a 0-1 fraction. + +use super::*; + +/// The `usageLimits` of each update a line made. +async fn limits(t: &Translator, line: Value) -> Vec { + t.inbound(&line) + .await + .iter() + .filter_map(|m| match m { + Message::Notification { params, .. } => { + params.as_ref()?.pointer("/update/_meta/acpmux/usageLimits").cloned() + } + _ => None, + }) + .collect() +} + +fn event(info: Value) -> Value { + json!({"type": "rate_limit_event", "rate_limit_info": info, "uuid": "u-1", "session_id": "s-1"}) +} + +#[tokio::test] +async fn a_rate_limit_event_names_each_window_in_percent() { + let t = Translator::new("acp-1".into(), "default", "haiku", "default"); + let got = limits( + &t, + event(json!({ + "status": "allowed_warning", + "rateLimitType": "seven_day_opus", + "utilization": 0.815, + "resetsAt": 1_790_000_000, + "unifiedWindows": { + "five_hour": {"utilization": 0.07, "resetsAt": 1_789_000_000}, + "seven_day": {"utilization": 0.4, "resetsAt": 1_790_500_000}, + "seven_day_overage_included": {"utilization": 0.1, "resetsAt": 1_790_500_000}, + }, + "isUsingOverage": false, + })), + ) + .await; + assert_eq!( + got, + vec![json!({"windows": [ + {"id": "session", "usedPercent": 7.0, "resetsAt": 1_789_000_000}, + {"id": "weekly", "usedPercent": 40.0, "resetsAt": 1_790_500_000}, + {"id": "weekly_opus", "usedPercent": 81.5, "resetsAt": 1_790_000_000}, + ]})] + ); +} + +#[tokio::test] +async fn a_later_event_updates_only_its_window() { + let t = Translator::new("acp-1".into(), "default", "haiku", "default"); + let both = json!({"status": "allowed", "unifiedWindows": { + "five_hour": {"utilization": 0.2, "resetsAt": 100}, + "seven_day": {"utilization": 0.3, "resetsAt": 200}, + }}); + limits(&t, event(both)).await; + // The limiting window alone, past its cap (utilization above 1 reads 100), no reset time. + let got = limits( + &t, + event(json!({"status": "rejected", "rateLimitType": "five_hour", "utilization": 1.25})), + ) + .await; + assert_eq!( + got, + vec![json!({"windows": [ + {"id": "session", "usedPercent": 100.0}, + {"id": "weekly", "usedPercent": 30.0, "resetsAt": 200}, + ]})] + ); +} + +#[tokio::test] +async fn a_malformed_or_overage_only_event_sends_nothing() { + let t = Translator::new("acp-1".into(), "default", "haiku", "default"); + for line in [ + json!({"type": "rate_limit_event"}), + event(json!("allowed")), + event(json!({"status": "allowed"})), + event(json!({"status": "allowed", "rateLimitType": "five_hour"})), + event(json!({"status": "allowed", "rateLimitType": "five_hour", "utilization": "half"})), + event(json!({"status": "allowed", "rateLimitType": "overage", "utilization": 0.5})), + event(json!({"status": "allowed", "unifiedWindows": [1, 2]})), + event(json!({"status": "allowed", "unifiedWindows": {"seven_day": "full"}})), + event(json!({"status": "allowed", "unifiedWindows": { + "seven_day_overage_included": {"utilization": 0.5, "resetsAt": 1}, + }})), + ] { + assert!(t.inbound(&line).await.is_empty(), "{line}"); + } + // A later good event is not mixed with anything from those. + let got = limits( + &t, + event(json!({"status": "allowed", "rateLimitType": "seven_day_sonnet", + "utilization": 0, "resetsAt": "soon"})), + ) + .await; + assert_eq!(got, vec![json!({"windows": [{"id": "weekly_sonnet", "usedPercent": 0.0}]})]); +} + +#[test] +fn saved_limits_keep_the_windows_a_newer_one_does_not_name() { + let saved = json!({"plan": "max", "windows": [ + {"id": "session", "usedPercent": 10.0, "resetsAt": 1}, + {"id": "weekly", "usedPercent": 20.0, "resetsAt": 2}, + ]}); + let newer = json!({"windows": [ + {"id": "weekly", "usedPercent": 25.0, "resetsAt": 2}, + {"id": "weekly_opus", "usedPercent": 5.0}, + ]}); + assert_eq!( + merge_usage_limits(Some(&saved), &newer), + json!({"plan": "max", "windows": [ + {"id": "session", "usedPercent": 10.0, "resetsAt": 1}, + {"id": "weekly", "usedPercent": 25.0, "resetsAt": 2}, + {"id": "weekly_opus", "usedPercent": 5.0}, + ]}) + ); + assert_eq!(merge_usage_limits(None, &newer), newer); +} diff --git a/cmux-tui/crates/acpmux/src/hub/fork.rs b/cmux-tui/crates/acpmux/src/hub/fork.rs index f9a0844d7fb0..1b6d17e2d2b4 100644 --- a/cmux-tui/crates/acpmux/src/hub/fork.rs +++ b/cmux-tui/crates/acpmux/src/hub/fork.rs @@ -266,6 +266,8 @@ impl Hub { turn_count, usage: None, context_breakdown: None, + // The account's limits, which the fork shares. + usage_limits: parent_meta.usage_limits.clone(), available_commands: parent_meta.available_commands.clone(), goal: None, permission_rules: None, diff --git a/cmux-tui/crates/acpmux/src/hub/permissions.rs b/cmux-tui/crates/acpmux/src/hub/permissions.rs index 59fa6ca7ae8b..86e65146947c 100644 --- a/cmux-tui/crates/acpmux/src/hub/permissions.rs +++ b/cmux-tui/crates/acpmux/src/hub/permissions.rs @@ -15,16 +15,12 @@ impl Hub { match item { Inbound::Notification { method: m, params, agent } => { if m == method::SESSION_UPDATE { - // A /context breakdown from an agent that was replaced (a failover's - // dying primary answering late) is not the chat's context now. The + // A /context breakdown or usage limits from an agent that was replaced + // (a failover's dying primary answering late) are not the chat's now. The // child lock is held while it is applied: a failover takes it to retire // the old agent, and again to detach it, before it clears the // breakdown, so the check and the write cannot straddle the failover. - let breakdown = params - .as_ref() - .and_then(|p| p.pointer("/update/_meta/acpmux/contextBreakdown")) - .is_some(); - if breakdown { + if params.as_ref().is_some_and(crate::agent::process_state) { let child = session.child.lock().await; if child .as_ref() @@ -186,6 +182,13 @@ impl Hub { m.context_breakdown = Some(breakdown.clone()); breakdown_changed = true; } + // The account's plan usage limits, merged by window (`claude_stdio`). + if let Some(limits) = update.pointer("/_meta/acpmux/usageLimits") { + let merged = + crate::claude_stdio::merge_usage_limits(m.usage_limits.as_ref(), limits); + breakdown_changed |= m.usage_limits.as_ref() != Some(&merged); + m.usage_limits = Some(merged); + } // Claude Code's system/init: its MCP servers, plugins and skills. reported_tools = update .pointer("/_meta/claude") diff --git a/cmux-tui/crates/acpmux/src/hub/resolve.rs b/cmux-tui/crates/acpmux/src/hub/resolve.rs index b9d6dac06ab2..23c5c808d80d 100644 --- a/cmux-tui/crates/acpmux/src/hub/resolve.rs +++ b/cmux-tui/crates/acpmux/src/hub/resolve.rs @@ -326,6 +326,7 @@ pub(super) fn draft_meta(d: Draft<'_>) -> SessionMeta { turn_count: 0, usage: None, context_breakdown: None, + usage_limits: None, available_commands: None, goal: None, permission_rules: None, diff --git a/cmux-tui/crates/acpmux/src/hub/turns.rs b/cmux-tui/crates/acpmux/src/hub/turns.rs index 9b9dc36951c9..b05ac5be577f 100644 --- a/cmux-tui/crates/acpmux/src/hub/turns.rs +++ b/cmux-tui/crates/acpmux/src/hub/turns.rs @@ -503,8 +503,10 @@ impl Hub { { let mut m = session.meta.lock().unwrap(); m.harness = to.clone(); - // The last harness's /context breakdown is not the fallback's. + // The last harness's /context breakdown is not the fallback's, nor its + // account's usage limits. m.context_breakdown = None; + m.usage_limits = None; } self.save_meta(session); let control = session.turn().map_or(control, |t| t.control); diff --git a/cmux-tui/crates/acpmux/src/hub/views.rs b/cmux-tui/crates/acpmux/src/hub/views.rs index 077a097f79fc..c7c11ea21203 100644 --- a/cmux-tui/crates/acpmux/src/hub/views.rs +++ b/cmux-tui/crates/acpmux/src/hub/views.rs @@ -418,6 +418,8 @@ impl Hub { v["goal"] = m.goal.clone().unwrap_or(Value::Null); // What the context holds by category, as Claude Code's /context says after each turn. v["contextBreakdown"] = m.context_breakdown.clone().unwrap_or(Value::Null); + // The account's plan usage limits, as Claude Code's rate_limit_event last said them. + v["usageLimits"] = m.usage_limits.clone().unwrap_or(Value::Null); // The newest command list: a client that has it needs no read of the log for it. if let Some(commands) = &m.available_commands { v["availableCommands"] = commands.clone(); diff --git a/cmux-tui/crates/acpmux/src/store.rs b/cmux-tui/crates/acpmux/src/store.rs index c22ab7c7ed65..865b197c1b08 100644 --- a/cmux-tui/crates/acpmux/src/store.rs +++ b/cmux-tui/crates/acpmux/src/store.rs @@ -191,6 +191,11 @@ pub struct SessionMeta { /// sent after each turn; none from any other harness. #[serde(default, skip_serializing_if = "Option::is_none")] pub context_breakdown: Option, + /// The account's plan usage limits (`claude_stdio::usage_limits`), newest + /// per window, from Claude Code's `rate_limit_event`; none from any other + /// harness. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub usage_limits: Option, /// The agent's newest `available_commands_update` (its slash commands), so /// opening a chat names them without reading its log back to the start, /// where an agent lists them once (perf100). diff --git a/cmux-tui/crates/acpmux/tests/fake_claude.py b/cmux-tui/crates/acpmux/tests/fake_claude.py index c0eadff37157..d02c1f40a069 100644 --- a/cmux-tui/crates/acpmux/tests/fake_claude.py +++ b/cmux-tui/crates/acpmux/tests/fake_claude.py @@ -58,7 +58,7 @@ (=error refuses it). FAKE_CLAUDE_LATE_CONTEXT= makes a FAKE_CLAUDE_DIE_IF prompt a usage limit instead (the process lives on, and answers no more get_context_usage), and leaves a process outside its own (forked twice, so a kill of its group and descendants misses it) that 1 s -later writes a /context answer, then a `system/late_probe` line, then creates : a replaced +later writes a /context answer and a rate_limit_event, then a `system/late_probe` line, then creates : a replaced Claude Code's answer that arrives after the failover. FAKE_CLAUDE_MCP_DOWN= fails mcp_reconnect of that server, and every other reconnect succeeds. "env: NAME" replies with that environment variable. Any plain prompt @@ -68,6 +68,9 @@ per cost-probe turn, from 0 at each start). "cost-restore X" first restores the running total X, as a resumed Claude Code does; "cost-zero" reports a zeroed total, as a crashed one's last result can. +"rate-limits-probe" sends two rate_limit_event lines as Claude Code 2.1.307 does +(`utilization` a 0-1 fraction): the session and weekly windows, then the +session window alone, moved. FAKE_CLAUDE_LIMITS=1 sends them on every prompt. """ import json import os @@ -527,6 +530,8 @@ def done(): time.sleep(1.0) send({"type": "control_response", "response": {"subtype": "success", "request_id": "ctx-late", "response": CONTEXT_USAGE}}) + send({"type": "rate_limit_event", "uuid": "rl-late", "session_id": SESSION, "rate_limit_info": { + "status": "rejected", "rateLimitType": "five_hour", "utilization": 1.0, "resetsAt": 1}}) send({"type": "system", "subtype": "late_probe"}) open(late, "w").close() os._exit(0) @@ -724,6 +729,15 @@ def done(): if said.strip() == "compact-probe": send({"type": "system", "subtype": "compact_boundary", "compact_metadata": {"trigger": "manual", "pre_tokens": 4500}}) + # "rate-limits-probe": the account's plan usage limits, then a later event for one window. + if said.strip() == "rate-limits-probe" or os.environ.get("FAKE_CLAUDE_LIMITS") == "1": + send({"type": "rate_limit_event", "uuid": "rl-1", "session_id": SESSION, "rate_limit_info": { + "status": "allowed", "isUsingOverage": False, "unifiedWindows": { + "five_hour": {"utilization": 0.12, "resetsAt": 1790000000}, + "seven_day": {"utilization": 0.4, "resetsAt": 1790500000}}}}) + send({"type": "rate_limit_event", "uuid": "rl-2", "session_id": SESSION, "rate_limit_info": { + "status": "allowed_warning", "rateLimitType": "five_hour", "utilization": 0.9, + "resetsAt": 1790000000}}) # "bg-probe": the turn starts two shells in the background (Bash run_in_background); Claude # Code reports each as a local_bash task, and one is killed while the other finishes. if said.strip() == "bg-probe": diff --git a/cmux-tui/crates/acpmux/tests/hub_integration/claude_failover.rs b/cmux-tui/crates/acpmux/tests/hub_integration/claude_failover.rs index 080b6683aa41..ed70b525863d 100644 --- a/cmux-tui/crates/acpmux/tests/hub_integration/claude_failover.rs +++ b/cmux-tui/crates/acpmux/tests/hub_integration/claude_failover.rs @@ -289,6 +289,7 @@ async fn context_failover_setup(root: &std::path::Path, late: bool) -> (Arc let mut env = BTreeMap::from([("FAKE_CLAUDE_STORE".to_owned(), path("claude"))]); if primary { env.insert("FAKE_CLAUDE_CONTEXT".into(), "1".into()); + env.insert("FAKE_CLAUDE_LIMITS".into(), "1".into()); env.insert("FAKE_CLAUDE_DIE_IF".into(), path("die")); if late { env.insert("FAKE_CLAUDE_LATE_CONTEXT".into(), path("late")); @@ -335,6 +336,8 @@ async fn chat_with_a_breakdown(c: &mut TestClient) -> String { c.request(method::SESSION_PROMPT, prompt).await.unwrap(); let before = info_breakdown(c, &id, |b| !b.is_null()).await; assert!(!before.is_null(), "the primary's turn leaves its /context breakdown"); + let info = c.request(method::MUX_INFO, json!({"sessionId": id})).await.unwrap(); + assert!(!info["usageLimits"].is_null(), "the primary's turn leaves its usage limits: {info}"); id } @@ -354,6 +357,9 @@ async fn a_failover_drops_the_last_harnesss_context_breakdown() { let after = info_breakdown(&mut c, &id, Value::is_null).await; assert!(after.is_null(), "the primary's breakdown outlived the failover: {after}"); assert!(session.meta().context_breakdown.is_none(), "the saved meta keeps the old breakdown"); + // Nor do the primary account's usage limits stay. + let info = c.request(method::MUX_INFO, json!({"sessionId": id})).await.unwrap(); + assert!(info["usageLimits"].is_null(), "the primary's limits outlived the failover: {info}"); hub.shutdown_all().await; let _ = std::fs::remove_dir_all(&root); } @@ -384,6 +390,7 @@ async fn a_replaced_harnesss_late_context_breakdown_is_not_kept() { assert!(probed, "the replaced process's late lines never reached its reader"); let info = c.request(method::MUX_INFO, json!({"sessionId": id})).await.unwrap(); assert!(info["contextBreakdown"].is_null(), "the late answer came back: {info}"); + assert!(info["usageLimits"].is_null(), "the late limits came back: {info}"); assert!(session.meta().context_breakdown.is_none()); // Nor is it in the log after the failover, where a pane's replay would take it up. let log = events(&hub); @@ -391,9 +398,14 @@ async fn a_replaced_harnesss_late_context_breakdown_is_not_kept() { let late: Vec<_> = log .iter() .filter(|e| e.seq > failover) - .filter(|e| e.msg.pointer("/params/update/_meta/acpmux/contextBreakdown").is_some()) + .filter(|e| { + let meta = e.msg.pointer("/params/update/_meta/acpmux"); + meta.is_some_and(|m| { + m.get("contextBreakdown").is_some() || m.get("usageLimits").is_some() + }) + }) .collect(); - assert!(late.is_empty(), "a breakdown was logged after the failover: {late:?}"); + assert!(late.is_empty(), "a breakdown or limits were logged after the failover: {late:?}"); hub.shutdown_all().await; let _ = std::fs::remove_dir_all(&root); } diff --git a/cmux-tui/crates/acpmux/tests/hub_integration/fork_through.rs b/cmux-tui/crates/acpmux/tests/hub_integration/fork_through.rs index ef9296fb984e..3488dc79a95b 100644 --- a/cmux-tui/crates/acpmux/tests/hub_integration/fork_through.rs +++ b/cmux-tui/crates/acpmux/tests/hub_integration/fork_through.rs @@ -14,6 +14,9 @@ mod btw; // The context window a Claude chat reports, which shares these helpers too. #[path = "context_window.rs"] mod context_window; +// The account's plan usage limits a Claude chat reports, which share these helpers too. +#[path = "usage_limits.rs"] +mod usage_limits; // Claude's todo list as an ACP plan, which shares these helpers too. #[path = "todo_plan.rs"] mod todo_plan; diff --git a/cmux-tui/crates/acpmux/tests/hub_integration/usage_limits.rs b/cmux-tui/crates/acpmux/tests/hub_integration/usage_limits.rs new file mode 100644 index 000000000000..68ff5efb1d51 --- /dev/null +++ b/cmux-tui/crates/acpmux/tests/hub_integration/usage_limits.rs @@ -0,0 +1,39 @@ +//! A Claude chat's plan usage limits (`claude_stdio/usage_limits.rs`): Claude Code's +//! `rate_limit_event` lines become the session detail's `usageLimits` and a +//! `session_info_update` with `_meta.acpmux.usageLimits`, for the pane's Context card. + +use super::*; + +#[tokio::test] +async fn a_claude_chat_keeps_its_accounts_usage_limits() { + let root = std::env::temp_dir().join(format!("acpmux-limits-{}", uuid::Uuid::now_v7())); + let claude = root.join("claude"); + std::fs::create_dir_all(&claude).unwrap(); + let (hub, mut c) = claude_setup(&claude, &[]).await; + let s = c.request(method::SESSION_NEW, json!({"cwd": cwd(), "mcpServers": []})).await.unwrap(); + let id = s["sessionId"].as_str().unwrap().to_owned(); + let info = c.request(method::MUX_INFO, json!({"sessionId": id})).await.unwrap(); + assert!(info["usageLimits"].is_null(), "{info}"); + let prompt = + json!({"sessionId": id, "prompt": [{"type": "text", "text": "rate-limits-probe"}]}); + c.request(method::SESSION_PROMPT, prompt).await.unwrap(); + + // The later event moved the session window only; the weekly one stays. + let want = json!({"windows": [ + {"id": "session", "usedPercent": 90.0, "resetsAt": 1_790_000_000}, + {"id": "weekly", "usedPercent": 40.0, "resetsAt": 1_790_500_000}, + ]}); + let info = c.request(method::MUX_INFO, json!({"sessionId": id})).await.unwrap(); + assert_eq!(info["usageLimits"], want, "{info}"); + let sent: Vec = hub + .events(&id, 0, 10_000) + .unwrap() + .iter() + .filter(|e| e.kind == "session_info_update") + .filter_map(|e| e.msg.pointer("/params/update/_meta/acpmux/usageLimits").cloned()) + .collect(); + assert_eq!(sent.len(), 2, "{sent:?}"); + assert_eq!(sent.last(), Some(&want), "{sent:?}"); + hub.shutdown_all().await; + let _ = std::fs::remove_dir_all(&root); +}