diff --git a/CHANGELOG.md b/CHANGELOG.md index 956d93d9..35504335 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- The usage footer refreshes as soon as a Claude or Codex turn ends, and the poll timer now ticks every minute so a snapshot lands as soon as it is eligible instead of up to fifteen minutes late. Both providers keep a five-minute floor between reads. + +### Fixed + +- A provider that was not signed in when the app launched no longer stays "not connected" forever; it is retried every 15 minutes, so signing in later recovers on its own. +- Clicking refresh while a background poll was running did nothing. The click is now queued and runs right after. +- An idle terminal no longer forks a `ps` every second to name its foreground process. +- A 429 from Claude's usage endpoint now backs off for 30 minutes instead of retrying on the normal cadence, with the reason in the chip's tooltip. + +### Removed + +- The usage footer no longer refreshes Claude Code's OAuth token. Those credentials belong to the `claude` CLI, which MonoCode already spawns for every turn and which refreshes them itself; writing to its keychain entry meant a rotation MonoCode could not save left the CLI holding a dead refresh token. The footer now only reads: an expired token shows `expired` until the next Claude turn renews it. + ## [0.1.16] - 2026-08-28 ### Added diff --git a/src-tauri/src/pty.rs b/src-tauri/src/pty.rs index 9a421476..a3e2a74f 100644 --- a/src-tauri/src/pty.rs +++ b/src-tauri/src/pty.rs @@ -513,11 +513,13 @@ fn foreground_label(master_fd: i32, shell_pid: u32) -> Option { return None; } let pid = pgrp; - if pid <= 0 { + // An idle terminal is the common case and it is polled once a second per + // pane, so bail before process_label forks a `ps`. + if pid <= 0 || pid == shell_pid as i32 { return None; } let label = process_label(pid)?; - if pid == shell_pid as i32 || is_shell_name(&label) { + if is_shell_name(&label) { return None; } Some(label) diff --git a/src-tauri/src/rate_limits.rs b/src-tauri/src/rate_limits.rs index 0c74a50e..afc18429 100644 --- a/src-tauri/src/rate_limits.rs +++ b/src-tauri/src/rate_limits.rs @@ -1,18 +1,15 @@ -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use serde::Serialize; -use serde_json::{json, Value}; +use serde_json::Value; use crate::dirs_home; const OAUTH_USAGE_URL: &str = "https://api.anthropic.com/api/oauth/usage"; -const OAUTH_TOKEN_URL: &str = "https://platform.claude.com/v1/oauth/token"; -const OAUTH_CLIENT_ID: &str = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"; const OAUTH_BETA: &str = "oauth-2025-04-20"; const USER_AGENT: &str = "claude-code/2.1.0"; const HTTP_TIMEOUT: Duration = Duration::from_secs(10); -const TOKEN_REFRESH_BUFFER_MS: i64 = 5 * 60 * 1000; #[cfg(target_os = "macos")] const KEYCHAIN_TIMEOUT: Duration = Duration::from_secs(5); @@ -30,22 +27,9 @@ pub struct ClaudeUsageFetch { pub error: Option, } -enum ClaudeCredStore { - #[cfg(target_os = "macos")] - Keychain { - account: String, - }, - File { - path: PathBuf, - }, -} - struct ClaudeCredentials { access_token: String, - refresh_token: String, expires_at_ms: Option, - blob: Value, - store: ClaudeCredStore, } fn usage_result( @@ -72,7 +56,7 @@ pub async fn fetch_claude_usage() -> Result { } fn fetch_claude_usage_sync() -> Result { - let Some(mut creds) = read_claude_credentials() else { + let Some(creds) = read_claude_credentials() else { return Ok(usage_result( "unavailable", None, @@ -81,17 +65,14 @@ fn fetch_claude_usage_sync() -> Result { )); }; - if token_needs_refresh(creds.expires_at_ms, now_ms()) { - refresh_claude_credentials(&mut creds); + // These credentials belong to the Claude Code CLI, which refreshes them on + // its own turns. Rotating them from here meant that a keychain write we + // could not complete left the CLI holding a dead refresh token, so this is + // a read-only view: report the expiry and let the next CLI turn fix it. + if token_expired(creds.expires_at_ms, now_ms()) { + return Ok(usage_error(401)); } - let first = fetch_usage_with_token(&creds.access_token); - if first.http_status != Some(401) { - return Ok(first); - } - if !refresh_claude_credentials(&mut creds) { - return Ok(first); - } Ok(fetch_usage_with_token(&creds.access_token)) } @@ -132,74 +113,14 @@ fn usage_error(status: u16) -> ClaudeUsageFetch { "Claude sign-in expired".into() } else if status == 403 { "Claude usage is unavailable for this account".into() + } else if status == 429 { + "Claude usage lookup rate limited".into() } else { format!("Claude usage request failed ({status})") }; usage_result("error", Some(status), None, Some(message)) } -fn refresh_claude_credentials(creds: &mut ClaudeCredentials) -> bool { - if creds.refresh_token.is_empty() { - return false; - } - let agent = ureq::AgentBuilder::new().timeout(HTTP_TIMEOUT).build(); - let body = json!({ - "grant_type": "refresh_token", - "refresh_token": creds.refresh_token, - "client_id": OAUTH_CLIENT_ID, - }); - let Ok(request_body) = serde_json::to_string(&body) else { - return false; - }; - let result = agent - .post(OAUTH_TOKEN_URL) - .set("Content-Type", "application/json") - .set("User-Agent", USER_AGENT) - .send_string(&request_body); - let response = match result { - Ok(response) if (200..300).contains(&response.status()) => response, - _ => return false, - }; - let Ok(text) = response.into_string() else { - return false; - }; - let Ok(payload) = serde_json::from_str::(&text) else { - return false; - }; - let Some(access) = apply_refresh_response(&mut creds.blob, &payload, now_ms()) else { - return false; - }; - creds.access_token = access; - if let Some(refresh) = string_field(&payload, "refresh_token") { - creds.refresh_token = refresh; - } - creds.expires_at_ms = oauth_expires_at_ms(&creds.blob); - persist_claude_credentials(creds) -} - -fn persist_claude_credentials(creds: &ClaudeCredentials) -> bool { - let Ok(raw) = serde_json::to_string(&creds.blob) else { - return false; - }; - match &creds.store { - #[cfg(target_os = "macos")] - ClaudeCredStore::Keychain { account } => write_macos_keychain_blob(account, &raw), - ClaudeCredStore::File { path } => write_credentials_file(path, &raw), - } -} - -fn write_credentials_file(path: &Path, raw: &str) -> bool { - if std::fs::write(path, raw).is_err() { - return false; - } - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)); - } - true -} - fn read_claude_credentials() -> Option { #[cfg(target_os = "macos")] { @@ -213,7 +134,7 @@ fn read_claude_credentials() -> Option { fn read_credentials_file() -> Option { let path = claude_credentials_path()?; let raw = std::fs::read_to_string(&path).ok()?; - credentials_from_blob(&raw, ClaudeCredStore::File { path }) + credentials_from_blob(&raw) } fn claude_credentials_path() -> Option { @@ -223,15 +144,12 @@ fn claude_credentials_path() -> Option { Some(PathBuf::from(home).join(".claude/.credentials.json")) } -fn credentials_from_blob(raw: &str, store: ClaudeCredStore) -> Option { +fn credentials_from_blob(raw: &str) -> Option { let blob: Value = serde_json::from_str(raw.trim()).ok()?; let access_token = extract_access_token(raw)?; Some(ClaudeCredentials { access_token, - refresh_token: extract_refresh_token(&blob).unwrap_or_default(), expires_at_ms: oauth_expires_at_ms(&blob), - blob, - store, }) } @@ -250,20 +168,6 @@ pub(crate) fn extract_access_token(raw: &str) -> Option { } } -fn extract_refresh_token(blob: &Value) -> Option { - let token = blob - .get("claudeAiOauth") - .and_then(|oauth| oauth.get("refreshToken")) - .or_else(|| blob.get("refreshToken")) - .and_then(Value::as_str)? - .trim(); - if token.is_empty() { - None - } else { - Some(token.to_string()) - } -} - fn oauth_expires_at_ms(blob: &Value) -> Option { let value = blob .get("claudeAiOauth") @@ -284,65 +188,10 @@ fn oauth_expires_at_ms(blob: &Value) -> Option { } } -fn oauth_object_mut(blob: &mut Value) -> Option<&mut Value> { - if blob.get("claudeAiOauth").is_some() { - blob.get_mut("claudeAiOauth") - } else { - Some(blob) - } -} - -pub(crate) fn apply_refresh_response( - blob: &mut Value, - response: &Value, - now_ms: i64, -) -> Option { - let access = string_field(response, "access_token")?; - let oauth = oauth_object_mut(blob)?; - oauth["accessToken"] = json!(access); - if let Some(refresh) = string_field(response, "refresh_token") { - oauth["refreshToken"] = json!(refresh); - } - if let Some(expires_in) = int_field(response, "expires_in") { - oauth["expiresAt"] = json!(now_ms.saturating_add(expires_in.saturating_mul(1000))); - } - if let Some(refresh_expires_in) = int_field(response, "refresh_token_expires_in") { - oauth["refreshTokenExpiresAt"] = - json!(now_ms.saturating_add(refresh_expires_in.saturating_mul(1000))); - } - Some(access) -} - -pub(crate) fn token_needs_refresh(expires_at_ms: Option, now_ms: i64) -> bool { - let Some(expires) = expires_at_ms else { - return false; - }; - now_ms.saturating_add(TOKEN_REFRESH_BUFFER_MS) >= expires -} - -fn string_field(value: &Value, key: &str) -> Option { - let text = value.get(key)?.as_str()?.trim(); - if text.is_empty() { - None - } else { - Some(text.to_string()) - } -} - -fn int_field(value: &Value, key: &str) -> Option { - match value.get(key)? { - Value::Number(number) => number.as_i64().or_else(|| { - number.as_f64().and_then(|float| { - if float.is_finite() { - Some(float as i64) - } else { - None - } - }) - }), - Value::String(text) => text.trim().parse().ok(), - _ => None, - } +/// An unknown expiry is treated as usable: the request itself will 401 if it is +/// not, and that is the same answer. +pub(crate) fn token_expired(expires_at_ms: Option, now_ms: i64) -> bool { + expires_at_ms.is_some_and(|expires| now_ms >= expires) } fn now_ms() -> i64 { @@ -356,27 +205,25 @@ fn now_ms() -> i64 { fn read_macos_keychain_credentials() -> Option { let user = keychain_user(); let candidates = [ - (user.clone(), { + { let mut args = keychain_find_args(); args.push("-w".into()); args - }), - (user.clone(), { + }, + { let mut args = keychain_find_args(); - args.extend(["-a".into(), user.clone(), "-w".into()]); + args.extend(["-a".into(), user, "-w".into()]); args - }), - (KEYCHAIN_FALLBACK_USER.into(), { + }, + { let mut args = keychain_find_args(); args.extend(["-a".into(), KEYCHAIN_FALLBACK_USER.into(), "-w".into()]); args - }), + }, ]; - for (account, args) in candidates { + for args in candidates { if let Some(secret) = security_output(&args) { - if let Some(creds) = - credentials_from_blob(&secret, ClaudeCredStore::Keychain { account }) - { + if let Some(creds) = credentials_from_blob(&secret) { return Some(creds); } } @@ -384,21 +231,6 @@ fn read_macos_keychain_credentials() -> Option { None } -#[cfg(target_os = "macos")] -fn write_macos_keychain_blob(account: &str, raw: &str) -> bool { - let args = vec![ - "add-generic-password".into(), - "-U".into(), - "-s".into(), - LEGACY_KEYCHAIN_SERVICE.into(), - "-a".into(), - account.into(), - "-w".into(), - raw.into(), - ]; - security_ok(&args) -} - #[cfg(target_os = "macos")] fn keychain_find_args() -> Vec { vec![ @@ -426,31 +258,17 @@ fn keychain_user() -> String { #[cfg(target_os = "macos")] fn security_output(args: &[String]) -> Option { - security_run(args, true) -} - -#[cfg(target_os = "macos")] -fn security_ok(args: &[String]) -> bool { - security_run(args, false).is_some() -} - -#[cfg(target_os = "macos")] -fn security_run(args: &[String], require_stdout: bool) -> Option { use std::process::{Command, Stdio}; let mut cmd = Command::new("security"); cmd.args(args) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::null()); - run_with_timeout(&mut cmd, KEYCHAIN_TIMEOUT, require_stdout) + run_with_timeout(&mut cmd, KEYCHAIN_TIMEOUT) } #[cfg(target_os = "macos")] -fn run_with_timeout( - cmd: &mut std::process::Command, - timeout: Duration, - require_stdout: bool, -) -> Option { +fn run_with_timeout(cmd: &mut std::process::Command, timeout: Duration) -> Option { use std::io::Read; use std::time::Instant; let mut child = cmd.spawn().ok()?; @@ -465,7 +283,7 @@ fn run_with_timeout( let mut out = String::new(); stdout.read_to_string(&mut out).ok()?; let trimmed = out.trim(); - if require_stdout && trimmed.is_empty() { + if trimmed.is_empty() { return None; } return Some(trimmed.to_string()); @@ -509,46 +327,23 @@ mod tests { } #[test] - fn token_needs_refresh_uses_five_minute_buffer() { + fn token_expired_only_once_the_expiry_has_passed() { let now = 1_000_000; - assert!(!token_needs_refresh( - Some(now + TOKEN_REFRESH_BUFFER_MS + 1), - now - )); - assert!(token_needs_refresh( - Some(now + TOKEN_REFRESH_BUFFER_MS), - now - )); - assert!(token_needs_refresh(Some(now - 1), now)); - assert!(!token_needs_refresh(None, now)); + assert!(!token_expired(Some(now + 1), now)); + assert!(token_expired(Some(now), now)); + assert!(token_expired(Some(now - 1), now)); } #[test] - fn apply_refresh_response_updates_oauth_blob() { - let mut blob = json!({ - "claudeAiOauth": { - "accessToken": "old-access", - "refreshToken": "old-refresh", - "expiresAt": 1, - "subscriptionType": "pro" - } - }); - let response = json!({ - "access_token": "new-access", - "refresh_token": "new-refresh", - "expires_in": 28800, - "refresh_token_expires_in": 2592000 - }); - let now = 1_700_000_000_000i64; - assert_eq!( - apply_refresh_response(&mut blob, &response, now).as_deref(), - Some("new-access") - ); - let oauth = blob.get("claudeAiOauth").unwrap(); - assert_eq!(oauth["accessToken"], "new-access"); - assert_eq!(oauth["refreshToken"], "new-refresh"); - assert_eq!(oauth["expiresAt"], now + 28_800_000); - assert_eq!(oauth["refreshTokenExpiresAt"], now + 2_592_000_000i64); - assert_eq!(oauth["subscriptionType"], "pro"); + fn token_without_an_expiry_is_left_to_the_request() { + assert!(!token_expired(None, 1_000_000)); + } + + #[test] + fn credentials_carry_the_expiry_alongside_the_token() { + let raw = r#"{"claudeAiOauth":{"accessToken":"t","expiresAt":1700000000000}}"#; + let creds = credentials_from_blob(raw).expect("credentials"); + assert_eq!(creds.access_token, "t"); + assert_eq!(creds.expires_at_ms, Some(1_700_000_000_000)); } } diff --git a/src/App.tsx b/src/App.tsx index 3ce24c7a..f55d16c0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -152,6 +152,7 @@ import { } from "./lib/checkpoint"; import { notifyDirsChanged } from "./lib/fileTree"; import { nudgeWatchedFiles } from "./lib/fileWatch"; +import { notifyUsageStale, usageProviderFor } from "./lib/rateLimits"; import { type EditorNavigationTarget, type OpenFileFn } from "./lib/search"; import { mergeModelSettings, @@ -3072,6 +3073,7 @@ export default function App({ ); notifyReviewChanged(sessionId); notifyGitChanged(); + notifyUsageStale(usageProviderFor(current.harness)); nudgeWorkspace(workCwd); nudgeWatchedFiles(); window.setTimeout(() => nudgeWatchedFiles(), 150); @@ -3134,6 +3136,7 @@ export default function App({ .then(() => notifyReviewChanged(sessionId)); nudgeWorkspace(sessionWorkCwd(session)); notifyGitChanged(); + notifyUsageStale(usageProviderFor(session.harness)); nudgeWatchedFiles(); window.setTimeout(() => nudgeWatchedFiles(), 150); } else { diff --git a/src/chrome/UsageFooter.tsx b/src/chrome/UsageFooter.tsx index 1a6ca9ef..67c90a0c 100644 --- a/src/chrome/UsageFooter.tsx +++ b/src/chrome/UsageFooter.tsx @@ -14,12 +14,21 @@ import { RATE_LIMIT_POLL_MS, rateLimitWindowTooltip, shouldFetchProvider, + subscribeUsageStale, + TURN_MIN_REFETCH_MS, type ProviderRateLimits, + type RateLimitProvider, type RateLimitWindow, } from "../lib/rateLimits"; const CLOCK_MS = 30_000; +type RefreshScope = { + /** Limit the fetch to one provider; omitted means both. */ + provider?: RateLimitProvider; + minAgeMs?: number; +}; + export function UsageFooter() { const [claude, setClaude] = useState(() => idleRateLimits("claude"), @@ -30,17 +39,32 @@ export function UsageFooter() { const [now, setNow] = useState(() => Date.now()); const [refreshing, setRefreshing] = useState(false); const inflight = useRef | null>(null); + const pendingForce = useRef(false); const claudeRef = useRef(claude); const codexRef = useRef(codex); claudeRef.current = claude; codexRef.current = codex; - const refresh = useCallback((force = false) => { - if (inflight.current) return inflight.current; + // The return annotation is required: the queued-force retry below refers to + // start from inside its own body. + const start = useCallback(function start( + force: boolean, + scope?: RefreshScope, + ): Promise | undefined { const visible = document.visibilityState === "visible"; - const fetchClaude = shouldFetchProvider(claudeRef.current, { force, visible }); - const fetchCodex = shouldFetchProvider(codexRef.current, { force, visible }); - if (!fetchClaude && !fetchCodex) return; + const wants = (limits: ProviderRateLimits) => + (!scope?.provider || scope.provider === limits.provider) && + shouldFetchProvider(limits, { + force, + visible, + minAgeMs: scope?.minAgeMs, + }); + const fetchClaude = wants(claudeRef.current); + const fetchCodex = wants(codexRef.current); + if (!fetchClaude && !fetchCodex) { + setRefreshing(false); + return; + } if (force) setRefreshing(true); const jobs: Promise[] = []; if (fetchClaude) { @@ -63,12 +87,31 @@ export function UsageFooter() { .then(() => undefined) .finally(() => { inflight.current = null; - setRefreshing(false); + if (!pendingForce.current) { + setRefreshing(false); + return; + } + pendingForce.current = false; + void start(true); }); inflight.current = run; return run; }, []); + const refresh = useCallback( + (force = false, scope?: RefreshScope) => { + if (!inflight.current) return start(force, scope); + // A click landing mid-poll must not be swallowed: the Codex probe owns a + // single child process, so queue the forced run instead of racing it. + if (force) { + pendingForce.current = true; + setRefreshing(true); + } + return inflight.current; + }, + [start], + ); + useEffect(() => { void refresh(); const poll = window.setInterval(() => void refresh(), RATE_LIMIT_POLL_MS); @@ -76,9 +119,13 @@ export function UsageFooter() { if (document.visibilityState === "visible") void refresh(); }; document.addEventListener("visibilitychange", onVisible); + const stopTurns = subscribeUsageStale((provider) => { + void refresh(false, { provider, minAgeMs: TURN_MIN_REFETCH_MS }); + }); return () => { window.clearInterval(poll); document.removeEventListener("visibilitychange", onVisible); + stopTurns(); }; }, [refresh]); diff --git a/src/lib/rateLimits.test.ts b/src/lib/rateLimits.test.ts index 86fe2e4c..b49f45a0 100644 --- a/src/lib/rateLimits.test.ts +++ b/src/lib/rateLimits.test.ts @@ -12,10 +12,16 @@ import { parseClaudeOAuthUsage, parseCodexRateLimits, parseResetTimestamp, + CODEX_MIN_REFETCH_MS, + RATE_LIMIT_BACKOFF_MS, RATE_LIMIT_MIN_REFETCH_MS, + RATE_LIMIT_UNAVAILABLE_RETRY_MS, rateLimitWindowTooltip, shouldFetchProvider, shouldFetchRateLimits, + throttledRateLimits, + TURN_MIN_REFETCH_MS, + usageProviderFor, } from "./rateLimits"; describe("formatWindowLabel", () => { @@ -201,12 +207,12 @@ describe("shouldFetchRateLimits", () => { const fresh = { ...idleRateLimits("claude"), status: "ok" as const, - updatedAt: now - 60_000, + updatedAt: now - 10_000, }; const stale = { ...fresh, provider: "codex" as const, - updatedAt: now - RATE_LIMIT_MIN_REFETCH_MS, + updatedAt: now - CODEX_MIN_REFETCH_MS, }; it("always fetches when forced", () => { @@ -243,32 +249,74 @@ describe("shouldFetchRateLimits", () => { ).toBe(false); }); - it("fetches on focus once either snapshot is 5 minutes old", () => { + it("holds both providers to the five-minute floor", () => { + const claude = { ...fresh, updatedAt: now - RATE_LIMIT_MIN_REFETCH_MS }; + const justUnder = { ...fresh, updatedAt: now - RATE_LIMIT_MIN_REFETCH_MS + 1 }; + expect(shouldFetchProvider(claude, { visible: true, now })).toBe(true); + expect(shouldFetchProvider(justUnder, { visible: true, now })).toBe(false); + expect(shouldFetchProvider(stale, { visible: true, now })).toBe(true); + }); + + it("keeps a throttled provider off the endpoint until the backoff clears", () => { + const hit = { ...throttledRateLimits("claude"), updatedAt: now }; + // Even a turn ending must not cut ahead of a 429. expect( - shouldFetchRateLimits({ + shouldFetchProvider(hit, { visible: true, - claude: fresh, - codex: stale, now, + minAgeMs: TURN_MIN_REFETCH_MS, }), + ).toBe(false); + expect( + shouldFetchProvider( + { ...hit, updatedAt: now - RATE_LIMIT_MIN_REFETCH_MS }, + { visible: true, now }, + ), + ).toBe(false); + expect( + shouldFetchProvider( + { ...hit, updatedAt: now - RATE_LIMIT_BACKOFF_MS }, + { visible: true, now }, + ), ).toBe(true); }); + it("drops to the turn floor when a turn just ended", () => { + const codex = { + ...fresh, + provider: "codex" as const, + updatedAt: now - TURN_MIN_REFETCH_MS, + }; + expect( + shouldFetchProvider(codex, { + visible: true, + now, + minAgeMs: TURN_MIN_REFETCH_MS, + }), + ).toBe(true); + expect( + shouldFetchProvider( + { ...codex, updatedAt: now - TURN_MIN_REFETCH_MS + 1 }, + { visible: true, now, minAgeMs: TURN_MIN_REFETCH_MS }, + ), + ).toBe(false); + }); + it("treats the first idle load as stale", () => { expect(isRateLimitSnapshotStale(idleRateLimits("claude"), now)).toBe(true); }); - it("does not keep polling a provider that is not connected", () => { + it("holds a not-connected provider off the normal cadence", () => { const disconnected = { ...idleRateLimits("codex"), status: "unavailable" as const, - updatedAt: now - RATE_LIMIT_MIN_REFETCH_MS, + updatedAt: now - CODEX_MIN_REFETCH_MS, error: "Codex CLI not found", }; expect(isRateLimitSnapshotStale(disconnected, now)).toBe(false); - expect( - shouldFetchProvider(disconnected, { visible: true, now }), - ).toBe(false); + expect(shouldFetchProvider(disconnected, { visible: true, now })).toBe( + false, + ); expect( shouldFetchRateLimits({ visible: true, @@ -279,16 +327,26 @@ describe("shouldFetchRateLimits", () => { ).toBe(false); }); + it("retries a not-connected provider so a later sign-in recovers", () => { + const disconnected = { + ...idleRateLimits("claude"), + status: "unavailable" as const, + updatedAt: now - RATE_LIMIT_UNAVAILABLE_RETRY_MS, + error: "Claude not signed in", + }; + expect(shouldFetchProvider(disconnected, { visible: true, now })).toBe(true); + }); + it("still polls the connected provider when the other is not", () => { const disconnected = { ...idleRateLimits("claude"), status: "unavailable" as const, - updatedAt: now - RATE_LIMIT_MIN_REFETCH_MS, + updatedAt: now, error: "Claude not signed in", }; - expect( - shouldFetchProvider(disconnected, { visible: true, now }), - ).toBe(false); + expect(shouldFetchProvider(disconnected, { visible: true, now })).toBe( + false, + ); expect( shouldFetchRateLimits({ visible: true, @@ -299,7 +357,7 @@ describe("shouldFetchRateLimits", () => { ).toBe(true); }); - it("retries a disconnected provider only when forced", () => { + it("retries a disconnected provider on demand", () => { const disconnected = { ...idleRateLimits("codex"), status: "unavailable" as const, @@ -311,3 +369,22 @@ describe("shouldFetchRateLimits", () => { ).toBe(true); }); }); + +describe("usageProviderFor", () => { + it("maps only the harnesses the footer tracks", () => { + expect(usageProviderFor("claude")).toBe("claude"); + expect(usageProviderFor("codex")).toBe("codex"); + expect(usageProviderFor("cursor")).toBe(null); + expect(usageProviderFor("")).toBe(null); + }); +}); + +describe("throttledRateLimits", () => { + it("drops the numbers instead of showing what we no longer know", () => { + const throttled = throttledRateLimits("claude"); + expect(throttled.session).toBe(null); + expect(throttled.weekly).toBe(null); + expect(throttled.error).toBe("Usage lookup rate limited"); + expect(throttled.backoffMs).toBe(RATE_LIMIT_BACKOFF_MS); + }); +}); diff --git a/src/lib/rateLimits.ts b/src/lib/rateLimits.ts index f242dea4..0d77a571 100644 --- a/src/lib/rateLimits.ts +++ b/src/lib/rateLimits.ts @@ -21,35 +21,72 @@ export type ProviderRateLimits = { updatedAt: number; error: string | null; status: RateLimitStatus; + /** Floor before the next attempt when the provider told us to back off. */ + backoffMs?: number; }; export const SESSION_WINDOW_MINUTES = 300; export const WEEKLY_WINDOW_MINUTES = 10_080; -/** Background poll while the window is visible. */ -export const RATE_LIMIT_POLL_MS = 15 * 60 * 1000; -/** Skip focus/restore and timer refetches until the snapshot is this old. */ +/** + * Timer granularity, not the request rate: every fetch still has to clear the + * per-provider floor below. The old 15-minute timer beat against the 5-minute + * floor, so a snapshot could sit unrefreshed for a quarter hour. + */ +export const RATE_LIMIT_POLL_MS = 60 * 1000; +/** + * Claude's usage endpoint 429s hard at 30-60s polling and does not send + * Retry-After, so 5 minutes is the community-safe floor. Do not lower it. + */ export const RATE_LIMIT_MIN_REFETCH_MS = 5 * 60 * 1000; +/** A Codex read spawns `codex app-server`, so it gets the same slow cadence. */ +export const CODEX_MIN_REFETCH_MS = 5 * 60 * 1000; +/** + * Turn ends may cut ahead of the steady floor, but every fetch resets the + * clock, so this doubles as the ceiling on request rate: 30 an hour in a + * session of back-to-back turns, half what the endpoint throttles at. + */ +export const TURN_MIN_REFETCH_MS = 2 * 60 * 1000; +/** A provider that reported "not connected" is retried this rarely, not never. */ +export const RATE_LIMIT_UNAVAILABLE_RETRY_MS = 15 * 60 * 1000; +/** Once throttled the endpoint stays throttled, so stop feeding it. */ +export const RATE_LIMIT_BACKOFF_MS = 30 * 60 * 1000; + +export function minRefetchMs(provider: RateLimitProvider): number { + return provider === "codex" ? CODEX_MIN_REFETCH_MS : RATE_LIMIT_MIN_REFETCH_MS; +} export function isRateLimitSnapshotStale( limits: ProviderRateLimits | null | undefined, now: number, - minAgeMs = RATE_LIMIT_MIN_REFETCH_MS, + minAgeMs?: number, ): boolean { if (!limits || limits.status === "idle") return true; - if (limits.status === "unavailable") return false; + // Signing in after launch has to recover on its own, but a probe that costs a + // process spawn should not retry on the normal cadence. + if (limits.status === "unavailable") { + return now - limits.updatedAt >= RATE_LIMIT_UNAVAILABLE_RETRY_MS; + } if (limits.updatedAt <= 0) return true; - return now - limits.updatedAt >= minAgeMs; + // A backoff outranks the caller's floor: a turn ending is not a reason to + // poke an endpoint that just throttled us. + const floor = + limits.backoffMs ?? minAgeMs ?? minRefetchMs(limits.provider); + return now - limits.updatedAt >= floor; } export function shouldFetchProvider( limits: ProviderRateLimits, - input: { force?: boolean; visible: boolean; now?: number }, + input: { + force?: boolean; + visible: boolean; + now?: number; + minAgeMs?: number; + }, ): boolean { if (input.force) return true; if (!input.visible) return false; - if (limits.status === "unavailable") return false; - return isRateLimitSnapshotStale(limits, input.now ?? Date.now()); + return isRateLimitSnapshotStale(limits, input.now ?? Date.now(), input.minAgeMs); } export function shouldFetchRateLimits(input: { @@ -58,6 +95,7 @@ export function shouldFetchRateLimits(input: { claude: ProviderRateLimits; codex: ProviderRateLimits; now?: number; + minAgeMs?: number; }): boolean { return ( shouldFetchProvider(input.claude, input) || @@ -65,6 +103,30 @@ export function shouldFetchRateLimits(input: { ); } +const USAGE_STALE = "monocode-usage-stale"; + +/** Which footer chip a harness spends quota from, if any. */ +export function usageProviderFor(harness: string): RateLimitProvider | null { + return harness === "claude" || harness === "codex" ? harness : null; +} + +/** Nudge the usage footer after a turn so the percentage tracks what just ran. */ +export function notifyUsageStale(provider: RateLimitProvider | null): void { + if (!provider) return; + window.dispatchEvent(new CustomEvent(USAGE_STALE, { detail: provider })); +} + +export function subscribeUsageStale( + listener: (provider: RateLimitProvider) => void, +): () => void { + const handler = (event: Event) => { + const provider = usageProviderFor((event as CustomEvent).detail); + if (provider) listener(provider); + }; + window.addEventListener(USAGE_STALE, handler); + return () => window.removeEventListener(USAGE_STALE, handler); +} + const WINDOW_DURATION_TOLERANCE_MINUTES = 1; export function idleRateLimits( @@ -134,6 +196,21 @@ export function errorRateLimits( }; } +/** + * A 429 from the usage endpoint. It arrives without Retry-After and tends to + * persist, so hold off far longer than a normal error. The chip drops to "—" + * rather than keeping the last numbers: we do not know them any more, and the + * tooltip has room to say why. + */ +export function throttledRateLimits( + provider: RateLimitProvider, +): ProviderRateLimits { + return { + ...errorRateLimits(provider, "Usage lookup rate limited"), + backoffMs: RATE_LIMIT_BACKOFF_MS, + }; +} + export function clampUsedPercent(value: number): number { if (!Number.isFinite(value)) return 0; return Math.min(100, Math.max(0, value)); diff --git a/src/lib/rateLimitsFetch.ts b/src/lib/rateLimitsFetch.ts index 4118fff2..1e2fdf25 100644 --- a/src/lib/rateLimitsFetch.ts +++ b/src/lib/rateLimitsFetch.ts @@ -4,6 +4,7 @@ import { errorRateLimits, parseClaudeOAuthUsage, parseCodexRateLimits, + throttledRateLimits, unavailableRateLimits, type ProviderRateLimits, } from "./rateLimits"; @@ -45,6 +46,7 @@ export async function fetchClaudeRateLimits(): Promise { result.error?.trim() || "Claude not signed in", ); } + if (result.httpStatus === 429) return throttledRateLimits("claude"); return errorRateLimits( "claude", result.error?.trim() || "Claude usage unavailable",