From 206319045d55f2b6739fb167c582b8702fe112a3 Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Sat, 29 Aug 2026 06:18:46 -0400 Subject: [PATCH] Show usage per machine, and say why a machine has none The server now reports limits per machine, so the screen has to as well: one card per machine that offers a paid service, named by the machine first, because these are one account's numbers and which account is decided by which box ran the session. It also has to say which of four things happened, and the reason for splitting them shows up here rather than in the data. A machine nobody has logged in on is working exactly as somebody set it up, so it reads as a plain statement in ordinary text -- marking it would be the interface nagging about a decision already made, and would dilute the marks that do mean something. Only "couldn't reach it" and "the endpoint refused" are coloured as faults, and they say different things because they need different things done. The old screen drew all three in the error colour. No machine offering a paid service is not an error either: it says so instead of drawing nothing. The app also stopped parsing: `available` no longer exists and `getBoolean` on a missing key throws, so this had to land with the server change rather than after it. An older backend sending no `state` is read as "failed" rather than "ok", since an empty card drawn as healthy is the worse failure. Looked at running, against five machines: local reporting notLoggedIn with the backend's HOME emptied, loopback-over-ssh returning real windows beside it, an unreachable host showing ssh's own message in red, and a machine with no Claude provider correctly absent. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VETa8afmpWaYezLCqJhDB8 --- AGENTS.md | 7 ++ PLAN.md | 23 ++++- .../src/main/kotlin/com/example/aiapp/Api.kt | 24 +++++- .../kotlin/com/example/aiapp/UsageScreen.kt | 86 +++++++++++++++---- 4 files changed, 116 insertions(+), 24 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 21d7da2..292a4d4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -57,6 +57,13 @@ repo is in PLAN.md's "Backend layout" section. status. Status is the obvious signal and is wrong: a turn that starts and finishes between two polls reads as idle at both, and its own output gets replayed on top of itself. That bug was visible on screen as `donedone`. +- `server/src/usage.rs` — rate-limit windows, asked **of each machine that + can run Claude**, not of the backend. Credentials are read through the + session `Transport`, so a remote setup is an ssh round trip and the local + one is unchanged; the HTTP call stays here. A machine with no Claude + provider is never asked. The four states (`ok`, `notLoggedIn`, + `unreachable`, `failed`) exist because a machine nobody logged in on is a + choice rather than a fault, and one `error` string made it look like one. - `server/src/models.rs` — downloaded GGUF models and the HuggingFace browsing behind them. Downloads are keyed by the model rather than by who asked, so any device can watch one; they resume through HTTP Range, diff --git a/PLAN.md b/PLAN.md index 50f042b..b600277 100644 --- a/PLAN.md +++ b/PLAN.md @@ -386,10 +386,29 @@ credential store (`~/.claude/.credentials.json`), headers Poll at ≥180 s, only while any Claude session exists or the usage screen is open, cache the last answer. Surface: 5-hour and weekly window utilization % and reset times. It's undocumented, so `usage.rs` treats every field as -optional and degrades to "unavailable" rather than erroring. Structure it as -one `UsageProvider` per paid service so a second service later is a new impl, +optional and degrades rather than erroring. Structure it as one +`UsageProvider` per paid service so a second service later is a new impl, not a parallel screen (rule 9). +**Per machine, not per backend (decided 2026-08-29).** The credential store +that matters is the one on the machine the session runs on, because that is +the account being billed. Reading this machine's was right only while the +backend and the CLI were the same box — and in the layout this is aiming +at they are not: `ai-server` belongs on the host, the host has no `claude` +CLI, and the CLI machine is a remote. So credentials are read through the +session `Transport` (`ssh host sh -c 'cat $HOME/…'`, `$HOME` expanded by +the far shell because a path built locally is the wrong home), one snapshot +per setup that offers Claude, cached per machine. The HTTP call stays on +the backend rather than running remotely, so the far end needs nothing but +a shell. + +The snapshot says which of four things happened rather than carrying a flag +and a message: `ok`, `notLoggedIn`, `unreachable`, `failed`. The one that +matters is `notLoggedIn` — a machine nobody put an account on is working as +configured, and collapsing it into an error string made a healthy setup +read as broken. A machine with no Claude provider is not asked and gets no +row at all. + ### HTTP surface (phone ⇄ backend) REST for actions, one SSE stream per open session screen for events, all over 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 892cca6..41dee07 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt @@ -418,9 +418,21 @@ data class UsageWindow( data class UsageSnapshot( val provider: String, - val available: Boolean, + /** Stable id of the machine these numbers belong to. */ + val setup: String, + /** That machine's current label. */ + val setupName: String, + /** + * What came back: "ok", "notLoggedIn", "unreachable" or "failed". + * + * Four rather than a flag, because the screen has to treat them differently. "notLoggedIn" is a + * machine somebody chose not to put an account on -- a fact, not a fault -- while the other two + * are faults worth chasing. Collapsing them made a healthy setup read as broken. + */ + val state: String, + /** Why, for the two states that are faults. Absent otherwise. */ + val detail: String?, val windows: List, - val error: String?, ) /** The backend caches; refreshing more often than its poll interval just re-reads the cache. */ @@ -429,8 +441,12 @@ fun fetchUsage(settings: ServerSettings): List = connection.jsonObjects { snapshot -> UsageSnapshot( provider = snapshot.getString("provider"), - available = snapshot.getBoolean("available"), - error = snapshot.optString("error").ifEmpty { null }, + setup = snapshot.optString("setup"), + setupName = snapshot.optString("setupName"), + // Unknown to an older backend, and unknown is not "fine": defaulting to "ok" + // would draw an empty card as a healthy one. + state = snapshot.optString("state").ifEmpty { "failed" }, + detail = snapshot.optString("detail").ifEmpty { null }, windows = snapshot.getJSONArray("windows").mapObjects { window -> UsageWindow( diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/UsageScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/UsageScreen.kt index 75cb14c..860199c 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/UsageScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/UsageScreen.kt @@ -53,10 +53,11 @@ fun UsageScreen(settings: ServerSettings, onBack: () -> Unit) { Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp)) { Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { // Deliberately not subtitled with the provider this was opened from. These numbers - // are the *account's*, reported by whichever paid service answered -- naming the - // session's provider here made an echo session's screen read "echo" above a card - // reading "claude", which is a claim about echo that nothing measured. Each card - // names the service it came from, which is the true scope. + // belong to an account on a particular machine, reported by whichever paid service + // answered there -- naming the session's provider here made an echo session's + // screen read "echo" above a card reading "claude", which is a claim about echo + // that nothing measured. Each card names the machine and the service it came from, + // which is the true scope. Text( "Usage", style = MaterialTheme.typography.headlineSmall, @@ -71,30 +72,79 @@ fun UsageScreen(settings: ServerSettings, onBack: () -> Unit) { is LoadState.Loading -> CircularProgressIndicator() is LoadState.Error -> Text(current.message, color = MaterialTheme.colorScheme.error) is LoadState.Loaded -> - current.value.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) { + if (current.value.isEmpty()) { + // Not an error and not a blank screen: no machine offers a paid service, + // so there is genuinely nothing to report and saying so is the answer. + Text( + "No machine here runs anything with usage limits.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + current.value.forEach { snapshot -> + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(16.dp)) { + // The machine first: these are one account's numbers, and + // which account is decided by which machine ran the session. Text( - snapshot.error ?: "Unavailable", - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodyMedium, + snapshot.setupName.ifEmpty { snapshot.setup }, + style = MaterialTheme.typography.titleMedium, ) - } - snapshot.windows.forEach { window -> - WindowBar(window) - Spacer(Modifier.height(12.dp)) + Text( + snapshot.provider, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(8.dp)) + SnapshotState(snapshot) + snapshot.windows.forEach { window -> + WindowBar(window) + Spacer(Modifier.height(12.dp)) + } } } + Spacer(Modifier.height(12.dp)) } - Spacer(Modifier.height(12.dp)) } } } } +/** + * Anything other than numbers: why this machine has none. + * + * The distinction the old single message could not draw. A machine nobody has logged in on is + * working exactly as somebody set it up, so it reads as a plain statement -- marking it would be + * the interface nagging about a decision already made, and would dilute the marks that do mean + * something. Only the two faults are coloured as faults. + */ +@Composable +private fun SnapshotState(snapshot: UsageSnapshot) { + when (snapshot.state) { + "ok" -> {} + "notLoggedIn" -> + Text( + "No Claude account on this machine.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + // Reached but refused, versus never reached at all: different things to go and do, + // so they say different things rather than sharing one "unavailable". + "failed" -> + Text( + snapshot.detail ?: "Couldn't read the limits from this machine.", + style = MaterialTheme.typography.bodyMedium, + color = failedColor, + ) + else -> + Text( + snapshot.detail ?: "Couldn't reach this machine.", + style = MaterialTheme.typography.bodyMedium, + color = failedColor, + ) + } +} + @Composable private fun WindowBar(window: UsageWindow) { val color =