Merge branch 'main' of git.arirex.me:iris/ai-app

# Conflicts:
#	AGENTS.md
#	PLAN.md
#	app/androidApp/src/main/kotlin/com/example/aiapp/SessionUsageBar.kt
#	app/androidApp/src/main/kotlin/com/example/aiapp/SpawnScreen.kt
#	server/src/config.rs
#	server/src/main.rs
#	server/src/routes.rs
#	server/src/session/echo.rs
#	server/src/session/llama.rs
#	server/src/session/transport.rs
#	server/src/ssh.rs
#	server/src/usage.rs
This commit is contained in:
iris committed 2026-09-04 17:56:50 -04:00
commit 3c0214ece8
94 files changed
+8299 -9454

No files matched your search

@@ -34,20 +34,19 @@ sealed class SessionUsage {
/**
* This machine meters nothing, so there is no window to show.
*
* Separate from [Unavailable], and the distinction is the whole point: a session on `echo` or
* on a local llama.cpp has no paid quota at all, which is a fact about how it was set up and
* not a failure to find something out. The backend never asks such a machine, so it returns no
* snapshot for it -- and reading that silence as "couldn't find out" is exactly the mistake of
* answering with the nearest available word. Drawn as nothing, because there is nothing.
* Separate from [Unavailable], and the distinction is the point: a session on `echo` or on a
* local llama.cpp has no paid quota at all, which is a fact about how it was set up and not a
* failure to find something out. The backend never asks such a machine, and reading that
* silence as "couldn't find out" is answering with the nearest available word.
*/
data object NotMetered : SessionUsage()
/**
* The question could not be answered, and why.
*
* Its own state because "we couldn't find out" and "none of it is used" are the pair that must
* never share an appearance: a bar sitting at zero because a machine is unreachable reads as
* plenty of headroom, which is the opposite of the truth.
* Its own state because "we couldn't find out" and "none of it is used" must never share an
* appearance: a bar sitting at zero because a machine is unreachable reads as plenty of
* headroom.
*/
data class Unavailable(val why: String) : SessionUsage()
}
@@ -59,17 +58,14 @@ private const val REFRESH_MS = 60_000L
* One poll of every machine's limits, and the handle to ask again.
*
* A screen shows this answer in more than one place -- the bar under the session header, the colour
* of the button beside it, and the dialog that button opens -- and each of those used to fetch for
* itself. Two fetches say one thing twice and then disagree about it: the bar's copy can be a whole
* refresh interval old when the dialog opens with a fresh one, so the header read 42% while the
* screen over it read 47%, about a number somebody is deciding on. One feed per screen, and
* [refresh] moves both.
* of the button beside it, and the dialog that button opens -- and each used to fetch for itself.
* Two fetches say one thing twice and then disagree: the bar's copy can be a whole refresh interval
* old when the dialog opens with a fresh one, so the header read 42% while the screen over it read
* 47%.
*/
class UsageFeed(
val snapshots: LoadState<List<UsageSnapshot>>,
/**
* A fetch is outstanding. Only ever true over an answer already shown; see [rememberUsageFeed].
*/
/** A fetch is outstanding. Only ever true over an answer already shown. */
val refreshing: Boolean,
/** Ask the backend again now. The dialog's refresh button; the poll does it on its own. */
val refresh: () -> Unit,
@@ -102,15 +98,15 @@ class UsageFeed(
fun rememberUsageFeed(settings: ServerSettings): UsageFeed {
var snapshots by remember { mutableStateOf<LoadState<List<UsageSnapshot>>>(LoadState.Loading) }
var refreshing by remember { mutableStateOf(true) }
// Bumped to ask again now. The poll below restarts from the new value, so a manual refresh
// also resets the countdown to the next one rather than leaving one due immediately after.
// Bumped to ask again now. The poll below restarts from the new value, so a manual refresh also
// resets the countdown rather than leaving one due immediately after.
var asked by remember { mutableIntStateOf(0) }
LaunchedEffect(asked) {
while (true) {
refreshing = true
// Replaces the answer only once the next one is in hand: dropping back to Loading
// would blank a bar somebody is reading for the length of a round trip, and what was
// on screen is still the last thing the machine actually said.
// Replaces the answer only once the next one is in hand: dropping back to Loading would
// blank a bar somebody is reading for the length of a round trip, and what was on
// screen is still the last thing the machine actually said.
snapshots =
try {
LoadState.Loaded(withContext(Dispatchers.IO) { fetchUsage(settings) })
@@ -130,13 +126,11 @@ fun rememberUsageFeed(settings: ServerSettings): UsageFeed {
* Worst rather than the five-hour one, because the button it colours opens *all* of them, and a
* blue icon over a weekly quota at 97% would be the interface answering a question nobody asked.
* Taken over however many windows came back rather than the three Claude sends today -- the backend
* deliberately passes windows it does not recognise straight through, so a fourth one is a thing
* that happens rather than a thing to notice later.
* passes windows it does not recognise straight through.
*
* Every state that is not a measurement takes the ordinary control colour instead. That is the
* point where colour stops being able to help: blue is the low end of a scale here, so colouring an
* unknown blue would say "measured, and fine" about a machine nobody could reach. The dialog behind
* the button is where those say, in words, which one they are.
* unknown blue would say "measured, and fine" about a machine nobody could reach.
*/
@Composable
fun usageGlyphColour(usage: SessionUsage): Color =
@@ -154,18 +148,17 @@ fun usageGlyphColour(usage: SessionUsage): Color =
* going, and it was a screen away from the place that decision gets made. It reports on this
* session's machine alone -- the dialog is still where every machine is compared.
*
* What it shows is the paid service's own metering, fetched from the machine that holds the
* account. It is never derived from what this app has watched go past: the transcript's token
* counts are a different quantity, measured differently, and a bar shaped like a quota gauge built
* out of them would be a guess wearing a measurement's clothes.
* What it shows is the paid service's own metering, never derived from what this app has watched go
* past: the transcript's token counts are a different quantity, measured differently, and a bar
* built out of them would be a guess wearing a measurement's clothes.
*/
@Composable
fun SessionUsageBar(usage: SessionUsage, modifier: Modifier = Modifier) {
DebugStats.count("usage bar recomposed")
// The countdown moves even when the numbers do not, so it is driven by a clock of its own
// rather than recomputed at draw time: a percentage that comes back unchanged is an equal
// value, Compose skips the recomposition, and a "left" that only ticked when the quota
// happened to move would sit at a stale figure for hours.
// value, Compose skips the recomposition, and a "left" that only ticked when the quota moved
// would sit at a stale figure for hours.
var now by remember { mutableStateOf(OffsetDateTime.now()) }
LaunchedEffect(Unit) {
while (true) {
@@ -174,15 +167,14 @@ fun SessionUsageBar(usage: SessionUsage, modifier: Modifier = Modifier) {
}
}
// Nothing at all for a session that meters nothing: a row saying "unknown" there would
// report a problem about a setup somebody chose, on every screen, forever.
// Nothing at all for a session that meters nothing: a row saying "unknown" there would report
// a problem about a setup somebody chose, on every screen, forever.
//
// And nothing while the first fetch is out, which is not the same kind of silence. A
// request in flight is not a state to report -- and the session that meters nothing is
// exactly the one this cannot yet tell apart, so "5-hour usage: checking" appeared under
// an echo session for half a second and was then taken away. A row that has to be
// withdrawn is worse than one that arrives late, and this is the only state here whose
// wrongness is a matter of timing rather than of fact.
// And nothing while the first fetch is out, which is a different silence. A request in flight
// is not a state to report -- and the session that meters nothing is exactly the one this
// cannot yet tell apart, so "5-hour usage: checking" appeared under an echo session for half a
// second and was then taken away. A row that has to be withdrawn is worse than one that
// arrives late.
if (usage is SessionUsage.NotMetered || usage is SessionUsage.Waiting) {
return
}
@@ -239,9 +231,8 @@ private fun UsageNote(text: String) {
* The percentage on its own does not answer the question it gets asked, which is whether to start
* something now; 80% with twenty minutes to go and 80% with four hours to go are opposite answers.
*
* The window's end has two missing cases and they are worded differently on purpose; see
* [WindowEnd]. A window that is not running gets the percentage and nothing else, because there is
* no countdown to report and inventing one would be the same fault as inventing the number.
* The window's end has two missing cases, worded differently on purpose; see [WindowEnd]. A window
* that is not running gets the percentage and nothing else.
*/
private fun fiveHourLabel(window: UsageWindow, now: OffsetDateTime): String {
val percent = "${window.percent.toInt()}%"
@@ -265,8 +256,7 @@ private fun fiveHourLabel(window: UsageWindow, now: OffsetDateTime): String {
* account and, while a test has one set, an echo session's invented one -- and a snapshot is one
* service on one machine.
*
* Every way of having *failed* to get numbers is [SessionUsage.Unavailable] with the reason in it:
* a machine nobody logged into, one that could not be reached, a snapshot that came back empty.
* Every way of having *failed* to get numbers is [SessionUsage.Unavailable] with the reason in it.
* None of them may look like zero, and none may look like [SessionUsage.NotMetered], which is the
* machine having no quota rather than the question going unanswered.
*/