Read Claude Code's own quota endpoint, not just its status line

Decision 0015 rejected the account usage endpoint because AGENTS.md forbade
reading a harness's credential. The rule was written to stop one program
helping itself to another's secrets, and it was catching a legitimate use with
it: the user asking about their own subscription, through software they
installed to do that. AGENTS.md now states the narrow allowance instead of an
absolute the project does not hold, and 0016 records it.

The status line stays. It is free and it speaks every turn. What it cannot do
is report the per-model weekly limits a Max plan meters separately, or answer
at all before a session has taken a turn. The first live reading found the
account-wide seven-day window at 38% left and a per-model weekly window at 77%
left — a second ceiling the footer previously could not see.

Constraints the credential is read under, all enforced in code: access token
only, never the refresh token; zeroed on drop, along with the file buffer it
was borrowed out of; unprintable by construction, since HarnessError carries no
owned strings and AccessToken's Debug is hand-written; identified as
lumbridge/<version>, because sending claude-code/2.1.0 would make our traffic
indistinguishable from the harness's in Anthropic's logs; and off entirely
under LUMBRIDGE_CLAUDE_OAUTH=0.

The request runs on a detached thread with a slow refresh and a 429 backoff, so
a ten-second round trip cannot stall the transcript follower or make quitting
wait on the network, and one surface failing does not fault the other two.

Footer polish on top: the harness name prints once per group instead of in
front of each of its four windows, each quota carries a short scope pill
(5h, 7d, Fable wk, tokens) where an invisible BORDER-weight label used to be,
quotas sort ahead of spend, and a window under ten percent turns its headline
amber — value colour on the number, provenance colour on the meter, never
mixed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Metal Agent
2026-08-31 22:04:34 -07:00
co-authored by Claude Opus 5
parent ef52aa7ce2
commit 219c674aea
13 changed files with 1674 additions and 73 deletions
+12 -1
View File
@@ -5,9 +5,20 @@ These rules apply to the entire Lumbridge repository.
## Product boundary ## Product boundary
Lumbridge is a terminal/workspace runtime and ACP client. It may host, supervise, 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. 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 ## Research boundary
Upstream repositories are cloned outside this Git repository under the desktop Upstream repositories are cloned outside this Git repository under the desktop
Generated
+256 -3
View File
@@ -27,7 +27,7 @@ version = "0.26.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bda177466b9524d59f1b12f0dd30b68696788e9992a7e959021c4a0ed96fcf59" checksum = "bda177466b9524d59f1b12f0dd30b68696788e9992a7e959021c4a0ed96fcf59"
dependencies = [ dependencies = [
"base64", "base64 0.22.1",
"bitflags 2.13.1", "bitflags 2.13.1",
"home", "home",
"libc", "libc",
@@ -84,6 +84,12 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "base64"
version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
[[package]] [[package]]
name = "base64ct" name = "base64ct"
version = "1.8.3" version = "1.8.3"
@@ -194,7 +200,7 @@ name = "buzz-core"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/block/buzz.git?rev=cb3144999bebc4939cb15b2200b373281d493b52#cb3144999bebc4939cb15b2200b373281d493b52" source = "git+https://github.com/block/buzz.git?rev=cb3144999bebc4939cb15b2200b373281d493b52#cb3144999bebc4939cb15b2200b373281d493b52"
dependencies = [ dependencies = [
"base64", "base64 0.22.1",
"chrono", "chrono",
"hex", "hex",
"hmac 0.13.0", "hmac 0.13.0",
@@ -224,6 +230,12 @@ dependencies = [
"uuid", "uuid",
] ]
[[package]]
name = "bytes"
version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]] [[package]]
name = "cbc" name = "cbc"
version = "0.1.2" version = "0.1.2"
@@ -336,6 +348,35 @@ version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" 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]] [[package]]
name = "core-foundation-sys" name = "core-foundation-sys"
version = "0.8.7" version = "0.8.7"
@@ -401,6 +442,12 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
[[package]]
name = "deranged"
version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
[[package]] [[package]]
name = "digest" name = "digest"
version = "0.10.7" version = "0.10.7"
@@ -435,12 +482,27 @@ dependencies = [
"syn 3.0.4", "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]] [[package]]
name = "downcast-rs" name = "downcast-rs"
version = "1.2.1" version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]] [[package]]
name = "errno" name = "errno"
version = "0.3.14" version = "0.3.14"
@@ -560,6 +622,12 @@ dependencies = [
"rand_core 0.10.1", "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]] [[package]]
name = "hermit-abi" name = "hermit-abi"
version = "0.5.3" version = "0.5.3"
@@ -617,6 +685,22 @@ dependencies = [
"windows-sys 0.61.2", "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]] [[package]]
name = "hybrid-array" name = "hybrid-array"
version = "0.4.14" version = "0.4.14"
@@ -754,6 +838,16 @@ dependencies = [
"icu_properties", "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]] [[package]]
name = "inout" name = "inout"
version = "0.1.4" version = "0.1.4"
@@ -828,6 +922,12 @@ version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
[[package]]
name = "litrs"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
[[package]] [[package]]
name = "lock_api" name = "lock_api"
version = "0.4.14" version = "0.4.14"
@@ -876,6 +976,7 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"thiserror 2.0.20", "thiserror 2.0.20",
"ureq",
] ]
[[package]] [[package]]
@@ -945,7 +1046,7 @@ version = "0.44.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40ff7b77ef428b40aa2834a6acbae38a0e104c98b306208ca4b87a420d579a4b" checksum = "40ff7b77ef428b40aa2834a6acbae38a0e104c98b306208ca4b87a420d579a4b"
dependencies = [ dependencies = [
"base64", "base64 0.22.1",
"bech32", "bech32",
"bip39", "bip39",
"bitcoin_hashes", "bitcoin_hashes",
@@ -963,6 +1064,12 @@ dependencies = [
"url", "url",
] ]
[[package]]
name = "num-conv"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
[[package]] [[package]]
name = "num-traits" name = "num-traits"
version = "0.2.19" version = "0.2.19"
@@ -1112,6 +1219,12 @@ dependencies = [
"zerovec", "zerovec",
] ]
[[package]]
name = "powerfmt"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]] [[package]]
name = "ppv-lite86" name = "ppv-lite86"
version = "0.2.21" version = "0.2.21"
@@ -1218,6 +1331,20 @@ version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" 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]] [[package]]
name = "rusqlite" name = "rusqlite"
version = "0.40.2" version = "0.40.2"
@@ -1255,6 +1382,41 @@ dependencies = [
"rustix", "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]] [[package]]
name = "rustversion" name = "rustversion"
version = "1.0.23" version = "1.0.23"
@@ -1523,6 +1685,36 @@ dependencies = [
"syn 3.0.4", "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]] [[package]]
name = "tinystr" name = "tinystr"
version = "0.8.4" version = "0.8.4"
@@ -1585,6 +1777,43 @@ dependencies = [
"subtle", "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]] [[package]]
name = "url" name = "url"
version = "2.5.8" version = "2.5.8"
@@ -1598,6 +1827,12 @@ dependencies = [
"serde_derive", "serde_derive",
] ]
[[package]]
name = "utf8-zero"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e"
[[package]] [[package]]
name = "utf8_iter" name = "utf8_iter"
version = "1.0.4" version = "1.0.4"
@@ -1702,6 +1937,15 @@ dependencies = [
"wasm-bindgen", "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]] [[package]]
name = "winapi" name = "winapi"
version = "0.3.9" version = "0.3.9"
@@ -1783,6 +2027,15 @@ dependencies = [
"windows-link", "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]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.59.0" version = "0.59.0"
+1
View File
@@ -12,6 +12,7 @@ lumbridge-core = { path = "../lumbridge-core" }
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
thiserror = "2.0" thiserror = "2.0"
ureq = { version = "3.4.0", default-features = false, features = ["rustls", "json"] }
[target.'cfg(unix)'.dependencies] [target.'cfg(unix)'.dependencies]
nix = { version = "0.28", features = ["process", "signal"] } nix = { version = "0.28", features = ["process", "signal"] }
@@ -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"),
}
}
+220 -17
View File
@@ -1,14 +1,23 @@
//! The Claude Code usage adapter, over two different surfaces. //! The Claude Code usage adapter, over two different surfaces.
//! //!
//! **Subscription windows** ([`statusline`]) are the real quota. Claude Code //! **Subscription windows** come from two surfaces that answer the same
//! 2.1.80 and later pipe a `rate_limits` object carrying `five_hour` and //! question, because neither is sufficient alone.
//! `seven_day` — each with `used_percentage` and `resets_at` — to the //!
//! configured `statusLine` command on every turn. Those are rate-limit headers //! [`statusline`] is the free continuous one. Claude Code 2.1.80 and later pipe
//! the CLI already received on its own API responses, so reading them costs //! a `rate_limits` object carrying `five_hour` and `seven_day` — each with
//! nothing and they are `ProviderReported`. Lumbridge does not call the //! `used_percentage` and `resets_at` — to the configured `statusLine` command on
//! account's usage endpoint for them: that would mean reading Claude Code's //! every turn. Those are rate-limit headers the CLI already received on its own
//! OAuth credential, which AGENTS.md forbids. The status line is pushed to a //! API responses, so reading them costs nothing. They only arrive while a
//! command the user installs, so no credential is ever touched. //! 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 //! **Token consumption** ([`transcript`]) comes from the session transcripts
//! Claude Code writes under `~/.claude/projects`. Those record the API's //! 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 //! was appended since, which is what makes the token total monotonic — the
//! ledger rejects an observation that moves a profile backwards. //! ledger rejects an observation that moves a profile backwards.
pub mod oauth;
mod statusline; mod statusline;
mod transcript; mod transcript;
@@ -36,6 +46,7 @@ use std::time::Duration;
use lumbridge_core::{AccountProfile, UsageObservation, UsageProvenance, UsageUnit}; use lumbridge_core::{AccountProfile, UsageObservation, UsageProvenance, UsageUnit};
use crate::HarnessError; use crate::HarnessError;
use crate::claude::oauth::{ClaudeOauthOptions, PlanIdentity, RefreshOutcome};
use crate::claude::statusline::parse_feed_line; use crate::claude::statusline::parse_feed_line;
pub use crate::claude::statusline::{ClaudeWindowKind, WindowReading}; pub use crate::claude::statusline::{ClaudeWindowKind, WindowReading};
use crate::claude::transcript::MAX_RECORD_BYTES; 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 /// Absent until the user installs the bridge, which is why the windows read
/// as unavailable rather than as zero before then. /// as unavailable rather than as zero before then.
pub rate_limit_feed: PathBuf, 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, pub poll_interval: Duration,
} }
@@ -75,6 +89,7 @@ impl Default for ClaudeCodeProbeOptions {
Self { Self {
projects_root: default_projects_root(), projects_root: default_projects_root(),
rate_limit_feed: default_rate_limit_feed(), rate_limit_feed: default_rate_limit_feed(),
oauth: ClaudeOauthOptions::default(),
poll_interval: Duration::from_secs(10), poll_interval: Duration::from_secs(10),
} }
} }
@@ -121,6 +136,9 @@ pub fn default_projects_root() -> PathBuf {
enum WorkerEvent { enum WorkerEvent {
Observation(UsageObservation), Observation(UsageObservation),
Health(ProbeHealth), 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<AccountProfile>),
} }
/// A running Claude Code usage probe. /// A running Claude Code usage probe.
@@ -134,13 +152,19 @@ pub struct ClaudeCodeProbe {
} }
impl ClaudeCodeProbe { impl ClaudeCodeProbe {
/// The static, account-free profiles this probe reports under: one per /// The account-free profiles this probe reports under: one per documented
/// documented subscription window, plus the transcript token total. /// subscription window, plus the transcript token total.
/// ///
/// The windows come first because they are the quota — what a user means /// 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 /// by "how much do I have left" — and the token total is the supporting
/// fact about spend. /// fact about spend.
fn profiles_for() -> Result<Vec<AccountProfile>, 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<Vec<AccountProfile>, HarnessError> {
let account = plan.label.as_deref().unwrap_or("subscription");
let mut profiles = Vec::new(); let mut profiles = Vec::new();
for kind in ClaudeWindowKind::ALL { for kind in ClaudeWindowKind::ALL {
profiles.push( profiles.push(
@@ -149,7 +173,7 @@ impl ClaudeCodeProbe {
"Claude Code", "Claude Code",
"Anthropic", "Anthropic",
kind.scope(), kind.scope(),
"subscription", account,
) )
.map_err(|_| HarnessError::EmptyProgram)?, .map_err(|_| HarnessError::EmptyProgram)?,
); );
@@ -160,7 +184,7 @@ impl ClaudeCodeProbe {
"Claude Code", "Claude Code",
"Anthropic", "Anthropic",
"session transcripts", "session transcripts",
"subscription", account,
) )
.map_err(|_| HarnessError::EmptyProgram)?, .map_err(|_| HarnessError::EmptyProgram)?,
); );
@@ -179,7 +203,8 @@ impl ClaudeCodeProbe {
if options.poll_interval < MIN_POLL_INTERVAL { if options.poll_interval < MIN_POLL_INTERVAL {
return Err(HarnessError::ProbeIntervalTooShort); 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 worker_profiles = profiles.clone();
let (event_sender, events) = mpsc::sync_channel(EVENT_QUEUE); let (event_sender, events) = mpsc::sync_channel(EVENT_QUEUE);
let stop = Arc::new(AtomicBool::new(false)); let stop = Arc::new(AtomicBool::new(false));
@@ -221,6 +246,11 @@ impl UsageProbe for ClaudeCodeProbe {
match events.try_recv() { match events.try_recv() {
Ok(WorkerEvent::Observation(observation)) => observations.push(observation), Ok(WorkerEvent::Observation(observation)) => observations.push(observation),
Ok(WorkerEvent::Health(health)) => self.health = health, 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::Empty) => break,
Err(TryRecvError::Disconnected) => { Err(TryRecvError::Disconnected) => {
if !self.health.is_faulted() { if !self.health.is_faulted() {
@@ -264,6 +294,9 @@ struct FollowState {
/// Read position in the status-line feed, and the newest reading seen. /// Read position in the status-line feed, and the newest reading seen.
feed_offset: u64, feed_offset: u64,
windows: BTreeMap<&'static str, WindowReading>, 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<String, (String, WindowReading)>,
} }
/// Reads whatever the status-line bridge appended and keeps the newest line. /// 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<AtomicBool>,
sender: mpsc::Sender<RefreshOutcome>,
results: Receiver<RefreshOutcome>,
}
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<String, WindowReading>,
declared: Vec<String>,
}
/// 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<WorkerEvent>,
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( fn run_worker(
options: &ClaudeCodeProbeOptions, options: &ClaudeCodeProbeOptions,
profiles: &[AccountProfile], profiles: &[AccountProfile],
@@ -334,7 +515,18 @@ fn run_worker(
let mut last_poll_ms = 0; let mut last_poll_ms = 0;
let mut last_emitted = TokenTally::default(); let mut last_emitted = TokenTally::default();
let mut last_windows: BTreeMap<&'static str, WindowReading> = BTreeMap::new(); 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 primed = false;
let mut endpoint = UsageEndpoint::new(&options.oauth);
loop { loop {
if stop.load(Ordering::Relaxed) { if stop.load(Ordering::Relaxed) {
@@ -354,6 +546,13 @@ fn run_worker(
// status-line reading is a complete snapshot, so it can be reported // status-line reading is a complete snapshot, so it can be reported
// immediately rather than waiting for priming. // immediately rather than waiting for priming.
follow_feed(&options.rate_limit_feed, &mut state, now_ms); 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 { for kind in ClaudeWindowKind::ALL {
let Some(reading) = state.windows.get(kind.profile_id()).copied() else { let Some(reading) = state.windows.get(kind.profile_id()).copied() else {
continue; continue;
@@ -554,8 +753,8 @@ fn follow(path: &Path, state: &mut FollowState, budget: &mut usize) -> bool {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{ use super::{
ClaudeCodeProbe, ClaudeCodeProbeOptions, FollowState, TokenTally, default_projects_root, ClaudeCodeProbe, ClaudeCodeProbeOptions, ClaudeOauthOptions, FollowState, TokenTally,
scan, default_projects_root, scan,
}; };
use crate::HarnessError; use crate::HarnessError;
use crate::probe::{ProbeHealth, UsageProbe}; use crate::probe::{ProbeHealth, UsageProbe};
@@ -693,6 +892,7 @@ mod tests {
let mut probe = ClaudeCodeProbe::start(ClaudeCodeProbeOptions { let mut probe = ClaudeCodeProbe::start(ClaudeCodeProbeOptions {
projects_root: root.clone(), projects_root: root.clone(),
rate_limit_feed: root.join("feed.jsonl"), rate_limit_feed: root.join("feed.jsonl"),
oauth: ClaudeOauthOptions::disabled(),
poll_interval: Duration::from_secs(1), poll_interval: Duration::from_secs(1),
}) })
.expect("the probe starts"); .expect("the probe starts");
@@ -746,6 +946,7 @@ mod tests {
let mut probe = ClaudeCodeProbe::start(ClaudeCodeProbeOptions { let mut probe = ClaudeCodeProbe::start(ClaudeCodeProbeOptions {
projects_root: root.clone(), projects_root: root.clone(),
rate_limit_feed: feed, rate_limit_feed: feed,
oauth: ClaudeOauthOptions::disabled(),
poll_interval: Duration::from_secs(1), poll_interval: Duration::from_secs(1),
}) })
.expect("the probe starts"); .expect("the probe starts");
@@ -788,6 +989,7 @@ mod tests {
let mut probe = ClaudeCodeProbe::start(ClaudeCodeProbeOptions { let mut probe = ClaudeCodeProbe::start(ClaudeCodeProbeOptions {
projects_root: root.clone(), projects_root: root.clone(),
rate_limit_feed: feed, rate_limit_feed: feed,
oauth: ClaudeOauthOptions::disabled(),
poll_interval: Duration::from_secs(1), poll_interval: Duration::from_secs(1),
}) })
.expect("starts"); .expect("starts");
@@ -814,6 +1016,7 @@ mod tests {
let mut probe = ClaudeCodeProbe::start(ClaudeCodeProbeOptions { let mut probe = ClaudeCodeProbe::start(ClaudeCodeProbeOptions {
projects_root: root.clone(), projects_root: root.clone(),
rate_limit_feed: root.join("feed.jsonl"), rate_limit_feed: root.join("feed.jsonl"),
oauth: ClaudeOauthOptions::disabled(),
poll_interval: Duration::from_secs(60), poll_interval: Duration::from_secs(60),
}) })
.expect("starts"); .expect("starts");
@@ -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<u8>);
impl AccessToken {
fn header_value(&self) -> Option<String> {
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(<redacted>)")
}
}
/// 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<String>,
}
/// A credential in hand: the token plus what the file said about the plan.
#[derive(Debug)]
struct Credential {
token: AccessToken,
expires_at_ms: Option<u64>,
identity: PlanIdentity,
}
#[derive(Debug, Deserialize)]
struct CredentialFileWire<'a> {
#[serde(borrow, rename = "claudeAiOauth")]
oauth: Option<OauthWire<'a>>,
}
#[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<i64>,
#[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<ScopedWindow>,
}
/// 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<Credential, HarnessError> {
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<Credential, HarnessError> {
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<String> {
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::<String>() + 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<UsageSnapshot>, 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<EndpointWindowWire>,
#[serde(default)]
seven_day: Option<EndpointWindowWire>,
#[serde(default)]
limits: Option<Vec<Option<ScopedLimitWire>>>,
}
#[derive(Debug, Deserialize)]
struct EndpointWindowWire {
#[serde(default)]
utilization: Option<f64>,
#[serde(default)]
used_percentage: Option<f64>,
#[serde(default)]
resets_at: Option<ResetsAtWire>,
}
#[derive(Debug, Deserialize)]
struct ScopedLimitWire {
#[serde(default)]
kind: Option<String>,
#[serde(default)]
scope: Option<ScopeWire>,
#[serde(default)]
percent: Option<f64>,
#[serde(default)]
utilization: Option<f64>,
#[serde(default)]
resets_at: Option<ResetsAtWire>,
}
#[derive(Debug, Deserialize)]
struct ScopeWire {
#[serde(default)]
model: Option<ScopeModelWire>,
}
#[derive(Debug, Deserialize)]
struct ScopeModelWire {
#[serde(default)]
display_name: Option<String>,
}
/// 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<u64> {
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<u64> {
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<i64> {
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<String> {
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<UsageSnapshot> {
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(<redacted>)");
}
#[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<String> = (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");
}
}
}
@@ -93,7 +93,7 @@ impl ClaudeWindowKind {
} }
} }
const fn length_ms(self) -> u64 { pub(crate) const fn length_ms(self) -> u64 {
match self { match self {
Self::FiveHour => FIVE_HOUR_MS, Self::FiveHour => FIVE_HOUR_MS,
Self::SevenDay => SEVEN_DAY_MS, Self::SevenDay => SEVEN_DAY_MS,
@@ -101,8 +101,15 @@ impl ClaudeWindowKind {
} }
} }
/// What one window in the feed means at a point in time. /// The documented length of a per-model weekly limit, which the usage endpoint
#[derive(Clone, Copy, Debug, Eq, PartialEq)] /// 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 { pub enum WindowReading {
/// A usable share of the window. /// A usable share of the window.
Usable { Usable {
@@ -110,6 +117,7 @@ pub enum WindowReading {
window: Option<UsageWindow>, window: Option<UsageWindow>,
}, },
/// The window is absent, or the account has no plan limits at all. /// The window is absent, or the account has no plan limits at all.
#[default]
Absent, Absent,
} }
@@ -124,6 +132,25 @@ pub(crate) fn parse_window(
let Some(percent) = wire.used_percentage.or(wire.utilization) else { let Some(percent) = wire.used_percentage.or(wire.utilization) else {
return WindowReading::Absent; 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<u64>,
length_ms: u64,
observed_at_ms: u64,
) -> WindowReading {
if !percent.is_finite() || percent < 0.0 { if !percent.is_finite() || percent < 0.0 {
return WindowReading::Absent; 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 permille = (percent.clamp(0.0, MAX_PERCENT) * PERMILLE_PER_PERCENT).round() as u64;
let window = wire let window = resets_at_ms
.resets_at
.and_then(unix_seconds_to_millis)
// The CLI documents that a window is present only while its resets_at // 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 // has not passed. One that has is stale, so its start-and-reset pair is
// dropped and only the percentage survives. // dropped and only the percentage survives.
.filter(|resets_at_ms| *resets_at_ms > observed_at_ms) .filter(|resets_at_ms| *resets_at_ms > observed_at_ms)
.and_then(|resets_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 { if started_at_ms <= observed_at_ms {
UsageWindow::new(started_at_ms, resets_at_ms).ok() UsageWindow::new(started_at_ms, resets_at_ms).ok()
} else { } else {
@@ -161,7 +186,7 @@ pub(crate) fn parse_window(
WindowReading::Usable { permille, window } WindowReading::Usable { permille, window }
} }
fn unix_seconds_to_millis(seconds: i64) -> Option<u64> { pub(crate) fn unix_seconds_to_millis(seconds: i64) -> Option<u64> {
u64::try_from(seconds.checked_mul(MILLIS_PER_SECOND)?).ok() u64::try_from(seconds.checked_mul(MILLIS_PER_SECOND)?).ok()
} }
+19 -7
View File
@@ -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 else, so the conversations in those files are not representable in a Lumbridge
value. See decision 0014. value. See decision 0014.
Claude Code's subscription windows come from a different surface: the CLI pipes Claude Code's subscription windows come from two further surfaces, because
a `rate_limits` object carrying the five-hour and seven-day windows to whatever neither answers the whole question. The CLI pipes a `rate_limits` object
`statusLine` command the user has configured, on every turn. A small installed carrying the five-hour and seven-day windows to whatever `statusLine` command
bridge writes those fields — and only those — to a local feed the probe tails, the user has configured, on every turn; a small installed bridge writes those
so the windows are `ProviderReported` and no credential is ever read. Reading fields — and only those — to a local feed the probe tails, for free and without
Claude Code's OAuth token would be more capable and is what comparable tools a credential (decision 0015). The account usage endpoint supplies what the
do; Lumbridge does not, because AGENTS.md forbids it. See decision 0015. 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 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 top of the declared ones, so the Claude windows appear once they report. Its
@@ -1,6 +1,8 @@
# 0015: Claude Code's subscription windows come from the status line, not the credential # 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. 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: 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 The cost is that a window only appears once a session has run, and that
installation edits `settings.json`. installation edits `settings.json`.
The endpoint is recorded here as a rejected-for-now alternative rather than an The endpoint was recorded here as a rejected-for-now alternative rather than an
unconsidered one. Reopening it means amending AGENTS.md first. 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 ## Provenance
@@ -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
`<redacted>`, 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/<version> (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.
+149 -3
View File
@@ -48,7 +48,7 @@ version = "0.26.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bda177466b9524d59f1b12f0dd30b68696788e9992a7e959021c4a0ed96fcf59" checksum = "bda177466b9524d59f1b12f0dd30b68696788e9992a7e959021c4a0ed96fcf59"
dependencies = [ dependencies = [
"base64", "base64 0.22.1",
"bitflags 2.13.1", "bitflags 2.13.1",
"home", "home",
"libc", "libc",
@@ -500,6 +500,12 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "base64"
version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
[[package]] [[package]]
name = "bindgen" name = "bindgen"
version = "0.71.1" version = "0.71.1"
@@ -1015,6 +1021,35 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" 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]] [[package]]
name = "core-foundation" name = "core-foundation"
version = "0.9.4" version = "0.9.4"
@@ -1294,6 +1329,12 @@ version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2" checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2"
[[package]]
name = "deranged"
version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
[[package]] [[package]]
name = "derive_more" name = "derive_more"
version = "0.99.20" version = "0.99.20"
@@ -1406,6 +1447,15 @@ dependencies = [
"libloading", "libloading",
] ]
[[package]]
name = "document-features"
version = "0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
dependencies = [
"litrs",
]
[[package]] [[package]]
name = "downcast-rs" name = "downcast-rs"
version = "1.2.1" version = "1.2.1"
@@ -3002,6 +3052,12 @@ version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
[[package]]
name = "litrs"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
[[package]] [[package]]
name = "lock_api" name = "lock_api"
version = "0.4.14" version = "0.4.14"
@@ -3052,6 +3108,7 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"thiserror 2.0.20", "thiserror 2.0.20",
"ureq",
] ]
[[package]] [[package]]
@@ -3480,6 +3537,12 @@ dependencies = [
"num-traits", "num-traits",
] ]
[[package]]
name = "num-conv"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
[[package]] [[package]]
name = "num-derive" name = "num-derive"
version = "0.4.2" version = "0.4.2"
@@ -3974,6 +4037,12 @@ dependencies = [
"zerovec", "zerovec",
] ]
[[package]]
name = "powerfmt"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]] [[package]]
name = "ppv-lite86" name = "ppv-lite86"
version = "0.2.21" version = "0.2.21"
@@ -4631,6 +4700,7 @@ version = "0.23.43"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"
dependencies = [ dependencies = [
"log",
"once_cell", "once_cell",
"ring", "ring",
"rustls-pki-types", "rustls-pki-types",
@@ -5589,6 +5659,36 @@ dependencies = [
"zune-jpeg", "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]] [[package]]
name = "tiny-keccak" name = "tiny-keccak"
version = "2.0.2" version = "2.0.2"
@@ -5983,6 +6083,37 @@ version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" 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]] [[package]]
name = "url" name = "url"
version = "2.5.8" version = "2.5.8"
@@ -6002,7 +6133,7 @@ version = "0.45.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "80be9b06fbae3b8b303400ab20778c80bbaf338f563afe567cf3c9eea17b47ef" checksum = "80be9b06fbae3b8b303400ab20778c80bbaf338f563afe567cf3c9eea17b47ef"
dependencies = [ dependencies = [
"base64", "base64 0.22.1",
"data-url", "data-url",
"flate2", "flate2",
"fontdb 0.23.0", "fontdb 0.23.0",
@@ -6029,6 +6160,12 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
[[package]]
name = "utf8-zero"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e"
[[package]] [[package]]
name = "utf8_iter" name = "utf8_iter"
version = "1.0.4" version = "1.0.4"
@@ -6365,6 +6502,15 @@ dependencies = [
"wasm-bindgen", "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]] [[package]]
name = "weezl" name = "weezl"
version = "0.1.12" version = "0.1.12"
@@ -7184,7 +7330,7 @@ version = "0.12.15-zed"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac2d05756ff48539950c3282ad7acf3817ad3f08797c205ad1c34a2ce03b9970" checksum = "ac2d05756ff48539950c3282ad7acf3817ad3f08797c205ad1c34a2ce03b9970"
dependencies = [ dependencies = [
"base64", "base64 0.22.1",
"bytes", "bytes",
"encoding_rs", "encoding_rs",
"futures-core", "futures-core",
+43 -10
View File
@@ -2126,10 +2126,21 @@ impl LumbridgeShell {
.child("no harness on this pane"), .child("no harness on this pane"),
) )
}) })
.children(segments.into_iter().map(|segment| { // 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<String> = 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); let selected = active.as_ref() == Some(&segment.id);
usage_segment(segment, selected) usage_segment(segment, selected, repeats)
})) })
.collect::<Vec<_>>()
})
} }
} }
@@ -2176,9 +2187,20 @@ fn usage_meter(consumed_permille: Option<u64>, color: u32) -> impl IntoElement {
.into_any_element() .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 color = provenance_color(segment.provenance);
let name_color = if selected { TEXT } else { MUTED }; 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() div()
.flex() .flex()
.items_center() .items_center()
@@ -2186,21 +2208,32 @@ fn usage_segment(segment: UsageSegment, selected: bool) -> impl IntoElement {
.when(selected, |view| { .when(selected, |view| {
view.px_2().py_1().rounded(px(4.0)).bg(rgb(PANEL_ACTIVE)) view.px_2().py_1().rounded(px(4.0)).bg(rgb(PANEL_ACTIVE))
}) })
.child( .when(!continues_group, |view| {
view.child(
div() div()
.flex_none() .flex_none()
.text_color(rgb(name_color)) .text_color(rgb(name_color))
.child(segment.label), .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()
.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(usage_meter(segment.consumed_permille, color))
.child( .child(
div() div()
.flex_none() .flex_none()
.text_color(rgb(if segment.headline.is_some() { .text_color(rgb(headline_color))
TEXT
} else {
MUTED
}))
.child(segment.headline.unwrap_or_else(|| "no reading".to_owned())), .child(segment.headline.unwrap_or_else(|| "no reading".to_owned())),
) )
.when_some(segment.reset, |view, reset| { .when_some(segment.reset, |view, reset| {
+59 -19
View File
@@ -183,6 +183,17 @@ impl UsageFeed {
let outcome = probe.poll(); let outcome = probe.poll();
let health = outcome.health(); let health = outcome.health();
for profile in probe.profiles() { 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) { if self.health.insert(profile.id().clone(), health) != Some(health) {
changed = true; changed = true;
} }
@@ -255,28 +266,46 @@ impl UsageFeed {
.into_iter() .into_iter()
.filter_map(|id| self.segment(id)) .filter_map(|id| self.segment(id))
.collect(); .collect();
// Two windows on one account would otherwise both read "CODEX". A // Keep one harness's quotas together. Claude Code alone reports four,
// strip that names two different quotas the same thing is worse than // and a strip that interleaves them with Codex reads as eight unrelated
// a longer label. // numbers instead of two accounts. Stable within a group, so a segment
let duplicated: Vec<String> = segments // never moves under the pointer.
.iter() let mut order: Vec<String> = Vec::new();
.filter(|segment| { for segment in &segments {
segments if !order.contains(&segment.label) {
.iter() order.push(segment.label.clone());
.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());
} }
} }
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 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<UsageSegment> { fn segment(&self, id: &AccountProfileId) -> Option<UsageSegment> {
let profile = self.profiles.get(id)?; let profile = self.profiles.get(id)?;
let projection = self.projection(id); let projection = self.projection(id);
@@ -285,7 +314,7 @@ impl UsageFeed {
Some(UsageSegment { Some(UsageSegment {
id: id.clone(), id: id.clone(),
label: profile.harness().to_uppercase(), label: profile.harness().to_uppercase(),
model: profile.model().to_owned(), scope: Self::short_scope(profile.model()),
consumed_permille, consumed_permille,
// "How much do I have left" is the question an engineer actually // "How much do I have left" is the question an engineer actually
// asks. Consumption stays available in the expanded detail. // asks. Consumption stays available in the expanded detail.
@@ -310,6 +339,10 @@ impl UsageFeed {
let unit = projection.unit()?; let unit = projection.unit()?;
Some(format!("{} used", unit.format_amount(consumed))) 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 // A profile that reports spend but no ceiling and no reset should
// say so where the reset would go, rather than leaving a silent // say so where the reset would go, rather than leaving a silent
// gap that reads as "we just haven't shown it yet". // 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) struct UsageSegment {
pub(crate) id: AccountProfileId, pub(crate) id: AccountProfileId,
pub(crate) label: String, 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<u64>, pub(crate) consumed_permille: Option<u64>,
/// What to lead with: how much is left when a ceiling is known, how much /// 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. /// was spent when it is not, and nothing at all when there is no reading.
pub(crate) headline: Option<String>, pub(crate) headline: Option<String>,
/// 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<String>, pub(crate) reset: Option<String>,
pub(crate) burn: String, pub(crate) burn: String,
pub(crate) burn_provenance: UsageProvenance, pub(crate) burn_provenance: UsageProvenance,