From df9f834a427936895d0f78fa01561e0628e1886c Mon Sep 17 00:00:00 2001 From: emircan-sahin Date: Fri, 28 Aug 2026 21:47:07 +0300 Subject: [PATCH] Refresh usage footer on turn end and stop rotating Claude's OAuth token The 15-minute poll timer beat against the 5-minute staleness floor, so a snapshot could sit unrefreshed for a quarter hour. The timer now ticks every minute while both providers keep the 5-minute floor, and a finished Claude or Codex turn refreshes its own chip within two minutes. MonoCode no longer refreshes Claude Code's OAuth token. Those credentials belong to the claude CLI, which MonoCode spawns for every turn and which refreshes them itself; a rotation MonoCode could not persist left the CLI holding a dead refresh token. The footer now only reads, and an expired token shows as expired until the next turn renews it. The usage endpoint 429s at 30-60s polling without sending Retry-After, so a throttle backs off for 30 minutes and the chip falls back to a dash with the reason in its tooltip. Also: - a provider that was not signed in at launch is retried every 15 minutes instead of never - clicking refresh mid-poll is queued instead of swallowed - an idle terminal no longer forks a ps every second to name its foreground process, since the check that makes that pointless ran after the spawn rather than before --- CHANGELOG.md | 15 ++ src-tauri/src/pty.rs | 6 +- src-tauri/src/rate_limits.rs | 291 ++++++----------------------------- src/App.tsx | 3 + src/chrome/UsageFooter.tsx | 59 ++++++- src/lib/rateLimits.test.ts | 109 +++++++++++-- src/lib/rateLimits.ts | 95 ++++++++++-- src/lib/rateLimitsFetch.ts | 2 + 8 files changed, 299 insertions(+), 281 deletions(-) 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",