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 =