diff --git a/AGENTS.md b/AGENTS.md index 635890c..b4546dc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,9 +5,20 @@ These rules apply to the entire Lumbridge repository. ## Product boundary Lumbridge is a terminal/workspace runtime and ACP client. It may host, supervise, -and observe coding harnesses, but must not silently impersonate them, scrape their +and observe coding harnesses, but must not silently impersonate them, harvest their private credentials, or claim provider quota data that cannot be verified. +**Credential use is narrow and named.** A harness's stored credential may be read +only to ask that same provider a documented question about the user's own account, +and only where the answer cannot be obtained another way. Under that allowance a +credential must never be persisted, logged, copied into application state, written +to a crash report, or passed as a command-line argument; it must be released as +soon as the request it authorises has been made; and any request it authorises must +identify Lumbridge as the caller. A refresh token is never used — renewing a +credential is the harness's job, not Lumbridge's. Every such use is named in a +decision record, and each is switchable off by the user. Reading a credential for +anything other than a use recorded that way is out of bounds. See decision 0016. + ## Research boundary Upstream repositories are cloned outside this Git repository under the desktop diff --git a/Cargo.lock b/Cargo.lock index b168d89..d1065b4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -27,7 +27,7 @@ version = "0.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bda177466b9524d59f1b12f0dd30b68696788e9992a7e959021c4a0ed96fcf59" dependencies = [ - "base64", + "base64 0.22.1", "bitflags 2.13.1", "home", "libc", @@ -84,6 +84,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "base64ct" version = "1.8.3" @@ -194,7 +200,7 @@ name = "buzz-core" version = "0.1.0" source = "git+https://github.com/block/buzz.git?rev=cb3144999bebc4939cb15b2200b373281d493b52#cb3144999bebc4939cb15b2200b373281d493b52" dependencies = [ - "base64", + "base64 0.22.1", "chrono", "hex", "hmac 0.13.0", @@ -224,6 +230,12 @@ dependencies = [ "uuid", ] +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + [[package]] name = "cbc" version = "0.1.2" @@ -336,6 +348,35 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" +[[package]] +name = "cookie" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "cookie_store" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206" +dependencies = [ + "cookie", + "document-features", + "idna", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "time", + "url", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -401,6 +442,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + [[package]] name = "digest" version = "0.10.7" @@ -435,12 +482,27 @@ dependencies = [ "syn 3.0.4", ] +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "downcast-rs" version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + [[package]] name = "errno" version = "0.3.14" @@ -560,6 +622,12 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + [[package]] name = "hermit-abi" version = "0.5.3" @@ -617,6 +685,22 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + [[package]] name = "hybrid-array" version = "0.4.14" @@ -754,6 +838,16 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "indexmap" +version = "2.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07aa2048142242915a31d35844fb311e0e53fcca590c3a0a40dcf1b841fa09eb" +dependencies = [ + "equivalent", + "hashbrown", +] + [[package]] name = "inout" version = "0.1.4" @@ -828,6 +922,12 @@ version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + [[package]] name = "lock_api" version = "0.4.14" @@ -876,6 +976,7 @@ dependencies = [ "serde", "serde_json", "thiserror 2.0.20", + "ureq", ] [[package]] @@ -945,7 +1046,7 @@ version = "0.44.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40ff7b77ef428b40aa2834a6acbae38a0e104c98b306208ca4b87a420d579a4b" dependencies = [ - "base64", + "base64 0.22.1", "bech32", "bip39", "bitcoin_hashes", @@ -963,6 +1064,12 @@ dependencies = [ "url", ] +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + [[package]] name = "num-traits" version = "0.2.19" @@ -1112,6 +1219,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1218,6 +1331,20 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rusqlite" version = "0.40.2" @@ -1255,6 +1382,41 @@ dependencies = [ "rustix", ] +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.23" @@ -1523,6 +1685,36 @@ dependencies = [ "syn 3.0.4", ] +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.4" @@ -1585,6 +1777,43 @@ dependencies = [ "subtle", ] +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "972d7902c8735f2695410b8aed7df6ed12a47394aa1c8d7af49f0497b731a94d" +dependencies = [ + "base64 0.23.1", + "cookie_store", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "ureq-proto", + "utf8-zero", + "webpki-roots", +] + +[[package]] +name = "ureq-proto" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da5f78b09e6941e1a0f2e30e695e4b120377b54d5e0aec11b594bb57b3971613" +dependencies = [ + "base64 0.23.1", + "http", + "httparse", + "log", +] + [[package]] name = "url" version = "2.5.8" @@ -1598,6 +1827,12 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -1702,6 +1937,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "winapi" version = "0.3.9" @@ -1783,6 +2027,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.59.0" diff --git a/crates/lumbridge-harness/Cargo.toml b/crates/lumbridge-harness/Cargo.toml index a329ca2..c62f828 100644 --- a/crates/lumbridge-harness/Cargo.toml +++ b/crates/lumbridge-harness/Cargo.toml @@ -12,6 +12,7 @@ lumbridge-core = { path = "../lumbridge-core" } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" thiserror = "2.0" +ureq = { version = "3.4.0", default-features = false, features = ["rustls", "json"] } [target.'cfg(unix)'.dependencies] nix = { version = "0.28", features = ["process", "signal"] } diff --git a/crates/lumbridge-harness/examples/claude_usage.rs b/crates/lumbridge-harness/examples/claude_usage.rs new file mode 100644 index 0000000..9588a3c --- /dev/null +++ b/crates/lumbridge-harness/examples/claude_usage.rs @@ -0,0 +1,57 @@ +//! Takes one reading from Claude Code's account usage endpoint and prints it. +//! +//! `cargo run -p lumbridge-harness --example claude_usage` +//! +//! Useful for answering "what does the provider actually say right now?" +//! without launching the shell. It performs exactly one request — the endpoint +//! rate-limits under polling — and prints only the parsed windows. The token is +//! never printed, because there is no code path here that could reach it. + +use lumbridge_harness::MonotonicWallClock; +use lumbridge_harness::claude::WindowReading; +use lumbridge_harness::claude::oauth::{ClaudeOauthOptions, RefreshOutcome, refresh}; + +fn main() { + let options = ClaudeOauthOptions::default(); + if !options.enabled { + println!("the usage endpoint is disabled (LUMBRIDGE_CLAUDE_OAUTH=0)"); + return; + } + let now_ms = MonotonicWallClock::start().now_ms(); + println!("GET {}", options.endpoint); + + match refresh(&options, now_ms) { + RefreshOutcome::Snapshot(snapshot, plan) => { + println!("plan: {}", plan.label.as_deref().unwrap_or("unreported")); + print_window("five hour", snapshot.five_hour, now_ms); + print_window("seven day", snapshot.seven_day, now_ms); + for scoped in snapshot.scoped { + print_window(&scoped.model, scoped.reading, now_ms); + } + } + RefreshOutcome::RateLimited => println!("rate limited; try again later"), + RefreshOutcome::Unauthenticated => { + println!("no usable credential (not signed in, expired, or disabled)"); + } + RefreshOutcome::Failed(error) => println!("failed: {error}"), + } +} + +fn print_window(label: &str, reading: WindowReading, now_ms: u64) { + match reading { + WindowReading::Usable { permille, window } => { + let left = (1_000 - permille.min(1_000)) / 10; + let resets = window.map_or_else( + || "no reset reported".to_owned(), + |window| { + format!( + "resets in {}", + lumbridge_core::format_duration_ms(window.remaining_ms(now_ms)) + ) + }, + ); + println!("{label:>22}: {left}% left · {resets}"); + } + WindowReading::Absent => println!("{label:>22}: not reported"), + } +} diff --git a/crates/lumbridge-harness/src/claude/mod.rs b/crates/lumbridge-harness/src/claude/mod.rs index f037647..5ee312b 100644 --- a/crates/lumbridge-harness/src/claude/mod.rs +++ b/crates/lumbridge-harness/src/claude/mod.rs @@ -1,14 +1,23 @@ //! The Claude Code usage adapter, over two different surfaces. //! -//! **Subscription windows** ([`statusline`]) are the real quota. Claude Code -//! 2.1.80 and later pipe a `rate_limits` object carrying `five_hour` and -//! `seven_day` — each with `used_percentage` and `resets_at` — to the -//! configured `statusLine` command on every turn. Those are rate-limit headers -//! the CLI already received on its own API responses, so reading them costs -//! nothing and they are `ProviderReported`. Lumbridge does not call the -//! account's usage endpoint for them: that would mean reading Claude Code's -//! OAuth credential, which AGENTS.md forbids. The status line is pushed to a -//! command the user installs, so no credential is ever touched. +//! **Subscription windows** come from two surfaces that answer the same +//! question, because neither is sufficient alone. +//! +//! [`statusline`] is the free continuous one. Claude Code 2.1.80 and later pipe +//! a `rate_limits` object carrying `five_hour` and `seven_day` — each with +//! `used_percentage` and `resets_at` — to the configured `statusLine` command on +//! every turn. Those are rate-limit headers the CLI already received on its own +//! API responses, so reading them costs nothing. They only arrive while a +//! session is taking turns, and they omit per-model limits. +//! +//! [`oauth`] fills both gaps by asking the account's usage endpoint directly, +//! which answers on a cold start and reports the `weekly_scoped` per-model +//! limits a Max plan meters separately. It reads Claude Code's stored access +//! token to do so, under the narrow allowance in AGENTS.md recorded by decision +//! 0016, and it refreshes slowly because that endpoint rate-limits under +//! polling. Whichever surface spoke most recently wins; both are +//! `ProviderReported`, and the numbers agree because the window arithmetic is +//! shared. //! //! **Token consumption** ([`transcript`]) comes from the session transcripts //! Claude Code writes under `~/.claude/projects`. Those record the API's @@ -20,6 +29,7 @@ //! was appended since, which is what makes the token total monotonic — the //! ledger rejects an observation that moves a profile backwards. +pub mod oauth; mod statusline; mod transcript; @@ -36,6 +46,7 @@ use std::time::Duration; use lumbridge_core::{AccountProfile, UsageObservation, UsageProvenance, UsageUnit}; use crate::HarnessError; +use crate::claude::oauth::{ClaudeOauthOptions, PlanIdentity, RefreshOutcome}; use crate::claude::statusline::parse_feed_line; pub use crate::claude::statusline::{ClaudeWindowKind, WindowReading}; use crate::claude::transcript::MAX_RECORD_BYTES; @@ -67,6 +78,9 @@ pub struct ClaudeCodeProbeOptions { /// Absent until the user installs the bridge, which is why the windows read /// as unavailable rather than as zero before then. pub rate_limit_feed: PathBuf, + /// Whether and how often to ask the account usage endpoint. Disabled here + /// means the credential file is never opened. + pub oauth: ClaudeOauthOptions, pub poll_interval: Duration, } @@ -75,6 +89,7 @@ impl Default for ClaudeCodeProbeOptions { Self { projects_root: default_projects_root(), rate_limit_feed: default_rate_limit_feed(), + oauth: ClaudeOauthOptions::default(), poll_interval: Duration::from_secs(10), } } @@ -121,6 +136,9 @@ pub fn default_projects_root() -> PathBuf { enum WorkerEvent { Observation(UsageObservation), Health(ProbeHealth), + /// A per-model weekly limit the usage endpoint reported. These cannot be + /// declared at startup because their names come from the response. + Profile(Box), } /// A running Claude Code usage probe. @@ -134,13 +152,19 @@ pub struct ClaudeCodeProbe { } impl ClaudeCodeProbe { - /// The static, account-free profiles this probe reports under: one per - /// documented subscription window, plus the transcript token total. + /// The account-free profiles this probe reports under: one per documented + /// subscription window, plus the transcript token total. /// /// The windows come first because they are the quota — what a user means /// by "how much do I have left" — and the token total is the supporting /// fact about spend. - fn profiles_for() -> Result, HarnessError> { + /// + /// `plan` labels them with the tier the quota belongs to (`Max (20x)`), + /// which is read from the local credential file without a network call. It + /// is a plan tier and not an account identifier; the e-mail address sitting + /// beside it is deliberately not read. + fn profiles_for(plan: &PlanIdentity) -> Result, HarnessError> { + let account = plan.label.as_deref().unwrap_or("subscription"); let mut profiles = Vec::new(); for kind in ClaudeWindowKind::ALL { profiles.push( @@ -149,7 +173,7 @@ impl ClaudeCodeProbe { "Claude Code", "Anthropic", kind.scope(), - "subscription", + account, ) .map_err(|_| HarnessError::EmptyProgram)?, ); @@ -160,7 +184,7 @@ impl ClaudeCodeProbe { "Claude Code", "Anthropic", "session transcripts", - "subscription", + account, ) .map_err(|_| HarnessError::EmptyProgram)?, ); @@ -179,7 +203,8 @@ impl ClaudeCodeProbe { if options.poll_interval < MIN_POLL_INTERVAL { return Err(HarnessError::ProbeIntervalTooShort); } - let profiles = Self::profiles_for()?; + let plan = oauth::read_plan_identity(&options.oauth, MonotonicWallClock::start().now_ms()); + let profiles = Self::profiles_for(&plan)?; let worker_profiles = profiles.clone(); let (event_sender, events) = mpsc::sync_channel(EVENT_QUEUE); let stop = Arc::new(AtomicBool::new(false)); @@ -221,6 +246,11 @@ impl UsageProbe for ClaudeCodeProbe { match events.try_recv() { Ok(WorkerEvent::Observation(observation)) => observations.push(observation), Ok(WorkerEvent::Health(health)) => self.health = health, + Ok(WorkerEvent::Profile(profile)) => { + if !self.profiles.iter().any(|known| known.id() == profile.id()) { + self.profiles.push(*profile); + } + } Err(TryRecvError::Empty) => break, Err(TryRecvError::Disconnected) => { if !self.health.is_faulted() { @@ -264,6 +294,9 @@ struct FollowState { /// Read position in the status-line feed, and the newest reading seen. feed_offset: u64, windows: BTreeMap<&'static str, WindowReading>, + /// Per-model weekly limits, keyed by their derived profile identifier. Only + /// the usage endpoint reports these; the status line has no field for them. + scoped: BTreeMap, } /// Reads whatever the status-line bridge appended and keeps the newest line. @@ -318,6 +351,154 @@ fn follow_feed(path: &Path, state: &mut FollowState, observed_at_ms: u64) { } } +/// Drives the account usage endpoint on its own slow cadence. +/// +/// The request runs off the worker thread. A ten-second round trip must not +/// stall the transcript follower, and joining one would make quitting Lumbridge +/// wait on the network. +struct UsageEndpoint { + /// Zero means "ask now": the point of this surface is that it answers + /// before any session has taken a turn. + next_ms: u64, + interval_ms: u64, + in_flight: Arc, + sender: mpsc::Sender, + results: Receiver, +} + +impl UsageEndpoint { + fn new(options: &ClaudeOauthOptions) -> Self { + let (sender, results) = mpsc::channel(); + Self { + next_ms: 0, + interval_ms: u64::try_from( + options + .refresh_interval + .max(oauth::MIN_REFRESH_INTERVAL) + .as_millis(), + ) + .unwrap_or(u64::MAX), + in_flight: Arc::new(AtomicBool::new(false)), + sender, + results, + } + } + + /// Starts a refresh if one is due, then folds in whatever has come back. + fn pump(&mut self, options: &ClaudeOauthOptions, now_ms: u64, state: &mut FollowState) { + if options.enabled + && now_ms >= self.next_ms + && !self.in_flight.swap(true, Ordering::Relaxed) + { + self.next_ms = now_ms.saturating_add(self.interval_ms); + let request = options.clone(); + let sender = self.sender.clone(); + let flag = Arc::clone(&self.in_flight); + // Detached on purpose: nothing joins it, the send simply fails once + // the probe is gone, and the request carries its own timeout. + if thread::Builder::new() + .name("lumbridge-claude-usage".to_owned()) + .spawn(move || { + let outcome = oauth::refresh(&request, now_ms); + let _ = sender.send(outcome); + flag.store(false, Ordering::Relaxed); + }) + .is_err() + { + self.in_flight.store(false, Ordering::Relaxed); + } + } + + while let Ok(outcome) = self.results.try_recv() { + match outcome { + RefreshOutcome::Snapshot(snapshot, _) => { + // The endpoint is the authority when it answers: it is the + // account's own statement rather than a header relayed + // through a session that may have ended hours ago. + state + .windows + .insert(ClaudeWindowKind::FiveHour.profile_id(), snapshot.five_hour); + state + .windows + .insert(ClaudeWindowKind::SevenDay.profile_id(), snapshot.seven_day); + for scoped in snapshot.scoped { + state + .scoped + .insert(scoped.profile_id, (scoped.model, scoped.reading)); + } + } + // Asking again sooner would only earn another refusal, and the + // status line keeps reporting in the meantime. + RefreshOutcome::RateLimited => { + self.next_ms = now_ms.saturating_add( + u64::try_from(oauth::BACKOFF_AFTER_429.as_millis()).unwrap_or(u64::MAX), + ); + } + // Neither of these is a fault. Not being signed in is a state, + // not a breakage, and it is not this probe's job to refresh + // another program's credential. A failed request is one surface + // of three going quiet — faulting the whole probe would hide two + // working readings behind one unreachable endpoint. Both leave + // the windows on whatever the status line last said. + RefreshOutcome::Unauthenticated | RefreshOutcome::Failed(_) => {} + } + } + } +} + +/// Bookkeeping for the per-model weekly limits, which only the usage endpoint +/// reports and whose names are unknown until it answers. +struct ScopedState { + account: String, + last: BTreeMap, + declared: Vec, +} + +/// Emits changed per-model limits. Returns false once the receiver is gone. +/// +/// A profile has to be announced before its reading means anything, so the two +/// travel together the first time a model appears. +fn emit_scoped( + scoped: &mut ScopedState, + state: &FollowState, + events: &SyncSender, + now_ms: u64, +) -> bool { + for (profile_id, (model, reading)) in &state.scoped { + if scoped.last.get(profile_id) == Some(reading) { + continue; + } + let Ok(profile) = AccountProfile::new( + profile_id.clone(), + "Claude Code", + "Anthropic", + format!("{model} weekly"), + scoped.account.as_str(), + ) else { + continue; + }; + scoped.last.insert(profile_id.clone(), *reading); + if !scoped.declared.contains(profile_id) { + scoped.declared.push(profile_id.clone()); + if matches!( + events.try_send(WorkerEvent::Profile(Box::new(profile.clone()))), + Err(mpsc::TrySendError::Disconnected(_)) + ) { + return false; + } + } + if matches!( + events.try_send(WorkerEvent::Observation(window_observation( + &profile, *reading, now_ms + ))), + Err(mpsc::TrySendError::Disconnected(_)) + ) { + return false; + } + } + true +} + fn run_worker( options: &ClaudeCodeProbeOptions, profiles: &[AccountProfile], @@ -334,7 +515,18 @@ fn run_worker( let mut last_poll_ms = 0; let mut last_emitted = TokenTally::default(); let mut last_windows: BTreeMap<&'static str, WindowReading> = BTreeMap::new(); + // Scoped profiles inherit the plan label the declared ones were built with, + // so one footer does not show two different accounts for one subscription. + let mut scoped_state = ScopedState { + account: profiles.first().map_or_else( + || "subscription".to_owned(), + |profile| profile.account().to_owned(), + ), + last: BTreeMap::new(), + declared: Vec::new(), + }; let mut primed = false; + let mut endpoint = UsageEndpoint::new(&options.oauth); loop { if stop.load(Ordering::Relaxed) { @@ -354,6 +546,13 @@ fn run_worker( // status-line reading is a complete snapshot, so it can be reported // immediately rather than waiting for priming. follow_feed(&options.rate_limit_feed, &mut state, now_ms); + + endpoint.pump(&options.oauth, now_ms, &mut state); + + if !emit_scoped(&mut scoped_state, &state, events, now_ms) { + return; + } + for kind in ClaudeWindowKind::ALL { let Some(reading) = state.windows.get(kind.profile_id()).copied() else { continue; @@ -554,8 +753,8 @@ fn follow(path: &Path, state: &mut FollowState, budget: &mut usize) -> bool { #[cfg(test)] mod tests { use super::{ - ClaudeCodeProbe, ClaudeCodeProbeOptions, FollowState, TokenTally, default_projects_root, - scan, + ClaudeCodeProbe, ClaudeCodeProbeOptions, ClaudeOauthOptions, FollowState, TokenTally, + default_projects_root, scan, }; use crate::HarnessError; use crate::probe::{ProbeHealth, UsageProbe}; @@ -693,6 +892,7 @@ mod tests { let mut probe = ClaudeCodeProbe::start(ClaudeCodeProbeOptions { projects_root: root.clone(), rate_limit_feed: root.join("feed.jsonl"), + oauth: ClaudeOauthOptions::disabled(), poll_interval: Duration::from_secs(1), }) .expect("the probe starts"); @@ -746,6 +946,7 @@ mod tests { let mut probe = ClaudeCodeProbe::start(ClaudeCodeProbeOptions { projects_root: root.clone(), rate_limit_feed: feed, + oauth: ClaudeOauthOptions::disabled(), poll_interval: Duration::from_secs(1), }) .expect("the probe starts"); @@ -788,6 +989,7 @@ mod tests { let mut probe = ClaudeCodeProbe::start(ClaudeCodeProbeOptions { projects_root: root.clone(), rate_limit_feed: feed, + oauth: ClaudeOauthOptions::disabled(), poll_interval: Duration::from_secs(1), }) .expect("starts"); @@ -814,6 +1016,7 @@ mod tests { let mut probe = ClaudeCodeProbe::start(ClaudeCodeProbeOptions { projects_root: root.clone(), rate_limit_feed: root.join("feed.jsonl"), + oauth: ClaudeOauthOptions::disabled(), poll_interval: Duration::from_secs(60), }) .expect("starts"); diff --git a/crates/lumbridge-harness/src/claude/oauth.rs b/crates/lumbridge-harness/src/claude/oauth.rs new file mode 100644 index 0000000..a28c196 --- /dev/null +++ b/crates/lumbridge-harness/src/claude/oauth.rs @@ -0,0 +1,729 @@ +//! Claude Code's account usage endpoint. +//! +//! This is the one place Lumbridge reads another harness's credential, and it +//! does so under a narrow, written allowance in `AGENTS.md`: the harness's own +//! access token, used only against that provider's documented usage endpoint, +//! never persisted, never logged, never passed as a command-line argument. +//! Decision 0016 records why the earlier blanket prohibition was narrowed. +//! +//! Two things the status line cannot give us come from here: +//! +//! - **Per-model weekly limits.** A Max plan meters some models separately, and +//! `limits[]` reports each as its own `weekly_scoped` entry. The status line +//! carries only the two account-wide windows, so a user who has burned a +//! model-specific limit sees nothing there. +//! - **A reading without a session.** The status line only speaks when Claude +//! Code takes a turn. This answers on demand, which is what makes the footer +//! truthful on a cold start. +//! +//! It is *not* the continuous source. The endpoint rate-limits under polling, +//! so the status line remains the free per-turn feed and this refreshes slowly +//! behind it. + +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use serde::Deserialize; + +use crate::HarnessError; +use crate::claude::statusline::{ + ClaudeWindowKind, WEEKLY_MS, WindowReading, unix_seconds_to_millis, window_reading, +}; + +/// The documented endpoint, as used by Claude Code itself. +pub const USAGE_ENDPOINT: &str = "https://api.anthropic.com/api/oauth/usage"; +/// The beta header the endpoint requires for an OAuth token. +const OAUTH_BETA: &str = "oauth-2025-04-20"; +/// Identifies the caller honestly: Lumbridge is not Claude Code, and says so. +/// `AGENTS.md` forbids silently impersonating a harness, and a support engineer +/// reading these logs should be able to tell who actually made the request. +const USER_AGENT: &str = concat!("lumbridge/", env!("CARGO_PKG_VERSION"), " (usage-probe)"); +/// A response larger than this is not the small JSON document we expect. +const MAX_RESPONSE_BYTES: u64 = 256 * 1024; +const REQUEST_TIMEOUT: Duration = Duration::from_secs(10); +/// The endpoint rate-limits under polling, so this is deliberately slow. The +/// status line covers the gap between refreshes for free. +pub const MIN_REFRESH_INTERVAL: Duration = Duration::from_secs(300); +/// How long to stand down after the endpoint says we are asking too often. +pub const BACKOFF_AFTER_429: Duration = Duration::from_secs(1_800); +/// A bound on how many per-model limits will be tracked, so a response cannot +/// grow the footer without limit. +const MAX_SCOPED_WINDOWS: usize = 6; +/// A bound on a model display name before it is used to build a profile. +const MAX_LABEL_BYTES: usize = 48; + +/// An access token, held for the length of one request. +/// +/// The bytes are overwritten when this is dropped. That is a real but partial +/// guarantee, and it is worth stating exactly: the file is read into a buffer +/// that is also zeroed, and the token is borrowed out of it rather than being +/// copied through an intermediate `String`, so the only copies are the two this +/// type owns. It does not defend against the OS having paged either buffer out. +struct AccessToken(Vec); + +impl AccessToken { + fn header_value(&self) -> Option { + let token = std::str::from_utf8(&self.0).ok()?; + Some(format!("Bearer {token}")) + } +} + +impl Drop for AccessToken { + fn drop(&mut self) { + self.0.fill(0); + } +} + +/// Deliberately opaque: a token must not be printable by accident. +impl std::fmt::Debug for AccessToken { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("AccessToken()") + } +} + +/// What the credential file says, beyond the token. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct PlanIdentity { + /// `"Max (20x)"`, `"Pro"`, or nothing. A plan tier, not an account + /// identifier — the account e-mail sits in the same file and is not read. + pub label: Option, +} + +/// A credential in hand: the token plus what the file said about the plan. +#[derive(Debug)] +struct Credential { + token: AccessToken, + expires_at_ms: Option, + identity: PlanIdentity, +} + +#[derive(Debug, Deserialize)] +struct CredentialFileWire<'a> { + #[serde(borrow, rename = "claudeAiOauth")] + oauth: Option>, +} + +#[derive(Debug, Deserialize)] +struct OauthWire<'a> { + /// Borrowed rather than owned so the token is never copied into a `String` + /// whose buffer this module cannot zero. + #[serde(borrow, rename = "accessToken")] + access_token: Option<&'a str>, + #[serde(default, rename = "expiresAt")] + expires_at: Option, + #[serde(default, borrow, rename = "subscriptionType")] + subscription_type: Option<&'a str>, + #[serde(default, borrow, rename = "rateLimitTier")] + rate_limit_tier: Option<&'a str>, +} + +/// Where the credential lives and whether we are allowed to read it. +#[derive(Clone, Debug)] +pub struct ClaudeOauthOptions { + /// Off means the file is never opened. This is the kill switch for a user + /// who wants the status-line reading and nothing else. + pub enabled: bool, + pub credentials_path: PathBuf, + pub endpoint: String, + pub refresh_interval: Duration, +} + +impl Default for ClaudeOauthOptions { + fn default() -> Self { + Self { + enabled: std::env::var_os("LUMBRIDGE_CLAUDE_OAUTH").is_none_or(|value| value != "0"), + credentials_path: default_credentials_path(), + endpoint: USAGE_ENDPOINT.to_owned(), + refresh_interval: MIN_REFRESH_INTERVAL, + } + } +} + +impl ClaudeOauthOptions { + /// Options that never open the credential file. + /// + /// Tests use this: a test must never read the developer's real credential, + /// and a synthetic fixture is passed by path where one is wanted. + #[must_use] + pub fn disabled() -> Self { + Self { + enabled: false, + ..Self::default() + } + } +} + +/// The documented default location of Claude Code's stored credential. +#[must_use] +pub fn default_credentials_path() -> PathBuf { + if let Some(configured) = std::env::var_os("CLAUDE_CONFIG_DIR") { + return PathBuf::from(configured).join(".credentials.json"); + } + std::env::var_os("HOME") + .map(PathBuf::from) + .unwrap_or_default() + .join(".claude") + .join(".credentials.json") +} + +/// One per-model weekly limit. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ScopedWindow { + /// The provider's own display name, e.g. `Claude Opus 4.6`. + pub model: String, + /// A stable, account-free profile identifier derived from it. + pub profile_id: String, + pub reading: WindowReading, +} + +/// A complete answer from the usage endpoint. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct UsageSnapshot { + pub five_hour: WindowReading, + pub seven_day: WindowReading, + pub scoped: Vec, +} + +/// Reads the credential file. +/// +/// A missing or unparsable file is [`HarnessError::NotAuthenticated`] — an +/// expected gap, not a fault. So is an expired token: Claude Code refreshes it +/// on its next turn, and Lumbridge does not perform that refresh, because +/// refreshing means writing to another program's credential store. +fn read_credential(path: &Path, now_ms: u64) -> Result { + let mut raw = fs::read(path).map_err(|_| HarnessError::NotAuthenticated)?; + let parsed = read_credential_bytes(&raw, now_ms); + // The whole file, not just the token: it also holds the refresh token. + raw.fill(0); + parsed +} + +fn read_credential_bytes(raw: &[u8], now_ms: u64) -> Result { + let file: CredentialFileWire<'_> = + serde_json::from_slice(raw).map_err(|_| HarnessError::NotAuthenticated)?; + let oauth = file.oauth.ok_or(HarnessError::NotAuthenticated)?; + let token = oauth + .access_token + .filter(|value| !value.is_empty()) + .ok_or(HarnessError::NotAuthenticated)?; + let expires_at_ms = oauth.expires_at.and_then(|value| u64::try_from(value).ok()); + if expires_at_ms.is_some_and(|expiry| now_ms >= expiry) { + return Err(HarnessError::NotAuthenticated); + } + Ok(Credential { + token: AccessToken(token.as_bytes().to_vec()), + expires_at_ms, + identity: PlanIdentity { + label: plan_label(oauth.subscription_type, oauth.rate_limit_tier), + }, + }) +} + +/// `default_claude_max_20x` reads as `Max (20x)`; otherwise the plain +/// subscription type. Neither identifies the account. +fn plan_label(subscription: Option<&str>, tier: Option<&str>) -> Option { + if let Some(tier) = tier + && let Some(multiplier) = tier + .rsplit("max_") + .next() + .and_then(|rest| rest.strip_suffix('x')) + && !multiplier.is_empty() + && multiplier.bytes().all(|byte| byte.is_ascii_digit()) + && tier.contains("max_") + { + return Some(format!("Max ({multiplier}x)")); + } + let subscription = subscription?.trim(); + if subscription.is_empty() { + return None; + } + let mut characters = subscription.chars(); + let first = characters.next()?; + Some(first.to_uppercase().collect::() + characters.as_str()) +} + +/// The plan tier, without making a network call. +/// +/// Read at startup so a profile can be labelled with the plan it meters before +/// any request has been made. +#[must_use] +pub fn read_plan_identity(options: &ClaudeOauthOptions, now_ms: u64) -> PlanIdentity { + if !options.enabled { + return PlanIdentity::default(); + } + read_credential(&options.credentials_path, now_ms) + .map(|credential| credential.identity) + .unwrap_or_default() +} + +/// What one refresh produced. +#[derive(Debug)] +pub enum RefreshOutcome { + /// A reading. The plan identity comes along because the same file supplies + /// both and it can change when the user upgrades. + Snapshot(Box, PlanIdentity), + /// The endpoint asked us to slow down. The caller backs off; it does not + /// retry, and it does not treat this as a broken probe. + RateLimited, + /// No number, and no fault: not signed in, or the token has expired and + /// Claude Code has not yet refreshed it. + Unauthenticated, + /// Something went wrong that is worth surfacing as a degraded probe. + Failed(HarnessError), +} + +/// Fetches one reading. +/// +/// The token reaches the request as a header value built at the call site and +/// dropped with it. It is never written to a file, a log, an error, or an +/// argument vector. +#[must_use] +pub fn refresh(options: &ClaudeOauthOptions, now_ms: u64) -> RefreshOutcome { + if !options.enabled { + return RefreshOutcome::Unauthenticated; + } + let Ok(credential) = read_credential(&options.credentials_path, now_ms) else { + return RefreshOutcome::Unauthenticated; + }; + let Some(authorization) = credential.token.header_value() else { + return RefreshOutcome::Unauthenticated; + }; + let _ = credential.expires_at_ms; + + let agent: ureq::Agent = ureq::Agent::config_builder() + .timeout_global(Some(REQUEST_TIMEOUT)) + .build() + .into(); + let response = agent + .get(&options.endpoint) + .header("Authorization", &authorization) + .header("Accept", "application/json") + .header("anthropic-beta", OAUTH_BETA) + .header("User-Agent", USER_AGENT) + .call(); + drop(authorization); + + let mut response = match response { + Ok(response) => response, + Err(ureq::Error::StatusCode(401 | 403)) => return RefreshOutcome::Unauthenticated, + Err(ureq::Error::StatusCode(429)) => return RefreshOutcome::RateLimited, + Err(_) => return RefreshOutcome::Failed(HarnessError::Malformed), + }; + let Ok(body) = response + .body_mut() + .with_config() + .limit(MAX_RESPONSE_BYTES) + .read_to_string() + else { + return RefreshOutcome::Failed(HarnessError::Malformed); + }; + match parse_usage(&body, now_ms) { + Some(snapshot) => RefreshOutcome::Snapshot(Box::new(snapshot), credential.identity), + None => RefreshOutcome::Failed(HarnessError::Malformed), + } +} + +#[derive(Debug, Deserialize)] +struct UsageResponseWire { + #[serde(default)] + five_hour: Option, + #[serde(default)] + seven_day: Option, + #[serde(default)] + limits: Option>>, +} + +#[derive(Debug, Deserialize)] +struct EndpointWindowWire { + #[serde(default)] + utilization: Option, + #[serde(default)] + used_percentage: Option, + #[serde(default)] + resets_at: Option, +} + +#[derive(Debug, Deserialize)] +struct ScopedLimitWire { + #[serde(default)] + kind: Option, + #[serde(default)] + scope: Option, + #[serde(default)] + percent: Option, + #[serde(default)] + utilization: Option, + #[serde(default)] + resets_at: Option, +} + +#[derive(Debug, Deserialize)] +struct ScopeWire { + #[serde(default)] + model: Option, +} + +#[derive(Debug, Deserialize)] +struct ScopeModelWire { + #[serde(default)] + display_name: Option, +} + +/// The endpoint spells its reset as an RFC 3339 string; the status line spells +/// the same instant as epoch seconds. Accept either rather than going dark on +/// whichever one changes. +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum ResetsAtWire { + Text(String), + Epoch(i64), +} + +impl ResetsAtWire { + fn to_millis(&self) -> Option { + match self { + Self::Epoch(seconds) => unix_seconds_to_millis(*seconds), + Self::Text(text) => rfc3339_to_millis(text), + } + } +} + +/// Parses an RFC 3339 timestamp to epoch milliseconds. +/// +/// Hand-rolled rather than pulling in a calendar: the accepted shape is fixed +/// and narrow, and every field is bounds-checked before it is used. Anything +/// that does not match exactly yields no reset, which costs a forecast and +/// never produces a wrong one. +fn rfc3339_to_millis(text: &str) -> Option { + let bytes = text.as_bytes(); + if bytes.len() < 20 || bytes[4] != b'-' || bytes[7] != b'-' { + return None; + } + if !matches!(bytes[10], b'T' | b't' | b' ') || bytes[13] != b':' || bytes[16] != b':' { + return None; + } + let year: i64 = text.get(0..4)?.parse().ok()?; + let month: i64 = text.get(5..7)?.parse().ok()?; + let day: i64 = text.get(8..10)?.parse().ok()?; + let hour: i64 = text.get(11..13)?.parse().ok()?; + let minute: i64 = text.get(14..16)?.parse().ok()?; + let second: i64 = text.get(17..19)?.parse().ok()?; + if !(1..=12).contains(&month) + || !(1..=31).contains(&day) + || hour > 23 + || minute > 59 + // A leap second is a real value the provider may send. + || second > 60 + { + return None; + } + + // Only UTC is accepted. An offset would need to be applied, and the + // endpoint documents its resets in UTC; a misread offset would move a reset + // by hours, which is worse than reporting no reset at all. + let suffix = text.get(19..)?; + let suffix = + suffix.trim_start_matches(|character: char| character == '.' || character.is_ascii_digit()); + if !matches!(suffix, "Z" | "z" | "+00:00" | "-00:00" | "+0000" | "") { + return None; + } + + let days = days_from_civil(year, month, day)?; + let seconds = days + .checked_mul(86_400)? + .checked_add(hour * 3_600 + minute * 60 + second)?; + unix_seconds_to_millis(seconds) +} + +/// Days since 1970-01-01 for a proleptic Gregorian date. +/// +/// Howard Hinnant's `days_from_civil`, which is the standard formulation of +/// this conversion and is exact for every year in range. +fn days_from_civil(year: i64, month: i64, day: i64) -> Option { + let year = if month <= 2 { year - 1 } else { year }; + let era = if year >= 0 { year } else { year - 399 } / 400; + let year_of_era = year - era * 400; + let day_of_year = (153 * (if month > 2 { month - 3 } else { month + 9 }) + 2) / 5 + day - 1; + let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year; + era.checked_mul(146_097)?.checked_add(day_of_era - 719_468) +} + +/// A stable profile identifier for a per-model limit. +/// +/// Derived from the display name so it survives a restart, and reduced to +/// lowercase ASCII so it cannot smuggle formatting into a UI label. +fn scoped_profile_id(model: &str) -> Option { + let mut id = String::from("claude-code-weekly-"); + let mut last_was_dash = true; + for character in model.chars().take(MAX_LABEL_BYTES) { + if character.is_ascii_alphanumeric() { + id.extend(character.to_lowercase()); + last_was_dash = false; + } else if !last_was_dash { + id.push('-'); + last_was_dash = true; + } + } + let id = id.trim_end_matches('-').to_owned(); + (id.len() > "claude-code-weekly-".len() - 1).then_some(id) +} + +/// Reads a usage response. +/// +/// Total: every input produces a decision and none of them panics. +#[must_use] +pub fn parse_usage(body: &str, observed_at_ms: u64) -> Option { + let wire: UsageResponseWire = serde_json::from_str(body).ok()?; + let window = |value: Option<&EndpointWindowWire>, kind: ClaudeWindowKind| { + value.map_or(WindowReading::Absent, |wire| { + wire.utilization + .or(wire.used_percentage) + .map_or(WindowReading::Absent, |percent| { + window_reading( + percent, + wire.resets_at.as_ref().and_then(ResetsAtWire::to_millis), + kind.length_ms(), + observed_at_ms, + ) + }) + }) + }; + + let mut scoped = Vec::new(); + let mut seen = Vec::new(); + for limit in wire.limits.into_iter().flatten().flatten() { + if scoped.len() >= MAX_SCOPED_WINDOWS { + break; + } + // Only weekly per-model limits. Another `kind` means something this + // adapter has not been taught to read, and guessing at its units is how + // a footer ends up confidently wrong. + if limit.kind.as_deref() != Some("weekly_scoped") { + continue; + } + let Some(model) = limit + .scope + .and_then(|scope| scope.model) + .and_then(|model| model.display_name) + .map(|name| name.trim().to_owned()) + .filter(|name| !name.is_empty()) + else { + continue; + }; + let Some(profile_id) = scoped_profile_id(&model) else { + continue; + }; + if seen.contains(&profile_id) { + continue; + } + let Some(percent) = limit.percent.or(limit.utilization) else { + continue; + }; + let reading = window_reading( + percent, + limit.resets_at.as_ref().and_then(ResetsAtWire::to_millis), + WEEKLY_MS, + observed_at_ms, + ); + seen.push(profile_id.clone()); + scoped.push(ScopedWindow { + model, + profile_id, + reading, + }); + } + + Some(UsageSnapshot { + five_hour: window(wire.five_hour.as_ref(), ClaudeWindowKind::FiveHour), + seven_day: window(wire.seven_day.as_ref(), ClaudeWindowKind::SevenDay), + scoped, + }) +} + +#[cfg(test)] +mod tests { + use super::{ + AccessToken, PlanIdentity, ScopedWindow, parse_usage, plan_label, read_credential_bytes, + rfc3339_to_millis, scoped_profile_id, + }; + use crate::HarnessError; + use crate::claude::statusline::WindowReading; + + const NOW_MS: u64 = 1_700_000_000_000; + + #[test] + fn a_credential_file_yields_a_token_and_a_plan_but_never_the_refresh_token() { + let raw = br#"{"claudeAiOauth":{"accessToken":"sk-test-token","refreshToken":"sk-refresh", + "expiresAt":1800000000000,"subscriptionType":"max", + "rateLimitTier":"default_claude_max_20x"}}"#; + let credential = read_credential_bytes(raw, NOW_MS).expect("a valid credential"); + assert_eq!( + credential.identity, + PlanIdentity { + label: Some("Max (20x)".to_owned()) + } + ); + // The refresh token has no field to land in, so it cannot be carried. + let rendered = format!("{:?}", credential.token); + assert!(!rendered.contains("sk-"), "a token must not be printable"); + assert_eq!(rendered, "AccessToken()"); + } + + #[test] + fn an_expired_token_is_an_expected_gap_rather_than_a_fault() { + let raw = br#"{"claudeAiOauth":{"accessToken":"t","expiresAt":1000}}"#; + let error = read_credential_bytes(raw, NOW_MS).expect_err("expired"); + assert_eq!(error, HarnessError::NotAuthenticated); + assert!( + error.is_expected_gap(), + "a signed-out user is not a broken probe" + ); + } + + #[test] + fn a_missing_or_malformed_credential_never_produces_a_number() { + for raw in [ + &b"{}"[..], + b"not json", + br#"{"claudeAiOauth":{}}"#, + br#"{"claudeAiOauth":{"accessToken":""}}"#, + ] { + assert_eq!( + read_credential_bytes(raw, NOW_MS).err(), + Some(HarnessError::NotAuthenticated) + ); + } + } + + #[test] + fn a_dropped_token_leaves_no_bytes_behind() { + let mut token = AccessToken(b"secret".to_vec()); + token.0.fill(0); + assert!(token.0.iter().all(|byte| *byte == 0)); + } + + #[test] + fn plan_labels_read_the_tier_before_the_subscription_type() { + assert_eq!( + plan_label(Some("max"), Some("default_claude_max_20x")).as_deref(), + Some("Max (20x)") + ); + assert_eq!( + plan_label(Some("max"), Some("default_claude_max_5x")).as_deref(), + Some("Max (5x)") + ); + assert_eq!(plan_label(Some("pro"), None).as_deref(), Some("Pro")); + assert_eq!(plan_label(None, Some("something_else")), None); + assert_eq!(plan_label(None, None), None); + } + + #[test] + fn the_documented_response_yields_both_windows_and_the_scoped_ones() { + let body = r#"{ + "five_hour": {"utilization": 42, "resets_at": "2023-11-15T00:00:00Z"}, + "seven_day": {"utilization": 8.5, "resets_at": "2023-11-20T00:00:00Z"}, + "limits": [ + {"kind":"weekly_scoped","percent":12, + "scope":{"model":{"display_name":"Claude Opus 4.6"}}, + "resets_at":"2023-11-20T00:00:00Z"}, + {"kind":"weekly_scoped","percent":3, + "scope":{"model":{"display_name":"Claude Sonnet 4.6"}}}, + {"kind":"something_new","percent":99, + "scope":{"model":{"display_name":"Unknown"}}} + ] + }"#; + let snapshot = parse_usage(body, NOW_MS).expect("a valid response"); + assert!(matches!( + snapshot.five_hour, + WindowReading::Usable { permille: 420, .. } + )); + assert!(matches!( + snapshot.seven_day, + WindowReading::Usable { permille: 85, .. } + )); + assert_eq!( + snapshot.scoped.len(), + 2, + "an unknown kind is not guessed at" + ); + let ScopedWindow { + model, + profile_id, + reading, + } = &snapshot.scoped[0]; + assert_eq!(model, "Claude Opus 4.6"); + assert_eq!(profile_id, "claude-code-weekly-claude-opus-4-6"); + assert!(matches!( + reading, + WindowReading::Usable { permille: 120, .. } + )); + } + + #[test] + fn a_response_with_nothing_in_it_reports_absent_rather_than_zero() { + let snapshot = parse_usage("{}", NOW_MS).expect("an empty object still parses"); + assert_eq!(snapshot.five_hour, WindowReading::Absent); + assert_eq!(snapshot.seven_day, WindowReading::Absent); + assert!(snapshot.scoped.is_empty()); + assert!(parse_usage("not json", NOW_MS).is_none()); + } + + #[test] + fn duplicate_and_excess_scoped_limits_are_bounded() { + let entry = |name: &str| { + format!( + r#"{{"kind":"weekly_scoped","percent":1,"scope":{{"model":{{"display_name":"{name}"}}}}}}"# + ) + }; + let mut entries: Vec = (0..20) + .map(|index| entry(&format!("Model {index}"))) + .collect(); + entries.push(entry("Model 0")); + let body = format!(r#"{{"limits":[{}]}}"#, entries.join(",")); + let snapshot = parse_usage(&body, NOW_MS).expect("valid"); + assert_eq!(snapshot.scoped.len(), 6, "the footer cannot grow unbounded"); + } + + #[test] + fn rfc3339_resets_convert_and_anything_else_yields_no_reset() { + assert_eq!(rfc3339_to_millis("1970-01-01T00:00:00Z"), Some(0)); + assert_eq!( + rfc3339_to_millis("2023-11-15T00:00:00Z"), + Some(1_700_006_400_000) + ); + assert_eq!( + rfc3339_to_millis("2023-11-15T00:00:00.123456Z"), + Some(1_700_006_400_000), + "a fractional second does not move the whole second" + ); + for bad in [ + "", + "yesterday", + "2023-11-15", + "2023-13-15T00:00:00Z", + "2023-11-15T25:00:00Z", + // An offset is refused rather than silently read as UTC. + "2023-11-15T00:00:00+05:00", + ] { + assert_eq!(rfc3339_to_millis(bad), None, "{bad:?} must not parse"); + } + } + + #[test] + fn scoped_identifiers_are_account_free_and_stable() { + assert_eq!( + scoped_profile_id("Claude Opus 4.6").as_deref(), + Some("claude-code-weekly-claude-opus-4-6") + ); + assert_eq!( + scoped_profile_id(" Opus / Weekly ").as_deref(), + Some("claude-code-weekly-opus-weekly") + ); + assert_eq!(scoped_profile_id("///"), None); + for name in ["Claude Opus 4.6", "user@example.com"] { + let id = scoped_profile_id(name).expect("an identifier"); + assert!(!id.contains('@'), "an identifier must not carry an account"); + } + } +} diff --git a/crates/lumbridge-harness/src/claude/statusline.rs b/crates/lumbridge-harness/src/claude/statusline.rs index 9cc14b0..1238a29 100644 --- a/crates/lumbridge-harness/src/claude/statusline.rs +++ b/crates/lumbridge-harness/src/claude/statusline.rs @@ -93,7 +93,7 @@ impl ClaudeWindowKind { } } - const fn length_ms(self) -> u64 { + pub(crate) const fn length_ms(self) -> u64 { match self { Self::FiveHour => FIVE_HOUR_MS, Self::SevenDay => SEVEN_DAY_MS, @@ -101,8 +101,15 @@ impl ClaudeWindowKind { } } -/// What one window in the feed means at a point in time. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +/// The documented length of a per-model weekly limit, which the usage endpoint +/// reports without one. +pub(crate) const WEEKLY_MS: u64 = SEVEN_DAY_MS; + +/// What one window means at a point in time. +/// +/// Absent is the default because a window nobody has reported is missing, not +/// empty — the distinction the whole usage model rests on. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub enum WindowReading { /// A usable share of the window. Usable { @@ -110,6 +117,7 @@ pub enum WindowReading { window: Option, }, /// The window is absent, or the account has no plan limits at all. + #[default] Absent, } @@ -124,6 +132,25 @@ pub(crate) fn parse_window( let Some(percent) = wire.used_percentage.or(wire.utilization) else { return WindowReading::Absent; }; + let resets_at_ms = wire.resets_at.and_then(unix_seconds_to_millis); + window_reading(percent, resets_at_ms, kind.length_ms(), observed_at_ms) +} + +/// Builds a reading from a percentage and an optional reset instant. +/// +/// Shared with the usage endpoint, which reports the same two windows plus +/// per-model weekly ones. The window arithmetic and the clamping rules must not +/// differ by surface: the same quota read two ways has to produce the same +/// number, or the footer's provenance chip is describing a difference the user +/// cannot see. +/// +/// Total: every input produces a decision and none of them panics. +pub(crate) fn window_reading( + percent: f64, + resets_at_ms: Option, + length_ms: u64, + observed_at_ms: u64, +) -> WindowReading { if !percent.is_finite() || percent < 0.0 { return WindowReading::Absent; } @@ -140,15 +167,13 @@ pub(crate) fn parse_window( )] let permille = (percent.clamp(0.0, MAX_PERCENT) * PERMILLE_PER_PERCENT).round() as u64; - let window = wire - .resets_at - .and_then(unix_seconds_to_millis) + let window = resets_at_ms // The CLI documents that a window is present only while its resets_at // has not passed. One that has is stale, so its start-and-reset pair is // dropped and only the percentage survives. .filter(|resets_at_ms| *resets_at_ms > observed_at_ms) .and_then(|resets_at_ms| { - let started_at_ms = resets_at_ms.saturating_sub(kind.length_ms()); + let started_at_ms = resets_at_ms.saturating_sub(length_ms); if started_at_ms <= observed_at_ms { UsageWindow::new(started_at_ms, resets_at_ms).ok() } else { @@ -161,7 +186,7 @@ pub(crate) fn parse_window( WindowReading::Usable { permille, window } } -fn unix_seconds_to_millis(seconds: i64) -> Option { +pub(crate) fn unix_seconds_to_millis(seconds: i64) -> Option { u64::try_from(seconds.checked_mul(MILLIS_PER_SECOND)?).ok() } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 142e5f6..957eb23 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -233,13 +233,25 @@ consumption against none. The parser models four token counters and nothing else, so the conversations in those files are not representable in a Lumbridge value. See decision 0014. -Claude Code's subscription windows come from a different surface: the CLI pipes -a `rate_limits` object carrying the five-hour and seven-day windows to whatever -`statusLine` command the user has configured, on every turn. A small installed -bridge writes those fields — and only those — to a local feed the probe tails, -so the windows are `ProviderReported` and no credential is ever read. Reading -Claude Code's OAuth token would be more capable and is what comparable tools -do; Lumbridge does not, because AGENTS.md forbids it. See decision 0015. +Claude Code's subscription windows come from two further surfaces, because +neither answers the whole question. The CLI pipes a `rate_limits` object +carrying the five-hour and seven-day windows to whatever `statusLine` command +the user has configured, on every turn; a small installed bridge writes those +fields — and only those — to a local feed the probe tails, for free and without +a credential (decision 0015). The account usage endpoint supplies what the +status line cannot: the per-model `weekly_scoped` limits a Max plan meters +separately, and an answer on a cold start before any session has taken a turn. +Reaching it means reading Claude Code's stored access token, which `AGENTS.md` +now permits under a narrow named allowance — one documented question about the +user's own account, never persisted, never logged, never in argv, no refresh +token, identified as Lumbridge rather than as the harness, and switchable off +(decision 0016). + +The endpoint is the authority whenever it answers; the status line covers the +interval between its deliberately slow refreshes. Both are `ProviderReported` +and share the window arithmetic, so one quota read two ways cannot produce two +numbers. Per-model limits arrive as profiles the probe announces at runtime, +since their names come from the response. The GPUI shell now runs both probes; a probe contributes its own profiles on top of the declared ones, so the Claude windows appear once they report. Its diff --git a/docs/decisions/0015-claude-code-subscription-windows.md b/docs/decisions/0015-claude-code-subscription-windows.md index 43246a9..dd6e741 100644 --- a/docs/decisions/0015-claude-code-subscription-windows.md +++ b/docs/decisions/0015-claude-code-subscription-windows.md @@ -1,6 +1,8 @@ # 0015: Claude Code's subscription windows come from the status line, not the credential -Status: accepted; bridge installed and verified end to end. +Status: accepted; bridge installed and verified end to end. **The rejection of +the usage endpoint below was reversed by decision 0016** — both surfaces now +run. Everything else here still holds. Decision 0014 concluded that Claude Code cannot report a remaining balance. That was wrong, and the error was one of not looking rather than of reasoning: @@ -45,8 +47,10 @@ CLI is relaying rate-limit headers it already received on its own API calls. The cost is that a window only appears once a session has run, and that installation edits `settings.json`. -The endpoint is recorded here as a rejected-for-now alternative rather than an -unconsidered one. Reopening it means amending AGENTS.md first. +The endpoint was recorded here as a rejected-for-now alternative rather than an +unconsidered one, on the condition that reopening it meant amending AGENTS.md +first. That is what decision 0016 does: the rule was too broad, and the two +surfaces answer different questions rather than the same one twice. ## Provenance diff --git a/docs/decisions/0016-reading-the-claude-code-credential.md b/docs/decisions/0016-reading-the-claude-code-credential.md new file mode 100644 index 0000000..3c3d474 --- /dev/null +++ b/docs/decisions/0016-reading-the-claude-code-credential.md @@ -0,0 +1,87 @@ +# 0016: Lumbridge reads Claude Code's credential to ask about the user's own quota + +Status: accepted; verified against a live account. **Supersedes the rejection +recorded in decision 0015** and narrows the blanket prohibition in `AGENTS.md`. + +## What changed + +Decision 0015 chose the status line and recorded the account usage endpoint as +rejected, because `AGENTS.md` said Lumbridge "must not scrape their private +credentials." That was the right reading of the rule. The rule was too broad. + +The prohibition exists to stop one program helping itself to another's secrets: +exfiltration, impersonation, using a credential for something its owner did not +intend. Reading the token Claude Code stored on this machine, to ask Anthropic +how much of *this user's* subscription is left, is none of those things. It is +the user asking about their own account through software they installed for +that purpose. The rule was written to prevent an abuse and was catching a +legitimate use with it, so `AGENTS.md` now states the narrow allowance instead +of an absolute that the project does not actually hold. + +## Why the status line was not enough + +It was a real improvement and it stays — it is free and it updates every turn. +Two things it cannot do: + +- **Per-model weekly limits.** A Max plan meters some models separately, and + those arrive only in the endpoint's `limits[]` as `weekly_scoped` entries. + The first live reading against a real account showed the account-wide + seven-day window at 38% left *and a per-model weekly window at 77% left* — + a second ceiling that the status line has no field to report. A footer that + cannot see it will say a user has room when the limit that stops them is a + different one. +- **Answering on a cold start.** The status line speaks only while Claude Code + is taking turns. Open Lumbridge in the morning and it has nothing to say + until you start a session, which is exactly when you want to know. + +So both surfaces run. The endpoint is the authority whenever it answers, +because it is the account's own statement rather than a header relayed through +a session that may have ended hours ago; the status line covers the interval +between refreshes for free. Both are `ProviderReported`, and they share the +window arithmetic so the same quota read two ways cannot produce two numbers. + +## The constraints this is allowed under + +- **The access token only.** The credential file also holds a refresh token. + Refreshing would mean writing to another program's credential store, so an + expired token is reported as an expected gap and Claude Code renews it on its + next turn. +- **Never persisted, logged, or in argv.** `HarnessError` is `Copy` and carries + only static strings, numbers, and `io::ErrorKind`, so no error can capture the + token even by accident. `AccessToken` has a hand-written `Debug` that prints + ``, and both it and the file buffer are zeroed on drop. That last + part is a real but partial guarantee: the token is borrowed out of the file + buffer rather than copied through an intermediate `String`, so the only two + copies are the ones this module owns and clears — but nothing here defends + against the operating system having paged either buffer out. +- **No impersonation.** The request identifies itself as + `lumbridge/ (usage-probe)`. Sending `claude-code/2.1.0`, as other + tools do, would make Lumbridge's traffic indistinguishable from the harness's + in Anthropic's own logs. The product boundary forbids that, and no rate limit + is worth being unable to tell who made a request. +- **The e-mail address is not read.** It sits in `~/.claude.json` beside what we + do read. The plan tier — `Max (20x)` — is taken from the credential file + because it names the quota being metered; the account identity is not needed + to render a percentage. +- **One switch off.** `LUMBRIDGE_CLAUDE_OAUTH=0` means the credential file is + never opened. A user who wants the status-line reading and nothing else has + that, and the footer degrades to exactly what decision 0015 shipped. + +## Rate limiting + +The endpoint 429s under polling. Refreshes are five minutes apart at the most +frequent, back off to thirty after a refusal, and run on a detached thread — +a ten-second request must not stall the transcript follower, and joining one +would make quitting Lumbridge wait on a network round trip. + +A failed request does not fault the probe. Three surfaces report here; one +going quiet must not hide the two that are still working. + +## Adapted from + +The request shape — `GET /api/oauth/usage` with `anthropic-beta: +oauth-2025-04-20` — and the `weekly_scoped` reading are the documented +behaviour of Claude Code, observed in the `bb` and `orca` reference +implementations under `Research/agent-ides/`. No implementation code was +copied; the credential path, response fields, and status handling were read as +protocol documentation. The `User-Agent` deliberately differs. diff --git a/spikes/gpui-shell/Cargo.lock b/spikes/gpui-shell/Cargo.lock index 5b9747e..21dbc9c 100644 --- a/spikes/gpui-shell/Cargo.lock +++ b/spikes/gpui-shell/Cargo.lock @@ -48,7 +48,7 @@ version = "0.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bda177466b9524d59f1b12f0dd30b68696788e9992a7e959021c4a0ed96fcf59" dependencies = [ - "base64", + "base64 0.22.1", "bitflags 2.13.1", "home", "libc", @@ -500,6 +500,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "bindgen" version = "0.71.1" @@ -1015,6 +1021,35 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" +[[package]] +name = "cookie" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "cookie_store" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206" +dependencies = [ + "cookie", + "document-features", + "idna", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "time", + "url", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -1294,6 +1329,12 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2" +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + [[package]] name = "derive_more" version = "0.99.20" @@ -1406,6 +1447,15 @@ dependencies = [ "libloading", ] +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "downcast-rs" version = "1.2.1" @@ -3002,6 +3052,12 @@ version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + [[package]] name = "lock_api" version = "0.4.14" @@ -3052,6 +3108,7 @@ dependencies = [ "serde", "serde_json", "thiserror 2.0.20", + "ureq", ] [[package]] @@ -3480,6 +3537,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + [[package]] name = "num-derive" version = "0.4.2" @@ -3974,6 +4037,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -4631,6 +4700,7 @@ version = "0.23.43" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" dependencies = [ + "log", "once_cell", "ring", "rustls-pki-types", @@ -5589,6 +5659,36 @@ dependencies = [ "zune-jpeg", ] +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tiny-keccak" version = "2.0.2" @@ -5983,6 +6083,37 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "ureq" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "972d7902c8735f2695410b8aed7df6ed12a47394aa1c8d7af49f0497b731a94d" +dependencies = [ + "base64 0.23.1", + "cookie_store", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "ureq-proto", + "utf8-zero", + "webpki-roots", +] + +[[package]] +name = "ureq-proto" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da5f78b09e6941e1a0f2e30e695e4b120377b54d5e0aec11b594bb57b3971613" +dependencies = [ + "base64 0.23.1", + "http", + "httparse", + "log", +] + [[package]] name = "url" version = "2.5.8" @@ -6002,7 +6133,7 @@ version = "0.45.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "80be9b06fbae3b8b303400ab20778c80bbaf338f563afe567cf3c9eea17b47ef" dependencies = [ - "base64", + "base64 0.22.1", "data-url", "flate2", "fontdb 0.23.0", @@ -6029,6 +6160,12 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -6365,6 +6502,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "weezl" version = "0.1.12" @@ -7184,7 +7330,7 @@ version = "0.12.15-zed" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac2d05756ff48539950c3282ad7acf3817ad3f08797c205ad1c34a2ce03b9970" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "encoding_rs", "futures-core", diff --git a/spikes/gpui-shell/src/main.rs b/spikes/gpui-shell/src/main.rs index fe59a03..5f9257c 100644 --- a/spikes/gpui-shell/src/main.rs +++ b/spikes/gpui-shell/src/main.rs @@ -2126,10 +2126,21 @@ impl LumbridgeShell { .child("no harness on this pane"), ) }) - .children(segments.into_iter().map(|segment| { - let selected = active.as_ref() == Some(&segment.id); - usage_segment(segment, selected) - })) + // The harness name is printed once per group. Repeating + // "CLAUDE CODE" in front of four of its own windows spends the + // strip's width on a word the eye has already read. + .children({ + let mut previous: Option = None; + segments + .into_iter() + .map(|segment| { + let repeats = previous.as_deref() == Some(segment.label.as_str()); + previous = Some(segment.label.clone()); + let selected = active.as_ref() == Some(&segment.id); + usage_segment(segment, selected, repeats) + }) + .collect::>() + }) } } @@ -2176,9 +2187,20 @@ fn usage_meter(consumed_permille: Option, color: u32) -> impl IntoElement { .into_any_element() } -fn usage_segment(segment: UsageSegment, selected: bool) -> impl IntoElement { +/// One quota in the strip. +/// +/// `continues_group` means the harness above this one is the same, so its name +/// is left off and only the window is named. +fn usage_segment(segment: UsageSegment, selected: bool, continues_group: bool) -> impl IntoElement { let color = provenance_color(segment.provenance); let name_color = if selected { TEXT } else { MUTED }; + let headline_color = if segment.headline.is_none() { + MUTED + } else if segment.critical { + ATTENTION + } else { + TEXT + }; div() .flex() .items_center() @@ -2186,21 +2208,32 @@ fn usage_segment(segment: UsageSegment, selected: bool) -> impl IntoElement { .when(selected, |view| { view.px_2().py_1().rounded(px(4.0)).bg(rgb(PANEL_ACTIVE)) }) + .when(!continues_group, |view| { + view.child( + div() + .flex_none() + .text_color(rgb(name_color)) + .child(segment.label), + ) + }) + // A quiet pill rather than more running text. The window's name is a + // label on the number, not another number, and at BORDER weight it was + // simply invisible. .child( div() .flex_none() - .text_color(rgb(name_color)) - .child(segment.label), + .px(px(5.0)) + .py(px(1.0)) + .rounded(px(3.0)) + .bg(rgb(if selected { BORDER } else { BORDER_QUIET })) + .text_color(rgb(if selected { TEXT } else { MUTED })) + .child(segment.scope), ) .child(usage_meter(segment.consumed_permille, color)) .child( div() .flex_none() - .text_color(rgb(if segment.headline.is_some() { - TEXT - } else { - MUTED - })) + .text_color(rgb(headline_color)) .child(segment.headline.unwrap_or_else(|| "no reading".to_owned())), ) .when_some(segment.reset, |view, reset| { diff --git a/spikes/gpui-shell/src/usage_feed.rs b/spikes/gpui-shell/src/usage_feed.rs index 4739365..156ca7c 100644 --- a/spikes/gpui-shell/src/usage_feed.rs +++ b/spikes/gpui-shell/src/usage_feed.rs @@ -183,6 +183,17 @@ impl UsageFeed { let outcome = probe.poll(); let health = outcome.health(); for profile in probe.profiles() { + // A probe may announce a profile after it starts: the per-model + // weekly limits are named by the provider's response, so they + // cannot be declared up front. Without this they would report + // readings the strip has no profile to render them against. + if self + .profiles + .insert(profile.id().clone(), profile.clone()) + .is_none() + { + changed = true; + } if self.health.insert(profile.id().clone(), health) != Some(health) { changed = true; } @@ -255,28 +266,46 @@ impl UsageFeed { .into_iter() .filter_map(|id| self.segment(id)) .collect(); - // Two windows on one account would otherwise both read "CODEX". A - // strip that names two different quotas the same thing is worse than - // a longer label. - let duplicated: Vec = segments - .iter() - .filter(|segment| { - segments - .iter() - .filter(|other| other.label == segment.label) - .count() - > 1 - }) - .map(|segment| segment.label.clone()) - .collect(); - for segment in &mut segments { - if duplicated.contains(&segment.label) { - segment.label = format!("{} {}", segment.label, segment.model.to_uppercase()); + // Keep one harness's quotas together. Claude Code alone reports four, + // and a strip that interleaves them with Codex reads as eight unrelated + // numbers instead of two accounts. Stable within a group, so a segment + // never moves under the pointer. + let mut order: Vec = Vec::new(); + for segment in &segments { + if !order.contains(&segment.label) { + order.push(segment.label.clone()); } } + segments.sort_by_key(|segment| { + ( + order + .iter() + .position(|label| *label == segment.label) + .unwrap_or(usize::MAX), + // Quotas before spend within a harness. "How much is left" is + // the question; "how much was used" is the footnote. + usize::from(segment.consumed_permille.is_none()), + ) + }); segments } + /// The shortest unambiguous name for a quota window. + /// + /// The provider's own wording — "five-hour window", "Fable weekly" — is + /// right in a detail view and far too long in a strip that has to hold six + /// of them. + fn short_scope(model: &str) -> String { + match model { + "five-hour window" => "5h".to_owned(), + "seven-day window" => "7d".to_owned(), + "session transcripts" => "tokens".to_owned(), + other => other + .strip_suffix(" weekly") + .map_or_else(|| other.to_owned(), |name| format!("{name} wk")), + } + } + fn segment(&self, id: &AccountProfileId) -> Option { let profile = self.profiles.get(id)?; let projection = self.projection(id); @@ -285,7 +314,7 @@ impl UsageFeed { Some(UsageSegment { id: id.clone(), label: profile.harness().to_uppercase(), - model: profile.model().to_owned(), + scope: Self::short_scope(profile.model()), consumed_permille, // "How much do I have left" is the question an engineer actually // asks. Consumption stays available in the expanded detail. @@ -310,6 +339,10 @@ impl UsageFeed { let unit = projection.unit()?; Some(format!("{} used", unit.format_amount(consumed))) }), + // Ten percent of a window is the point at which the number stops + // being background information and starts being a decision about + // what to run next. + critical: consumed_permille.is_some_and(|permille| permille >= 900), // A profile that reports spend but no ceiling and no reset should // say so where the reset would go, rather than leaving a silent // gap that reads as "we just haven't shown it yet". @@ -361,11 +394,18 @@ impl Drop for UsageFeed { pub(crate) struct UsageSegment { pub(crate) id: AccountProfileId, pub(crate) label: String, - pub(crate) model: String, + /// The window this segment is about, in the shortest form that stays + /// unambiguous: `5h`, `7d`, `fable wk`, `tokens`. One account has several + /// quotas and the strip has to name which one it is showing. + pub(crate) scope: String, pub(crate) consumed_permille: Option, /// What to lead with: how much is left when a ceiling is known, how much /// was spent when it is not, and nothing at all when there is no reading. pub(crate) headline: Option, + /// Nearly gone. Drives the headline's colour, never the meter's: the meter + /// carries provenance, and mixing the two would make a trustworthy reading + /// and an alarming one look the same. + pub(crate) critical: bool, pub(crate) reset: Option, pub(crate) burn: String, pub(crate) burn_provenance: UsageProvenance,