diff --git a/PLAN.md b/PLAN.md index 083dc14..87413ed 100644 --- a/PLAN.md +++ b/PLAN.md @@ -222,9 +222,9 @@ POST /sessions/:id/answer {question_id, answer} (questions and per POST /sessions/:id/interrupt POST /sessions/:id/model {model} POST /sessions/:id/compact (llama sessions) +POST /sessions/:id/attachments multipart upload → id (referenced by /message) +GET /sessions/:id/files/:ref images the session produced or was sent DELETE /sessions/:id kill process, release llama-server, delete transcript+files -POST /attachments multipart upload → id (referenced by /message) -GET /files/:session/:id images/files the session produced GET /usage cached usage windows GET/PUT /hosts, /models config editing from the phone ``` @@ -395,9 +395,19 @@ window just fills. numbers, delete); the app on the `tdep` emulator against the real server (QR-style enrollment via deep link, spawn, streamed echo turn, question answer, tool card). -2. **Claude local** — ClaudeDriver: spawn, stream text/tools, mid-run send, - interrupt, permission questions, AskUserQuestion, images both ways, delete. +2. **Claude local** — *done 2026-08-24.* ClaudeDriver: spawn, stream + text/tools, mid-run send, interrupt, permission questions, + AskUserQuestion, images both ways, delete. *Milestone: daily-drivable Claude replacement on localhost.* + Wire-format notes live in `session/claude.rs`'s module doc (pinned + against CLI 2.1.237): permissions need the hidden + `--permission-prompt-tool stdio` flag; AskUserQuestion answers ride + `updatedInput.answers` keyed by question text; `set_model`/`interrupt` + are control requests; 2.x permission modes are acceptEdits / auto / + bypassPermissions / manual / dontAsk / plan (no more "default"). + Attachments/files were re-homed under `/sessions/:id/…` (table above) + so their lifecycle is the session directory's — delete stays the + complete path out. 3. **Usage screen.** 4. **llama.cpp** — LlamaServerManager (local), PiDriver, model change, compaction controls. diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt index a1d6e50..e20c601 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt @@ -195,6 +195,45 @@ fun fetchSessionFile(settings: ServerSettings, sessionId: String, name: String): it.inputStream.readBytes() } +// One rate-limit window, rendered as a labeled bar on the usage screen. +data class UsageWindow( + val label: String, + val percent: Double, + val resetsAt: String?, + val active: Boolean, +) + +data class UsageSnapshot( + val provider: String, + val available: Boolean, + val windows: List, + val error: String?, +) + +/** The backend caches; refreshing more often than its poll interval just re-reads the cache. */ +fun fetchUsage(settings: ServerSettings): List = + requestFromServer(settings, "/usage", readTimeoutMs = 30000) { connection -> + val snapshots = JSONArray(connection.inputStream.bufferedReader().readText()) + (0 until snapshots.length()).map { i -> + val snapshot = snapshots.getJSONObject(i) + val windows = snapshot.getJSONArray("windows") + UsageSnapshot( + provider = snapshot.getString("provider"), + available = snapshot.getBoolean("available"), + error = snapshot.optString("error").ifEmpty { null }, + windows = (0 until windows.length()).map { j -> + val window = windows.getJSONObject(j) + UsageWindow( + label = window.getString("label"), + percent = window.getDouble("percent"), + resetsAt = window.optString("resetsAt").ifEmpty { null }, + active = window.getBoolean("active"), + ) + }, + ) + } + } + fun answerQuestion(settings: ServerSettings, sessionId: String, questionId: String, answer: String) { requestFromServer( settings, diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/AppRoot.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/AppRoot.kt index f5c5f6b..fb38c9a 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/AppRoot.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/AppRoot.kt @@ -16,6 +16,7 @@ private sealed class Screen { data object SessionList : Screen() data class Session(val summary: SessionSummary) : Screen() data object Spawn : Screen() + data object Usage : Screen() data object Settings : Screen() } @@ -54,6 +55,7 @@ fun AppRoot(settingsVersion: Int) { reloadToken = reloadToken, onOpen = { screen = Screen.Session(it) }, onSpawn = { screen = Screen.Spawn }, + onUsage = { screen = Screen.Usage }, onSettings = { screen = Screen.Settings }, ) is Screen.Session -> { @@ -81,6 +83,10 @@ fun AppRoot(settingsVersion: Int) { onBack = { screen = Screen.SessionList }, ) } + is Screen.Usage -> { + BackHandler { screen = Screen.SessionList } + UsageScreen(settings = current, onBack = { screen = Screen.SessionList }) + } is Screen.Settings -> { BackHandler { screen = Screen.SessionList } SettingsScreen( diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionListScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionListScreen.kt index 1951759..b3e7676 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionListScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionListScreen.kt @@ -56,6 +56,7 @@ fun SessionListScreen( reloadToken: Int, onOpen: (SessionSummary) -> Unit, onSpawn: () -> Unit, + onUsage: () -> Unit, onSettings: () -> Unit, ) { val scope = rememberCoroutineScope() @@ -83,6 +84,7 @@ fun SessionListScreen( style = MaterialTheme.typography.headlineSmall, modifier = Modifier.weight(1f), ) + TextButton(onClick = onUsage) { Text("Usage") } TextButton(onClick = onSettings) { Text("Settings") } TextButton(onClick = { refresh() }) { Text("Refresh") } } diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/UsageScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/UsageScreen.kt new file mode 100644 index 0000000..7042e62 --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/UsageScreen.kt @@ -0,0 +1,145 @@ +package com.example.aiapp + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.time.Duration +import java.time.OffsetDateTime + +private val WARN_COLOR = Color(0xFFB26A00) +private val OVER_COLOR = Color(0xFFB3261E) + +private sealed class UsageState { + data object Loading : UsageState() + data class Loaded(val snapshots: List) : UsageState() + data class Error(val message: String) : UsageState() +} + +/** Window bars for the account's rate limits, with reset times. */ +@Composable +fun UsageScreen(settings: ServerSettings, onBack: () -> Unit) { + val scope = rememberCoroutineScope() + var state by remember { mutableStateOf(UsageState.Loading) } + + fun refresh() { + state = UsageState.Loading + scope.launch { + state = try { + withContext(Dispatchers.IO) { UsageState.Loaded(fetchUsage(settings)) } + } catch (e: ApiException) { + UsageState.Error(e.message ?: "Unknown error") + } + } + } + LaunchedEffect(Unit) { refresh() } + + Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + Text( + "Usage", + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier.weight(1f), + ) + TextButton(onClick = onBack) { Text("Back") } + TextButton(onClick = { refresh() }) { Text("Refresh") } + } + Spacer(Modifier.height(16.dp)) + + when (val current = state) { + is UsageState.Loading -> CircularProgressIndicator() + is UsageState.Error -> Text(current.message, color = MaterialTheme.colorScheme.error) + is UsageState.Loaded -> current.snapshots.forEach { snapshot -> + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(16.dp)) { + Text(snapshot.provider, style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(8.dp)) + if (!snapshot.available) { + Text( + snapshot.error ?: "Unavailable", + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium, + ) + } + snapshot.windows.forEach { window -> + WindowBar(window) + Spacer(Modifier.height(12.dp)) + } + } + } + Spacer(Modifier.height(12.dp)) + } + } + } +} + +@Composable +private fun WindowBar(window: UsageWindow) { + val color = when { + window.percent >= 95 -> OVER_COLOR + window.percent >= 75 -> WARN_COLOR + else -> MaterialTheme.colorScheme.primary + } + Column { + Row(modifier = Modifier.fillMaxWidth()) { + Text( + window.label + if (window.active) " (active)" else "", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f), + ) + Text("${window.percent.toInt()}%", style = MaterialTheme.typography.bodyMedium) + } + Spacer(Modifier.height(4.dp)) + LinearProgressIndicator( + progress = { (window.percent / 100.0).toFloat().coerceIn(0f, 1f) }, + color = color, + modifier = Modifier.fillMaxWidth(), + ) + window.resetsAt?.let { + Spacer(Modifier.height(2.dp)) + Text( + "resets ${formatReset(it)}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +/** "in 3h 12m" -- close enough for deciding whether to start a big task. */ +private fun formatReset(resetsAt: String): String = try { + val until = Duration.between(OffsetDateTime.now(), OffsetDateTime.parse(resetsAt)) + when { + until.isNegative -> "soon" + until.toHours() >= 24 -> "in ${until.toDays()}d ${until.toHours() % 24}h" + until.toHours() > 0 -> "in ${until.toHours()}h ${until.toMinutes() % 60}m" + else -> "in ${until.toMinutes()}m" + } +} catch (_: Exception) { + "at $resetsAt" +} diff --git a/server/Cargo.lock b/server/Cargo.lock index 0232c89..939ba92 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aho-corasick" version = "1.1.5" @@ -23,6 +29,7 @@ dependencies = [ "if-addrs", "qrcode", "rand", + "rustls", "serde", "serde_json", "sha2", @@ -34,6 +41,7 @@ dependencies = [ "tower", "tracing", "tracing-subscriber", + "ureq", ] [[package]] @@ -337,6 +345,15 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550" +dependencies = [ + "cfg-if", +] + [[package]] name = "crypto-common" version = "0.2.2" @@ -406,6 +423,16 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "fnv" version = "1.0.7" @@ -713,6 +740,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.2.2" @@ -878,7 +915,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" dependencies = [ "aws-lc-rs", + "log", "once_cell", + "ring", "rustls-pki-types", "rustls-webpki", "subtle", @@ -1020,6 +1059,12 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + [[package]] name = "slab" version = "0.4.12" @@ -1300,6 +1345,41 @@ 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", + "flate2", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "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", + "http", + "httparse", + "log", +] + +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + [[package]] name = "utf8parse" version = "0.2.2" @@ -1324,6 +1404,15 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[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 = "windows-link" version = "0.2.1" diff --git a/server/Cargo.toml b/server/Cargo.toml index bb802cf..a509cab 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -30,6 +30,14 @@ qrcode = { version = "0.14", default-features = false } # The wg0-bound listener needs the interface's address; the stdlib has no # getifaddrs. This is the smallest crate that wraps just that. if-addrs = "0.15" +# Outbound HTTPS for the usage endpoint. A small blocking client fits an +# every-few-minutes poll better than pulling in reqwest's tower stack; +# rustls-backed like the rest of the TLS here. +ureq = "3" +# Direct dependency only to pick the process-level CryptoProvider in main: +# ureq pulls rustls-with-ring, axum-server rustls-with-aws-lc-rs, and with +# both in the graph rustls refuses to auto-select one. +rustls = "0.23" [dev-dependencies] tempfile = "3" diff --git a/server/src/main.rs b/server/src/main.rs index 54f2d16..2ffd6e7 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -17,6 +17,7 @@ mod auth; mod config; mod routes; mod session; +mod usage; use std::net::{IpAddr, SocketAddr}; use std::path::{Path, PathBuf}; @@ -119,6 +120,13 @@ fn print_enrollment(host: IpAddr, port: u16, token: &str) -> Result<()> { #[tokio::main] async fn main() -> Result<()> { + // Both rustls crypto providers are in the dependency graph (ureq + // brings ring, axum-server brings aws-lc-rs), so rustls refuses to + // pick one itself; choose before anything touches TLS. + rustls::crypto::aws_lc_rs::default_provider() + .install_default() + .expect("no other TLS crypto provider is installed before main"); + tracing_subscriber::fmt().with_env_filter("info").init(); let args = Args::parse(); @@ -174,13 +182,21 @@ async fn main() -> Result<()> { .await .context("failed to load TLS cert/key")?; + let monitor = Arc::new(usage::UsageMonitor::new(vec![Box::new(usage::ClaudeUsage { + credentials_path: std::env::home_dir() + .unwrap_or_else(|| PathBuf::from("/")) + .join(".claude/.credentials.json"), + })])); + // The bearer-token middleware wraps the entire router -- routes and // fallback alike -- here and only here, so a new route can't forget // auth. Zero unauthenticated endpoints. - let app = routes::router(Arc::clone(&manager)).layer(axum::middleware::from_fn_with_state( - Arc::clone(&manager), - auth::require_token, - )); + let app = routes::router(Arc::clone(&manager)) + .merge(routes::usage_router(monitor)) + .layer(axum::middleware::from_fn_with_state( + Arc::clone(&manager), + auth::require_token, + )); let addr = SocketAddr::new(bind_ip, args.port); tracing::info!("serving https://{addr}"); diff --git a/server/src/routes.rs b/server/src/routes.rs index fa1702a..f43c048 100644 --- a/server/src/routes.rs +++ b/server/src/routes.rs @@ -14,10 +14,10 @@ //! POST /sessions/{id}/attachments multipart image upload -> {id}, referenced by /message //! GET /sessions/{id}/files/{name} images the session produced or was sent //! DELETE /sessions/{id} kill process, delete transcript + files +//! GET /usage cached usage windows per provider //! ``` //! -//! Later phases add: `GET /usage`, `GET|PUT /hosts` and `/models` -- see -//! PLAN.md's table. +//! Later phases add: `GET|PUT /hosts` and `/models` -- see PLAN.md's table. //! //! Everything here works purely in the common event model; nothing may //! branch on the session kind (that's what drivers are for). @@ -26,6 +26,8 @@ use std::convert::Infallible; use std::path::PathBuf; use std::sync::Arc; +use anyhow::Context; + use axum::Router; use axum::extract::{Path as UrlPath, Query, State}; use axum::http::{HeaderMap, StatusCode}; @@ -69,6 +71,8 @@ enum ApiError { UnknownRoute, #[error("{0}")] BadRequest(String), + #[error(transparent)] + Internal(#[from] anyhow::Error), } impl IntoResponse for ApiError { @@ -76,6 +80,12 @@ impl IntoResponse for ApiError { let status = match self { Self::UnknownSession(_) | Self::UnknownRoute => StatusCode::NOT_FOUND, Self::BadRequest(_) => StatusCode::BAD_REQUEST, + Self::Internal(err) => { + // The only variant whose real cause isn't safe to hand + // back verbatim, and the only one worth a log line. + tracing::error!("{err:#}"); + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + } }; (status, self.to_string()).into_response() } @@ -186,6 +196,22 @@ async fn interrupt( Ok(StatusCode::NO_CONTENT) } +/// Separate router because its state is the usage monitor, not the +/// session manager; merged (and auth-wrapped) with the rest in `main`. +pub fn usage_router(monitor: Arc) -> Router { + Router::new().route("/usage", get(usage)).with_state(monitor) +} + +async fn usage( + State(monitor): State>, +) -> Result>, ApiError> { + // The fetch is blocking by design (see `usage`); off the workers. + let snapshots = tokio::task::spawn_blocking(move || monitor.snapshots()) + .await + .context("usage fetch panicked")?; + Ok(axum::Json(snapshots)) +} + #[derive(Deserialize)] struct ModelRequest { model: String, diff --git a/server/src/usage.rs b/server/src/usage.rs new file mode 100644 index 0000000..bac9e60 --- /dev/null +++ b/server/src/usage.rs @@ -0,0 +1,255 @@ +//! Usage-limit reporting -- the same numbers as Claude Code's `/usage`. +//! +//! Polls `https://api.anthropic.com/api/oauth/usage` with the OAuth access +//! token from Claude Code's local credential store. The endpoint is +//! undocumented and has changed before, so everything here is best-effort: +//! every field is optional, and failure degrades to an "unavailable" +//! snapshot with the reason, never an error that breaks the screen. +//! +//! Two rules learned from others hitting this endpoint (see PLAN.md's +//! references): send `User-Agent: claude-code/` (without it, +//! requests land in an aggressively rate-limited bucket) and poll no more +//! often than every 180 s. The cache below enforces the latter across any +//! number of phone refreshes; there is no background poll at all -- the +//! screen's fetch is the trigger, so no session activity means no traffic. +//! +//! One [`UsageProvider`] per paid service, so a second service later is a +//! new impl behind the same snapshot shape, not a parallel screen. + +use std::path::PathBuf; +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +use serde::Serialize; +use serde_json::Value; + +const USAGE_URL: &str = "https://api.anthropic.com/api/oauth/usage"; +const MIN_POLL_INTERVAL: Duration = Duration::from_secs(180); +/// Matched to the CLI version the wire formats were pinned against. +const USER_AGENT: &str = "claude-code/2.1.237"; + +/// One rate-limit window, as the phone renders it: a labeled bar. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct UsageWindow { + pub label: String, + /// 0-100. + pub percent: f64, + /// ISO-8601, as the API sends it; absent for windows that never reset. + #[serde(skip_serializing_if = "Option::is_none")] + pub resets_at: Option, + /// Whether this window is currently the binding one. + pub active: bool, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct UsageSnapshot { + pub provider: String, + pub available: bool, + pub windows: Vec, + /// Why `available` is false, written for the screen. + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, + /// Epoch seconds the numbers were fetched (they can be up to the poll + /// interval old). + pub fetched_at: f64, +} + +pub trait UsageProvider: Send + Sync { + fn name(&self) -> &'static str; + /// Blocking -- call off the async workers. + fn fetch(&self) -> UsageSnapshot; +} + +/// Reads the numbers behind Claude Code's `/usage` using its own stored +/// credentials -- nothing to configure, and it reports on exactly the +/// account the local CLI runs as. +pub struct ClaudeUsage { + pub credentials_path: PathBuf, +} + +impl ClaudeUsage { + fn unavailable(&self, error: String) -> UsageSnapshot { + UsageSnapshot { + provider: self.name().to_string(), + available: false, + windows: Vec::new(), + error: Some(error), + fetched_at: crate::session::now(), + } + } + + fn access_token(&self) -> Result { + let text = std::fs::read_to_string(&self.credentials_path).map_err(|err| { + format!( + "couldn't read {} ({err}) -- is Claude Code logged in on this machine?", + self.credentials_path.display() + ) + })?; + serde_json::from_str::(&text) + .ok() + .and_then(|creds| { + creds.get("claudeAiOauth")?.get("accessToken")?.as_str().map(String::from) + }) + .ok_or_else(|| { + "credential file has no claudeAiOauth.accessToken -- log in with `claude` once" + .to_string() + }) + } +} + +impl UsageProvider for ClaudeUsage { + fn name(&self) -> &'static str { + "claude" + } + + fn fetch(&self) -> UsageSnapshot { + let token = match self.access_token() { + Ok(token) => token, + Err(error) => return self.unavailable(error), + }; + let text = match ureq::get(USAGE_URL) + .header("Authorization", &format!("Bearer {token}")) + .header("anthropic-beta", "oauth-2025-04-20") + .header("User-Agent", USER_AGENT) + .call() + .and_then(|mut response| response.body_mut().read_to_string()) + { + Ok(text) => text, + Err(err) => { + // The error string can embed the URL but never the token. + return self.unavailable(format!("usage endpoint unreachable: {err}")); + } + }; + let body: Value = match serde_json::from_str(&text) { + Ok(body) => body, + Err(err) => return self.unavailable(format!("usage endpoint sent non-JSON: {err}")), + }; + UsageSnapshot { + provider: self.name().to_string(), + available: true, + windows: parse_windows(&body), + error: None, + fetched_at: crate::session::now(), + } + } +} + +/// Pulls the `limits` array apart, defensively: entries with no percent +/// are skipped, unknown kinds keep their raw name as the label rather +/// than being dropped -- a new window appearing should show up, not +/// vanish. +fn parse_windows(body: &Value) -> Vec { + let Some(limits) = body.get("limits").and_then(Value::as_array) else { + return Vec::new(); + }; + limits + .iter() + .filter_map(|limit| { + let percent = limit.get("percent")?.as_f64()?; + let kind = limit.get("kind").and_then(Value::as_str).unwrap_or("unknown"); + let scope_model = limit + .get("scope") + .and_then(|scope| scope.get("model")) + .and_then(|model| model.get("display_name")) + .and_then(Value::as_str); + let label = match (kind, scope_model) { + ("session", _) => "5-hour window".to_string(), + ("weekly_all", _) => "Weekly (all models)".to_string(), + ("weekly_scoped", Some(model)) => format!("Weekly ({model})"), + (other, Some(model)) => format!("{other} ({model})"), + (other, None) => other.to_string(), + }; + Some(UsageWindow { + label, + percent, + resets_at: limit + .get("resets_at") + .and_then(Value::as_str) + .map(String::from), + active: limit.get("is_active").and_then(Value::as_bool).unwrap_or(false), + }) + }) + .collect() +} + +/// The cache in front of whatever providers exist: at most one real fetch +/// per provider per [`MIN_POLL_INTERVAL`], no matter how often the phone +/// asks. +pub struct UsageMonitor { + providers: Vec>, + cache: Mutex>, +} + +impl UsageMonitor { + pub fn new(providers: Vec>) -> Self { + Self { providers, cache: Mutex::new(Vec::new()) } + } + + /// Blocking -- call via `spawn_blocking`. + pub fn snapshots(&self) -> Vec { + let mut cache = self.cache.lock().unwrap(); + self.providers + .iter() + .enumerate() + .map(|(i, provider)| { + if let Some((fetched, snapshot)) = cache.get(i) + && fetched.elapsed() < MIN_POLL_INTERVAL + { + return snapshot.clone(); + } + let snapshot = provider.fetch(); + match cache.get_mut(i) { + Some(slot) => *slot = (Instant::now(), snapshot.clone()), + None => cache.push((Instant::now(), snapshot.clone())), + } + snapshot + }) + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_the_limits_array_defensively() { + // Trimmed from a live 2026-08-24 response. + let body: Value = serde_json::from_str( + r#"{"limits":[ + {"kind":"session","group":"session","percent":70,"severity":"normal","resets_at":"2026-08-25T04:29:59+00:00","scope":null,"is_active":true}, + {"kind":"weekly_all","group":"weekly","percent":25,"resets_at":"2026-08-28T21:59:59+00:00","is_active":false}, + {"kind":"weekly_scoped","percent":15,"resets_at":"2026-08-28T21:59:59+00:00","scope":{"model":{"id":null,"display_name":"Fable"}},"is_active":false}, + {"kind":"mystery_new_window","percent":5}, + {"kind":"broken_entry_without_percent"} + ]}"#, + ) + .expect("json"); + let windows = parse_windows(&body); + assert_eq!(windows.len(), 4); + assert_eq!(windows[0].label, "5-hour window"); + assert_eq!(windows[0].percent, 70.0); + assert!(windows[0].active); + assert_eq!(windows[1].label, "Weekly (all models)"); + assert_eq!(windows[2].label, "Weekly (Fable)"); + // Unknown kinds surface under their raw name instead of vanishing. + assert_eq!(windows[3].label, "mystery_new_window"); + assert_eq!(windows[3].resets_at, None); + } + + #[test] + fn missing_credentials_degrade_to_unavailable() { + let provider = ClaudeUsage { credentials_path: PathBuf::from("/nonexistent/creds.json") }; + let snapshot = provider.fetch(); + assert!(!snapshot.available); + assert!(snapshot.error.expect("reason").contains("logged in")); + } + + #[test] + fn an_empty_or_alien_body_yields_no_windows() { + assert!(parse_windows(&serde_json::json!({})).is_empty()); + assert!(parse_windows(&serde_json::json!({"limits": "what"})).is_empty()); + } +}