From a802522039d713e96209effd446812031d36be79 Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Fri, 4 Sep 2026 15:00:25 -0400 Subject: [PATCH 1/4] Keep visited transcripts on the phone Reopening a session downloaded the conversation again, every time, over the tunnel. It now draws from a copy of what the server has already sent and asks for one event to check that copy is still current. Per session, under cacheDir, the server's own event lines in chunks named for the range they cover -- so a coalesced page, whose lines do not say what they cover, still records it. Only the contiguous run ending at the newest chunk is served; a gap is closed by paging through it, bounded by `after` on /transcript so the page stops where the phone's copy starts and can therefore be kept. Nothing is derived and stored: rows are a rendering, and a cache of them would need throwing away on every change to the fold. Nothing here is load-bearing. Missing, evicted, damaged or unwritable all degrade to the cold open this screen did before, and the check before the stream resumes -- one request, one event -- is what stops a replaced or truncated file being spliced onto a copy of a different conversation. What that check cannot see, a line changed mid-file with the tail intact, is what Reload in session settings is for. Measured on the emulator against ui-sandbox, on a 505-event session: reopening it costs one request for one event, including scrolling the whole conversation back; a cold open is two requests and 100 events. A reset after falling 300 behind fetched the gap as four coalesced rows rather than re-fetching 104 events and discarding them. Every chunk was checked line by line against what the server says for the range its name claims, across the reset and the gap-fill. transcript-bench.sh, same viewport content and gestures, before and after: p50 16.9ms both, p90 25.6 -> 23.2ms, p99 33.5 -> 36.7ms, and the transcript's own draw accounting 0.33ms -> 0.32ms with place 0.31ms either way. Within the emulator's noise, which is what a cache must be: it changes what is fetched, not what is drawn. Building it also found that the server handed out the same transcript line two different ways. serde_json's default float parser is not correctly rounded, so a ts written as ...0757 came back from /transcript as ...0755 while the SSE stream sent the original -- invisible on screen, since a ts is drawn as a relative time, and visible here only because the cache compares a line it holds against the server's answer. Fixed with float_roundtrip, with a test that fails the moment it is dropped. Co-Authored-By: Claude Opus 5 --- AGENTS.md | 48 ++ PLAN.md | 13 +- TRANSCRIPT_CACHE.md | 510 ++++++++++++++ .../src/main/kotlin/com/example/aiapp/Api.kt | 16 +- .../kotlin/com/example/aiapp/EventStream.kt | 11 +- .../kotlin/com/example/aiapp/ImportScreen.kt | 9 - .../com/example/aiapp/SessionListScreen.kt | 17 + .../kotlin/com/example/aiapp/SessionScreen.kt | 220 +++++-- .../example/aiapp/SessionSettingsDialog.kt | 52 +- .../main/kotlin/com/example/aiapp/Sizes.kt | 20 + .../com/example/aiapp/TranscriptCache.kt | 621 ++++++++++++++++++ .../com/example/aiapp/TranscriptItems.kt | 11 +- .../com/example/aiapp/TranscriptSource.kt | 183 ++++++ .../com/example/aiapp/TranscriptCacheTest.kt | 310 +++++++++ server/Cargo.toml | 8 +- server/src/routes.rs | 10 + server/src/session/transcript.rs | 155 ++++- 17 files changed, 2140 insertions(+), 74 deletions(-) create mode 100644 TRANSCRIPT_CACHE.md create mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/Sizes.kt create mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptCache.kt create mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptSource.kt create mode 100644 app/androidApp/src/test/kotlin/com/example/aiapp/TranscriptCacheTest.kt diff --git a/AGENTS.md b/AGENTS.md index 718aa0d..e7f3962 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -829,6 +829,54 @@ machine belongs in `~/.claude/TOOLCHAIN.md` (toolchain versions) or every page. Both are off it now. The shape to watch for is a `withContext` that wraps the *fetch* and leaves the work done with the result outside it. +- **The phone keeps the transcripts it has been sent, and the design is + `TRANSCRIPT_CACHE.md`** -- read that before touching `TranscriptCache.kt`, + `TranscriptSource.kt`, or the opening and stream effects in + `SessionScreen.kt`. What the day-to-day work needs to know: + `/transcripts/v1/_//` holds the server's + own event lines in chunks named for the range they cover + (`-.rows.jsonl`, `.raw.jsonl`, and one `-open.raw.jsonl` + the live stream appends to), and only the contiguous run ending at the + newest chunk is ever served. Measured on the emulator 2026-09-04 against + the sandbox: reopening a 500-event session costs **one request for one + event** -- the probe -- and scrolling the whole conversation back costs + nothing more. A cold open of the same session is two pages, 100 events. + Four things are easy to undo by accident. + **The probe is not optional**: before a stream is resumed from a cached + cursor, `GET /transcript?before=&limit=1` has to come back as the + line the cache holds, or the cache is thrown away and the open is cold. It + is what stops a replaced or truncated file being spliced onto this phone's + copy of a different conversation, with no seam to see. + **A page fetched for the gap passes `after`** (the transcript route's own + parameter, added for this), so it stops where the phone's copy starts. A + page that overlaps a chunk cannot be stored -- a coalesced event has no + clean cut inside its delta run -- so without the bound the first scroll + back after a reset throws away everything behind it. + **Nothing here is load-bearing.** Every read has a network path beside it + giving the same answer, and a missing, evicted, damaged or unwritable cache + degrades to a cold open. Keep it that way: a cache that can blank the + screen is worse than no cache. + **Reload, in session settings, is the answer to what the probe cannot + see** -- a line changed in the middle of the file with the tail intact. + It purges and rebuilds the screen as a cold open, putting the reader back + where they were. + Exercise all of it with `./ui-sandbox.sh` and + `RUST_LOG=ai_server=debug`, which logs every page with its `before`, + `after` and what came back; `adb shell run-as com.example.aiapp ls + cache/transcripts/v1/*/` shows whether the chunk names are adjacent, + which is the one thing the screen cannot tell you. +- **The server used to hand out the same transcript line two different + ways.** `serde_json`'s default float parser is not correctly rounded, so a + `ts` of `1788546972.6030757` in the transcript came back from + `/transcript` as `...0755` while the SSE stream, serializing the same + struct, sent the original. Nothing on screen could show it -- a `ts` is + drawn as a relative time -- and what found it was the phone's cache + comparing a line it held against the server's own answer, which turned an + invisible last-bit difference into a cache silently thrown away and a + transcript downloaded again. The `float_roundtrip` feature in + `server/Cargo.toml` is the fix and + `a_line_read_back_is_the_line_that_was_written` is what keeps it; that test + fails within a second of the feature being dropped. - **ZXing only looks for a dark code on a light ground.** The enrollment QR is block characters in the terminal's foreground colour, so a dark-themed terminal renders it as a negative and the in-app scanner diff --git a/PLAN.md b/PLAN.md index f83f603..ab7427d 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1016,9 +1016,16 @@ dev-updater (Kotlin 2.4.x, CMP 1.11.x, JDK 21). Screens: Networking mirrors dev-updater's app layer (`AppsApi.kt` style thin client + pinned transport), plus an SSE client with `after=` resume driven by -connectivity/lifecycle. The app keeps no persistent transcript store — the -backend's transcript is the source of truth; the app caches only for the -screen it's showing. +connectivity/lifecycle. The backend's transcript is the source of truth, and +the app keeps a **copy of what it has already been sent** — see +`TRANSCRIPT_CACHE.md`, added 2026-09-04, because reopening a session over the +tunnel was re-downloading a conversation the phone had just read. The copy is +the server's own event lines, per session, under `cacheDir`; it is checked +against the server before a stream is resumed from it, thrown away rather than +patched when that check fails, and never load-bearing — every path that reads +it has a network path beside it giving the same answer. What the app still +does not keep is anything *derived*: the folded rows are rebuilt from events +every time. ### Notifications: two places, never both (decided 2026-08-30) diff --git a/TRANSCRIPT_CACHE.md b/TRANSCRIPT_CACHE.md new file mode 100644 index 0000000..5ab6cb9 --- /dev/null +++ b/TRANSCRIPT_CACHE.md @@ -0,0 +1,510 @@ +# The transcript cache + +Asked for by Iris on 2026-09-04: keep the transcripts of recently visited +sessions on the phone, so reopening one does not download it again. It has +to save data over the tunnel, it must not disturb a reply that is streaming +when the screen is reopened, it must never skip an event, and session +settings needs a manual reload for when the file on the machine has +changed under it. + +Built 2026-09-04. Like EXPLORER.md this records each decision with its +reason and what was rejected, so that when one changes it is changed here +rather than re-argued -- three of them changed during the building, and +"What building it changed" at the foot says which and why. What it is *not* +is the operational half: how to exercise it, and what has bitten, are in +AGENTS.md with the rest of the working notes. + +## What it is, in one paragraph + +A per-session file on the phone holding the exact JSON lines the server has +already sent, in transcript order, with a record of which sequence numbers +each run of lines covers. Everything the session screen fetches today -- +the opening window, the pages it scrolls back through, the span an anchor +restore reaches for -- is asked of the cache first and of the server only +for what the cache does not hold, and everything that arrives from the +server is written into it. The live stream then resumes from the newest +cached event, exactly as it resumes today from the newest event on screen, +so the server sends only what happened since. One tiny request checks that +the cached tail is still what the server has before the stream is opened +from it, and a button in session settings throws the cache away and +rebuilds the screen as a cold open for the cases that check cannot see. + +## The invariants + +Everything below is in service of four rules. When a decision looks +arbitrary, it is one of these forcing it. + +1. **What is on screen is what the server's transcript says, in order, + with nothing missing, for every sequence number the screen claims to + show.** The cache is a copy of server output and is never inferred, + folded, or edited on the phone. Where the copy cannot be shown to be + current, it is thrown away, not patched. +2. **A cached line is never ahead of the live cursor, and the live cursor + is never ahead of the cache.** The stream resumes from the newest cached + event, so a reply that was mid-stream when the screen closed picks up at + its next delta and folds into the same row, as it does today when the + phone merely lost the tunnel for a second. +3. **The cache is never load-bearing.** A missing, evicted, corrupt or + unwritable cache degrades to today's behaviour -- a cold open -- and + never to a blank or wrong screen. Every path that reads it has a + network path beside it that produces the same result. +4. **Data crosses the tunnel once.** A line already on the phone is not + fetched again unless the reader asks for that (the reload button) or the + check in decision 3 says it must be. + +## Decisions + +### 1. Raw server lines, on the phone, keyed by server and session + +The cache stores the server's own JSON, one event per line, byte-for-byte +as it arrived: the elements of the `/transcript` array and the `data:` +payload of each SSE frame. Reading the cache means running the same +`parseSeqEvent` the network path runs, so a cached transcript and a fetched +one cannot draw differently, and an event type this build does not know +(`SessionEvent.Unknown`) survives on disk for the build that will. + +It lives under `context.cacheDir` -- `/transcripts/v1/_//` +-- because it is exactly what that directory is for: bytes the phone can +regenerate from the server, which Android may delete under storage +pressure without asking. Keyed by the server's host and port because two +servers can hold a session with the same id (the sandbox and the real +server, or a re-enrolment), and a line from one shown against the other +is invariant 1 broken. `ServerSettings` has both fields; the key is +`"${settings.host}_${settings.port}"` with `:` never appearing in it. +The `v1` segment is the format version: any change to the layout below +bumps it, and a directory of another version is deleted on first use. + +Rejected: a database (Room, SQLite). The access pattern is "the newest N +lines" and "the lines before seq X", on files of tens of megabytes at most, +and a JSONL file per contiguous run answers both by reading from its end. +A database would be a new dependency for an index the file layout already +provides. + +Rejected: caching folded `TranscriptItem` rows instead of events. Rows are +a *rendering* of events, and their shape changes when the fold changes; +the cache would need invalidating on every app update that touched +`foldEvent`, and would still have to keep raw seqs for the stream cursor. +Events are the server's contract and the only thing that is stable. + +### 2. Chunks with explicit coverage; one contiguous run behind the cursor + +A page from the server is a set of lines *and a claim about what they +cover*, and the two are not the same thing. A coalesced page +(`coalesce=true`, which the scroll-back pager asks for) joins each run of +`assistantText` deltas into one event carrying the seq of its *oldest* +delta, so a page whose newest event has seq 1,200 may in fact cover every +line up to the `before` it was asked with, say 1,650. Nothing in the lines +themselves says so. So each stored chunk records its coverage as a +half-open range `[first, end)`, where `first` is the seq of its oldest +event and `end` is the `before` the request was made with -- or, for a +raw chunk, its newest seq plus one. + +Chunks are files named by their coverage: + + -.rows.jsonl a coalesced page; end is the `before` it was fetched with + -.raw.jsonl an uncoalesced page or a closed live run + -open.raw.jsonl the live run: appended to by the stream; end = last line's seq + 1 + +Two chunks are **adjacent** when one's `end` equals the other's `first`. +The cache serves only the contiguous run of adjacent chunks that ends at +the newest raw chunk (the **suffix**); chunks behind a gap are kept on +disk, because the gap is usually filled (decision 4), but are never served +across the gap. + +**The newest chunk is always raw.** That is what makes the stream cursor +and the check in decision 3 well defined: a raw chunk's last line is a real +event at a real seq, and the server never coalesces the newest window +("the live cursor depends on real seqs", `read_window`). It holds by +construction -- the opening window is fetched with no `before`, stream +frames are raw, and a `reset` window is raw -- and is *checked* on read: +if the newest chunk on disk is a `.rows` chunk (which can only happen if +the app died between closing one live run and appending to the next), the +session's cache is purged and the open is cold. + +There is at most one open chunk. When a stream event arrives whose seq is +not the open chunk's `end` -- which is what a `reset` looks like from +here, see decision 6 -- the open chunk is closed by renaming it with its +real end, and a new open chunk starts at the arriving seq. An event whose +seq is below the open chunk's `end` is already covered and is not written +(the SSE contract is `seq > after`, so this is a guard, not a path). + +Rejected: one file per session, rewritten to prepend older pages. A +20 MB transcript would be rewritten on every page scrolled back to. The +chunk directory costs a directory listing per open instead. + +Rejected: trimming chunks to resolve overlaps. A coalesced event cannot be +split at a seq inside its run, so an overlap between a coalesced page and +an existing chunk has no clean cut. The cache therefore **never stores a +page that overlaps an existing chunk**; decision 4 makes sure such a page +is never fetched in the first place, and if one arrives anyway (a server +without decision 4's change) it is used for display and not stored. + +### 3. The cached tail is checked against the server before the stream opens from it + +The screen must not resume a stream from a cached seq unless the server's +event at that seq is the one in the cache. The transcript file on the +machine is append-only in ordinary use, but it can be replaced or +truncated -- a sandbox re-seeded with the same ids, a backup restored, a +directory deleted and the session re-imported under the same name -- and +`catch_up` on such a file would hand the phone a continuation of a +different conversation, spliced onto the cached one with no seam. That is +the worst thing this feature can do, and it is caught with one request. + +**The probe:** `GET /sessions/{id}/transcript?before=&limit=1`, +where `cursor` is the seq of the cache's newest line. `read_window` with +that `before` returns the single newest event with seq ≤ cursor, which is +the event *at* the cursor when it exists. The probe passes when that +response, parsed with `parseSeqEvent`, is `==` to the cached line parsed +the same way -- data-class equality over seq, ts, and the whole event. It +fails when the response is empty, is a different seq, or differs in any +field. + +That equality rested on an assumption this plan stated and did not check: +that the two ways the server hands out a line agree bit for bit. **They did +not.** `serde_json`'s default float parser is not correctly rounded, so a +`ts` of `1788546972.6030757` written to the transcript came back from +`/transcript` as `...0755`, while the SSE stream -- serializing the same +struct -- sent the original. Measured on the emulator 2026-09-04: 23 of 330 +cached lines differed from the server's answer in the last bit, so the probe +would have failed on any session whose cached tail happened to be one of +them, silently and only sometimes. That is a defect in the server +independent of this feature -- two answers to "what is line 30" -- and it is +fixed there, with `float_roundtrip` and a test +(`a_line_read_back_is_the_line_that_was_written`) that fails the moment the +feature is dropped. Comparing everything *except* `ts` was the other option +and was rejected: a re-seeded fixture is identical in content and differs +only in when it happened, which is exactly the case the probe exists for. A failed probe **purges the session's cache +and proceeds as a cold open**. A probe that cannot be made (no route to +the server) leaves the cached transcript on screen, shows the request's +error on the stream banner where a connection failure shows today, and is +retried on the stream loop's schedule (`RECONNECT_DELAY_MS`); the stream +is never opened until a probe has passed once for this screen instance. + +What the probe does *not* catch: a line changed in the middle of the file +with the tail intact, or a file rewritten so that the event at the cursor +happens to be identical. Those are what the reload button is for, and the +button's caption says so. + +Cost: one request of a few hundred bytes, one round trip, in the slot +where the opening page's request is today -- so the round trips before +the stream is live are unchanged at two, and the bytes fall from a page to +a line. The cached rows are drawn *before* the probe returns, which is the +whole point of the feature; a failed probe replaces them, the same +appearance as a `reset`. + +Rejected: a server-side check on the stream (`events?after=N&ts=T`, +answered with a distinct frame when the event at N is not what the phone +thinks). Strictly better coverage -- it would run on every reconnect, not +only on open -- and no extra round trip. Not chosen because it puts a +cache's validation into a protocol that otherwise knows nothing about +caching, and because the reset frame already has to keep meaning "you are +behind, your history is fine" (decision 6), so a second frame would be +needed. Worth revisiting if the probe's round trip is ever measured as the +thing making reopen slow; note it as the alternative here and in PLAN.md. + +Rejected: trusting the cache without a check and relying on the reload +button. Invariant 1 is not something a button restores after the fact. + +Rejected: fetching the newest page as today and using it to validate the +overlap. Zero saving on the opening page, which is the request paid on +every open. + +### 4. Pages ask the server only for the gap: `after` on `/transcript` + +After a reader has been away, the cache holds `[a, b)` and the screen +holds the newest window `[W, …)` with a gap between `b` and `W`. Paging +back from `W` asks the server for a coalesced page before `W`, and that +page may reach back past `b` -- a single reply is hundreds of lines, so +forty rows can be thousands of seqs -- producing exactly the overlap +decision 2 refuses to store. Left like that, every cached chunk would be +overlapped and dropped in turn as the reader paged back through the gap, +and the cache would save nothing for the sessions it exists for. + +So the transcript route gains a lower bound. `TranscriptQuery` in +`server/src/routes.rs` gets + + /// Return nothing at or below this seq; the page stops here instead of at `limit`. + /// The phone passes the end of what it already holds, so a page never overlaps it. + #[serde(default)] + after: Option, + +named to match the SSE route's `after` (exclusive, `seq > after`). +`read_window(path, before, after, limit, coalesce)` in +`server/src/session/transcript.rs` computes +`start = first_at_or_after(after + 1)` and stops the walk there: the raw +branch parses `max(start, end - limit)..end`; `parse_coalesced` takes a +`start` and its `while index > 0` becomes `while index > start`. A delta +run cut at `start` is emitted as the partial it is, exactly as one cut by +`limit` already is, and `healSplitMessage` welds it on the phone -- no new +mechanism. The route's table comment in `routes.rs` gains the parameter, +and `transcript.rs` gets a test beside +`a_window_is_the_events_before_a_cursor_and_nothing_else`: with `after` +set, the page's oldest seq is greater than `after`, and with `after` set +inside a delta run the partial run's seq is the first delta above `after`. + +The phone passes `after = b - 1` where `b` is the `end` of the nearest +chunk whose `end ≤ before`, and nothing when there is none. A page that +comes back with `first == b` is adjacent, and the suffix now runs through +the old chunks: the gap is closed with exactly the bytes it was wide, and +the history behind it is served locally from then on. + +Rejected: fetching the gap raw in one request (`before=W&limit=W-b`, +which is what the anchor restore already does). Exact, but a gap of ten +thousand lines is several megabytes downloaded to save re-downloading +history the reader may never scroll to; the feature exists to save data. +Paging as today with a bound saves the same bytes and fetches only what +is read. + +Rejected: dropping the cached run whenever a gap opens. Being more than +`CATCH_UP_LIMIT` (200) events behind is the *ordinary* state of an active +session revisited -- 200 raw events is one reply -- so this would empty +the cache for exactly the sessions that are opened most. + +### 5. A page is served locally in rows, mirroring the server's count + +`loadOlderPage` asks for `HISTORY_PAGE` (40) **rows** when +`coalesce = true`, and for a number of **events** otherwise (the anchor +restore). Served from the cache, the events branch is the `limit` lines +before `before`. The rows branch walks back from the line before `before` +counting rows the way `parse_coalesced` does: every event that is not an +`assistantText` is a row, and each maximal run of `assistantText` lines is +one row; it stops only between rows, once `limit` rows are complete, and +returns the raw lines oldest-first. It does not join the deltas -- the +fold does that (`foldEvent` appends a delta to a preceding +`AssistantMsg`), and the joined row keeps the seq of its first delta either +way, so anchors and the next `before` land where they do today. + +A cached page is allowed to be **short**: the suffix's oldest chunk starts +at some `first`, and a walk that reaches it returns what it found. The +caller already treats a short page as a page; only an *empty* page means +"start of the conversation" (`moreHistory = false`), and the cache never +returns an empty page -- it returns `null` (a miss) and the network is +asked. The walk may cross a chunk boundary inside the suffix, since adjacent +chunks are one run; a delta run straddling a boundary counts as one row, as +it should. + +A miss is `before` **outside what the suffix covers continuously** -- above +its newest `end`, or at or below its oldest `first`. This plan first said a +miss was "no chunk of the suffix ends at `before`", which is wrong in the +commonest case there is: a warm open draws the newest eighty lines of the +live run, so the cursor the reader then scrolls back from is in the *middle* +of a chunk, not at a boundary. Under the narrower rule every warm open sent +its first backwards page to the server, and that page -- reaching back past +the run the phone already held -- overlapped it and could not be stored, so +the same history was fetched again on every visit. The feature would have +saved the opening window and nothing else. + +The row rule is a copy of the server's, and copies drift. It is short +(one comparison), it is pure, and it goes under a JVM unit test with the +same fixture as the server's `coalescing_counts_rows_and_joins_delta_runs` +-- the three cases are a run cut by the limit, a `usageDelta` inside a run +(the server flushes the run there, so it is two rows), and a page that is +all one run. + +### 6. What a `reset` means for the cache: behind, not wrong + +The server sends `reset` when the cursor is more than `CATCH_UP_LIMIT` +events behind, then the newest 200 raw events. The screen already drops +everything and rebuilds from that window. For the cache, a reset means +**the history is intact and there is a gap**: the probe passed, the file +is append-only, and the window's first seq is above the open chunk's end. +The store learns this from the first window event's seq (decision 2: +a seq that is not the open chunk's `end` closes it and opens a new chunk) +and needs no signal from the screen; the gap is filled by paging +(decision 4). + +Two things the reset handler in `SessionScreen` does not clear today and +must: `queued` and `waitingCommands`. Both are folded from events, and a +`messageQueued` whose resolving `userMessage` fell in the gap would +otherwise draw a waiting bubble for a message the session has long since +read. This is a latent bug today, made likely by the cache because a +cached tail is older than a fetched one. `contextTokens` needs no change: +`UsageDelta.context` is absolute, so the window's first one corrects it. + +### 7. Session state that is not the transcript comes from the list, not the cache + +`apply` derives `status`, `model`, `permissionMode` and `compactingSince` +from `Status` and `Settings` events. Replayed from a fetched page those are +current; replayed from the cache they are as old as the last visit, while +`summary.status`, `summary.model` and `summary.permissionMode` -- the row +the reader just tapped -- were fetched moments ago. So the cache replay +runs through `apply` for the transcript's sake (queued bubbles, context, +rows) and then **reassigns those four from `summary`**, which is the newer +of the two measurements; the stream's catch-up then makes them current. +Without this a session that finished an hour ago would open saying +"working" until the stream connected, which is a status row lying for a +round trip. + +### 8. Reload, in session settings + +`SessionSettingsDialog` gains a row under the working directory: + + [ Transcript ] 2.3 MB cached [ Reload ] + +The size is what the button discards, and it is the unknown state made +visible: `null` while the directory is being measured (spinner, as the +notifications switch does), "nothing cached" when the directory is absent +or empty, else the size. A caption in the style of Move's, because the +button costs something the reader cannot see: + + Reload throws away this phone's copy and fetches the transcript from the + server again. Use it when what is shown here disagrees with the file on + the machine. + +Pressing it: purge the session's cache directory, close the dialog, and +rebuild the screen as a cold open -- the same sequence as `reset` plus a +fresh opening fetch, with the reader put back where they were. The +mechanism is an `epoch` counter (`mutableIntStateOf(0)`) added to the key +of the opening effect and the stream effect; incrementing it cancels both +(the stream's `finally` closes the socket) and relaunches them. State the +relaunch must see cleared: `items`, `replies.clear()`, `held`, `oldestSeq += 0`, `moreHistory = true`, `queued`, `waitingCommands`, `lastSeq.set(0)`, +`ready = false`. `savedAnchor` becomes `remember(summary.id, epoch)` so +the restore path reads the anchor saved at the reader's *current* +position (the anchor saver writes on every settle, so it is there), and +`restoring` is re-derived from it. The button is enabled whether or not +anything is cached: "what I see disagrees with the machine" is a state an +empty cache can also be in, and a control that comes and goes makes its +own presence the signal. + +Nothing is announced on success. The transcript shows the opening spinner +and then the rows, which is what the screen already says about a reload. +A failure is the opening fetch's, and lands on the stream banner where +that failure lands today. + +Rejected: a global "clear transcript cache" in the app's settings screen. +Not asked for; eviction (decision 9) bounds the total, and the per-session +button is where the reader is when they notice a problem. Easy to add as +one more caller of `TranscriptCache.purgeAll` if wanted. + +### 9. Budget, eviction, pruning + +The cache is bounded three ways, each with its path out written beside +the path in: + +- **Budget.** `CACHE_BUDGET_BYTES = 256 MB` across all sessions of one + server. Each open touches the session directory's mtime; after the + opening replay, on `Dispatchers.IO`, the store sums the server's + directories and deletes least-recently-touched session directories + (never the one on screen) until under budget. 256 MB is a dozen of the + largest transcripts seen in this VM (21 MB for 24,000 events) and a + small fraction of a phone; it is a number to revisit against real use, + not a measurement. +- **Deleted sessions.** `SessionListScreen`'s delete calls + `cache.session(id).purge()` after `deleteSession` succeeds, and every + successful list fetch calls `cache.retainOnly(ids)` for that server, so + a session deleted from another device or from the backend is pruned on + the next visit to the list. `Drafts.kt` chose not to prune because its + residue is bytes; here it is megabytes, so the pass is worth having. +- **Android.** `cacheDir` may be emptied under pressure at any moment, + including while a screen is open. Every read tolerates a missing + directory (cold open) and every write failure is swallowed once and + disables writing for that screen instance (decision 10). + +### 10. The cache never breaks the screen + +Every store operation that touches the disk catches `IOException` and +answers as if the cache were empty: `null` from a read, no-op from a +write, with the failure logged once at `Log.w("ai-app", …)`. After a +write failure the `SessionCache` instance sets `disabled = true` and +writes nothing more, so a full disk costs one log line rather than one +per delta. A line at the end of an open chunk that does not parse -- the +app died mid-write -- is dropped and the file truncated to the last +good line before anything is served from it; a line that does not parse +anywhere else purges the session's cache (that file was not written by +this code). None of this is reported on screen: none of it changes what +the screen shows, and the reader has nothing to do about it. + +## Layout on disk + + /transcripts/ + v1/ + 10.0.2.2_8443/ one directory per server (host_port) + 3f2c…/ one per session id + 1-1650.rows.jsonl coalesced page: covers seqs 1..1649 + 1650-2001.rows.jsonl + 2001-2400.raw.jsonl a closed live run + 2600-open.raw.jsonl the live run; end = last line's seq + 1 + +Here 2400..2599 is a gap: the reader was away for two hundred events and +the stream reset. The suffix is the single chunk `2600-open`; the first +backwards page asks the server for `before=2600&after=2399&coalesce=true`, +and once a page comes back with `first == 2400` the suffix runs to seq 1. + +Each `.jsonl` is one JSON object per line, oldest first, exactly as the +server sent it. No header, no index: coverage is in the name, order is the +file's, and the seq is in every line. + +## What building it changed + +Each of these contradicted something written above, and each was found by +running it rather than by reading it. The decisions themselves are amended +in place; this is the list of what moved, so that a reader who remembers the +first version knows what to re-read. + +- **The probe's equality had a false premise** -- decision 3. The server did + not hand out the same line twice the same way. Fixed on the server. +- **A cached page starts anywhere inside the run** -- decision 5. Requiring + a chunk boundary would have made the cache save the opening window and + nothing else. +- **The opening window is stored by `append`, not by `storePage`.** The + sketch below had `storePage` grow a special case for "this page is the new + open chunk", decided by an implicit condition that a raw history page also + satisfies. Appending each line instead is the mechanism that already + exists, and the open chunk stays the one thing that grows. +- **Chunks are read backwards, in blocks, and never whole.** Every question + the cache is asked is about the newest end, and a live run reaches the size + of the conversation -- so reading a chunk to answer with eighty lines of it + is the cost the server's own reader was rewritten to stop paying, arriving + on the phone. Damage is therefore noticed when a read reaches it rather + than up front, which is the better time: what is not read cannot be wrong. +- **The stream waits for the opening effect's probe.** The screen lifts + `ready` before the probe returns -- that is the point of the cache -- so + `ready` stopped being the whole gate, and the stream loop asked the same + question a second time and raced its own answer. Two probes per warm open, + visible in the server's log. +- **`SessionCache` is synchronized.** The stream appends live events from + one IO thread while a reader scrolling back reads pages from another; the + open chunk's name, its end and its writer must never be seen + half-rotated. + +## What it cost, measured + +On the emulator against `app/ui-sandbox.sh`, 2026-09-04, on a session of +505 events (three short exchanges and two 300-delta replies): + +- **Reopening it: one request, for one event.** The probe, and nothing else + -- including scrolling the whole conversation back to its first line. A + cold open of the same session is two requests and 100 events. +- **A reset after falling 300 events behind costs the gap and no more.** + The window arrived at seq 306, the phone held up to 202, and the first + backwards page asked `before=306&after=201` and came back with **four + coalesced rows** covering 202..305 -- against the 104 raw events an + unbounded page would have re-fetched and then thrown away. +- **Every chunk is exactly what the server says for the range its name + claims**, checked line by line against `/transcript` for each chunk's own + `before`/`after`/`coalesce`, across a reset and a gap-fill. +- **Nothing about drawing changed**, which is what a cache must not do: + `transcript-bench.sh` before and after, same viewport content and the same + gestures, reported p50 16.9ms both times and the transcript's own draw + accounting at 0.33ms against 0.32ms. + +Still to measure, in real use rather than here: the size the cache reaches +against `CACHE_BUDGET_BYTES`, and whether the probe's round trip is ever +what a reader waits on. + +## Open questions + +- **The probe on every reconnect, not only on open?** Decision 3 probes + once per screen instance. A file replaced *while* the screen is open is + today's behaviour and not made worse, but the server-side check it + rejects would close it. Decide after measuring how often the probe's + round trip is what the reader waits on. +- **TRANSCRIPT_RENDERING.md's item 1** (a reset arriving during an anchor + restore left the screen reconnecting). The cache makes the restore + cheaper and therefore shorter, which narrows the window without closing + it; the fix there is still owed and is unrelated to this. +- **Images.** `SessionImage` fetches bytes from the files route on draw; + they are not part of this cache and are re-downloaded per view. A + separate, simpler cache (a directory of refs, no ordering) if the + measurement above says the images are where the data goes. 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 051cc71..c3e39b6 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt @@ -775,15 +775,27 @@ fun fetchTranscript( // seq). Ignored by the server for the newest window, where the live cursor needs real seqs. // See the server's `read_window`. coalesce: Boolean = false, -): List { + // Return nothing at or below this seq, stopping the page here instead of at [limit]. The + // phone passes the end of the run it already holds cached, so a page never overlaps that copy + // -- an overlap it cannot store, since a coalesced event cannot be cut at a seq inside its own + // delta run. Exclusive, like the SSE route's cursor. See TranscriptCache and `read_window`. + after: Long? = null, +): List> { val query = buildString { append("?limit=").append(limit) if (before != null) append("&before=").append(before) if (coalesce) append("&coalesce=true") + if (after != null) append("&after=").append(after) } return requestFromServer(settings, "/sessions/$sessionId/transcript$query") { connection -> val body = JSONArray(connection.inputStream.bufferedReader().readText()) - (0 until body.length()).map { parseSeqEvent(body.getJSONObject(it).toString()) } + // The text as well as the event: the transcript cache stores the one and the fold needs + // the other, and they have to be the same line -- a second entry point differing only in + // return type would be two answers to one question. + (0 until body.length()).map { + val line = body.getJSONObject(it).toString() + line to parseSeqEvent(line) + } } } diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/EventStream.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/EventStream.kt index c031e1a..4176411 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/EventStream.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/EventStream.kt @@ -27,12 +27,19 @@ class EventStream(settings: ServerSettings, private val sessionId: String) { * caller drops what it holds and rebuilds -- the same thing it does when the screen opens. It * arrives before those events, so a caller that clears on it stays in order. */ - fun run(after: Long, onOpen: () -> Unit, onReset: () -> Unit, onEvent: (SeqEvent) -> Unit) { + fun run( + after: Long, + onOpen: () -> Unit, + onReset: () -> Unit, + // The frame's own text as well as the event parsed from it: the transcript cache stores + // the one and the screen folds the other, and they have to be the same line. + onEvent: (raw: String, event: SeqEvent) -> Unit, + ) { stream.run("/sessions/$sessionId/events?after=$after", onOpen) { name, data -> // A named frame carries no payload and a data frame has no name, so this is one or // the other. if (name == RESET_EVENT) onReset() - else if (data.isNotEmpty()) onEvent(parseSeqEvent(data)) + else if (data.isNotEmpty()) onEvent(data, parseSeqEvent(data)) } } } diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/ImportScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/ImportScreen.kt index d1cfd65..6f817e9 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/ImportScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/ImportScreen.kt @@ -667,15 +667,6 @@ private fun ImportableList( } } -/** A byte count at the coarsest unit that still says something, so rows stay comparable. */ -private fun humanSize(bytes: Long): String? = - when { - bytes <= 0L -> null - bytes >= 1_000_000L -> "${bytes / 1_000_000L} MB" - bytes >= 1_000L -> "${bytes / 1_000L} kB" - else -> "$bytes B" - } - /** What this session is: the measurements, in the order they are worth knowing. */ private fun statsOf(session: Importable): String = listOfNotNull( 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 832b087..5f63f55 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionListScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionListScreen.kt @@ -29,6 +29,7 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -68,6 +69,11 @@ fun SessionListScreen( // request rather than to the session. var deleting by remember { mutableStateOf>(emptySet()) } + // This phone's copies of these sessions' transcripts, pruned from here because this is where + // a session stops existing. See TranscriptCache. + val context = LocalContext.current + val transcriptCache = remember(settings) { TranscriptCache(cacheRoot(context, settings)) } + fun refresh() { listState = LoadState.Loading scope.launch { @@ -76,6 +82,14 @@ fun SessionListScreen( val loaded = withContext(Dispatchers.IO) { LoadState.Loaded(fetchSessions(settings)) } deleteErrors = emptyMap() + // The path out for a cached transcript whose session was deleted somewhere + // else -- from another device, or at the backend. This list is the only place + // that ever learns the full set, and what the residue costs here is megabytes + // rather than a draft's few bytes. On the answer rather than in `finally`: a + // list that failed to arrive says nothing about which sessions exist. + withContext(Dispatchers.IO) { + transcriptCache.retainOnly(loaded.value.map { it.id }.toSet()) + } loaded } catch (e: ApiException) { LoadState.failed(e) @@ -223,6 +237,9 @@ fun SessionListScreen( try { withContext(Dispatchers.IO) { deleteSession(settings, session.id, alsoDeleteForeign) + // After it succeeded, not before: a refused delete leaves the + // session exactly as it was, and its transcript with it. + transcriptCache.session(session.id).purge() } // Only this row, and only what changed. Refetching the list // instead put every other session back through loading and diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt index 78834f6..9722eb8 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt @@ -81,7 +81,6 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.repeatOnLifecycle import java.util.concurrent.atomic.AtomicLong -import java.util.concurrent.atomic.AtomicReference import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.delay @@ -324,7 +323,27 @@ fun SessionScreen( val lifecycleOwner = LocalLifecycleOwner.current // The resume cursor, written from the stream's IO thread. val lastSeq = remember { AtomicLong(0) } - val activeStream = remember { AtomicReference(null) } + // Bumped to rebuild this screen from nothing -- what Reload in the settings dialog does. It + // keys everything that describes one visit to this session: the source below, the opening + // effect, the stream, and the anchor being put back. See TRANSCRIPT_CACHE.md's decision 8. + var epoch by remember(summary.id) { mutableIntStateOf(0) } + // This server's cached transcripts, and this session's half of them. The cache is per server + // because two servers can hold a session with the same id; the source is per visit because + // Reload throws away what it was reading from. + val cache = remember(settings) { TranscriptCache(cacheRoot(context, settings)) } + val source = + remember(summary.id, epoch) { + TranscriptSource(settings, summary.id, cache.session(summary.id)) + } + // Whether the cached tail has been shown to still be the server's own line. Nothing is + // resumed from a cached cursor until it has -- see [TranscriptSource.probe] -- and a probe + // that could not be made leaves this false for the stream loop to try again. + var probePassed by remember(summary.id, epoch) { mutableStateOf(false) } + // Whether the opening effect is still settling that question. It draws the cached rows and + // lifts [ready] before the answer arrives, which is the point of the cache -- so the stream + // below has to wait for this rather than for `ready`, or it asks the very same question a + // second time and races the answer. + var probing by remember(summary.id, epoch) { mutableStateOf(true) } // The oldest sequence number loaded, and whether there is more behind // it. Paging backwards is what keeps opening a long session cheap: the // screen starts with the end of the conversation and fetches earlier @@ -333,12 +352,12 @@ fun SessionScreen( // Where this session was last being read, from this device's own store. Read once, because // it is the question "where did I leave off" and the answer stops being interesting the // moment the list is on screen. - val savedAnchor = remember(summary.id) { loadScrollAnchor(context, summary.id) } + val savedAnchor = remember(summary.id, epoch) { loadScrollAnchor(context, summary.id) } // Whether the saved position is still being put back -- the history it needs fetched, and the // scroll applied. Nothing is drawn while it is: opening at the newest end and then travelling // to the anchor is exactly the journey a reader must never see, and this transcript is not // allowed to move under one. - var restoring by remember(summary.id) { mutableStateOf(savedAnchor != null) } + var restoring by remember(summary.id, epoch) { mutableStateOf(savedAnchor != null) } // Sent, but not yet read by the session -- which is when the backend // records it and it comes back as a row. Until then it is drawn below // the working indicator, because that is where it is in the session's @@ -400,6 +419,31 @@ fun SessionScreen( val currentUnits by rememberUpdatedState(units) val lastTouch = remember { LastTouch() } + /** + * Drops everything loaded, so the screen can be rebuilt from a window that is not adjacent to + * it. + * + * One function rather than a clearing written at each of the three places that need it -- a + * stream reset, a cached transcript the server turns out not to have, and Reload -- because + * what has to go is a property of "these rows are no longer continuous with what comes next", + * not of who noticed. The two easy ones to leave out are [queued] and [waitingCommands]: both + * are folded from events, so a `messageQueued` whose resolving `userMessage` fell in the gap + * draws a bubble waiting for a message the session read long ago. [contextTokens] needs no + * clearing, because `UsageDelta.context` is absolute and the next one corrects it. + * + * The resume cursor is deliberately *not* cleared here: a reset continues from where it was, + * and only a caller that is starting the conversation again from the server says so itself. + */ + fun dropLoadedTranscript() { + items = listOf() + replies.clear() + held = listOf() + oldestSeq = 0L + moreHistory = true + queued = listOf() + waitingCommands = listOf() + } + /** * Everything the transcript list draws, from one event. * @@ -614,14 +658,7 @@ fun SessionScreen( // `items` read below happens back on the caller's thread, where the write does too. val page = withContext(Dispatchers.IO) { - val older = - fetchTranscript( - settings, - summary.id, - before = oldestSeq, - limit = limit, - coalesce = coalesce, - ) + val older = source.page(before = oldestSeq, limit = limit, coalesce = coalesce) if (older.isEmpty()) return@withContext null // Folded oldest-first into a list of their own, then put in front: `foldEvent` // merges streaming text into the item before it, so replaying an older page @@ -694,17 +731,22 @@ fun SessionScreen( // The stream lifecycle: connect, follow, and on any drop reconnect // from the cursor -- so a flaky link (or a backend restart) costs // nothing but the gap's latency. - // The newest page first, in one request, before the stream opens. The - // stream then starts from where that page ended, so it carries live - // events only -- which is what it is good at. - LaunchedEffect(summary.id) { - try { - val page = withContext(Dispatchers.IO) { fetchTranscript(settings, summary.id) } - // Warmed before the fold lands rather than after: flattening the rows into units - // splits every settled reply ([transcriptUnits]), and the flatten runs in the - // composition that first sees the rows. Folded into a scratch list off this thread - // to find out what needs warming; the real fold below also maintains the queue and - // the cursor, so it cannot be reused here. + // The newest window first, before the stream opens, so the stream starts from where that + // window ended and carries live events only -- which is what it is good at. The window comes + // from this phone's own copy when there is one, and then costs a single request to check + // that the server's transcript is still the one it came from; otherwise it is a page fetched + // as it always was. See TRANSCRIPT_CACHE.md. + LaunchedEffect(summary.id, epoch) { + /** + * One opening window onto the screen, whichever side it came from. + * + * Warmed before the fold lands rather than after: flattening the rows into units splits + * every settled reply ([transcriptUnits]), and the flatten runs in the composition that + * first sees the rows. Folded into a scratch list off this thread to find out what needs + * warming; the real fold below also maintains the queue and the cursor, so it cannot be + * reused here. + */ + suspend fun open(page: List) { withContext(Dispatchers.IO) { var scratch = listOf() page.forEach { entry -> @@ -715,6 +757,58 @@ fun SessionScreen( warm(replies, scratch) } page.forEach { apply(it) } + } + + try { + // This phone's own copy first, drawn before anything is asked of the server -- which + // is the whole point of the cache. What makes it safe to draw before it is checked is + // that a failed check replaces these rows, with the same appearance as a reset. + val cached = withContext(Dispatchers.IO) { source.cachedOpening() } + if (cached != null) { + open(cached) + // A replay is as old as the last visit; the row this screen was opened from was + // fetched moments ago. So the transcript comes from the cache and everything that + // is not the transcript comes from the summary, which is the newer measurement of + // the same thing -- otherwise a session that finished an hour ago opens saying + // "working" until the stream connects, which is a status row lying for a round + // trip. + status = summary.status + model = summary.model + permissionMode = summary.permissionMode ?: "auto" + if (summary.status != "compacting") compactingSince = null + // Nothing to put back, so these rows are the screen and the probe can return + // under them. A restore still has history to fetch and is gated below. + if (savedAnchor == null) ready = true + } + // The one thing a cached cursor has to be shown before the stream resumes from it. + val usable = cached != null && withContext(Dispatchers.IO) { source.probe() } + if (usable) probePassed = true + if (!usable) { + // Either there was nothing cached, or what was cached is not what the server + // has -- the file was replaced or truncated under it. Same clearing as a reset, + // then an ordinary cold open. + if (cached != null) { + dropLoadedTranscript() + lastSeq.set(0) + } + open(withContext(Dispatchers.IO) { source.fetchOpening() }) + // Refilled from the server, so the tail is the server's by construction. + probePassed = true + } + } catch (e: ApiException) { + // Not fatal: the stream below still replays from zero, which is slow but complete. + // Saying so beats silently showing nothing. + // + // It is also where a probe that could not be *made* lands -- a phone with no route to + // the server. Whatever was cached stays on screen and [probePassed] stays false, so + // the stream loop asks again before it resumes from that cursor. + streamError = e.message + } finally { + // However that went, the stream is free to take it from here. + probing = false + } + + try { // Then back where reading stopped. An anchor deeper than the newest page is exactly // the one worth restoring -- somebody who read to the bottom has no anchor at all -- // and the cost was already paid on the way down there. @@ -792,8 +886,8 @@ fun SessionScreen( } } } catch (e: ApiException) { - // Not fatal: the stream below still replays from zero, which is - // slow but complete. Saying so beats silently showing nothing. + // A page of history that never arrived. The reader is left at the newest end rather + // than where they were, which is the state this screen opens in anyway. streamError = e.message } // Whatever happened above, including a page that never arrived: an empty transcript is a @@ -816,6 +910,13 @@ fun SessionScreen( loadingHistory = false } } + // Last, and off this thread: this session is what must not be evicted, so it is marked + // as visited before the budget is applied, and both are a walk of the cache directory + // that nothing on screen is waiting for. + withContext(Dispatchers.IO) { + source.cache.touch() + cache.evictToBudget(keep = summary.id) + } } // Only while the screen is actually on screen. Android stops the @@ -826,16 +927,37 @@ fun SessionScreen( // that has been backgrounded is work nobody is watching. Stopping the // stream deliberately makes the drop a close rather than an error (see // EventStream.close), and resuming reconnects from the same cursor. - LaunchedEffect(summary.id, ready, lifecycleOwner) { + LaunchedEffect(summary.id, ready, epoch, lifecycleOwner) { if (!ready) return@LaunchedEffect + // The opening effect draws cached rows and lifts `ready` *before* it has checked that the + // cursor under them is still the server's, so `ready` is no longer the whole gate: this + // waits for that check to settle. Without it the two run at once, ask the same question + // twice, and race each other's answer -- two probes per warm open in the server's log. + snapshotFlow { probing }.first { !it } lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { try { while (true) { - val stream = EventStream(settings, summary.id) - activeStream.set(stream) try { + // A cached cursor whose probe never got an answer, because the server + // could not be reached when the screen opened. Resuming a stream from an + // unchecked cursor is the one thing this must not do, so it is asked + // again here, on the reconnect schedule, with the cached rows still on + // screen meanwhile. False covers both answers that mean "open cold": + // the file is not the one these rows came from, and there was nothing + // cached to check. + if (!probePassed) { + if (withContext(Dispatchers.IO) { source.probe() }) { + probePassed = true + } else { + dropLoadedTranscript() + lastSeq.set(0) + withContext(Dispatchers.IO) { source.fetchOpening() } + .forEach { apply(it) } + probePassed = true + } + } withContext(Dispatchers.IO) { - stream.run( + source.follow( after = lastSeq.get(), // Connected, measured rather than inferred: this is what // takes a failure off the screen, and nothing else does. @@ -850,11 +972,10 @@ fun SessionScreen( // is what makes this the same as opening the // screen -- `apply` refills them, and scrolling // up pages the rest back in as it always does. - items = listOf() - replies.clear() - held = listOf() - oldestSeq = 0L - moreHistory = true + // The cache needs no telling: the window's first + // seq is not the seq it was expecting, which is + // what closes its live run and starts another. + dropLoadedTranscript() }, ) { entry -> apply(entry) @@ -870,7 +991,7 @@ fun SessionScreen( // closing the app over. Reported on the screen either way. streamError = e.message ?: e::class.simpleName } finally { - stream.close() + source.close() } delay(RECONNECT_DELAY_MS) } @@ -878,14 +999,15 @@ fun SessionScreen( // Cancellation -- going below STARTED, or leaving the screen -- // cannot interrupt a blocking socket read. Closing is what // unblocks it, and what marks the drop deliberate. - activeStream.getAndSet(null)?.close() + source.close() } } } // The screen going away entirely, which the lifecycle scope above does // not cover: a composable can leave the composition while the activity - // stays started. - DisposableEffect(summary.id) { onDispose { activeStream.get()?.close() } } + // stays started. Keyed on the epoch as well, so that Reload's replacement + // source is the one a later disposal closes. + DisposableEffect(summary.id, epoch) { onDispose { source.close() } } // Nothing gets announced about the session somebody is reading; see NotificationService. // RESUMED rather than STARTED because "looking at it" means the foreground -- a session left @@ -1936,10 +2058,32 @@ fun SessionScreen( UsageDialog(feed = usageFeed, onDismiss = { usageOpen = false }) } if (settingsOpen) { + // Measured when the dialog opens rather than kept up to date: what the reader is being + // told is what pressing the button now would discard, and null until the walk of the + // directory returns is what not knowing looks like. + var cachedBytes by remember(summary.id, epoch) { mutableStateOf(null) } + LaunchedEffect(summary.id, epoch) { + cachedBytes = withContext(Dispatchers.IO) { source.cache.bytes() } + } SessionSettingsDialog( settings = settings, sessionId = summary.id, title = title, + cachedBytes = cachedBytes, + // The purge finishes before the epoch moves, because the relaunched opening effect + // reads the same directory and would otherwise draw what is about to be deleted. + // Everything else here is the clearing a cold open needs; the epoch is what makes it + // one, by rebuilding the opening effect, the stream, and the anchor being put back. + onReload = { + settingsOpen = false + scope.launch { + withContext(Dispatchers.IO) { source.cache.purge() } + dropLoadedTranscript() + lastSeq.set(0) + ready = false + epoch++ + } + }, // The header takes the new name at once and the dialog closes on it, because the // rename has already been accepted by the server -- see [title], which is this app's // own datum. The list behind this refetches on the way out of the session anyway. diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionSettingsDialog.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionSettingsDialog.kt index d04f52b..bccbe09 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionSettingsDialog.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionSettingsDialog.kt @@ -42,9 +42,11 @@ import kotlinx.coroutines.withContext * are changed *while* reading a turn -- "not this model, try that one" -- and a control belongs * with the thing it acts on. * - * Nothing here is captioned. Each control is a labelled noun with a switch or a field beside it, - * and a paragraph under every one of them made the dialog longer than the conversation it covers. - * Failures still get their words: those are what the reader cannot work out by looking. + * Captions are for what a control costs rather than for what it is. Each control is a labelled noun + * with a switch or a field beside it, and a paragraph under every one of them made the dialog + * longer than the conversation it covers -- so Notifications has none, while Move and Reload do, + * because what those two take away is not visible from here. Failures get their words for the same + * reason: they are what the reader cannot work out by looking. */ @Composable fun SessionSettingsDialog( @@ -55,6 +57,12 @@ fun SessionSettingsDialog( */ title: String, onRenamed: (String) -> Unit, + /** + * What this phone is holding of the conversation, or null while that is being measured -- see + * the Reload row below, which is what would discard it. + */ + cachedBytes: Long?, + onReload: () -> Unit, onDismiss: () -> Unit, ) { val scope = rememberCoroutineScope() @@ -250,6 +258,44 @@ fun SessionSettingsDialog( style = MaterialTheme.typography.bodySmall, ) } + Spacer(Modifier.height(8.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Transcript", modifier = Modifier.weight(1f)) + // The size is what the button discards, and the unknown state is drawn + // rather than guessed: a spinner while the directory is being measured, and + // words when there is nothing there, because "nothing cached" and "0 B" read + // as different claims. + when { + cachedBytes == null -> + CircularProgressIndicator( + modifier = Modifier.width(16.dp).height(16.dp), + strokeWidth = 2.dp, + ) + else -> + Text( + humanSize(cachedBytes)?.let { "$it cached" } ?: "nothing cached", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(Modifier.width(12.dp)) + // Enabled whether or not anything is cached: "what I see disagrees with the + // machine" is a state an empty cache can be in too, and a control that comes + // and goes makes its own presence the signal. + TextButton(onClick = onReload) { Text("Reload") } + } + // Captioned, unlike the controls above it, for the same reason Move is: what it + // costs is not visible, and neither is the case it exists for. + Text( + "Reload throws away this phone's copy and fetches the transcript from the " + + "server again. Use it when what is shown here disagrees with the file " + + "on the machine.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) error?.let { Spacer(Modifier.height(8.dp)) Text( diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Sizes.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Sizes.kt new file mode 100644 index 0000000..1255f1b --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Sizes.kt @@ -0,0 +1,20 @@ +package com.example.aiapp + +/** + * A byte count at the coarsest unit that still says something, so two of them stay comparable. + * + * Null for nothing at all, which is a different answer from a small number and is drawn with words + * rather than a figure: an import row with no size says nothing about size, and a transcript cache + * holding nothing says "nothing cached". + * + * Here rather than beside either caller because a second copy of it would drift, and there is + * already one variant too many -- `ModelsScreen`'s `gigabytes` writes a download's size to two + * decimal places, which is a different question about a much larger number. + */ +fun humanSize(bytes: Long): String? = + when { + bytes <= 0L -> null + bytes >= 1_000_000L -> "${bytes / 1_000_000L} MB" + bytes >= 1_000L -> "${bytes / 1_000L} kB" + else -> "$bytes B" + } diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptCache.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptCache.kt new file mode 100644 index 0000000..d985416 --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptCache.kt @@ -0,0 +1,621 @@ +package com.example.aiapp + +import android.util.Log +import java.io.BufferedWriter +import java.io.File +import java.io.FileWriter +import java.io.IOException +import java.io.RandomAccessFile + +/** + * This phone's copy of the transcripts it has already been sent, so reopening a session does not + * download it again. + * + * What is stored is the server's own JSON for one event per line, in transcript order -- the + * elements of a `/transcript` page and the payload of each SSE frame. Reading the cache means + * running the same [parseSeqEvent] the network path runs, so a cached transcript and a fetched one + * cannot draw differently, and an event type this build does not know ([SessionEvent.Unknown]) + * keeps every field it arrived with, on disk, for the build that will. Rows are deliberately *not* + * what is stored: a row is a rendering of events, its shape changes whenever the fold does, and a + * cache of rows would need throwing away on every app update that touched `foldEvent`. + * + * See TRANSCRIPT_CACHE.md for the design. Four rules run through all of it: + * 1. what is on screen is what the server's transcript says, in order, with nothing missing -- the + * cache is a copy and is never inferred, folded or edited here; + * 2. a cached line is never ahead of the live cursor, and the cursor never ahead of the cache; + * 3. the cache is never load-bearing -- missing, evicted, damaged or unwritable all degrade to a + * cold open, never to a blank or a wrong screen; + * 4. a line already on the phone is not fetched again. + * + * A plain [File] root and no Compose, `Context` or network, so the whole of the file logic runs + * under the JVM unit tests. It is also why there is no JSON parser in here: what it needs off a + * line is the sequence number and whether the line is a streamed delta, and both are read with a + * regex over text the server wrote. A line it cannot read that way is treated as damage, which + * gives the same answer as having no cache at all. + * + * [warn] is where failures are said, for the same reason -- `android.util.Log` is a stub that + * throws under the JVM tests, and this file has to be exercisable there. + */ +class TranscriptCache( + private val root: File, + private val warn: (String) -> Unit = { Log.w("ai-app", it) }, +) { + /** The cache for one session, whether or not anything has been stored for it yet. */ + fun session(id: String): SessionCache = SessionCache(File(root, id), warn) + + /** + * Deletes every session directory not in [ids], called after a successful list fetch. + * + * The path out for a session deleted on another device or at the backend: nothing here would + * otherwise ever hear about it, and unlike a draft's few bytes what it leaves behind is + * megabytes. + */ + fun retainOnly(ids: Set) = + guardIo(Unit, warn) { + sessionDirs().forEach { if (it.name !in ids) it.deleteRecursively() } + } + + /** + * Deletes least-recently-touched session directories, never [keep], until the whole of this + * server's cache is under [budget]. + * + * Least-recently-touched rather than largest: what a reader is likely to open again is what + * they opened last, and evicting the big ones first would empty the cache for exactly the + * conversations it exists for. + */ + fun evictToBudget(keep: String, budget: Long = CACHE_BUDGET_BYTES) = + guardIo(Unit, warn) { + val dirs = sessionDirs().sortedBy { it.lastModified() } + var total = dirs.sumOf { sizeOf(it) } + for (dir in dirs) { + if (total <= budget) break + if (dir.name == keep) continue + val was = sizeOf(dir) + if (dir.deleteRecursively()) total -= was + } + } + + fun purgeAll() = guardIo(Unit, warn) { root.deleteRecursively() } + + private fun sessionDirs(): List = root.listFiles()?.filter { it.isDirectory }.orEmpty() +} + +/** + * How much of this phone's cache directory all of one server's transcripts may take. + * + * A dozen of the largest transcripts seen in the dev VM (21 MB for 24,000 events) and a small + * fraction of a phone. A number to revisit against real use rather than a measurement of anything. + */ +const val CACHE_BUDGET_BYTES: Long = 256L * 1000 * 1000 + +/** + * What the newest cached line says, which is what the probe checks against the server. + * + * Both halves are wanted together and by the same caller: the seq is what the request asks about, + * and the line is what its answer is compared with. + */ +data class CachedTail(val seq: Long, val line: String) + +/** + * One session's cached lines, as a directory of chunks. + * + * A chunk is a set of lines *and a claim about what they cover*, and the two are not the same + * thing: a coalesced page joins each run of streamed deltas into one event carrying the seq of the + * run's oldest delta, so a page whose newest event is seq 1,200 may in fact cover everything up to + * the 1,650 it was fetched with, and nothing in the lines says so. So coverage is the half-open + * range in the file's name: + * ``` + * -.rows.jsonl a coalesced page; end is the `before` it was fetched with + * -.raw.jsonl an uncoalesced page, or a closed live run + * -open.raw.jsonl the live run; end is its last line's seq + 1 + * ``` + * + * Two chunks are adjacent when one's `end` is the other's `first`. Only the contiguous run of + * adjacent chunks ending at the newest chunk -- the **suffix** -- is ever served: chunks behind a + * gap are kept, because the gap is usually closed by paging back through it, but nothing is served + * across one. + * + * **The newest chunk is always raw**, which is what makes the stream cursor and the probe well + * defined -- a raw chunk's last line is a real event at a real seq, and the server never coalesces + * the newest window. It holds by construction (the opening window and every stream frame are raw) + * and is checked on read: a `.rows` chunk at the newest end can only mean this app died between + * closing one live run and opening the next, and it discards the session. + * + * Nothing here is load-bearing. Every operation that touches the disk answers as though the cache + * were empty when it cannot, and a write failure disables writing for the rest of this instance's + * life so that a full disk costs one log line rather than one per delta. + * + * Every operation is synchronized, because two of them really do run at once: the stream appends + * live events from its own IO thread while a reader scrolling back reads pages from another. The + * lock is uncontended in the ordinary case and what it buys is that the open chunk's name, its end + * and its writer are never read half-rotated -- which would show up as a page silently fetched + * again, or as a stored chunk overlapping the run it was written beside. + */ +class SessionCache( + private val dir: File, + private val warn: (String) -> Unit = { Log.w("ai-app", it) }, +) { + /** Set by the first write that fails: a second would fail the same way, once per delta. */ + private var disabled = false + /** + * The open chunk's writer, its file, and the seq that chunk now ends at. + * + * Buffered, and flushed on [flush], because a delta is a hundred bytes and arrives dozens of + * times a second while a reply streams -- a syscall each is the thing to avoid. What that costs + * is the unflushed tail on a crash, which is safe: a shorter cache is a longer catch-up, never + * a wrong one. + */ + private var writer: BufferedWriter? = null + private var openFile: File? = null + private var openEnd: Long = 0 + + /** + * The newest line of the suffix, or null when there is none or the newest chunk is not raw. + * + * This is the cursor the live stream would resume from, so it is also what has to be shown to + * still be the server's own line before anything is resumed from it -- see + * `TranscriptSource.probe`. + */ + @Synchronized + fun tail(): CachedTail? = + guard(null) { + val newest = suffix().lastOrNull() ?: return@guard null + var found: CachedTail? = null + eachLine(newest) { line -> + found = CachedTail(seqOf(line)!!, line) + false + } + found + } + + /** The newest [limit] lines of the suffix, oldest first -- the opening window. */ + @Synchronized + fun newest(limit: Int): List = + guard(emptyList()) { + val taken = ArrayDeque() + for (chunk in suffix().asReversed()) { + if (taken.size >= limit) break + eachLine(chunk) { line -> + taken.addFirst(line) + taken.size < limit + } + } + taken.toList() + } + + /** + * The page of lines before [before], oldest first, or null when the cache cannot answer. + * + * Null is a miss -- the suffix does not cover the ground immediately below [before] -- and + * means the server has to be asked. It is deliberately not an empty list: an empty page is how + * the screen is told it has reached the start of the conversation, and a cache saying that of + * history it merely does not hold would stop the transcript scrolling back for good. + * + * [before] is anywhere inside the suffix, not only at a chunk boundary. The cursor a warm open + * leaves behind is in the middle of the live run -- the screen draws the newest eighty lines of + * it -- so a cache that could only answer at a boundary would send the very first backwards + * page to the server and, since that page would overlap the run, keep none of it. + * + * A short page is fine, and is what a walk that reaches the oldest chunk of the suffix returns: + * the caller already treats a short page as a page. + * + * With [rows] the count is rows rather than lines, mirroring the server's `parse_coalesced`: + * every event that is not a streamed delta is a row, and each maximal run of deltas is one row. + * The deltas are not joined here -- `foldEvent` does that, and the joined row keeps the seq of + * its first delta either way, so anchors and the next `before` land where they do today. + */ + @Synchronized + fun page(before: Long, limit: Int, rows: Boolean): List? = + guard(null) { + val suffix = suffix() + val newest = suffix.lastOrNull() ?: return@guard null + // Above what is held, or at or below where it starts: either way the run the caller + // is scrolling into is not continuous with this one, and only the server has it. + if (before > newest.end || before <= suffix.first().first) return@guard null + val taken = ArrayDeque() + var counted = 0 + var inRun = false + var wanting = true + for (chunk in suffix.asReversed()) { + if (!wanting) break + if (chunk.first >= before) continue + eachLine(chunk) { line -> + // The page is what is *before* the cursor; the rows at or above it are the + // ones already on screen. + if (seqOf(line)!! >= before) return@eachLine true + if (rows) { + val delta = isDelta(line) + // Stop only between rows: a delta continuing the run being gathered is + // part of a row already counted, and breaking on it would drop the half + // of that row already taken. + if (counted >= limit && !(delta && inRun)) wanting = false + else { + if (!delta || !inRun) counted++ + inRun = delta + } + } else if (taken.size >= limit) { + wanting = false + } + if (wanting) taken.addFirst(line) + wanting + } + } + taken.toList() + } + + /** + * The `end` of the nearest chunk at or below [before], which is the floor a fetched page is + * asked with so that it stops where this phone's copy starts. Null when there is no such chunk. + * + * Any chunk, not only the suffix's: the whole point is to reach the run behind a gap, so that + * the gap is closed with exactly the bytes it is wide and the history behind it is served + * locally from then on. + */ + @Synchronized + fun coveredUpTo(before: Long): Long? = + guard(null) { chunks().map { it.end }.filter { it <= before }.maxOrNull() } + + /** + * Stores a fetched page covering `[first, end)`; false when it was not stored. + * + * Refused when it overlaps a chunk already here, because there is no clean cut: a coalesced + * event cannot be split at a seq inside its own delta run. `TranscriptSource` keeps that from + * arising by bounding what it fetches, and this is the guard for a page that arrives anyway -- + * from a server without the `after` parameter, say. Such a page is still drawn; it is only not + * kept. + * + * The newest chunk is never stored through here: the opening window and every live frame go + * through [append], which is what keeps the newest chunk raw and open. + */ + @Synchronized + fun storePage(lines: List, first: Long, end: Long, rows: Boolean): Boolean = + guard(false) { + if (disabled || lines.isEmpty() || end <= first) return@guard false + if (chunks().any { first < it.end && it.first < end }) return@guard false + dir.mkdirs() + val kind = if (rows) "rows" else "raw" + File(dir, "$first-$end.$kind.jsonl").writeText(lines.joinToString("\n", postfix = "\n")) + true + } + + /** + * Appends one live event, which is also how a freshly fetched opening window is stored. + * + * A seq equal to the open chunk's end extends it. A larger one is a gap -- which is what a + * `reset` looks like from here -- and closes the open chunk under the end it turned out to have + * before starting a new one at [seq]. A smaller one is already covered and is ignored; the SSE + * contract is `seq > after`, so that is a guard rather than a path. + */ + @Synchronized + fun append(line: String, seq: Long) = + guard(Unit) { + if (disabled) return@guard + val writer = writerFor(seq) ?: return@guard + // Written as it arrived. A newline inside it would split one event into two + // unreadable halves, but neither source can produce one: SSE framing forbids it, and + // a page's elements are re-serialized compactly, which escapes it. + writer.write(line) + writer.write("\n") + openEnd = seq + 1 + } + + /** + * Flushes what [append] has buffered. Called on each `Status` event -- the boundaries of a + * turn, which is the granularity a crash may as well lose -- and when the stream closes. + */ + @Synchronized fun flush() = guard(Unit) { writer?.flush() } + + /** What [purge] would discard, for the reload row in session settings. */ + @Synchronized fun bytes(): Long = guard(0L) { sizeOf(dir) } + + /** Marks this session as visited, which is what eviction ranks by. */ + @Synchronized + fun touch() = + guard(Unit) { if (dir.isDirectory) dir.setLastModified(System.currentTimeMillis()) } + + @Synchronized + fun purge() = + guard(Unit) { + closeWriter() + dir.deleteRecursively() + } + + // -- chunks ------------------------------------------------------------------------------ + + private data class Chunk(val file: File, val first: Long, val end: Long, val open: Boolean) { + val rows: Boolean + get() = file.name.endsWith(".rows.jsonl") + } + + /** + * Every chunk on disk, oldest first. A name this does not recognise is not ours and is ignored. + * + * Recomputed per operation rather than kept: another operation may have changed the directory, + * and a hundred names is a directory listing. + */ + private fun chunks(): List { + writer?.flush() + return dir.listFiles() + .orEmpty() + .mapNotNull { file -> + val match = CHUNK_NAME.matchEntire(file.name) ?: return@mapNotNull null + val first = match.groupValues[1].toLongOrNull() ?: return@mapNotNull null + val open = match.groupValues[2] == "open" + val end = if (open) openEndOf(file, first) else match.groupValues[2].toLongOrNull() + // A chunk covering nothing is one that was created and never written to -- an + // append whose very first write failed. It says nothing, so it is not a chunk. + if (end == null || end <= first) null else Chunk(file, first, end, open) + } + .sortedBy { it.first } + } + + /** + * The open chunk's end: its last line's seq plus one, or the in-memory end while this instance + * is the one writing it. + * + * An open chunk whose last line cannot be read is this app having died mid-write. That line is + * dropped and the file truncated to the last good one before anything is served from it, which + * is the one place damage is repaired rather than discarded: the tail of an append-only file is + * the only place a partial line can be. + */ + private fun openEndOf(file: File, first: Long): Long { + if (openFile == file && openEnd > 0) return openEnd + repairTail(file) + var end = first + eachLineBackwards(file) { _, line -> + seqOf(line)?.let { end = it + 1 } + false + } + return end + } + + /** + * The contiguous run of adjacent chunks ending at the newest one, oldest first. + * + * A newest chunk that is not raw cannot happen while this code is the only writer, and means + * the directory is not to be trusted -- so the session is discarded rather than served across + * whatever else is wrong with it. + */ + private fun suffix(): List { + val all = chunks() + var index = all.size - 1 + val newest = all.lastOrNull() ?: return emptyList() + if (newest.rows) throw Damaged(newest.file) + val run = ArrayDeque() + run.addFirst(newest) + while (index > 0 && all[index - 1].end == run.first().first) { + index-- + run.addFirst(all[index]) + } + return run.toList() + } + + /** + * Each line of [chunk], newest first, until [take] says stop. + * + * Backwards and lazily, because every question this cache is asked is about the newest end -- + * the tail, the opening window, the page before a cursor -- and a live run grows to the size of + * the conversation. Reading the file whole to answer with eighty lines of it is the cost the + * server's own reader was rewritten to stop paying. + * + * Damage anywhere but at the tail of the open chunk was not written by this code, and there is + * no honest way to say what a chunk covers with a line of it unreadable -- so it discards the + * session rather than serving what it can read. + */ + private fun eachLine(chunk: Chunk, take: (String) -> Boolean) { + eachLineBackwards(chunk.file) { _, line -> + if (seqOf(line) == null) throw Damaged(chunk.file) + take(line) + } + } + + // -- writing ----------------------------------------------------------------------------- + + /** The writer for the chunk [seq] belongs in, opening or rotating one as it has to. */ + private fun writerFor(seq: Long): BufferedWriter? { + writer?.let { held -> + if (seq == openEnd) return held + if (seq < openEnd) return null + // A gap: what this instance has written covers up to `openEnd`, and that is the name + // the chunk gets before a new one starts at the arriving seq. + closeOpenChunk(openEnd) + } + dir.mkdirs() + // An open chunk left by an earlier instance, or by an earlier screen. + chunks() + .lastOrNull { it.open } + ?.let { existing -> + if (seq < existing.end) return null + if (seq == existing.end) { + openFile = existing.file + openEnd = existing.end + return FileWriter(existing.file, true).buffered().also { writer = it } + } + rename(existing.file, existing.first, existing.end) + } + // A chunk that was created and never written to would otherwise be left behind under a + // name a second one is about to want; it covers nothing, so nothing is lost with it. + dir.listFiles().orEmpty().forEach { + if (CHUNK_NAME.matchEntire(it.name)?.groupValues?.get(2) == "open" && it.length() == 0L) + it.delete() + } + val file = File(dir, "$seq-open.raw.jsonl") + openFile = file + openEnd = seq + return FileWriter(file, false).buffered().also { writer = it } + } + + /** Renames the open chunk to the range it turned out to cover, so it stops being open. */ + private fun closeOpenChunk(end: Long) { + val file = openFile + closeWriter() + if (file == null) return + val first = CHUNK_NAME.matchEntire(file.name)?.groupValues?.get(1)?.toLongOrNull() + if (first != null) rename(file, first, end) + } + + private fun rename(file: File, first: Long, end: Long) { + file.renameTo(File(dir, "$first-$end.raw.jsonl")) + } + + private fun closeWriter() { + try { + writer?.close() + } catch (_: IOException) { + // Nothing left to do about it: the file is what it is, and the read path repairs a + // half-written tail. + } + writer = null + openFile = null + openEnd = 0 + } + + // -- failure ----------------------------------------------------------------------------- + + /** A chunk that cannot be read as what its name claims. */ + private class Damaged(val file: File) : RuntimeException() + + /** + * Runs [body], answering [ifBroken] when the directory cannot give a real answer. + * + * None of this is reported on screen: none of it changes what the screen shows -- every read + * here has a network path beside it producing the same result -- and the reader has nothing to + * do about it. It is logged, and damage discards this session's cache, which is what makes the + * next open an ordinary cold one. + */ + private fun guard(ifBroken: T, body: () -> T): T = + // A disk that refused once will refuse again, once per delta, so the first refusal is + // also the last: this instance stops writing rather than logging a line a token. + guardIo( + ifBroken, + warn, + onFailure = { + disabled = true + closeWriter() + }, + ) { + try { + body() + } catch (e: Damaged) { + warn("transcript cache damaged at ${e.file}; discarding ${dir.name}") + closeWriter() + dir.deleteRecursively() + ifBroken + } + } +} + +/** `-..jsonl`; anything else in the directory is not ours. */ +private val CHUNK_NAME = Regex("""^(\d+)-(\d+|open)\.(rows|raw)\.jsonl$""") + +private val SEQ_IN_LINE = Regex(""""seq"\s*:\s*(\d+)""") +private val TYPE_IN_LINE = Regex(""""type"\s*:\s*"([^"]*)"""") + +/** + * One line's sequence number, or null when the line is not one of ours. + * + * A regex rather than a JSON parse, so that this file carries no parser and runs under the JVM + * tests: the seq is the first field the server writes (`SeqEvent`'s declaration order, with the + * event flattened after it), so the first match is the top-level one. + */ +private fun seqOf(line: String): Long? = SEQ_IN_LINE.find(line)?.groupValues?.get(1)?.toLongOrNull() + +/** Whether a line is one streamed piece of a reply, which is what makes a run of them one row. */ +private fun isDelta(line: String): Boolean = + TYPE_IN_LINE.find(line)?.groupValues?.get(1) == "assistantText" + +/** + * How much of a file is read at a time when walking it backwards. One block covers a page of a + * transcript comfortably, and the walk stops as soon as the caller has what it asked for. + */ +private const val READ_BLOCK = 64 * 1024 + +/** + * Calls [onLine] with each non-blank line of [file], **newest first**, along with the byte offset + * it starts at, until [onLine] answers false. + * + * Every question the cache is asked is about the newest end of a chunk, and a live run reaches the + * size of the conversation, so reading forwards means reading a transcript to answer with the last + * eighty lines of it. This reads blocks from the end and stops where the caller stops. + * + * Splitting on bytes is safe because the separator is `\n`, which cannot occur inside a multi-byte + * UTF-8 sequence; each line is decoded whole, so nothing is cut through a character. A missing file + * yields nothing, which is the same answer as an empty one. + */ +private fun eachLineBackwards(file: File, onLine: (offset: Long, line: String) -> Boolean) { + if (!file.isFile) return + RandomAccessFile(file, "r").use { handle -> + // Bytes below `unread` have not been looked at; `pending` is the oldest line so far, which + // is incomplete until a newline is found before it in an older block. + var unread = handle.length() + var pending = ByteArray(0) + while (unread > 0) { + val take = minOf(READ_BLOCK.toLong(), unread).toInt() + val start = unread - take + val block = ByteArray(take) + handle.seek(start) + handle.readFully(block) + val buffer = if (pending.isEmpty()) block else block + pending + var lineEnd = buffer.size + var at = buffer.size - 1 + while (at >= 0) { + if (buffer[at] == NEWLINE) { + val line = String(buffer, at + 1, lineEnd - at - 1, Charsets.UTF_8) + if (line.isNotBlank() && !onLine(start + at + 1, line)) return + lineEnd = at + } + at-- + } + pending = buffer.copyOfRange(0, lineEnd) + unread = start + } + // The first line of a file has no newline before it to be found. + val first = String(pending, Charsets.UTF_8) + if (first.isNotBlank()) onLine(0, first) + } +} + +private const val NEWLINE = '\n'.code.toByte() + +/** + * Drops a final line that is not one of ours, by truncating the file to where it starts. + * + * This app having died mid-write is the one kind of damage that is repaired rather than discarded: + * the tail of an append-only file is the only place a partial line can be, and everything before it + * is intact. A second bad line is not this, and is left for the read path to notice. + */ +private fun repairTail(file: File) { + var truncateTo = -1L + eachLineBackwards(file) { offset, line -> + if (seqOf(line) == null) truncateTo = offset + false + } + if (truncateTo >= 0) RandomAccessFile(file, "rw").use { it.setLength(truncateTo) } +} + +private fun sizeOf(file: File): Long = + if (file.isDirectory) file.listFiles().orEmpty().sumOf { sizeOf(it) } else file.length() + +/** + * The disk half of [SessionCache.guard], shared with [TranscriptCache]'s own maintenance. + * + * [onFailure] is what the caller does about it beyond answering [ifBroken] -- for a session's + * cache, giving up on writing. + */ +private fun guardIo( + ifBroken: T, + warn: (String) -> Unit, + onFailure: () -> Unit = {}, + body: () -> T, +): T = + try { + body() + } catch (e: IOException) { + warn("transcript cache unusable: ${e.message}") + onFailure() + ifBroken + } catch (e: SecurityException) { + warn("transcript cache unreadable: ${e.message}") + onFailure() + ifBroken + } diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptItems.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptItems.kt index 867bec6..cb91df9 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptItems.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptItems.kt @@ -5,9 +5,14 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext /** - * What the transcript renders: the event stream folded into displayable rows (see [foldEvent]). The - * stream is the only data source -- opening a session screen replays from seq 0, and a reconnect - * resumes from the last seq seen, so there is no separate history fetch to drift from it. + * What the transcript renders: the event stream folded into displayable rows (see [foldEvent]). + * + * Events are the only data source, and there is deliberately no second shape for history to drift + * from: a page fetched backwards, a live frame, and a line read out of this phone's own cache are + * all the same events through the same parser. Since 2026-09-04 the cache is where most of them + * come from on a session opened again -- see [TranscriptCache], which stores the server's lines + * rather than these rows for exactly that reason: a row is a rendering, and its shape changes + * whenever this file does. */ @Immutable sealed class TranscriptItem { diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptSource.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptSource.kt new file mode 100644 index 0000000..b716009 --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptSource.kt @@ -0,0 +1,183 @@ +package com.example.aiapp + +import android.content.Context +import java.io.File +import java.util.concurrent.atomic.AtomicReference + +/** + * Where the session screen gets a transcript from: this phone's copy first, the server for the + * rest. + * + * One seam rather than a cache the screen has to remember to consult. Everything it fetched before + * -- the opening window, the pages it scrolls back through, the span an anchor restore reaches for + * -- is asked of this, and everything the server sends is written into the cache on the way past, + * so the screen never learns which side answered. What it does learn, through [DebugStats], is how + * often each one did, which is how the saving is measured. + * + * See TRANSCRIPT_CACHE.md. The one rule worth keeping in mind here: the cache is never + * load-bearing. Every read has a network path beside it producing the same result, so a missing, + * evicted or damaged cache degrades to exactly what this screen did before it existed. + */ +class TranscriptSource( + private val settings: ServerSettings, + private val sessionId: String, + val cache: SessionCache, +) { + private val stream = AtomicReference(null) + + /** + * The cached opening window, or null when there is nothing usable to draw. + * + * Drawn *before* [probe] returns, which is the whole point of the feature: the rows are on + * screen while the check that they are still the server's rows is in flight, and a failed check + * replaces them exactly as a `reset` does. + */ + fun cachedOpening(limit: Int = OPENING_WINDOW): List? { + if (cache.tail() == null) return null + val lines = cache.newest(limit) + if (lines.isEmpty()) return null + return try { + lines.map { parseSeqEvent(it) } + } catch (e: org.json.JSONException) { + // Lines this build cannot read at all, which the cache's own checks cannot see: it + // reads a seq off a line, not an event. Nothing to serve, so a cold open. + cache.purge() + null + } + } + + /** + * Whether the server's event at the cached cursor is still the cached one. + * + * The screen must not resume a stream from a cached seq unless it is the same conversation. A + * transcript is append-only in ordinary use, but the file can be replaced or truncated -- a + * sandbox re-seeded with the same ids, a backup restored, a directory deleted and the session + * re-imported -- and the server's catch-up on such a file would hand this phone a continuation + * of a *different* conversation, spliced onto the cached one with no seam. That is the worst + * thing this feature can do, and it is caught with one request of a few hundred bytes, in the + * slot the opening page's request used to be in. + * + * False purges the cache and means "open cold". A throw is the server not being askable, which + * is neither: the cached rows stay on screen, the failure goes on the stream banner, and the + * caller tries again on the stream's own reconnect schedule. + * + * What this cannot see is a line changed in the middle of the file with the tail intact. That + * is what the Reload button in session settings is for, and its caption says so. + */ + suspend fun probe(): Boolean { + val tail = cache.tail() ?: return false + // `before = seq + 1` is the newest event with seq <= the cursor, which is the event *at* + // the cursor when the server still has one there. + val answer = fetchTranscript(settings, sessionId, before = tail.seq + 1, limit = 1) + val matches = + answer.size == 1 && + try { + answer[0].second == parseSeqEvent(tail.line) + } catch (e: org.json.JSONException) { + false + } + if (!matches) cache.purge() + return matches + } + + /** + * Today's opening fetch, kept as the start of the live run. Only called when the cache has + * nothing to open with, or when [probe] said what it had was not the server's. + */ + suspend fun fetchOpening(): List { + DebugStats.count("transcript page from server") + val page = fetchTranscript(settings, sessionId, limit = OPENING_WINDOW) + page.forEach { (line, entry) -> cache.append(line, entry.seq) } + cache.flush() + return page.map { it.second } + } + + /** + * The page before [before]: from the cache when it holds it, otherwise from the server bounded + * by what the cache already has. + * + * The bound is what keeps the cache worth having. A coalesced page reaches back as far as its + * row count takes it -- a single reply is hundreds of lines -- so a page fetched after the + * reader has been away would run straight past the cached run and overlap it, and an + * overlapping page cannot be stored. Told where this phone's copy starts, the server stops + * there instead, the gap is closed with exactly the bytes it was wide, and the history behind + * it is served locally from then on. + */ + suspend fun page(before: Long, limit: Int, coalesce: Boolean): List { + cache.page(before, limit, rows = coalesce)?.let { lines -> + DebugStats.count("transcript page from cache") + return lines.map { parseSeqEvent(it) } + } + DebugStats.count("transcript page from server") + val page = + fetchTranscript( + settings, + sessionId, + before = before, + limit = limit, + coalesce = coalesce, + after = cache.coveredUpTo(before)?.minus(1), + ) + if (page.isNotEmpty()) { + // `before` rather than the newest line's seq: a coalesced page covers everything up to + // the cursor it was asked with, and nothing in its lines says so. + cache.storePage(page.map { it.first }, page.first().second.seq, before, rows = coalesce) + } + return page.map { it.second } + } + + /** + * [EventStream.run], with every frame written to the cache before [onEvent] sees it. + * + * Before, so that an event held back for a reader who is scrolled away is already on disk -- + * what the cache holds is what the server sent, not what the screen has got round to drawing. + * Flushed on each status change, which is a turn's boundary and the granularity a crash may as + * well lose. + */ + fun follow(after: Long, onOpen: () -> Unit, onReset: () -> Unit, onEvent: (SeqEvent) -> Unit) { + val opened = EventStream(settings, sessionId) + stream.getAndSet(opened)?.close() + try { + opened.run(after, onOpen, onReset) { raw, entry -> + cache.append(raw, entry.seq) + if (entry.event is SessionEvent.Status) cache.flush() + onEvent(entry) + } + } finally { + cache.flush() + } + } + + /** Ends the stream, from any thread, and leaves the cache with everything it was given. */ + fun close() { + stream.getAndSet(null)?.close() + cache.flush() + } +} + +/** + * How many events the screen opens with, cached or fetched. + * + * The server's own default for a page, named here because the cached opening has to be the same + * size as the fetched one -- a reader must not get a shorter first screen for having been here + * before. + */ +private const val OPENING_WINDOW = 80 + +/** + * Where this server's cached transcripts live. + * + * Under `cacheDir` because that is exactly what it is for: bytes the phone can regenerate from the + * server, which Android may delete under storage pressure without asking. Keyed by host and port + * because two servers can hold a session with the same id -- the sandbox and the real server, or a + * re-enrolment -- and a line from one shown against the other is the whole invariant broken. `v1` + * is the layout's version: a change to it bumps the segment, and a directory of another version is + * deleted the first time this is called. + */ +fun cacheRoot(context: Context, settings: ServerSettings): File { + val transcripts = File(context.cacheDir, "transcripts") + transcripts.listFiles()?.forEach { if (it.name != CACHE_VERSION) it.deleteRecursively() } + return File(transcripts, "$CACHE_VERSION/${settings.host}_${settings.port}") +} + +private const val CACHE_VERSION = "v1" diff --git a/app/androidApp/src/test/kotlin/com/example/aiapp/TranscriptCacheTest.kt b/app/androidApp/src/test/kotlin/com/example/aiapp/TranscriptCacheTest.kt new file mode 100644 index 0000000..04b4db9 --- /dev/null +++ b/app/androidApp/src/test/kotlin/com/example/aiapp/TranscriptCacheTest.kt @@ -0,0 +1,310 @@ +package com.example.aiapp + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue +import org.junit.jupiter.api.io.TempDir + +/** + * The cache's file logic, which is the half of the transcript cache that can be wrong without + * anything on screen saying so: a page served short, a chunk served across a gap, or a run of lines + * whose recorded coverage does not match what is in it. + * + * Lines here are the shape the server writes -- `{"seq":N,"ts":T,"type":...}` -- because that is + * what the cache reads its two facts off. Nothing parses JSON on either side. + */ +class TranscriptCacheTest { + @field:TempDir lateinit var temp: File + + private val said = mutableListOf() + + private fun cache() = TranscriptCache(File(temp, "v1/host_8443")) { said += it } + + private fun session(id: String = "s") = cache().session(id) + + private fun line(seq: Long, type: String = "toolStart") = + """{"seq":$seq,"ts":1.5,"type":"$type","id":"x"}""" + + private fun delta(seq: Long) = line(seq, "assistantText") + + private fun dirOf(id: String = "s") = File(temp, "v1/host_8443/$id") + + private fun names(id: String = "s") = dirOf(id).list().orEmpty().sorted() + + private fun write(name: String, lines: List, id: String = "s") { + dirOf(id).mkdirs() + File(dirOf(id), name).writeText(lines.joinToString("\n", postfix = "\n")) + } + + private fun seqs(lines: List?) = lines?.map { + Regex("\"seq\":(\\d+)").find(it)!!.groupValues[1].toLong() + } + + @Test + fun an_appended_run_is_one_open_chunk_and_its_newest_line_is_the_tail() { + val cache = session() + (1L..3L).forEach { cache.append(line(it), it) } + cache.flush() + + assertEquals(listOf("1-open.raw.jsonl"), names()) + assertEquals(CachedTail(3, line(3)), cache.tail()) + assertEquals(listOf(line(2), line(3)), cache.newest(2)) + // More than there is is what there is, which is a short opening window and not a failure. + assertEquals(3, cache.newest(80).size) + } + + @Test + fun a_gap_in_the_stream_closes_the_open_chunk_under_the_end_it_turned_out_to_have() { + val cache = session() + (1L..3L).forEach { cache.append(line(it), it) } + // What a `reset` looks like from here: the next event is not the one after the last. + cache.append(line(90), 90) + cache.flush() + + assertEquals(listOf("1-4.raw.jsonl", "90-open.raw.jsonl"), names()) + // Nothing is served across the gap: the suffix is the newest chunk alone. + assertEquals(listOf(line(90)), cache.newest(80)) + assertEquals(CachedTail(90, line(90)), cache.tail()) + } + + @Test + fun an_event_already_covered_is_not_written_again() { + val cache = session() + (1L..3L).forEach { cache.append(line(it), it) } + cache.append(line(2), 2) + cache.flush() + + assertEquals(listOf(1L, 2L, 3L), seqs(cache.newest(80))) + } + + @Test + fun an_adjacent_page_extends_the_suffix_and_a_gap_stops_it() { + val cache = session() + (100L..102L).forEach { cache.append(line(it), it) } + cache.flush() + + // Adjacent: its end is the open chunk's first. + assertTrue(cache.storePage((60L..99L).map { line(it) }, 60, 100, rows = true)) + assertEquals(listOf(98L, 99L), seqs(cache.page(before = 100, limit = 2, rows = false))) + assertEquals(60L, seqs(cache.newest(80))?.first()) + + // Behind a gap: kept on disk, because paging usually closes the gap, but never served + // across it. + assertTrue(cache.storePage((1L..9L).map { line(it) }, 1, 10, rows = true)) + assertNull(cache.page(before = 10, limit = 5, rows = false)) + assertEquals(60L, seqs(cache.newest(200))?.first()) + } + + @Test + fun a_page_that_overlaps_what_is_here_is_not_stored() { + val cache = session() + cache.append(line(100), 100) + cache.flush() + assertTrue(cache.storePage((60L..99L).map { line(it) }, 60, 100, rows = true)) + + assertFalse(cache.storePage((50L..79L).map { line(it) }, 50, 80, rows = true)) + assertFalse(cache.storePage(emptyList(), 40, 60, rows = true)) + assertEquals(listOf("100-open.raw.jsonl", "60-100.rows.jsonl"), names()) + } + + @Test + fun a_miss_is_null_and_never_an_empty_page() { + val cache = session() + (100L..102L).forEach { cache.append(line(it), it) } + cache.flush() + + // At or below where the run starts, so what the reader is scrolling into is the server's. + // An empty list here would be read as the start of the conversation and would stop the + // transcript scrolling back at all. + assertNull(cache.page(before = 100, limit = 40, rows = true)) + assertNull(cache.page(before = 40, limit = 40, rows = true)) + assertNull(session("never-visited").page(before = 100, limit = 40, rows = true)) + } + + @Test + fun a_page_starts_from_anywhere_inside_the_run_not_only_at_a_boundary() { + val cache = session() + (1L..10L).forEach { cache.append(line(it), it) } + cache.flush() + + // Where a warm open leaves the cursor: in the middle of the live run, because the screen + // drew the newest lines of it. A cache that could only answer at a chunk boundary would + // send this to the server -- and the page that came back would overlap the run and be + // thrown away, so the whole of the scroll back would be fetched again on every visit. + assertEquals(listOf(5L, 6L, 7L), seqs(cache.page(before = 8, limit = 3, rows = false))) + assertEquals((1L..7L).toList(), seqs(cache.page(before = 8, limit = 99, rows = false))) + } + + @Test + fun a_page_counted_in_rows_folds_each_delta_run_into_one_and_cuts_only_between_rows() { + val cache = session() + // Two replies of three deltas each, split by a tool call: the same fixture as the + // server's `coalescing_counts_rows_and_joins_delta_runs`. + val lines = + listOf(delta(1), delta(2), delta(3), line(4), delta(5), delta(6), delta(7), line(8)) + write("1-9.raw.jsonl", lines) + cache.append(line(9), 9) + cache.flush() + + // Three rows: the tool call at 8, the run 5..7, and the tool call at 4. The cut lands + // between rows, so the older run is not started. + assertEquals( + listOf(4L, 5L, 6L, 7L, 8L), + seqs(cache.page(before = 9, limit = 3, rows = true)), + ) + // One row is one whole run, however many deltas it is made of. + assertEquals(listOf(8L), seqs(cache.page(before = 9, limit = 1, rows = true))) + // A page of lines counts lines, which is what the anchor restore asks for. + assertEquals(listOf(7L, 8L), seqs(cache.page(before = 9, limit = 2, rows = false))) + } + + @Test + fun a_row_page_crosses_a_chunk_boundary_and_stops_short_at_the_oldest_chunk() { + val cache = session() + write("5-9.raw.jsonl", listOf(delta(5), delta(6), line(7), delta(8))) + cache.append(delta(9), 9) + cache.append(line(10), 10) + cache.flush() + + // A run straddling the boundary is one row, as it will be once folded. + assertEquals(listOf(8L, 9L, 10L), seqs(cache.page(before = 11, limit = 2, rows = true))) + // Asking for more rows than the suffix holds is a short page, not a failure and not a + // claim that the conversation starts here. + assertEquals((5L..10L).toList(), seqs(cache.page(before = 11, limit = 40, rows = true))) + } + + @Test + fun the_floor_for_a_fetch_is_the_nearest_chunk_at_or_below_it() { + val cache = session() + write("1-10.rows.jsonl", (1L..9L).map { line(it) }) + write("10-40.rows.jsonl", (10L..39L).map { line(it) }) + cache.append(line(90), 90) + cache.flush() + + // The run behind the gap, which is what makes the fetched page adjacent to it: a page + // fetched before 90 with a floor of 39 stops at 40 and closes the gap exactly. + assertEquals(40L, cache.coveredUpTo(90)) + assertEquals(40L, cache.coveredUpTo(41)) + assertEquals(10L, cache.coveredUpTo(10)) + // Nothing at or below the oldest chunk's start, so the page is bounded only by its limit. + assertNull(cache.coveredUpTo(9)) + } + + @Test + fun a_newest_chunk_that_is_not_raw_discards_the_session() { + val cache = session() + write("1-10.rows.jsonl", (1L..9L).map { line(it) }) + + // Only reachable by dying between closing one live run and opening the next, and there is + // no cursor to be read off a coalesced line -- so the open is a cold one. + assertNull(cache.tail()) + assertFalse(dirOf().exists()) + } + + @Test + fun a_half_written_last_line_is_dropped_and_the_file_repaired() { + val cache = session() + dirOf().mkdirs() + File(dirOf(), "1-open.raw.jsonl").writeText(line(1) + "\n" + line(2) + "\n" + """{"se""") + + assertEquals(CachedTail(2, line(2)), cache.tail()) + assertEquals(line(1) + "\n" + line(2) + "\n", File(dirOf(), "1-open.raw.jsonl").readText()) + // And the run continues from where the good tail left off. + cache.append(line(3), 3) + cache.flush() + assertEquals(listOf(1L, 2L, 3L), seqs(cache.newest(80))) + } + + @Test + fun damage_anywhere_else_discards_the_session_when_a_read_reaches_it() { + val cache = session() + write("1-open.raw.jsonl", listOf(line(1), "not ours", line(3))) + + // Not seen by the tail, which reads the newest line and stops -- reading a chunk from its + // end is exactly not reading the rest of it, and that is what keeps a warm open cheap on + // a conversation of tens of megabytes. + assertEquals(CachedTail(3, line(3)), cache.tail()) + // Reached by a read that walks past it, and there is no honest way to say what a chunk + // covers with a line of it unreadable -- so what is served is nothing, and the session + // opens cold from here on. + assertEquals(emptyList(), cache.newest(80)) + assertFalse(dirOf().exists()) + assertTrue(said.any { it.contains("damaged") }) + } + + @Test + fun a_name_this_does_not_recognise_is_ignored() { + val cache = session() + write("notes.txt", listOf("hello")) + write("1-open.raw.jsonl", listOf(line(1))) + + assertEquals(CachedTail(1, line(1)), cache.tail()) + } + + @Test + fun a_chunk_larger_than_one_read_block_is_walked_across_the_boundaries() { + val cache = session() + // Well past the 64 kB block the backwards reader takes at a time, so a page has to be + // stitched across several of them -- including a line that straddles a boundary, which + // is the case nothing else here would notice going wrong. + val padding = "x".repeat(300) + val lines = (1L..500L).map { """{"seq":$it,"ts":1.5,"type":"toolStart","id":"$padding"}""" } + write("1-open.raw.jsonl", lines) + + assertEquals(500L, cache.tail()!!.seq) + assertEquals(lines.takeLast(80), cache.newest(80)) + assertEquals(lines.subList(0, 400), cache.page(before = 401, limit = 999, rows = false)) + // And a non-ASCII line, whose bytes a naive split could cut through a character. + val accented = """{"seq":501,"ts":1.5,"type":"assistantText","delta":"héllo — ok"}""" + cache.append(accented, 501) + cache.flush() + assertEquals(accented, cache.tail()!!.line) + } + + @Test + fun eviction_takes_the_least_recently_touched_and_never_the_one_on_screen() { + val cache = cache() + listOf("old", "middle", "open").forEachIndexed { at, id -> + write("1-open.raw.jsonl", List(50) { line(it + 1L) }, id = id) + dirOf(id).setLastModified(1_000_000L + at * 1000L) + } + val each = dirOf("old").walkTopDown().filter { it.isFile }.sumOf { it.length() } + + // Room for two of the three, so the oldest goes -- and the session being read never does, + // however long ago it was last touched. + cache.evictToBudget(keep = "open", budget = each * 2) + assertEquals(listOf("middle", "open"), File(temp, "v1/host_8443").list()!!.sorted()) + + cache.evictToBudget(keep = "open", budget = 0) + assertEquals(listOf("open"), File(temp, "v1/host_8443").list()!!.sorted()) + } + + @Test + fun retaining_deletes_exactly_the_sessions_the_server_no_longer_lists() { + val cache = cache() + listOf("a", "b", "c").forEach { write("1-open.raw.jsonl", listOf(line(1)), id = it) } + + cache.retainOnly(setOf("a", "c")) + assertEquals(listOf("a", "c"), File(temp, "v1/host_8443").list()!!.sorted()) + } + + @Test + fun size_and_purge_are_the_two_halves_of_the_reload_button() { + val cache = session() + assertEquals(0L, cache.bytes()) + (1L..5L).forEach { cache.append(line(it), it) } + cache.flush() + + assertTrue(cache.bytes() > 0) + cache.purge() + assertEquals(0L, cache.bytes()) + assertNull(cache.tail()) + // And the session is usable again straight afterwards, which is what a reload does next. + cache.append(line(9), 9) + cache.flush() + assertEquals(listOf(9L), seqs(cache.newest(80))) + } +} diff --git a/server/Cargo.toml b/server/Cargo.toml index 6c941ef..2e42672 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -23,7 +23,13 @@ tokio-stream = { version = "0.1", features = ["sync"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } serde = { version = "1", features = ["derive"] } -serde_json = "1" +# `float_roundtrip` because this server hands out the same transcript line two ways -- the +# `/transcript` page and the SSE backlog both parse it out of the file and serialize it again -- +# and serde_json's default float parser is not correctly rounded. Measured 2026-09-04: a `ts` of +# 1788546972.6030757 in the file came back as ...0755, so the two answers to "what is line 30" +# differed in the last bit while looking identical. What made that visible was the phone's +# transcript cache, which compares a line it already holds against the server's own answer. +serde_json = { version = "1", features = ["float_roundtrip"] } # The config file's format. Not JSON, because this file is written and read # by hand and RON says a sum type as syntax. The two house rules both # projects write it under live in wg-app-link; this is here for the error diff --git a/server/src/routes.rs b/server/src/routes.rs index f2b5c51..22e7dde 100644 --- a/server/src/routes.rs +++ b/server/src/routes.rs @@ -21,6 +21,9 @@ //! GET /sessions/{id}/events?after=N SSE: backlog after N, then live //! (a backlog past CATCH_UP_LIMIT arrives as a //! `reset` frame plus the newest window) +//! GET /sessions/{id}/transcript a page of history: ?before=N (newest when absent), +//! ?limit=N, ?coalesce=true to count rows not deltas, +//! ?after=N to floor it at what the caller already holds //! POST /sessions/{id}/message {text, attachmentIds?} //! (starts the process first if it has exited) //! POST /sessions/{id}/unqueue {messageId} -- take back one not read yet @@ -1647,6 +1650,11 @@ struct TranscriptQuery { /// counts events to reach a known seq. See `read_window`. #[serde(default)] coalesce: bool, + /// Return nothing at or below this seq; the page stops here instead of at `limit`. + /// The phone passes the end of what it already holds, so a page never overlaps it. + /// Named to match the SSE route's `after`, and exclusive in the same way. + #[serde(default)] + after: Option, } fn default_window() -> usize { @@ -1668,6 +1676,7 @@ async fn transcript( let events = crate::session::transcript::read_window( session.transcript_path(), query.before, + query.after, query.limit, query.coalesce, ) @@ -1679,6 +1688,7 @@ async fn transcript( tracing::debug!( session = %id, before = ?query.before, + after = ?query.after, limit = query.limit, got = events.len(), oldest = ?events.first().map(|entry| entry.seq), diff --git a/server/src/session/transcript.rs b/server/src/session/transcript.rs index 36a2d92..019ba04 100644 --- a/server/src/session/transcript.rs +++ b/server/src/session/transcript.rs @@ -148,9 +148,18 @@ impl Transcript { /// `before` pages backwards for history somebody actually scrolls to. Only /// the window is parsed; see [`Indexed`] for why that is the whole cost of /// this call. +/// +/// `after` is a floor: nothing at or below it is returned, and the page +/// stops there rather than at `limit`. A phone holding a cached run of the +/// transcript passes the end of what it already has, so the page it gets +/// back is exactly the gap and never overlaps its copy -- an overlap it +/// cannot store, since a coalesced event cannot be cut at a seq inside its +/// own delta run. A run cut by this floor is emitted as the partial it is, +/// exactly as one cut by `limit` already is. pub fn read_window( path: &Path, before: Option, + after: Option, limit: usize, coalesce: bool, ) -> Result> { @@ -161,15 +170,21 @@ pub fn read_window( Some(before) => indexed.first_at_or_after(before)?, None => indexed.lines.len(), }; + let start = match after { + Some(after) => indexed.first_at_or_after(after.saturating_add(1))?, + None => 0, + }; + // A floor above the window is an empty page, not a walk backwards past it. + let start = start.min(end); // Coalescing counts *rows*, not events, and would misread the newest window: a message still // streaming there would fold to one event whose seq is its first delta, and the phone resumes // its live stream from the newest seq it applied -- so the deltas the coalesced event hid // would replay and double. Only settled history (`before` set) is safe, and it is the only // place the phone asks for it. See `parse_coalesced`. if coalesce && before.is_some() { - indexed.parse_coalesced(end, limit) + indexed.parse_coalesced(start, end, limit) } else { - indexed.parse(end.saturating_sub(limit)..end) + indexed.parse(start.max(end.saturating_sub(limit))..end) } } @@ -350,8 +365,9 @@ impl<'a> Indexed<'a> { /// next page pages from, land where they always did. A run cut by the `limit` (its older /// deltas beyond this page) is emitted as the partial it is; the next page carries the rest and /// the phone's `healSplitMessage` welds the two, exactly as it does for a run cut by any page - /// boundary. - fn parse_coalesced(&self, end: usize, limit: usize) -> Result> { + /// boundary. `start` is the same kind of cut from the other end -- the floor `read_window`'s + /// `after` computes -- and a run reaching it is partial in the same way. + fn parse_coalesced(&self, start: usize, end: usize, limit: usize) -> Result> { // Newest first while walking back, reversed to transcript order at the end. let mut out: Vec = Vec::new(); // The run currently being gathered: its oldest seq/ts so far, and its deltas newest-first. @@ -369,7 +385,7 @@ impl<'a> Indexed<'a> { } }; let mut index = end; - while index > 0 { + while index > start { // A row is counted when it lands in `out`; an open run is the row being gathered, so // stopping while one is open would drop the deltas already read. Break only between // rows, and flush the last run after the loop. @@ -528,7 +544,7 @@ mod tests { } // No cursor is the newest page, which is what opening a session asks for. - let newest = read_window(&path, None, 3, false).expect("window"); + let newest = read_window(&path, None, None, 3, false).expect("window"); assert_eq!( newest.iter().map(|entry| entry.seq).collect::>(), [8, 9, 10] @@ -536,7 +552,7 @@ mod tests { // Then backwards from the oldest of those, exclusive: the page a phone // scrolling up asks for must not repeat the row it is scrolling from. - let older = read_window(&path, Some(8), 3, false).expect("window"); + let older = read_window(&path, Some(8), None, 3, false).expect("window"); assert_eq!( older.iter().map(|entry| entry.seq).collect::>(), [5, 6, 7] @@ -544,24 +560,108 @@ mod tests { // Asking for more than there is gives what there is, rather than failing. assert_eq!( - read_window(&path, None, 100, false).expect("window").len(), + read_window(&path, None, None, 100, false) + .expect("window") + .len(), 10 ); // Nothing before the first event, which is how the phone learns to stop // paging. An empty answer here is the end of the history, not a fault. assert!( - read_window(&path, Some(1), 3, false) + read_window(&path, Some(1), None, 3, false) .expect("window") .is_empty() ); assert!( - read_window(&dir.path().join("nope.jsonl"), None, 3, false) + read_window(&dir.path().join("nope.jsonl"), None, None, 3, false) .expect("window") .is_empty() ); } + #[test] + fn a_floor_stops_a_page_at_what_the_caller_already_holds() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("transcript.jsonl"); + let mut transcript = Transcript::open(&path).expect("open"); + for n in 1..=10 { + transcript + .append(text(&n.to_string()), 0.0) + .expect("append"); + } + + // The floor is exclusive, like the SSE route's `after`, and it -- not the + // limit -- is what the page stops at. This is the gap between a phone's + // cached run and the window on its screen, fetched exactly. + let page = read_window(&path, Some(9), Some(5), 100, false).expect("window"); + assert_eq!( + page.iter().map(|entry| entry.seq).collect::>(), + [6, 7, 8] + ); + + // A limit smaller than the gap still bites; the floor is a bound, not a + // replacement for one. + let page = read_window(&path, Some(9), Some(2), 3, false).expect("window"); + assert_eq!( + page.iter().map(|entry| entry.seq).collect::>(), + [6, 7, 8] + ); + + // A floor at or above the window is an empty page, not a walk past it. + assert!( + read_window(&path, Some(4), Some(9), 10, false) + .expect("window") + .is_empty() + ); + } + + #[test] + fn a_floor_inside_a_delta_run_leaves_the_partial_run_it_cuts() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("transcript.jsonl"); + let mut transcript = Transcript::open(&path).expect("open"); + for d in ["a", "b", "c", "d"] { + transcript.append(text(d), 0.0).expect("append"); // seq 1..4 + } + transcript + .append( + Event::ToolStart { + id: "t".into(), + tool: "Bash".into(), + input: serde_json::Value::Null, + }, + 0.0, + ) + .expect("append"); // seq 5 + + // Cut inside the run: what comes back is the deltas above the floor, seq'd + // at the first of them -- the partial the phone's `healSplitMessage` welds + // onto the rest, the same as a run cut by the limit. + let rows = read_window(&path, Some(6), Some(2), 10, true).expect("window"); + assert_eq!(rows.len(), 2); + assert!(matches!( + &rows[0], + SeqEvent { seq: 3, event: Event::AssistantText { delta }, .. } if delta == "cd" + )); + assert!(matches!( + &rows[1], + SeqEvent { + seq: 5, + event: Event::ToolStart { .. }, + .. + } + )); + + // And with no floor the whole run is one row, as before. + let rows = read_window(&path, Some(6), None, 10, true).expect("window"); + assert_eq!(rows.len(), 2); + assert!(matches!( + &rows[0], + SeqEvent { seq: 1, event: Event::AssistantText { delta }, .. } if delta == "abcd" + )); + } + #[test] fn coalescing_counts_rows_and_joins_delta_runs() { let dir = tempfile::tempdir().expect("tempdir"); @@ -588,7 +688,7 @@ mod tests { // Three rows asked for, three rows returned -- each delta run one event -- where a raw // window of three would have shown one and a half tokens of the newer reply. - let rows = read_window(&path, Some(8), 3, true).expect("window"); + let rows = read_window(&path, Some(8), None, 3, true).expect("window"); assert_eq!(rows.len(), 3); // A run keeps its oldest delta's seq, so the phone anchors and pages from where it always // did. @@ -610,14 +710,43 @@ mod tests { )); // The next page pages from the oldest row's seq and returns the rest, no repeat, no gap. - let older = read_window(&path, Some(1), 3, true).expect("window"); + let older = read_window(&path, Some(1), None, 3, true).expect("window"); assert!(older.is_empty()); // The newest window never coalesces even when asked: the live cursor depends on real seqs. - let newest = read_window(&path, None, 2, true).expect("window"); + let newest = read_window(&path, None, None, 2, true).expect("window"); assert_eq!(newest.iter().map(|e| e.seq).collect::>(), [6, 7]); } + #[test] + fn a_line_read_back_is_the_line_that_was_written() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("transcript.jsonl"); + // A timestamp with enough digits to be lost: the clock produces these all day, and this + // one is real (2026-09-04). serde_json's default float parser is not correctly rounded, + // so it read this back as ...0755 and every reader got a line one bit different from the + // one in the file -- while the SSE stream, which serializes the same struct, had already + // sent the original. Two answers to "what is line 1", indistinguishable by eye. + // + // Nothing on screen showed it: a `ts` is drawn as a relative time. What found it was the + // phone's transcript cache, which keeps the line it was sent and checks it against the + // server's own answer before resuming a stream from it -- so the mismatch turned into a + // cache thrown away and a transcript downloaded again, silently and only sometimes. The + // `float_roundtrip` feature in Cargo.toml is the fix; this is what keeps it. + let mut transcript = Transcript::open(&path).expect("open"); + transcript + .append(text("hello"), 1788546972.6030757) + .expect("append"); + drop(transcript); + + let written = std::fs::read_to_string(&path).expect("read"); + let entry = read_window(&path, None, None, 10, false).expect("window"); + assert_eq!( + serde_json::to_string(&entry[0]).expect("serialize"), + written.trim() + ); + } + #[test] fn a_missing_file_reads_as_empty() { let dir = tempfile::tempdir().expect("tempdir"); From 79682f03a7758ba1c0e4884c9e0f1cdcb1778193 Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Fri, 4 Sep 2026 15:45:43 -0400 Subject: [PATCH 2/4] Condense the documentation and thin the server's comments The markdown had accumulated a lot that was stale rather than wrong. PLAN.md still described pi as the llama.cpp harness, a refcounted LlamaServerManager, and a providers-by-hosts cross-product, all of which were superseded or never built; it also carried a second copy of the HTTP table that routes.rs owns. EXPLORER.md and TRANSCRIPT_CACHE.md held implementation checklists for work that has since landed. AGENTS.md restated most of PLAN.md's design instead of being the working-notes layer it says it is. 3225 lines of markdown to 2180, with the stale sections gone rather than reworded. On the server, comments explaining what the code already says are out and the ones recording a constraint, a measurement or an incident are kept but cut to a few lines each: 5504 comment lines to 4586. Four doc comments in session/mod.rs, and one each in process.rs and usage.rs, had drifted onto the item above the one they describe -- functions were reordered without them, so `stop_session`'s doc sat on `set_session_cwd`, `stat_of`'s on `struct Stat`, and `UsageMonitor`'s on `type Cached`. Each is back on its own item. routes.rs's module table also claimed later phases would add `/hosts`, which setups replaced. cargo test (127 passed), clippy --all-targets and fmt are clean. Co-Authored-By: Claude Opus 5 --- AGENTS.md | 1366 +++++++------------ EXPLORER.md | 603 +++----- PLAN.md | 1741 ++++++++++-------------- TRANSCRIPT_CACHE.md | 647 ++++----- server/src/auth.rs | 18 +- server/src/config.rs | 285 ++-- server/src/files.rs | 234 ++-- server/src/main.rs | 161 +-- server/src/models.rs | 202 ++- server/src/routes.rs | 607 ++++----- server/src/session/claude.rs | 783 +++++------ server/src/session/claude/translate.rs | 426 +++--- server/src/session/driver.rs | 555 +++----- server/src/session/echo.rs | 415 +++--- server/src/session/import.rs | 543 +++----- server/src/session/llama.rs | 241 ++-- server/src/session/mod.rs | 1379 +++++++------------ server/src/session/pending.rs | 84 +- server/src/session/process.rs | 261 ++-- server/src/session/transcript.rs | 220 ++- server/src/session/transport.rs | 139 +- server/src/setups.rs | 123 +- server/src/ssh.rs | 161 +-- server/src/usage.rs | 199 ++- 24 files changed, 4572 insertions(+), 6821 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 47d818f..a8991eb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,234 +1,271 @@ # ai-app -A phone interface to AI coding sessions (Claude Code and llama.cpp via pi), +A phone interface to AI coding sessions (Claude Code and llama.cpp), replacing the Claude app for daily use. Rust/Axum backend on the desktop, Kotlin/Compose Android app, WireGuard + pinned self-signed TLS + bearer token between them. -**`PLAN.md` is the design source of truth.** Read it before building or -changing anything structural. It records every decision with its date, its -rationale, and the alternatives that were rejected and why — keep that habit -when a decision changes: update the plan in place, don't let this file and -the plan drift into two versions of the truth. This file is the working notes -layer: conventions, commands, and things that have bitten. +**`PLAN.md` is the design source of truth** — every decision with its date, +its rationale, and what was rejected. Read it before changing anything +structural, and update it in place when a decision changes rather than +letting this file and the plan become two versions of the truth. This file is +the working notes layer: layout, commands, rigs, and things that have bitten. The central design point, worth not undoing by accident: **a session is a -child process speaking JSONL over stdio, translated into one common event -model.** Claude Code (stream-json) and pi (RPC mode) are two translators -behind one `Driver` trait; the transcript, the SSE stream, the phone UI, and -SSH spawning (the same command wrapped in `ssh host …`) all work purely in -the common model. A new session type is a new driver — never a -session-type branch in shared code (routes, transcript, app screens). +child process, translated into one common event model.** A new session type +is a new driver — never a session-type branch in shared code (routes, +transcript, app screens). ## Layout -Mirrors `../dev-updater` deliberately — same stack (axum 0.8 + -axum-server/rustls, tokio, clap; Kotlin 2.4.x + Compose Multiplatform, -single `:androidApp` module), same cert scheme, same registry pattern (every -session mutation funnels through the manager so in-memory and on-disk state -can't come apart). Read dev-updater's `README.md` and `AGENTS.md` for the -conventions before diverging from them; module-by-module intent for this -repo is in PLAN.md's "Backend layout" section. +Mirrors `../dev-updater` deliberately: same stack (axum 0.8 + +axum-server/rustls, tokio, clap; Kotlin 2.4.x + Compose Multiplatform, single +`:androidApp` module), same cert scheme, same registry pattern. Read +dev-updater's `README.md` and `AGENTS.md` before diverging from them. +Module-by-module intent is in PLAN.md's "Backend layout". -- `server/src/session/import.rs` — continuing a Claude Code session the - machine already has. Claude Code keeps each one as JSONL under - `~/.claude/projects/`, and the CLI resumes one with `--resume ` — - which `claude.rs` already does for crash recovery, so an import is that - same path with the token written up front rather than a second way to - start a session. The phone picks an **id**, never a path: the server - resolves which file that is, so an enrolled token cannot become "read me - an arbitrary file" — the same rule that keeps a command out of - `POST /setups`. Only the tail is replayed (`REPLAY_LINES`) because these - files reach tens of megabytes and the CLI reads the real one itself; what - crosses the tunnel is what a person reads, not what the model is given. - Images in the replayed tail are written into the session's `files/` by - the same function the live translator uses, so a screenshot looks the - same whether it was watched happening or replayed afterwards, and the - phone fetches the bytes only when it draws one. - An imported session then **keeps itself level with that file**, so work - done at a terminal appears without anyone pressing anything. Which new - lines came from *here* is answered by counting the events this session - has recorded, **not** by looking at its status — 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/files.rs` — the file explorer's half of the backend: - listing a directory, reading a file, writing one, creating a file or a - directory, on whichever machine a setup names. Each is one small POSIX - script run through `Transport`, so the local and the ssh case are the - same code and a machine the backend cannot reach fails with ssh's own - message. The path is a **positional argument**, never text spliced into - the script; `PATH_PRELUDE` is the one line that gives a leading `~` its - meaning, since a shell expands a tilde in text and not in an argument. - A read has four answers — `text`, `binary`, `tooBig`, or the machine's - own error — because a binary file drawn as text and a big one cut off - silently are both wrong in ways the reader cannot see. A write carries - the sha256 the read reported and is refused (409) when the file has - moved on, which is the ordinary case when an agent is editing the same - file. `EXPLORER.md` is the design. -- `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, - refuse to resume onto a partial from a different revision, and are - checked against HuggingFace's published sha256 before the file gets its - real name. -- **Attachments** are one list on a user message (`attachments`, the - ref the files route serves), in two shapes. An image is `.` - and goes to the model as an image block. Anything else is - `-` -- the name it was shared or picked under, cleaned by - `safe_file_name` -- and the Claude driver appends `Attached file: - /abs/path` to the message text, since the CLI reads files by path and - a model cannot be shown a trace. `media::media_type_for` on the server - and `isImageRef` on the phone tell the two apart; keep those lists - level. The phone attaches from the photo picker, the file chooser and - Android's share sheet (`Share.kt`; the manifest's SEND filter), all - through one `attach` path in `SessionScreen`, streamed both from the - phone and onto disk. A file for a session on another machine is also - copied there during the upload (setup's `attachmentsDir`, else the - session's cwd, else home) and the driver names that path, read from - the `.remote` marker beside the file -- PLAN.md's "Transport" has - the reasoning. -- `server/` — Rust backend (`ai-server`). `main.rs` bootstraps (TLS, the - auth layer, token/QR enrollment, wg0 binding), `routes.rs` has the HTTP - table in its module doc comment, `auth.rs` the bearer-token middleware, - `config.rs` the persisted schema (written in the shared RON house rules), - `session/` the manager (registry pattern), `Driver` trait + event model, - `EchoDriver`, and transcripts. -- `app/` — Compose Android app, single `:androidApp` module, package - `com.example.aiapp`, label "AI Sessions". `AppRoot.kt` is the navigation - `when`; `MainScreen.kt` the root's four tabs (sessions, import, models, - setups) with settings and refresh on the title row; `Api.kt`/`EventStream.kt` - the REST + SSE clients; `Events.kt` the event model mirror; - `ServerConfig.kt` settings + Keystore-sealed token; screens in - `SessionListScreen/SessionScreen/SpawnScreen/SettingsScreen`. - `Notifications.kt` is the foreground service holding the notification - stream and the one place that decides where a notification is said -- - nothing for the session on screen, a `SessionAlerts` banner while the app - is up, Android's drawer otherwise, never two of them. See PLAN.md's - "Notifications: two places, never both". - **Icons are Nerd Fonts glyphs from a committed subset**, not vector assets - and not ordinary Unicode — `NerdIcons.kt` declares each codepoint and - `app/build-icon-font.sh` subsets the font. The two lists have to agree: a - codepoint in the Kotlin that the script did not subset is a glyph that - silently isn't there. Rerun the script and commit its output when adding - one; it needs network access. `md-cog` and `md-refresh` are deliberately - the same codepoints dev-updater uses and must not drift from it. The - subset is the **Mono** face, where every glyph is one em square — that is - what makes two icon buttons the same width without either being given - one, and it is why `GLYPH_SIZE` is smaller than it looks like it should - be. -- **The file explorer** — `FilesScreen.kt` (the navigation stack, the - per-directory cache, the create dialog), `FileViewer.kt` (a `LazyColumn` - of lines, each with its own colours from `FileLines.kt`, sharing one - horizontal scroll so nothing wraps), `FileEditor.kt` (a - `BasicTextField` with a `VisualTransformation` carrying the scanner's - spans, which is the one Compose API that colours a field's own text). - It draws **over** the session in `AppRoot`'s `Screen.Session`, so the - session under it stays composed and coming back from a file costs - nothing; back steps editor → viewer → directory → parent and only closes - from where it opened. `EXPLORER.md` is the design and `server/src/files.rs` - is the other half. - To exercise it, `./ui-sandbox.sh` builds a fixture tree at the sandbox - home's `~/files` holding the states that are otherwise only reachable by - finding a real machine in one: an empty directory, a name with a tab in - it and one with an apostrophe, a binary file, one over `FILE_LIMIT`, one - `chmod 000`, a symlink to a directory and a broken one, a source file per - language, and the three sizes the limits were measured against - (`edit-32k.rs`, `edit-128k.rs`, `big-source.rs`), so those figures can be - taken again rather than re-derived. Point a session at it with +- `server/` — the Rust backend (`ai-server`). `routes.rs`'s module doc + comment is the HTTP table and the surface's source of truth. +- `app/` — the Compose app, package `com.example.aiapp`, label "AI Sessions". + `AppRoot.kt` is the navigation `when`; `MainScreen.kt` the root's four tabs + (sessions, import, models, setups); `Api.kt`/`EventStream.kt` the REST + SSE + clients; `Events.kt` the event model mirror; `ServerConfig.kt` settings and + the Keystore-sealed token. +- `wg-app-link/` — a **git submodule** shared with dev-updater: the pinned CA + and leaf (`certs`), QR enrollment and the bearer token (`enroll`), wg0 + binding and the certificate's SANs (`netif`), owner-only files (`private`), + and the RON house rules (`format`). Clone with `--recurse-submodules`, or + `git submodule update --init` in an existing checkout — `server/` will not + build without it, since it is a path dependency, which is what keeps the two + projects version-locked to the commit this repo pins. What deliberately did + **not** move is the API surface and the config *schema*: routes, drivers, + sessions and setups are what makes this project itself. +- `EXPLORER.md` — the file explorer's design (`server/src/files.rs` and + `FilesScreen.kt` / `FileViewer.kt` / `FileEditor.kt`). +- `TRANSCRIPT_CACHE.md` — the phone's copy of what it has been sent. Read it + before touching `TranscriptCache.kt`, `TranscriptSource.kt`, or the opening + and stream effects in `SessionScreen.kt`. +- `TODO.md` — the working list. +- `.dev-updater.ron` — what Dev Updater builds here: the server (run as + `service: Managed(…)`, supervised by Dev Updater's own implementation + rather than a script kept here) and the APK, in parallel. It points at + `resources.ron`, which is *ours* rather than Dev Updater's — it names + `~/.local/share/ai-app` and `~/.config/ai-app` so the Uninstall dialog can + offer them. Note what deleting the config directory takes with it: the CA + under `certs`, which is the one-way door. **Stop** on the server card stops + the server a phone reaches through the tunnel, so on that phone it stays + down until somebody starts it again; Dev Updater reaches it over its own + port and is unaffected, which is what makes the button safe to press and + easy to regret. + +### Icons + +**Nerd Fonts glyphs from a committed subset**, not vector assets and not +ordinary Unicode. `NerdIcons.kt` declares each codepoint and +`app/build-icon-font.sh` subsets the font; the two lists have to agree, +because a codepoint in the Kotlin that the script did not subset is a glyph +that silently isn't there. Rerun the script and commit its output when adding +one — it needs network access. `md-cog` and `md-refresh` are deliberately the +same codepoints dev-updater uses and must not drift from it. The subset is +the **Mono** face, where every glyph is one em square, which is what makes +two icon buttons the same width without either being given one — and why +`GLYPH_SIZE` is smaller than it looks like it should be. + +## Checking your work + +- **Server**: `./run-tests.sh` from the repo root (or `cargo test` from + `server/`), plus `cargo clippy --all-targets` and `cargo fmt`. The build + stays warning-clean and rustfmt-clean at the defaults — there is no + `rustfmt.toml` and there should not be one. +- **App**: from `app/`, + `. ./android-env.sh && ./gradlew :androidApp:ktfmtFormat + :androidApp:compileDebugKotlin :androidApp:lintDebug + :androidApp:testDebugUnitTest`. The unit tests are JVM-only and cover the + syntax highlighter, the ANSI parser and the transcript cache — the app's + pure logic with no Android in it. +- **Android Lint is not optional and is not run by a build.** It found a + crash that had been shipping (`java.time` on a minSdk-24 app with + desugaring off) and later a permission check that silently dropped every + notification on Android 12 and below. Fully clean as of 2026-08-31; keep it + that way, and suppress with `tools:ignore` plus a written reason rather + than by lowering the bar. +- Then `./build-apk.sh` for the APK to install on a phone through Dev + Updater, or `./run-android.sh` to build, install and launch on the + emulator. **The phone gets the release build**, signed with a key the + script generates once under `~/.config/ai-app/release.jks` (never in the + repo); `./build-apk.sh debug` builds the other variant, and Dev Updater's + build modes call the script with exactly that word. Dev Updater lists every + variant under `build/outputs/apk`, so pick `release` there; a phone still + holding the debug build has to uninstall it first, since the two are signed + differently. +- The emulator scripts stay on the debug build. **Never read a frame time + from one as the app's** — a debuggable build runs Compose at a fraction of + release speed; the render report says which build it came from. + +## Running it here + +- Run the server for development with `--bind 127.0.0.1`. Without it the + server binds wg0, which exists here but is unreachable from the emulator + (it dials 10.0.2.2). First run prints the enrollment QR/URI with the token. + `ai-server --enroll-link` mints one more device's link while the server + keeps running; the server adopts that token on its first use. It is what + Dev Updater's Enroll button runs. +- Point development at a scratch state directory rather than the real one: + `--config /tmp/…/config.ron --data-dir /tmp/…/sessions --port 8444`. +- **The APK pins the CA of the machine that builds it**, read at build time + from `$XDG_CONFIG_HOME/ai-app/certs/ca.pem` (`AI_APP_CA` overrides). So the + server must have started once on that machine first — the build stops with + that instruction otherwise — and an APK built in this VM only works against + a server in this VM. +- Prefer exercising the server directly over going through the UI: + `curl --cacert ~/.config/ai-app/certs/ca.pem -H "Authorization: Bearer …" https://127.0.0.1:8443/sessions`. + The CA is wherever `--certs` put it — by default under `$XDG_CONFIG_HOME`, + never in the checkout, so a relative `certs/ca.pem` finds nothing. + The emulator app reaches it at `https://10.0.2.2:8443`; enroll with + `adb shell "am start -a android.intent.action.VIEW -d 'aiapp://enroll?host=10.0.2.2&port=8443&token=…'"`. +- **`ai-server --delay MS` holds every response back.** Over the tunnel a + phone's requests take tens to hundreds of milliseconds, and several faults + live entirely in what the app does *while* one is outstanding. On a + loopback server those windows close before anything can be observed, so the + bug looks like it is not there. +- **`RUST_LOG=ai_server=debug`** logs every transcript page with its `before`, + `after` and what came back, and logs each SSE subscriber's cursor and + whether it was continued or reset (`stream backlog:`). That is the only + place "how far had this phone fallen behind" is answerable — the app sees a + window arrive and cannot tell. +- **`./test-wg-tunnel.sh up|test|down`** builds a real tunnel between two + network namespaces inside one machine and drives the server through it — a + genuine handshake against 10.66.0.1 with pinned TLS, no router or phone + involved. That is how to verify the wg0-only posture. + +## The rigs + +Each exists because something was invisible without it. + +- **`app/ui-sandbox.sh`** — a second `ai-server` with its own `$HOME`, config + and data directory, holding eight invented Claude Code transcripts and a + `claude` that is two lines of shell. **That isolation is the point**: the + import screen lists whatever is in `~/.claude/projects`, which in this VM is + real agent transcripts, so exercising *delete* against the ordinary server + deletes somebody's conversation and exercising *import* starts a real + `--resume` on the owner's account. + Its port and root derive from the checkout's name, so two checkouts' + sandboxes cannot reach each other, and its token is generated once into + `~/.config/ai-app/sandbox-token` and carried across restarts along with any + the enrolment flow appended — so the emulator app is enrolled **once** (the + start banner prints the command) and stays enrolled. It shares the real TLS + certificates, because the installed APK pins that CA. + Driving verbs, so none of this is re-derived per session: + `./ui-sandbox.sh spawn [title]` (an echo session, prints its id), + `./ui-sandbox.sh send SID text|@file`, and + `./ui-sandbox.sh api /path [curl args]`. + `./ui-sandbox.sh keep` restarts the server without wiping the sessions and + enrolment already there — for when the fixture under test was expensive to + build; plain `start` wipes them, which is right for the list-screen + fixtures and wrong for that. + It passes `--delay` by default, and `AI_SANDBOX_BIG_MB` puts one large + transcript among the small ones while `AI_SANDBOX_SPAWN_DELAY` makes the + fake CLI slow to start. Both exist because operations that finish in + milliseconds have states on the way that nothing can observe, and an + unobservable state is one where broken and working look identical. + It also builds a fixture tree at the sandbox home's `~/files` for the + explorer, holding the states otherwise only reachable by finding a real + machine in one: an empty directory, a name with a tab and one with an + apostrophe, a binary file, one over `FILE_LIMIT`, one `chmod 000`, a + symlink to a directory and a broken one, a source file per language, and + the three sizes the limits were measured against (`edit-32k.rs`, + `edit-128k.rs`, `big-source.rs`). Point a session at it with `./ui-sandbox.sh api /sessions//cwd -X POST -H 'content-type: application/json' -d '{"cwd":"~/files"}'`. - The 409 is produced by editing the file on the machine (`printf … > file`) - between pressing the pencil and pressing save. - **Reading is cheap and editing is not**, and the sizes are measured - rather than guessed -- see EXPLORER.md's "What the measurements said". - The viewer handles a 1 MiB, 28,000-line file because it draws one row per - line; the editor is one `BasicTextField`, which costs two seconds a frame - at 128 kB and stops the app at 1 MiB, so `EDIT_LIMIT` caps it at 32 kB - with the reason said on screen. If you make the editor faster, that - number is what to move. -- `.dev-updater.ron` — what Dev Updater is asked to do with this checkout: - the server (built in `server/`, run as `service: Managed(...)`) and the - APK (built in `app/`), built in parallel. The project it serves is the - repository, not either half of it, which is why this sits at the root - rather than in `app/`. - It points at `resources.ron` beside it, which says this project keeps its - state as `ai-app` — so the Uninstall dialog offers `~/.local/share/ai-app` - and `~/.config/ai-app` instead of saying it cannot tell. That file is - *ours*, not Dev Updater's: it ignores keys it doesn't know, so anything - else worth keeping in one place belongs there too. Note what deleting the - config directory takes with it — the CA under `certs`, which is the - one-way door described below. - `Managed` means Dev Updater supervises `ai-server` with its own built-in - service implementation rather than a script kept here. ai-app had such a - script until 2026-08-28 and it was the generic case exactly — no - arguments, no environment — so the two projects were maintaining one - behaviour twice, including the OpenRC branch neither can test from a - systemd machine. - Worth knowing before pressing it: **Stop** on the server card stops the - server that a phone reaches through the tunnel, so on that phone it stays - down until someone starts it again from Dev Updater. Dev Updater reaches - it over its own port and is unaffected, which is what makes the button - safe to press and easy to regret. -- `wg-app-link/` — a **git submodule**, and the half of this backend that - dev-updater also needed: the pinned CA and leaf (`certs`), QR enrollment - and the bearer token (`enroll`), wg0 binding and the certificate's SANs - (`netif`), owner-only files (`private`), and the RON house rules - (`format`). Both projects had written all five and they had drifted; see - that repo's `README.md` for the diff that decided each one. Clone with - `git clone --recurse-submodules`, or `git submodule update --init` in an - existing checkout — `server/` will not build without it, since it is a - path dependency rather than a registry one, which is what keeps the two - projects version-locked to the commit this repo pins. - The certificates are the one-way door: the CA is generated once on first - start into `$XDG_CONFIG_HOME/ai-app/certs` and regenerating it strands - the installed app. - What deliberately did **not** move is the API surface and the config - *schema* — routes, drivers, sessions and setups are what makes this - project itself. -## Status + The explorer's 409 is produced by editing the file on the machine + (`printf … > file`) between pressing the pencil and pressing save. +- **`app/debug-transcript.sh`** — a real conversation on the emulator. The + echo driver is the right rig for most things and the wrong one for anything + whose cost scales with what was actually written: a real reply is longer, + is real markdown, and carries tool calls whose input and output are + kilobytes. Two faults were invisible until a real transcript was loaded — a + page of history landing mid-fling threw the reader back to the newest end, + and parsing one real reply took 51ms against 4.6ms for a synthetic one. + `-b` takes the biggest conversation on the machine rather than the newest, + which is what a scrolling test wants; `--stop` takes it down. + It copies the transcript into `/tmp` and gives the server a `HOME` of its + own, so the import can only see the copy — importing spawns `claude + --resume`, and against the real file that is a second CLI writing to a + conversation somebody may still be in. **A transcript never goes in this + repository**: they hold whatever was said, read and written in that + session, and `~/repos` is shared with the host besides. +- **A fake CLI exercises the process lifecycle without a token.** Point a + `claude_cli` provider's `command` at a two-line script — `#!/bin/sh` and + `cat > /dev/null` — and it behaves the way the lifecycle code cares about: + it holds the fifo open, records a real pid, writes nothing, and dies on a + signal. So adopt, stop, restart and start are all drivable without a real + `--resume` and without spending a turn on somebody's account. Reach for + this when what is under test is *whether a process is running*, and for + `debug-transcript.sh` when it is *what the transcript draws*. +- **`app/transcript-bench.sh`** is the standard scroll measurement: it opens + the first session (or `-k` keeps the current screen), scrolls a fixed + gesture loop, and prints the app's render report — the same one the in-app + copy button produces, whose `on screen:` line names what the viewport was + holding. Compare two runs with the same gestures; the emulator's absolute + frame times transfer nothing, the report's accounting does. Run it either + side of any change under `Markdown*.kt`, `Transcript*.kt` or + `SessionScreen.kt`'s list, and put the report in the commit. The numbers + that move first are the worst `record: one block`, the reparse mean while + streaming, and the draw phase's accounting line. +- **`app/stream-bench.sh [-k] FILE`** is that measurement for a reply still + arriving. It taps "Jump to latest" so the list is pinned to the newest end, + resets the report, sends FILE, waits for the transcript to stop growing, + and prints. Both of those are corrections to a first version that measured + nothing: a transcript parked further back never redraws while a reply + streams into it, and a session is idle at *both* ends of a turn, so polling + for idle answers before the turn has started. +- **`app/trace-draw.sh`** names what a scrolling frame spends inside the + framework, from `atrace` text output with no trace processor needed. It is + how the cost of a layout node per link was attributed to the framework + rather than guessed at. -Phases 1–3 done 2026-08-24 (PLAN.md's phase list says what each verified): -the skeleton pipe, the full Claude driver (streaming, tools, permission + -AskUserQuestion cards, steering, interrupt, `--resume` crash recovery, -images both ways), and the usage screen. +### Driving the UI -**Phase 5 (SSH)** is written and exercised (2026-08-28): a session names a -host, `session::transport` turns that into an `ssh host …` invocation, and -the driver never learns which it got. +**No script that drives this app's UI presses a coordinate.** Every control +is found by the name it already carries for assistive technology — +`ui-trace record --do "tap 'Session settings'"` — which resolves the label +against the screen at the moment of the gesture and fails the whole run when +it is not there. `app/bench-lib.sh` is what the bench scripts share for it. A +coordinate is a position measured once by hand, and anything that moves the +control makes the tap land on whatever now sits there — the bench then +reports a number that was never measured, which reads exactly like a result. +Both bench scripts pressed the render report at `tap 723 205` until that +button moved into the session settings dialog on 2026-09-03. The check that +none has crept back: -**Phase 4 (llama.cpp)** works end to end, phone included (2026-08-28). -Models are browsed and downloaded from HuggingFace (`models.rs`, resumable -and verified), and `session::llama` runs one through `llama-server` over -its OpenAI-compatible streaming endpoint. Two things are deliberate and -easy to undo by accident: the conversation is rebuilt from the -**transcript** rather than kept in the driver, because driver memory is -invisible to a second device; and a llama session is refused on an ssh -host, because the model is reached over HTTP and forwarding that port is -not built. + grep -n "tap [0-9]" app/*.sh -Setups — machines, each carrying what it can run — are added, renamed, -re-probed and removed from the app; providers are **discovered by asking -the machine**, never typed, so the enrolled token cannot introduce a -command. What is left is real-phone/WireGuard bring-up, which is -operational rather than code. +Swipes are still coordinates, deliberately: a gesture across a scrolling area +is a distance rather than a control. -**`command -v` follows PATH under a non-interactive ssh session**, which is -not the PATH a login shell shows, so a binary somewhere unusual is -invisible to discovery — llama.cpp unpacked into `~/.local/opt` needs a -symlink into `~/.local/bin` before a setup finds it. The escape hatch for -anything odder is editing `config.ron` on the backend, deliberately the one -authority the phone does not have. +**Two traps in the emulator bench loop**, each of which cost a run. +`adb shell pm clear` removes the enrolment and the notification permission +along with the saved anchors, so the next run measures a permission dialog — +re-enrol with the command `ui-sandbox.sh` prints, and +`pm grant … POST_NOTIFICATIONS`. And a saved scroll anchor is per session id, +so the only way two builds start a scroll from the same place is a *fresh +session for each*. -**Testing llama.cpp here:** the prebuilt CPU build lives outside the repo -at `~/.local/opt/llama.cpp` (the 15 MB `ubuntu-x64` release asset). It -needs its own directory on `LD_LIBRARY_PATH`, so start the server as +**The emulator is `~/repos/emulator-tools`' business, not this repo's.** +`emu up` creates and boots the AVD named after this checkout — whatever `emu +name` prints, never a name typed out here, since this file is the same in +every clone. `run-android.sh` is that plus a build and an install. The `adb` +on `PATH` after sourcing `android-env.sh` is that repo's wrapper, which fills +in `-s` from the same rule. Gradle does not go through it, so a Gradle init +script from `emulator-tools` runs `emu check` before `installDebug`, +`uninstallDebug` and `connectedAndroidTest` and fails rather than fanning out +to every attached device; when it refuses, say which device you mean at the +moment you use it — `ANDROID_SERIAL=$(emu serial) ./gradlew …`. + +### Testing llama.cpp and ssh here + +The prebuilt CPU llama.cpp lives outside the repo at +`~/.local/opt/llama.cpp` (the 15 MB `ubuntu-x64` release asset). It needs its +own directory on `LD_LIBRARY_PATH`, so start the server as `LD_LIBRARY_PATH=~/.local/opt/llama.cpp ai-server …` and point a provider's `command` at `~/.local/opt/llama.cpp/llama-server`. A 0.6B Q8_0 answers at usable speed on this VM's 8 cores. **Do not test with a 2-bit quant**: the @@ -236,735 +273,248 @@ IQ2_XXS of that model produces fluent nonsense, which reads exactly like a broken driver — `llama-cli` produces the same from the file directly, which is how to tell the two apart in a hurry. -**No script that drives this app's UI presses a coordinate.** Every control -is found by the name it already carries for assistive technology -- -`ui-trace record --do "tap 'Session settings'"`, which resolves the label -against the screen at the moment of the gesture and fails the whole run -when it is not there. `app/bench-lib.sh` is what `transcript-bench.sh` and -`stream-bench.sh` share for it. A coordinate is a position measured once by -hand, and anything that moves the control makes the tap land on whatever -now sits there -- the bench then reports a number that was never measured, -which reads exactly like a result. Both scripts pressed the render report -at `tap 723 205` until that button moved into the session settings dialog -on 2026-09-03. The check that none has crept back: - - grep -n "tap [0-9]" app/*.sh - -Swipes are still coordinates, deliberately: a gesture across a scrolling -area is a distance rather than a control. - -**How to test SSH here, since there is no second machine:** ssh this VM to -itself. Generate a throwaway key, append the public half to -`~/.ssh/authorized_keys`, and configure a host of `bob@127.0.0.1` with -`identityFile` pointing at it plus -`options: ["StrictHostKeyChecking=no", "UserKnownHostsFile=…"]` so it -touches nothing real. Point a provider's `command` at something harmless -like `/bin/echo` rather than at `claude`: the transport is what is under -test, the process exiting immediately is the signal, and it costs no -tokens. **Take the key back out afterwards.** Note the remote login shell -here is **fish**; the remote script (`cd '…' && exec '…'`) and `ssh.rs`'s -POSIX quoting happen to mean the same thing in both, but that is luck -rather than design, and a shell that isn't either is the thing to suspect -first if a remote spawn ever mangles an argument. - -## Checking your work - -- Server: `./run-tests.sh` from the repo root (or `cargo test` from - `server/`) + - `cargo clippy --all-targets` + `cargo fmt`. The build stays - warning-clean and rustfmt-clean at the defaults — there is no - `rustfmt.toml` and there should not be one. -- App: from `app/`, `. ./android-env.sh && ./gradlew :androidApp:ktfmtFormat - :androidApp:compileDebugKotlin :androidApp:lintDebug - :androidApp:testDebugUnitTest` — format, typecheck, lint and test, the - app-side equivalent of the line above. The unit tests are JVM-only and - cover the syntax highlighter's scanner, which is the app's one piece of - pure logic with no Android in it. Then `./build-apk.sh` - to produce the APK to install on a phone (through Dev Updater), or - `./run-android.sh` to build, install, and launch on the emulator. - **The phone gets the release build**, signed with a key the script - generates once under `~/.config/ai-app/release.jks` (never in the repo); - `./build-apk.sh debug` builds the other variant, and Dev Updater's build - modes call the script with exactly that word. - The emulator scripts stay on the debug build; a debuggable build runs - Compose at a fraction of release speed, so never read a frame time from - one as the app's -- the render report now says which build it came from. - Dev Updater lists every variant under `build/outputs/apk`, so pick - `release` there; a phone still holding the debug build has to uninstall - it first, since the two are signed differently. -- **A row something is happening to is dimmed, drained of colour, inert, - and says which operation in a word** -- `BusyItem`, used by both the - session list and the import list so the appearance is learned once. The - word rather than a bare spinner because "deleting" and "importing" differ - in kind. It dims and desaturates but does **not** make the row inert: the - caller disables its own click handler while it passes a label. An overlay - consuming pointer events was tried and swallowed the drag along with the - tap, so a list could not be scrolled while anything in it was busy. -- **Importing and deleting run on the server, not in the request, and a - batch is handed over in one call.** `POST - /setups/{id}/importable/delete` and `POST /setups/{id}/importable/import` - each take a list of session ids, answer 202, and do the work in spawned - tasks -- because the phone that asked is free to leave and used to cancel - its own batch by doing so. A list rather than a route per session because - one request per row made a handover only as atomic as the network: some - rows started and the rest were never asked for, and a row nobody asked - for looks exactly like a row nobody picked. Every id is registered as in - flight before the 202 goes back. Only the *registering* is atomic; the - work itself settles per row, since six deletes that all roll back - together is not something a filesystem offers. What replaces the reply is - `session::pending`: every row of the listing carries `pending` and - `error`, and `GET /setups/{id}/importable/events` streams the changes. - **Both, not either.** The stream is a broadcast with no memory, so an - operation that starts and finishes while it is still connecting is one - nothing will ever be said about -- that left a row marked "waiting" for - ever, and the listing is what repairs it. So the screen fetches again - after a handover when anything still looks outstanding, and takes the row - states from the answer rather than from what it remembers. -- **A single tap still waits.** "Continue this and take me to it" needs the - session it made, and 202 does not carry one. The batch and the tap share - `spawn` on the server so the two cannot drift about what importing means. -- **The import screen selects in batches: hold to enter, tap to add.** The - options that act on a selection appear along the bottom, and are Delete - and Import only. Submitting clears the selection immediately and marks - every chosen row -- the one in flight as "importing" or "deleting", the - rest as "waiting" -- so the bar goes away and the affected set is what - says the work is happening. Rows are taken out as each one lands rather - than all at the end: a finished row still sitting there looks exactly - like one that has not been imported, and tapping it starts a second CLI - on the same transcript. What that costs is that the rows below slide up - under the reader's finger, so a row that has just moved ignores taps for - half a second (`SETTLE_MS`). -- **An answered question keeps its options and marks the one that was - taken**, in the same purple that says "picked" while it is still open -- - it does not collapse into a line repeating the answer. The options are - what the question *was*, and "Deny" alone does not say that Allow was the - alternative. One rule in two places (`AskedQuestion` and `PermissionAsk`), - since a permission is a question with two bare options rather than a - different kind of thing. An answer typed into **Other** matches no option, - so that one is still written out -- the state the marking cannot say. -- **Anything that is a note *about* the conversation rather than a turn in - it is closed by default**: a tool call, a peer message, and now a memory - note (``). Open-ness is the screen's, never the card's -- a - card that remembered for itself forgets the moment the lazy list stops - composing it, so a note opened and scrolled past would shut behind the - reader. -- **The full-screen image lives on the screen, not in the row that drew the - thumbnail** (`SessionImageViewer`). A `Read` whose result is an image is a - row of one call until the next call arrives and makes it a group -- a - different composable in a different part of the tree, so the old subtree - and everything it remembered goes, the open dialog included. Somebody - looking at a screenshot was thrown back to the transcript because the - session made another tool call. `/tools n gap` puts an image on its first - call so this is reproducible: open it, wait a gap, watch the row regroup. -- **All transcript text is selectable, from one `SelectionContainer` around - the whole list** (`TranscriptList.kt`). Not per row: a transcript is one - body of text to a reader, so a selection has to be able to run from a - reply into the tool output under it -- and a container per row leaves - whatever was drawn without one silently unselectable, which nothing on - screen reports. Rows keep their tap handlers; selection is a long press. - **An inline code chip is drawn behind the text** rather than as the - renderer's span background, because a span background is part of the - text's own drawing and hid the selection under it -- see - `appendCodeChip` in `MarkdownLinks.kt`. -- **A session can be moved to another directory** from the settings dialog - (`POST /sessions/{id}/cwd`). It stops the process, because a working - directory is settled at spawn; the next message starts it in the new one. - **`claude --resume ` finds a session from any directory** -- measured - on 2.1.237 -- so nothing of Claude Code's is relocated, and should you ever - be tempted, its project directory is the path with every non-alphanumeric - character replaced by `-`, cut at 200 characters with a hash appended, and - overridable besides. -- **A message from another agent reaches a live session on the turn's - `result`, not before.** Measured on CLI 2.1.237 by sending a real - cross-session message to a real stream-json session: no `user` record, and - nothing in the partial-message stream -- the whole of it is an `origin` - object on the `result`, the same shape the session file records, which is - why `import::peer_message` reads both. So it is *recorded* after the reply - it caused, and cannot be recorded anywhere else in an append-only log -- - which is why the event carries `turnStart`, the seq of the status that - opened its turn, and the phone draws the note at that seq instead of where - it arrived. Exercise it with the echo driver's `/peer-turn`; plain `/peer` - is the in-place shape an import replays. See PLAN.md. -- **A queued message can be tapped to take it back**, which is - `POST /sessions/{id}/unqueue` and a `messageDropped` event -- see PLAN.md's - "Taking a queued message back". On a **Claude** session it always refuses, - and that is correct rather than broken: the driver writes a steer into the - CLI the moment it arrives, so what the bubble is waiting for is the CLI - *reading* it, not this server sending it. The refusal is drawn on the - bubble. The echo driver really does hold its queue, so that is the rig for - the case where the drop succeeds. -- **Deleting a session offers to take the machine's own transcript with - it.** `DELETE /sessions/{id}?deleteForeign=true`, behind a switch in the - confirmation, and only where the driver keeps a record of its own - (`keepsOwnTranscript`, which today means Claude Code). Off by default, - because leaving that copy is what makes an ordinary delete recoverable -- - and the dialog's paragraph is rewritten when it is on rather than - appended to, since the sentence promising the conversation "should still - be there to import again" is exactly the one the switch makes false. The - server deletes the machine's copy *first*, so a machine it cannot reach - leaves the session where it was instead of half-deleted. -- **One Claude Code session id can name two files, and the listing offers - it once.** Resuming a session from a different working directory makes - the CLI write a second transcript with the same id under that - directory's project folder -- an ordinary state of a machine, not - corruption. Everything downstream addresses a session by id (`--resume`, - the delete glob, the in-flight registry) and the phone keyed its list on - it, so two rows sharing one *closed the app* on a Compose duplicate-key - throw. `parse_listing` keeps the copy with the most lines, because the - other is usually a few-hundred-byte stub and is often the *newer* of the - two -- so recency is the wrong key. Deleting removes every copy rather - than the first, or the row came back after a delete that reported - success. The phone's half is `uniqueItems`, which every list keyed on a - server-chosen id goes through: a repeat there must never be able to - close the app, whatever produced it. -- **A reply is drawn as pieces of one parse, never as re-parsed - substrings.** `MarkdownPieces.kt`: a `Piece` addresses a top-level block - of the message's tree, or one item of a top-level list, and every piece - is drawn from the same `State.Success` that `ParsedReplies` cached and - `warm` made. That is what bounds a lazy-list item (one paragraph, one - bullet) without parsing a message more than once, and it is why a - forty-item list of sources is forty units rather than one. The renderer - is still the parser and the environment: `MarkdownRoot` provides its - locals and `MarkdownElement` dispatches a whole block through our - component table, so paragraphs, headings and table cells are span-linked - `LinkedText` (links as spans with one tap detector per text, not a layout - node per link -- the cost that made a list of sources bumpy) and lists - are ours wherever the dispatch meets one. A heading's words are its - `ATX_CONTENT`/`SETEXT_CONTENT` child; the inline builder draws nothing - for a node type it does not know, so hand it the child. -- **A markdown table wraps its cells and never cuts one off.** The - renderer's own defaults draw every cell at one line with an ellipsis, - which on a phone loses most of a table -- and an elided cell looks - exactly like a short one, so nothing on screen says anything was cut. - `Markdown.kt` supplies its own rows (`LinkedTableRow`): as many lines as - a cell needs, cells aligned to the top of the row so a two-line cell - does not re-centre its neighbours, and each cell a `LinkedText`. Width is - the other half: a column narrows to 136dp and no further, and past that - the whole table scrolls sideways rather than squeezing -- 136 because it - is the widest floor that still fits three columns across a phone, which - is the commonest table there is. Exercise it with the echo driver's - `/table N` (default six columns), which writes long cells on purpose: - a fixture of tidy one-word values renders fine whether or not the - truncation is fixed. -- **Android Lint is not optional and is not run by a build.** It found a - crash that had been shipping: `java.time` on a minSdk-24 app with - desugaring off — and later a permission check that silently dropped every - notification on Android 12 and below. It is fully clean as of 2026-08-31; - keep it that way, and suppress with `tools:ignore` plus a written reason - rather than by lowering the bar. -- **The APK pins the CA of the machine that builds it**, read at build time - from `$XDG_CONFIG_HOME/ai-app/certs/ca.pem` (`AI_APP_CA` overrides) and - generated into a constant. So the server must have started once on that - machine first — the build stops with that instruction otherwise — and an - APK built in this VM only works against a server in this VM. -- Run the server for development with `--bind 127.0.0.1`. Without it the - server binds wg0, which exists here but is unreachable from the emulator - (it dials 10.0.2.2). First run prints the enrollment QR/URI with the - token — capture it from the log. `ai-server --enroll-link` (same - `--config`/`--bind`/`--port`) mints one more device's link while the - server keeps running and prints only the URI; the server adopts that - token on its first use. It is what Dev Updater's Enroll button runs. -- **`app/debug-transcript.sh` puts a real conversation on the emulator.** - The echo driver stays the right rig for most things and is the wrong one - for anything whose cost scales with what was actually written: a real - reply is longer, is real markdown, and carries tool calls whose input and - output are kilobytes rather than a word. Two faults were invisible until - a real transcript was loaded — a page of history landing mid-fling threw - the reader back to the newest end, and parsing one real reply took 51ms - against 4.6ms for a synthetic one. `-b` takes the biggest conversation on - the machine rather than the newest, which is what a scrolling test wants; - `--stop` takes it all down again. - It copies the transcript into `/tmp` and gives the server a `HOME` of its - own, so the import can only see the copy — importing spawns `claude - --resume`, and against the real file that is a second CLI writing to a - conversation somebody may still be in. **A transcript never goes in this - repository**: they hold whatever was said, read and written in that - session, and `~/repos` is shared with the host besides. -- **`app/ui-sandbox.sh` is the rig for driving the UI against invented - sessions.** It starts a second `ai-server` with its own `$HOME`, config - and data directory, holding eight invented Claude Code transcripts and a - `claude` that is two lines of shell. That isolation is the point: the - import screen lists whatever is in `~/.claude/projects`, which in this VM - is real agent transcripts, so exercising *delete* against the ordinary - server deletes somebody's conversation and exercising *import* starts a - real `--resume` on the owner's account. Neither is a price worth paying to - look at a list. It shares the real TLS certificates, because the - installed APK pins that CA. - Its port and root are derived from the checkout's name, so two checkouts' - sandboxes (and the emulators enrolled against them) cannot reach each - other, and its token is generated once into - `~/.config/ai-app/sandbox-token` and carried across restarts along with - any tokens the server's own enrolment flow appended -- so the emulator app - is enrolled **once** (the start banner prints the command) and stays - enrolled. It also carries the driving verbs every UI investigation needs, - so none of this is re-derived per session: - `./ui-sandbox.sh spawn [title]` (an echo session, prints its id), - `./ui-sandbox.sh send SID text|@file`, and - `./ui-sandbox.sh api /path [curl args]` for everything else. - `./ui-sandbox.sh keep` restarts the server without wiping the sessions and - enrolment already there -- for when the fixture under test was expensive to - build (a long delta-heavy transcript, say) and should survive a rebuild of - the server binary; plain `start` wipes them, which is right for the - list-screen fixtures and wrong for that. - It passes `--delay` by default for the reason the next entry gives, and - `AI_SANDBOX_BIG_MB` puts one large transcript among the small ones -- - `AI_SANDBOX_SPAWN_DELAY` makes the fake CLI slow to start. Both exist - because operations that finish in milliseconds have states on the way that - nothing can observe, and an unobservable state is one where broken and - working look identical. -- **`app/transcript-bench.sh` is the standard scroll measurement.** It - opens the first session (or `-k` keeps the current screen), scrolls a - fixed gesture loop, and prints the app's render report -- the same one - the in-app copy button produces, whose `on screen:` line names what the - viewport was actually holding. Compare two runs of it with the same - gestures; the emulator's absolute frame times transfer nothing, the - report's accounting does. Run it either side of any change under - `Markdown*.kt`, `Transcript*.kt` or `SessionScreen.kt`'s list, and put the - report in the commit; the numbers that move first are the worst - `record: one block`, the reparse mean while streaming, and the draw - phase's accounting line. -- **`app/stream-bench.sh [-k] FILE` is that measurement for a reply still - arriving.** It opens the first session, taps "Jump to latest" so the list - is pinned to the newest end, resets the report, sends FILE, waits for the - transcript to stop growing, and prints. Both of those are corrections to a - first version that measured nothing: a transcript parked further back never - redraws while a reply streams into it, and a session is idle at *both* ends - of a turn, so polling for idle answers before the turn has started. -- **`app/trace-draw.sh` names what a scrolling frame spends inside the - framework**, from `atrace` text output with no trace processor needed. It - is how the cost of a layout node per link was attributed to the framework - rather than guessed at. -- **Two traps in the emulator bench loop**, each of which cost a run. - `adb shell pm clear` removes the enrolment and the notification permission - along with the saved anchors, so the next run measures a permission dialog - -- re-enrol with the command `ui-sandbox.sh` prints, and - `pm grant ... POST_NOTIFICATIONS`. And a saved scroll anchor is per session - id, so the only way two builds start a scroll from the same place is a - *fresh session for each*. -- **A phone that falls behind the stream is answered with `reset`, and - `RUST_LOG=ai_server=debug` says when.** Every SSE subscriber logs the - cursor it arrived with and whether it was continued or reset - (`stream backlog:` in `send_backlog`), which is the only place that - question is answerable: the app sees a window arrive and cannot tell how - far it had fallen, and a reset is the one thing that makes its screen jump - to the newest end. Measured 2026-09-04 against a session streaming at 20 - events a second: reopening one with an anchor 1,800 events back connects - **87-119 events behind**, well under `CATCH_UP_LIMIT`'s 200, because the - restore is two requests -- the opening page, then one span covering the - whole distance to the anchor. So the reset path is not reachable by - reopening a session, and **to exercise it at all you have to lower - `CATCH_UP_LIMIT`** in a throwaway server build; at 5 the app takes the - reset on a live connection, clears, refills and carries on without - reconnecting. Worth knowing alongside it: **the session screen's stream - survives backgrounding here** -- 20 seconds at the launcher while 415 - events were produced brought no reconnect at all -- which is not what the - comment above that loop expects, and is most likely this emulator being - headless rather than the phone's behaviour. -- **`ai-server --delay MS` holds every response back.** Over the tunnel a - phone's requests take tens to hundreds of milliseconds, and several - faults live entirely in what the app does *while* one is outstanding. On - a loopback server those windows close before anything can be observed, - so the bug looks like it is not there. -- **A fake CLI exercises the process lifecycle without a token.** Point a - `claude_cli` provider's `command` at a two-line script — `#!/bin/sh` and - `cat > /dev/null` — and it behaves the way the lifecycle code cares - about: it holds the fifo open, records a real pid, writes nothing, and - dies on a signal. So adopt, stop, restart and start are all drivable - without a real `--resume` and without spending a turn on somebody's - account. Sibling to `debug-transcript.sh`, and the two cover different - halves: reach for this when what is under test is *whether a process is - running*, and for the script when it is *what the transcript draws*. - (From the ai-app-2 session, 2026-08-30, which found a clock bug with it - that the tests did not have.) -- Prefer exercising the server directly over going through the UI: - `curl --cacert ~/.config/ai-app/certs/ca.pem -H "Authorization: Bearer …" https://127.0.0.1:8443/sessions`. - The CA is wherever `--certs` put it — by default under - `$XDG_CONFIG_HOME` (`~/.config` when that is unset), never in the - checkout, so a relative `certs/ca.pem` finds nothing. - The emulator app reaches it at `https://10.0.2.2:8443`; enroll it with - `adb -s "$SERIAL" shell "am start -a android.intent.action.VIEW -d 'aiapp://enroll?host=10.0.2.2&port=8443&token=…'"` - (quote so the device shell doesn't eat the `&`s). -- **The emulator is `~/repos/emulator-tools`' business, not this repo's.** - `emu up` creates and boots the AVD named after this checkout — whatever - `emu name` prints, never a name typed out here, since this file is the same - in every clone — refusing when the machine has no room for one; `emu list` - says what is attached and what it costs; `emu down` stops it. - `run-android.sh` is that plus a build and an install. Run that repo's - `install.sh` once if `emu` is missing. - The `adb` on `PATH` after sourcing `android-env.sh` is that repo's wrapper, - which fills in `-s` from the same rule — so a bare `adb shell` reaches this - checkout's emulator and refuses to reach another one's. That defaulting is - what makes the old advice unnecessary rather than wrong: with two attached - and no `-s`, a bare `adb shell pm list packages` comes back **empty**, - which reads as the app having been uninstalled rather than as the question - being ambiguous. - **Gradle does not go through that wrapper**, so it had the same hole until - 2026-08-31: `installDebug`, `uninstallDebug` and `connectedAndroidTest` ask - the adb server for every attached device and act on all of them, which is - how one session's debug build landed on another's emulator. A Gradle init - script from `emulator-tools` now runs `emu check` before those tasks and - fails the build rather than fanning out. When it refuses, say which device - you mean at the moment you use it — `ANDROID_SERIAL=$(emu serial) - ./gradlew …` — rather than exporting a serial into the shell, which goes - stale the next time an emulator restarts and another checkout's takes the - port. +There is no second machine, so **ssh this VM to itself**: generate a +throwaway key, append the public half to `~/.ssh/authorized_keys`, and +configure a host of `bob@127.0.0.1` with `identityFile` pointing at it plus +`options: ["StrictHostKeyChecking=no", "UserKnownHostsFile=…"]` so it touches +nothing real. Point a provider's `command` at something harmless like +`/bin/echo` rather than at `claude`: the transport is what is under test, the +process exiting immediately is the signal, and it costs no tokens. **Take the +key back out afterwards.** The remote login shell here is **fish**; the +remote script and `ssh.rs`'s POSIX quoting happen to mean the same thing in +both, but that is luck rather than design, and a shell that is neither is the +thing to suspect first if a remote spawn ever mangles an argument. ## Where things run (host vs this VM) -Established 2026-08-25. The machine itself — the two boxes, the shared -`~/repos` mount, and why the VM is untrusted — is described once in -`~/.claude/MACHINE.md`; what follows is only what that means here. +The machine itself — the two boxes, the shared `~/repos` mount, and why the +VM is untrusted — is described once in `~/.claude/MACHINE.md`. What that +means here: - **`ai-server` belongs on the host in production.** That is where the LAN address the phone can reach is, and where WireGuard terminates. - `wg-setup-host.sh` sets that up (keys, `wg0.conf`, the phone's QR); run - it there with `sudo WG_ENDPOINT=`. + `wg-setup-host.sh` sets that up (keys, `wg0.conf`, the phone's QR); run it + there with `sudo WG_ENDPOINT=`. - **The tunnel and the real phone can never terminate in the VM**, because - nothing outside can open a connection into it. Phone bring-up is host - work. -- `wg0` (10.66.0.1) exists in this VM too, so the production path — - `ai-server` with no `--bind` — is exercisable during development. It has - no reachable peer and doesn't need one. Consequence: **with no `--bind` - the emulator can't reach the server** (it dials 10.0.2.2), so keep using - `--bind 127.0.0.1` for app work. -- `./test-wg-tunnel.sh up|test|down` builds a real tunnel between two - network namespaces inside one machine and drives the server through it — - a genuine handshake against 10.66.0.1 with pinned TLS, no router or - phone involved. That's how to verify the wg0-only posture. + nothing outside can open a connection into it. Phone bring-up is host work. +- `wg0` (10.66.0.1) exists in this VM too, so the production path is + exercisable during development. It has no reachable peer and does not need + one — but with no `--bind` the emulator cannot reach the server. - **The `claude` CLI is only in the VM, so from the host it is a remote.** - The backend reaches it as it would any other machine: a configured host, - and a session that names it. -- **Nothing secret goes in the repo**, which is shared with the host and - attacker-writable under this project's threat model (PLAN.md's security - section). State lives outside it: `$XDG_CONFIG_HOME/ai-app/config.ron` - and `certs/`, `$XDG_DATA_HOME/ai-app/sessions/`, owner-only. -- Certificates are generated **by the server, on first start**, into - `$XDG_CONFIG_HOME/ai-app/certs` (`--certs` overrides). The CA is created - once and left alone; the leaf is reissued every start, so covering a new - address is a restart. Starting the server in the VM therefore makes a - separate throwaway dev CA — never install a build pinning that on the - real phone. -- Point development at a scratch state directory rather than the real one: - `--config /tmp/…/config.ron --data-dir /tmp/…/sessions --port 8444`. + The backend reaches it as it would any other machine. +- Starting the server in the VM makes a separate throwaway dev CA. **Never + install a build pinning that on the real phone.** ## Sessions outlive the backend -Since 2026-08-29 a session's process is **deliberately left running when -`ai-server` stops**, and adopted again when it starts — so restarting the -backend does not end a turn. PLAN.md has the design; what matters day to -day: +Since 2026-08-29 a session's process is deliberately left running when +`ai-server` stops, and adopted again when it starts. PLAN.md has the design; +day to day: - **Stopping the server no longer stops the sessions.** After `pkill - ai-server` the `claude` processes are still there, on purpose, and the - next start picks them up (`reattaching to the claude-cli it left - running` in the log). To end one, either `POST /sessions/{id}/stop` — - which keeps the session and its transcript, and `POST .../start` brings - the process back on the same conversation — or delete the session, which - ends the conversation too. -- **A message or a command sent to a stopped session starts it.** `POST - .../message`, `.../command` and `.../compact` go through - `SessionManager::send_message` and `::run_command`, which start a process - first when the session is known to have exited and then hand the thing to - the driver that has one behind it. Only on `exited`: `unknown` has a - process that may well be reading its fifo. `/rename` starts one too, and - for a sharper reason than the rest: the CLI keeps its own copy of the - name, that copy is what its session picker and other agents' session - lists show, and a session is only ever *given* a name at birth — every - later start is a `--resume` — so a rename that reached no process would - leave the two lists disagreeing for good. Its save happens before the - telling, so a failure there says the telling failed rather than the - rename. So the Start button is for when you want a process and nothing to - say to it yet. -- **A backend start adopts and starts nothing** (2026-08-30). It picks up - the processes still running and leaves every other session as it found - it: listed, with its transcript and its stream, reporting `exited`, with - no process and no driver until somebody asks for one. Restarting the - server used to relaunch a driver for every session, which started a CLI - for each one that had none — so a session stopped on purpose came back at - the next rebuild, and the `Idle` the new driver announced stamped every - row as active just now. If you are looking for a stopped session's - process after a restart, there is deliberately none; press Start, or send - it anything. -- **A launch never moves a session's clock.** A status it has to correct is - written at the time of the last thing the session actually did, not at - `now()`, and a session that has never done anything reports - `SessionConfig::created` rather than the clock — its transcript is empty, - since a driver announcing the state it starts in is not news, so there is - no line to read a time off. Both are the same rule as - `Transcript::last_activity`: a restart has been told nothing, so it must - not claim anything happened. -- **A session spawned while testing cleans itself up: `--throwaway-sessions`** - (2026-08-30), which a **debug build defaults to on**. Every session - spawned by such a server is marked `throwaway: true` in `config.ron`, and - its process is stopped — SIGTERM, then SIGKILL after - `process::STOP_GRACE` — when the server exits or is sent SIGTERM/SIGINT. - Sessions outliving the backend is right for the ones somebody is using - and wrong for the ones a test made: those leave a `claude` behind that - every later server adopts, and they pile up unnoticed (twelve on this - machine in a day, each holding a conversation open). - Two things worth knowing. The flag decides only what **new** sessions are - marked as; what happens on the way out is decided by the **mark**, which - is the session's own — so a session you spawned deliberately keeps - running whichever server is up when one exits, and a throwaway one is - cleaned away even by a server started without the flag. And the waiting - is not optional: `process::stop` leaves its SIGKILL on a tokio timer, - which a runtime that is shutting down never runs, so - `process::wait_gone` does the waiting on the way out. Pass - `--throwaway-sessions=false` to keep what a development server spawns. -- **A process that has exited but not been reaped reads as dead**, not - alive. `/proc//stat` keeps the entry — same pid, same start time — - until the status is collected, so a zombie used to answer "still there", - which made `exited` unsayable: the session showed `unknown`, its Start - button never appeared, and stopping it said there was nothing to stop. - `process::stat_of` reads the state field alongside the start time. -- **Each session directory now holds `process.json`, `stdin.fifo`, - `stdout.log` and `stderr.log`.** `stdout.log` is the driver's input, read - from the byte offset in `process.json`; removing either by hand while the - session is live loses output or replays it. -- **`--resume` only ever runs when nothing is running.** That check is the - fix for the incident below, and the reason there is one entry point - (`ClaudeDriver::launch`) rather than a spawn and an attach. The status a - launch reports obeys the same rule: a session recorded as `exited` whose - launch has just started a process reports `idle`, because `exited` is the - word that refuses every command and offers a phone the chance to start a - second CLI on a live conversation. -- **`exited` is never taken on trust; it is checked against the process - record** (`corrected` in `session/mod.rs`). It is the one status that draws - the phone's Start button and lets `start_session` build a driver, so a - record that is not known to be dead makes it false and the session reports - `unknown` instead. Without that, a session adopted at a backend start kept - the transcript's `exited` while its CLI was running, Start was accepted - every press, and each press left another reader on the same process — - which reads on screen as one reply written several times, interleaved - (`GotGotGot it — it — it —`), not as anything to do with a button. - A driver that `start_session` replaces gets `Driver::detach` for the same - reason: swapping the `Arc` does not end the tasks the old one is running. -- Remote sessions are adopted too. The pid recorded for one is the **`ssh` - client's**, on this machine — that is the process the backend owns, and it - lives as long as the remote command does. (This said "local only" until - 2026-08-29; the code never had that branch.) Note the far `claude` always - has an sshd pipe on stdin whichever version started it, since the fifo is - on the backend's side — so you cannot tell a backend's version by looking - at a remote session's stdin. + ai-server` the `claude` processes are still there, on purpose + (`reattaching to the claude-cli it left running` in the log). To end one, + `POST /sessions/{id}/stop` — which keeps the session and its transcript, + and `/start` brings the process back on the same conversation — or delete + the session, which ends the conversation too. +- **A message or a command sent to a stopped session starts it**, so the + Start button is for when you want a process and nothing to say to it yet. +- **A backend start adopts and starts nothing.** If you are looking for a + stopped session's process after a restart, there is deliberately none. +- **A session spawned while testing cleans itself up**: `--throwaway-sessions`, + which a debug build defaults to on. Pass `--throwaway-sessions=false` to + keep what a development server spawns. The flag decides only what **new** + sessions are marked as; what happens on the way out is decided by the + **mark**. +- Each session directory holds `process.json`, `stdin.fifo`, `stdout.log` and + `stderr.log`. `stdout.log` is the driver's input, read from the byte offset + in `process.json`; removing either by hand while the session is live loses + output or replays it. + +## Importing The import list reports each session's **size as well as its line count**, because the two disagree in the way that matters: these transcripts embed -screenshots as base64, so one line can be a megabyte. On this machine a -69 MB session has 3,427 lines and a 44 MB one has 6,792 — nothing about a -line count tells you what continuing a session will cost. Shown, not warned -about; importing a large session is a choice somebody is entitled to make. +screenshots as base64, so one line can be a megabyte. On this machine a 69 MB +session has 3,427 lines and a 44 MB one has 6,792 — nothing about a line +count tells you what continuing a session will cost. Shown, not warned about; +importing a large session is a choice somebody is entitled to make. **Never import a Claude Code session that is open in a terminal.** The app -refuses it now — it reads `~/.claude/sessions/.json`, which Claude -Code keeps for every live session, and checks the pid's start time so a -descriptor left by a crashed CLI doesn't count. Refused rather than warned -about, because on 2026-08-29 an agent imported the session it was *itself* -running in. That put two `claude --resume` processes on one file: the whole -65 MB conversation, 154 embedded screenshots included, was re-appended to -the transcript under a new prompt id, both copies replayed each other's -writes as work done elsewhere, and the adopted one was billed for re-reading -all of it. It ended at the account's session limit, with three `claude` -processes running against one checkout. +refuses it — see PLAN.md for the incident that made that a refusal rather +than a warning. + +**One Claude Code session id can name two files, and the listing offers it +once.** Resuming from a different working directory makes the CLI write a +second transcript with the same id under that directory's project folder — an +ordinary state of a machine, not corruption. Everything downstream addresses +a session by id, and the phone keyed its list on it, so two rows sharing one +**closed the app** on a Compose duplicate-key throw. `parse_listing` keeps +the copy with the most lines, because the other is usually a few-hundred-byte +stub and is often the *newer* of the two, so recency is the wrong key. +Deleting removes every copy rather than the first, or the row came back after +a delete that reported success. The phone's half is `uniqueItems`, which +every list keyed on a server-chosen id goes through: a repeat there must +never be able to close the app, whatever produced it. + +**Deleting a session offers to take the machine's own transcript with it** — +`DELETE /sessions/{id}?deleteForeign=true`, behind a switch in the +confirmation, and only where the driver keeps a record of its own +(`keepsOwnTranscript`, which today means Claude Code). Off by default, +because leaving that copy is what makes an ordinary delete recoverable — and +the dialog's paragraph is rewritten when it is on rather than appended to, +since the sentence promising the conversation "should still be there to +import again" is exactly the one the switch makes false. The server deletes +the machine's copy *first*, so a machine it cannot reach leaves the session +where it was instead of half-deleted. + +## Shared appearance + +- **A row something is happening to is dimmed, drained of colour, and says + which operation in a word** — `BusyItem`, used by both the session list and + the import list so the appearance is learned once. The word rather than a + bare spinner because "deleting" and "importing" differ in kind. It does + **not** make the row inert: the caller disables its own click handler while + it passes a label. An overlay consuming pointer events was tried and + swallowed the drag along with the tap, so a list could not be scrolled + while anything in it was busy. ## Things that have bitten -Project-specific only — a lesson that would bite any project on this -machine belongs in `~/.claude/TOOLCHAIN.md` (toolchain versions) or -`~/.claude/MACHINE.md` (the machine itself) instead. +Project-specific only — a lesson that would bite any project on this machine +belongs in `~/.claude/TOOLCHAIN.md` or `~/.claude/MACHINE.md` instead. - **tracing caches callsite interest process-wide.** A test that hits a - `tracing::warn!` with no subscriber installed can poison the interest - cache for a concurrent test that captures logs (flaky "nothing was - logged" failures). Keep every exercise of a logging code path under the - one capturing subscriber — that's why the auth middleware has a single + `tracing::warn!` with no subscriber installed can poison the interest cache + for a concurrent test that captures logs (flaky "nothing was logged" + failures). Keep every exercise of a logging code path under the one + capturing subscriber — that is why the auth middleware has a single combined gating+logging test. - **The composer can get stuck floating above the bottom of the screen after the keyboard closes, while a reply is streaming.** The composer's position and the transcript's bottom padding are both driven by the raw, animated `WindowInsets.ime` value read inside a `graphicsLayer` block, to avoid - recomposing the whole screen every frame of the keyboard's animation (see - the layout note above it). That animation is carried by a - `WindowInsetsAnimationCallback`, and a callback interrupted mid-flight - leaves whatever it was carrying frozen at its last value with nothing - left to correct it, since no further keyboard movement will fire it - again. A streaming reply invalidates the view every frame, which is - exactly the condition known to starve that callback of its `onEnd`. - `WindowInsets.isImeVisible` (`ExperimentalLayoutApi`) does not share the - failure mode -- it is set once, from the platform's own start/end of the - transition over a different path -- so it is read once per keyboard - toggle and used to force both places back to zero the moment the - platform says the keyboard is gone, whatever the animated value still - claims. + recomposing the whole screen every frame of the keyboard's animation. That + animation is carried by a `WindowInsetsAnimationCallback`, and a callback + interrupted mid-flight leaves whatever it was carrying frozen at its last + value with nothing left to correct it. A streaming reply invalidates the + view every frame, which is exactly the condition known to starve that + callback of its `onEnd`. `WindowInsets.isImeVisible` does not share the + failure mode — it is set once, from the platform's own start/end of the + transition over a different path — so it is read once per keyboard toggle + and used to force both places back to zero. **The guard is a boolean; the inset itself must never be read in the - composable body.** That correction first shipped as a `padding(bottom = - ... imeInsets.getBottom(this) ...)` computed in `SessionScreen`, which - subscribes the whole screen to a value that changes every frame of the - animation: measured on the emulator at **16 full recompositions of - `SessionScreen` per keyboard open, against 1**, and it put the - transcript's position behind a recomposition while the composer's stayed - a draw-phase read of the same frame, so the two were only together while - that recomposition kept landing inside the frame. It is `.then(if (imeVisible) Modifier.imePadding() else - Modifier)` instead -- `imePadding` reads the inset in the layout phase, - which is what the comment above the transcript box means by "the whole of - what the keyboard re-measures", and dropping the modifier is the same - coercion to zero that the boolean was added for. The counter to check is - `session screen recomposed` in the debug button's report, which should - move by one across a keyboard open, not by the number of frames it took. + composable body.** That correction first shipped as a `padding(bottom = … + imeInsets.getBottom(this) …)`, which subscribes the whole screen to a value + that changes every frame: measured at **16 full recompositions of + `SessionScreen` per keyboard open, against 1**. It is + `.then(if (imeVisible) Modifier.imePadding() else Modifier)` instead — + `imePadding` reads the inset in the layout phase, and dropping the modifier + is the same coercion to zero the boolean was added for. The counter to + check is `session screen recomposed` in the debug report, which should move + by one across a keyboard open, not by the number of frames it took. - **The keyboard pans the window unless the activity opts into resize.** Without `android:windowSoftInputMode="adjustResize"`, opening the IME slides the whole window up (top bar off screen) instead of resizing — - `imePadding()` alone doesn't fix it and the transcript looks empty. + `imePadding()` alone does not fix it and the transcript looks empty. - **A PEM constant must start at the opening quotes.** A generated - `"""\n-----BEGIN CERTIFICATE-----` costs Android's `CertificateFactory` - its preamble sniff, so it tries DER instead and fails at runtime with - `ASN.1 ... DECODE_ERROR` — nowhere near the code that produced it. -- **A reconnecting phone used to be sent the entire backlog.** The SSE - stream replayed everything after the client's cursor, unbounded, while - *opening* a session was bounded to a page — so a long disconnect - delivered thousands of events one frame at a time. Past - `CATCH_UP_LIMIT` the stream now sends a `reset` frame and the newest - window instead, and the client rebuilds from it exactly as it does when - the screen opens. The reset is not optional: without it the window is - spliced onto rows that are no longer adjacent to it, which reads as - ordinary output. -- **The five-hour window has no reset time between blocks, and that is not - a missing value.** The usage API anchors it to the block it started in -- - measured 2026-08-31, the reset came back as exactly five hours after work - resumed, and the weekly windows in the same response carried the identical - microsecond, so both are computed from one `now()` at request time. When - no block is running there is nothing to reset and `resets_at` is `null`; - the same response shows other idle windows with the same shape. The weekly - ones always have a reset because a week is always running, which is why - "the others seem fine". - So `resets_at` absent means **not running**, and only a timestamp that - arrives and cannot be parsed is unknown. The app collapsed both into one - null and the session bar said "reset time unknown" for a machine behaving - perfectly -- while the usage dialog, reading the same field, quietly drew - nothing. `WindowEnd` in `ResetCountdown.kt` is now the one rule both go - through. -- **Resolving one importable session used to list every one of them.** - `import::delete` and the import seed both called `list`, which reads every - transcript Claude Code has ever written -- measured at 3.7 seconds against - the 867 MB in this VM, paid once per session in a batch. `import::find` - takes the same script with one glob narrower, and `delete` resolves the - path itself: 78ms. Ids are checked (`is_session_id`) before they reach - that glob, since a `/` or `..` in one walks it out of the projects - directory and `delete` removes what it lands on. -- **A transcript page used to cost the whole transcript.** `read_window` - read and parsed every line and then kept the last `limit` of them, so the - work was the size of the conversation rather than the size of the answer: - on a 21 MB, 24,000-event transcript one page took ~500ms of server time to - return 620 KB, and took the same 500ms whichever page was asked for. A - phone scrolling back paid it per page and every stream reconnect paid it - again to find out nothing had happened. It is a bisection now - (`Indexed` in `transcript.rs`) -- sequence numbers only increase, so the - edge of a range is found by parsing one line per halving and only the - window is built. Same page, ~110ms, of which ~20ms is the file scan. The - file is still read whole; that is where the remaining cost is, and going - further means a chunked backwards reader. - `RUST_LOG=ai_server=debug` logs each page with what was asked and what - came back, which is how to see a phone paging back in real time. -- **Paging back has two failures that look like "there is simply no more - history", and neither says anything on screen.** Both fixed 2026-08-31, - both invisible on a loopback server and reproducible at `--delay 150`. - The pager fires on the *first layout*, before any event has arrived -- - `moreHistory` starts true, so the history spinner is in the list and - `visibleItemsInfo` is not empty -- and `before = 0` asks for the events - before the first one, which is none, which is exactly how this code is - told it has reached the start. `loadOlderPage` refuses `oldestSeq == 0` - now. And `joinPages` only ran `adoptRun` on the path where a *split* call - had been found, so a boundary landing cleanly between two calls -- most of - them -- left one run of tool calls drawn as two groups with the seam - wherever the reader happened to have paged. Reproducing either takes a - boundary placed on purpose: the opening page is 80 events, so arrange the - transcript so that event counts back from the newest. -- **A page is 800 events and a screen is a handful of rows, and the two - have no fixed ratio.** A run of thirty-five tool calls is one row; a reply - is hundreds of text deltas folded into one. So anything that budgets in - rows has to measure a screen rather than name a number: the history - cushion was eight rows, which on a tool-heavy transcript is less than one - screenful, and the reader hit the end of what was loaded on every swipe - and stood there for a round trip. It is `HISTORY_SCREENS` viewports now, - counted from what is actually on screen. Measured at the server, which is - the one number here that does not depend on how the emulator renders: - against a 24,000-event transcript, ten swipes asked for ten pages before - and three after. -- **What the transcript screen costs to scroll, for whoever measures it - next.** Taken 2026-08-30 on the GPU emulator (`emu up` provides one; a - frame number from the software rasteriser means nothing -- see - `~/.claude/MACHINE.md`), against a real imported transcript with the debug - server at `--delay 120`. Settled and flinging fast, both into fresh - history and back through rows already drawn: **5.2-5.9% janky frames, 99th - percentile 29-32ms, 0-2 slow UI-thread frames.** The stock Settings app on - the same device is 3.3% and 38ms, so this is at the platform floor and - what is left is the emulator rather than the app. The number that is *not* - at the floor is the first few seconds after opening a session, where every - row on the way is being composed for the first time; that is inherent to a - lazy list and it is why a measurement taken before the screen settles - reads three times worse. **Settle first, then reset `gfxinfo`.** -- **Only `fetchTranscript` was off the main thread; the fold was not.** - `foldEvent` returns a new list per event, so a page is that many copies of - a growing list -- fine at 80 events and about 300,000 element copies at - 800, run in the middle of the scroll that asked for it. `warm` had the - same shape: the `markdownIn` scan that decides *what* to parse ran before - the hop to `Dispatchers.Default`, over every assistant message loaded, on - every page. Both are off it now. The shape to watch for is a - `withContext` that wraps the *fetch* and leaves the work done with the - result outside it. -- **The phone keeps the transcripts it has been sent, and the design is - `TRANSCRIPT_CACHE.md`** -- read that before touching `TranscriptCache.kt`, - `TranscriptSource.kt`, or the opening and stream effects in - `SessionScreen.kt`. What the day-to-day work needs to know: - `/transcripts/v1/_//` holds the server's - own event lines in chunks named for the range they cover - (`-.rows.jsonl`, `.raw.jsonl`, and one `-open.raw.jsonl` - the live stream appends to), and only the contiguous run ending at the - newest chunk is ever served. Measured on the emulator 2026-09-04 against - the sandbox: reopening a 500-event session costs **one request for one - event** -- the probe -- and scrolling the whole conversation back costs - nothing more. A cold open of the same session is two pages, 100 events. - Four things are easy to undo by accident. - **The probe is not optional**: before a stream is resumed from a cached - cursor, `GET /transcript?before=&limit=1` has to come back as the - line the cache holds, or the cache is thrown away and the open is cold. It - is what stops a replaced or truncated file being spliced onto this phone's - copy of a different conversation, with no seam to see. - **A page fetched for the gap passes `after`** (the transcript route's own - parameter, added for this), so it stops where the phone's copy starts. A - page that overlaps a chunk cannot be stored -- a coalesced event has no - clean cut inside its delta run -- so without the bound the first scroll - back after a reset throws away everything behind it. - **Nothing here is load-bearing.** Every read has a network path beside it - giving the same answer, and a missing, evicted, damaged or unwritable cache - degrades to a cold open. Keep it that way: a cache that can blank the - screen is worse than no cache. - **Reload, in session settings, is the answer to what the probe cannot - see** -- a line changed in the middle of the file with the tail intact. - It purges and rebuilds the screen as a cold open, putting the reader back - where they were. - Exercise all of it with `./ui-sandbox.sh` and - `RUST_LOG=ai_server=debug`, which logs every page with its `before`, - `after` and what came back; `adb shell run-as com.example.aiapp ls - cache/transcripts/v1/*/` shows whether the chunk names are adjacent, - which is the one thing the screen cannot tell you. -- **The server used to hand out the same transcript line two different - ways.** `serde_json`'s default float parser is not correctly rounded, so a - `ts` of `1788546972.6030757` in the transcript came back from - `/transcript` as `...0755` while the SSE stream, serializing the same - struct, sent the original. Nothing on screen could show it -- a `ts` is - drawn as a relative time -- and what found it was the phone's cache - comparing a line it held against the server's own answer, which turned an - invisible last-bit difference into a cache silently thrown away and a - transcript downloaded again. The `float_roundtrip` feature in + `"""\n-----BEGIN CERTIFICATE-----` costs Android's `CertificateFactory` its + preamble sniff, so it tries DER instead and fails at runtime with + `ASN.1 … DECODE_ERROR` — nowhere near the code that produced it. +- **ZXing only looks for a dark code on a light ground.** The enrollment QR + is block characters in the terminal's foreground colour, so a dark-themed + terminal renders it as a negative and the in-app scanner silently never + matches — while the phone's own camera app, which tries both, does. The + scanner asks for `Intents.Scan.MIXED_SCAN`, which alternates normal and + inverted frames; keep it that way rather than making the server dictate the + colours. +- **`serde_json`'s default float parser is not correctly rounded**, so the + server handed out the same transcript line two different ways: a `ts` of + `1788546972.6030757` came back from `/transcript` as `…0755` while the SSE + stream sent the original. Nothing on screen could show it — a `ts` is drawn + as a relative time — and what found it was the phone's cache comparing a + line it held against the server's answer. The `float_roundtrip` feature in `server/Cargo.toml` is the fix and `a_line_read_back_is_the_line_that_was_written` is what keeps it; that test fails within a second of the feature being dropped. -- **ZXing only looks for a dark code on a light ground.** The enrollment - QR is block characters in the terminal's foreground colour, so a - dark-themed terminal renders it as a negative and the in-app scanner - silently never matches — while the phone's own camera app, which tries - both, does. The scanner asks for `Intents.Scan.MIXED_SCAN`, which - alternates normal and inverted frames; keep it that way rather than - making the server dictate the colours. `EnrollmentScanActivity` also - turns off the library's 10% framing-rect inset (it decodes only what is - inside it) and its laser/result-point decorations. +- **Resolving one importable session used to list every one of them.** + `import::delete` and the import seed both called `list`, which reads every + transcript Claude Code has ever written — measured at 3.7 seconds against + the 867 MB in this VM, paid once per session in a batch. `import::find` + takes the same script with one glob narrower: 78ms. Ids are checked + (`is_session_id`) before they reach that glob, since a `/` or `..` walks it + out of the projects directory. +- **A transcript page used to cost the whole transcript.** `read_window` read + and parsed every line and then kept the last `limit` of them, so the work + was the size of the conversation rather than the size of the answer: one + page of a 21 MB, 24,000-event transcript took ~500ms to return 620 KB, and + took the same 500ms whichever page was asked for. It is a bisection now + (`Indexed` in `transcript.rs`) — sequence numbers only increase, so the + edge of a range is found by parsing one line per halving. Same page, + ~110ms, of which ~20ms is the file scan. The file is still read whole; that + is where the remaining cost is, and going further means a chunked backwards + reader. +- **Paging back has two failures that look like "there is simply no more + history", and neither says anything on screen.** Both invisible on a + loopback server and reproducible at `--delay 150`. The pager fires on the + *first layout*, before any event has arrived — `moreHistory` starts true, + so the spinner is in the list and `visibleItemsInfo` is not empty — and + `before = 0` asks for the events before the first one, which is none, which + is exactly how this code is told it has reached the start. `loadOlderPage` + refuses `oldestSeq == 0` now. And `joinPages` only ran `adoptRun` on the + path where a *split* call had been found, so a boundary landing cleanly + between two calls — most of them — left one run of tool calls drawn as two + groups with the seam wherever the reader happened to have paged. + Reproducing either takes a boundary placed on purpose: the opening page is + 80 events, so arrange the transcript so that event counts back from the + newest. +- **A page is 800 events and a screen is a handful of rows, and the two have + no fixed ratio.** A run of thirty-five tool calls is one row; a reply is + hundreds of text deltas folded into one. So anything that budgets in rows + has to measure a screen rather than name a number: the history cushion was + eight rows, which on a tool-heavy transcript is less than one screenful, so + the reader hit the end of what was loaded on every swipe and stood there + for a round trip. It is `HISTORY_SCREENS` viewports now, counted from what + is actually on screen. +- **Only `fetchTranscript` was off the main thread; the fold was not.** + `foldEvent` returns a new list per event, so a page is that many copies of + a growing list — fine at 80 events and about 300,000 element copies at 800, + run in the middle of the scroll that asked for it. `warm` had the same + shape: the `markdownIn` scan that decides *what* to parse ran before the + hop to `Dispatchers.Default`. The shape to watch for is a `withContext` + that wraps the *fetch* and leaves the work done with the result outside it. + +## Measurements worth not re-taking + +- **What the transcript screen costs to scroll.** Taken 2026-08-30 on the GPU + emulator against a real imported transcript with the server at + `--delay 120`. Settled and flinging fast, both into fresh history and back + through rows already drawn: **5.2–5.9% janky frames, 99th percentile + 29–32ms, 0–2 slow UI-thread frames.** The stock Settings app on the same + device is 3.3% and 38ms, so this is at the platform floor. The number that + is *not* at the floor is the first few seconds after opening a session, + where every row on the way is being composed for the first time; that is + inherent to a lazy list and it is why a measurement taken before the screen + settles reads three times worse. **Settle first, then reset `gfxinfo`.** +- **The reset path is not reachable by reopening a session.** Measured + 2026-09-04 against a session streaming at 20 events a second: reopening one + with an anchor 1,800 events back connects **87–119 events behind**, well + under `CATCH_UP_LIMIT`'s 200, because the restore is two requests — the + opening page, then one span covering the whole distance. To exercise the + reset at all you have to lower `CATCH_UP_LIMIT` in a throwaway build; at 5 + the app takes the reset on a live connection, clears, refills and carries + on without reconnecting. +- **The session screen's stream survives backgrounding here** — 20 seconds at + the launcher while 415 events were produced brought no reconnect at all, + which is not what the comment above that loop expects, and is most likely + this emulator being headless rather than the phone's behaviour. +- **Reopening a cached session costs one request for one event** (the probe), + and scrolling the whole conversation back costs nothing more; a cold open + of the same 500-event session is two pages, 100 events. Measured + 2026-09-04 on the emulator against the sandbox. +- **Reading is cheap and editing is not.** The viewer handles a 1 MiB, + 28,000-line file because it draws one row per line; the editor is one + `BasicTextField`, which costs two seconds a frame at 128 kB and stops the + app at 1 MiB, so `EDIT_LIMIT` caps it at 32 kB with the reason said on + screen. If you make the editor faster, that number is what to move. + EXPLORER.md's "What the measurements said" has the rest. diff --git a/EXPLORER.md b/EXPLORER.md index d6c6e45..2732388 100644 --- a/EXPLORER.md +++ b/EXPLORER.md @@ -1,256 +1,212 @@ # The file explorer -Asked for by Bryan on 2026-09-03: replace the session screen's debug -button with a folder icon that opens a file and directory viewer for the -machine the session runs on. Browse directories, open files with the -existing syntax highlighting, line numbers, no wrapping; edit a file behind -a pencil icon; create files through a modal like the ones the app already -has; work over ssh; open at the session's working directory. +Asked for by Bryan on 2026-09-03 and built the same day: browse a machine's +directories, open files with the existing syntax highlighting and line +numbers, edit behind a pencil, create through a modal, work over ssh, and +open at the session's working directory. -Built on 2026-09-03. This is the design, decision by decision with the -reason and what was rejected, so that when one changes it is changed here -rather than re-argued. The operational half -- how to run it, what to press, -what to produce on purpose -- is in AGENTS.md, where the rest of this -project's working notes are. +This is the design, decision by decision with the reason and what was +rejected, so that when one changes it is changed here rather than re-argued. +The operational half — how to run it and what to produce on purpose — is in +AGENTS.md. `server/src/files.rs` is the backend and `FilesScreen.kt` / +`FileViewer.kt` / `FileEditor.kt` / `FileLines.kt` are the app. ## What it is, in one paragraph -A machine's filesystem, seen from the phone through the backend. The -explorer belongs to a **setup** (a machine), not to a session: a session -only says where to start. Every operation -- list, read, write, create -- -is one shell script run through `Transport`, exactly the way the import -listing and the usage fetch already work, so the local and the ssh case -are one implementation and a machine the backend cannot reach fails with -ssh's own message. The phone draws what came back: a listing, a file with -its lines coloured by the scanner in `Highlighter.kt`, or an editor over -the same text. +A machine's filesystem, seen from the phone through the backend. The explorer +belongs to a **setup** (a machine), not to a session: a session only says +where to start. Every operation — list, read, write, create — is one shell +script run through `Transport`, exactly the way the import listing and the +usage fetch already work, so the local and the ssh case are one +implementation and a machine the backend cannot reach fails with ssh's own +message. The phone draws what came back. ## Decisions ### 1. Keyed on the machine, opened from the session -Routes live under `/setups/{id}/…`, beside `importable`, because a -filesystem is a property of a machine. The session screen's folder button -opens the explorer with the session's setup and its `cwd` as the starting -directory; a session with no `cwd` opens at the machine's home, which the -machine resolves (`cd` with no argument and `pwd -P`), never a path the -phone guessed. Nothing in the explorer knows what a session is, so a later -entry point from the setups tab is one more caller and no new code. +Routes live under `/setups/{id}/…`, beside `importable`, because a filesystem +is a property of a machine. The session screen's folder button opens the +explorer with the session's setup and its `cwd`; a session with no `cwd` +opens at the machine's home, which the **machine** resolves (`cd` with no +argument and `pwd -P`), never a path the phone guessed. Nothing in the +explorer knows what a session is, so a later entry point from the setups tab +is one more caller and no new code. Rejected: routes under `/sessions/{id}/`. The session would be a detour to -find the setup, and "browse this machine" from anywhere but a session would -need a session to exist first. +find the setup, and "browse this machine" from anywhere else would need a +session to exist first. ### 2. One shell script per operation, over `Transport`, on both transports -Each operation is a small POSIX shell script handed to `sh -c script sh -"$path" …` through `Transport::capture` (or the stdin-carrying variant -below). The path and every other value cross as **positional arguments**, -never interpolated into the script -- the same rule `import::find` follows -with `"$1"`, and the same reason `ssh::quote` exists: a path is -attacker-adjacent input in a server whose job is running commands. A `~` -prefix is handled by the same `quote_path`/`expand_home` pair every other -path goes through; nothing new is invented for it. +Each operation is a small POSIX script handed to `sh -c script sh "$path" …` +through `Transport::capture` (or `capture_with_input`). The path and every +other value cross as **positional arguments**, never interpolated into the +script — the same rule `import::find` follows and the same reason +`ssh::quote` exists: a path is attacker-adjacent input in a server whose job +is running commands. `PATH_PRELUDE` is the one line that gives a leading `~` +its meaning, since a shell expands a tilde in text and not in an argument. The scripts assume GNU coreutils and findutils (`find -printf`, `stat -c`, -`sha256sum`, `chmod --reference`). That is already what `import.rs` -assumes (`stat -c`, `/proc`), and both machines that exist are Linux. A -machine without them fails with that tool's own message, which names what -is missing. +`sha256sum`, `chmod --reference`) — already what `import.rs` assumes, and +both machines that exist are Linux. A machine without them fails with that +tool's own message, which names what is missing. Rejected: `std::fs` for the local transport and scripts for ssh. Two -implementations of "list a directory" drift -- the ordering of entries, -what a symlink reports, how a permission error reads -- and the local one -is the one that gets tested, so the remote one ships broken. The transport -design exists so that a driver never learns which machine it got; the -explorer is held to the same rule. The cost is a `sh` process per -operation locally, which is under a millisecond. +implementations of "list a directory" drift — the ordering of entries, what a +symlink reports, how a permission error reads — and the local one is the one +that gets tested, so the remote one ships broken. The cost is an `sh` process +per operation locally, which is under a millisecond. -Rejected: a Rust SSH or SFTP library. PLAN.md rule 23 -- the system `ssh` -inherits `~/.ssh/config`, agents and jump hosts, and there is one place to -configure a connection. SFTP would need a second one. +Rejected: a Rust SSH or SFTP library. The system `ssh` inherits +`~/.ssh/config`, agents and jump hosts, and there is one place to configure a +connection; SFTP would need a second. ### 3. The token can now name a path, and that is written down -AGENTS.md says of the import route: "the phone picks an **id**, never a -path: the server resolves which file that is, so an enrolled token cannot -become 'read me an arbitrary file'." The explorer's whole purpose is the -path, so it takes one. This is recorded in PLAN.md's Security section as a -change to the threat model paragraph, in these terms: the token already -gates spawning a bypass-permissions agent in any directory on any machine -a setup names, and that agent can already read and write every file its -user can. The explorer is a shorter path to authority the token already -holds, not new authority. The import route's rule stands where it is, -because there a path was unnecessary and refusing it cost nothing. +Elsewhere the phone picks an **id** and the server resolves which file it +names, so an enrolled token cannot become "read me an arbitrary file". The +explorer's whole purpose is the path, so it takes one. Recorded in PLAN.md's +Security section in these terms: the token already gates spawning a +bypass-permissions agent in any directory on any machine a setup names, and +that agent can already read and write every file its user can. The explorer +is a shorter path to authority the token already holds, not new authority. +The import rule stands where it is, because there a path was unnecessary and +refusing it cost nothing. -What is *not* changed: no route accepts a command. Listing, reading and +What is *not* changed: **no route accepts a command.** Listing, reading and writing are fixed scripts; the phone chooses only the path and the bytes. ### 4. Paths are absolute or `~`-prefixed, and the machine answers with the real one -Same rule as `POST /sessions/{id}/cwd`: a relative path is refused with -the same wording, because where it would be depends on where nothing the -reader can see. Every listing answers with `pwd -P` of the directory it -listed, so the phone navigates on a resolved absolute path -- the parent -of `/home/bob/repos/ai-app` is a string operation on that, and a `~` the -session was spawned with is shown as what it turned out to be. The phone -never resolves `..` itself. +Same rule as `POST /sessions/{id}/cwd`, with the same wording, because where +a relative path would be depends on something the reader cannot see. Every +listing answers with `pwd -P` of the directory it listed, so the phone +navigates on a resolved absolute path — the parent is a string operation on +that, and a `~` the session was spawned with is shown as what it turned out +to be. The phone never resolves `..` itself. ### 5. A read is capped and typed, and every state it can be in has a word -`GET /setups/{id}/file` answers with one of: - -- `text` -- the content, with its size, mtime and sha256. -- `binary` -- the content is not UTF-8. Size reported, nothing shown. -- `tooBig` -- over `FILE_LIMIT` (1 MiB to start; see "Numbers to - measure"). Size reported so the reader knows what they are looking at. -- an error -- no such file, permission denied, machine unreachable -- - carrying the machine's message. +`GET /setups/{id}/file` answers with one of `text` (content, size, mtime, +sha256), `binary` (not UTF-8; size reported, nothing shown), `tooBig` (over +`FILE_LIMIT`, 1 MiB; size reported so the reader knows what they are looking +at), or the machine's own error. Four outcomes rather than content-or-error, because a binary file drawn as -text and a big file cut off silently are both wrong in ways the reader -cannot see, and "couldn't read it" must not look like "it is empty". An -empty file is `text` with empty content and is drawn as one empty line -numbered 1, which is what it is. - -Not in the first cut: showing images (the phone has `isImageRef` and a -viewer already; the route would serve bytes). Listed under "later". +text and a big file cut off silently are both wrong in ways the reader cannot +see, and "couldn't read it" must not look like "it is empty". An empty file +is `text` with empty content, drawn as one empty line numbered 1, which is +what it is. ### 6. A write is conditional on what the reader saw `PUT /setups/{id}/file` carries the sha256 the read reported. The script -compares it against the file as it is now and refuses with a distinct exit -code if it differs; the server answers **409** with "changed on the machine -since you opened it". Agents edit files while people read them; this is -the common case, not the exotic one, and silently overwriting an agent's -edit with a stale copy is the worst available outcome. The phone offers -three ways out and says what each costs: **Overwrite** (theirs is lost), -**Reload** (yours is lost), **Cancel** (keep editing, decide later). +compares it against the file as it is now and exits distinctly if it differs; +the server answers **409**. Agents edit files while people read them; this is +the common case, not the exotic one, and silently overwriting an agent's edit +with a stale copy is the worst available outcome. The phone offers three ways +out and says what each costs: **Overwrite** (theirs is lost), **Reload** +(yours is lost), **Cancel** (keep editing). -The write is `cat > "$1.ai-app-tmp" && chmod --reference="$1" -"$1.ai-app-tmp" && mv -f -- "$1.ai-app-tmp" "$1"`, with the bytes on -stdin. A temp file and a rename, so a connection dropped mid-write leaves -the old file whole rather than a truncated one; `chmod --reference` keeps -the mode, which a fresh file would otherwise lose (an executable script -would stop being one). What this trades away: the inode changes, so a hard -link elsewhere stops being the same file. Accepted; editors do the same. -The check-then-write is not atomic against a writer landing between the -two -- a window of microseconds on the same machine -- and that is accepted -too, and noted at the script. - -The response carries the new size, mtime and sha256, so the editor's +The write is `cat > "$1.ai-app-tmp" && chmod --reference="$1" … && mv -f`, +with the bytes on stdin: a temp file and a rename, so a connection dropped +mid-write leaves the old file whole rather than truncated, and +`chmod --reference` keeps the mode a fresh file would lose (an executable +script would stop being one). What this trades away is the inode, so a hard +link elsewhere stops being the same file — accepted; editors do the same. The +check-then-write is not atomic against a writer landing between the two, a +window of microseconds on the same machine; accepted, and noted at the +script. The response carries the new size, mtime and sha256, so the editor's precondition is fresh without a second read. ### 7. Create refuses to overwrite -`POST /setups/{id}/file {path}` runs under `set -C` (noclobber) and -`: > "$1"`, so a name that exists fails with the shell's own message rather -than truncating somebody's file. `POST /setups/{id}/dir {path}` is `mkdir ---` with the same property. The modal names one thing in the current -directory and has a switch for "directory"; a created file opens straight -into edit mode, because an empty file is not something to look at. +`POST /setups/{id}/file` runs under `set -C` (noclobber) and `: > "$1"`, so a +name that exists fails with the shell's own message rather than truncating +somebody's file; `POST /setups/{id}/dir` is `mkdir --` with the same +property. The modal names one thing in the current directory and has a switch +for "directory"; a created file opens straight into edit mode, because an +empty file is not something to look at. -Rejected: create-with-content in one request. The editor is the place -content is typed, and a modal with a text area is a second editor. +Rejected: create-with-content in one request. The editor is where content is +typed, and a modal with a text area is a second editor. ### 8. The viewer is a list of lines, coloured once -The file is scanned once, off the main thread, by `scan` in -`Highlighter.kt` with `rulesOf(language)`; the spans are bucketed per line -in one pass, and each line's `AnnotatedString` is built when that line is -composed. A `LazyColumn` of lines, not one `Text`: text layout is linear -in the text, and a 20,000-line file in one `Text` measures all of it to -draw a screenful. Lines are drawn with `softWrap = false` inside one -shared `horizontalScroll` state, so the whole file scrolls sideways as a -block and a line never wraps. +The file is scanned once, **off the main thread**, by `scan` in +`Highlighter.kt`; the spans are bucketed per line in one pass and each line's +`AnnotatedString` is built when that line is composed. A `LazyColumn` of +lines, not one `Text`: text layout is linear in the text, so a 20,000-line +file in one `Text` measures all of it to draw a screenful. -**Sharing that state is not enough on its own, and this is where it was -wrong.** `horizontalScroll` is a node per row, and each one coerces the -shared offset into *its own* range -- content width less viewport -- so -with rows at their natural widths a short line's range is zero and it does -not move at all while the long line beside it does. Each row also writes +**Every row is given the same width**, and that is what makes the shared +horizontal scroll work. `horizontalScroll` is a node per row, and each one +coerces the shared offset into *its own* range — content width less viewport +— so with rows at their natural widths a short line's range is zero and it +does not move at all while the long line beside it does. Each row also writes `maxValue` as it measures, so how far the file could be dragged was decided -by whichever row measured last, and changed as the list scrolled. Both go -away once **every row is given the same width**: the longest line in -columns times one character's advance, which is arithmetic rather than -twenty thousand measurements because the face is monospace. A tab counts as -eight columns and deliberately upwards -- over-estimating leaves a little -empty space past the longest line, under-estimating puts the end of that -line out of reach -- and the width is capped well under what `Constraints` -can carry, so a minified file is a scroll that stops early rather than a -crash. Reported by Iris on 2026-09-04 as "it seems to affect different rows -differently", which is precisely what a per-row range looks like. +by whichever row measured last and changed as the list scrolled. The width is +the longest line in columns times one character's advance, which is +arithmetic rather than twenty thousand measurements because the face is +monospace. A tab counts as eight columns and deliberately upwards — +over-estimating leaves a little empty space past the longest line, +under-estimating puts the end of that line out of reach — and the width is +capped well under what `Constraints` can carry, so a minified file is a +scroll that stops early rather than a crash. Reported by Iris on 2026-09-04 +as "it seems to affect different rows differently", which is precisely what a +per-row range looks like. **The stretch at the ends is one effect too**, shared by every row and -rendered once on the box around the list -- `horizontalScroll` makes its -own per node otherwise, so only the line under the finger bent and the -rest of the file sat still beside it. That is the same complaint one layer -further out, and it is only fixable now that every row agrees where the -end is. It cannot be seen from this VM: the emulator's screenshots come -back with no stretch in them at all, for any scrollable, so this one is -checked on the phone. +rendered once on the box around the list — `horizontalScroll` makes its own +per node otherwise, so only the line under the finger bent while the rest of +the file sat still. It cannot be seen from this VM: the emulator's +screenshots come back with no stretch in them at all, for any scrollable, so +that one is checked on the phone. **The numbers sit outside that box**, so they neither travel with the text nor bend with it. The rows leave a spacer where the numbers go and a `SubcomposeLayout` beside the list draws them. That is the one arrangement that keeps them level: which numbers exist *and* where each goes both come from the list's own `layoutInfo`, read in the measure block, and -subcomposition happens during measurement -- so it composes from the answer -the list has just produced rather than from one it read a frame ago. A -column translated by the scroll position could not, since the translation -would be current while the set of numbers was a composition behind, and -during a fling the numbers would slide against their lines. Checked at -about 1kHz through a fling: 23,520 row observations over 552 frames, every -one of them with its number at exactly its own top. +subcomposition happens during measurement — so it composes from the answer +the list has just produced rather than one it read a frame ago. A column +translated by the scroll position could not, since the translation would be +current while the set of numbers was a composition behind, and during a fling +the numbers would slide against their lines. Checked at about 1kHz through a +fling: 23,520 row observations over 552 frames, every one with its number at +exactly its own top. A consequence worth having: the numbers are outside the +`SelectionContainer`, so copying part of a file gives the code rather than +the code with a number in front of every line. -A consequence worth having: the numbers are no longer inside the -`SelectionContainer`, so selecting part of a file and copying it gives the -code rather than the code with a number in front of every line. - -Line numbers are a gutter in each row, right-aligned, with the gutter -width taken from the digit count of the line count in the same monospace -style -- so a 9-line file and a 12,000-line file each get exactly the -width they need and nothing is measured by hand. Because nothing wraps, a -logical line is one visual line, and the gutter cannot drift from the text -it numbers. Gutter numbers take `onSurfaceVariant`; the text takes the +The gutter is right-aligned, its width taken from the digit count of the line +count in the same monospace style, so a 9-line file and a 12,000-line file +each get exactly the width they need and nothing is measured by hand. Because +nothing wraps, a logical line is one visual line and the gutter cannot drift +from the text it numbers. Numbers take `onSurfaceVariant`; the text takes the scanner's palette on `rawSurface`, the surface every verbatim thing in the app already sits on. The language comes from the file's extension through the same table -`fenceLanguage` reads (`FENCE_LANGUAGES` already keys on `kt`, `rs`, -`py`, …). One function, `fileLanguage(name)`, takes the part after the -last dot and asks that table; it is one table, not two, so a language -added for fences is added for files. A file with no entry is drawn plain, -for the reason the table's comment gives. - -Selection: the lines sit inside one `SelectionContainer`, as the -transcript does, so a selection can run across lines. +`fenceLanguage` reads — one table, not two, so a language added for fences is +added for files. A file with no entry is drawn plain. ### 9. The editor is the legacy text field with a highlighting transformation -Edit mode swaps the viewer for a `BasicTextField(TextFieldValue)` in the -same monospace style, inside the same horizontal scroll so it does not -wrap, with a `VisualTransformation` that returns the text unchanged and -the scanner's spans as styles (`OffsetMapping.Identity`, since no -character moves). This is the one Compose API that colours a field's text -without replacing the field; the newer `TextFieldState` API has no hook -for styles. The gutter is one `Text` of `1\n2\n…` in the same style beside -the field, aligned for the same reason as the viewer: no wrap, one line -each. +Edit mode swaps the viewer for a `BasicTextField(TextFieldValue)` in the same +monospace style, inside the same horizontal scroll so it does not wrap, with +a `VisualTransformation` that returns the text unchanged and the scanner's +spans as styles (`OffsetMapping.Identity`, since no character moves). This is +the one Compose API that colours a field's text without replacing the field; +the newer `TextFieldState` API has no hook for styles. The gutter is one +`Text` of `1\n2\n…` beside the field, aligned for the same reason as the +viewer. -Save is a glyph in the header, **disabled** until the text differs from -what was loaded (never hidden -- a control that comes and goes makes its -own absence the signal), and a `GlyphSpinner` while the write is out. -Back with unsaved changes asks; the question says the edits will be lost. -The keyboard: the explorer draws over the session, which deliberately has -no `imePadding` (see `SessionScreen`'s layout note), so the explorer's own -box adds it. - -Re-scanning on every keystroke is the cost to watch. For a file under -`FILE_LIMIT` it is expected to be a few milliseconds (the scanner replaced -a library that took 174ms on 200 lines; ours has not been measured on a -1 MiB file). Measure before deciding whether edit mode needs a size below -which highlighting is on -- see "Numbers to measure". +Save is a glyph in the header, **disabled** until the text differs from what +was loaded — never hidden, since a control that comes and goes makes its own +absence the signal. Back with unsaved changes asks, and says the edits will +be lost. The explorer draws over the session, which deliberately has no +`imePadding`, so the explorer's own box adds it. ### 10. The explorer draws over the session, and back closes it first @@ -258,190 +214,85 @@ which highlighting is on -- see "Numbers to measure". `FilesScreen` is composed **on top of** the session in the same `Box`, and the session stays composed under it: its event stream keeps flowing, its scroll position and draft stay where they were, and returning from a file -costs nothing. Back -- the button and the platform gesture -- -clears `files` when it is set and goes to the list otherwise. Inside the -explorer the same back steps one level: editor → viewer (with the unsaved -question), viewer → listing, listing → parent directory it came from, and -only from the starting directory does it close. "Back returns; it does not -exit." +costs nothing. Back — the button and the platform gesture — clears `files` +when set and goes to the list otherwise. Inside the explorer the same back +steps one level: editor → viewer (with the unsaved question) → listing → +parent directory, and only from the starting directory does it close. "Back +returns; it does not exit." -Rejected: a `Screen.Files` beside `Screen.Session`. Every route back from -a leaf screen goes to Main today, and a session disposed and re-created on -each return refetches its transcript over the tunnel -- exactly the flip -between "what did it change" and "what is it saying" this feature is for. -The image viewer already made the same choice for the same reason. +Rejected: a `Screen.Files` beside `Screen.Session`. Every route back from a +leaf screen goes to Main today, and a session disposed and re-created on each +return refetches its transcript over the tunnel — exactly the flip between +"what did it change" and "what is it saying" this feature is for. The image +viewer already made the same choice for the same reason. ### 11. The listing is drawn as it came, sorted at display time Entries carry name, kind (`directory`, `file`, `other`), size, mtime, and -whether the entry is a symlink (with the kind being the *target's*, from -`find -printf '%Y'`, so a link to a directory navigates). Sorted on the -phone, stably: directories first, then case-insensitive name. Dotfiles are -shown -- in a repository they are half of what matters. A row is the -glyph, the name, and the size for a file; tapping a directory descends, -tapping a file opens it. Each directory's entries are kept for as long as -the explorer is open, keyed by path, so returning to one does not refetch -it; the header's refresh glyph refetches the current one on purpose, and a -create refetches the directory it created into, since that is what the -operation changed. +whether the entry is a symlink — with the kind being the *target's*, from +`find -printf '%Y'`, so a link to a directory navigates. Sorted on the phone, +stably: directories first, then case-insensitive name. Dotfiles are shown; in +a repository they are half of what matters. Each directory's entries are kept +for as long as the explorer is open, keyed by path, so returning to one does +not refetch it; the header's refresh glyph refetches the current one on +purpose, and a create refetches the directory it created into, since that is +what the operation changed. -An empty directory says "Nothing here". A listing that failed says why, -in the machine's words, where the rows would be -- never an empty list. +An empty directory says "Nothing here". A listing that failed says why, in +the machine's words, where the rows would be — never an empty list. Entries are separated by `\0` in the script's output and by `\t` within a -line (`find -printf '%y\t%Y\t%s\t%T@\t%f\0'`), so a filename with a -newline or a tab in it survives; `parse_entries` is a unit test with -exactly those names in it. +line, so a filename with a newline or a tab in it survives; `parse_entries` +is a unit test with exactly those names in it. ### 12. Icons -Added to `NerdIcons.kt` **and** `build-icon-font.sh`, then the script -rerun and its output committed (it needs network): +Added to `NerdIcons.kt` **and** `build-icon-font.sh`, then the script rerun +and its output committed: `md-folder` U+F024B (the header button and +directory rows), `md-plus` U+F0415, `md-pencil` U+F03EB, +`md-content_save` U+F0193, `md-file_outline` U+F0224. The folder and the plus +are the same codepoints dev-updater uses and must not drift from it, as the +cog and the refresh arrow already must not. All five were looked up in Nerd +Fonts' own `glyphnames.json` rather than copied from memory, which is the +check that a codepoint means the glyph its comment names. -- `md-folder` U+F024B -- the header button, and directory rows. The same - codepoint dev-updater uses, and it must not drift from it, as the cog - and the refresh arrow already must not. -- `md-plus` U+F0415 -- create. Also dev-updater's. -- `md-pencil` U+F03EB -- edit. -- `md-content_save` U+F0193 -- save. -- `md-file_outline` U+F0224 -- file rows. +**The folder button sits between the usage chart and the cog**, so the header +reads widest scope to narrowest and the cog stays at the end where every +other screen keeps it. Asked for in that order by Iris on 2026-09-03. -All five were looked up in Nerd Fonts' own `glyphnames.json` rather than -copied from memory, which is the check that a codepoint means the glyph its -comment names. +### 13. The render report moved, and the benches moved with it -**Where the folder button sits**: between the usage chart and the cog, so -the header reads widest scope to narrowest and the cog stays at the end -where every other screen in this app keeps it. Asked for in that order by -Iris on 2026-09-03. - -### 13. The render report moves, and the benches move with it - -The speedometer goes. The report it copies is the standard measurement -`transcript-bench.sh` and `stream-bench.sh` read from logcat, so it stays -reachable: a "Copy render timings" row in `SessionSettingsDialog`, which -is where the session's other about-the-session controls already are. - -**No script that drives the UI taps by coordinate, and moving this -button is where that rule gets enforced** (Bryan, 2026-09-03). Both bench -scripts press the button today as `ui-trace record --do 'tap 723 205'`, a -position measured once by hand. Anything that moves the header -- this -change, a font size, a density, another emulator -- makes that tap land on -whatever now sits there, and the script then reports a number that was -never measured, which reads exactly like a result. A control is found by -the name it already carries for assistive technology (`GlyphButton`'s -`label`, a row's text) and pressed at the bounds the screen reports at -that moment. - -That belongs in the tool, not in each script: `ui-trace` in -`~/repos/emulator-tools` gains a tap-by-label action (`tap 'Session -settings'`, resolving the element's box from the same uiautomator tree -`elements` already reads, at the moment of the gesture), and both benches -move onto it in the same commit as the button -- cog, then "Copy render -timings" -- so the measurement is never unavailable and never wrong -quietly. `grep -n "tap [0-9]" app/*.sh` is the check that no coordinate -tap is left, and it goes in the emulator-tools README beside the action. -Once the action exists, this rule applies to every script that presses -something on an Android screen, not only these two. +The speedometer went; the report is a "Copy render timings" row in +`SessionSettingsDialog`, where the session's other about-the-session controls +already are. **Moving it is where the no-coordinate-taps rule got enforced** +(Bryan, 2026-09-03) — see AGENTS.md's "Driving the UI". ## HTTP surface -Added to the table in `routes.rs`'s module doc: - -```text -GET /setups/{id}/dir?path=P entries of directory P, and P resolved -GET /setups/{id}/file?path=P content of file P, or why not -PUT /setups/{id}/file {path, content, ifSha256} -> new size/mtime/sha256 - (409 when the file no longer matches ifSha256) -POST /setups/{id}/file {path} create empty; refused if it exists -POST /setups/{id}/dir {path} create; refused if it exists -``` - -Bodies use `deny_unknown_fields` like every other body here. Paths in the -query string are URL-encoded by `Api.kt`'s existing helper. +In `routes.rs`'s module doc with the rest. Bodies use `deny_unknown_fields` +like every other body here; paths in the query string are URL-encoded by +`Api.kt`'s existing helper. ```json GET dir -> {"path":"/home/bob/repos/ai-app", - "entries":[{"name":"app","kind":"directory","size":4096,"modified":1756900000,"link":false}, - {"name":"README.md","kind":"file","size":1234,"modified":1756900000,"link":false}]} + "entries":[{"name":"app","kind":"directory","size":4096,"modified":1756900000,"link":false}]} GET file -> {"path":"/…/x.rs","kind":"text","size":1234,"modified":…,"sha256":"…","content":"…"} | {"path":"/…/a.png","kind":"binary","size":45678,"modified":…} | {"path":"/…/big.log","kind":"tooBig","size":12345678,"modified":…} PUT file -> {"size":1240,"modified":…,"sha256":"…"} ``` -Errors: `BadRequest` with the machine's message for a path that is not -there, not allowed or not absolute; the existing 409 variant for the -precondition; `Internal` only for the server's own faults. The message is -what the phone shows, in place, so it is written to be read there. - -## Server work (`server/src/files.rs`) - -One module, with the same shape as `setups.rs`: the scripts as constants, -one `pub async fn` per operation taking `&Transport`, and the parsing as -pure functions with tests. - -1. `Transport::capture_with_input(launch, stdin)` -- `capture` with bytes - on stdin. `ship_attachment` in `routes.rs` builds this by hand today - (an `ssh::command`, a `File` on stdin, `output().await`); it moves onto - the new helper in the same change, so there is one description of - "run this there with this on stdin" rather than two. -2. `list(transport, path) -> Listing`: `cd -- "$1" && pwd -P && find . - -mindepth 1 -maxdepth 1 -printf '%y\t%Y\t%s\t%T@\t%f\0'`. First line is - the resolved path; the rest is entries. `parse_entries` tested with - names containing a tab, a newline, a leading dash and a `'`. -3. `read(transport, path) -> Read`: `stat -c '%s %Y' -- "$1"`, refuse - above `FILE_LIMIT` before `cat` so a 2 GB log never crosses the - tunnel, then `sha256sum -- "$1"` and `cat -- "$1"`, header lines then - bytes; the server splits at the header and decides `text`/`binary` by - `String::from_utf8`. -4. `write(transport, path, expected_sha256, bytes) -> Written`: the - script in decision 6, with a distinct exit code for the precondition - (`exit 3`) that the route maps to 409; anything else is the machine's - stderr. -5. `create_file`, `create_dir`: decision 7. -6. Routes in `routes.rs`, each resolving the setup with `setup_by_id` and - `Transport::for_setup` as `set_cwd` does. The path check (absolute or - `~`) is one function shared with `set_cwd`, which has it inline today. -7. Tests: the parsers; the quoting (a path that tries to close the quote - ends up as one absurd argument -- `ssh.rs` has the pattern); and an - integration test running each script through `Transport::Here` - against a `tempfile` tree, which is cheap because `sh` is there - wherever `cargo test` runs. The precondition test writes the file - between the read and the write and asserts the 409 path. -8. PLAN.md: the Security paragraph from decision 3, and an "Explorer" - section pointing here. AGENTS.md: the layout bullet for `files.rs`. - -## App work - -1. `Api.kt`: `fetchDir`, `fetchFile`, `writeFile`, `createFile`, - `createDir`, and the three data classes (`DirEntry`, `FileContent` - as a sealed class with the four kinds, `Written`). -2. `NerdIcons.kt` + `build-icon-font.sh`: decision 12. -3. `Languages.kt` (or `CodeFence.kt`, wherever `FENCE_LANGUAGES` sits): - `fileLanguage(name)`. -4. `FileLines.kt`: the pure half of the viewer -- spans bucketed per line, - `lineOf(index) -> AnnotatedString` -- so it has a JVM unit test beside - `HighlighterTest`, the app's one existing test suite, covering a block - comment that spans lines and a file with no trailing newline. -5. `FilesScreen.kt`: the listing, the navigation stack, the per-directory - cache, the create dialog (modelled on `AddSetupDialog`: fields, a busy - state, the failure shown inside the dialog beside the button that - caused it), and the header. `LoadState` for the listing. -6. `FileViewer.kt`: decision 8. `FileEditor.kt`: decision 9, including - the conflict dialog. -7. `AppRoot.kt`: decision 10. `SessionScreen.kt`: the folder glyph where - the speedometer was, `onFiles(setup, cwd)` out to the root. -8. `SessionSettingsDialog.kt`: the render-report row. In - `~/repos/emulator-tools`, `ui-trace`'s tap-by-label action; then the - two bench scripts onto it, with no coordinate tap left in `app/*.sh`. +Errors: `BadRequest` with the machine's message for a path that is not there, +not allowed or not absolute; 409 for the precondition; `Internal` only for +the server's own faults. The message is what the phone shows, in place, so it +is written to be read there. ## What the measurements said (2026-09-04) -Taken on the emulator in a **debug** build, which runs Compose at a -fraction of release speed and renders in software -- so these rank -correctly against each other and are pessimistic in absolute terms. -Generated Rust, through the app's own render report. +Taken on the emulator in a **debug** build, which runs Compose at a fraction +of release speed and renders in software — so these rank correctly against +each other and are pessimistic in absolute terms. Generated Rust, through the +app's own render report. | file | lines | scan + cut | scan per keystroke | worst frame record | |--------|--------|------------|--------------------|--------------------| @@ -451,32 +302,29 @@ Generated Rust, through the app's own render report. Three things followed. -**The viewer's scan had to leave the main thread.** Decision 8 said "off -the main thread" and the first version did it in a `remember` inside the -composition, which is not that: 460ms of frozen screen at the size the -server is willing to send, long enough that the accessibility tree cannot -be read -- which is exactly what "the app has stopped" looks like from -outside. It now runs on `Dispatchers.Default` with a spinner where the file -will be. +**The viewer's scan had to leave the main thread.** Decision 8 said "off the +main thread" and the first version did it in a `remember` inside the +composition, which is not that: 460ms of frozen screen at the size the server +is willing to send, long enough that the accessibility tree cannot be read — +which is exactly what "the app has stopped" looks like from outside. **`FILE_LIMIT` at 1 MiB is right for reading.** Time to first line for a -1 MiB file, tap to text on screen, was **2.4s** against the sandbox -- -1.2s of which is that server's deliberate `--delay`, and 460ms the scan. -The transfer is not what dominates, so the route gains nothing from -streaming. +1 MiB file, tap to text on screen, was **2.4s** against the sandbox — 1.2s of +which is that server's deliberate `--delay`, and 460ms the scan. The transfer +is not what dominates, so the route gains nothing from streaming. **Edit mode needed a cap, and not the one that was expected.** The plan -expected to be deciding a size below which highlighting stays on. That is -not the cost that matters: highlighting 128 kB costs 40ms a keystroke, -which is survivable, while laying the same text out in one -`BasicTextField` costs two seconds -- characters typed into it were -dropped, and a 1 MiB file stopped the app responding altogether. Since -every arrangement of a single text field pays that, switching highlighting -off would have saved nothing. So `EDIT_LIMIT` is **32 kB**, the largest -size measured as usable, and above it the pencil is disabled with the -reason said in words beside it -- a disabled control teaches what the thing -can do but cannot say why it is off, and a reader who cannot edit a file -they can plainly read would otherwise conclude the app is broken. +expected to be deciding a size below which highlighting stays on. That is not +the cost that matters: highlighting 128 kB costs 40ms a keystroke, which is +survivable, while laying the same text out in one `BasicTextField` costs two +seconds — characters typed into it were dropped, and a 1 MiB file stopped the +app responding altogether. Since every arrangement of a single text field +pays that, switching highlighting off would have saved nothing. So +`EDIT_LIMIT` is **32 kB**, the largest size measured as usable, and above it +the pencil is disabled with the reason said in words beside it — a disabled +control teaches what the thing can do but cannot say why it is off, and a +reader who cannot edit a file they can plainly read would otherwise conclude +the app is broken. Reading is unaffected: the viewer opens and scrolls the 1 MiB file fine, because it is a `LazyColumn` of lines rather than one text object. That @@ -484,21 +332,20 @@ difference is the whole of decision 8. ## Later, deliberately not now -- Delete, rename and move. Destructive controls belong here eventually, - shown and confirmed rather than hidden, but none of them is needed to - read or change a file. +- Delete, rename and move. Destructive controls belong here eventually, shown + and confirmed rather than hidden, but none is needed to read or change a + file. - Images in the viewer, through the existing `SessionImageViewer`. -- Following an agent's edits live: a file open in the viewer refreshing - when a `Write`/`Edit` tool call on the same path lands in the - transcript. The transcript already knows the path. +- Following an agent's edits live: a file open in the viewer refreshing when + a `Write`/`Edit` tool call on the same path lands in the transcript. The + transcript already knows the path. - Remembering the last directory per session. - Uploading from the phone into a directory. Attachments already do the - upload half; this would be the same route with a chosen destination. + upload half. - Search within a file, and find-in-files. -- **A line-by-line editor**, which is the way past `EDIT_LIMIT`. The - viewer already draws a file as rows and stays fast on a megabyte; an - editor built the same way -- a field per line, or a field over the lines - on screen -- would not pay Compose's cost of laying out one enormous - text. It is a good deal more than this feature needed, and 32 kB covers - the config files, notes and ordinary source files anybody edits from a - phone. +- **A line-by-line editor**, which is the way past `EDIT_LIMIT`. The viewer + already draws a file as rows and stays fast on a megabyte; an editor built + the same way — a field per line, or a field over the lines on screen — + would not pay Compose's cost of laying out one enormous text. It is a good + deal more than this feature needed, and 32 kB covers the config files, + notes and ordinary source files anybody edits from a phone. diff --git a/PLAN.md b/PLAN.md index ab7427d..133ab15 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1,113 +1,26 @@ # ai-app — plan -A phone interface to AI coding sessions — Claude Code and llama.cpp for now — -built to replace the Claude app for day-to-day use. Two motivations: local -models need a front end at all, and owning the client means fixing the things -the official app gets wrong (e.g. it won't deliver a typed message until the -session fully finishes its turn, where the TUI injects it at the next tool -boundary). +A phone interface to AI coding sessions — Claude Code and llama.cpp — built +to replace the Claude app for day-to-day use. Two motivations: local models +need a front end at all, and owning the client means fixing what the official +app gets wrong (it won't deliver a typed message until the turn fully +finishes, where the TUI injects it at the next tool boundary). Same shape as `../dev-updater`: a Rust (Axum) backend on the desktop, a Kotlin/Compose Android app, pinned self-signed TLS between them. +This file records decisions with their date, their rationale, and what was +rejected. Update it in place when one changes; `AGENTS.md` is the working +notes layer and must not become a second version of it. + ## The one idea everything hangs off -Both session types are **a child process speaking JSONL over stdio**: - -- Claude Code: `claude -p --input-format stream-json --output-format stream-json` - — bidirectional streaming JSON. User messages sent while a turn is running - are injected at the next opportunity (the TUI behavior we want), a control - protocol carries interrupts and permission requests, `--resume ` picks a - session back up after a backend restart. -- llama.cpp: **pi in RPC mode** (`pi --mode rpc`), pointed at a llama-server - endpoint. Same deal: JSONL on stdio, `prompt` (with images), `steer` for - mid-run injection, `abort`, `set_model`, `compact` / `set_auto_compaction`, - session files that survive restarts, structured events for streaming text - and tool executions. - -So the backend has one abstraction — spawn a process, translate its dialect to -a common event stream, keep an append-only transcript — and two translators. -SSH support falls out of the same shape: a remote session is the identical -command run as `ssh `; stdio doesn't care. - -Decisions already made (2026-08-24): - -- llama.cpp harness: **pi RPC now**, with the session abstraction kept clean - enough that a custom Rust agent loop can be added as a third driver later. -- The backend **manages llama-server itself** (start with a chosen GGUF, stop, - swap models), locally and over SSH. -- Claude permission prompts are **interactive in the app**, with a per-session - permission mode chosen at spawn. -- **One backend** on the main machine; the phone talks only to it, and it - reaches other hosts via SSH. Remote hosts need the CLIs installed but no - backend. - -## Architecture - -### Setups and providers (decided and built 2026-08-28, superseding the below) - -**A setup is a machine, and it carries the providers that machine has.** -Optional ssh details, plus the list of what can be run there. Spawning is -then two choices in order: pick a setup, then pick one of its providers. - -This replaces the independent providers × hosts model recorded below, -which is what the code does today. What went wrong with it: the two axes -are not actually independent. A provider is only real on a machine where -that CLI is installed, so a free cross-product offers combinations that -cannot work — `claude-cli` on a machine with no `claude`, and every -provider paired with a host the driver ignores entirely (`EchoDriver` -takes no host, so "Run on" is a control that silently does nothing for -it). Grouping providers under the machine they exist on makes the picker -show only what is true. - -Settled while building it: - -- **Echo is seeded, not implicit.** It lives in the setup with no ssh, - because it runs in-process and has no transport to cross. It is written - into `config.ron` on first run rather than conjured at read time — a - provider nobody can see in the file is one nobody can edit from the - phone, which is the opposite of what this app is for. -- **Migrated once, then the migration was deleted** (2026-08-28). Unknown - fields default away, so a `providers:`/`hosts:` file would have loaded as - an empty config and then been seeded over, losing everything silently. - The first answer to that was to *refuse* such a file, which was the wrong - trade and proved it: this process is how a phone reaches the backend at - all, so refusing to start stranded the person who would have to fix it, - as a crash loop with nothing reachable to explain it. It was replaced by - a migration that kept the token hashes, backed the old file up, and - rebuilt the rest — which is discoverable now anyway. - That migration has since run on the one host there is, so it is gone - again, per the standing rule that migration code is deleted once the - update carrying it has been received. With one backend and one phone, - nothing is left on the old shape, and a second parsing path nothing - exercises only constrains later changes to the schema. A file in the old - shape now fails to parse, which is correct because no such file exists. -- **Still to do: editing setups from the phone.** `GET /setups` exists; - writing them is not built, so a new machine is still a hand edit on the - backend. That is the remaining gap against the standing preference that - configuration be reachable from the app. Key material is the honest - exception — a setup names an identity file that must already exist on - the backend machine, because a private key must not travel. - -The superseded model, for the reasoning it recorded: - -Two independent axes, configured separately and chosen per session: - -- A **provider** is *what* runs: a driver kind, the command to invoke, and - the models worth offering. `claude-cli` is the first — named for the CLI - specifically, since bare "claude" would suggest the credit-billed API, - which this is not. llama.cpp becomes a second provider later. -- A **host** is *where* it runs: an ssh target. Absent means the backend - machine itself. - -Sessions name both. Keeping them independent is what the motivating setup -requires: the backend runs on the machine the phone can reach (where -WireGuard terminates), which is not necessarily where a CLI is installed — -here the Claude CLI lives only in a VM on that machine, while llama.cpp -will be on the host itself. Pinning a host into a provider would make "the -Claude CLI" and "the Claude CLI over there" two things to configure and -choose between, and would stop the same provider from being sent somewhere -else for one session. +**A session is a child process, translated into one common event model.** +The backend spawns it, translates its dialect into a common event stream, +and keeps an append-only transcript. A new session type is a new driver — +never a session-type branch in shared code (routes, transcript, app +screens). SSH falls out of the same shape: a remote session is the identical +command wrapped in `ssh host …`, and the driver never learns which it got. ``` Android app (Compose) @@ -115,1089 +28,819 @@ Android app (Compose) ▼ backend (Rust/Axum, desktop) ├─ SessionManager ── Session ── Driver (trait) - │ ├─ ClaudeDriver (claude stream-json) - │ └─ PiDriver (pi --mode rpc) - │ each driver's process is spawned locally or as `ssh host …`, - │ decided per session by the host it names - ├─ LlamaServerManager (llama-server lifecycle, local + SSH) - ├─ UsageMonitor (Anthropic OAuth usage endpoint) + │ ├─ ClaudeDriver (claude stream-json over stdio) + │ ├─ LlamaDriver (llama-server over HTTP) + │ └─ EchoDriver (the test rig) + │ each driver's process is spawned through a Transport, + │ locally or as `ssh host …`, decided by the setup it names + ├─ usage.rs (Anthropic OAuth usage endpoint, per machine) + ├─ models.rs (HuggingFace browsing and GGUF downloads) + ├─ files.rs (the file explorer's half of the backend) └─ config.ron + per-session transcript files ``` +## Architecture + +### Setups and providers (2026-08-28) + +**A setup is a machine, and it carries the providers that machine has.** +Optional ssh details, plus the list of what can be run there. Spawning is +two choices in order: pick a setup, then pick one of its providers. + +This replaced an independent providers × hosts cross-product, because the +two axes are not independent: a provider is only real on a machine where +that CLI is installed, so the cross-product offered combinations that cannot +work — `claude-cli` on a machine with no `claude`, and every provider paired +with a host the driver ignores (`EchoDriver` takes no host, so "Run on" was +a control that silently did nothing). + +- **Echo is seeded, not implicit.** It lives in the setup with no ssh, + because it runs in-process and has no transport to cross. It is written + into `config.ron` on first run rather than conjured at read time — a + provider nobody can see in the file is one nobody can edit from the phone. +- **Providers are discovered by asking the machine**, never typed, so an + enrolled token cannot introduce a command. The escape hatch for a binary + somewhere unusual is editing `config.ron`, deliberately the one authority + the phone does not have. +- **Migration code is deleted once the update carrying it is received.** The + providers/hosts migration ran on the one host there is and is gone. A file + in the old shape now fails to parse, which is correct because no such file + exists. + ### Backend layout (`server/`) -Mirroring dev-updater's stack: axum 0.8, axum-server + rustls, tokio, serde, -clap, tracing. Rust edition 2024, warning-clean, clippy in CI habit. +axum 0.8, axum-server + rustls, tokio, serde, clap, tracing. Rust edition +2024, warning-clean, clippy clean. -- `main.rs` — bootstrap, TLS listener. -- `routes.rs` — the whole HTTP table in one module doc comment (as in - dev-updater). -- `session/mod.rs` — `SessionManager`: the live session registry, every - mutation funnels through it (the `registry.rs` pattern: in-memory and - on-disk state can't come apart). -- `session/driver.rs` — the `Driver` trait and the common event model. -- `session/claude.rs`, `session/pi.rs` — the two translators. -- `session/transcript.rs` — append-only JSONL event log per session, with - monotonically increasing sequence numbers (the phone's resume cursor). -- `llama.rs` — `LlamaServerManager`. -- `ssh.rs` — the ssh command builder (host configs ended up in `config.rs` - with the rest of the schema, so this module is only the wrapping; named - for what it does rather than `hosts.rs` as first sketched). -- `usage.rs` — Anthropic usage polling. -- `config.rs` — persisted schema. -- `certs.rs` — the TLS certificates, generated in process on first start - (added 2026-08-25, replacing a `gen-dev-cert.sh` that shelled out to - openssl). -- `private.rs` — creating files and directories owner-only. One module - owns the modes so "nothing this server writes is readable by anyone - else" is checkable in one place instead of re-argued at each `create` - (added 2026-08-25; config, certs, and session dirs had three copies). +- `main.rs` — bootstrap, TLS listener, auth layer, enrollment, wg0 binding. +- `routes.rs` — the whole HTTP table in its module doc comment. **That + comment is the surface's source of truth**; this file does not repeat it. +- `auth.rs` — the bearer-token middleware. +- `config.rs` — the persisted schema. +- `setups.rs` — machines and provider discovery. +- `files.rs` — the file explorer (`EXPLORER.md`). +- `usage.rs` — Anthropic usage polling, per machine. +- `models.rs` — HuggingFace browsing and GGUF downloads. - `media.rs` — the image media-type/extension table, shared by the four - places that have to agree on it: storing an upload, serving it back, - handing one to a driver's dialect, and saving one a tool produced. + places that must agree: storing an upload, serving it back, handing one to + a driver, and saving one a tool produced. +- `session/mod.rs` — `SessionManager`, the live registry; every mutation + funnels through it so in-memory and on-disk state cannot come apart. +- `session/driver.rs` — the `Driver` trait and the common event model. +- `session/claude.rs`, `session/llama.rs`, `session/echo.rs` — the drivers. +- `session/transcript.rs` — the append-only JSONL event log per session, + with monotonically increasing sequence numbers (the phone's resume cursor). +- `session/transport.rs`, `ssh.rs` — running a driver's command locally or + over ssh. +- `session/process.rs` — the pid + start-time record that lets a process + outlive the backend. +- `session/import.rs` — continuing a Claude Code session the machine has. +- `session/pending.rs` — operations in flight on importable sessions. -`session/pi.rs` and `llama.rs` are phase 4 and not built yet; everything -else above exists. +The certificates, enrollment, wg0 binding, owner-only file modes and RON +house rules live in the `wg-app-link` submodule, shared with dev-updater. ### The common event model -Driver output, whatever the dialect, is normalized into one event enum before -it touches the transcript or the phone: +Driver output, whatever the dialect, is normalized into one enum before it +touches the transcript or the phone. Every event is appended to the session's +transcript with a sequence number, then fanned out to SSE subscribers. The +phone renders purely from this stream: reconnecting is "give me events after +seq N", so there is no separate history path to drift from the live one. -- `UserMessage { text }` — what the user sent, echoed into the transcript - by the manager (not by drivers) so every device renders the conversation - from the one stream. (Added 2026-08-24 during phase 1: without it, - reconnects and second devices would lose the user's side.) -- `AssistantText { delta }` — streaming text (rendered as markdown). -- `ToolStart / ToolUpdate / ToolEnd { tool, input, output }` — the "view tools - it's running" screen is just these. -- `Image { ref }` — images in output (screenshots from tools, etc.) are saved - under the session dir and referenced by id; the phone fetches them by URL. -- `Question { id, prompt, options }` — anything the session needs a human for: - Claude's AskUserQuestion, and **permission requests** (canUseTool) are the - same shape with approve/deny options. Answered via one endpoint. -- `Answered { id, answer }` — the manager's record of a question being - answered, so a rendered question card resolves on every connected device, - not just the one that answered (added 2026-08-24, same reasoning as - `UserMessage`). +- `UserMessage { text }` — echoed into the transcript **by the manager, not + by drivers**, so every device renders the conversation from one stream. +- `AssistantText { delta }` — streaming text, rendered as markdown. +- `ToolStart / ToolUpdate / ToolEnd { tool, input, output }`. +- `Image { ref }` — saved under the session dir, fetched by URL. +- `Question { id, prompt, options }` — anything needing a human. Claude's + AskUserQuestion and permission requests (canUseTool) are the same shape; + a permission is a question with two bare options, not a different kind. +- `Answered { id, answer }` — so a question card resolves on every connected + device, not just the one that answered. - `Status { state }` — idle / running / awaiting-input / compacting / exited. -- `UsageDelta { tokens, context }` — what a turn cost, and how much the - model was holding when it ended, where the dialect reports them (both do). - `context` is prompt plus both cache figures, taken from the **last - assistant message** rather than the turn's `result`: measured 2026-08-30 - against CLI 2.1.237, the result adds a turn's messages up, so its cache - read of 40,211 was the same conversation counted twice and no size the - model ever held. It is carried rather than summed by readers because it - goes *down* — a compaction replaces it with what the compaction reports, - and a clear leaves it unmeasured. `driver::context_after` is that rule, - and the phone folds with the same one (2026-08-30: this replaced a running - spend total, which could only climb and so kept reporting a context a - compaction or a clear had already taken away). - A session the server has no measurement of asks the CLI's own file - instead of waiting for a turn — `import::context_of`, the same three - fields the import list reads, in the background at load so a start never - waits on an ssh. A clear needs no special case: it gives the CLI a new - session id, so the lookup lands on a file with no usage in it and - answers "unknown", which is true. +- `UsageDelta { tokens, context }` — what a turn cost and how much the model + was holding when it ended. `context` is prompt plus both cache figures, + taken from the **last assistant message** rather than the turn's `result`: + measured 2026-08-30 against CLI 2.1.237, the result adds a turn's messages + up, so its cache read of 40,211 was the same conversation counted twice. + It is carried rather than summed, because it goes *down* — a compaction + replaces it and a clear leaves it unmeasured. `driver::context_after` is + that rule and the phone folds with the same one. A session the server has + no measurement of asks the CLI's own file instead of waiting for a turn + (`import::context_of`). +- `MessageQueued` / `MessageDropped` — see "Taking a queued message back". +- `PeerMessage` — see "A message from another agent". - `Error { message }`. -Every event is appended to the session's transcript file with a sequence -number, then fanned out to any connected SSE subscribers. The phone renders -purely from this stream: reconnecting means "give me events after seq N" — -no separate "load history" path to drift from the live one. +Inbound, the `Driver` trait is small: send a message, answer a question, +interrupt, set the model, compact, unqueue, and two ways out — `detach` (the +server is going away and means to come back) and `stop` (the session is being +deleted, so the process must not survive). Every driver owes exactly one of +the two. -Inbound, the driver trait is small: - -```rust -trait Driver { - fn send_user_message(&self, text: String, images: Vec); - fn answer_question(&self, id: QuestionId, answer: Answer); - fn interrupt(&self); // stop mid-run, session survives - fn set_model(&self, model: &str); - fn compact(&self); // pi: native; claude: /compact - fn shutdown(&self); // graceful process exit -} -``` - -`send_user_message` during a run is the point of the whole app: both dialects -queue it for injection at the next tool boundary rather than the end of the -turn. Claude's dialect: a `user` message on stdin mid-stream; pi's: `steer`. +`send_user_message` during a run is the point of the whole app: the dialect +queues it for injection at the next tool boundary rather than the end of the +turn. ### Claude driver specifics -- Spawn: `claude -p --verbose --input-format stream-json --output-format - stream-json --permission-mode ` in the chosen working directory, plus - `--model` at spawn. Permission mode (default/plan/acceptEdits/ - bypassPermissions) is chosen on the spawn screen. -- Interactive permissions: run with the stream-json control protocol's - permission request flow (the same mechanism the Agent SDK's `canUseTool` - uses) so tool approvals arrive as control requests, become `Question` - events, and our answer goes back as the control response. **Verify the - exact control-request wire format against the current CLI early in - implementation** — it's the least-documented part of this plan. -- Interrupt: control-protocol interrupt request. -- Model change mid-session: try the control protocol's set-model; if the - installed CLI doesn't support it, fall back to `shutdown` + respawn with - `--resume --model ` — cheap, since Claude persists - sessions in `~/.claude/projects` anyway. That resume path is the recovery - story for a process that has genuinely died; a backend restart never takes - it — it adopts the process that is still there, and starts nothing for the - session that has none (see below). - **Resuming is only ever safe when nothing else has that session open.** -- Images in: base64 image content blocks in the stream-json user message. -- Working directory, host, and model are spawn-screen fields. +Spawn: `claude -p --verbose --input-format stream-json --output-format +stream-json --permission-mode ` in the chosen working directory, plus +`--model`. Wire-format notes are pinned against CLI 2.1.237 in +`session/claude.rs`'s module doc: permissions need the hidden +`--permission-prompt-tool stdio` flag, AskUserQuestion answers ride +`updatedInput.answers` keyed by question text, and `set_model`/`interrupt` +are control requests. -### Moving a session to another directory (decided 2026-08-31) +**`--resume` only ever runs when nothing else has that session open.** That +is the rule behind the import refusal, the single `ClaudeDriver::launch` +entry point, and the `Exited` correction below; two CLIs on one session file +duplicate the conversation into it and bill the second for re-reading it all. -`POST /sessions/{id}/cwd {cwd}`, behind a field in the session settings -dialog. The directory is settled when the process is spawned -- the CLI is -launched with it as its cwd and there is no control request that changes one --- so this records the new one and **ends** the process that is in the old -one. It does not start a replacement: a session with no process starts on -the next thing said to it or on Start, which is this app's rule for that -everywhere else, and "usually restarts" would be a worse control than -"always stops" (starting one here would have to wait for the recorded status -to catch up with a process already gone). +### The llama driver + +One `llama-server` per session, started through the same `Transport` as any +other process and then reached over HTTP on a loopback port. Two things are +deliberate and easy to undo by accident: + +- **The conversation is rebuilt from the transcript**, not kept in the + driver. A copy in driver memory is invisible to a second device and gone + when the process restarts. That leaves the Claude driver as the odd one + out rather than this one — the CLI's memory is a cache in front of the same + transcript. Resolve any inconsistency in this direction. +- **A llama session on an ssh host is refused.** The model is reached over + HTTP and forwarding that port is not built, so refusing beats silently + talking to the wrong machine. A transport is "run this" plus "reach this + port", and only the first half exists. + +### Models (2026-08-28) + +- **A download belongs to the model, not to the request.** Keyed by + `owner/repo/file.gguf` and owned by the server, so a second device can + watch one it did not start and an hour-long fetch survives a locked screen. + Every run has an id and its outcome outlives it, because "not downloading" + otherwise means finished, never started, or someone else's run ended while + you were away. +- **Progress is measured**, never estimated: `total` is Content-Length, or + Content-Range's last field on a resume, and absent when the server says + nothing. +- **Resume is guarded by identity, not by hope.** A partial carries the ETag + it was written against and a mismatch discards it. `If-Range` would be the + tidy mechanism but HuggingFace's CDN ignores it (probed 2026-08-28). The + published sha256 is checked before the file is renamed. +- Sampling parameters reach a driver as an untyped `params` map, so the + shared schema does not grow llama.cpp's vocabulary. + +### Transport (ssh) + +- A remote session is a local one with the command wrapped in `ssh -T host …`, + every argument shell-quoted, run with `exec` so dropping the connection + takes the CLI down rather than orphaning it. Key-based auth only, through + the system `ssh` client, which inherits `~/.ssh/config`, agents and jump + hosts for free. +- **The transport wraps the driver, not the other way round** (2026-08-28). + A driver says what to run; something above it turns that into a process. + Otherwise transport knowledge sits inside a translator whose job is a wire + format, and every future driver has to remember to do the same. +- **`command -v` follows ssh's non-login PATH**, which is narrower than an + interactive shell's, so a binary somewhere unusual is invisible to + discovery. Point `command` at an absolute path. +- **Images need no file transfer.** `attachment_block` base64s an upload into + the stream-json message, and produced images come back the same way. +- **Any other file is told to the session by path** (2026-09-03): a trace, a + log, a zip — things a model cannot be shown and the CLI can read. The + upload is streamed to disk under the session's attachments on this machine, + and the message ends with `Attached file: /abs/path`. For a session on + another machine the upload also copies the file there in the same request, + over one `ssh` invocation, landing in the setup's `attachmentsDir` if set, + else the session's cwd, else the login home. The resolved remote path is + recorded beside the file (`.remote`) and is what the driver names. A + copy that fails fails the upload, so no message ever names a file that is + not there. + +### Moving a session to another directory (2026-08-31) + +`POST /sessions/{id}/cwd`, from the session settings dialog. A working +directory is settled when the process is spawned, so this records the new one +and **ends** the process in the old one. It does not start a replacement: a +session with no process starts on the next thing said to it or on Start, +which is this app's rule everywhere else. The path is checked against the session's own machine and **refused** if it -is not there, rather than corrected. The spawn path corrects instead, -because it is resuming a directory the *machine* recorded and that can be -gone through nobody's fault; a path somebody has just typed is different, -and a mistyped one accepted here would surface much later as a session that -would not start, with nothing pointing at the typo. +is not there, rather than corrected. The spawn path corrects instead, because +it is resuming a directory the *machine* recorded, which can be gone through +nobody's fault; a path somebody has just typed is different, and a mistyped +one accepted here surfaces much later as a session that will not start. -**Nothing of Claude Code's own is moved**, and that is a measurement rather -than an omission. Checked against CLI 2.1.237 on 2026-08-31: `claude ---resume ` finds a session from any working directory — an id that does -not exist answers "No conversation found with session ID", and a real one -resumed from an unrelated directory did not. So the conversation continues -in the new place with nothing relocated, and the session file stays under -the project directory the CLI made for it, which is where the CLI itself -looks. Relocating it would mean reproducing a rule this app cannot see the -whole of: the CLI's project directory is the path with every non-alphanumeric -character replaced by `-`, truncated at 200 characters with a hash of its own -appended, and an override can replace the name entirely. +**Nothing of Claude Code's own is moved.** Measured against CLI 2.1.237: +`claude --resume ` finds a session from any working directory. Relocating +the file would mean reproducing a rule this app cannot see the whole of — the +project directory is the path with every non-alphanumeric character replaced +by `-`, truncated at 200 characters with a hash appended, and overridable. -While fixing this: `SessionInfo.cwd` came from the snapshot a session -launched with, so a moved session reported its *old* directory for as long -as the process lived. It is read from the config where the row is built now, -the same way `setup_name` already was and for the same reason. +### A message from another agent (measured 2026-08-31) -### A message from another agent, on a live session (measured 2026-08-31) +Measured by sending a real cross-session message to a real stream-json +session on CLI 2.1.237: the CLI emits **no `user` record** for it, and +nothing in the partial-message stream mentions it. The whole of it arrives as +an `origin` object on the turn's `result`, in the same shape the session file +records — so `import::peer_message` reads both and there is one function for +one wire format. Only peer-caused turns carry it. -Peer messages were only ever produced by the *import* path, reading them out -of the CLI's own session file — so a message another agent sent a session -this server was running never appeared at all, and the session simply -started working on something nobody on the phone had asked for. +**The cost is the position, and it is paid on the wire rather than on +screen.** The event cannot be recorded in place: at no earlier point does the +CLI say why the turn started, and the transcript is append-only, so by the +time anyone knows, everything the message caused is already written above it. +Tailing the CLI's own session file instead was rejected and stays rejected — +two sources of truth for one conversation and a poll per live session. -Measured rather than guessed, by sending a real cross-session message to a -real `--input-format stream-json` session on CLI 2.1.237: the CLI emits **no -`user` record** for it, and nothing in the partial-message stream mentions -it. The whole of it arrives as an `origin` object on the turn's `result`, in -the same shape the session file records — `kind: "peer"`, the sending -session's `name`, and the message as `body` — so `import::peer_message` reads -both, and there is one function for one wire format. Only peer-caused turns -carry it: four ordinary results on a real session's stdout had no `origin` -between them. +So `PeerMessage` carries a `turnStart`: the seq of the `Status` that opened +the turn, stamped by the pump, which is the only thing that knows a seq and +sees every driver's turns. The phone draws the note at that seq. A status +draws no row, so there is nothing to collide with and the list stays sorted, +which is what the scroll anchor and paging depend on. `turnStart` is absent +where there is nothing to correct — a message replayed by `import` is already +in the right place. The echo driver models both shapes: `/peer` and +`/peer-turn`. -**The cost was the position, and it is paid on the wire rather than on -screen** (2026-09-01). The event cannot be *recorded* in place: at no earlier -point in the turn does the CLI say why the turn started, and the transcript -is append-only, so by the time anyone knows, everything the message caused -has already been written above it. Reading it out of the CLI's own session -file instead — a second reader tailing the one record stdout does not carry — -was rejected then and stays rejected: two sources of truth for one -conversation and a poll per live session. +### Taking a queued message back (2026-08-31) -So the event carries **where it belongs** instead. `PeerMessage` has a -`turnStart`: the seq of the `Status` that opened the turn it started, stamped -by the pump, which is the only thing that knows a seq and the only thing that -sees every driver's turns. The phone gives the note that seq, so it sorts -into the transcript above the turn rather than being drawn out of order at -the end. That seq belongs to a status change, and a status draws no row, so -there is nothing for the note to collide with and the list stays sorted — -which is what the scroll anchor and paging depend on. - -`turnStart` is absent where there is nothing to correct: a message replayed -out of a session file by `import` is already in the right place, and one that -opened no turn has no turn to sit above. Both are drawn where they arrive. -The echo driver models both shapes — `/peer` for the in-place one, and -`/peer-turn` for the live one, which reveals the note only after a reply and -a run of tool calls. - -### Taking a queued message back (decided 2026-08-31) - -A message sent into a running turn is drawn as a bubble waiting below the -transcript, and tapping it asks the server to drop it before the session -reads it — `POST /sessions/{id}/unqueue {messageId}`, answered by -`Driver::unqueue` and recorded as `Event::MessageDropped` so that every -device watching loses the bubble and a reconnect does not replay it back. +`POST /sessions/{id}/unqueue`, answered by `Driver::unqueue` and recorded as +`Event::MessageDropped` so every device loses the bubble and a reconnect does +not replay it. The answer has **three** states rather than a yes/no, and that is the whole -of the design: `Dropped`, `AlreadySent`, and `Unknown`. The reason is that -the Claude driver can only ever give the middle one. It writes a steer into -the CLI's stdin the instant it arrives — that is what makes a steer reach -the model at the next tool boundary instead of at the end of the turn, and -it was measured (see `Queue`'s doc comment) — so the line is gone before the -phone could ask for it back. What waits in `awaiting` is the *announcement*, -not the message. - -Holding the write until a boundary was considered and rejected on 2026-08-31: -it would make the drop real everywhere, but it costs a steer one model call, -which is the latency the immediate write was introduced to remove. So the -refusal is the honest answer and it is reported where the reader pressed — -on the bubble itself, not in the screen's error row, which is under the -header a screen away. What a tap buys on a Claude session is therefore -knowing that the session has already been told; on a driver that really does -hold a queue (echo today) the message goes. +design: `Dropped`, `AlreadySent`, and `Unknown`. The Claude driver can only +ever give the middle one — it writes a steer into stdin the instant it +arrives, which is what makes a steer reach the model at the next tool +boundary instead of the end of the turn. What waits in `awaiting` is the +*announcement*, not the message. Holding the write until a boundary would +make the drop real everywhere but costs a steer one model call, which is the +latency the immediate write removed. So the refusal is the honest answer, and +it is reported on the bubble the reader pressed rather than in the screen's +error row a screen away. `Unknown` is not "we could not find out": a driver that is gone reported -everything it was holding when it closed, so there is nothing waiting. +everything it was holding when it closed. -### Session processes outlive the backend (decided 2026-08-29) +### Session processes outlive the backend (2026-08-29) -A session's process is **left running when the backend stops, and adopted -again when it starts.** Restarting the server — a rebuild, a service -restart, a crash — must not end a turn somebody is waiting on, and a turn -can easily be minutes long. +A session's process is **left running when the backend stops and adopted +again when it starts.** A rebuild, a service restart or a crash must not end +a turn somebody is waiting on, and a turn can be minutes long. What this +replaced leaked processes either way: `shutdown_all` asked every driver to +stop and then exited immediately, with the SIGKILL escape hatch on a timer +inside the dying runtime, and whatever survived was orphaned with nothing +written down to find it by. -What this replaces: `shutdown_all` asked every driver to stop, then the -process exited immediately. The SIGKILL escape hatch was a timer inside the -runtime that died with it, so the stop was unreliable; whatever survived was -orphaned with nothing written down to find it by. Processes leaked either -way. The change is that they are now left on purpose and can be picked back -up. +Inside the session directory, beside the transcript: -How it works, all inside the session directory beside the transcript: - -- `process.json` — the pid, the kernel's **start time** for that pid, and - how much of the output log has been read. The start time is what makes - the pid an identity: pids are reused, and adopting a stranger's would mean - never resuming the real conversation and signalling something unrelated. -- `stdin.fifo` — opened **read-write** and inherited by the process, so it - is its own last writer and never reads EOF when the server goes away. - Closing stdin therefore stops being the graceful-exit signal; ending a - process is a signal now, and only `Driver::stop` does it. +- `process.json` — the pid, the kernel's **start time** for that pid, and how + much of the output log has been read. The start time is what makes the pid + an identity: pids are reused, and adopting a stranger's would mean never + resuming the real conversation and signalling something unrelated. +- `stdin.fifo` — opened **read-write** and inherited by the process, so it is + its own last writer and never reads EOF when the server goes away. Closing + stdin therefore stops being the graceful-exit signal; ending a process is a + signal, and only `Driver::stop` sends one. - `stdout.log` / `stderr.log` — plain appended files, read from a byte offset. A fifo would fill its 64 KB buffer and block the process while - nothing was draining it, which would stall the very turn the leak exists - to protect. Measured: the CLI writes to a file unbuffered, so streaming is - unaffected. + nothing drained it, stalling the very turn this exists to protect. -Two consequences worth stating: +**Remote sessions are adopted too, and the recorded pid is the `ssh` +client's** — the process the backend owns, which lives exactly as long as the +remote command does. The far `claude` always has an sshd pipe on stdin +whichever version started it, since the fifo is on the backend's side, so a +remote session's stdin says nothing about which server started it. -- **`--resume` is reachable only when nothing is running.** This is the same - rule as the import refusal below, and for the same reason: two CLIs on one - session file duplicate the conversation into it and bill the second for - re-reading all of it. -- **Remote sessions are adopted too, and the recorded pid is the `ssh` - client's.** This was written down as "local only" and that was wrong about - the code: `start` records a pid whatever the transport, and for a remote - session the process the backend owns *is* the ssh client. Adopting it is - coherent — the fifo still feeds it, its logs still capture the far end's - output, and `ssh` lives exactly as long as the remote command does, so its - liveness is the session's liveness. - The consequence worth knowing: **the remote `claude` always has an sshd - pipe on stdin, under old code and new alike**, because the fifo is on the - backend's side of the connection. So the far process's stdin says nothing - about which version of this server started it. +**A zombie is dead.** `/proc//stat` keeps the entry, with the same pid +and start time, until the exit status is collected — so a finished process +answered "still there" for as long as nothing reaped it, and `Alive` is the +word that makes `Exited` unsayable. `process::stat_of` reads the state field +alongside the start time. -`Driver` therefore has two ways out rather than one: `detach` (the server is -going away and means to come back) and `stop` (the session is being deleted, -so the process must not survive). Every driver owes exactly one of them. +### Stopping and starting a session's process (2026-08-30) -### Stopping and starting a session's process (decided 2026-08-30) +`POST /sessions/{id}/stop` and `/start`: end the process without ending the +session, and start it again on the same conversation. Three decisions worth +not undoing: -If a session outlives the backend, the person holding the phone needs the -other direction too: **end the process without ending the session, and start -it again on the same conversation.** `POST /sessions/:id/stop` and -`/start`. - -Three decisions worth not undoing: - -- **Stop signals the recorded process and says nothing else.** It does not - go through the driver and it does not announce `Exited`. The record is the - session's rather than any dialect's, so signalling it here works for a - session whose driver is in no state to be asked and adds no trait method a - new driver could implement wrongly — and the driver's own reader already - reports the death correctly, draining the last output and recording the - status. Announcing it from here would be a guess arriving ahead of the - measurement, and wrong for the grace period a process that ignores SIGTERM - keeps running. +- **Stop signals the recorded process and says nothing else.** It does not go + through the driver and does not announce `Exited`. The record is the + session's rather than any dialect's, so this works for a session whose + driver is in no state to be asked, and the driver's own reader already + reports the death correctly. Announcing it here would be a guess arriving + ahead of the measurement, and wrong for the grace period. - **Start replaces the driver and nothing else.** The transcript, the event - pump and the SSE stream every open phone is reading stay where they were, - so starting a session again is not a reconnect for anybody watching, and - there is still exactly one writer of the transcript — which relaunching - the whole `LiveSession` would not be, since the old pump outlives its - session and an imported session's sync task would go on feeding it. - `LiveSession` and `Commands` therefore share one `Mutex>` - rather than each holding a copy. + pump and every open SSE stream stay where they were, so starting again is + not a reconnect for anybody watching, and there is still exactly one writer + of the transcript. `LiveSession` and `Commands` share one + `Mutex>` rather than each holding a copy. - **Start is refused unless the session is *known* to have exited.** - `Unknown` means nobody could find out whether the process is alive, and - starting one on that is exactly the two-CLIs-on-one-conversation fault - `session::process` exists to prevent. - -That last rule found a real bug in the launch path, which is where the phone -would have hit it: a relaunched session took its status from the transcript, -so one whose process had died before a backend restart reported `Exited` -while the launch it had just gone through was starting a new process — -`Exited` there is not merely stale, it is the word that refuses every command -and invites somebody to start a second process against a live conversation. - -**Who says so matters as much as what is said.** The first fix wrote `Idle` -straight into the manager's view, and that produced a second bug on the -phone: the session list reads the manager's status and the session screen -replays the transcript, so a status written in one and not the other is two -screens disagreeing about one session — visible as a stop button that turned -into a play button a moment after the screen opened. So the rule is that -**a driver announces the state it starts in, through the event sink**, which -is what `EchoDriver::new` and `LlamaDriver::attached` already did; -`ClaudeDriver` was the one that started a process silently. It says `Idle` -only when it *started* one — adopting says nothing, because a process that -was already running may be mid-turn and the transcript's last word is the -better answer until its output says otherwise. Coming from the driver also -orders it against the exit `follow` reports, which a status written from the -manager could not be. + `Unknown` means nobody could find out, and starting on that is exactly the + two-CLIs-on-one-conversation fault `session::process` exists to prevent. **`Exited` is a claim about a process, and the record is what settles it.** -Adopting saying nothing left one word standing that a live process -contradicts. A session whose process was reported gone and then found again -at the next backend start kept `Exited` from the transcript — and `Exited` is -the word that draws a Start button. Start was then accepted every time it was -pressed, and since starting replaces the driver, each press attached *another* -reader to the one process: every line the CLI wrote was translated once per -reader, so three presses put three interleaved copies of one reply on screen. -Two rules come out of it, and neither is optional: +It is the one status that draws the phone's Start button and lets +`start_session` build a driver, so it is checked against `session::process` +before it is believed (`corrected`, called in `launch` and `start_session`). +A record not known to be dead makes it false and the session reports +`Unknown` instead. Every other status is left alone — those are the pump's, +written from what the process itself said. Without this, a session adopted at +a backend start kept the transcript's `Exited` while its CLI ran, Start was +accepted every press, and each press attached *another* reader to one +process: one reply drawn interleaved several times over +(`GotGotGot it — it — it —`). **A driver that `start_session` replaces gets +`Driver::detach`**, because swapping the `Arc` does not end the tasks the old +one is running. -- **`Exited` is checked against `session::process` before it is believed** — - `corrected`, called in `launch` and again in `start_session`. A record that - is not known to be dead makes it false, and what replaces it is `Unknown`: - there is a process, and nothing here has heard from it, which is the answer - `status_of_unlaunched` already gave to the same question. Every other status - is left exactly as it was — those are the pump's, written from what the - process itself said, and none of them authorises starting anything. The - correction goes out through the sink for the reason above: written into the - manager's view alone it would be the list and the screen disagreeing again. -- **A driver that is replaced is detached.** Swapping the `Arc` does not end - the tasks the old one is running. `Driver::detach` is what does — it already - existed for the backend going away — and it is the whole of what a driver - whose process has exited is owed. +**Who says so matters as much as what is said.** A status written into the +manager's view alone is two screens disagreeing — the list reads the +manager's status and the session screen replays the transcript, which showed +up as a stop button turning into a play button a moment after the screen +opened. So **a driver announces the state it starts in, through the event +sink.** It says `Idle` only when it *started* a process; adopting says +nothing, because a process already running may be mid-turn and the +transcript's last word is the better answer until its output says otherwise. -**A message or a command starts the process if there isn't one** (decided -2026-08-30). Refusing was work handed back: read the status word, find the -other button, press it, type the thing again. Both plainly mean "do this -now", and `--resume` puts the new process on the same conversation, so -nothing about what was typed changes — only whether there was anything there -to read it. A rename is included, and for a sharper reason than the rest: -Claude Code keeps its own copy of the name, that copy is what its session -picker shows and what other agents read when they list sessions, and a -session is only ever *given* a name at birth, since every later start is a -`--resume`. So a rename that reached no process would leave the two lists -disagreeing permanently, with this app's the only one that had moved — and -the cost of a resume buys the one thing renaming is for. It stays -`rename_session` rather than becoming a command like the others, because the -name is persisted and listed as well as forwarded and that is one operation; -the save happens first, so a failure to start reports that the telling -failed, not the rename. The manager's `send_message` and `run_command` and -the Start button ask one function -(`start_if_exited`) and want opposite answers from it: "there is already a -process" is a refusal worth showing to somebody who pressed Start, and -nothing at all to a message. Deciding it in one place under one write lock is -also what stops two requests arriving together from starting two CLIs. Only -`Exited` starts anything, for the reason above — `Unknown` has a process that -may well be reading its fifo, and what was typed goes to the driver as it -always did. +**A message or a command starts the process if there isn't one.** Refusing +was work handed back: read the status word, find the other button, press it, +type the thing again. `--resume` puts the new process on the same +conversation, so nothing about what was typed changes. A rename is included +for a sharper reason: Claude Code keeps its own copy of the name, that copy +is what its session picker and other agents' session lists show, and a +session is only ever *given* a name at birth since every later start is a +`--resume` — so a rename reaching no process would leave the two lists +disagreeing permanently. Its save happens before the telling, so a failure +there says the telling failed rather than the rename. -A command needs one thing a message does not. `Commands::submit` refuses on -`Exited`, and a driver that has just started a process announces `Idle` -through the sink rather than writing it — so a command judged against the -session's own status would be refused by the word the start had just -replaced. `start_if_exited` returning `Exited` is what says a process was -started, so `run_command` judges against `Idle` from there rather than +`start_if_exited` is one function under one write lock, which is what stops +two requests arriving together from starting two CLIs. Its callers want +opposite answers: "there is already a process" is a refusal worth showing to +somebody who pressed Start, and nothing at all to a message. Only `Exited` +starts anything — `Unknown` has a process that may well be reading its fifo. +`run_command` judges against what `start_if_exited` returned rather than re-reading a status the pump may not have caught up with. -The phone's half is that the process button is disabled while its own request -is in flight, so a second press cannot be decided against a status the first -one has not changed yet. That is a courtesy rather than the fix: the server -refuses the second request either way, because a phone that has lost the -stream cannot be relied on to know. - On the phone this is one button in the composer, left of Send, whose mark and -colour say what pressing it would do now: an orange pause while a turn is -running (interrupt — the process stays), a red stop when it is not (end the -process), and a green play when it has exited (start it again). One button -rather than three that come and go, so its presence is never the signal. +colour say what pressing it would do now: an orange pause while a turn runs +(interrupt — the process stays), a red stop when it is not (end the process), +a green play when it has exited. One button rather than three that come and +go, so its presence is never the signal. It is disabled while its own request +is in flight, as a courtesy; the server refuses the second request either way. -### A backend start adopts, and starts nothing (decided 2026-08-30) +### A backend start adopts, and starts nothing (2026-08-30) -Starting the server is not something a session should be able to tell -happened. `SessionManager::new` takes charge of the processes that are still -running and **leaves every other session exactly as it found it** — listed, -with its transcript, its event pump and the SSE stream a phone reads, and no -driver at all until somebody asks for one. +`SessionManager::new` takes charge of the processes still running and +**leaves every other session exactly as it found it** — listed, with its +transcript, its pump and its SSE stream, and no driver until somebody asks +for one. It used to launch a driver for every session in the config, and +`ClaudeDriver::launch` starts a process when there is none to adopt, so a +session somebody had deliberately stopped came back at the next rebuild, and +the `Idle` the new driver announced stamped it as active at the moment of the +restart. On the phone that read as *every* session idle and "just now", with +the list sorted by that time in an order that meant nothing. -What it did before was launch a driver for every session in the config, and -`ClaudeDriver::launch` starts a process when there is none to adopt. So a -session somebody had deliberately stopped came back at the next rebuild, -which is the decision Stop exists to make being undone by an unrelated -event — and since a driver announces `Idle` for a process it started, the -session was also stamped as active at the moment of the restart. On the -phone that read as *every* session idle and "just now" after every restart, -with the list — sorted by that time — in an order that meant nothing. - -- **`Launching` is the parameter that says which it is**, and the seed an - import carries rides on the asked-for variant, because a restart re-seeding - a transcript would write the imported conversation into it twice. +- **`Launching` is the parameter that says which it is**, and an import's + seed rides on the asked-for variant, because a restart re-seeding a + transcript would write the imported conversation into it twice. - **A session with no process has no driver.** `DriverCell` is an option rather than a driver whose requests go nowhere, so "nothing is running this" is a state the code can be asked about instead of one it discovers by - sending into a dead fifo. `LiveSession::ask` is the one place that answers - it, with an `Event::Error` naming what could not happen — a request nobody - can carry out is reported, never swallowed. -- **`--resume` on a crashed session is now a press rather than a restart.** - That is the whole of what is given up, and it is small: a session whose CLI - died reports `Exited` and draws the Start button, and *sending it anything - at all* starts it (above). What is bought is that the two are told apart by - who asked, rather than a restart guessing that everything it found should be - running. -- **What a launch settles the status to is written into the transcript, at - the time of the last thing the session actually did.** Adopting, the - transcript's word stands except for the `Exited` a live process disproves. - Taking charge of nothing, every word but `Exited` is disproved at once — a - backend killed mid-turn leaves a transcript saying `Running`, and that - draws a stop button for a turn that ended hours ago. The correction goes in - the transcript because the list reads the manager's status and the session - screen replays the file; it is stamped with the transcript's own last time - because it is not something the session did — this server noticed, at a - moment of its own choosing, and `now` there is the same lie in the same - field that `Transcript::last_activity` exists to prevent. + sending into a dead fifo. `LiveSession::ask` answers it with an + `Event::Error` naming what could not happen — a request nobody can carry + out is reported, never swallowed. +- **A launch never moves a session's clock.** A status a launch has to + correct is written at the time of the last thing the session actually did, + not at `now()`. Taking charge of nothing, every word but `Exited` is + disproved at once — a backend killed mid-turn leaves a transcript saying + `Running`, which draws a stop button for a turn that ended hours ago — but + stamping the correction with `now` is the same lie in the same field that + `Transcript::last_activity` exists to prevent. - **A session that has never done anything reports when it was created.** Its - transcript is empty — a driver announcing the state it starts in is not - news, so nothing is written — which makes it the one session with no line to - read a time off. The clock was the fallback, so a session nobody had sent - anything to climbed to the top of the list at every restart. Not the - transcript file's mtime, which is the same instant for an empty file and a - worse answer for a shared checkout that can be copied or touched; + transcript is empty, since a driver announcing the state it starts in is + not news, so it is the one session with no line to read a time off. Not the + file's mtime, which is a worse answer for a checkout that can be copied; `SessionConfig::created` is recorded rather than inferred. -### Sessions spawned while testing clean themselves up (decided 2026-08-30) +### Sessions spawned while testing clean themselves up (2026-08-30) `--throwaway-sessions`, **on by default in a debug build**. Every session -spawned by such a server is marked `throwaway` in the config, and a marked -session's process is stopped when the server exits or is signalled, instead -of being left for the next start to adopt. +such a server spawns is marked `throwaway` in the config, and a marked +session's process is stopped when the server exits or is signalled. -Leaving processes running is the design and it is right for the sessions -somebody is using. It is exactly wrong for the ones a test made: those leave -a `claude` behind that every later server adopts, they cost tokens if -anything ever speaks to them, and nothing ever says they are there — twelve -accumulated on this machine in a day. An agent testing this app should not -have to remember a cleanup step, and "remember to" is not a mechanism. +Leaving processes running is right for the sessions somebody is using and +exactly wrong for the ones a test made: those leave a `claude` behind that +every later server adopts, they cost tokens if anything speaks to them, and +nothing says they are there — twelve accumulated on this machine in a day. - **The flag marks; the mark decides.** What a server was told at startup governs only the sessions it spawns, and the mark is written into the session, so it outlives that server. A session spawned deliberately keeps - running whichever server happens to be up when one exits, and a throwaway - one is cleaned away even by a server started without the flag. The - alternative — the exiting server stopping whatever it happens to have - marked in memory — makes cleanup depend on which process is up, which is - the thing that fails at exactly the wrong moment. -- **Stopping is not asking.** `process::stop` sends SIGTERM and leaves its - SIGKILL on a tokio timer, and a runtime that is shutting down never runs - it. That is precisely how the original `shutdown_all` leaked the processes - it reported stopping, so the exit path waits for them with + running whichever server is up when one exits, and a throwaway one is + cleaned away even by a server started without the flag. The alternative — + the exiting server stopping whatever it has marked in memory — makes + cleanup depend on which process is up. +- **Stopping is not asking.** `process::stop` leaves its SIGKILL on a tokio + timer, which a shutting-down runtime never runs; that is precisely how the + original `shutdown_all` leaked. The exit path waits with `process::wait_gone` — one deadline for all of them, since they were - signalled together — and kills whatever is left. `Driver::stop` is the - per-driver half, the same one a delete uses; only the waiting differs. -- **A zombie is dead.** Found by the test for the above: `/proc//stat` - keeps the entry, with the same pid and the same start time, until the exit - status is collected — so a process that had plainly finished answered - "still there" for as long as nothing reaped it, and `Liveness::Alive` is - the word that makes `Exited` unsayable. The state field is read alongside - the start time now. This was reachable outside the test: anything that - blocks the runtime delays tokio's own reaping. + signalled together — and kills whatever is left. -### Importing refuses a session that is already open (decided 2026-08-29) +### Importing refuses a session that is already open (2026-08-29) Claude Code keeps a descriptor per live session at -`~/.claude/sessions/.json` carrying the `sessionId` and a `procStart` -— the same pid-plus-start-time identity used above. So "is this session -open right now" is a **measurement**, not a heuristic, and the import list -reports it as `no` / `yes` / `unknown`. Three answers because a machine that -keeps no such record cannot answer, and "could not check" is not "nobody is -using it". +`~/.claude/sessions/.json` carrying the `sessionId` and a `procStart` — +the same pid-plus-start-time identity used above. So "is this session open +right now" is a **measurement**, and the import list reports it as `no` / +`yes` / `unknown`. Three answers because a machine that keeps no such record +cannot answer, and "could not check" is not "nobody is using it". -`yes` is refused. This is not hypothetical: on 2026-08-29 an agent imported -the session it was itself running in. Two `claude --resume` processes then -edited one checkout and appended to one transcript, the whole 65 MB -conversation — 154 embedded screenshots — was duplicated into the file under -a new prompt id, and the adopted copy re-read all of it. It ended at the -account's session limit. +`yes` is refused. On 2026-08-29 an agent imported the session it was itself +running in: two `claude --resume` processes on one file, the whole 65 MB +conversation with 154 embedded screenshots duplicated into it under a new +prompt id, and the adopted copy billed for re-reading all of it. It ended at +the account's session limit. -### pi driver specifics +**Importing and deleting run on the server, and a batch is handed over in one +call.** `POST /setups/{id}/importable/{delete,import}` each take a list of +ids, answer 202, and do the work in spawned tasks — the phone that asked is +free to leave, and used to cancel its own batch by doing so. A list rather +than a route per session because one request per row made a handover only as +atomic as the network, and a row nobody asked for looks exactly like a row +nobody picked. Only the *registering* is atomic; the work settles per row, +since six deletes that all roll back together is not something a filesystem +offers. -- Spawn: `pi --mode rpc --provider openai-generic --model ` (endpoint = - the llama-server the LlamaServerManager provides), `--session-dir` under - our session storage so transcripts and pi's own session files live together. -- Auto-compaction on by default (`set_auto_compaction`), threshold - configurable per session; manual `compact` exposed as a button. -- `steer` for mid-run messages, `abort` for stop, `set_model` when the target - endpoint changes. -- pi's session JSONL gives resume-after-restart, same as Claude's. +What replaces the reply is `session::pending`: every row carries `pending` +and `error`, and `/importable/events` streams the changes. **Both, not +either** — the stream is a broadcast with no memory, so an operation that +starts and finishes while it is still connecting is one nothing will ever be +said about, which left a row marked "waiting" for ever. A single tap still +waits, because "continue this and take me to it" needs the session it made +and 202 does not carry one; the batch and the tap share `spawn` so the two +cannot drift about what importing means. -### Models (built 2026-08-28) - -The owner asked for listing and downloading models from HuggingFace and running -them with different parameters, which makes model management part of the -feature rather than something done by hand beforehand. - -- **A download belongs to the model, not to the request.** Keyed by - `owner/repo/file.gguf` and owned by the server, so a second device can - watch one it did not start, and so an hour-long fetch survives a phone - locking its screen. Every run has an id and its outcome outlives it, - because "not downloading" otherwise means finished, never started, or - someone else's run ended while you were away. -- **Progress is measured.** `total` is Content-Length, or Content-Range's - last field on a resumed request, and absent when the server says - nothing — never an estimate. -- **Resume is guarded by identity, not by hope.** A partial carries the - ETag it was written against; a mismatch discards it. `If-Range` would be - the tidy mechanism but HuggingFace's CDN ignores it (probed - 2026-08-28). The published sha256 is checked before the file is renamed. -- Parameters reach a driver as an untyped `params` map on the session, so - the shared schema does not grow llama.cpp's vocabulary. - -### llama-server management - -`config.ron` lists **models** (name → GGUF path or llama-server args, per -host) and **hosts**. The manager runs at most one llama-server per -`(host, model)`, spawned on demand when a session needs it: - -- Spawn (local or `ssh host llama-server …`) on an allocated port, wait on - `/health`, hand the endpoint to the pi driver. -- Refcounted by sessions. The path out, written in the same change as the - spawn: the last session using an instance releasing it starts an idle - timer (configurable, e.g. 10 min), after which it's killed. Delete of the - last session kills it immediately. -- "Change model" on a llama session = acquire the new model's server, - `set_model` on pi, release the old one. Context carries over (it's - prompt-replayed by pi against the new endpoint). -- Remote llama-server output is only reachable from the backend host, and - binds localhost on the remote side with an SSH local port forward - (`ssh -L`) held by the manager — no LAN-exposed inference ports. - -### SSH - -- Host entries in `config.ron`: name, `user@host`, optional ssh options, - which capabilities it has (claude / pi / llama-server, with paths if not on - PATH). Key-based auth only, using the system `ssh` client via - `tokio::process` — no Rust SSH library; this inherits `~/.ssh/config`, - agents, and jump hosts for free. (Rule 23: openssh is already here and - battle-tested; a library buys nothing but a second config surface.) -- A remote session is exactly a local one with the command wrapped in - `ssh -T host …`. Process death ≙ connection death; the session shows as - `exited` and both dialects resume (`--resume` / pi session file) on respawn, - so a dropped SSH connection is an annoyance, not data loss. -- **The transport wraps the driver, not the other way round** (decided - 2026-08-28). A driver says what to run — program, arguments, working - directory — and something above it turns that into a process, locally or - through ssh. Today `ClaudeDriver::spawn` calls `ssh::command` itself, - which puts transport knowledge inside a translator whose job is a wire - format, and means every future driver has to remember to do the same. - Inverting it also removes the "Run on" lie for free: a driver that emits - no command, like the echo one, has nothing for a transport to wrap, and - the picker can say so. -- The interface that inversion needs is **not just "run a command"**, and - llama.cpp is the case that shows it: a managed `llama-server` is started - as a process but then spoken to over HTTP, so a remote one needs a - forwarded port (`ssh -L`) as well as a spawned process. A transport is - therefore "run this" plus "reach this port", and the second operation is - a no-op locally. -- Images need no file transfer, contrary to what this section said - before: `attachment_block` base64s an uploaded image into the - stream-json message itself, and produced images come back the same way - for the translator to write out locally. Nothing has to exist on the - remote filesystem, so there is no `scp` step to get wrong. -- **Any other file is told to the session by path** (2026-09-03: a trace, - a log, a zip -- things a model cannot be shown and the CLI can read). - The upload is streamed to disk under the session's `attachments/` on - this machine -- the transcript references it there and the phone can - fetch it -- and the message ends with `Attached file: /abs/path`. For a - session on another machine the upload also copies the file there, in - the same request, over one `ssh` invocation (`cat` from stdin, then - `pwd -P` so the answer is the absolute path the CLI is told). It lands - in the setup's `attachmentsDir` if set, else the session's cwd, else - the login home; the resolved remote path is recorded beside the file - (`.remote`) and is what the driver names. A copy that fails fails - the upload, so no message ever names a file that is not there. Images - are unaffected: they ride the message as base64. +An imported session **keeps itself level with the CLI's file**, so work done +at a terminal appears without anyone pressing anything. Which lines came from +*here* is answered by counting the events this session has recorded, **not** +by looking at its status — 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`. ### Usage limits (Claude) -Poll `https://api.anthropic.com/api/oauth/usage` — the same endpoint behind -Claude Code's `/usage` — with the OAuth access token from Claude Code's local -credential store (`~/.claude/.credentials.json`), headers -`anthropic-beta: oauth-2025-04-20` and `User-Agent: claude-code/` -(without the User-Agent it lands in an aggressively rate-limited bucket). -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 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). +Poll `https://api.anthropic.com/api/oauth/usage` — the endpoint behind Claude +Code's `/usage` — with the OAuth token from `~/.claude/.credentials.json`, +headers `anthropic-beta: oauth-2025-04-20` and `User-Agent: +claude-code/` (without the User-Agent it lands in an aggressively +rate-limited bucket). Poll at ≥180 s, only while a Claude session exists or +the usage screen is open, and cache the last answer. It is undocumented, so +`usage.rs` treats every field as optional and degrades rather than erroring. -**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. +**Per machine, not per backend (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 — and in the layout this aims at, `ai-server` is on the +host, the host has no `claude`, and the CLI machine is a remote. So +credentials are read through the session `Transport` (`$HOME` expanded by the +far shell, because a path built locally is the wrong home), one snapshot per +setup that offers Claude. The HTTP call stays on the backend, 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. +and a message: `ok`, `notLoggedIn`, `unreachable`, `failed`. `notLoggedIn` is +the one that matters — 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 at all. -### HTTP surface (phone ⇄ backend) +**The five-hour window has no reset time between blocks, and that is not a +missing value.** Measured 2026-08-31: the API anchors the window to the block +it started in, and when no block is running there is nothing to reset, so +`resets_at` is `null`. The weekly windows always have one because a week is +always running. So absent means **not running**, and only a timestamp that +arrives and cannot be parsed is unknown. `WindowEnd` in `ResetCountdown.kt` +is the one rule both readers go through. -REST for actions, one SSE stream per open session screen for events, all over -the pinned TLS listener. SSE over WebSocket because resume-by-cursor -(`Last-Event-ID` = transcript seq) is native to it and the inbound direction -is plain POSTs anyway. +### HTTP surface -``` -GET /providers what can be spawned (name, kind, models) -GET /hosts machines a session can be run on -GET /sessions list (id, provider, host, title, model, status, last activity) -POST /sessions spawn {provider, host, model, cwd, permission_mode, title} -GET /sessions/:id/events?after=N SSE: transcript replay from N, then live -POST /sessions/:id/message {text, attachment_ids} -POST /sessions/:id/unqueue {message_id} take back one not read yet -POST /sessions/:id/answer {question_id, answer} (questions and permissions) -POST /sessions/:id/interrupt stop the running turn; the process stays -POST /sessions/:id/stop end the process; the session and transcript stay -POST /sessions/:id/start run the process again, continuing the conversation -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 -GET /usage cached usage windows -GET /setups/:id/dir?path=P entries of directory P, and P resolved -GET /setups/:id/file?path=P content of file P, or why not -PUT /setups/:id/file {path, content, ifSha256}; 409 if it moved on -POST /setups/:id/file {path} create empty; refused if it exists -POST /setups/:id/dir {path} create; refused if it exists -GET/PUT /hosts, /models config editing from the phone -``` +**`routes.rs`'s module doc comment is the table.** REST for actions, one SSE +stream per open session screen for events, all over the pinned TLS listener. +SSE rather than WebSocket because resume-by-cursor (`Last-Event-ID` = +transcript seq) is native to it and the inbound direction is plain POSTs. -Sessions live in `config.ron` (`$XDG_CONFIG_HOME/ai-app/`) + a per-session -directory under `$XDG_DATA_HOME/ai-app/sessions/` (transcript.jsonl, -attachments, produced images), owner-only. Deleting a session is the +Sessions live in `config.ron` (`$XDG_CONFIG_HOME/ai-app/`) plus a per-session +directory under `$XDG_DATA_HOME/ai-app/sessions/` (transcript, attachments, +produced images, process record), owner-only. Deleting a session is the complete path out of everything spawning one created. -### The file explorer (decided 2026-09-03) +**Every request body refuses fields it does not know** +(`serde(deny_unknown_fields)`). A caller that misspells `permissionMode` got +a 200 and a session in the default mode, which is indistinguishable from +success at the place they are looking. Query strings are deliberately +permissive. -**`EXPLORER.md` holds this design**, decision by decision with what was -rejected, the same way this file does — it is long enough to be its own -document and it is where a change to it belongs. The one-line version: a -machine's filesystem, seen from the phone through the backend, keyed on -the **setup** rather than on a session (a session only says where to -start), with every operation one fixed shell script run through -`Transport` so the local and the ssh case are one implementation. The -security consequence is in the token paragraph below. +**A phone that falls behind is answered with `reset`.** Past +`CATCH_UP_LIMIT` the stream sends a `reset` frame and the newest window, and +the client rebuilds from it exactly as it does when the screen opens. Not +optional: without it the window is spliced onto rows no longer adjacent to +it, which reads as ordinary output. The stream used to replay everything +after the client's cursor, unbounded, while *opening* a session was bounded +to a page — so a long disconnect delivered thousands of events one frame at a +time. + +### The file explorer (2026-09-03) + +**`EXPLORER.md` holds this design.** The one-line version: a machine's +filesystem, seen from the phone through the backend, keyed on the **setup** +rather than on a session (a session only says where to start), with every +operation one fixed shell script run through `Transport` so the local and the +ssh case are one implementation. ### Security -- TLS with a self-signed CA, pinned in the app — same - idempotent-CA/reissued-leaf scheme as dev-updater, same one-way-door - caveat about regenerating the CA, but generated **in process on first - start** (`certs.rs`) rather than by a shell script calling openssl - (2026-08-25). One place then decides the extensions, the file modes, and - which addresses the leaf covers — every local IPv4 plus loopback and the - emulator's host alias, so nobody maintains a hardcoded IP — and there is - no setup step to forget. +- **TLS with a self-signed CA, pinned in the app.** Generated in process on + first start into `$XDG_CONFIG_HOME/ai-app/certs`, so one place decides the + extensions, the file modes and which addresses the leaf covers — every + local IPv4 plus loopback and the emulator's host alias, so nobody maintains + a hardcoded IP. The CA is created once and left alone; the leaf is reissued + every start, so covering a new address is a restart. **Regenerating the CA + strands the installed app** — the one-way door. - Unlike dev-updater, the pinned CA is **not a constant in the source**: the build reads `$XDG_CONFIG_HOME/ai-app/certs/ca.pem` from the machine doing the build and generates the constant (`generatePinnedCert` in - `app/androidApp/build.gradle.kts`; `AI_APP_CA` overrides). Decided - 2026-08-25, and it does three things at once — the trust anchor follows - the build machine, so an APK built on the backend host pins that host - and one built in the dev VM pins the VM's throwaway CA and is only good - for its emulator; there is no second anchor to add for development and - forget to remove; and regenerating a CA needs a rebuild rather than a - paste, so a stale constant can't quietly disagree with the server. -- **The dev VM is untrusted** (decided 2026-08-25): a machine that isn't - malicious but could become so. It matters because the repo is a - read-write virtiofs mount shared between the VM and the backend host, so - under this model everything in it — source, `server/target/` binaries, - and the shell scripts the host runs, some with sudo — is - attacker-writable. Two consequences: - - **Nothing secret lives in the repo.** Certificates are generated on - the machine that serves them and written to - `$XDG_CONFIG_HOME/ai-app/certs` (0700, keys 0600); `config.ron` and - session transcripts go to the XDG config and data directories, per - machine. A CA private key the VM could read would let it mint a leaf - the pinned app accepts, which is precisely the attack pinning exists - to stop — pinning against a CA the attacker holds is no pinning at - all. Transcripts move for a plainer reason: they are whole - conversations. As a bonus this ends the host and VM sharing one - config, which had already produced a test token live on the backend, - and takes state out of reach of `git clean -xdf`. - - **The host should not execute what the VM can write** — build and run - the backend from a host-only checkout rather than the shared mount. - Moving the keys closes the smaller door; this is the larger one. - - Development in the VM generates its own throwaway CA. Whatever is - installed on the real phone must pin only the host's. - - The CA key is not needed by the server at all (only `leaf.pem` and - `leaf-key.pem` are read), so it can move offline once the setup is - stable; reissuing a leaf is the only time it is wanted. - - Not addressed, and accepted: a compromised VM can return anything it - likes from the sessions it runs, since running an agent there is the - point. The blast radius is that session's content, not the backend. -- This server is strictly more dangerous than the updater: its API *is* - remote code execution (spawn a bypass-permissions Claude on any SSH host). - Pinning authenticates the server to the phone but not the phone to the - server, so a bearer token adds the other direction. Threat model: the token - gates LAN-reachable RCE; it does not (and cannot) defend a compromised - backend host or phone — those are inside the trust boundary, and a - compromised phone is handled by rotation. + `app/androidApp/build.gradle.kts`; `AI_APP_CA` overrides). That does + three things at once — the trust anchor follows the build machine, so an + APK built in the dev VM is only good for its emulator; there is no second + anchor to add for development and forget to remove; and regenerating a CA + needs a rebuild rather than a paste, so a stale constant cannot quietly + disagree with the server. +- **The dev VM is untrusted** (2026-08-25): not malicious, but it could + become so. The repo is a read-write mount shared between the VM and the + backend host, so everything in it — source, binaries, and the shell scripts + the host runs — is attacker-writable. + - **Nothing secret lives in the repo.** A CA private key the VM could read + would let it mint a leaf the pinned app accepts, which is precisely the + attack pinning exists to stop. Transcripts move for a plainer reason: + they are whole conversations. + - **The host should not execute what the VM can write** — build and run the + backend from a host-only checkout rather than the shared mount. Moving + the keys closes the smaller door; this is the larger one. + - Accepted: a compromised VM can return anything it likes from the sessions + it runs, since running an agent there is the point. The blast radius is + that session's content, not the backend. +- **This server's API *is* remote code execution** (spawn a + bypass-permissions Claude on any ssh host). Pinning authenticates the + server to the phone but not the phone to the server, so a bearer token adds + the other direction. The token gates LAN-reachable RCE; it cannot defend a + compromised backend host or phone — those are inside the trust boundary, + and a compromised phone is handled by rotation. + - **No route accepts a command.** Listing, reading and writing files are + fixed scripts in `files.rs`; the phone chooses only the path and the + bytes. Provider discovery asks the machine rather than taking a command. - **The explorer's routes take a path, and that is deliberate** - (2026-09-03; see EXPLORER.md's decision 3). Elsewhere the rule is that - the phone picks an **id** and the server resolves which file it names — - the import listing is written that way so an enrolled token cannot - become "read me an arbitrary file". `/setups/{id}/dir` and - `/setups/{id}/file` take the path, because the path is the whole - feature. It grants nothing new: the same token already spawns a - bypass-permissions agent in any directory on any machine a setup names, - and that agent already reads and writes every file its user can, so - this is a shorter path to authority the token holds either way. The - import rule stands where it is, because there a path was unnecessary - and refusing one cost nothing. What is unchanged is the harder line: - **no route accepts a command.** Listing, reading and writing are fixed - scripts in `files.rs`; the phone chooses only the path and the bytes. - - **Generation**: 256 bits from the OS CSPRNG on first run, base64url. A - machine credential, never typed twice, so unguessable costs nothing; at - this entropy no key stretching is needed. - - **Enrollment**: printed once as a terminal QR code (`qrcode` crate, - ANSI), encoding `aiapp://enroll?host=…&port=…&token=…`. The CA stays - embedded in the APK (`PinnedCert.kt` pattern), so the QR carries no - trust material — photographing the terminal leaks only the token - (rotatable), never a way to weaken pinning. The app registers an intent - filter for the `aiapp://enroll` scheme as a fallback, for a camera app - that redirects a scanned URI straight to `MainActivity` (2026-08-24). - That was meant to be the only path — "the app side needs no QR library - at all" — but reversed the same day: not every phone's stock camera - redirects a scanned URI to an app reliably, so the Settings screen also - scans in-app via `zxing-android-embedded`'s `ScanContract` (a ready-made - scanner Activity reached through the AndroidX Activity Result API, - fully offline, no Play Services/ML Kit model download) and feeds the - decoded URI to the same `parseEnrollmentUri` (2026-08-25). - - **Storage**: server keeps only the SHA-256 in `config.ron` (plain hash - is enough for high-entropy random input; buys that a leaked config - doesn't leak the credential). No "show token again" — lost means rotate. - Phone side: sealed with an Android Keystore AES-GCM key (a small - hand-rolled helper in `ServerConfig.kt` — Jetpack's - EncryptedSharedPreferences is deprecated with no drop-in successor, and - Google's guidance is now "use Keystore directly"; 2026-08-24). - - **Transport**: `Authorization: Bearer` header on every request including - the SSE GET. Never a query parameter (URLs leak into logs). The tracing - layer must not log the header — covered by a test so a logging change - can't silently start leaking it. - - **Verification**: one middleware wrapping the entire router in `main.rs`, - never per-route, so a new route can't forget auth. Zero unauthenticated + (EXPLORER.md's decision 3). Elsewhere the phone picks an **id** and the + server resolves which file it names, so an enrolled token cannot become + "read me an arbitrary file" — the import listing is written that way. The + explorer is different because the path is the whole feature, and it + grants nothing new: the same token already spawns a bypass-permissions + agent in any directory on any machine a setup names. The import rule + stands where it is, because there a path was unnecessary. + - **Generation**: 256 bits from the OS CSPRNG, base64url. A machine + credential, never typed twice, so at this entropy no stretching is needed. + - **Enrollment**: printed once as a terminal QR code encoding + `aiapp://enroll?host=…&port=…&token=…`. The CA is embedded in the APK, so + the QR carries no trust material — photographing the terminal leaks only + the token, never a way to weaken pinning. The app registers an intent + filter for the scheme, and the Settings screen also scans in-app via + `zxing-android-embedded`, because not every phone's stock camera + redirects a scanned URI to an app reliably. + - **Storage**: the server keeps only the SHA-256 in `config.ron`; a plain + hash is enough for high-entropy random input. No "show token again" — + lost means rotate. The phone seals it with an Android Keystore AES-GCM + key (`ServerConfig.kt`; Jetpack's EncryptedSharedPreferences is deprecated + with no drop-in successor and Google's guidance is now "use Keystore + directly"). + - **Transport**: `Authorization: Bearer` on every request including the SSE + GET, never a query parameter, since URLs leak into logs. The tracing layer + must not log the header — covered by a test, so a logging change cannot + silently start leaking it. + - **Verification**: one middleware wrapping the entire router, never + per-route, so a new route cannot forget auth. Zero unauthenticated endpoints, `/health` included. Hash-then-constant-time-compare (`subtle`); failures logged with peer address plus a small fixed delay — - not against brute force (infeasible at 256 bits) but so scanners show up - in the log. + not against brute force, but so scanners show up in the log. - **Rotation (the path out)**: `--rotate-token` regenerates, invalidates - the old hash immediately, reprints the QR. That's the whole lost-phone - story. Config stores a *list* of `{name, hash}` (of one, today) so - per-device tokens with individual revocation are a config entry later, - not a schema migration. - - **Why not mTLS**: stronger in theory (key never leaves the Keystore, no - bearer secret to exfiltrate), but given pinning the delta is only - "someone reads the token off a device already inside the trust - boundary", and it costs Android client-cert provisioning ceremony and a - worse new-phone story than a QR scan. Revisit if this outgrows - single-user-on-LAN. -- **Off-network access: plain WireGuard** (decided 2026-08-24; no third - party). The backend binds to the WireGuard interface (`wg0`) only; the - phone runs the official WireGuard app (always-on VPN, per-app tunneling), - enrolled by scanning its config as a terminal QR - (`qrencode -t ansiutf8 < phone.conf` — same gesture as token enrollment). - The only internet-visible thing is one forwarded UDP port that is silent - to unauthenticated packets — scanners see it as closed — so the app's - pre-auth surface (rustls handshake, hyper parsing, auth middleware) is - reachable only from enrolled peers, and the token becomes defense in depth - rather than the sole gate. Addressing stays single-path: the phone reaches - the backend at its WireGuard address (e.g. `10.66.0.1`) from everywhere — - one address in the app, one SAN in the leaf cert (`SERVER_IP=`/SAN - override in the cert script), no home/away distinction. Another machine - later is one keypair + one `[Peer]` block. - - Operational needs, accepted: a public endpoint hostname. The home IP is - mostly static but not guaranteed, so the phone's endpoint is a DDNS name - (free, e.g. DuckDNS, or the router's built-in client; a curl cron on the - backend host works too) that tracks changes automatically. One WireGuard - nuance: the phone app resolves the endpoint hostname when the tunnel - comes up and does not re-resolve on its own, so on the rare IP change - the fix is toggling the tunnel off/on once DDNS has caught up (minutes). - The symptom is obvious (app can't reach the backend) and lossless — the - SSE cursor design means reconnects replay whatever was missed. Also: - at-home traffic rides NAT hairpinning on the router (verify early; most - support it, and the fallback is toggling the tunnel off at home). - - Rejected: **Tailscale** — same WireGuard underneath with easier setup - (no port forward, LAN peer discovery), but it adds a third-party - coordination service and account this setup doesn't need at two or - three devices; **Headscale** — self-hosting that coordination server is - strictly more moving parts than one wg config per peer at this scale; - **forwarding the HTTPS port directly** — puts every internet scanner - one pre-auth bug away from RCE on a machine holding SSH keys. - - The server still refuses to start without TLS — no plaintext listener - exists even inside the tunnel, so the token can't travel unencrypted by - misconfiguration, and interface binding failing closed (refuse to start - if `wg0` is absent, rather than falling back to 0.0.0.0) is part of the - same guarantee. Development gets `--bind ` as an *explicit, logged* - override (loopback for curl, a LAN address for a pre-WireGuard phone) — - a deliberate flag, never a fallback, so the fail-closed default is - untouched (2026-08-24). -- The bootstrap-over-HTTP trick from the updater is unnecessary here — the - app installs via Dev Updater. + the old hash, reprints the QR. Config stores a *list* of `{name, hash}`, + so per-device revocation is a config entry later, not a migration. + - **Why not mTLS**: stronger in theory, but given pinning the delta is only + "someone reads the token off a device already inside the trust boundary", + and it costs Android client-cert provisioning and a worse new-phone + story. Revisit if this outgrows single-user-on-LAN. +- **Off-network access: plain WireGuard** (2026-08-24, no third party). The + backend binds `wg0` only; the phone runs the official WireGuard app, + enrolled by scanning its config as a terminal QR. The only internet-visible + thing is one forwarded UDP port silent to unauthenticated packets, so the + pre-auth surface is reachable only from enrolled peers and the token becomes + defence in depth rather than the sole gate. Addressing stays single-path: + the phone reaches the backend at its WireGuard address from everywhere. + - Accepted operationally: the endpoint is a DDNS name, since the home IP is + not guaranteed static. The WireGuard app resolves it when the tunnel comes + up and does not re-resolve, so a rare IP change is fixed by toggling the + tunnel once DDNS catches up. The symptom is obvious and lossless — the SSE + cursor design replays whatever was missed. + - Rejected: **Tailscale** and **Headscale**, which add a coordination + service this setup does not need at two or three devices; **forwarding + the HTTPS port directly**, which puts every internet scanner one pre-auth + bug away from RCE on a machine holding SSH keys. + - The server refuses to start without TLS, so the token cannot travel + unencrypted by misconfiguration, and binding fails closed — refusing to + start if `wg0` is absent rather than falling back to 0.0.0.0. + `--bind ` is an *explicit, logged* override for development, a + deliberate flag and never a fallback. ## App (`app/`) Kotlin + Compose Multiplatform, single `:androidApp` module, same versions as -dev-updater (Kotlin 2.4.x, CMP 1.11.x, JDK 21). Screens: +dev-updater (Kotlin 2.4.x, CMP 1.11.x, JDK 21). -1. **Session list** — cards: kind icon, title, host, model, status - (running / awaiting answer / idle / exited), last activity. Spawn FAB; - swipe/long-press to delete (confirm). Sessions awaiting an answer sort to - the top — that's the "your turn" inbox. -2. **Spawn** — kind, host (from config), model (Claude list is static+editable; - llama list from config), working directory, permission mode (Claude), - title. -3. **Session screen** — the core: - - Transcript rendered from the event stream: markdown text, inline images, - collapsed-by-default tool cards (name + input summary, expandable to - output; a spinner while `ToolStart` has no matching `ToolEnd`). - - Question cards inline: option buttons for AskUserQuestion, allow/deny for - permissions, free-text where allowed. - - Expanding a row keeps still **the end nearest the tap**: touch a row's +1. **Session list** — kind icon, title, setup, model, status, last activity. + Sessions awaiting an answer sort to the top: the "your turn" inbox. +2. **Import** — Claude Code sessions the machine already has, selected in + batches (hold to enter, tap to add), with Delete and Import along the + bottom. Submitting clears the selection immediately and marks every chosen + row, so the bar goes away and the affected set is what says the work is + happening. Rows are taken out as each one lands rather than all at the + end: a finished row still sitting there looks exactly like one that has + not been imported, and tapping it starts a second CLI on the same + transcript. That makes rows below slide up under the reader's finger, so a + row that has just moved ignores taps for `SETTLE_MS`. +3. **Models** and **Setups** — browsing and downloading GGUFs; adding, + renaming, re-probing and removing machines. +4. **Session screen** — the core: + - The transcript rendered from the event stream: markdown, inline images, + tool cards, question cards. + - **Anything that is a note *about* the conversation rather than a turn in + it is closed by default** — a tool call, a peer message, a memory note. + Open-ness is the screen's, never the card's: a card that remembered for + itself forgets the moment the lazy list stops composing it, so a note + opened and scrolled past would shut behind the reader. + - **An answered question keeps its options and marks the one taken**, in + the same purple that says "picked" while it is open — it does not + collapse into a line repeating the answer. The options are what the + question *was*, and "Deny" alone does not say Allow was the alternative. + One rule in two places (`AskedQuestion` and `PermissionAsk`). An answer + typed into **Other** matches no option, so that one is still written out. + - **Expanding a row keeps still the end nearest the tap**: touch a row's upper half and its top edge holds, so it opens downwards; touch its - lower half and the bottom edge holds, as the list does by default. - Which half, rather than which control, so that everything that opens - behaves alike whether or not it has a control at each end — a group's - heading and foot bar simply fall in the halves they already occupy. The transcript is laid out from the - bottom, so a row's bottom edge is anchored for free and the top one - has to be arranged. The correction lives in the *layout* phase - (`Modifier.holdTopEdge`): the measurement that discovers the row's new - height asks the list to shift by that much, via - `requestScrollToItem`, before anything is drawn. Doing it from an - effect instead means the wrong position is drawn once first, which - reads as a flick and gets worse the faster the screen refreshes - (2026-08-30, asked for after groups opened upwards and sent their own - heading off the top of the screen). - - Input bar: text, attach (camera/gallery/file), send — **always enabled**; - mid-run sends become steering messages. - - Top bar: model chip (tap to change), stop button while running, token - count, compact button (llama), overflow → delete. (The count settled as - context held rather than tokens spent, and sits on the status row under - the transcript — see `UsageDelta` above.) -4. **Usage** — window bars for the 5-hour and weekly limits with reset times. -5. **Settings** — server address + token, hosts editor, llama model list - editor. + lower half and the bottom edge holds, as the list does by default. Which + half, rather than which control, so everything that opens behaves alike + whether or not it has a control at each end. The transcript is laid out + from the bottom, so a bottom edge is anchored for free and the top one + has to be arranged: `Modifier.holdTopEdge` asks the list to shift during + the *layout* phase, before anything is drawn. From an effect instead, + the wrong position is drawn once first, which reads as a flick. + - **The full-screen image lives on the screen, not in the row that drew + the thumbnail** (`SessionImageViewer`). A `Read` whose result is an image + is a row of one call until the next call arrives and makes it a group — a + different composable in a different part of the tree, so the old subtree + and its open dialog go. Somebody looking at a screenshot was thrown back + to the transcript because the session made another tool call. + - **All transcript text is selectable, from one `SelectionContainer` + around the whole list.** Not per row: a transcript is one body of text, + so a selection has to run from a reply into the tool output under it — + and a container per row leaves whatever was drawn without one silently + unselectable. An inline code chip is drawn *behind* the text rather than + as the renderer's span background, because a span background is part of + the text's own drawing and hid the selection under it. + - Input bar: text, attach, send — **always enabled**; mid-run sends become + steering messages. A queued message can be tapped to take it back. + - The composer's process button (interrupt / stop / start) as above. -Networking mirrors dev-updater's app layer (`AppsApi.kt` style thin client + -pinned transport), plus an SSE client with `after=` resume driven by -connectivity/lifecycle. The backend's transcript is the source of truth, and -the app keeps a **copy of what it has already been sent** — see -`TRANSCRIPT_CACHE.md`, added 2026-09-04, because reopening a session over the -tunnel was re-downloading a conversation the phone had just read. The copy is -the server's own event lines, per session, under `cacheDir`; it is checked -against the server before a stream is resumed from it, thrown away rather than -patched when that check fails, and never load-bearing — every path that reads -it has a network path beside it giving the same answer. What the app still -does not keep is anything *derived*: the folded rows are rebuilt from events -every time. +### Markdown -### Notifications: two places, never both (decided 2026-08-30) +**A reply is drawn as pieces of one parse, never as re-parsed substrings.** +A `Piece` addresses a top-level block of the message's tree, or one item of a +top-level list, and every piece is drawn from the same cached parse. That is +what bounds a lazy-list item without parsing a message more than once, and it +is why a forty-item list of sources is forty units rather than one. Links are +spans with one tap detector per text, not a layout node per link — the cost +that made a list of sources bumpy. -The backend's `GET /notifications` is one SSE stream of attention-wanting -moments, and the app decides where each one is said. Three outcomes, in one -place (`NotificationService.show`): +**A table wraps its cells and never cuts one off.** The renderer's defaults +draw every cell at one line with an ellipsis, which on a phone loses most of +a table — and an elided cell looks exactly like a short one. `LinkedTableRow` +gives a cell as many lines as it needs, aligned to the top of the row so a +two-line cell does not re-centre its neighbours. A column narrows to 136dp +and no further, past which the whole table scrolls sideways; 136 because it +is the widest floor that still fits three columns across a phone. Exercise it +with the echo driver's `/table N`, which writes long cells on purpose — a +fixture of tidy one-word values renders fine either way. + +### The transcript cache + +The backend's transcript is the source of truth, and the app keeps a copy of +what it has already been sent — see **`TRANSCRIPT_CACHE.md`** (2026-09-04), +because reopening a session over the tunnel was re-downloading a conversation +the phone had just read. It is the server's own event lines, per session, +under `cacheDir`; it is checked against the server before a stream is resumed +from it, thrown away rather than patched when that check fails, and **never +load-bearing** — every path that reads it has a network path beside it giving +the same answer. What the app does not keep is anything *derived*: the folded +rows are rebuilt from events every time. + +### Notifications: two places, never both (2026-08-30) + +`GET /notifications` is one SSE stream of attention-wanting moments, and the +app decides where each one is said. Three outcomes, in one place +(`NotificationService.show`): - **Nothing at all** if the session is the one on screen. The transcript in front of the reader is already saying it. - **A banner over the app** if the app is up — `SessionAlerts`, queued, one per session replacing that session's own, dismissable by a push in either - direction, and otherwise retiring itself when the bar across its foot runs - out. Tapping one opens the session, through the same path a tapped - notification uses. + direction and otherwise retiring itself when the bar across its foot runs + out. - **A row in Android's drawer** otherwise, which is what the foreground service exists for. -Never two of them for one moment. A notification that has already been shown -in the app is not something to also find in the shade afterwards, and a -drawer that fills up behind an app that showed you each one is a drawer -nobody reads. - -Which of the three applies is answered without a flag anybody has to keep -level: the session on screen is registered by the one composable that draws -one, and "the app is up" *is* the banner queue being collected, since it -collects only while it is on screen. +Never two of them for one moment. A drawer that fills up behind an app that +showed you each one is a drawer nobody reads. Which of the three applies is +answered without a flag anybody has to keep level: the session on screen is +registered by the one composable that draws one, and "the app is up" *is* the +banner queue being collected, since it collects only while it is on screen. **What counts as finished** is decided in `notification_for`, and since -2026-08-31 it takes the number of messages the session has been given and -not started reading. With one waiting, a turn ending is not the work -ending: a message written into the tail of a turn is read the moment that -turn's `result` lands, so the session goes idle and immediately runs again --- and the phone that sent it was told its work had finished, seconds -before any of it was done. The count is kept in `pump` from the recorded -events (`MessageQueued` up, the `UserMessage` that resolves it or a -`MessageDropped` down), because that is the one place that sees every event -in transcript order. It does not suppress *awaiting input*: a question is -worth saying whatever is queued behind it, and the queue is exactly what -will not move until it is answered. +2026-08-31 it takes the number of messages the session has been given and not +started reading. With one waiting, a turn ending is not the work ending: a +message written into the tail of a turn is read the moment that turn's +`result` lands, so the session goes idle and immediately runs again — and the +phone that sent it was told its work had finished seconds before any of it +was done. The count is kept in `pump`, the one place that sees every event in +transcript order. It does not suppress *awaiting input*: a question is worth +saying whatever is queued behind it. -The alternative considered and rejected was giving the app its own -connection to `/notifications` while it is in front. That is a second stream -per device saying the same thing, and it puts the "which of these two shows -it" decision in two processes' worth of code instead of one function. +Rejected: giving the app its own connection to `/notifications` while it is +in front. That is a second stream per device saying the same thing, and it +puts the "which of these two shows it" decision in two processes' worth of +code instead of one function. ### Deferred polish -Noticed and deliberately not fixed yet, so they are not re-found from -scratch. None is a defect; each is a decision waiting for the app to have -been used enough to say which way. +Noticed and deliberately not fixed, so they are not re-found from scratch. -- **The session screen's header is lopsided.** The row is `padding( - horizontal = 8.dp)`, so the status on the right sits exactly 8dp from the - edge while "Back" on the left is a `TextButton` whose touch target is - wider than its text — the same 8dp reads as more. It is the "align the - mark, not the box" case: either align the button's content or size the - button to what it draws, rather than nudging with a hardcoded offset. +- **The session screen's header is lopsided.** The row is + `padding(horizontal = 8.dp)`, so the status on the right sits exactly 8dp + from the edge while "Back" on the left is a `TextButton` whose touch target + is wider than its text. It is the "align the mark, not the box" case: + either align the button's content or size the button to what it draws, + rather than nudging with a hardcoded offset. -## Compaction: options explored +## Status -Context: raw llama-server has no conversation memory management; the context -window just fills. +Phases 1–3 (the skeleton pipe, the full Claude driver, the usage screen) done +2026-08-24. Phase 4 (llama.cpp: model browsing, downloads, and `llama-server` +through its OpenAI-compatible endpoint) and phase 5 (ssh) done 2026-08-28. +The file explorer and the transcript cache followed in September. What is +left is real-phone/WireGuard bring-up, which is operational rather than code. -1. **pi's auto-compaction** — *chosen*. When the prompt nears the model's - context limit, pi summarizes older history with the model itself and - replaces it with a structured summary; threshold configurable; manual - `compact` also exposed. Battle-tested, zero work for us. -2. **Manual compaction in a custom Rust loop** — *the later third driver*. - The design when we build it: every llama-server response reports prompt + - completion token counts; track them against `n_ctx` (from `/props`); at a - threshold (~75%), pause, run a summarization request over all but the last - few turns ("state of the task, decisions made, open items, relevant - file/tool state"), replace those turns with the summary as a system-adjacent - message, continue. Keep the full pre-compaction transcript on disk — the - phone view never loses history, only the model's view shrinks. Worth doing - eventually for control over the summarization prompt and for tool-loop - experiments pi doesn't allow. -3. **llama-server `--context-shift`** — *rejected* as the strategy. It - truncates old KV cache entries: silent forgetting, no summary, and it - corrupts the harness's view of what the model knows. Fine as a server-side - safety net; not memory management. - -## Phases - -1. **Skeleton** — *done 2026-08-24.* Repo layout, cert script, TLS + token - auth, wg0-bound listener (fail closed if the interface is missing), - config.ron, session registry with a fake `EchoDriver`, session list + - session screen in the app end-to-end over SSE. Proves the whole pipe - before any AI is involved. Verified: 10 server tests + clippy clean; - curl end-to-end over pinned TLS (auth rejection, spawn, SSE - replay/resume by cursor, question round trip, restart continuing seq - numbers, delete); the app on another checkout's emulator against the real - server (QR-style enrollment via deep link, spawn, streamed echo turn, - question answer, tool card). -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** — *done 2026-08-24.* The undocumented endpoint's - `limits[]` array parsed defensively into labeled window bars; cached - behind the ≥180 s minimum with no background polling. -4. **llama.cpp** — LlamaServerManager (local), PiDriver, model change, - compaction controls. *Deferred (2026-08-24): pi/llama-server aren't set - up in this VM, so this phase isn't testable here — Claude first; the - driver seam is ready when it is.* -5. **SSH** — host config, remote spawn for both kinds, remote llama-server - with port forward, attachment shipping. *Host config and remote spawn - done 2026-08-25* (any session of any provider can name a host; the - command is the identical one wrapped in `ssh -T`, with every argument - shell-quoted). Attachment shipping turned out to be unnecessary for - images — they ride the stdio JSONL as base64 in both directions, so - nothing needs `scp` — and was built on 2026-09-03 for files, which - are attached by path (see "Transport" above). Still outstanding: - remote llama-server with its port forward, which comes with phase 4. - Two things learned doing it: a remote session inherits ssh's non-login - PATH, which is narrower than an interactive shell's (point `command` at - an absolute path if a CLI isn't found), and the remote command is run - with `exec` so dropping the connection takes the CLI down rather than - orphaning it. -6. **Polish** — reconnect edges, notification when a session awaits an answer - (the "your turn" push), transcript search, whatever daily use surfaces. - -Each phase ends runnable and verified against the real thing (rule 22); the -backend gets tests where logic is pure (event normalization, transcript -cursors, config persistence, refcounting) — the app is UI over the API and is +Each phase ended runnable and verified against the real thing. The backend +gets tests where logic is pure — event normalization, transcript cursors, +config persistence, the syntax scanner; the app is UI over the API and is verified by running it, matching dev-updater's posture. -## Open questions / risks +## Open questions and risks -- **Claude stream-json control protocol details** (permission requests, - set-model, interrupt wire format) are the least-documented dependency and - version-coupled to the installed CLI. Phase 2 starts by probing the - installed version and pinning what works; the `--resume` respawn fallback - covers whatever the control channel can't do. -- The **usage endpoint is undocumented** and has changed rate-limit behavior - before; treat as best-effort. -- **pi RPC schema drift** — pin a pi version; the translator is one file. -- Whether **notifications** need FCM or a foreground-service polling - connection — decide in phase 6; the SSE cursor design already supports - either. -- Claude sessions over SSH need the remote host **logged in to Claude**; usage - reporting reads only the backend host's credentials. Acceptable for now - (same account everywhere); revisit if not. +- **The Claude stream-json control protocol** is the least-documented + dependency and is version-coupled to the installed CLI. What works is + pinned in `session/claude.rs`'s module doc against the version it was + measured on. +- **The usage endpoint is undocumented** and has changed rate-limit + behaviour before; treat as best-effort. +- **Compaction for llama sessions is not built.** `LlamaDriver::compact` + refuses. The design when it is built: every response reports prompt and + completion token counts, so track them against `n_ctx` (from `/props`), and + at ~75% summarize all but the last few turns and replace them, keeping the + full pre-compaction transcript on disk so the phone's view never loses + history. llama-server's own `--context-shift` is rejected as the strategy: + it truncates old KV cache entries, which is silent forgetting with no + summary, and it corrupts the harness's view of what the model knows. Fine + as a server-side safety net; not memory management. +- **Remote llama-server** needs its port forwarded (`ssh -L`) and is not + built; such a session is refused rather than misdirected. +- **Claude sessions over ssh need the remote machine logged in to Claude.** + Usage reporting reads each machine's own credentials, so this is visible + rather than silent. ## References -Research behind the decisions above (verified 2026-08-24; re-check against -installed versions when each phase starts): - -- pi RPC protocol: https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/rpc.md - — commands (`prompt`, `steer`, `follow_up`, `abort`, `set_model`, - `compact`, `set_auto_compaction`, session ops) and the event stream. -- pi + llama-server in practice: https://medium.com/@tolgaeren/running-pi-with-local-llms-c596aa14b062 -- llama-server API (`/health`, `/props`, OpenAI-compatible endpoints, - `--context-shift`): https://github.com/ggml-org/llama.cpp/blob/master/tools/server/README.md - and the offline-agentic-coding walkthrough: - https://github.com/ggml-org/llama.cpp/discussions/14758 +- llama-server API (`/health`, `/props`, OpenAI-compatible endpoints): + https://github.com/ggml-org/llama.cpp/blob/master/tools/server/README.md - Usage endpoint (`GET https://api.anthropic.com/api/oauth/usage`, bearer token from `~/.claude/.credentials.json`, headers `anthropic-beta: oauth-2025-04-20` + `User-Agent: claude-code/`, - ≥180 s polling; wrong User-Agent → aggressive 429 bucket): - https://github.com/anthropics/claude-code/issues/31637 and - https://github.com/Maciek-roboblog/Claude-Code-Usage-Monitor/issues/202 -- Sibling project this repo's conventions mirror: `../dev-updater` + ≥180 s polling; a wrong User-Agent lands in an aggressive 429 bucket): + https://github.com/anthropics/claude-code/issues/31637 +- The sibling project this repo's conventions mirror: `../dev-updater` (README.md + AGENTS.md — server/registry/routes layout, cert scheme, - testing posture, Android env notes). + testing posture). diff --git a/TRANSCRIPT_CACHE.md b/TRANSCRIPT_CACHE.md index 34fcda4..c4c6c28 100644 --- a/TRANSCRIPT_CACHE.md +++ b/TRANSCRIPT_CACHE.md @@ -1,419 +1,343 @@ # The transcript cache -Asked for by Iris on 2026-09-04: keep the transcripts of recently visited -sessions on the phone, so reopening one does not download it again. It has -to save data over the tunnel, it must not disturb a reply that is streaming -when the screen is reopened, it must never skip an event, and session -settings needs a manual reload for when the file on the machine has -changed under it. +Asked for by Iris on 2026-09-04 and built the same day: keep the transcripts +of recently visited sessions on the phone, so reopening one does not download +it again. It has to save data over the tunnel, must not disturb a reply that +is streaming when the screen is reopened, must never skip an event, and needs +a manual reload for when the file on the machine has changed under it. -Built 2026-09-04. Like EXPLORER.md this records each decision with its -reason and what was rejected, so that when one changes it is changed here -rather than re-argued -- three of them changed during the building, and -"What building it changed" at the foot says which and why. What it is *not* -is the operational half: how to exercise it, and what has bitten, are in -AGENTS.md with the rest of the working notes. +Like EXPLORER.md this records each decision with its reason and what was +rejected, so that when one changes it is changed here rather than re-argued. +"What building it changed" at the foot says which of them moved while it was +being built. How to exercise it, and what has bitten, are in AGENTS.md. ## What it is, in one paragraph A per-session file on the phone holding the exact JSON lines the server has already sent, in transcript order, with a record of which sequence numbers -each run of lines covers. Everything the session screen fetches today -- -the opening window, the pages it scrolls back through, the span an anchor -restore reaches for -- is asked of the cache first and of the server only -for what the cache does not hold, and everything that arrives from the -server is written into it. The live stream then resumes from the newest -cached event, exactly as it resumes today from the newest event on screen, -so the server sends only what happened since. One tiny request checks that -the cached tail is still what the server has before the stream is opened -from it, and a button in session settings throws the cache away and -rebuilds the screen as a cold open for the cases that check cannot see. +each run of lines covers. Everything the session screen fetches — the opening +window, the pages it scrolls back through, the span an anchor restore reaches +for — is asked of the cache first and of the server only for what the cache +does not hold, and everything that arrives from the server is written into +it. The live stream then resumes from the newest cached event, exactly as it +resumes from the newest event on screen, so the server sends only what +happened since. One tiny request checks that the cached tail is still what +the server has before the stream is opened from it, and a button in session +settings throws the cache away and rebuilds the screen as a cold open for the +cases that check cannot see. ## The invariants -Everything below is in service of four rules. When a decision looks -arbitrary, it is one of these forcing it. +When a decision below looks arbitrary, it is one of these forcing it. -1. **What is on screen is what the server's transcript says, in order, - with nothing missing, for every sequence number the screen claims to - show.** The cache is a copy of server output and is never inferred, - folded, or edited on the phone. Where the copy cannot be shown to be - current, it is thrown away, not patched. -2. **A cached line is never ahead of the live cursor, and the live cursor - is never ahead of the cache.** The stream resumes from the newest cached +1. **What is on screen is what the server's transcript says, in order, with + nothing missing, for every sequence number the screen claims to show.** + The cache is a copy of server output and is never inferred, folded, or + edited on the phone. Where the copy cannot be shown to be current, it is + thrown away, not patched. +2. **A cached line is never ahead of the live cursor, and the live cursor is + never ahead of the cache.** The stream resumes from the newest cached event, so a reply that was mid-stream when the screen closed picks up at - its next delta and folds into the same row, as it does today when the - phone merely lost the tunnel for a second. + its next delta and folds into the same row. 3. **The cache is never load-bearing.** A missing, evicted, corrupt or - unwritable cache degrades to today's behaviour -- a cold open -- and - never to a blank or wrong screen. Every path that reads it has a - network path beside it that produces the same result. + unwritable cache degrades to a cold open, never to a blank or wrong + screen. Every path that reads it has a network path beside it producing + the same result. 4. **Data crosses the tunnel once.** A line already on the phone is not - fetched again unless the reader asks for that (the reload button) or the - check in decision 3 says it must be. + fetched again unless the reader asks (the reload button) or the check in + decision 3 says it must be. ## Decisions ### 1. Raw server lines, on the phone, keyed by server and session -The cache stores the server's own JSON, one event per line, byte-for-byte -as it arrived: the elements of the `/transcript` array and the `data:` -payload of each SSE frame. Reading the cache means running the same -`parseSeqEvent` the network path runs, so a cached transcript and a fetched -one cannot draw differently, and an event type this build does not know +The cache stores the server's own JSON, one event per line, byte-for-byte as +it arrived: the elements of the `/transcript` array and the `data:` payload +of each SSE frame. Reading the cache runs the same `parseSeqEvent` the +network path runs, so a cached transcript and a fetched one cannot draw +differently, and an event type this build does not know (`SessionEvent.Unknown`) survives on disk for the build that will. -It lives under `context.cacheDir` -- `/transcripts/v1/_//` --- because it is exactly what that directory is for: bytes the phone can -regenerate from the server, which Android may delete under storage -pressure without asking. Keyed by the server's host and port because two -servers can hold a session with the same id (the sandbox and the real -server, or a re-enrolment), and a line from one shown against the other -is invariant 1 broken. `ServerSettings` has both fields; the key is -`"${settings.host}_${settings.port}"` with `:` never appearing in it. -The `v1` segment is the format version: any change to the layout below -bumps it, and a directory of another version is deleted on first use. +It lives under `context.cacheDir`, which is exactly what that directory is +for: bytes the phone can regenerate from the server, which Android may delete +under storage pressure without asking. Keyed by the server's host and port, +because two servers can hold a session with the same id (the sandbox and the +real server, or a re-enrolment) and a line from one shown against the other +is invariant 1 broken. The `v1` segment is the format version: any change to +the layout below bumps it, and a directory of another version is deleted on +first use. Rejected: a database (Room, SQLite). The access pattern is "the newest N lines" and "the lines before seq X", on files of tens of megabytes at most, -and a JSONL file per contiguous run answers both by reading from its end. -A database would be a new dependency for an index the file layout already -provides. +and a JSONL file per contiguous run answers both by reading from its end. A +database would be a new dependency for an index the file layout provides. -Rejected: caching folded `TranscriptItem` rows instead of events. Rows are -a *rendering* of events, and their shape changes when the fold changes; -the cache would need invalidating on every app update that touched -`foldEvent`, and would still have to keep raw seqs for the stream cursor. -Events are the server's contract and the only thing that is stable. +Rejected: caching folded `TranscriptItem` rows instead of events. Rows are a +*rendering* of events, and their shape changes when the fold changes; the +cache would need invalidating on every app update that touched `foldEvent`, +and would still have to keep raw seqs for the stream cursor. Events are the +server's contract and the only thing that is stable. ### 2. Chunks with explicit coverage; one contiguous run behind the cursor -A page from the server is a set of lines *and a claim about what they -cover*, and the two are not the same thing. A coalesced page -(`coalesce=true`, which the scroll-back pager asks for) joins each run of -`assistantText` deltas into one event carrying the seq of its *oldest* -delta, so a page whose newest event has seq 1,200 may in fact cover every -line up to the `before` it was asked with, say 1,650. Nothing in the lines -themselves says so. So each stored chunk records its coverage as a -half-open range `[first, end)`, where `first` is the seq of its oldest -event and `end` is the `before` the request was made with -- or, for a -raw chunk, its newest seq plus one. +A page from the server is a set of lines *and a claim about what they cover*, +and the two are not the same thing. A coalesced page joins each run of +`assistantText` deltas into one event carrying the seq of its *oldest* delta, +so a page whose newest event has seq 1,200 may in fact cover every line up to +the `before` it was asked with, say 1,650. Nothing in the lines themselves +says so. So each stored chunk records its coverage as a half-open range +`[first, end)`, where `end` is the `before` the request was made with — or, +for a raw chunk, its newest seq plus one. Chunks are files named by their coverage: -.rows.jsonl a coalesced page; end is the `before` it was fetched with -.raw.jsonl an uncoalesced page or a closed live run - -open.raw.jsonl the live run: appended to by the stream; end = last line's seq + 1 + -open.raw.jsonl the live run: appended to by the stream -Two chunks are **adjacent** when one's `end` equals the other's `first`. -The cache serves only the contiguous run of adjacent chunks that ends at -the newest raw chunk (the **suffix**); chunks behind a gap are kept on -disk, because the gap is usually filled (decision 4), but are never served -across the gap. +Two chunks are **adjacent** when one's `end` equals the other's `first`. The +cache serves only the contiguous run of adjacent chunks that ends at the +newest raw chunk (the **suffix**); chunks behind a gap are kept on disk, +because the gap is usually filled (decision 4), but are never served across +it. -**The newest chunk is always raw.** That is what makes the stream cursor -and the check in decision 3 well defined: a raw chunk's last line is a real -event at a real seq, and the server never coalesces the newest window -("the live cursor depends on real seqs", `read_window`). It holds by -construction -- the opening window is fetched with no `before`, stream -frames are raw, and a `reset` window is raw -- and is *checked* on read: -if the newest chunk on disk is a `.rows` chunk (which can only happen if -the app died between closing one live run and appending to the next), the -session's cache is purged and the open is cold. +**The newest chunk is always raw.** That is what makes the stream cursor and +the probe well defined: a raw chunk's last line is a real event at a real +seq, and the server never coalesces the newest window. It holds by +construction — the opening window is fetched with no `before`, stream frames +are raw, and a `reset` window is raw — and is *checked* on read: a `.rows` +chunk found newest (which can only happen if the app died between closing one +live run and appending to the next) purges the session's cache. -There is at most one open chunk. When a stream event arrives whose seq is -not the open chunk's `end` -- which is what a `reset` looks like from -here, see decision 6 -- the open chunk is closed by renaming it with its -real end, and a new open chunk starts at the arriving seq. An event whose -seq is below the open chunk's `end` is already covered and is not written -(the SSE contract is `seq > after`, so this is a guard, not a path). +There is at most one open chunk. A stream event whose seq is not the open +chunk's `end` — which is what a `reset` looks like from here — closes it by +renaming it with its real end and starts a new one. An event whose seq is +below the open chunk's `end` is already covered and is not written; the SSE +contract is `seq > after`, so that is a guard rather than a path. -Rejected: one file per session, rewritten to prepend older pages. A -20 MB transcript would be rewritten on every page scrolled back to. The -chunk directory costs a directory listing per open instead. +Rejected: one file per session, rewritten to prepend older pages. A 20 MB +transcript would be rewritten on every page scrolled back to. The chunk +directory costs a directory listing per open instead. Rejected: trimming chunks to resolve overlaps. A coalesced event cannot be -split at a seq inside its run, so an overlap between a coalesced page and -an existing chunk has no clean cut. The cache therefore **never stores a -page that overlaps an existing chunk**; decision 4 makes sure such a page -is never fetched in the first place, and if one arrives anyway (a server -without decision 4's change) it is used for display and not stored. +split at a seq inside its run, so an overlap between a coalesced page and an +existing chunk has no clean cut. The cache therefore **never stores a page +that overlaps an existing chunk**; decision 4 makes sure such a page is never +fetched, and one that arrives anyway is used for display and not stored. ### 3. The cached tail is checked against the server before the stream opens from it -The screen must not resume a stream from a cached seq unless the server's -event at that seq is the one in the cache. The transcript file on the -machine is append-only in ordinary use, but it can be replaced or -truncated -- a sandbox re-seeded with the same ids, a backup restored, a -directory deleted and the session re-imported under the same name -- and -`catch_up` on such a file would hand the phone a continuation of a -different conversation, spliced onto the cached one with no seam. That is -the worst thing this feature can do, and it is caught with one request. +The transcript file is append-only in ordinary use, but it can be replaced or +truncated — a sandbox re-seeded with the same ids, a backup restored, a +session deleted and re-imported — and `catch_up` on such a file would hand +the phone a continuation of a *different* conversation, spliced onto the +cached one with no seam. That is the worst thing this feature can do, and it +is caught with one request. -**The probe:** `GET /sessions/{id}/transcript?before=&limit=1`, -where `cursor` is the seq of the cache's newest line. `read_window` with -that `before` returns the single newest event with seq ≤ cursor, which is -the event *at* the cursor when it exists. The probe passes when that -response, parsed with `parseSeqEvent`, is `==` to the cached line parsed -the same way -- data-class equality over seq, ts, and the whole event. It -fails when the response is empty, is a different seq, or differs in any -field. +**The probe** is `GET /sessions/{id}/transcript?before=&limit=1`, +where `cursor` is the seq of the cache's newest line. `read_window` with that +`before` returns the single newest event with seq ≤ cursor, which is the +event *at* the cursor when it exists. It passes when that response, parsed +with `parseSeqEvent`, is `==` to the cached line parsed the same way — over +seq, ts, and the whole event. It fails when the response is empty, is a +different seq, or differs in any field. That equality rested on an assumption this plan stated and did not check: that the two ways the server hands out a line agree bit for bit. **They did -not.** `serde_json`'s default float parser is not correctly rounded, so a -`ts` of `1788546972.6030757` written to the transcript came back from -`/transcript` as `...0755`, while the SSE stream -- serializing the same -struct -- sent the original. Measured on the emulator 2026-09-04: 23 of 330 -cached lines differed from the server's answer in the last bit, so the probe -would have failed on any session whose cached tail happened to be one of -them, silently and only sometimes. That is a defect in the server -independent of this feature -- two answers to "what is line 30" -- and it is -fixed there, with `float_roundtrip` and a test -(`a_line_read_back_is_the_line_that_was_written`) that fails the moment the -feature is dropped. Comparing everything *except* `ts` was the other option -and was rejected: a re-seeded fixture is identical in content and differs -only in when it happened, which is exactly the case the probe exists for. A failed probe **purges the session's cache -and proceeds as a cold open**. A probe that cannot be made (no route to -the server) leaves the cached transcript on screen, shows the request's +not**, and the server was fixed — see AGENTS.md's entry on `float_roundtrip`. +Comparing everything *except* `ts` was the other option and was rejected: a +re-seeded fixture is identical in content and differs only in when it +happened, which is exactly the case the probe exists for. + +A failed probe **purges the session's cache and proceeds as a cold open**. A +probe that cannot be made leaves the cached transcript on screen, shows the error on the stream banner where a connection failure shows today, and is -retried on the stream loop's schedule (`RECONNECT_DELAY_MS`); the stream -is never opened until a probe has passed once for this screen instance. +retried on the stream loop's schedule; the stream is never opened until a +probe has passed once for this screen instance. What the probe does *not* catch: a line changed in the middle of the file with the tail intact, or a file rewritten so that the event at the cursor happens to be identical. Those are what the reload button is for, and the button's caption says so. -Cost: one request of a few hundred bytes, one round trip, in the slot -where the opening page's request is today -- so the round trips before -the stream is live are unchanged at two, and the bytes fall from a page to -a line. The cached rows are drawn *before* the probe returns, which is the -whole point of the feature; a failed probe replaces them, the same -appearance as a `reset`. +Cost: one request of a few hundred bytes, in the slot where the opening +page's request would be — so the round trips before the stream is live are +unchanged at two, and the bytes fall from a page to a line. The cached rows +are drawn *before* the probe returns, which is the whole point; a failed +probe replaces them, with the same appearance as a `reset`. -Rejected: a server-side check on the stream (`events?after=N&ts=T`, -answered with a distinct frame when the event at N is not what the phone -thinks). Strictly better coverage -- it would run on every reconnect, not -only on open -- and no extra round trip. Not chosen because it puts a -cache's validation into a protocol that otherwise knows nothing about -caching, and because the reset frame already has to keep meaning "you are -behind, your history is fine" (decision 6), so a second frame would be -needed. Worth revisiting if the probe's round trip is ever measured as the -thing making reopen slow; note it as the alternative here and in PLAN.md. +Rejected: a server-side check on the stream, answered with a distinct frame +when the event at N is not what the phone thinks. Strictly better coverage — +it would run on every reconnect — and no extra round trip. Not chosen because +it puts a cache's validation into a protocol that otherwise knows nothing +about caching, and because the reset frame already has to keep meaning "you +are behind, your history is fine". Worth revisiting if the probe's round trip +is ever measured as the thing making reopen slow. -Rejected: trusting the cache without a check and relying on the reload -button. Invariant 1 is not something a button restores after the fact. +Rejected: trusting the cache and relying on the reload button. Invariant 1 is +not something a button restores after the fact. -Rejected: fetching the newest page as today and using it to validate the -overlap. Zero saving on the opening page, which is the request paid on -every open. +Rejected: fetching the newest page as before and using it to validate the +overlap. Zero saving on the opening page, which is the request paid on every +open. ### 4. Pages ask the server only for the gap: `after` on `/transcript` -After a reader has been away, the cache holds `[a, b)` and the screen -holds the newest window `[W, …)` with a gap between `b` and `W`. Paging -back from `W` asks the server for a coalesced page before `W`, and that -page may reach back past `b` -- a single reply is hundreds of lines, so -forty rows can be thousands of seqs -- producing exactly the overlap -decision 2 refuses to store. Left like that, every cached chunk would be -overlapped and dropped in turn as the reader paged back through the gap, -and the cache would save nothing for the sessions it exists for. +After a reader has been away, the cache holds `[a, b)` and the screen holds +the newest window `[W, …)` with a gap between `b` and `W`. Paging back from +`W` asks for a coalesced page before `W`, and that page may reach back past +`b` — a single reply is hundreds of lines, so forty rows can be thousands of +seqs — producing exactly the overlap decision 2 refuses to store. Left like +that, every cached chunk would be dropped in turn as the reader paged back +through the gap, and the cache would save nothing for the sessions it exists +for. -So the transcript route gains a lower bound. `TranscriptQuery` in -`server/src/routes.rs` gets +So the transcript route takes a lower bound, `after`, named to match the SSE +route's (exclusive, `seq > after`). `read_window` starts the walk at +`first_at_or_after(after + 1)` instead of at `end - limit`. A delta run cut +at the start is emitted as the partial it is, exactly as one cut by `limit` +already is, and `healSplitMessage` welds it on the phone — no new mechanism. - /// Return nothing at or below this seq; the page stops here instead of at `limit`. - /// The phone passes the end of what it already holds, so a page never overlaps it. - #[serde(default)] - after: Option, +The phone passes `after = b - 1` where `b` is the `end` of the nearest chunk +whose `end ≤ before`, and nothing when there is none. A page that comes back +with `first == b` is adjacent, and the suffix now runs through the old +chunks: the gap is closed with exactly the bytes it was wide, and the history +behind it is served locally from then on. -named to match the SSE route's `after` (exclusive, `seq > after`). -`read_window(path, before, after, limit, coalesce)` in -`server/src/session/transcript.rs` computes -`start = first_at_or_after(after + 1)` and stops the walk there: the raw -branch parses `max(start, end - limit)..end`; `parse_coalesced` takes a -`start` and its `while index > 0` becomes `while index > start`. A delta -run cut at `start` is emitted as the partial it is, exactly as one cut by -`limit` already is, and `healSplitMessage` welds it on the phone -- no new -mechanism. The route's table comment in `routes.rs` gains the parameter, -and `transcript.rs` gets a test beside -`a_window_is_the_events_before_a_cursor_and_nothing_else`: with `after` -set, the page's oldest seq is greater than `after`, and with `after` set -inside a delta run the partial run's seq is the first delta above `after`. - -The phone passes `after = b - 1` where `b` is the `end` of the nearest -chunk whose `end ≤ before`, and nothing when there is none. A page that -comes back with `first == b` is adjacent, and the suffix now runs through -the old chunks: the gap is closed with exactly the bytes it was wide, and -the history behind it is served locally from then on. - -Rejected: fetching the gap raw in one request (`before=W&limit=W-b`, -which is what the anchor restore already does). Exact, but a gap of ten -thousand lines is several megabytes downloaded to save re-downloading -history the reader may never scroll to; the feature exists to save data. -Paging as today with a bound saves the same bytes and fetches only what -is read. +Rejected: fetching the gap raw in one request, which is what the anchor +restore does. Exact, but a gap of ten thousand lines is several megabytes +downloaded to save re-downloading history the reader may never scroll to. Rejected: dropping the cached run whenever a gap opens. Being more than `CATCH_UP_LIMIT` (200) events behind is the *ordinary* state of an active -session revisited -- 200 raw events is one reply -- so this would empty -the cache for exactly the sessions that are opened most. +session revisited — 200 raw events is one reply — so this would empty the +cache for exactly the sessions that are opened most. ### 5. A page is served locally in rows, mirroring the server's count -`loadOlderPage` asks for `HISTORY_PAGE` (40) **rows** when -`coalesce = true`, and for a number of **events** otherwise (the anchor -restore). Served from the cache, the events branch is the `limit` lines -before `before`. The rows branch walks back from the line before `before` -counting rows the way `parse_coalesced` does: every event that is not an -`assistantText` is a row, and each maximal run of `assistantText` lines is -one row; it stops only between rows, once `limit` rows are complete, and -returns the raw lines oldest-first. It does not join the deltas -- the -fold does that (`foldEvent` appends a delta to a preceding -`AssistantMsg`), and the joined row keeps the seq of its first delta either -way, so anchors and the next `before` land where they do today. +`loadOlderPage` asks for `HISTORY_PAGE` (40) **rows** when coalescing and for +a number of **events** otherwise (the anchor restore). Served from the cache, +the events branch is the `limit` lines before `before`. The rows branch walks +back counting rows the way `parse_coalesced` does — every event that is not +an `assistantText` is a row, and each maximal run of `assistantText` lines is +one row — stopping only between rows. It does not join the deltas; the fold +does that, and the joined row keeps the seq of its first delta either way, so +anchors and the next `before` land where they do on the network path. -A cached page is allowed to be **short**: the suffix's oldest chunk starts -at some `first`, and a walk that reaches it returns what it found. The -caller already treats a short page as a page; only an *empty* page means -"start of the conversation" (`moreHistory = false`), and the cache never -returns an empty page -- it returns `null` (a miss) and the network is -asked. The walk may cross a chunk boundary inside the suffix, since adjacent -chunks are one run; a delta run straddling a boundary counts as one row, as -it should. +A cached page is allowed to be **short**: a walk that reaches the suffix's +oldest chunk returns what it found. The caller already treats a short page as +a page; only an *empty* page means "start of the conversation", and the cache +never returns one — it returns `null` (a miss) and the network is asked. -A miss is `before` **outside what the suffix covers continuously** -- above +A miss is `before` **outside what the suffix covers continuously** — above its newest `end`, or at or below its oldest `first`. This plan first said a miss was "no chunk of the suffix ends at `before`", which is wrong in the commonest case there is: a warm open draws the newest eighty lines of the live run, so the cursor the reader then scrolls back from is in the *middle* -of a chunk, not at a boundary. Under the narrower rule every warm open sent -its first backwards page to the server, and that page -- reaching back past -the run the phone already held -- overlapped it and could not be stored, so -the same history was fetched again on every visit. The feature would have -saved the opening window and nothing else. +of a chunk. Under the narrower rule every warm open sent its first backwards +page to the server, and that page overlapped what the phone already held and +could not be stored, so the same history was fetched again on every visit. +The feature would have saved the opening window and nothing else. -The row rule is a copy of the server's, and copies drift. It is short -(one comparison), it is pure, and it goes under a JVM unit test with the -same fixture as the server's `coalescing_counts_rows_and_joins_delta_runs` --- the three cases are a run cut by the limit, a `usageDelta` inside a run -(the server flushes the run there, so it is two rows), and a page that is -all one run. +The row rule is a copy of the server's, and copies drift. It is short, it is +pure, and it is under a JVM unit test with the same fixture as the server's +`coalescing_counts_rows_and_joins_delta_runs` — a run cut by the limit, a +`usageDelta` inside a run (the server flushes the run there, so it is two +rows), and a page that is all one run. ### 6. What a `reset` means for the cache: behind, not wrong -The server sends `reset` when the cursor is more than `CATCH_UP_LIMIT` -events behind, then the newest 200 raw events. The screen already drops -everything and rebuilds from that window. For the cache, a reset means -**the history is intact and there is a gap**: the probe passed, the file -is append-only, and the window's first seq is above the open chunk's end. -The store learns this from the first window event's seq (decision 2: -a seq that is not the open chunk's `end` closes it and opens a new chunk) -and needs no signal from the screen; the gap is filled by paging -(decision 4). +The server sends `reset` when the cursor is more than `CATCH_UP_LIMIT` events +behind, then the newest 200 raw events. For the cache that means **the +history is intact and there is a gap**: the probe passed, the file is +append-only, and the window's first seq is above the open chunk's end. The +store learns this from the first window event's seq and needs no signal from +the screen; the gap is filled by paging. -Two things the reset handler in `SessionScreen` does not clear today and -must: `queued` and `waitingCommands`. Both are folded from events, and a -`messageQueued` whose resolving `userMessage` fell in the gap would -otherwise draw a waiting bubble for a message the session has long since -read. This is a latent bug today, made likely by the cache because a -cached tail is older than a fetched one. `contextTokens` needs no change: -`UsageDelta.context` is absolute, so the window's first one corrects it. +The reset handler also clears `queued` and `waitingCommands`, which it did +not originally. Both are folded from events, and a `messageQueued` whose +resolving `userMessage` fell in the gap would otherwise draw a waiting bubble +for a message the session has long since read. That was a latent bug made +likely by the cache, because a cached tail is older than a fetched one. +`contextTokens` needs no clearing: `UsageDelta.context` is absolute, so the +window's first one corrects it. ### 7. Session state that is not the transcript comes from the list, not the cache `apply` derives `status`, `model`, `permissionMode` and `compactingSince` from `Status` and `Settings` events. Replayed from a fetched page those are -current; replayed from the cache they are as old as the last visit, while -`summary.status`, `summary.model` and `summary.permissionMode` -- the row -the reader just tapped -- were fetched moments ago. So the cache replay -runs through `apply` for the transcript's sake (queued bubbles, context, -rows) and then **reassigns those four from `summary`**, which is the newer -of the two measurements; the stream's catch-up then makes them current. -Without this a session that finished an hour ago would open saying -"working" until the stream connected, which is a status row lying for a -round trip. +current; replayed from the cache they are as old as the last visit, while the +list row the reader just tapped was fetched moments ago. So the cache replay +runs through `apply` for the transcript's sake and then **reassigns those +four from `summary`**, which is the newer of the two measurements; the +stream's catch-up then makes them current. Without this a session that +finished an hour ago would open saying "working" until the stream connected, +which is a status row lying for a round trip. ### 8. Reload, in session settings -`SessionSettingsDialog` gains a row under the working directory: +A row under the working directory showing what the button discards: [ Transcript ] 2.3 MB cached [ Reload ] -The size is what the button discards, and it is the unknown state made -visible: `null` while the directory is being measured (spinner, as the -notifications switch does), "nothing cached" when the directory is absent -or empty, else the size. A caption in the style of Move's, because the -button costs something the reader cannot see: +The size is the unknown state made visible — `null` while the directory is +being measured (spinner, as the notifications switch does), "nothing cached" +when the directory is absent or empty, else the size. The caption is in the +style of Move's, because the button costs something the reader cannot see: +*"Reload throws away this phone's copy and fetches the transcript from the +server again. Use it when what is shown here disagrees with the file on the +machine."* - Reload throws away this phone's copy and fetches the transcript from the - server again. Use it when what is shown here disagrees with the file on - the machine. +Pressing it purges the session's cache directory, closes the dialog, and +rebuilds the screen as a cold open, with the reader put back where they were. +The mechanism is an `epoch` counter in the key of the opening effect and the +stream effect; incrementing it cancels both and relaunches them. `savedAnchor` +is keyed on the epoch too, so the restore reads the anchor saved at the +reader's *current* position. The button is enabled whether or not anything is +cached: "what I see disagrees with the machine" is a state an empty cache can +also be in, and a control that comes and goes makes its own presence the +signal. -Pressing it: purge the session's cache directory, close the dialog, and -rebuild the screen as a cold open -- the same sequence as `reset` plus a -fresh opening fetch, with the reader put back where they were. The -mechanism is an `epoch` counter (`mutableIntStateOf(0)`) added to the key -of the opening effect and the stream effect; incrementing it cancels both -(the stream's `finally` closes the socket) and relaunches them. State the -relaunch must see cleared: `items`, `replies.clear()`, `held`, `oldestSeq -= 0`, `moreHistory = true`, `queued`, `waitingCommands`, `lastSeq.set(0)`, -`ready = false`. `savedAnchor` becomes `remember(summary.id, epoch)` so -the restore path reads the anchor saved at the reader's *current* -position (the anchor saver writes on every settle, so it is there), and -`restoring` is re-derived from it. The button is enabled whether or not -anything is cached: "what I see disagrees with the machine" is a state an -empty cache can also be in, and a control that comes and goes makes its -own presence the signal. +Nothing is announced on success — the transcript shows the opening spinner +and then the rows, which is what the screen already says about a reload. A +failure is the opening fetch's, and lands on the stream banner. -Nothing is announced on success. The transcript shows the opening spinner -and then the rows, which is what the screen already says about a reload. -A failure is the opening fetch's, and lands on the stream banner where -that failure lands today. - -Rejected: a global "clear transcript cache" in the app's settings screen. -Not asked for; eviction (decision 9) bounds the total, and the per-session -button is where the reader is when they notice a problem. Easy to add as -one more caller of `TranscriptCache.purgeAll` if wanted. +Rejected: a global "clear transcript cache" in the app's settings. Not asked +for; eviction bounds the total, and the per-session button is where the +reader is when they notice a problem. Easy to add as one more caller of +`purgeAll`. ### 9. Budget, eviction, pruning -The cache is bounded three ways, each with its path out written beside -the path in: +Bounded three ways, each with its path out written beside the path in: -- **Budget.** `CACHE_BUDGET_BYTES = 256 MB` across all sessions of one - server. Each open touches the session directory's mtime; after the - opening replay, on `Dispatchers.IO`, the store sums the server's - directories and deletes least-recently-touched session directories - (never the one on screen) until under budget. 256 MB is a dozen of the - largest transcripts seen in this VM (21 MB for 24,000 events) and a - small fraction of a phone; it is a number to revisit against real use, - not a measurement. -- **Deleted sessions.** `SessionListScreen`'s delete calls - `cache.session(id).purge()` after `deleteSession` succeeds, and every - successful list fetch calls `cache.retainOnly(ids)` for that server, so - a session deleted from another device or from the backend is pruned on - the next visit to the list. `Drafts.kt` chose not to prune because its - residue is bytes; here it is megabytes, so the pass is worth having. -- **Android.** `cacheDir` may be emptied under pressure at any moment, - including while a screen is open. Every read tolerates a missing - directory (cold open) and every write failure is swallowed once and - disables writing for that screen instance (decision 10). +- **Budget.** `CACHE_BUDGET_BYTES` is 256 MB across all sessions of one + server. Each open touches the session directory's mtime; after the opening + replay, on `Dispatchers.IO`, the store sums the server's directories and + deletes least-recently-touched ones (never the one on screen) until under + budget. 256 MB is a dozen of the largest transcripts seen in this VM + (21 MB for 24,000 events) and a small fraction of a phone; it is a number + to revisit against real use, not a measurement. +- **Deleted sessions.** The list screen's delete purges after `deleteSession` + succeeds, and every successful list fetch calls `retainOnly(ids)`, so a + session deleted from another device is pruned on the next visit to the + list. `Drafts.kt` chose not to prune because its residue is bytes; here it + is megabytes. +- **Android.** `cacheDir` may be emptied at any moment, including while a + screen is open. Every read tolerates a missing directory and every write + failure is swallowed once. ### 10. The cache never breaks the screen -Every store operation that touches the disk catches `IOException` and -answers as if the cache were empty: `null` from a read, no-op from a -write, with the failure logged once at `Log.w("ai-app", …)`. After a -write failure the `SessionCache` instance sets `disabled = true` and -writes nothing more, so a full disk costs one log line rather than one -per delta. A line at the end of an open chunk that does not parse -- the -app died mid-write -- is dropped and the file truncated to the last -good line before anything is served from it; a line that does not parse -anywhere else purges the session's cache (that file was not written by -this code). None of this is reported on screen: none of it changes what -the screen shows, and the reader has nothing to do about it. +Every store operation that touches the disk catches `IOException` and answers +as if the cache were empty: `null` from a read, no-op from a write, logged +once. After a write failure the instance stops writing, so a full disk costs +one log line rather than one per delta. A line at the end of an open chunk +that does not parse — the app died mid-write — is dropped and the file +truncated to the last good line before anything is served from it; a line +that does not parse anywhere else purges the session's cache, since that file +was not written by this code. None of this is reported on screen: none of it +changes what the screen shows, and the reader has nothing to do about it. ## Layout on disk @@ -424,12 +348,12 @@ the screen shows, and the reader has nothing to do about it. 1-1650.rows.jsonl coalesced page: covers seqs 1..1649 1650-2001.rows.jsonl 2001-2400.raw.jsonl a closed live run - 2600-open.raw.jsonl the live run; end = last line's seq + 1 + 2600-open.raw.jsonl the live run -Here 2400..2599 is a gap: the reader was away for two hundred events and -the stream reset. The suffix is the single chunk `2600-open`; the first -backwards page asks the server for `before=2600&after=2399&coalesce=true`, -and once a page comes back with `first == 2400` the suffix runs to seq 1. +Here 2400..2599 is a gap: the reader was away for two hundred events and the +stream reset. The suffix is the single chunk `2600-open`; the first backwards +page asks the server for `before=2600&after=2399&coalesce=true`, and once a +page comes back with `first == 2400` the suffix runs to seq 1. Each `.jsonl` is one JSON object per line, oldest first, exactly as the server sent it. No header, no index: coverage is in the name, order is the @@ -437,76 +361,67 @@ file's, and the seq is in every line. ## What building it changed -Each of these contradicted something written above, and each was found by -running it rather than by reading it. The decisions themselves are amended -in place; this is the list of what moved, so that a reader who remembers the -first version knows what to re-read. +Each of these contradicted the plan, and each was found by running it rather +than by reading it. The decisions above are amended in place; this is what +moved, so a reader who remembers the first version knows what to re-read. -- **The probe's equality had a false premise** -- decision 3. The server did +- **The probe's equality had a false premise** (decision 3). The server did not hand out the same line twice the same way. Fixed on the server. -- **A cached page starts anywhere inside the run** -- decision 5. Requiring - a chunk boundary would have made the cache save the opening window and +- **A cached page starts anywhere inside the run** (decision 5). Requiring a + chunk boundary would have made the cache save the opening window and nothing else. - **The opening window is stored by `append`, not by `storePage`.** The - sketch below had `storePage` grow a special case for "this page is the new - open chunk", decided by an implicit condition that a raw history page also - satisfies. Appending each line instead is the mechanism that already - exists, and the open chunk stays the one thing that grows. + sketch had `storePage` grow a special case for "this page is the new open + chunk", decided by an implicit condition a raw history page also satisfies. + Appending each line instead is the mechanism that already exists, and the + open chunk stays the one thing that grows. - **Chunks are read backwards, in blocks, and never whole.** Every question the cache is asked is about the newest end, and a live run reaches the size - of the conversation -- so reading a chunk to answer with eighty lines of it + of the conversation — so reading a chunk to answer with eighty lines of it is the cost the server's own reader was rewritten to stop paying, arriving on the phone. Damage is therefore noticed when a read reaches it rather than up front, which is the better time: what is not read cannot be wrong. - **The stream waits for the opening effect's probe.** The screen lifts - `ready` before the probe returns -- that is the point of the cache -- so + `ready` before the probe returns — that is the point of the cache — so `ready` stopped being the whole gate, and the stream loop asked the same question a second time and raced its own answer. Two probes per warm open, visible in the server's log. -- **`SessionCache` is synchronized.** The stream appends live events from - one IO thread while a reader scrolling back reads pages from another; the - open chunk's name, its end and its writer must never be seen - half-rotated. +- **`SessionCache` is synchronized.** The stream appends live events from one + IO thread while a reader scrolling back reads pages from another; the open + chunk's name, its end and its writer must never be seen half-rotated. ## What it cost, measured -On the emulator against `app/ui-sandbox.sh`, 2026-09-04, on a session of -505 events (three short exchanges and two 300-delta replies): +On the emulator against `app/ui-sandbox.sh`, 2026-09-04, on a session of 505 +events (three short exchanges and two 300-delta replies): -- **Reopening it: one request, for one event.** The probe, and nothing else - -- including scrolling the whole conversation back to its first line. A - cold open of the same session is two requests and 100 events. -- **A reset after falling 300 events behind costs the gap and no more.** - The window arrived at seq 306, the phone held up to 202, and the first +- **Reopening it: one request, for one event.** The probe, and nothing else — + including scrolling the whole conversation back to its first line. A cold + open of the same session is two requests and 100 events. +- **A reset after falling 300 events behind costs the gap and no more.** The + window arrived at seq 306, the phone held up to 202, and the first backwards page asked `before=306&after=201` and came back with **four - coalesced rows** covering 202..305 -- against the 104 raw events an - unbounded page would have re-fetched and then thrown away. + coalesced rows** covering 202..305 — against the 104 raw events an + unbounded page would have re-fetched and thrown away. - **Every chunk is exactly what the server says for the range its name claims**, checked line by line against `/transcript` for each chunk's own `before`/`after`/`coalesce`, across a reset and a gap-fill. - **Nothing about drawing changed**, which is what a cache must not do: - `transcript-bench.sh` before and after, same viewport content and the same - gestures, reported p50 16.9ms both times and the transcript's own draw - accounting at 0.33ms against 0.32ms. + `transcript-bench.sh` before and after, same viewport content and gestures, + p50 16.9ms both times and the transcript's own draw accounting at 0.33ms + against 0.32ms. Still to measure, in real use rather than here: the size the cache reaches -against `CACHE_BUDGET_BYTES`, and whether the probe's round trip is ever -what a reader waits on. +against `CACHE_BUDGET_BYTES`, and whether the probe's round trip is ever what +a reader waits on. ## Open questions -- **The probe on every reconnect, not only on open?** Decision 3 probes - once per screen instance. A file replaced *while* the screen is open is - today's behaviour and not made worse, but the server-side check it - rejects would close it. Decide after measuring how often the probe's - round trip is what the reader waits on. -- **A reset arriving during an anchor restore** was an open worry when this - was written, and was measured and closed on 2026-09-04 (see "The reconnect - loop does not reproduce") before this landed. The cache makes the restore - cheaper again -- a warm one is now the probe and nothing else -- so it can - only have narrowed the window further. Worth re-measuring here only if a - reader reports the screen reconnecting on reopen. -- **Images.** `SessionImage` fetches bytes from the files route on draw; - they are not part of this cache and are re-downloaded per view. A - separate, simpler cache (a directory of refs, no ordering) if the - measurement above says the images are where the data goes. +- **The probe on every reconnect, not only on open?** A file replaced *while* + the screen is open is not made worse than it was, but the server-side check + decision 3 rejects would close it. Decide after measuring how often the + probe's round trip is what the reader waits on. +- **Images.** `SessionImage` fetches bytes from the files route on draw; they + are not part of this cache and are re-downloaded per view. A separate, + simpler cache (a directory of refs, no ordering) if the measurement above + says the images are where the data goes. diff --git a/server/src/auth.rs b/server/src/auth.rs index 3b2c884..7719bce 100644 --- a/server/src/auth.rs +++ b/server/src/auth.rs @@ -1,17 +1,15 @@ //! Bearer-token auth for the entire HTTP surface. //! -//! This server's API *is* remote code execution, so the token gates every -//! route with zero unauthenticated endpoints -- the middleware is applied -//! once around the whole router (including the fallback) in `main.rs`, -//! never per-route, so a new route can't forget it. See PLAN.md's security -//! section for the threat model; the short version is that the token gates -//! LAN/tunnel-reachable RCE and is rotatable, and WireGuard makes it -//! defense in depth rather than the sole gate. +//! This server's API *is* remote code execution, so the token gates every route +//! with zero unauthenticated endpoints -- the middleware is applied once around +//! the whole router (including the fallback) in `main.rs`, never per-route, so a +//! new route can't forget it. See PLAN.md's security section for the threat +//! model. //! //! Nothing in this module -- and nothing anywhere else -- may log the -//! Authorization header or the token; the test below holds a tripwire -//! against a logging change silently starting to. It is one test covering -//! both gating and logging on purpose -- see the note in it. +//! Authorization header or the token; the test below is a tripwire against a +//! logging change silently starting to. It is one test covering both gating and +//! logging on purpose -- see the note in it. use std::net::SocketAddr; use std::sync::Arc; diff --git a/server/src/config.rs b/server/src/config.rs index 9760d8f..2cfe565 100644 --- a/server/src/config.rs +++ b/server/src/config.rs @@ -1,20 +1,17 @@ -//! The server's persistent state: the enrolled token hashes and the -//! sessions that exist. +//! The server's persistent state: the enrolled token hashes and the sessions +//! that exist. //! -//! Written whole and atomically (temp file + rename) rather than appended -//! to: it is small, and a half-written config would take the server down on -//! next start with no obvious way to recover from a phone. Every mutation -//! funnels through `SessionManager` (the registry pattern), so in-memory -//! and on-disk state can't come apart. +//! Written whole and atomically (temp file + rename) rather than appended to: +//! it is small, and a half-written config would take the server down on next +//! start with no obvious way to recover from a phone. Every mutation funnels +//! through `SessionManager`, so in-memory and on-disk state can't come apart. //! -//! The file is RON, in the shape [`wg_app_link::format`] describes -- the -//! same format, and the same two house rules, as the sibling dev-updater -//! project's config, because both are written and read by hand, and both -//! now read and write them through the one module. +//! The file is RON, in the shape [`wg_app_link::format`] describes -- the same +//! two house rules as dev-updater's config, because both are read and written +//! by hand. //! -//! Transcripts do NOT live here -- each session's events are an append-only -//! JSONL file in its own directory (see `session::transcript`); this file -//! holds only the metadata needed to list and respawn sessions. +//! Transcripts do NOT live here: each session's events are an append-only JSONL +//! file in its own directory. use std::collections::BTreeMap; use std::path::{Path, PathBuf}; @@ -27,24 +24,20 @@ use wg_app_link::format; #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase", default)] pub struct Config { - /// Enrolled device tokens, hashes only -- a leaked config doesn't leak - /// the credential. A list (of one, today) so per-device tokens with - /// individual revocation are a config entry later, not a migration. + /// Enrolled device tokens, hashes only -- a leaked config doesn't leak the + /// credential. A list (of one, today) so per-device tokens with individual + /// revocation are a config entry later, not a migration. pub tokens: Vec, - /// Every machine this server can run something on, and what each of - /// them can run. See [`SetupConfig`]. pub setups: Vec, pub sessions: Vec, } /// A machine, and the things it can run. /// -/// This is the unit a session is spawned against: pick a setup, then one -/// of its providers. Grouping them this way is what stops the spawn -/// screen offering combinations that cannot work -- a provider only -/// exists on a machine where that program is installed, and the previous -/// model, which let any provider be paired with any host, offered the -/// whole cross-product including the impossible parts of it. +/// This is the unit a session is spawned against. Grouping providers under the +/// machine they exist on is what stops the spawn screen offering combinations +/// that cannot work; the previous model let any provider be paired with any +/// host and offered the whole cross-product. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SetupConfig { @@ -53,15 +46,12 @@ pub struct SetupConfig { /// renaming a machine on the phone does not orphan its sessions -- /// which is the whole reason the two are separate fields. pub id: String, - /// The label a person reads and may edit. pub name: String, - /// How to reach it, absent for this machine. A setup with no `ssh` is - /// where the server itself runs. + /// How to reach it, absent for this machine. #[serde(default, skip_serializing_if = "Option::is_none")] pub ssh: Option, /// What can be spawned here. Names are unique within a setup, and only - /// within it: two machines may each have a `claude-cli`, which is the - /// point. + /// within it: two machines may each have a `claude-cli`, which is the point. #[serde(default)] pub providers: Vec, } @@ -88,12 +78,10 @@ pub struct ProviderConfig { pub models: Vec, } -/// How to reach a setup that isn't this machine, with the system `ssh` -/// client -- so `~/.ssh/config`, agents, and jump hosts all keep working, -/// and there is one place to configure connections (PLAN.md, rule 23). -/// -/// A remote session is the identical command with `ssh host …` in front, -/// and nothing downstream of the spawn knows the difference. +/// How to reach a setup that isn't this machine, with the system `ssh` client +/// -- so `~/.ssh/config`, agents and jump hosts all keep working, and there is +/// one place to configure connections. A remote session is the identical +/// command with `ssh host …` in front, and nothing downstream knows. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SshConfig { @@ -107,59 +95,49 @@ pub struct SshConfig { #[serde(default, skip_serializing_if = "Vec::is_empty")] pub options: Vec, /// Where a file attached from the phone is put on this machine so the - /// session can read it. Absent means the session's own working - /// directory, or the login home for a session that has none. A `~` - /// prefix is the remote home. + /// session can read it. Absent means the session's own working directory, + /// or the login home for a session that has none. A `~` prefix is the + /// remote home. #[serde(default, skip_serializing_if = "Option::is_none")] pub attachments_dir: Option, } -/// Which translator runs a session. A new one is a new driver behind the -/// same trait -- never a branch in shared code. +/// Which translator runs a session. A new one is a new driver behind the same +/// trait -- never a branch in shared code. /// -/// Snake case, which is both Rust's and RON's: this is written into a -/// config a person edits by hand, and a hyphen is not a RON identifier, so -/// kebab case cost the file a `kind: r#claude-cli` escape to say a name -/// nobody would type that way. The same string is what the phone compares -/// against (`SpawnScreen.kt`), so the two move together. +/// Snake case, which is both Rust's and RON's: this is written into a config a +/// person edits by hand, and a hyphen is not a RON identifier, so kebab case +/// cost the file a `kind: r#claude-cli` escape. The same string is what the +/// phone compares against, so the two move together. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum DriverKind { - /// The phase-1 fake: echoes messages back as streamed events. Proves - /// the pipe (spawn, SSE, transcript cursors, questions) with no AI - /// involved, and stays useful as a connectivity check that costs no - /// tokens. Always available as a built-in provider. + /// The fake driver: echoes messages back as streamed events, proving the + /// pipe with no AI involved. Always available as a built-in provider. Echo, - /// A GGUF model served by llama.cpp's `llama-server` (see - /// `session::llama`). The model itself is one this machine has - /// downloaded; the provider's command is the server binary. + /// A GGUF model served by llama.cpp's `llama-server`. The model is one this + /// machine has downloaded; the provider's command is the server binary. LlamaCpp, - /// The Claude Code CLI over stream-json (see `session::claude`). - /// Named for the CLI specifically: bare "claude" would suggest the - /// credit-billed API, which this is not. + /// The Claude Code CLI over stream-json. Named for the CLI specifically: + /// bare "claude" would suggest the credit-billed API, which this is not. ClaudeCli, } impl DriverKind { - /// The longest edge, in pixels, an image should have when it reaches - /// this kind of session -- `None` where nothing here has a limit worth - /// enforcing. + /// The longest edge, in pixels, an image should have when it reaches this + /// kind of session -- `None` where nothing here has a limit worth enforcing. /// /// Reported to the phone rather than applied here, so the bytes are made - /// small before they cross the tunnel instead of after: a modern phone - /// photo is several megabytes and twelve megapixels, and every one of - /// those bytes was being uploaded over WireGuard only to be rejected at - /// the other end. What decides the number is the provider, which is why - /// it lives beside the kind rather than in the app -- a phone that knew - /// each provider's limits would be a second place to update when one - /// changes. + /// small before they cross the tunnel instead of after: a modern phone photo + /// is several megabytes, and every one of them was being uploaded over + /// WireGuard only to be rejected at the other end. What decides the number + /// is the provider, which is why it lives beside the kind rather than in the + /// app. /// - /// 1568 for the Claude CLI because that is the longest edge the API - /// itself resizes to; anything larger is charged the same and spends the - /// upload for nothing, and far larger is refused outright, which is what - /// "sending an image is broken" turned out to be. The others take images - /// through no path that cares, so they get no limit rather than a made-up - /// one. + /// 1568 for the Claude CLI because that is the longest edge the API itself + /// resizes to; anything larger is charged the same and spends the upload for + /// nothing, and far larger is refused outright -- which is what "sending an + /// image is broken" turned out to be. pub fn max_image_edge(self) -> Option { match self { DriverKind::ClaudeCli => Some(1568), @@ -167,21 +145,17 @@ impl DriverKind { } } - /// Whether the conversation exists outside this app, so that deleting - /// the session here does not end it. + /// Whether the conversation exists outside this app, so that deleting the + /// session here does not end it. /// - /// The Claude Code CLI owns its own transcript under - /// `~/.claude/projects/` and is resumable from it whatever started - /// it -- so a session this app spawned is every bit as recoverable as - /// one it imported, and the difference between those two is only how - /// it got here. Echo has nothing to keep, and a llama session's - /// conversation is folded out of *this* app's transcript, so for both - /// of those a delete is the end of it. + /// The Claude Code CLI owns its own transcript and is resumable from it + /// whatever started it, so a session this app spawned is every bit as + /// recoverable as one it imported. Echo has nothing to keep, and a llama + /// session's conversation is folded out of *this* app's transcript. /// - /// Asked before warning somebody that a deletion cannot be undone, - /// which is the one sentence that has to be true: said of a session - /// that can in fact be brought back, it spends the credibility the - /// warning needs on the sessions where it is real. + /// Asked before warning somebody that a deletion cannot be undone, which is + /// the one sentence that has to be true: said of a session that can in fact + /// be brought back, it spends the credibility the warning needs. pub fn keeps_own_transcript(self) -> bool { match self { Self::ClaudeCli => true, @@ -193,11 +167,9 @@ impl DriverKind { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TokenEntry { - /// Which device this token belongs to, for the human rotating it. pub name: String, - /// Hex SHA-256 of the token. A plain hash is enough: the token is 256 - /// bits from the OS CSPRNG, so there is nothing to dictionary-attack - /// and no stretching needed. + /// Hex SHA-256 of the token. A plain hash is enough: the token is 256 bits + /// from the OS CSPRNG, so there is nothing to dictionary-attack. pub sha256: String, } @@ -206,73 +178,56 @@ pub struct TokenEntry { pub struct SessionConfig { /// Stable identifier; names the session's directory and its routes. pub id: String, - /// Id of the [`SetupConfig`] this session runs on -- the id, not the - /// label, so the machine can be renamed without losing its sessions. + /// Id of the [`SetupConfig`] this session runs on -- the id, not the label, + /// so the machine can be renamed without losing its sessions. pub setup: String, - /// Name of the provider within that setup. Both stored by name rather - /// than resolved, so an edited setup (a new command path, another - /// model) takes effect on the next relaunch; a session whose setup or - /// provider is gone reports as exited and can still be deleted. + /// Name of the provider within that setup. Both stored by name rather than + /// resolved, so an edited setup takes effect on the next relaunch; a session + /// whose setup or provider is gone reports as exited and can still be + /// deleted. pub provider: String, pub title: String, #[serde(skip_serializing_if = "Option::is_none")] pub model: Option, - /// Working directory the session's process runs in. #[serde(skip_serializing_if = "Option::is_none")] pub cwd: Option, - /// Claude permission mode chosen at spawn. Meaningless for other - /// kinds, and kept as a string because it is passed straight to the - /// CLI's `--permission-mode` rather than interpreted here -- so the - /// CLI stays the one authority on which modes exist, and a new one - /// needs no change on this side. + /// Claude permission mode chosen at spawn. Kept as a string because it is + /// passed straight to `--permission-mode` rather than interpreted here, so + /// the CLI stays the one authority on which modes exist. #[serde(skip_serializing_if = "Option::is_none")] pub permission_mode: Option, /// Settings the driver interprets, chosen at spawn. /// - /// Deliberately untyped here: what a temperature or a context size - /// means is the driver's business, and giving this schema a field per - /// driver is how a shared model starts carrying one dialect's - /// vocabulary. `permission_mode` above predates this and should fold - /// into it. A map rather than a list so the phone can send exactly - /// what a person changed, and BTreeMap so the file's order is stable - /// across writes. + /// Deliberately untyped: what a temperature or a context size means is the + /// driver's business, and a field per driver is how a shared model starts + /// carrying one dialect's vocabulary. `permission_mode` above predates this + /// and should fold into it. BTreeMap so the file's order is stable. #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub params: BTreeMap, /// Whether a phone should be told when this session wants attention. /// /// Stored here rather than on the phone because it is a fact about the - /// session: one that runs unattended overnight should be quiet on - /// every device, and answering that question again on each new phone - /// is how two devices come to disagree about which sessions matter. + /// session: one that runs unattended overnight should be quiet on every + /// device. /// - /// Defaults to on, and on for a config written before this field - /// existed. The alternative -- silent unless asked -- makes the - /// feature invisible to anyone who does not go looking for it, and a - /// notification nobody wanted is turned off in one tap where one that - /// never arrived is not diagnosable at all. + /// Defaults to on. Silent-unless-asked makes the feature invisible to + /// anyone who does not go looking, and a notification nobody wanted is + /// turned off in one tap where one that never arrived is not diagnosable. #[serde(default = "notify_default")] pub notify: bool, - /// Whether this session's process is stopped when the server exits, - /// instead of being left running for the next start to adopt. + /// Whether this session's process is stopped when the server exits, instead + /// of being left running for the next start to adopt. /// - /// A fact about the session rather than about the run that spawned it, - /// which is why it is persisted: whichever server is running when the - /// time comes is the one that has to act on it, and a session nobody - /// meant to keep should not depend on the same server still being up - /// to clean it away. + /// A fact about the session rather than about the run that spawned it, which + /// is why it is persisted: whichever server is running when the time comes + /// is the one that has to act on it. /// - /// Written by a server started with `--throwaway-sessions`, which is - /// the default in a debug build. A session spawned while testing is - /// one nobody means to keep, and under the ordinary rule its `claude` - /// outlives every server that ever knew about it -- twelve of them - /// accumulated on this machine in a day, each holding a conversation - /// open. - /// - /// Absent means false: every session written before this existed, and - /// every one spawned by a release build. + /// Written by a server started with `--throwaway-sessions`, the default in a + /// debug build. Under the ordinary rule a test session's `claude` outlives + /// every server that ever knew about it -- twelve accumulated on this + /// machine in a day. Absent means false. #[serde(default, skip_serializing_if = "not_set")] pub throwaway: bool, - /// Epoch seconds when the session was spawned. pub created: f64, } @@ -281,29 +236,25 @@ fn notify_default() -> bool { } /// Keeps the ordinary case out of the file entirely -- see -/// [`SessionConfig::throwaway`], which is false for every session a -/// production build writes. +/// [`SessionConfig::throwaway`]. fn not_set(flag: &bool) -> bool { !*flag } -/// The name of the echo provider, and of the setup this machine gets on -/// first run. +/// The name of the echo provider, and of the setup this machine gets on first +/// run. /// -/// Echo is seeded into the config rather than conjured at read time the -/// way it used to be. An implicit provider is one a person cannot see in -/// the file or edit from the phone, and the point of this app is that -/// configuration is visible and editable; if somebody deletes it, that was -/// a choice. +/// Echo is seeded into the config rather than conjured at read time. An +/// implicit provider is one a person cannot see in the file or edit from the +/// phone; if somebody deletes it, that was a choice. pub const ECHO_PROVIDER: &str = "echo"; pub const LOCAL_SETUP: &str = "this machine"; -/// The id of the setup a fresh install seeds. Fixed rather than random so -/// a hand-written config can name it without looking one up. +/// The id of the setup a fresh install seeds. Fixed rather than random so a +/// hand-written config can name it without looking one up. pub const LOCAL_SETUP_ID: &str = "local"; -/// Where `ai-server --enroll-link` leaves a token for the running server -/// to adopt: beside the config, since it is config in transit. See -/// `wg_app_link::enroll::spool_pending`. +/// Where `ai-server --enroll-link` leaves a token for the running server to +/// adopt: beside the config, since it is config in transit. pub fn pending_enrollments_dir(config_path: &Path) -> PathBuf { config_path.with_file_name("pending-enrollments") } @@ -313,22 +264,19 @@ impl Config { self.setups.iter().find(|setup| setup.id == id) } - /// A setup by the label a person sees, for messages and for the one - /// place a name still arrives from outside: nothing else should look - /// one up this way, since labels are editable and ids are not. + /// A setup by the label a person sees, for messages and for the one place a + /// name still arrives from outside. Nothing else should look one up this + /// way, since labels are editable and ids are not. pub fn setup_named(&self, name: &str) -> Option<&SetupConfig> { self.setups.iter().find(|setup| setup.name == name) } /// This machine, offering whatever was found on it. /// - /// The providers are passed in rather than written here because they - /// have to be *discovered*: a hardcoded list is a claim about what is - /// installed, and this one was wrong -- every fresh install asserted a - /// `claude-cli` provider whether or not `claude` existed, which on a - /// machine without it is a spawn option that cannot work and a - /// statement the server never checked. Providers are discovered by - /// asking the machine, here exactly as for any other setup. + /// The providers are passed in rather than written here because they have to + /// be *discovered*: a hardcoded list is a claim about what is installed, and + /// this one was wrong -- every fresh install asserted a `claude-cli` + /// provider whether or not `claude` existed. pub fn seed(providers: Vec) -> SetupConfig { SetupConfig { id: LOCAL_SETUP_ID.to_string(), @@ -338,12 +286,9 @@ impl Config { } } - /// The one provider that needs no discovery, and the floor to fall - /// back to when discovery itself fails. - /// - /// Echo runs in-process, so it exists exactly where this server does - /// and nowhere else -- there is nothing to probe for, and offering it - /// on a remote machine would be a choice that changes nothing. + /// The one provider that needs no discovery, and the floor to fall back to + /// when discovery itself fails. Echo runs in-process, so it exists exactly + /// where this server does and nowhere else. pub fn echo_provider() -> ProviderConfig { ProviderConfig { name: ECHO_PROVIDER.to_string(), @@ -357,8 +302,8 @@ impl Config { match std::fs::read_to_string(path) { Ok(text) => format::parse(&text) .with_context(|| format!("{} is not valid config RON", path.display())), - // A first run has no config -- the normal starting state; a - // token is generated and saved on that first start. + // A first run has no config -- the normal starting state; a token is + // generated and saved on that first start. Err(err) if err.kind() == std::io::ErrorKind::NotFound => { warn_about_a_config_left_behind(path); Ok(Self::default()) @@ -369,12 +314,10 @@ impl Config { /// Writes the config, owner-readable only. /// - /// The token hashes here are verifiers, not secrets -- a 256-bit - /// random token can't be recovered from its SHA-256 -- but the file - /// also names every host this backend can reach and every session it - /// is running, which is nobody else's business on a shared machine. - /// The mode is set on the temporary file *before* the rename, so the - /// config is never briefly world-readable at its real path. + /// The token hashes here are verifiers rather than secrets, but the file + /// also names every host this backend can reach and every session it is + /// running. The mode is set on the temporary file *before* the rename, so + /// the config is never briefly world-readable at its real path. pub fn save(&self, path: &Path) -> Result<()> { format::write(path, self) } diff --git a/server/src/files.rs b/server/src/files.rs index 88056df..1affb2b 100644 --- a/server/src/files.rs +++ b/server/src/files.rs @@ -1,71 +1,58 @@ //! Reading and changing files on the machine a setup names. //! -//! Every operation here is one small POSIX shell script handed to -//! `Transport`, exactly the way `setups::discover` and `import::list` -//! already ask a machine a question. That is what makes the local and the -//! ssh case one implementation: a second one written against `std::fs` -//! would be the one that gets tested, and the remote half -- the ordering -//! of entries, what a symlink reports, how a permission error reads -- -//! would drift until it shipped broken. The cost is an `sh` process per -//! operation on this machine, which is under a millisecond. +//! Every operation here is one small POSIX shell script handed to `Transport`, +//! the way `setups::discover` and `import::list` already ask a machine a +//! question. That is what makes the local and the ssh case one implementation: +//! a second one written against `std::fs` would be the one that gets tested, +//! and the remote half -- the ordering of entries, what a symlink reports, how +//! a permission error reads -- would drift until it shipped broken. The cost is +//! an `sh` process per operation here, which is under a millisecond. //! -//! The scripts assume GNU coreutils and findutils (`find -printf`, -//! `stat -c`, `sha256sum`, `chmod --reference`), which is what -//! `session::import` already assumes and what both machines here run. One -//! without them fails with that tool's own message, which names what is -//! missing. +//! The scripts assume GNU coreutils and findutils, which is what +//! `session::import` already assumes. A machine without them fails with that +//! tool's own message, which names what is missing. //! -//! **The phone names a path, and that is deliberate** -- see PLAN.md's -//! Security section. The enrolled token already spawns an agent in any -//! directory on any machine a setup names, and that agent reads and writes -//! every file its user can; this is a shorter path to authority the token -//! already holds. What is *not* given up: no route here accepts a command. -//! Listing, reading and writing are the fixed scripts below, and the phone -//! chooses only the path and the bytes. +//! **The phone names a path, and that is deliberate** -- see PLAN.md's Security +//! section. What is *not* given up: no route here accepts a command. Listing, +//! reading and writing are the fixed scripts below, and the phone chooses only +//! the path and the bytes. use anyhow::{Context, Result}; use serde::Serialize; use crate::session::transport::{Input, Launch, Transport}; -/// The most of a file that crosses the tunnel, in bytes. -/// -/// Checked on the far machine before anything reads the file, so a 2 GB -/// log costs a `stat` rather than a transfer. A file over it is reported -/// as [`FileRead::TooBig`] with its size, because "we did not read this" -/// and "this is empty" must not look the same on the phone. +/// The most of a file that crosses the tunnel, in bytes. Checked on the far +/// machine before anything reads the file, so a 2 GB log costs a `stat` rather +/// than a transfer. A file over it is [`FileRead::TooBig`] with its size, +/// because "we did not read this" and "this is empty" must not look the same. pub const FILE_LIMIT: u64 = 1024 * 1024; -/// The prelude every script here starts with: the path arrives as `$1`, -/// and this is where a leading `~` becomes that machine's own home. +/// The prelude every script here starts with: the path arrives as `$1`, and +/// this is where a leading `~` becomes that machine's own home. /// -/// The path is a **positional argument** and never text spliced into the -/// script -- the rule `import::find` follows with `"$1"`, for the reason -/// `ssh::quote` exists: a path is attacker-adjacent input in a server -/// whose job is running commands, and interpolated it would be syntax -/// rather than data. +/// The path is a **positional argument** and never text spliced into the script +/// -- the rule `import::find` follows, for the reason `ssh::quote` exists: a +/// path is attacker-adjacent input in a server whose job is running commands, +/// and interpolated it would be syntax rather than data. /// /// `~` is the one character that costs something for it. A shell expands a -/// tilde in *text*, so a path handed over as an argument arrives with a -/// literal one; expanding it here, once, gives it the same meaning -/// `ssh::quote_path` and `ssh::expand_home` give it everywhere else, and -/// it is the *far* machine's `$HOME` -- the only one that could be right. -/// `~user` stays literal here too, and fails with the shell's own message. +/// tilde in *text*, so a path handed over as an argument arrives with a literal +/// one; expanding it here gives it the same meaning `ssh::quote_path` gives it +/// everywhere else, and it is the *far* machine's `$HOME`. `~user` stays +/// literal and fails with the shell's own message. /// -/// Everything below uses `$p` for the path and `$2` for whatever else it -/// was given. +/// Everything below uses `$p` for the path and `$2` for whatever else. const PATH_PRELUDE: &str = r#"p=$1; case $p in "~") p=$HOME;; "~/"*) p=$HOME/${p#"~/"};; esac; "#; /// What a directory turned out to be, and what is in it. #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct Listing { - /// `pwd -P` of the directory that was listed. - /// - /// Answered by the machine rather than worked out here, so the phone - /// navigates on a resolved absolute path: the parent of one of these - /// is a string operation, and a `~` a session was spawned with is - /// shown as what it turned out to be. + /// `pwd -P` of the directory that was listed. Answered by the machine + /// rather than worked out here, so the phone navigates on a resolved + /// absolute path: the parent of one of these is a string operation, and a + /// `~` a session was spawned with is shown as what it turned out to be. pub path: String, pub entries: Vec, } @@ -74,14 +61,13 @@ pub struct Listing { #[serde(rename_all = "camelCase")] pub struct Entry { pub name: String, - /// What tapping it does, which for a symlink is decided by its - /// *target* -- a link to a directory navigates. + /// What tapping it does, which for a symlink is decided by its *target* -- + /// a link to a directory navigates. pub kind: EntryKind, pub size: u64, - /// Seconds since the epoch. pub modified: i64, - /// Whether the entry itself is a symlink, whatever [`Entry::kind`] - /// says its target is. + /// Whether the entry itself is a symlink, whatever [`Entry::kind`] says + /// its target is. pub link: bool, } @@ -90,18 +76,18 @@ pub struct Entry { pub enum EntryKind { Directory, File, - /// A socket, a device, a fifo -- and a symlink whose target is missing - /// or loops, which `find` reports the same way. Shown, because a - /// directory that hid what it held would be lying about being empty. + /// A socket, a device, a fifo -- and a symlink whose target is missing or + /// loops, which `find` reports the same way. Shown, because a directory + /// that hid what it held would be lying about being empty. Other, } /// What reading a file produced -- four answers, not content-or-error. /// -/// A binary file drawn as text and a big file cut off silently are both -/// wrong in ways the reader cannot see, and "couldn't read it" must not -/// look like "it is empty". A file that is genuinely empty is -/// [`FileRead::Text`] with nothing in it, which is what it is. +/// A binary file drawn as text and a big file cut off silently are both wrong +/// in ways the reader cannot see, and "couldn't read it" must not look like +/// "it is empty". A genuinely empty file is [`FileRead::Text`] with nothing in +/// it, which is what it is. #[derive(Debug, Serialize)] #[serde(tag = "kind", rename_all = "camelCase")] pub enum FileRead { @@ -114,8 +100,8 @@ pub enum FileRead { }, /// Not UTF-8. Its size is reported; nothing is shown. Binary { size: u64, modified: i64 }, - /// Over [`FILE_LIMIT`]. Its size is reported, so the reader knows what - /// they are looking at rather than only that they cannot have it. + /// Over [`FILE_LIMIT`]. Its size is reported, so the reader knows what they + /// are looking at rather than only that they cannot have it. TooBig { size: u64, modified: i64 }, } @@ -129,18 +115,16 @@ pub struct Written { pub sha256: String, } -/// The exit code the write script uses for "this is not the file you -/// read", which the route turns into a 409. Distinct from every other -/// failure, which is a message from the machine. +/// The exit code the write script uses for "this is not the file you read", +/// which the route turns into a 409. Distinct from every other failure, which +/// is a message from the machine. pub const STALE: i32 = 3; /// A path the phone may name: absolute, or home-relative on that machine. /// -/// Shared with `POST /sessions/{id}/cwd`, which asks the same question for -/// the same reason -- a relative path is relative to something nobody -/// looking at the screen can see, so it is refused rather than resolved -/// against a guess. Returns the path with the whitespace a phone keyboard -/// adds taken off. +/// Shared with `POST /sessions/{id}/cwd`, which asks the same question for the +/// same reason -- a relative path is relative to something nobody looking at +/// the screen can see, so it is refused rather than resolved against a guess. pub fn check_path(path: &str) -> Result { let path = path.trim(); if path.is_empty() { @@ -160,8 +144,8 @@ fn launch(script: String, path: &str, extra: Option<&str>) -> Launch { let mut args = vec![ "-c".to_string(), script, - // `$0`, which is what `sh` names itself in a message about the - // script; the path is `$1`. + // `$0`, which is what `sh` names itself in a message about the script; + // the path is `$1`. "sh".to_string(), path.to_string(), ]; @@ -169,11 +153,10 @@ fn launch(script: String, path: &str, extra: Option<&str>) -> Launch { Launch::new("sh", args, None) } -/// Everything in `path`, and what `path` resolved to. -/// -/// Entries are separated by `\0` and their fields by `\t`, so a filename -/// with a newline or a tab in it survives -- both are legal, and a listing -/// that lost one would quietly show the wrong thing. +/// Everything in `path`, and what `path` resolved to. Entries are separated by +/// `\0` and their fields by `\t`, so a filename with a newline or a tab in it +/// survives -- both are legal, and a listing that lost one would quietly show +/// the wrong thing. pub async fn list(transport: &Transport, path: &str) -> Result { let script = format!( "{PATH_PRELUDE}cd -- \"$p\" && pwd -P && \ @@ -193,11 +176,9 @@ pub async fn list(transport: &Transport, path: &str) -> Result { }) } -/// The `find` output above, as rows. -/// -/// A record that does not have all five fields is dropped rather than -/// guessed at: it can only come from a `find` that printed something else, -/// and half a row is worse than no row. +/// The `find` output above, as rows. A record without all five fields is +/// dropped rather than guessed at: it can only come from a `find` that printed +/// something else, and half a row is worse than no row. fn parse_entries(text: &str) -> Vec { text.split('\0') .filter(|record| !record.is_empty()) @@ -208,8 +189,7 @@ fn parse_entries(text: &str) -> Vec { let own = fields.next()?; let target = fields.next()?; let size = fields.next()?.parse().ok()?; - // `%T@` is seconds with a fractional part; the phone shows a - // date, so the fraction is dropped rather than carried. + // `%T@` is seconds with a fractional part; the phone shows a date. let modified = fields.next()?.split('.').next()?.parse().ok()?; let name = fields.next()?; Some(Entry { @@ -229,14 +209,13 @@ fn parse_entries(text: &str) -> Vec { /// One file's content, or the reason there is none to show. /// -/// The size is checked on the far machine *before* anything reads the -/// file, so a file over [`FILE_LIMIT`] costs a `stat` rather than a -/// transfer. `stat -L` and `sha256sum` both follow symlinks, as `cat` -/// does, so a link to a file reports the file. +/// The size is checked on the far machine *before* anything reads the file, so +/// a file over [`FILE_LIMIT`] costs a `stat` rather than a transfer. `stat -L` +/// and `sha256sum` both follow symlinks, as `cat` does. pub async fn read(transport: &Transport, path: &str) -> Result { - // Two header lines, then the bytes: ` `, then either - // `tooBig` or the digest. A header rather than a JSON envelope because - // the content is bytes and may not be text at all. + // Two header lines, then the bytes: ` `, then either `tooBig` + // or the digest. A header rather than a JSON envelope because the content + // is bytes and may not be text at all. let script = format!( "{PATH_PRELUDE}set -e; \ h=$(stat -L -c '%s %Y' -- \"$p\"); \ @@ -281,24 +260,21 @@ fn split_read(out: &[u8]) -> Result<(u64, i64, &str, &[u8])> { )) } -/// Replaces `path`'s contents, but only while it still hashes to -/// `expected`. +/// Replaces `path`'s contents, but only while it still hashes to `expected`. /// -/// Agents edit files while people read them, so a stale copy landing on -/// top of somebody else's edit is the common case rather than the exotic -/// one. The digest the reader was shown is compared on the machine, and a -/// file that has moved on comes back as [`STALE`] rather than being -/// overwritten. +/// Agents edit files while people read them, so a stale copy landing on top of +/// somebody else's edit is the common case rather than the exotic one. The +/// digest the reader was shown is compared on the machine, and a file that has +/// moved on comes back as [`STALE`] rather than being overwritten. /// -/// A temp file and a rename, so a connection dropped mid-write leaves the -/// old file whole rather than a truncated one, and `chmod --reference` so -/// the mode survives -- an executable script written as a fresh file would -/// stop being one. What that trades away: the inode changes, so a hard -/// link elsewhere stops being the same file. Editors do the same. +/// A temp file and a rename, so a connection dropped mid-write leaves the old +/// file whole, and `chmod --reference` so the mode survives -- an executable +/// script written as a fresh file would stop being one. What that trades away: +/// the inode changes, so a hard link elsewhere stops being the same file. /// -/// The check and the write are **not** atomic against a writer landing -/// between them -- a window of microseconds on that machine. Accepted: the -/// alternative is a lock this has no way to make every other writer take. +/// The check and the write are **not** atomic against a writer landing between +/// them -- a window of microseconds on that machine. Accepted: the alternative +/// is a lock this has no way to make every other writer take. pub async fn write( transport: &Transport, path: &str, @@ -334,17 +310,16 @@ pub async fn write( })) } -/// The file is not the one that was read. Its own type rather than an -/// error string, because the route answers it with a different status and -/// the phone with a different question. +/// The file is not the one that was read. Its own type rather than an error +/// string, because the route answers it with a different status and the phone +/// with a different question. #[derive(Debug)] pub struct Stale; /// Creates an empty file, refusing to truncate one that is already there. -/// -/// `set -C` is the shell's own noclobber, so an existing name fails with -/// the shell's own message rather than with a check that could race the -/// redirection it is guarding. +/// `set -C` is the shell's own noclobber, so an existing name fails with the +/// shell's own message rather than with a check that could race the redirection +/// it is guarding. pub async fn create_file(transport: &Transport, path: &str) -> Result<()> { let script = format!("{PATH_PRELUDE}set -C; : > \"$p\""); transport @@ -354,9 +329,9 @@ pub async fn create_file(transport: &Transport, path: &str) -> Result<()> { Ok(()) } -/// Creates a directory. Plain `mkdir`, not `-p`, for the same reason -/// [`create_file`] sets noclobber: a name that exists is something the -/// person typing it should be told about. +/// Creates a directory. Plain `mkdir`, not `-p`, for the reason +/// [`create_file`] sets noclobber: a name that exists is something the person +/// typing it should be told about. pub async fn create_dir(transport: &Transport, path: &str) -> Result<()> { let script = format!("{PATH_PRELUDE}mkdir -- \"$p\""); transport @@ -376,8 +351,8 @@ fn text(captured: crate::session::transport::Captured) -> Result { mod tests { use super::*; - /// The names a listing has to survive. All four are legal, and each - /// one broke a listing somewhere before it was separated with `\0`. + /// The names a listing has to survive. All four are legal, and each one + /// broke a listing somewhere before it was separated with `\0`. #[test] fn a_listing_survives_the_names_a_filesystem_allows() { let record = |own: &str, target: &str, size: &str, time: &str, name: &str| { @@ -406,8 +381,8 @@ mod tests { assert_eq!(entries[0].modified, 1756900000); assert_eq!(entries[2].kind, EntryKind::Directory); assert_eq!(entries[2].size, 4096); - // The kind is the target's, so a link to a directory navigates -- - // and one whose target is gone is neither a file nor a directory. + // The kind is the target's, so a link to a directory navigates -- and + // one whose target is gone is neither a file nor a directory. assert!(entries[3].link); assert_eq!(entries[3].kind, EntryKind::Directory); assert_eq!(entries[4].kind, EntryKind::Other); @@ -431,9 +406,9 @@ mod tests { assert!(refused.contains("start it with / or ~"), "{refused}"); } - /// The scripts, against a real tree, through the transport that runs - /// them here -- which is cheap, because `sh` is wherever `cargo test` - /// is. The remote transport runs the identical text. + /// The scripts, against a real tree, through the transport that runs them + /// here -- cheap, because `sh` is wherever `cargo test` is. The remote + /// transport runs the identical text. fn tree() -> tempfile::TempDir { let dir = tempfile::tempdir().unwrap(); std::fs::write(dir.path().join("hello.txt"), "one\ntwo\n").unwrap(); @@ -454,8 +429,7 @@ mod tests { .await .unwrap(); // `pwd -P`, so a temp directory reached through a symlinked /tmp - // answers with what it really is -- which is the path the phone - // then navigates on. + // answers with what it really is. assert!(listing.path.starts_with('/'), "{}", listing.path); let mut names: Vec<&str> = listing.entries.iter().map(|e| e.name.as_str()).collect(); names.sort_unstable(); @@ -498,8 +472,8 @@ mod tests { std::fs::write(dir.path().join("big"), vec![b'x'; FILE_LIMIT as usize + 1]).unwrap(); assert!(matches!(read_at("big").await, FileRead::TooBig { .. })); - // Empty is text with nothing in it, which is what it is -- not a - // fourth state and not the same as any of the three above. + // Empty is text with nothing in it -- not a fourth state, and not the + // same as any of the three above. std::fs::write(dir.path().join("empty"), "").unwrap(); assert!(matches!( read_at("empty").await, @@ -592,9 +566,9 @@ mod tests { ); } - /// A path that tries to close the quote and start a command of its - /// own. It is an argument rather than syntax, so it stays one absurd - /// filename -- the same property `ssh.rs` tests for the remote side. + /// A path that tries to close the quote and start a command of its own. It + /// is an argument rather than syntax, so it stays one absurd filename -- the + /// same property `ssh.rs` tests for the remote side. #[tokio::test] async fn a_path_full_of_shell_crosses_as_data() { let dir = tree(); @@ -614,8 +588,8 @@ mod tests { ); } - /// The tilde is the one character the prelude gives a meaning, and it - /// is the *machine's* home -- here, this one. + /// The tilde is the one character the prelude gives a meaning, and it is the + /// *machine's* home -- here, this one. #[tokio::test] async fn a_leading_tilde_means_the_machine_s_own_home() { let Some(home) = std::env::home_dir() else { diff --git a/server/src/main.rs b/server/src/main.rs index f85d4f5..fb6f479 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -1,14 +1,12 @@ -//! A phone interface to AI coding sessions -- the backend. See PLAN.md for -//! the whole picture; this is the entry point: config + session registry, -//! token bootstrap, and the one TLS listener. +//! A phone interface to AI coding sessions -- the backend. See PLAN.md for the +//! whole picture; this is the entry point: config + session registry, token +//! bootstrap, and the one TLS listener. //! -//! The listener binds the WireGuard interface's address only, and fails -//! closed -- if `wg0` is down the server refuses to start rather than -//! falling back to `0.0.0.0`, because this API *is* remote code execution -//! and the tunnel is what keeps its pre-auth surface (TLS handshake, HTTP -//! parsing, auth middleware) off the open internet. `--bind` overrides -//! explicitly for development; that is a deliberate, logged choice, never a -//! fallback. +//! The listener binds the WireGuard interface's address only, and fails closed +//! -- if `wg0` is down the server refuses to start rather than falling back to +//! `0.0.0.0`, because this API *is* remote code execution and the tunnel is +//! what keeps its pre-auth surface off the open internet. `--bind` overrides +//! explicitly for development; a deliberate, logged choice, never a fallback. //! //! There is no plaintext listener at all, so the bearer token can't travel //! unencrypted by misconfiguration -- even inside the tunnel. @@ -50,10 +48,9 @@ struct Args { #[arg(long, default_value_t = DEFAULT_PORT)] port: u16, - /// Address to bind instead of the wg0 interface's -- a development - /// override (e.g. 127.0.0.1 for curl, or a LAN address for a phone - /// before the tunnel exists). Production runs without it and fails - /// closed when wg0 is absent. + /// Address to bind instead of the wg0 interface's -- a development override + /// (127.0.0.1 for curl, or a LAN address for a phone before the tunnel + /// exists). Production runs without it and fails closed when wg0 is absent. #[arg(long)] bind: Option, @@ -83,44 +80,34 @@ struct Args { rotate_token: bool, /// Enroll one more device without touching the running server: mint a - /// token, print its enrollment link (one line, stdout, nothing else) - /// and exit. The server adopts the token the first time that device - /// uses it. For a tool -- Dev Updater -- that opens the link on the - /// phone, where a QR printed here cannot be scanned. + /// token, print its enrollment link (one line, stdout, nothing else) and + /// exit. The server adopts the token the first time that device uses it. + /// For a tool that opens the link on the phone, where a QR printed here + /// cannot be scanned. #[arg(long)] enroll_link: bool, /// Hold every response back by this many milliseconds. /// - /// A development aid, and a specific one: over the tunnel a phone's - /// requests take tens to hundreds of milliseconds, and several faults - /// live entirely in what the app does *while* one is outstanding -- - /// a page of history landing mid-fling, a screen drawn before its - /// first answer arrives. On a loopback server every response is back - /// within a millisecond or two, so those windows close before - /// anything can be observed and the bug looks like it is not there. - /// This reopens them on demand rather than by unplugging something. + /// A development aid, and a specific one: over the tunnel a phone's requests + /// take tens to hundreds of milliseconds, and several faults live entirely + /// in what the app does *while* one is outstanding. On a loopback server + /// those windows close before anything can be observed and the bug looks + /// like it is not there. #[arg(long, default_value_t = 0, value_name = "MS")] delay: u64, - /// Mark every session spawned here as throwaway: its process is - /// stopped when this server exits, instead of being left running for - /// the next start to adopt. On by default in a debug build. + /// Mark every session spawned here as throwaway: its process is stopped + /// when this server exits, instead of being left running for the next start + /// to adopt. On by default in a debug build. /// - /// Sessions outlive the backend on purpose, which is right for the - /// ones somebody is using and wrong for the ones a test made: a - /// session spawned to check something leaves a `claude` behind that - /// every later server adopts, and they accumulate silently -- twelve - /// of them on this machine in a day, each holding a conversation open. - /// So a development build cleans up after itself unless told not to - /// (`--throwaway-sessions=false`), and a release build never does - /// unless asked. + /// Sessions outlive the backend on purpose, which is right for the ones + /// somebody is using and wrong for the ones a test made -- twelve of those + /// accumulated on this machine in a day, each holding a conversation open. /// - /// The flag decides only what *new* sessions are marked as. What - /// happens on the way out is decided by the mark, which is written - /// into the session and outlives the server that made it -- so - /// sessions spawned without it keep running, whichever server is up - /// when one exits. + /// The flag decides only what *new* sessions are marked as. What happens on + /// the way out is decided by the mark, which outlives the server that made + /// it. #[arg( long, default_value_t = cfg!(debug_assertions), @@ -134,18 +121,16 @@ struct Args { #[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. + // 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. rustls::crypto::aws_lc_rs::default_provider() .install_default() .expect("no other TLS crypto provider is installed before main"); - // `info` unless RUST_LOG says otherwise. Written as a *fallback* rather than as the filter, - // because `with_env_filter("info")` is a fixed directive that never reads the environment -- - // so the per-request diagnostics that AGENTS.md tells you to turn on with - // `RUST_LOG=ai_server=debug` printed nothing, and the switch looked like the code it was - // meant to instrument being wrong. + // `info` unless RUST_LOG says otherwise. Written as a *fallback* rather than + // as the filter, because `with_env_filter("info")` is a fixed directive that + // never reads the environment -- so `RUST_LOG=ai_server=debug` printed + // nothing, and the switch looked like the code it was meant to instrument. tracing_subscriber::fmt() .with_env_filter( tracing_subscriber::EnvFilter::try_from_default_env() @@ -157,11 +142,10 @@ async fn main() -> Result<()> { let config_path = args .config .unwrap_or_else(|| config_home("ai-app").join("config.ron")); - // Before the manager exists, on purpose: constructing it and seeding - // setups touches sessions and subprocesses this invocation has no - // business with while another instance is serving. Only the hash - // reaches disk, in the spool `auth.rs` reads; the link itself goes to - // stdout alone, because the caller opens whatever this prints. + // Before the manager exists, on purpose: constructing it and seeding setups + // touches sessions and subprocesses this invocation has no business with + // while another instance is serving. Only the hash reaches disk, in the + // spool `auth.rs` reads; the link goes to stdout alone. if args.enroll_link { let bind_ip = match args.bind { Some(ip) => ip, @@ -182,9 +166,9 @@ async fn main() -> Result<()> { let data_dir = args .data_dir .unwrap_or_else(|| data_home("ai-app").join("sessions")); - // Beside the session data rather than under it: models outlive every - // session and are shared by all of them, so deleting a session must - // never take a multi-gigabyte download with it. + // Beside the session data rather than under it: models outlive every session + // and are shared by all of them, so deleting a session must never take a + // multi-gigabyte download with it. let models_dir = args .models_dir .unwrap_or_else(|| data_home("ai-app").join("models")); @@ -200,9 +184,9 @@ async fn main() -> Result<()> { server exits rather than left running (--throwaway-sessions=false to keep them)" ); } - // After construction rather than inside it: seeding asks this machine - // what it has, which is I/O, and a constructor that quietly runs a - // subprocess is a surprise to every caller including the tests. + // After construction rather than inside it: seeding asks this machine what + // it has, and a constructor that quietly runs a subprocess is a surprise to + // every caller including the tests. manager.seed_setup().await?; tracing::info!("config: {}", config_path.display()); @@ -210,9 +194,9 @@ async fn main() -> Result<()> { for setup in manager.setups() { match &setup.ssh { Some(ssh) => tracing::info!(" setup \"{}\" -> {}", setup.name, ssh.address), - // No parenthetical naming the local machine: the default - // setup is *called* "this machine", and the line read - // "setup this machine (this machine)". + // No parenthetical naming the local machine: the default setup is + // *called* "this machine", and the line read "setup this machine + // (this machine)". None => tracing::info!(" setup \"{}\" runs here", setup.name), } for provider in &setup.providers { @@ -228,10 +212,9 @@ async fn main() -> Result<()> { ); } - // Before the interface check below, deliberately: the certificates are - // also what the phone app embeds at build time, so they need to be - // obtainable on a machine whose tunnel isn't up yet. The leaf is - // reissued on every start, so once wg0 exists the next start covers it. + // Before the interface check below, deliberately: the certificates are also + // what the phone app embeds at build time, so they need to be obtainable on + // a machine whose tunnel isn't up yet. The leaf is reissued on every start. let certs_dir = args .certs .unwrap_or_else(|| config_home("ai-app").join("certs")); @@ -256,9 +239,8 @@ async fn main() -> Result<()> { None => netif::wg_address("ai-server")?, }; - // Token bootstrap: first run generates one; --rotate-token replaces - // whatever exists. Either way the plaintext appears exactly once, in - // the QR printed here. + // Token bootstrap: first run generates one; --rotate-token replaces whatever + // exists. Either way the plaintext appears exactly once, in the QR. if args.rotate_token || manager.tokens().is_empty() { let rotating = args.rotate_token && !manager.tokens().is_empty(); let token = enroll::generate_token(); @@ -279,16 +261,13 @@ async fn main() -> Result<()> { .await .context("failed to load TLS cert/key")?; - // No providers listed here any more: which machines can be asked, and - // about what, comes from the setups at the moment the screen is opened - // -- so a machine added from the phone reports its limits without a - // restart, and the backend's own account stops standing in for every - // machine's. + // No providers listed here any more: which machines can be asked, and about + // what, comes from the setups at the moment the screen is opened -- so a + // machine added from the phone reports its limits without a restart. let monitor = Arc::new(usage::UsageMonitor::new()); - // 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. + // The bearer-token middleware wraps the entire router -- routes and fallback + // alike -- here and only here, so a new route can't forget auth. let app = routes::router(Arc::clone(&manager)) .merge(routes::usage_router(monitor, Arc::clone(&manager))) .merge(routes::models_router(Arc::clone(&models))) @@ -297,9 +276,9 @@ async fn main() -> Result<()> { auth::require_token, )); - // Outside the auth layer, so an unauthenticated request is refused at - // the speed it always was: this is here to slow the app down, not to - // widen the window on anything guessing at tokens. + // Outside the auth layer, so an unauthenticated request is refused at the + // speed it always was: this is here to slow the app down, not to widen the + // window on anything guessing at tokens. let app = match args.delay { 0 => app, ms => { @@ -316,13 +295,11 @@ async fn main() -> Result<()> { let addr = SocketAddr::new(bind_ip, args.port); tracing::info!("serving https://{addr}"); - // Let go of the sessions on the way out rather than stopping them: - // their processes are meant to outlive this one, so restarting the - // backend does not end a turn somebody is waiting on. Each is recorded - // in its session directory and adopted again on the way back up (see - // `session::process`). The exception is the sessions marked throwaway, - // which are stopped first -- see `--throwaway-sessions`. Both signals, - // because systemd and OpenRC send TERM while a terminal sends INT. + // Let go of the sessions on the way out rather than stopping them: their + // processes are meant to outlive this one. Each is recorded in its session + // directory and adopted again on the way back up. The exception is the + // sessions marked throwaway, which are stopped first. Both signals, because + // systemd and OpenRC send TERM while a terminal sends INT. let serving = axum_server::bind_rustls(addr, tls_config) .serve(app.into_make_service_with_connect_info::()); let mut terminate = signal(SignalKind::terminate()).context("listening for SIGTERM")?; @@ -331,9 +308,9 @@ async fn main() -> Result<()> { _ = terminate.recv() => tracing::info!("SIGTERM -- letting go of sessions"), _ = tokio::signal::ctrl_c() => tracing::info!("interrupted -- letting go of sessions"), } - // Stopped before the rest are let go of, and on every way out of the - // select above: a throwaway session is one nobody meant to keep, and - // the whole point is that nothing has to remember to clean it up. + // Stopped before the rest are let go of, and on every way out of the select + // above: a throwaway session is one nobody meant to keep, and the whole point + // is that nothing has to remember to clean it up. manager.stop_throwaway_sessions(); manager.detach_all(); diff --git a/server/src/models.rs b/server/src/models.rs index 6f3ae7a..ccbbdf1 100644 --- a/server/src/models.rs +++ b/server/src/models.rs @@ -1,28 +1,22 @@ //! GGUF models on this machine, and the downloads that produce them. //! -//! The registry pattern again (see `session`): one owner, one lock, so what -//! is on disk and what this server believes cannot come apart. -//! -//! Three things shape the design, all of them consequences of a model file -//! being gigabytes rather than kilobytes: +//! The registry pattern again: one owner, one lock, so what is on disk and what +//! this server believes cannot come apart. Three things shape the design, all +//! consequences of a model file being gigabytes rather than kilobytes: //! //! **A download belongs to the model, not to whoever asked for it.** It is -//! keyed by the model it produces and lives here, so any device can watch -//! it -- including one that did not start it, and one that opened the app -//! after it finished. State in a per-connection channel would not survive -//! the phone locking its screen, which for an hour-long download is the -//! normal case rather than an edge one. +//! keyed by the model it produces and lives here, so any device can watch it -- +//! including one that did not start it. State in a per-connection channel would +//! not survive the phone locking its screen, which for an hour-long download is +//! the normal case. //! -//! **Every run has an id, and its outcome outlives it.** Without those, -//! "not downloading" is three different answers at once -- it finished, -//! it never started, or a different run finished while you were away -- -//! and over an hour that ambiguity is certain to be hit. A device compares -//! the run it was watching against the run reported now. +//! **Every run has an id, and its outcome outlives it.** Without those, "not +//! downloading" is three answers at once -- it finished, it never started, or a +//! different run finished while you were away. //! //! **Progress is measured, never estimated.** `total` is whatever -//! `Content-Length` said and nothing else; when the server does not send -//! one it stays `None` and the phone shows that it does not know, rather -//! than a bar drawn from how long the last download took. +//! `Content-Length` said and nothing else; when the server does not send one it +//! stays `None` and the phone shows that it does not know. use std::collections::HashMap; use std::io::{Read, Seek, SeekFrom, Write}; @@ -40,35 +34,33 @@ use wg_app_link::private; const USER_AGENT: &str = concat!("ai-server/", env!("CARGO_PKG_VERSION")); /// Read size per loop iteration. Big enough that the syscall overhead is -/// nothing against a multi-gigabyte file, small enough that a cancel is -/// noticed promptly -- the flag is only checked between chunks. +/// nothing against a multi-gigabyte file, small enough that a cancel is noticed +/// promptly -- the flag is only checked between chunks. const CHUNK: usize = 256 * 1024; /// A model file sitting on this machine, ready to run. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct LocalModel { - /// `owner/repo/file.gguf` -- the HuggingFace coordinates, which are - /// already unique, so nothing has to invent an id. + /// `owner/repo/file.gguf` -- the HuggingFace coordinates, which are already + /// unique, so nothing has to invent an id. pub key: String, pub repo: String, pub file: String, pub bytes: u64, } -/// What a run is doing, or did. -/// -/// Flat rather than a tagged enum carrying its message, because the phone -/// switches on this and a string it can compare is easier to render than a -/// variant it has to destructure. +/// What a run is doing, or did. Flat rather than a tagged enum carrying its +/// message, because the phone switches on this and a string it can compare is +/// easier to render than a variant it has to destructure. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "snake_case")] pub enum DownloadState { Running, - /// Reading the finished file back to check it against the hash - /// HuggingFace publishes. Its own state because it takes real time on - /// a multi-gigabyte file and "still working" is the honest thing to - /// show, rather than a bar sitting at 100% for half a minute. + /// Reading the finished file back to check it against the hash HuggingFace + /// publishes. Its own state because it takes real time on a multi-gigabyte + /// file and "still working" is the honest thing to show, rather than a bar + /// sitting at 100% for half a minute. Verifying, Finished, Failed, @@ -80,20 +72,17 @@ pub enum DownloadState { #[serde(rename_all = "camelCase")] pub struct DownloadStatus { pub key: String, - /// Distinguishes this run from any earlier one for the same model. - /// A device that was watching run 3 can tell that what it is looking - /// at now is run 4 rather than assuming its own run ended. + /// Distinguishes this run from any earlier one for the same model, so a + /// device that was watching run 3 can tell it is now looking at run 4. pub run: u64, pub repo: String, pub file: String, pub state: DownloadState, - /// Bytes on disk, including any carried over from a resumed attempt. pub done: u64, /// What `Content-Length` said, or absent when the server did not say. - /// Absent means "unknown", never "zero" -- see this module's doc. + /// Absent means "unknown", never "zero". #[serde(skip_serializing_if = "Option::is_none")] pub total: Option, - /// Present only when [`DownloadState::Failed`], and it is the reason. #[serde(skip_serializing_if = "Option::is_none")] pub error: Option, pub started: f64, @@ -152,9 +141,8 @@ impl Run { /// Every model this machine has, and every download in flight or finished. pub struct ModelStore { dir: PathBuf, - /// Keyed by model key: one run per model at a time, and the last run - /// for a model stays here after it ends so its outcome can still be - /// read. Bounded by how many distinct models have been asked for. + /// Keyed by model key: one run per model at a time, and the last run for a + /// model stays here after it ends so its outcome can still be read. runs: Mutex>>, next_run: AtomicU64, } @@ -171,11 +159,10 @@ impl ModelStore { /// Where a model's file lives, refusing anything that would escape the /// models directory. /// - /// The repo and file come from a phone, and this server runs as the - /// user who started it, so they are treated as hostile: every - /// component must be an ordinary name. Rejecting is deliberate rather - /// than sanitising, since a silently rewritten path would download the - /// right bytes to the wrong place. + /// The repo and file come from a phone, so they are treated as hostile: + /// every component must be an ordinary name. Rejecting rather than + /// sanitising, since a silently rewritten path would download the right + /// bytes to the wrong place. fn path_for(&self, repo: &str, file: &str) -> Result { let mut path = self.dir.clone(); for part in repo.split('/').chain(file.split('/')) { @@ -191,11 +178,9 @@ impl ModelStore { format!("{repo}/{file}") } - /// Every `.gguf` found under the models directory, newest first. - /// - /// Read from disk on each call rather than cached: a file deleted by - /// hand should stop being offered, and the directory is small enough - /// that walking it costs nothing next to loading a model. + /// Every `.gguf` found under the models directory, newest first. Read from + /// disk on each call rather than cached: a file deleted by hand should stop + /// being offered. pub fn list(&self) -> Vec { let mut found = Vec::new(); collect(&self.dir, &self.dir, &mut found); @@ -211,12 +196,10 @@ impl ModelStore { all } - /// Starts fetching `file` from `repo`, or returns the run already - /// doing so. - /// - /// Idempotent on purpose: a phone that lost its connection and came - /// back will press the button again, and that must join the existing - /// run rather than start a second one writing the same file. + /// Starts fetching `file` from `repo`, or returns the run already doing so. + /// Idempotent on purpose: a phone that lost its connection will press the + /// button again, and that must join the existing run rather than start a + /// second one writing the same file. pub fn start(self: &Arc, repo: &str, file: &str) -> Result { let key = Self::key_for(repo, file); let target = self.path_for(repo, file)?; @@ -251,8 +234,8 @@ impl ModelStore { drop(runs); // A dedicated thread rather than the blocking pool: this holds its - // thread for as long as the download takes, which is minutes to - // hours, and the pool exists for short work. + // thread for as long as the download takes, which is minutes to hours, + // and the pool exists for short work. let store = Arc::clone(self); std::thread::spawn(move || { let outcome = store.fetch(&run, &target); @@ -275,8 +258,8 @@ impl ModelStore { Ok(status) } - /// Asks a running download to stop. The partial file stays, so - /// starting again resumes rather than refetching. + /// Asks a running download to stop. The partial file stays, so starting + /// again resumes rather than refetching. pub fn cancel(&self, key: &str) -> Result { let runs = self.runs.lock().unwrap(); let Some(run) = runs.get(key) else { @@ -303,7 +286,6 @@ impl ModelStore { Ok(()) } - /// The download loop: resume where a partial left off, write, report. fn fetch(&self, run: &Run, target: &Path) -> Result<()> { let partial = partial_of(target); let identity = identity_of(target); @@ -311,9 +293,8 @@ impl ModelStore { private::create_dir(parent)?; } - // What we have, and what it was part of. A partial with no - // recorded identity is not resumable -- it could be a fragment of - // any revision -- so it is refetched rather than guessed at. + // What we have, and what it was part of. A partial with no recorded + // identity is not resumable -- it could be a fragment of any revision. let known = std::fs::read_to_string(&identity) .ok() .map(|s| s.trim().to_string()); @@ -331,13 +312,11 @@ impl ModelStore { let mut etag = etag_of(&response); // HuggingFace's CDN ignores `If-Range` -- probed 2026-08-28: a - // deliberately stale validator still answers 206 with the ranged - // bytes rather than 200 with the whole file. So the header cannot - // be relied on to restart us, and the check is done here instead: - // if what arrived is not the revision our partial belongs to, - // resuming would splice two files into something of exactly the - // right length and the wrong contents. Throw the partial away and - // ask again from zero. + // deliberately stale validator still answers 206 with the ranged bytes. + // So the header cannot be relied on to restart us, and the check is done + // here instead: if what arrived is not the revision our partial belongs + // to, resuming would splice two files into something of exactly the + // right length and the wrong contents. if resumed && etag.is_some() && etag != known { tracing::info!( "{} changed upstream since the partial was written -- starting again", @@ -349,11 +328,9 @@ impl ModelStore { etag = etag_of(&response); } - // On a 206, Content-Length is the length of the *range*, not of - // the file -- it answers a different question than the one a - // progress bar asks, and taken at face value it would fill the bar - // at 72 MB of a 234 MB model. The whole size is the last field of - // Content-Range (`bytes 162000000-234074815/234074816`), which has + // On a 206, Content-Length is the length of the *range*, not of the file + // -- taken at face value it would fill the bar at 72 MB of a 234 MB + // model. The whole size is the last field of Content-Range, which has // the further merit of not depending on where the range began. let total: Option = if resumed { response @@ -378,12 +355,10 @@ impl ModelStore { p.total = total; } - // `truncate(false)` is the whole resume story: the file is opened - // to be seeked into and appended to, and truncating here would - // throw away exactly the bytes the Range request just asked the - // server not to send again. Stated rather than left to the - // default, because the default is what a reader would have to - // remember. + // `truncate(false)` is the whole resume story: the file is opened to be + // seeked into and appended to, and truncating would throw away exactly + // the bytes the Range request just asked the server not to send again. + // Stated rather than left to the default. let mut file = std::fs::OpenOptions::new() .create(true) .write(true) @@ -397,9 +372,8 @@ impl ModelStore { file.set_len(0) .context("truncate a partial we cannot resume onto")?; } - // Written before the body, so an interrupted download leaves a - // partial that can still say which revision it belongs to. That is - // what makes it safe to keep one across a restart of this server. + // Written before the body, so an interrupted download leaves a partial + // that can still say which revision it belongs to. if let Some(etag) = &etag { std::fs::write(&identity, etag).ok(); } @@ -425,12 +399,10 @@ impl ModelStore { file.flush().context("flushing the model file")?; drop(file); - // Checked before the rename, so a file that fails never gets the - // real name and `list` never offers it. With the identity check - // above this should not fire; it is here because a download of - // this size has too many ways to go subtly wrong to take on - // trust, and because a wrong model is the kind of failure that - // surfaces as bad output rather than as an error. + // Checked before the rename, so a file that fails never gets the real + // name and `list` never offers it. With the identity check above this + // should not fire; it is here because a wrong model is the kind of + // failure that surfaces as bad output rather than as an error. if let Some(expected) = published_sha256(&run.repo, &run.file) { run.progress.lock().unwrap().state = DownloadState::Verifying; let actual = sha256_of(&partial)?; @@ -446,8 +418,8 @@ impl ModelStore { } } - // Renamed only once complete, so a file at its real name is always - // a whole model -- `list` needs no other way to tell. + // Renamed only once complete, so a file at its real name is always a + // whole model -- `list` needs no other way to tell. std::fs::rename(&partial, target) .with_context(|| format!("finish {}", target.display()))?; std::fs::remove_file(&identity).ok(); @@ -455,9 +427,8 @@ impl ModelStore { } } -/// The sha256 of a file, read in chunks -- these are gigabytes, and -/// reading one into memory to hash it would be the largest allocation this -/// server ever makes. +/// The sha256 of a file, read in chunks -- these are gigabytes, and reading one +/// into memory to hash it would be the largest allocation this server makes. fn sha256_of(path: &Path) -> Result { use sha2::{Digest, Sha256}; let mut file = @@ -471,8 +442,8 @@ fn sha256_of(path: &Path) -> Result { } hasher.update(&buffer[..read]); } - // Hex by hand, as wg_app_link::enroll::token_hash_hex also has to, - // since this sha2 version's output type does not implement LowerHex. + // Hex by hand, as `wg_app_link::enroll::token_hash_hex` also has to, since + // this sha2 version's output type does not implement LowerHex. Ok(hasher .finalize() .iter() @@ -487,9 +458,8 @@ fn request(url: &str, from: u64) -> Result<(ureq::http::Response, bo get = get.header("Range", &format!("bytes={from}-")); } let response = get.call().with_context(|| format!("GET {url}"))?; - // Trust the status, not the request: a server that ignores Range - // answers 200 with the whole file, and appending to that would - // corrupt it. + // Trust the status, not the request: a server that ignores Range answers + // 200 with the whole file, and appending to that would corrupt it. let resumed = response.status() == 206; Ok((response, resumed)) } @@ -506,8 +476,8 @@ fn etag_of(response: &ureq::http::Response) -> Option { ) } -/// `x.gguf` -> `x.gguf.part.etag`, holding which revision the partial -/// beside it is a piece of. +/// `x.gguf` -> `x.gguf.part.etag`, holding which revision the partial beside it +/// is a piece of. fn identity_of(target: &Path) -> PathBuf { let mut name = target.as_os_str().to_os_string(); name.push(".part.etag"); @@ -567,18 +537,17 @@ pub struct RemoteRepo { pub struct RemoteFile { pub path: String, pub bytes: u64, - /// Already on this machine, so the phone can say so rather than - /// offering to fetch it again. + /// Already on this machine, so the phone can say so rather than offering to + /// fetch it again. pub have: bool, } /// Searches HuggingFace for GGUF repositories matching `query`. /// /// Proxied through this server rather than called from the phone, for two -/// reasons that both matter: the app trusts exactly one certificate -- -/// this server's -- and has no general internet trust to spend on -/// huggingface.co, and the machine that has to do the downloading is this -/// one, so it is also the one whose view of what exists is relevant. +/// reasons that both matter: the app trusts exactly one certificate -- this +/// server's -- and has no general internet trust to spend on huggingface.co, +/// and the machine that has to do the downloading is this one. pub fn search(query: &str) -> Result> { let url = format!( "https://huggingface.co/api/models?search={}&filter=gguf&limit=25&sort=downloads&direction=-1", @@ -606,11 +575,9 @@ pub fn search(query: &str) -> Result> { .collect()) } -/// The sha256 HuggingFace publishes for one file, if it publishes one. -/// -/// It is the LFS object id, which for these repositories is the sha256 of -/// the content -- so it is a free integrity check on a download rather -/// than something we would have to compute a second source of truth for. +/// The sha256 HuggingFace publishes for one file, if it publishes one. It is +/// the LFS object id, which for these repositories is the sha256 of the content +/// -- so it is a free integrity check rather than a second source of truth. fn published_sha256(repo: &str, file: &str) -> Option { let url = format!("https://huggingface.co/api/models/{repo}/tree/main?expand=true"); let body = get_json(&url).ok()?; @@ -659,10 +626,9 @@ fn get_json(url: &str) -> Result { serde_json::from_str(&text).with_context(|| format!("{url} did not return JSON")) } -/// Percent-encodes a query string. Deliberately minimal -- this escapes -/// what a model search actually contains rather than implementing the -/// whole rule set, and anything unexpected becomes `%XX` rather than -/// being passed through. +/// Percent-encodes a query string. Deliberately minimal -- this escapes what a +/// model search actually contains rather than implementing the whole rule set, +/// and anything unexpected becomes `%XX` rather than being passed through. fn urlencode(value: &str) -> String { value .bytes() diff --git a/server/src/routes.rs b/server/src/routes.rs index 0aabcf2..948ec16 100644 --- a/server/src/routes.rs +++ b/server/src/routes.rs @@ -48,7 +48,7 @@ //! moments, live only (see `notifications`) //! GET /usage cached usage windows per provider //! ``` -//! +//! Later phases add `GET|PUT /models` config editing from the phone. //! Later phases add: `GET|PUT /hosts` and `/models` -- see PLAN.md's table. //! //! Everything here works purely in the common event model; nothing may @@ -92,8 +92,7 @@ pub fn router(manager: Arc) -> Router { .route("/setups/probe", post(probe_setup)) .route("/setups/{id}/importable", get(list_importable)) // A batch at a time, never a session at a time -- see - // [`delete_importable`]. There is no `{session}` route to collide - // with, so all three of these are plain static segments. + // [`delete_importable`]. .route("/setups/{id}/importable/delete", post(delete_importable)) .route("/setups/{id}/importable/import", post(start_import)) .route("/setups/{id}/importable/events", get(importable_events)) @@ -101,10 +100,9 @@ pub fn router(manager: Arc) -> Router { "/setups/{id}", get(read_setup).put(update_setup).delete(delete_setup), ) - // The filesystem of the machine a setup names -- see - // `crate::files`. Under the setup rather than under a session - // because a filesystem is a property of a machine; a session only - // says where to start looking. + // The filesystem of the machine a setup names. Under the setup + // rather than under a session because a filesystem is a property of + // a machine; a session only says where to start looking. .route("/setups/{id}/dir", get(list_dir).post(create_dir)) .route( "/setups/{id}/file", @@ -130,17 +128,16 @@ pub fn router(manager: Arc) -> Router { .route("/sessions/{id}/command", post(command)) .route( "/sessions/{id}/attachments", - // A trace or a log is bigger than a photo; the cap below is - // for everything else, and the innermost limit is the one - // axum applies. + // A trace or a log is bigger than a photo; the cap below is for + // everything else, and the innermost limit is the one axum + // applies. post(upload_attachment).layer(axum::extract::DefaultBodyLimit::max(ATTACHMENT_LIMIT)), ) .route("/sessions/{id}/files/{name}", get(serve_file)) // Phone photos overflow axum's 2 MB default body cap. .layer(axum::extract::DefaultBodyLimit::max(32 * 1024 * 1024)) - // An explicit fallback so the auth middleware (layered around the - // whole router in main.rs) also covers unknown paths -- a scanner - // gets the same 401 everywhere, never a route map. + // An explicit fallback so the auth middleware also covers unknown + // paths -- a scanner gets the same 401 everywhere, never a route map. .fallback(|| async { ApiError::UnknownRoute }) .with_state(manager) } @@ -168,8 +165,8 @@ impl IntoResponse for ApiError { Self::BadRequest(_) => StatusCode::BAD_REQUEST, Self::Conflict(_) => StatusCode::CONFLICT, Self::Internal(err) => { - // The only variant whose real cause isn't safe to hand - // back verbatim, and the only one worth a log line. + // 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(); } @@ -178,9 +175,9 @@ impl IntoResponse for ApiError { } } -/// An `anyhow` error from a session mutation is a message written *for* -/// the phone ("no session abc123") -- not an internal fault, so it comes -/// back as a 400 with that message rather than a 500 and a log line. +/// An `anyhow` error from a session mutation is a message written *for* the +/// phone ("no session abc123"), so it comes back as a 400 with that message +/// rather than a 500 and a log line. fn bad_request(err: anyhow::Error) -> ApiError { ApiError::BadRequest(format!("{err:#}")) } @@ -197,12 +194,11 @@ async fn list_sessions(State(manager): State>) -> axum::Json /// One session's row, for a screen that has to show what is true now. /// -/// The list is a snapshot taken when somebody last looked at it, and a -/// screen opened from a row carries that snapshot with it. That is fine for -/// what a row *says* and wrong for what a control is *set to*: a switch -/// drawn from a stale row shows the position it had when the list was -/// fetched, which may be minutes and another device ago, and the person -/// reading it cannot tell. Same reason `GET /setups/{id}` exists. +/// The list is a snapshot taken when somebody last looked at it, and a screen +/// opened from a row carries that snapshot with it. Fine for what a row +/// *says* and wrong for what a control is *set to*: a switch drawn from a +/// stale row shows the position it had when the list was fetched, and the +/// person reading it cannot tell. Same reason `GET /setups/{id}` exists. async fn read_session( State(manager): State>, UrlPath(id): UrlPath, @@ -219,11 +215,10 @@ async fn read_session( /// hardcoded list: a setup added to `config.ron` shows up with no app /// rebuild. /// -/// One list rather than two, because the choice is a pair and the halves -/// are not independent. A provider only exists on a machine that has it -/// installed, so listing providers and machines separately offered their -/// whole cross-product -- including "the Claude CLI on the box that hasn't -/// got it". +/// One list rather than two, because the halves are not independent. A +/// provider only exists on a machine that has it installed, so listing them +/// separately offered the whole cross-product -- including "the Claude CLI on +/// the box that hasn't got it". #[derive(serde::Serialize)] #[serde(rename_all = "camelCase")] struct SetupInfo { @@ -231,8 +226,7 @@ struct SetupInfo { id: String, /// The editable label. name: String, - /// Where it runs, for telling two setups apart. Absent for the one - /// that is this machine. + /// Where it runs, for telling two setups apart. Absent for this machine. #[serde(skip_serializing_if = "Option::is_none")] address: Option, providers: Vec, @@ -269,9 +263,9 @@ fn info_for(setup: crate::config::SetupConfig) -> SetupInfo { /// How to reach a machine, as the phone describes it. /// -/// Note what is absent: nothing here names a program. Providers are found -/// by asking the machine (`crate::setups`), never sent, so the enrolled -/// token cannot introduce something to run. +/// Note what is absent: nothing here names a program. Providers are found by +/// asking the machine (`crate::setups`), never sent, so the enrolled token +/// cannot introduce something to run. #[derive(Deserialize)] #[serde(rename_all = "camelCase")] #[serde(deny_unknown_fields)] @@ -279,8 +273,8 @@ struct SshRequest { address: String, #[serde(default)] port: Option, - /// A path on the *backend*, not a key itself: private keys do not - /// travel, so this names one that must already be there. + /// A path on the *backend*, not a key itself: private keys do not travel, + /// so this names one that must already be there. #[serde(default)] identity_file: Option, #[serde(default)] @@ -291,8 +285,8 @@ struct SshRequest { } impl SshRequest { - /// Tidied at the boundary rather than stored as typed -- this came - /// from a phone keyboard, so it may have a stray space or a `~`. + /// Tidied at the boundary rather than stored as typed -- this came from a + /// phone keyboard, so it may have a stray space or a `~`. fn into_config(self) -> Result { let address = crate::setups::tidy(&self.address) .ok_or_else(|| ApiError::BadRequest("a machine needs an address".to_string()))?; @@ -309,9 +303,8 @@ impl SshRequest { .iter() .filter_map(|o| crate::setups::tidy(o)) .collect(), - // Not `tidy`: that expands `~` to *this* machine's home, and - // this path is on the other one. The remote shell expands it - // there (`ssh::quote_path`). + // Not `tidy`: that expands `~` to *this* machine's home, and this + // path is on the other one. The remote shell expands it there. attachments_dir: self .attachments_dir .as_deref() @@ -332,11 +325,10 @@ struct AddSetupRequest { ssh: Option, } -/// What a machine turned out to have, without saving anything. -/// -/// The point of trying before committing: a wrong address or an -/// unauthorised key is caught while the person is still looking at the -/// form that caused it, rather than at the first spawn. +/// What a machine turned out to have, without saving anything. The point of +/// trying before committing: a wrong address or an unauthorised key is caught +/// while the person is still looking at the form that caused it, rather than +/// at the first spawn. #[derive(Deserialize)] #[serde(rename_all = "camelCase")] #[serde(deny_unknown_fields)] @@ -363,9 +355,8 @@ async fn probe_setup( } /// Asks the machine an `ssh` block describes -- or this one -- what it has. -/// -/// `label` only ever appears in a failure message, so a probe of an -/// unsaved form can still say which machine would not answer. +/// `label` only ever appears in a failure message, so a probe of an unsaved +/// form can still say which machine would not answer. async fn probe( ssh: Option, label: &str, @@ -387,9 +378,9 @@ async fn add_setup( axum::Json(body): axum::Json, ) -> Result, ApiError> { let ssh = body.ssh.map(SshRequest::into_config).transpose()?; - // Ask the machine being added what it has, before writing anything -- - // so a bad address fails here rather than leaving a setup that can - // never spawn. + // Ask the machine being added what it has, before writing anything, so a + // bad address fails here rather than leaving a setup that can never + // spawn. let providers = probe(ssh.clone(), &body.name).await?; let setup = manager .add_setup(&body.name, ssh, providers) @@ -397,10 +388,8 @@ async fn add_setup( Ok(axum::Json(info_for(setup))) } -/// One setup by id, or the 404 that says so. -/// -/// Three handlers ask this same question; the answer, and the wording of -/// the refusal, belong in one place. +/// One setup by id, or the 404 that says so. Three handlers ask this same +/// question; the answer, and the wording of the refusal, belong in one place. fn setup_by_id( manager: &Arc, id: &str, @@ -465,11 +454,10 @@ async fn delete_setup( Ok(StatusCode::NO_CONTENT) } -/// The five explorer routes below all begin the same way: find the -/// machine, and check that what the phone named is a path this will act on. -/// -/// The check is `files::check_path`, shared with [`set_cwd`] -- one rule -/// about what an acceptable path is, and one wording for refusing it. +/// The five explorer routes below all begin the same way: find the machine, +/// and check that what the phone named is a path this will act on. The check +/// is `files::check_path`, shared with [`set_cwd`] -- one rule about what an +/// acceptable path is, and one wording for refusing it. fn files_on( manager: &Arc, id: &str, @@ -483,20 +471,16 @@ fn files_on( )) } -/// A failure from one of the scripts is the *machine's* message -- "no -/// such file or directory", "permission denied", ssh refusing the -/// connection -- and it is written to be read where it happened, which is -/// the phone. So it comes back as a 400 with those words rather than as a -/// 500 and a log line only the backend can see. +/// A failure from one of the scripts is the *machine's* message, written to +/// be read where it happened, which is the phone. So it comes back as a 400 +/// with those words rather than a 500 and a log line only the backend sees. fn from_machine(err: anyhow::Error) -> ApiError { ApiError::BadRequest(format!("{err:#}")) } -/// Where a path is named for these routes. -/// -/// Query rather than a path segment: a path contains slashes, and a -/// segment that had to be escaped and unescaped would be a second encoding -/// to keep in step with the phone's. +/// Where a path is named for these routes. Query rather than a path segment: +/// a path contains slashes, and a segment that had to be escaped and +/// unescaped would be a second encoding to keep in step with the phone's. #[derive(Deserialize)] struct PathQuery { path: String, @@ -515,10 +499,9 @@ async fn list_dir( .map_err(from_machine) } -/// One file's content, or which of the three reasons there is none. -/// -/// The path it was asked for rides along, so a phone that has moved on -/// since can tell which answer this is. +/// One file's content, or which of the three reasons there is none. The path +/// it was asked for rides along, so a phone that has moved on since can tell +/// which answer this is. #[derive(Serialize)] struct FileResponse { path: String, @@ -544,10 +527,10 @@ async fn read_file( struct WriteFileRequest { path: String, content: String, - /// The digest the read reported. Not optional: an editor that could - /// omit it would be one overwrite away from losing an agent's edit, - /// and "I did not check" is not something a caller should be able to - /// say by leaving a field out. + /// The digest the read reported. Not optional: an editor that could omit + /// it would be one overwrite away from losing an agent's edit, and "I did + /// not check" is not something a caller should be able to say by leaving a + /// field out. if_sha256: String, } @@ -617,18 +600,16 @@ struct SpawnRequest { cwd: Option, #[serde(default)] permission_mode: Option, - /// Whatever the chosen driver understands -- llama.cpp's context size - /// and sampling, for instance. Opaque here on purpose: see - /// `SessionConfig::params`. + /// Whatever the chosen driver understands -- llama.cpp's context size and + /// sampling. Opaque here on purpose: see `SessionConfig::params`. #[serde(default)] params: std::collections::BTreeMap, - /// Continue a Claude Code session the machine already has, named by - /// the id `GET /setups/{id}/importable` reported. + /// Continue a Claude Code session the machine already has, named by the id + /// `GET /setups/{id}/importable` reported. /// - /// An id and not a path, deliberately. The server looks the path up - /// again among the sessions it enumerated, so an enrolled token cannot - /// turn this field into "read me an arbitrary file" -- the same rule - /// that keeps a provider's command out of `POST /setups`. + /// An id and not a path, deliberately: the server looks the path up again + /// among the sessions it enumerated, so an enrolled token cannot turn this + /// field into "read me an arbitrary file". #[serde(default)] import: Option, } @@ -643,30 +624,23 @@ async fn list_importable( let mut found = crate::session::import::list(&transport) .await .map_err(bad_request)?; - // Anything this app is already continuing is not offered again. Left - // out rather than shown-and-disabled, because it has not disappeared: - // it is in the session list, which is where it now belongs. Absence - // here means "already somewhere you can reach it", not "gone". - // - // Joined here because the importer knows about files and the manager - // knows about sessions, and putting the two together is the route's - // job rather than either one's. + // Anything this app is already continuing is not offered again. Left out + // rather than shown-and-disabled, because it has not disappeared: it is in + // the session list, which is where it now belongs. // // Except while this server is in the middle of importing it. A spawn - // creates the session partway through, so the row would vanish the - // instant the work started and reappear as a session only once it - // finished -- and in between, the screen that asked for it would be - // showing nothing at all where the thing it is waiting for used to be. - // A row with an operation on it stays until the operation settles. + // creates the session partway through, so the row would vanish the instant + // the work started and reappear as a session only once it finished -- and + // in between, the screen that asked for it would show nothing at all where + // the thing it is waiting for used to be. found.retain(|candidate| { manager.pending().running(&id, &candidate.id).is_some() || manager.session_driving(&candidate.id).is_none() }); - // What the server is doing to each of them, joined on here because a - // phone that was asleep, out of range, or freshly opened never heard - // the events -- see `pending`. An operation is *not* filtered out - // above: a row being imported has to stay visible, marked, or the list + // What the server is doing to each of them, joined on here because a phone + // that was asleep or freshly opened never heard the events -- see + // `pending`. A row being imported has to stay visible, marked, or the list // would say the work never started. let present: Vec = found.iter().map(|row| row.id.clone()).collect(); manager.pending().prune(&id, &present); @@ -685,11 +659,8 @@ async fn list_importable( } /// A row of the import list: what the machine has, plus what this server is -/// doing to it. -/// -/// Flattened, so the two halves arrive as one object -- the phone is -/// drawing one row and has no use for the seam between "what the machine -/// said" and "what we are doing about it". +/// doing to it. Flattened, so the two halves arrive as one object -- the phone +/// is drawing one row and has no use for the seam. #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct ImportableRow { @@ -700,35 +671,28 @@ struct ImportableRow { #[serde(skip_serializing_if = "Option::is_none")] pending: Option<&'static str>, /// How the last attempt on this row failed, if it did. Kept until - /// something replaces it, because the phone that needs to see it may - /// not have been connected when it happened. + /// something replaces it, because the phone that needs to see it may not + /// have been connected when it happened. #[serde(skip_serializing_if = "Option::is_none")] error: Option, } /// Removes Claude Code sessions from a machine. /// -/// The transcript *is* the session, so this ends any chance of resuming -/// those conversations -- including from an ai-app session already -/// importing one. The phone confirms before calling this; the server does -/// not second-guess a decision somebody was shown the cost of. +/// The transcript *is* the session, so this ends any chance of resuming those +/// conversations. The phone confirms before calling this; the server does not +/// second-guess a decision somebody was shown the cost of. /// -/// A batch and never a single session, which is the whole reason this is a -/// POST with a body rather than a `DELETE` on each id. The phone used to -/// send one request per row, and a handover was then only as atomic as the -/// network was reliable: leave the screen, lose signal, or have the fourth +/// A batch and never a single session, which is why this is a POST with a body +/// rather than a `DELETE` on each id. One request per row made a handover only +/// as atomic as the network: leave the screen, lose signal, or have the fourth /// of six requests fail, and some rows are being deleted while the rest are -/// untouched, with nothing anywhere that knows the difference. Here every -/// id is registered as in flight before the 202 goes back, so the answer to -/// "did my batch start" is one answer for the batch. +/// untouched, with nothing anywhere that knows the difference. Here every id +/// is registered as in flight before the 202 goes back. /// -/// Registering is what has to be atomic; the work itself does not. The -/// batch runs as one command on the machine -- see -/// [`crate::session::import::delete`] for why it is not one per id -- but -/// each row still settles on its own event from its own outcome, because -/// six deletes that must all succeed or all roll back is not something a -/// filesystem offers, and pretending otherwise would mean holding five -/// sessions hostage to the one that failed. +/// Registering is what has to be atomic; the work is not. Each row settles on +/// its own event from its own outcome, because six deletes that must all +/// succeed or all roll back is not something a filesystem offers. async fn delete_importable( State(manager): State>, UrlPath(id): UrlPath, @@ -751,8 +715,8 @@ async fn delete_importable( .collect(); let sessions = body.sessions; tokio::spawn(async move { - // One failure here is the machine being unreachable, which is true - // of every row rather than of any one of them, so they all say so. + // One failure here is the machine being unreachable, which is true of + // every row rather than of any one of them. let outcomes = match crate::session::import::delete(&transport, &sessions).await { Ok(outcomes) => outcomes, Err(err) => { @@ -777,9 +741,9 @@ async fn delete_importable( tracing::warn!("deleting {session} on {id} failed: {message}"); flight.failed(message.clone()); } - // `delete` promises an entry per id, so this is a bug - // rather than a state -- but a row stuck on "deleting" - // for ever is a worse answer than one that says so. + // `delete` promises an entry per id, so this is a bug rather + // than a state -- but a row stuck on "deleting" for ever is a + // worse answer than one that says so. None => flight.failed(format!("nothing was reported about {session}")), } } @@ -798,21 +762,20 @@ struct DeleteBatch { /// Continues Claude Code sessions, in the background. /// /// Separate from `POST /sessions` because the two are asked different -/// questions. That one means "start this and take me to it", so it waits -/// and answers with the session. This one is the import screen's batch: -/// several at once, nobody waiting on any particular one, and the answer -/// arrives as a row changing rather than as a reply -- which is the whole -/// point, since the screen it was started from may well be gone by then. +/// questions. That one means "start this and take me to it", so it waits and +/// answers with the session. This one is the import screen's batch: several at +/// once, nobody waiting on any particular one, and the answer arrives as a row +/// changing rather than as a reply -- the screen it was started from may well +/// be gone by then. /// -/// A list for the same reason [`delete_importable`] takes one: the batch is -/// handed over in a single request, so it cannot half-arrive. +/// A list for the same reason [`delete_importable`] takes one. async fn start_import( State(manager): State>, UrlPath(id): UrlPath, axum::Json(body): axum::Json, ) -> Result { - // Checked before accepting, so an unknown machine is still an error the - // caller sees rather than a failure it has to go and read off a row. + // Checked before accepting, so an unknown machine is an error the caller + // sees rather than one it has to go and read off a row. setup_by_id(&manager, &id)?; for session in body.sessions { let request = SpawnRequest { @@ -859,11 +822,9 @@ struct ImportRequest { } /// Runs `work` on the server, marked as in flight for as long as it takes. -/// -/// Spawned rather than awaited, which is the whole difference: the phone -/// asked for it, but the phone leaving must not cancel it. What replaces -/// the reply is the pending registry -- the row says what is happening to -/// it, whoever is looking and whenever they look. +/// Spawned rather than awaited, which is the whole difference: the phone asked +/// for it, but the phone leaving must not cancel it. What replaces the reply +/// is the pending registry. fn in_background( manager: &Arc, setup: String, @@ -882,28 +843,26 @@ fn in_background( } Err(err) => { tracing::warn!("{} {session} on {setup} failed: {err:#}", operation.label()); - // The server's own words, the way every other failure in - // this app reaches a person. + // The server's own words, the way every other failure in this + // app reaches a person. running.failed(format!("{err:#}")); } } }); } -/// Every change to what is in flight against one machine. -/// -/// Scoped to the setup the screen is showing, the same way a session's -/// events are scoped to that session -- a phone watching one machine's -/// import list has no use for another's. +/// Every change to what is in flight against one machine. Scoped to the setup +/// the screen is showing, the same way a session's events are scoped to that +/// session. async fn importable_events( State(manager): State>, UrlPath(id): UrlPath, ) -> Sse>> { let live = manager.pending().subscribe(); let stream = BroadcastStream::new(live).filter_map(move |item| { - // A lagged subscriber has missed changes it cannot get back here, - // and that is what the listing is for: the screen refetches on - // arrival and carries the truth whatever this stream missed. + // A lagged subscriber has missed changes it cannot get back here, and + // that is what the listing is for: the screen refetches on arrival and + // carries the truth whatever this stream missed. let change = item.ok()?; if change.setup() != id { return None; @@ -923,15 +882,13 @@ async fn spawn_session( /// Starts a session, continuing a Claude Code one where `body.import` names /// it. /// -/// A function rather than only a handler because the import screen's batch -/// runs this from a background task -- see [`start_import`]. Spawning has to -/// mean exactly the same thing either way: the same refusal when something -/// else already has the conversation open, the same title, the same working -/// directory. +/// A function rather than only a handler because the import screen's batch runs +/// this from a background task. Spawning has to mean exactly the same thing +/// either way: the same refusal when something else already has the +/// conversation open, the same title, the same working directory. async fn spawn(manager: &Arc, body: SpawnRequest) -> Result { - // Resolved before the spawn because both halves of it are the - // machine's answer, not the phone's: which file that id names, and - // what is in it. + // Resolved before the spawn because both halves are the machine's answer, + // not the phone's: which file that id names, and what is in it. let seed = match &body.import { Some(want) => { let setup = setup_by_id(manager, &body.setup)?; @@ -953,12 +910,12 @@ async fn spawn(manager: &Arc, body: SpawnRequest) -> Result, body: SpawnRequest) -> Result, body: SpawnRequest) -> Result - // session" fallback, so every import arrived called "claude-cli - // session". Absent and empty mean the same thing to a person and - // have to mean the same thing here. + // opening message is the title unless one was typed. Blank normalised + // to absent rather than trusted as a choice: a client with nothing to + // say sends `""`, which is `Some` and so satisfied `or_else`, and every + // import arrived called "claude-cli session". title: body .title .filter(|title| !title.trim().is_empty()) @@ -1030,9 +983,9 @@ async fn spawn(manager: &Arc, body: SpawnRequest) -> Result, body: SpawnRequest) -> Result, Query(query): Query, ) -> Result { - // Before the session goes, because only the session record says which - // file on which machine this conversation is. + // Before the session goes, because only the session record says which file + // on which machine this conversation is. let foreign = query .delete_foreign .then(|| manager.foreign_transcript(&id)) .flatten(); - // And *deleted* before it too, so a machine that cannot be reached - // leaves everything as it was rather than a deleted session and a - // transcript the phone has already promised is gone. The phone can - // then retry, or turn the toggle off. + // And *deleted* before it too, so a machine that cannot be reached leaves + // everything as it was rather than a deleted session and a transcript the + // phone has already promised is gone. if let Some((setup, session)) = &foreign { let setup = setup_by_id(&manager, setup)?; let transport = crate::session::transport::Transport::for_setup(&setup); - // A batch of one: the same call, so there is one description of - // what deleting a foreign transcript means. Its outcome is this - // request's outcome, since there is only the one row. + // A batch of one: the same call, so there is one description of what + // deleting a foreign transcript means. crate::session::import::delete(&transport, std::slice::from_ref(session)) .await .map_err(bad_request)? @@ -1117,9 +1068,8 @@ async fn message( UrlPath(id): UrlPath, axum::Json(body): axum::Json, ) -> Result { - // For the 404 a session that is not here has always answered with; the - // send itself goes through the manager, which may have to start a - // process before there is anything to send to. + // For the 404 a session that is not here has always answered with; the send + // goes through the manager, which may have to start a process first. lookup(&manager, &id)?; if body.text.trim().is_empty() && body.attachment_ids.is_empty() { return Err(ApiError::BadRequest("message is empty".to_string())); @@ -1134,8 +1084,8 @@ async fn message( #[serde(rename_all = "camelCase")] #[serde(deny_unknown_fields)] struct UnqueueRequest { - /// The id the `messageQueued` event carried, which is what the bubble - /// on screen is drawn from. + /// The id the `messageQueued` event carried, which is what the bubble on + /// screen is drawn from. message_id: String, } @@ -1143,11 +1093,9 @@ struct UnqueueRequest { /// /// The two failures are separate answers rather than one refusal, because /// they are different things to whoever tapped: `409` means the session has -/// already been told and the message is on its way into the conversation, -/// and `404` means nothing is waiting under that id -- a bubble on screen -/// that something else has already resolved. See [`Driver::unqueue`]; the -/// Claude driver can only ever give the first, since it writes a steer into -/// the CLI the moment it arrives. +/// already been told, and `404` means nothing is waiting under that id -- a +/// bubble something else has already resolved. The Claude driver can only +/// ever give the first, since it writes a steer into the CLI on arrival. async fn unqueue( State(manager): State>, UrlPath(id): UrlPath, @@ -1169,10 +1117,9 @@ async fn unqueue( #[serde(deny_unknown_fields)] struct AnswerRequest { question_id: String, - /// Everything chosen, in the order it was offered. A question that - /// takes one answer sends a list of one, so there is one shape here - /// rather than a single-answer route and a multi-answer route beside - /// it. + /// Everything chosen, in the order it was offered. A question that takes + /// one answer sends a list of one, so there is one shape here rather than + /// a single-answer route and a multi-answer route beside it. answers: Vec, } @@ -1198,12 +1145,11 @@ async fn interrupt( Ok(StatusCode::NO_CONTENT) } -/// Ends the session's process. The session stays, and `start` brings it -/// back -- see [`SessionManager::stop_session`]. +/// Ends the session's process. The session stays, and `start` brings it back. /// -/// Not `lookup`ed: a session that failed to relaunch has no live entry and -/// may still have a process running, which is exactly one worth being able -/// to stop. +/// Not `lookup`ed: a session that failed to relaunch has no live entry and may +/// still have a process running, which is exactly one worth being able to +/// stop. async fn stop( State(manager): State>, UrlPath(id): UrlPath, @@ -1213,8 +1159,8 @@ async fn stop( } /// Starts a process for a session that has none, continuing the same -/// conversation -- see [`SessionManager::start_session`], which refuses -/// unless the session is known to have exited. +/// conversation. [`SessionManager::start_session`] refuses unless the session +/// is known to have exited. async fn start( State(manager): State>, UrlPath(id): UrlPath, @@ -1226,8 +1172,7 @@ async fn start( /// The usage screen needs two things that live in different places: the /// cache, and the current list of machines to ask. Carried together rather /// than the monitor holding the manager, which would point the dependency -/// upward -- `usage` sits below the session layer and should not reach -/// into it. +/// upward -- `usage` sits below the session layer. #[derive(Clone)] pub struct UsageState { monitor: Arc, @@ -1248,9 +1193,9 @@ pub fn usage_router( async fn usage( State(state): State, ) -> Result>, ApiError> { - // Read here rather than inside the fetch, so the list of machines is - // the one that existed when the request arrived and cannot change - // under a fetch that takes an ssh round trip per machine. + // Read here rather than inside the fetch, so the list of machines is the + // one that existed when the request arrived and cannot change under a + // fetch that takes an ssh round trip per machine. let setups = state.manager.setups(); // The fetch is blocking by design (see `usage`); off the workers. let snapshots = tokio::task::spawn_blocking(move || state.monitor.snapshots(&setups)) @@ -1285,21 +1230,17 @@ struct CwdRequest { /// Moves a session to a different working directory. /// -/// The directory is checked here rather than in the manager because -/// checking it is an ssh round trip on a remote setup, and the manager is -/// not async -- the same division `POST /sessions` already makes for the -/// directory an import was recorded in. +/// The directory is checked here rather than in the manager because checking +/// it is an ssh round trip on a remote setup, and the manager is not async. /// -/// Checked rather than trusted, and refused rather than corrected: a -/// mistyped path that was accepted would leave a session recorded somewhere -/// its process cannot start, and the failure would arrive later, as a -/// session that would not come back, with nothing pointing at the typo. The -/// spawn path corrects instead because it is resuming a directory the -/// *machine* recorded, which can be gone through nobody's fault; a path -/// somebody has just typed is different. +/// Checked rather than trusted, and refused rather than corrected: a mistyped +/// path that was accepted would leave a session recorded somewhere its process +/// cannot start, and the failure would arrive later with nothing pointing at +/// the typo. The spawn path corrects instead because it is resuming a +/// directory the *machine* recorded, which can be gone through nobody's fault. /// -/// Note what this does not do: it does not start a replacement process. -/// See [`SessionManager::set_session_cwd`]. +/// It does not start a replacement process; see +/// [`SessionManager::set_session_cwd`]. async fn set_cwd( State(manager): State>, UrlPath(id): UrlPath, @@ -1311,9 +1252,8 @@ async fn set_cwd( .find(|session| session.id == id) .ok_or_else(|| ApiError::NotFound(format!("no session {id}")))?; // Absolute, because the alternative is relative to whatever the CLI is - // launched from, which is not something the person typing it can see. - // The same question the explorer asks of every path it is given, so it - // is asked in one place and refused in one wording. + // launched from, which is not something the person typing it can see. The + // same question the explorer asks of every path, asked in one place. let cwd = crate::files::check_path(&body.cwd.to_string_lossy()).map_err(bad_request)?; let setup = setup_by_id(&manager, &session.setup)?; let transport = crate::session::transport::Transport::for_setup(&setup); @@ -1323,10 +1263,9 @@ async fn set_cwd( setup.name ))); } - // Stored in the short form, so the one path that is kept is the one - // the phone will draw -- rather than storing `/home/bob/…` and - // abbreviating it again at each place it is shown, which is two - // representations of one directory and a second rule to keep in step. + // Stored in the short form, so the one path kept is the one the phone will + // draw -- rather than storing `/home/bob/…` and abbreviating it again at + // each place it is shown, which is two representations of one directory. // Only where the setup runs here; see `setups::shorten_home`. let stored = if setup.ssh.is_none() { crate::setups::shorten_home(&cwd) @@ -1399,10 +1338,9 @@ struct CommandRequest { /// Runs one of the session's own commands, now or at the next boundary. /// -/// The two this server understands are turned into the operations it has -/// -- a compaction, a rename, which is also how the settings screen asks -/// -- and everything else is passed to the session verbatim, because a -/// dialect's vocabulary is its own and grows without this file. +/// The two this server understands are turned into the operations it has, and +/// everything else is passed to the session verbatim, because a dialect's +/// vocabulary is its own and grows without this file. async fn command( State(manager): State>, UrlPath(id): UrlPath, @@ -1414,13 +1352,12 @@ async fn command( None => (text, ""), }; // All of these start the session's process first if it has exited: a - // command is something somebody asked the session to do, and answering - // that its process is gone hands back the work of starting one. + // command is something somebody asked the session to do, and answering that + // its process is gone hands back the work of starting one. // - // A rename still goes through `rename_session` rather than being a - // command like the rest, because the name is persisted and listed as - // well as forwarded, and that is one operation. It starts a process - // too, and for a sharper reason than the others -- see there. + // A rename still goes through `rename_session` rather than being a command + // like the rest, because the name is persisted and listed as well as + // forwarded, and that is one operation. let command = match (name, rest) { ("/compact", _) => SessionCommand::Compact, ("/clear", _) => SessionCommand::Clear, @@ -1452,20 +1389,18 @@ async fn compact( /// gigabyte, and this leaves room for a few of them. const ATTACHMENT_LIMIT: usize = 4 * 1024 * 1024 * 1024; -/// Accepts one file (any multipart field) and stores it under the -/// session; the returned id goes into a later `/message`'s attachmentIds. -/// An image is later shown to the model, anything else is named to it by -/// path -- see `ClaudeDriver::send_user_message`. +/// Accepts one file (any multipart field) and stores it under the session; the +/// returned id goes into a later `/message`'s attachmentIds. An image is later +/// shown to the model, anything else is named to it by path. /// -/// Written to disk as it arrives rather than collected first: a trace is -/// bigger than this process should hold, and the phone streams it for the -/// same reason. Under a `.part` name until it is whole, so a tunnel that -/// drops mid-upload leaves nothing a message could reference. +/// Written to disk as it arrives rather than collected first: a trace is bigger +/// than this process should hold. Under a `.part` name until it is whole, so a +/// tunnel that drops mid-upload leaves nothing a message could reference. /// -/// A file for a session on another machine is copied there too, because -/// the path the session is told has to exist where the session runs. The -/// copy is part of the upload: if it fails, the upload fails and says so, -/// rather than a message later naming a file that is not there. +/// A file for a session on another machine is copied there too, because the +/// path the session is told has to exist where the session runs. The copy is +/// part of the upload: if it fails, the upload fails and says so, rather than +/// a message later naming a file that is not there. async fn upload_attachment( State(manager): State>, UrlPath(id): UrlPath, @@ -1538,15 +1473,14 @@ async fn upload_attachment( Ok(axum::Json(serde_json::json!({ "id": name }))) } -/// Copies `local` to the machine `ssh` names, into the configured -/// attachments directory, else `cwd`, else the login home, and returns the -/// absolute path it has there. +/// Copies `local` to the machine `ssh` names, into the configured attachments +/// directory, else `cwd`, else the login home, and returns the absolute path it +/// has there. /// -/// One `ssh` invocation does the copy and answers the path: the file goes -/// over stdin to `cat`, and `pwd -P` afterwards resolves whatever the -/// directory was written as -- a `~`, a relative name, a symlink -- into -/// the path the session will be told, which is the one a CLI's file tools -/// take. `scp` would need a second round trip for that answer. +/// One `ssh` invocation does the copy and answers the path: the file goes over +/// stdin to `cat`, and `pwd -P` afterwards resolves whatever the directory was +/// written as into the path the session will be told. `scp` would need a second +/// round trip for that answer. async fn ship_attachment( ssh: &crate::config::SshConfig, cwd: Option<&Path>, @@ -1557,15 +1491,15 @@ async fn ship_attachment( let mut script = String::new(); if let Some(dir) = dir { let dir = crate::ssh::quote_path(&dir.to_string_lossy()); - // Created if missing: a configured directory may not exist yet, - // and a session's own cwd already does, so this costs it nothing. + // Created if missing: a configured directory may not exist yet, and a + // session's own cwd already does, so this costs it nothing. script.push_str(&format!("mkdir -p {dir} && cd {dir} && ")); } script.push_str(&format!("cat > {} && pwd -P", crate::ssh::quote(name))); let source = std::fs::File::open(local).with_context(|| format!("open {}", local.display()))?; - // Through the transport's own "with this on stdin", which the - // explorer's write also uses -- one description of what that means - // rather than an ssh invocation assembled here as well. + // Through the transport's own "with this on stdin", which the explorer's + // write also uses -- one description of what that means rather than an ssh + // invocation assembled here as well. let transport = crate::session::transport::Transport::Ssh { name: ssh.address.clone(), ssh: ssh.clone(), @@ -1589,16 +1523,16 @@ fn remote_marker(local: &Path) -> std::path::PathBuf { local.with_file_name(format!("{name}.remote")) } -/// Serves a session's stored files -- both `files/` (images produced by -/// tools) and `attachments/` (uploaded from the phone), by the id events -/// and uploads reference. +/// Serves a session's stored files -- both `files/` (images produced by tools) +/// and `attachments/` (uploaded from the phone), by the id events and uploads +/// reference. async fn serve_file( State(manager): State>, UrlPath((id, name)): UrlPath<(String, String)>, ) -> Result { - // Ids are server-generated -- hex and an extension, or hex and a - // cleaned file name (`safe_file_name`); anything else (and any path - // separator in particular) is refused, not resolved. + // Ids are server-generated -- hex and an extension, or hex and a cleaned + // file name; anything else (any path separator in particular) is refused, + // not resolved. if !name .chars() .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_') @@ -1617,12 +1551,12 @@ async fn serve_file( ))); }; // A file that is there but unreadable is this server's fault, not the - // request's -- Internal logs it and says nothing more to the caller. + // request's. let bytes = std::fs::read(path) .with_context(|| format!("read {}", path.display())) .map_err(ApiError::Internal)?; - // Every image this server writes has an extension it knows; the rest - // are files attached by name, served as the bytes they are. + // Every image this server writes has an extension it knows; the rest are + // files attached by name, served as the bytes they are. let content_type = crate::media::media_type_for(&name).unwrap_or("application/octet-stream"); Ok(([(axum::http::header::CONTENT_TYPE, content_type)], bytes).into_response()) } @@ -1633,10 +1567,10 @@ struct EventsQuery { after: u64, } -/// The session screen's one data source: replay everything after the -/// cursor from the transcript, then live events as they happen. An SSE -/// auto-reconnect sends the last event id it saw as `Last-Event-ID`, which -/// takes precedence over `after` -- same cursor, native mechanism. +/// The session screen's one data source: replay everything after the cursor +/// from the transcript, then live events. An SSE auto-reconnect sends the last +/// event id it saw as `Last-Event-ID`, which takes precedence over `after` -- +/// same cursor, native mechanism. #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct TranscriptQuery { @@ -1645,14 +1579,15 @@ struct TranscriptQuery { before: Option, #[serde(default = "default_window")] limit: usize, - /// Join each reply's streamed deltas into one event, so a page counts rows rather than - /// tokens. The scroll-back pager asks for this; the anchor-restore path does not, because it - /// counts events to reach a known seq. See `read_window`. + /// Join each reply's streamed deltas into one event, so a page counts rows + /// rather than tokens. The scroll-back pager asks for this; the + /// anchor-restore path does not, because it counts events to reach a known + /// seq. See `read_window`. #[serde(default)] coalesce: bool, - /// Return nothing at or below this seq; the page stops here instead of at `limit`. - /// The phone passes the end of what it already holds, so a page never overlaps it. - /// Named to match the SSE route's `after`, and exclusive in the same way. + /// Return nothing at or below this seq; the page stops here instead of at + /// `limit`. The phone passes the end of what it already holds, so a page + /// never overlaps it. Exclusive, like the SSE route's `after`. #[serde(default)] after: Option, } @@ -1663,10 +1598,9 @@ fn default_window() -> usize { /// A page of a session's transcript, newest first to open with. /// -/// One request rather than one stream frame per event. The SSE stream -/// stays as it is and remains the right shape for *live* events, which -/// arrive one at a time by nature; it is only the backlog that has to -/// stop pretending to be live. +/// One request rather than one stream frame per event. The SSE stream remains +/// the right shape for *live* events, which arrive one at a time by nature; it +/// is only the backlog that has to stop pretending to be live. async fn transcript( State(manager): State>, UrlPath(id): UrlPath, @@ -1681,10 +1615,10 @@ async fn transcript( query.coalesce, ) .map_err(bad_request)?; - // How far back a phone has paged, and how much each page cost it to get - // there, which is the one question this route raises and nothing else - // can answer: the app asks for events and draws rows, and the ratio - // between them is a property of the conversation. `RUST_LOG=ai_server=debug`. + // How far back a phone has paged, and what each page cost it, which is the + // one question this route raises and nothing else can answer: the app asks + // for events and draws rows, and the ratio between them is a property of + // the conversation. `RUST_LOG=ai_server=debug`. tracing::debug!( session = %id, before = ?query.before, @@ -1727,20 +1661,18 @@ async fn events( /// /// **Live only, with no cursor**, which is the one place this server does not /// offer to catch a client up. A notification is a claim about now: replaying -/// "your turn" from an hour ago tells somebody to go and look at a session -/// that may have been answered from another device since, and a notification -/// that is wrong is worse than one that never came -- it costs the reader the -/// trip *and* teaches them to distrust the next one. What was missed while -/// disconnected is still on the session list, which is the surface that -/// answers "what is waiting" without claiming to be news. +/// "your turn" from an hour ago sends somebody to a session that may have been +/// answered from another device since, and a notification that is wrong costs +/// the reader the trip *and* teaches them to distrust the next one. What was +/// missed is still on the session list, which answers "what is waiting" +/// without claiming to be news. async fn notifications( State(manager): State>, ) -> Sse>> { let live = manager.subscribe_notifications(); let stream = BroadcastStream::new(live).filter_map(|item| { - // A lagged subscriber has lost the oldest notifications, and there is - // nothing useful to say about that: the ones it still gets are the - // recent ones, which are the ones worth acting on. + // A lagged subscriber has lost the oldest notifications, and the ones + // it still gets are the recent ones -- the ones worth acting on. let notification = item.ok()?; Some(Ok(SseEvent::default().json_data(¬ification).ok()?)) }); @@ -1785,28 +1717,22 @@ async fn stream_session( /// subscriber is still there. /// /// A [`CatchUp::Restart`] is preceded by the `reset` frame that tells the -/// client to drop what it holds. Without it the window would be spliced -/// onto rows that are no longer adjacent to it, which reads as ordinary -/// output rather than as a gap -- which is why a bounded backlog cannot -/// simply be "the newest events". +/// client to drop what it holds. Without it the window would be spliced onto +/// rows that are no longer adjacent to it, which reads as ordinary output +/// rather than as a gap -- which is why a bounded backlog cannot simply be +/// "the newest events". /// /// Both ways into a backlog come through here -- the first replay and the -/// recovery from a lapped broadcast -- because either can be arbitrarily -/// far behind and owes the client the same answer. -/// -/// Synchronous file reads from an async task: transcript lines are small -/// and local; revisit if daily use produces transcripts where this shows -/// (phase 6 territory). +/// recovery from a lapped broadcast -- because either can be arbitrarily far +/// behind and owes the client the same answer. async fn send_backlog(transcript: &Path, last: &mut u64, tx: &mpsc::Sender) -> bool { let cursor = *last; let entries = match catch_up(transcript, *last, CATCH_UP_LIMIT) { Ok(CatchUp::Continue(entries)) => { - // The pair of them at debug, because "was this subscriber reset, - // and how far behind was it" is a question about a phone that - // nothing else here can answer -- the app sees a window arrive - // and cannot tell how far it had fallen, and a reset is the one - // thing that makes its screen jump. `RUST_LOG=ai_server=debug`, - // beside the transcript pages. + // The pair at debug, because "was this subscriber reset, and how + // far behind was it" is a question about a phone that nothing else + // here can answer: the app sees a window arrive and cannot tell how + // far it had fallen. tracing::debug!(cursor, sent = entries.len(), "stream backlog: continue"); entries } @@ -1844,8 +1770,8 @@ async fn send_event( /// /// Keys are `owner/repo/file.gguf` and so contain slashes, which is why /// nothing here puts one in the path: a key travels in the body or a query -/// string, and the routes stay addressable without escaping rules nobody -/// would get right from a phone. +/// string, and the routes stay addressable without escaping rules nobody would +/// get right from a phone. pub fn models_router(store: Arc) -> Router { Router::new() .route("/models", get(list_models)) @@ -1857,11 +1783,10 @@ pub fn models_router(store: Arc) -> Router { .with_state(store) } -/// What this machine has and what it is fetching, in one answer. -/// -/// Both together deliberately: a phone showing the model list needs both -/// to draw one screen, and two routes would let it render a model as -/// absent while its download sits at 99%. +/// What this machine has and what it is fetching, in one answer. Both +/// together deliberately: a phone showing the model list needs both to draw +/// one screen, and two routes would let it render a model as absent while its +/// download sits at 99%. #[derive(serde::Serialize)] #[serde(rename_all = "camelCase")] struct ModelsResponse { diff --git a/server/src/session/claude.rs b/server/src/session/claude.rs index 106f110..5adfc93 100644 --- a/server/src/session/claude.rs +++ b/server/src/session/claude.rs @@ -63,20 +63,14 @@ use super::transport::{Launch, Streams, Transport}; use crate::config::{ProviderConfig, SessionConfig}; use translate::{AnswerOutcome, Setting, Translator, starts_a_model_call}; -/// How much of a failing process's stderr the exit report carries. -/// -/// Enough for a shell's complaint plus the context it prints around it -- -/// fish's `cd` failure is seven lines including a caret pointing at the -/// offending line -- and bounded because this is held per session for the -/// life of the process and a chatty program would otherwise grow without -/// limit. +/// How much of a failing process's stderr the exit report carries. Enough +/// for a shell's complaint plus the context it prints around it, and bounded +/// because this is held per session for the life of the process. const STDERR_LINES_KEPT: usize = 50; -/// The kept stderr as one block, with blank lines trimmed off both ends. -/// -/// The trailing trim is the point: a shell's error ends with a blank line, -/// so the last line of stderr is routinely empty and anything that reports -/// "the last line" reports nothing at all. +/// The kept stderr as one block, with blank lines trimmed off both ends. The +/// trailing trim is the point: a shell's error ends with a blank line, so +/// anything reporting "the last line" reports nothing at all. fn tail_of(kept: &VecDeque) -> String { let lines: Vec<&str> = kept.iter().map(String::as_str).collect(); let start = lines @@ -91,41 +85,31 @@ fn tail_of(kept: &VecDeque) -> String { lines[start..end].join("\n") } -/// Where the driver remembers its CLI session id between backend runs -- -/// the whole crash-recovery story: respawning with `--resume ` picks -/// the conversation back up from Claude's own session files. Kept in the -/// session directory rather than config.ron so the shared schema stays -/// free of per-driver state. +/// Where the driver remembers its CLI session id between backend runs -- the +/// whole crash-recovery story, since respawning with `--resume ` picks the +/// conversation back up. Kept in the session directory rather than config.ron +/// so the shared schema stays free of per-driver state. pub(super) mod translate; const RESUME_FILE: &str = "claude-session.json"; /// Messages handed to the CLI that it has not visibly acted on yet. /// -/// The CLI *does* take a message written mid-turn: it goes into the next -/// model call, which is the next tool boundary, and the whole point of -/// this app is steering a turn that is already running. An earlier version -/// of this file claimed the opposite and held every mid-turn message until -/// the turn ended -- so a steer sent after the second tool call sat unread -/// until all the work it was meant to redirect had finished. Measured -/// rather than argued: a line written between two Bash calls was answered -/// inside the same turn, with one `result` for the whole thing. +/// The CLI *does* take a message written mid-turn: it goes into the next model +/// call, which is the next tool boundary, and steering a running turn is the +/// point of this app. An earlier version held every mid-turn message until the +/// turn ended, so a steer sent after the second tool call sat unread until all +/// the work it was meant to redirect had finished. /// -/// What the CLI does not do is say on stdout that it has read one. So the -/// line goes out immediately and the *announcement* waits here instead, -/// until the CLI opens the next model call -- see -/// [`translate::starts_a_model_call`]. That keeps a phone's held bubble -/// where it belongs -- below the working indicator until the session has -/// actually taken it -- without delaying the message itself to get it. +/// What the CLI does not do is say on stdout that it has read one. So the line +/// goes out immediately and the *announcement* waits here, until the CLI opens +/// the next model call -- see [`translate::starts_a_model_call`]. /// -/// The proof has to be the model call and not the output, which is what -/// an earlier version took it to be. Assistant text and a tool call both -/// keep arriving from a message that was *already in flight* when the -/// steer was written, and that message saw none of it: a steer sent -/// while an answer was streaming was recorded in the middle of it, above -/// tool calls the model had already committed to. On screen the answer -/// split into two bubbles around a message it had not read, and the tool -/// results that followed read as things the steer had asked for. +/// The proof has to be the model call and not the output. Assistant text and a +/// tool call both keep arriving from a message that was *already in flight* +/// when the steer was written, and that message saw none of it: a steer sent +/// while an answer was streaming was recorded in the middle of it, above tool +/// calls the model had already committed to. #[derive(Default)] struct Queue { /// A turn is in flight, so a message sent now is a steer into it. @@ -136,28 +120,23 @@ struct Queue { awaiting: VecDeque<(String, String, Vec)>, /// The process is gone, so nothing can be taken up any more. /// - /// Needed because every other way out of a turn is an `Idle` this - /// driver sees, and an exit is the one that is not. Without it a - /// process that died mid-turn left `running` true for good, and since - /// a message is only recorded when it is *announced*, each later one - /// vanished with nothing on screen to say it had not been delivered. + /// Needed because every other way out of a turn is an `Idle` this driver + /// sees, and an exit is the one that is not. Without it a process that + /// died mid-turn left `running` true for good, and since a message is only + /// recorded when *announced*, each later one vanished silently. closed: bool, } impl Queue { /// Gives up on everything held, because the process is gone. /// - /// Reported rather than dropped. These are messages somebody typed - /// that never reached the session and never reached the transcript, so - /// this is the only place they can be mentioned at all. + /// Reported rather than dropped: these are messages somebody typed that + /// never reached the session and never reached the transcript, so this is + /// the only place they can be mentioned at all. /// - /// Each one is also *resolved*, with the same `MessageDropped` that a - /// phone tapping the bubble produces. Without it the bubble sat there - /// for good: a message drawn as waiting to be read, by a session that - /// no longer exists, with the only thing that ever clears it -- the - /// `UserMessage` -- exactly what is not coming. The error says what - /// happened and the drop is what ends it, which is the same division - /// of labour as everywhere else here. + /// Each is also *resolved*, with the same `MessageDropped` that tapping the + /// bubble produces -- otherwise the bubble sat there for good, waiting on a + /// `UserMessage` that is exactly what is not coming. fn close(&mut self, sink: &EventSink, why: &str) { self.closed = true; self.running = false; @@ -189,31 +168,26 @@ impl Queue { } } -/// The session directory's copies of the process's standard streams. -/// -/// Named once rather than built at each use, because the spawn path and -/// the attach path must agree about which file is which; if they drift, -/// a reattached session reads a file nothing is writing and simply looks -/// idle forever. +/// The session directory's copies of the process's standard streams. Named +/// once rather than built at each use, because the spawn path and the attach +/// path must agree about which file is which; if they drift, a reattached +/// session reads a file nothing is writing and looks idle forever. const STDIN_FIFO: &str = "stdin.fifo"; const STDOUT_LOG: &str = "stdout.log"; const STDERR_LOG: &str = "stderr.log"; -/// How often a reader with nothing to read looks again. -/// -/// A poll rather than a watch: the alternative is an inotify dependency -/// for one file per session, and at this interval the streaming text is -/// already arriving faster than a phone renders it. +/// How often a reader with nothing to read looks again. A poll rather than a +/// watch: the alternative is an inotify dependency for one file per session, +/// and at this interval the streaming text already arrives faster than a phone +/// renders it. const POLL: std::time::Duration = std::time::Duration::from_millis(50); pub struct ClaudeDriver { sink: EventSink, queue: Arc>, - /// Lines for the process's stdin. - /// - /// Not closeable, unlike the pipe this used to be: stdin is a fifo the - /// process holds open itself, so closing this end says nothing to it. - /// Ending the process is [`Driver::stop`]'s job and it uses a signal. + /// Lines for the process's stdin. Not closeable, unlike the pipe this used + /// to be: stdin is a fifo the process holds open itself, so closing this + /// end says nothing to it. Ending the process is [`Driver::stop`]'s job. to_child: mpsc::UnboundedSender, state: Arc>, session_dir: PathBuf, @@ -226,14 +200,13 @@ impl ClaudeDriver { /// Takes charge of this session's process: the one already running if /// there is one, otherwise a new one. /// - /// One entry point rather than two, because the choice is not the - /// caller's to make and getting it wrong is the expensive bug. A - /// second `--resume` against a session file that is already open - /// duplicates the whole conversation into it and bills the reattached - /// copy for re-reading it -- measured at 65 MB and 154 screenshots on - /// 2026-08-29, when an import of a *live* session did exactly this. - /// So `--resume` is reachable only through the spawn half below, under - /// a check that nothing is running. + /// One entry point rather than two, because the choice is not the caller's + /// and getting it wrong is the expensive bug. A second `--resume` against + /// a session file that is already open duplicates the whole conversation + /// into it and bills the reattached copy for re-reading it -- measured at + /// 65 MB and 154 screenshots on 2026-08-29. So `--resume` is reachable + /// only through the spawn half below, under a check that nothing is + /// running. pub fn launch( meta: &SessionConfig, provider: &ProviderConfig, @@ -245,19 +218,17 @@ impl ClaudeDriver { let queue = Arc::new(Mutex::new(Queue::default())); let reading = Arc::new(AtomicBool::new(true)); - // Adopting is only possible for a process this server left behind - // on this machine: an ssh session's child is at the far end of a - // connection that died with the server, so there is nothing there - // to find. Nothing was ever recorded for one, so this answers "no" - // without needing to know that, which is why the remote case is - // not a branch here. - // Whether this launch *started* a process or picked up one that was - // already there. The two owe the session different things -- see the - // `Status` below. + // Adopting is only possible for a process this server left behind on + // this machine: an ssh session's child died with the connection. + // Nothing was ever recorded for one, so this answers "no" without + // needing to know that. + // + // `started_here` is whether this launch *started* a process or picked + // one up; the two owe the session different things. let started_here; let record = match process::recorded(session_dir) { - // Still running, and ours. Pick it up where it was left -- - // the one path that must not pass `--resume`. + // Still running, and ours. Pick it up where it was left -- the one + // path that must not pass `--resume`. Some((record, process::Liveness::Alive)) => { tracing::info!( "session {} reattaching to the {} it left running (pid {})", @@ -269,9 +240,8 @@ impl ClaudeDriver { record } // Recorded, and the machine will not say whether it is still - // there. Starting one anyway is the mistake this module is - // for, so nothing is started; `follow` keeps asking and - // reports the state as unknown until it gets an answer. + // there. Starting one anyway is the mistake this module is for, so + // nothing is started; `follow` keeps asking. Some((record, process::Liveness::Unknown)) => { tracing::warn!( "session {} recorded pid {} but this machine won't say whether it is running; \ @@ -289,43 +259,35 @@ impl ClaudeDriver { }; // A process this driver has just started has been asked for nothing, - // which is what idle means. Said here because nothing else will say - // it: the CLI writes not one line until it is given work, so a - // session whose transcript last recorded `Exited` -- one whose - // process died while this server was down, or one somebody stopped - // from the phone -- would keep that word. `Exited` refuses every - // command sent to the session, and it offers a phone the chance to - // start a second CLI against a conversation that already has one. + // which is what idle means. Said here because nothing else will: the + // CLI writes not one line until it is given work, so a session whose + // transcript last recorded `Exited` would keep that word -- and + // `Exited` refuses every command and invites starting a second CLI + // against a conversation that already has one. // - // From the driver rather than from the manager, and before `follow` - // is spawned, so it cannot overtake the exit `follow` reports for a - // process that dies immediately: both come from here, in this order. - // Adopting says nothing, because a process that was already running - // may be mid-turn, and the transcript's last word is the better - // answer until its output says otherwise. - // - // The llama driver has always done this (see `LlamaDriver::attached`, - // which reports `Running` while the model loads and `Idle` when it - // answers); this side was the one silent about it. + // From the driver rather than the manager, and before `follow` is + // spawned, so it cannot overtake the exit `follow` reports for a + // process that dies immediately. Adopting says nothing, because a + // process already running may be mid-turn and the transcript's last + // word is the better answer until its output says otherwise. if started_here { let _ = sink.send(Event::Status { state: SessionStatus::Idle, }); } - // Where reading of its output had reached. A process just started - // has said nothing, so its record says zero and this is the same - // question with the same answer. + // Where reading of its output had reached. A process just started has + // said nothing, so its record says zero. let resuming_from = match record.detail { process::Detail::Stdio { stdout_read } => stdout_read, - // A record of the wrong shape belongs to a different driver; - // read its output from the start rather than trusting an - // offset into a file that means something else. + // A record of the wrong shape belongs to a different driver; read + // its output from the start rather than trusting an offset into a + // file that means something else. _ => 0, }; - // The writer end of the fifo. Opened write-only here: the process - // holds its own read-write handle, so this side coming and going - // across a restart is invisible to it. + // The writer end of the fifo. Opened write-only here: the process holds + // its own read-write handle, so this side coming and going across a + // restart is invisible to it. let stdin = std::fs::OpenOptions::new() .write(true) .open(session_dir.join(STDIN_FIFO)) @@ -365,10 +327,8 @@ impl ClaudeDriver { } /// Starts a new CLI for this session, with its streams in the session - /// directory so the next run of this server can find them. - /// - /// The only path that passes `--resume`, and it is reached only when - /// nothing is running -- see [`ClaudeDriver::launch`]. + /// directory so the next run of this server can find them. The only path + /// that passes `--resume`, and it is reached only when nothing is running. fn start( meta: &SessionConfig, provider: &ProviderConfig, @@ -391,50 +351,37 @@ impl ClaudeDriver { if let Some(mode) = &meta.permission_mode { push("--permission-mode", mode); } - // Named at birth, so this session is the same session in the CLI's - // own picker and in what other agents see when they list it. + // Named at birth, so this session is the same session in the CLI's own + // picker and in what other agents see. // - // Only when we are the ones creating it. A resume is a session - // that already existed -- an import, or this server starting again - // -- and it already has whatever name it was given, quite possibly - // by the person who was typing in it. Renaming that from a title - // we derived from its first message would be taking something the - // app was only ever shown. `Driver::set_title` is how it changes - // after this point, and that one is asked for. + // Only when we are creating it. A resume is a session that already + // existed -- an import, or this server starting again -- and it already + // has whatever name it was given, quite possibly by the person typing + // in it. `Driver::set_title` is how it changes after this point, and + // that one is asked for. match read_resume_token(session_dir) { Some(resume) => push("--resume", &resume), None => push("--name", &meta.title), } args.push("--include-partial-messages".to_string()); - // Makes `bypassPermissions` *reachable*, without selecting it: the - // session still starts in whatever mode was asked for above, and - // only moves if somebody moves it. + // Makes `bypassPermissions` *reachable* without selecting it: the + // session still starts in whatever mode was asked for above. // - // Here because the CLI is asymmetric about that mode, which is not - // obvious and cost a confused bug report. It will *launch* in - // `bypassPermissions` on the strength of `--permission-mode` alone - // -- so spawning straight into it from the phone has always worked - // -- but it refuses to *switch* into it later: + // The CLI is asymmetric about that mode. It will *launch* in + // `bypassPermissions` on `--permission-mode` alone, but refuses to + // *switch* into it later ("the session was not launched with + // --dangerously-skip-permissions"), so the phone's mode picker offered + // a mode that could not be picked on every session not given it at + // birth. Since the mode is already reachable at spawn, this grants + // nothing that was being withheld. // - // Cannot set permission mode to bypassPermissions because the - // session was not launched with --dangerously-skip-permissions - // - // So the phone's own mode picker offered a mode that could not be - // picked, on every session it had not been given at birth. Since - // the mode is already reachable at spawn, this grants nothing that - // was being withheld; it makes the two routes to it agree. - // - // Measured against 2.1.237, both ways round: without this flag the - // control request comes back `subtype: error` with the message - // above, and with it `subtype: success, mode: bypassPermissions`. - // Note it is the `--allow-` form -- `--dangerously-skip-permissions` - // is the one that turns it on for everything, and that would take - // the choice away from whoever is holding the phone. + // Measured against 2.1.237 both ways round. Note it is the `--allow-` + // form; `--dangerously-skip-permissions` turns it on for everything, + // which would take the choice away from whoever holds the phone. args.push("--allow-dangerously-skip-permissions".to_string()); - // Fresh logs, because the offsets that index them start at zero - // and everything the previous process said is already in the - // transcript. + // Fresh logs, because the offsets that index them start at zero and + // everything the previous process said is already in the transcript. let stdin = make_fifo(&session_dir.join(STDIN_FIFO))?; let stdout = create_log(&session_dir.join(STDOUT_LOG))?; let stderr = create_log(&session_dir.join(STDERR_LOG))?; @@ -458,11 +405,10 @@ impl ClaudeDriver { transport.describe() ); - // Reaped rather than waited on. This server is the parent, so - // something has to collect the exit status or the process becomes - // a zombie -- but it is `follow` that decides what the session is - // doing, because after a restart there is no `Child` to wait on - // and the answer has to come from the same place either way. + // Reaped rather than waited on. This server is the parent, so something + // has to collect the exit status or the process becomes a zombie -- but + // `follow` decides what the session is doing, because after a restart + // there is no `Child` to wait on. tokio::spawn(async move { let mut child = child; let _ = child.wait().await; @@ -481,23 +427,18 @@ impl ClaudeDriver { /// Writes one of the CLI's own commands into the session. /// /// Slash commands ride the normal user-message channel -- there is no - /// control request for them; `set_session_name` is not a subtype the - /// CLI knows, measured by asking. The turn they start is marked here - /// because they produce a `result` like any other, so a message sent - /// meanwhile belongs in the queue's "written, announce when read" - /// path rather than being reported as read the moment it is typed. + /// control request for them, measured by asking. The turn they start is + /// marked here because they produce a `result` like any other, so a message + /// sent meanwhile belongs in the queue's "written, announce when read" path. /// - /// Nothing is emitted about the command itself: the manager has - /// already said it was sent, and the CLI announces what it does -- - /// saying so here would be this side's guess standing in for its - /// measurement. + /// Nothing is emitted about the command itself: the manager has already + /// said it was sent, and the CLI announces what it does. fn local_command(&self, text: String) { let mut queue = self.queue.lock().unwrap(); - // The same check `send_user_message` makes, for the same reason: a - // line written into a fifo nothing is reading goes nowhere and looks - // exactly like one that arrived. `Commands::submit` refuses a - // session already known to have exited, so what this catches is the - // process going away between that check and this write. + // The same check `send_user_message` makes: a line written into a fifo + // nothing is reading goes nowhere and looks exactly like one that + // arrived. What this catches is the process going away between + // `Commands::submit`'s check and this write. if queue.closed { drop(queue); let _ = self.sink.send(Event::Error { @@ -507,11 +448,10 @@ impl ClaudeDriver { } queue.running = true; drop(queue); - // The session is working from this moment, and until now nothing - // said so: a command's reply carries no assistant text, so - // `proves_a_turn` never saw it and the recorded status stayed idle - // for the whole round trip -- which meant the *next* idle was not a - // change, so nothing was ever released behind it. + // The session is working from this moment, and until now nothing said + // so: a command's reply carries no assistant text, so `proves_a_turn` + // never saw it and the recorded status stayed idle for the whole round + // trip -- which meant the *next* idle was not a change. let _ = self.sink.send(Event::Status { state: SessionStatus::Running, }); @@ -525,19 +465,17 @@ impl ClaudeDriver { /// Sends a control request, remembering what it asked for. /// - /// `confirms` is the setting this request will have made if the CLI - /// answers success -- see [`Translator::expect_setting`], and - /// `Driver::set_model` for why a request is not a confirmation. - /// `None` for the ones that change no setting, like an interrupt. + /// `confirms` is the setting this request will have made if the CLI answers + /// success -- see [`Translator::expect_setting`]. `None` for the ones that + /// change no setting, like an interrupt. /// - /// The id is random rather than the clock it used to be: two requests - /// in the same second shared an id, which was harmless while nothing - /// looked one up and is not any more. + /// The id is random rather than the clock it used to be: two requests in + /// the same second shared an id. fn send_control(&self, request: Value, confirms: Option) { let id = format!("req-{}", super::random_hex()); if let Some(setting) = confirms { - // Before the line goes out: the reader thread is already - // running, and a fast answer to a slow lock arrives first. + // Before the line goes out: the reader thread is already running, + // and a fast answer to a slow lock arrives first. self.state .lock() .unwrap() @@ -553,10 +491,9 @@ impl Driver for ClaudeDriver { fn send_user_message(&self, text: String, attachments: Vec) { let mut content = Vec::new(); // An image goes into the message itself; the model looks at it. Any - // other file stays where the upload put it and the message says - // where, because the CLI can read a file by path and a model cannot - // be handed a trace, a log or a zip any other way. Named after the - // text, so the words come first, the way they were typed. + // other file stays where the upload put it and the message says where, + // because the CLI can read a file by path and a model cannot be handed + // a trace any other way. Named after the text, so the words come first. let mut files = Vec::new(); for id in &attachments { let sent = if crate::media::media_type_for(id).is_some() { @@ -583,9 +520,9 @@ impl Driver for ClaudeDriver { let line = json!({"type": "user", "message": {"role": "user", "content": content}}).to_string(); let mut queue = self.queue.lock().unwrap(); - // Saying so beats writing into a fifo that nothing is reading, - // which is what this used to do -- the message went nowhere and - // looked exactly like one that had been delivered. + // Saying so beats writing into a fifo that nothing is reading, which is + // what this used to do -- the message went nowhere and looked exactly + // like one that had been delivered. if queue.closed { drop(queue); let _ = self.sink.send(Event::Error { @@ -595,14 +532,13 @@ impl Driver for ClaudeDriver { return; } if queue.running { - // Into the running turn, now. Announced when the CLI shows it - // has been round the model again -- see `Queue`. + // Into the running turn, now. Announced when the CLI shows it has + // been round the model again -- see `Queue`. // - // The *waiting* is recorded here, though, which is the one - // thing that must not be left to the phone to remember: it put - // the bubble on screen from its own state, so leaving the - // session or restarting the app drew nothing pending while a - // message was still in the queue. + // The *waiting* is recorded here, which is the one thing that must + // not be left to the phone to remember: it drew the bubble from its + // own state, so leaving the session showed nothing pending while a + // message was still queued. let id = super::random_hex(); queue .awaiting @@ -618,9 +554,8 @@ impl Driver for ClaudeDriver { } queue.running = true; drop(queue); - // Nothing is in flight, so there is nothing to wait for: this - // message *is* the turn about to start, and it never had a - // `MessageQueued` to resolve. + // Nothing is in flight, so there is nothing to wait for: this message + // *is* the turn about to start, and it never had a `MessageQueued`. let _ = self.sink.send(Event::MessageTaken { id: None, text, @@ -632,15 +567,11 @@ impl Driver for ClaudeDriver { self.send_line(line); } - /// Never droppable, and that is a property of the design rather than - /// an omission. - /// - /// A message queued here has already been written to the CLI's stdin - /// -- see [`Queue`], where only the *announcement* waits -- because - /// that is what makes a steer reach the model at the next tool - /// boundary instead of at the end of the turn. A line in the fifo - /// cannot be recalled, so the only honest answers are "the session has - /// already been told" and "nothing is waiting under that id". + /// Never droppable, and that is a property of the design rather than an + /// omission. A message queued here has already been written to the CLI's + /// stdin -- see [`Queue`], where only the *announcement* waits -- because + /// that is what makes a steer reach the model at the next tool boundary. + /// A line in the fifo cannot be recalled. fn unqueue(&self, id: &str) -> Unqueued { let queue = self.queue.lock().unwrap(); if queue.awaiting.iter().any(|(waiting, ..)| waiting == id) { @@ -672,13 +603,12 @@ impl Driver for ClaudeDriver { } } - /// Anything queued behind the interrupted turn still goes: it was - /// typed deliberately, and dropping it would lose a message that never - /// reached the transcript, with nothing on screen to say so. + /// Anything queued behind the interrupted turn still goes: it was typed + /// deliberately, and dropping it would lose a message that never reached + /// the transcript. fn interrupt(&self) { - // Recorded before the request goes out, so the result it produces is - // read as the stop somebody asked for rather than as a failure -- - // see `Translator::interrupting`. + // Recorded before the request goes out, so the result it produces reads + // as the stop somebody asked for rather than as a failure. self.state.lock().unwrap().expect_interrupt(); self.send_control(json!({"subtype": "interrupt"}), None); } @@ -698,22 +628,18 @@ impl Driver for ClaudeDriver { } fn run_command(&self, text: &str) { - // Whatever the CLI's own vocabulary holds -- `/context`, `/usage`, - // a command added after this was written. It rides the same - // channel as `/compact` and `/rename` and starts a turn the same - // way, so the same bookkeeping applies; what it means is the - // CLI's business, not this file's. + // Whatever the CLI's own vocabulary holds. It rides the same channel as + // `/compact` and starts a turn the same way, so the same bookkeeping + // applies; what it means is the CLI's business. self.local_command(text.to_string()); } fn set_title(&self, title: &str) { - // The CLI's own mechanism, and a local command rather than a - // control request -- `set_session_name` is not a subtype it - // knows, measured by asking. It answers this the way it answers - // `/compact`: a fresh `init`, then a `result` for a turn with no - // model call in it, so the same "a turn is in flight" bookkeeping - // applies. A name with a newline in it would be two lines and the - // second would be a message, so it is refused rather than sent. + // The CLI's own mechanism, and a local command rather than a control + // request -- `set_session_name` is not a subtype it knows, measured by + // asking. It answers this the way it answers `/compact`. A name with a + // newline would be two lines and the second would be a message, so it + // is refused rather than sent. if title.contains('\n') { let _ = self.sink.send(Event::Error { message: "a session name cannot contain a line break".to_string(), @@ -728,13 +654,11 @@ impl Driver for ClaudeDriver { } fn clear(&self) { - // Nothing is emitted here on purpose: the transcript should - // record a clear that happened, not one that was asked for. The - // CLI announces it with a `conversation_reset` line, which - // `translate.rs` turns into `Event::Cleared`, and follows it with - // a fresh `init` whose new `session_id` the reader persists as - // the resume token -- so the next launch resumes the cleared - // conversation with nothing here to keep in step. + // Nothing is emitted here on purpose: the transcript should record a + // clear that happened, not one that was asked for. The CLI announces it + // with `conversation_reset`, which `translate.rs` turns into + // `Event::Cleared`, and follows it with a fresh `init` whose new + // `session_id` the reader persists as the resume token. self.local_command("/clear".to_string()); } @@ -744,10 +668,9 @@ impl Driver for ClaudeDriver { } fn detach(&self) { - // Stop reading and leave everything else exactly as it is. The - // process keeps its fifo (which it holds open itself), keeps - // writing its log, and keeps its record -- which is how the next - // run of this server finds it. See `Driver::detach`. + // Stop reading and leave everything else exactly as it is. The process + // keeps its fifo, keeps writing its log, and keeps its record -- which + // is how the next run of this server finds it. self.reading.store(false, Ordering::SeqCst); } @@ -760,19 +683,17 @@ impl Driver for ClaudeDriver { } } -/// Follows the process's stdout log, turning it into events, and is also -/// what decides whether the session is still running. +/// Follows the process's stdout log, turning it into events, and is also what +/// decides whether the session is still running. /// -/// One loop rather than a reader plus a monitor. After a restart there is -/// no `Child` to wait on -- the process was reparented away from this -/// server -- so liveness has to be a question asked of the record either -/// way, and asking it in two places is how the two answers come to -/// disagree. +/// One loop rather than a reader plus a monitor. After a restart there is no +/// `Child` to wait on -- the process was reparented away -- so liveness has to +/// be a question asked of the record either way, and asking it in two places is +/// how the two answers come to disagree. /// -/// Reading is resumable because the position is written down with the -/// process (see [`process::Record`]): everything before it is already in -/// the transcript, so a server coming back picks up exactly where the last -/// one stopped and the conversation has no hole in it. +/// Reading is resumable because the position is written down with the process: +/// everything before it is already in the transcript, so a server coming back +/// picks up exactly where the last one stopped. #[allow(clippy::too_many_arguments)] async fn follow( session_dir: PathBuf, @@ -786,10 +707,9 @@ async fn follow( ) { let stdout_path = session_dir.join(STDOUT_LOG); let stderr_path = session_dir.join(STDERR_LOG); - // Whatever is already in the stderr log has been logged by whichever - // run of this server was watching when it was written, so a reattach - // starts at the end of it rather than repeating it. The tail is still - // read from the file if the process dies, which is when it matters. + // Whatever is already in the stderr log was logged by whichever run of this + // server was watching, so a reattach starts at the end of it. The tail is + // still read from the file if the process dies, which is when it matters. let mut stderr_at = process::size_of(&stderr_path); let mut said_unknown = false; @@ -797,13 +717,11 @@ async fn follow( let (bytes, _) = match process::read_from(&stdout_path, offset) { Ok(found) => found, Err(err) => { - // Reported, not only logged. This is the end of the - // session's output as far as anyone watching is - // concerned, and a phone told nothing shows a session - // that is merely quiet -- indistinguishable from one - // thinking. The status is `Unknown` rather than `Exited` - // because the process may well still be running; what - // has failed is this server's ability to hear it. + // Reported, not only logged. This is the end of the session's + // output as far as anyone watching is concerned, and a phone + // told nothing shows a session that is merely quiet. The status + // is `Unknown` rather than `Exited` because the process may well + // still be running; what has failed is hearing it. tracing::error!("couldn't read {}: {err:#}", stdout_path.display()); let _ = sink.send(Event::Error { message: format!( @@ -823,18 +741,15 @@ async fn follow( } }; // Only whole lines, and the offset stops at the last newline -- so a - // line the process is halfway through writing is simply read again - // next pass. Deliberately *not* held in memory between passes: the - // offset would then have to point behind the bytes being held, and - // the next read would return them a second time to be prepended to - // the copy already there. It is also what makes the position - // crash-safe, since it never claims a partial line was handled. + // line the process is halfway through writing is read again next pass. + // Deliberately *not* held in memory between passes: the offset would + // then have to point behind the bytes being held. It is also what makes + // the position crash-safe. // - // Counted in bytes rather than on a decoded string: a read can cut - // a multi-byte character in half, and the replacement character - // that decoding puts there is a different length from what it - // replaced -- which would slide the offset out of step with the - // file for the rest of the session. + // Counted in bytes rather than on a decoded string: a read can cut a + // multi-byte character in half, and the replacement character is a + // different length from what it replaced -- which would slide the offset + // out of step with the file for the rest of the session. let complete = complete_lines(&bytes); for line in String::from_utf8_lossy(&bytes[..complete]).lines() { @@ -853,10 +768,9 @@ async fn follow( process::write(&session_dir, &record); } - // Diagnostics only, and the tail of it is what an exit report - // carries -- so it is read from the file rather than kept in - // memory, which also means a reattached session can still explain - // a failure it did not witness. + // Diagnostics only, and the tail of it is what an exit report carries -- + // so it is read from the file rather than kept in memory, which means a + // reattached session can still explain a failure it did not witness. if let Ok((bytes, at)) = process::read_from(&stderr_path, stderr_at) && at != stderr_at { @@ -872,10 +786,9 @@ async fn follow( process::Liveness::Alive => said_unknown = false, // Drain whatever it wrote on the way out before saying so. // - // Progress, not "there were bytes": a process that died - // mid-line leaves a partial one that is re-read every pass and - // never completes, so waiting on a non-empty read would wait - // for ever and the exit would never be reported. + // Progress, not "there were bytes": a process that died mid-line + // leaves a partial one that is re-read every pass and never + // completes, so waiting on a non-empty read would wait for ever. process::Liveness::Dead if complete > 0 => {} process::Liveness::Dead => { queue.lock().unwrap().close(&sink, "the session ended"); @@ -892,10 +805,9 @@ async fn follow( return; } // The record is there and the machine will not say whether the - // process behind it is. Reported rather than guessed: calling - // it exited would invite starting a second one against the - // same conversation, which is the expensive mistake here. - // Kept polling, so it resolves itself if the answer comes back. + // process behind it is. Reported rather than guessed: calling it + // exited would invite starting a second one against the same + // conversation. Kept polling, so it resolves itself. process::Liveness::Unknown => { if !said_unknown { said_unknown = true; @@ -909,22 +821,16 @@ async fn follow( } } -/// How many leading bytes of `bytes` form complete lines. +/// How many leading bytes of `bytes` form complete lines. The offset only ever +/// advances by this, which is what lets a read land anywhere -- mid-line, +/// mid-character -- without the reader losing its place. /// -/// The offset only ever advances by this, which is what lets a read land -/// anywhere -- mid-line, mid-character -- without the reader losing its -/// place. See the call site for why the remainder is not kept. -/// -/// A line ends at `\n` and at nothing else, deliberately. This stream is -/// JSONL: a record is a line, and something terminated by a bare `\r` is -/// not a record, so treating one as a line would hand `serde_json` a -/// fragment. The accepted consequence is that such a line is held here -/// forever rather than being reported -- and it is worth knowing what -/// that would look like, because it looks like nothing: the session goes -/// quiet with the process healthy, no error anywhere, and the cause is a -/// line splitter, which is not where anybody would look. A progress -/// indicator is the usual reason a program writes one (`\r` is how it -/// redraws in place), and the CLI has never written one here. +/// A line ends at `\n` and at nothing else, deliberately: this stream is JSONL, +/// so something terminated by a bare `\r` is not a record and treating one as a +/// line would hand `serde_json` a fragment. The accepted consequence is that +/// such a line is held here forever, and it is worth knowing what that looks +/// like, because it looks like nothing: the session goes quiet with the process +/// healthy and no error anywhere. The CLI has never written one here. fn complete_lines(bytes: &[u8]) -> usize { bytes .iter() @@ -943,12 +849,9 @@ fn translate_line( queue: &Arc>, ) -> bool { let Ok(message) = serde_json::from_str::(line) else { - // By characters, not bytes: the CLI emits plenty of non-ASCII, and - // a byte slice that lands mid-character panics -- inside `follow`, - // which is the task reading this session's output, so the session - // would go permanently deaf with nothing on screen to say so. The - // other three truncations in this codebase (`setups.rs`, - // `translate.rs`, `import.rs`) already do it this way. + // By characters, not bytes: the CLI emits plenty of non-ASCII, and a + // byte slice that lands mid-character panics -- inside `follow`, so the + // session would go permanently deaf with nothing on screen to say so. let shown: String = line.chars().take(200).collect(); tracing::warn!("unparseable claude output line: {shown}"); return true; @@ -964,19 +867,19 @@ fn translate_line( if let Some(session_id) = new_session_id { write_resume_token(session_dir, &session_id); } - // A turn the CLI began by itself, said one line earlier than anything - // else could say it. + // A turn the CLI began by itself, said one line earlier than anything else + // could say it. // // The CLI picks the conversation back up with nothing written to it -- - // measured: a backgrounded `sleep` finished nine seconds after the - // turn's result and it started again unprompted. It announces that with - // an `init`, and the first assistant text follows about a second and a - // half later; until this, that second and a half read as idle, which is - // long enough to send a command into and have it read as text. + // measured: a backgrounded `sleep` finished nine seconds after the turn's + // result and it started again unprompted. It announces that with an `init`, + // and the first assistant text follows about a second and a half later; + // until this, that read as idle, which is long enough to send a command into + // and have it read as text. // - // `before.is_some()` is what separates this from the `init` at startup, - // which announces a session that is *waiting*. Our own `/clear` also - // produces one, and is excluded by `running` already being true -- + // `before.is_some()` separates this from the `init` at startup. Our own + // `/clear` also produces one, and is excluded by `running` already being + // true. // `local_command` set it before the line went out. if opens_a_turn_by_itself(&message, before.is_some()) { let started = { @@ -997,20 +900,16 @@ fn translate_line( return false; } } - // The steer is announced where the CLI opens the model call that read - // it, and the announcement goes out *before* that call's output, so - // the message sits above what it produced and below what it did not. - // - // This line carries no events of its own, which is what makes it the - // right place: everything the previous call produced -- its text, its - // tool calls, their results -- is already recorded above. + // The steer is announced where the CLI opens the model call that read it, + // and *before* that call's output, so the message sits above what it + // produced and below what it did not. This line carries no events of its + // own, which is what makes it the right place. if opens_a_model_call && !announce_steers(queue, sink) { return false; } for event in events { - // A turn nobody here started -- see `proves_a_turn`. Said before - // the event that proves it, for the same reason a steer is: the - // session was already working when it produced this. + // A turn nobody here started -- see `proves_a_turn`. Said before the + // event that proves it, for the same reason a steer is. let started = { let mut queue = queue.lock().unwrap(); let started = proves_a_turn(&event) && !queue.running && !queue.closed; @@ -1034,11 +933,10 @@ fn translate_line( state: SessionStatus::Idle } ) { - // The case that must not be missed: a message written after - // the final model call of a turn has no later `message_start` - // to prove anything, so without this it would never be - // announced at all. The end of the turn is where it belongs - // anyway -- nothing above it came after the message. + // The case that must not be missed: a message written after the + // final model call of a turn has no later `message_start` to prove + // anything, so without this it would never be announced at all. The + // end of the turn is where it belongs anyway. if !announce_steers(queue, sink) { return false; } @@ -1053,12 +951,10 @@ fn translate_line( /// Whether this line is the CLI announcing work it started on its own. /// -/// `system/init` is how it says a conversation is beginning, and it sends -/// one in three cases: at startup, after a `/clear`, and when it picks the -/// conversation back up by itself. Only the third is a turn nobody here -/// asked for. `already_started` -- whether the translator had a session id -/// before this line -- rules out the first, and the caller's `running` -/// check rules out the second. +/// `system/init` is how it says a conversation is beginning, and it sends one +/// at startup, after a `/clear`, and when it picks the conversation back up by +/// itself. Only the third is a turn nobody here asked for: `already_started` +/// rules out the first, and the caller's `running` check rules out the second. fn opens_a_turn_by_itself(message: &Value, already_started: bool) -> bool { already_started && message.get("type").and_then(Value::as_str) == Some("system") @@ -1067,22 +963,16 @@ fn opens_a_turn_by_itself(message: &Value, already_started: bool) -> bool { /// Whether this event could only have come from a turn in flight. /// -/// The turn this side starts is announced where it is started, and that -/// covers the common case and nothing else. Everything below happens -/// without a phone asking for it: a compaction the CLI decided on by -/// itself, a session adopted while it was already mid-turn, a message -/// that reached the conversation by some route other than this server -- -/// another agent writing to it, or somebody at the terminal. In all of -/// them the CLI is plainly working and the only thing that would ever -/// have said so is a `Running` nobody sent, so the session sits there -/// reading as idle until the turn ends. +/// The turn this side starts is announced where it is started, and that covers +/// the common case and nothing else. Everything below happens without a phone +/// asking: a compaction the CLI decided on itself, a session adopted mid-turn, +/// a message that reached the conversation by another route. In all of them the +/// CLI is plainly working and the only thing that would have said so is a +/// `Running` nobody sent, so the session reads as idle until the turn ends. /// -/// So the driver says it from what it observes rather than from what it -/// was asked to do. Deliberately a wider set than what announces a steer -/// (see [`announce_steers`]): any sign of work proves a turn is running, -/// while only a `message_start` proves a line written a moment ago has -/// been read. `Idle` is the pair to this -- it is where `running` goes -/// back to false, a few lines above where it is set here. +/// Deliberately a wider set than what announces a steer: any sign of work +/// proves a turn is running, while only a `message_start` proves a line written +/// a moment ago has been read. fn proves_a_turn(event: &Event) -> bool { matches!( event, @@ -1098,13 +988,12 @@ fn proves_a_turn(event: &Event) -> bool { ) } -/// Records every message written since the last announcement, in the -/// order it was written. False means the session has been torn down. +/// Records every message written since the last announcement, in the order it +/// was written. False means the session has been torn down. /// -/// Called from the two places that prove the CLI has consumed them: the -/// start of a new model call, and the end of the turn. Both are in -/// [`translate_line`], and the pair is the whole of the rule -- a steer -/// announced anywhere else lands above output that predates it. +/// Called from the two places that prove the CLI has consumed them: the start +/// of a new model call, and the end of the turn. The pair is the whole of the +/// rule -- a steer announced anywhere else lands above output that predates it. fn announce_steers(queue: &Arc>, sink: &EventSink) -> bool { let taken: Vec<(String, String, Vec)> = { let mut queue = queue.lock().unwrap(); @@ -1125,12 +1014,10 @@ fn announce_steers(queue: &Arc>, sink: &EventSink) -> bool { true } -/// The end of the stderr log, for an exit report a person reads. -/// -/// Bounded because this is held in a message; trimmed of blank lines at -/// both ends because a shell's error ends with one, so anything reporting -/// "the last line" reports nothing at all. A failing `cd` cost an evening -/// to exactly that. +/// The end of the stderr log, for an exit report a person reads. Bounded +/// because this is held in a message; trimmed of blank lines at both ends +/// because a shell's error ends with one, so anything reporting "the last +/// line" reports nothing at all. A failing `cd` cost an evening to that. fn stderr_tail(path: &Path) -> String { let Ok(text) = std::fs::read_to_string(path) else { return String::new(); @@ -1147,23 +1034,20 @@ fn stderr_tail(path: &Path) -> String { tail_of(&kept) } -/// Creates the stdin fifo if it is not already there, and opens it -/// read-write for the process to inherit. +/// Creates the stdin fifo if it is not already there, and opens it read-write +/// for the process to inherit. /// -/// Read-write is the whole trick, and it is not an accident of -/// convenience: a fifo opened read-only delivers EOF as soon as the last -/// writer closes, so the process would exit the moment this server did -- -/// which is exactly what leaving it running has to prevent. Holding it -/// open for writing as well means the process is its own last writer and -/// never sees the end of its input. +/// Read-write is the whole trick: a fifo opened read-only delivers EOF as soon +/// as the last writer closes, so the process would exit the moment this server +/// did -- exactly what leaving it running has to prevent. Holding it open for +/// writing means the process is its own last writer. fn make_fifo(path: &Path) -> Result { if !path.exists() { let c_path = std::ffi::CString::new(path.as_os_str().as_encoded_bytes()) .with_context(|| format!("{} is not a usable path", path.display()))?; - // SAFETY: a nul-terminated path this call only reads, and a mode - // with no bits the kernel can object to. Owner-only, like - // everything else in a session directory: this carries what the - // person typed. + // SAFETY: a nul-terminated path this call only reads, and a mode with + // no bits the kernel can object to. Owner-only, like everything else in + // a session directory: this carries what the person typed. let made = unsafe { libc::mkfifo(c_path.as_ptr(), 0o600) }; if made != 0 { return Err(std::io::Error::last_os_error()) @@ -1207,9 +1091,8 @@ pub(super) fn write_resume_token(session_dir: &Path, session_id: &str) { /// Where an uploaded attachment is, as a path the CLI can be told. /// /// Absolute, because the CLI's working directory is the session's and the -/// attachments are not in it. Refused rather than resolved when the id is -/// not one this server would have written -- see -/// `SessionManager::save_attachment` -- so a crafted id cannot name a file +/// attachments are not in it. Refused rather than resolved when the id is not +/// one this server would have written, so a crafted id cannot name a file /// outside the session. fn attachment_path(session_dir: &Path, id: &str) -> Result { if !id @@ -1220,10 +1103,9 @@ fn attachment_path(session_dir: &Path, id: &str) -> Result { anyhow::bail!("invalid attachment id"); } let path = session_dir.join("attachments").join(id); - // A file copied to the session's own machine is named where it landed - // there -- `routes::upload_attachment` writes that down beside it -- - // because the path has to be one the CLI can open, not one this server - // can. + // A file copied to the session's own machine is named where it landed there + // -- `routes::upload_attachment` writes that down beside it -- because the + // path has to be one the CLI can open, not one this server can. let shipped = path.with_file_name(format!("{id}.remote")); if let Ok(remote) = std::fs::read_to_string(&shipped) { return Ok(PathBuf::from(remote.trim())); @@ -1276,9 +1158,9 @@ mod tests { assert!(attachment_path(dir.path(), "missing.bin").is_err()); } - /// Drives real CLI output lines through the reader and collects what - /// came out, which is the only way to check the wiring between "the - /// CLI said this" and "the transcript records that". + /// Drives real CLI output lines through the reader and collects what came + /// out, which is the only way to check the wiring between "the CLI said + /// this" and "the transcript records that". fn events_from_lines(lines: &[&str]) -> Vec { let dir = tempfile::tempdir().expect("temp dir"); let state = Arc::new(Mutex::new(Translator::new(dir.path().to_path_buf()))); @@ -1295,12 +1177,10 @@ mod tests { events } - /// Feeds lines through the reader, running `interject` between two of - /// them, and returns what came out. - /// - /// The hook is what makes a steer testable at all: what matters is - /// not which events a line produces but *where* a message written - /// part-way through the stream ends up among them. + /// Feeds lines through the reader, running `interject` between two of them, + /// and returns what came out. The hook is what makes a steer testable at + /// all: what matters is not which events a line produces but *where* a + /// message written part-way through the stream ends up among them. fn events_with_interjection( lines: &[&str], after: usize, @@ -1325,12 +1205,10 @@ mod tests { events } - /// One assistant message, streamed: two text deltas, then the - /// `tool_use` it ends with, then that call's result. - /// - /// Written out rather than shortened because the point of both tests - /// below is the *order*, and the shape of a real turn is what makes - /// the order mean anything. Recorded from 2.1.237. + /// One assistant message, streamed: two text deltas, then the `tool_use` it + /// ends with, then that call's result. Written out rather than shortened + /// because the point of both tests below is the *order*, and the shape of a + /// real turn is what makes the order mean anything. Recorded from 2.1.237. const STREAMED_CALL: &[&str] = &[ r#"{"type":"stream_event","event":{"type":"message_start"},"session_id":"s","parent_tool_use_id":null}"#, r#"{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Let me "}},"session_id":"s","parent_tool_use_id":null}"#, @@ -1343,10 +1221,8 @@ mod tests { /// answer's tool call and its result, not among them. /// /// The message reaches the CLI immediately; what waits is saying so. - /// Everything the CLI emits after it was typed still belongs to a - /// model call that had not read it -- the rest of the text, the - /// `tool_use` the model had already committed to, the result that - /// came back. `message_start` is the first line that proves the next + /// Everything emitted after it was typed still belongs to a model call that + /// had not read it. `message_start` is the first line that proves the next /// call has it, so that is where the announcement goes. #[test] fn a_steer_is_recorded_below_the_call_that_had_not_read_it() { @@ -1373,9 +1249,9 @@ mod tests { .unwrap_or_else(|| panic!("nothing matched in {events:?}")) }; let taken = at(|e| matches!(e, Event::MessageTaken { .. })); - // Named, not just announced: the phone has a waiting bubble on screen for this message - // and clears the one with this id. Matching on the text instead would clear the wrong - // bubble whenever the same thing was sent twice. + // Named, not just announced: the phone has a waiting bubble for this + // message and clears the one with this id. Matching on the text would + // clear the wrong bubble whenever the same thing was sent twice. assert!( matches!( &events[taken], @@ -1402,12 +1278,9 @@ mod tests { } /// A steer written after the turn's last model call is still recorded. - /// - /// Nothing further is coming, so no `message_start` will ever prove - /// it was read -- and a message that is only recorded when announced - /// would otherwise vanish, leaving a phone drawing it as still - /// waiting forever. The end of the turn is also where it belongs: - /// nothing above it happened after it was typed. + /// Nothing further is coming, so no `message_start` will ever prove it was + /// read -- and a message only recorded when announced would vanish, leaving + /// a phone drawing it as waiting forever. #[test] fn a_steer_with_no_model_call_left_is_recorded_at_the_end_of_the_turn() { let mut lines = STREAMED_CALL.to_vec(); @@ -1444,15 +1317,12 @@ mod tests { ); } - /// The divider comes from the CLI announcing the reset, not from an - /// `init` arriving. - /// - /// Measured against 2.1.237: `/clear` emits `conversation_reset`, + /// The divider comes from the CLI announcing the reset, not from an `init` + /// arriving. Measured against 2.1.237: `/clear` emits `conversation_reset`, /// then a fresh `init` carrying a new session id. Watching the id be - /// replaced would work, but it reads the event through one of its - /// side effects; the announcement says so directly and arrives first, - /// so the divider lands above the new conversation rather than below - /// its opening line. + /// replaced would work, but it reads the event through a side effect; the + /// announcement says so directly and arrives first, so the divider lands + /// above the new conversation. #[test] fn a_conversation_reset_is_what_records_a_clear() { let events = events_from_lines(&[ @@ -1468,14 +1338,10 @@ mod tests { } /// An `init` on its own never records a clear, whatever id it carries. - /// - /// Three ways one arrives and none of them is a cleared conversation: - /// the first init of a session, the one a compaction re-announces - /// carrying the *same* id, and the one that follows a resume. Reading - /// any of them as a clear would open sessions with a divider - /// announcing something that never happened, or draw one on top of a - /// compaction's own mark and tell the reader the conversation had been - /// dropped when it had been summarised. + /// Three ways one arrives and none is a cleared conversation: the first + /// init of a session, the one a compaction re-announces carrying the *same* + /// id, and the one that follows a resume. Reading any as a clear would tell + /// the reader a conversation had been dropped when it had been summarised. #[test] fn an_init_alone_is_never_a_clear() { for ids in [["first", "first"], ["first", "second"]] { @@ -1493,10 +1359,9 @@ mod tests { } } - /// The failure this exists for: a shell's complaint ends with a blank - /// line, so reporting "the last line of stderr" reported nothing, and - /// the phone showed a bare exit status while the reason sat in the - /// server's log. + /// The failure this exists for: a shell's complaint ends with a blank line, + /// so reporting "the last line of stderr" reported nothing, and the phone + /// showed a bare exit status while the reason sat in the server's log. #[test] fn the_report_keeps_the_message_and_not_the_blank_line_after_it() { let fish_cd_failure = [ @@ -1534,11 +1399,10 @@ mod tests { #[test] fn a_stream_read_in_arbitrary_chunks_yields_each_line_once() { - // The property the reading position has to have: however a write - // is split -- mid-line, or mid-character -- every line comes out - // exactly once and in order. Chunked at every prime-ish size so - // the cuts land in different places, including inside the - // multi-byte character. + // The property the reading position has to have: however a write is + // split -- mid-line, or mid-character -- every line comes out exactly + // once and in order. Chunked at every prime-ish size so the cuts land in + // different places, including inside the multi-byte character. let stream = "{\"a\":1}\n{\"b\":\"caf\u{e9}\"}\n{\"c\":3}\n"; for chunk in [1usize, 2, 3, 5, 7, 11, 1000] { let mut offset = 0usize; @@ -1547,8 +1411,8 @@ mod tests { let mut available = 0usize; while available < bytes.len() { available = (available + chunk).min(bytes.len()); - // What a read from the recorded offset returns: the file - // as far as it has been written, from where we left off. + // What a read from the recorded offset returns: the file as far + // as it has been written, from where we left off. let unread = &bytes[offset..available]; let complete = complete_lines(unread); for line in String::from_utf8_lossy(&unread[..complete]).lines() { @@ -1567,8 +1431,8 @@ mod tests { #[test] fn an_incomplete_line_advances_nothing() { - // Nothing to do yet, and crucially the position does not move -- - // so a crash here re-reads the line rather than skipping it. + // Nothing to do yet, and crucially the position does not move -- so a + // crash here re-reads the line rather than skipping it. assert_eq!(complete_lines(b"{\"partial\": tru"), 0); assert_eq!(complete_lines(b""), 0); // And a complete line followed by a partial one advances only past @@ -1591,8 +1455,8 @@ mod tests { .push_back(("q2".into(), "second".into(), Vec::new())); queue.close(&sink, "the session ended"); - // Named rather than counted, because these never reached the - // transcript: this message is the only record they existed. + // Named rather than counted, because these never reached the transcript: + // this message is the only record they existed. let Some(Event::Error { message }) = received.try_recv().ok() else { panic!("closing a queue holding messages must report them"); }; @@ -1603,18 +1467,18 @@ mod tests { "{message}" ); - // And the flag is cleared, so a later message is refused with a - // reason rather than queued behind a turn that will never end. + // And the flag is cleared, so a later message is refused with a reason + // rather than queued behind a turn that will never end. assert!(!queue.running); assert!(queue.closed); } #[test] fn a_turn_this_side_did_not_start_still_reports_as_running() { - // The case: a session picked up while it was already working, or - // one another agent wrote to. Nothing called `send_user_message`, - // so the only thing that can say the session is busy is what it - // is observed doing. + // The case: a session picked up while it was already working, or one + // another agent wrote to. Nothing called `send_user_message`, so the + // only thing that can say the session is busy is what it is observed + // doing. let dir = tempfile::tempdir().expect("tempdir"); let (sink, mut received) = mpsc::unbounded_channel(); let state = Arc::new(Mutex::new(Translator::new(dir.path().to_path_buf()))); @@ -1634,8 +1498,8 @@ mod tests { Some(Event::AssistantText { .. }) )); - // Once only: the turn is known to be running now, and a status per - // delta would be a status per word. + // Once only: the turn is known to be running now, and a status per delta + // would be a status per word. assert!(translate_line(text, dir.path(), &state, &sink, &queue)); assert!(matches!( received.try_recv().ok(), @@ -1657,10 +1521,9 @@ mod tests { #[test] fn output_from_a_process_that_has_gone_does_not_revive_the_turn() { - // `close` is what says the process is gone and reports the - // messages that died with it. Anything still in the pipe after - // that must not put the session back to work, because there is - // nothing left to do the work. + // `close` is what says the process is gone and reports the messages that + // died with it. Anything still in the pipe after that must not put the + // session back to work. let dir = tempfile::tempdir().expect("tempdir"); let (sink, mut received) = mpsc::unbounded_channel(); let state = Arc::new(Mutex::new(Translator::new(dir.path().to_path_buf()))); @@ -1681,8 +1544,8 @@ mod tests { let (sink, mut received) = mpsc::unbounded_channel(); let mut queue = Queue::default(); queue.close(&sink, "the session ended"); - // A session that exits with nothing held has lost nothing, and an - // error saying so would be noise on every ordinary exit. + // A session that exits with nothing held has lost nothing, and an error + // saying so would be noise on every ordinary exit. assert!(received.try_recv().is_err()); assert!(queue.closed); } diff --git a/server/src/session/claude/translate.rs b/server/src/session/claude/translate.rs index 692ed6c..3baae30 100644 --- a/server/src/session/claude/translate.rs +++ b/server/src/session/claude/translate.rs @@ -1,16 +1,13 @@ //! The stream-json dialect: CLI lines in, common [`Event`]s out. //! //! Split from the driver beside it because the two change for unrelated -//! reasons. This half moves when the CLI's wire format does -- a new -//! message subtype, a field that changed shape -- and that is what the -//! tests at the bottom pin, replaying recorded lines. The driver half -//! moves when spawning, resuming or shutting down changes, and never -//! reads a line itself. +//! reasons. This half moves when the CLI's wire format does, which is what the +//! tests at the bottom pin by replaying recorded lines; the driver half moves +//! when spawning, resuming or shutting down changes. //! -//! The one side effect here is saving images a tool result carries into -//! the session directory (they would bloat the transcript as base64); -//! everything else is pure, which is what makes the mapping testable -//! without a process. +//! The one side effect here is saving images a tool result carries into the +//! session directory; everything else is pure, which is what makes the mapping +//! testable without a process. use std::collections::HashMap; use std::path::{Path, PathBuf}; @@ -21,17 +18,14 @@ use super::super::driver::{Event, QuestionOption, SessionStatus, context_tokens} /// Whether this line is the CLI opening a fresh model call. /// -/// `message_start` begins one assistant message, and the CLI sends the -/// previous call's tool results back before it opens the next -- so this -/// is the first moment at which anything written since the last one can -/// have been read. Nothing earlier will do: the text deltas and the -/// `tool_use` block of a message *already in flight* keep arriving after -/// a steer is written, and none of them saw it. +/// `message_start` begins one assistant message, and the CLI sends the previous +/// call's tool results back before opening the next -- so this is the first +/// moment at which anything written since the last one can have been read. +/// Nothing earlier will do: the deltas and `tool_use` of a message *already in +/// flight* keep arriving after a steer is written, and none of them saw it. /// -/// Only present because the driver passes `--include-partial-messages`. -/// Without it there are no `stream_event` lines at all and this is never -/// true, which is why the caller keeps a fallback that does not depend on -/// it. +/// Only present because the driver passes `--include-partial-messages`, which +/// is why the caller keeps a fallback that does not depend on it. pub(super) fn starts_a_model_call(message: &Value) -> bool { message.get("type").and_then(Value::as_str) == Some("stream_event") && message["event"].get("type").and_then(Value::as_str) == Some("message_start") @@ -46,71 +40,56 @@ pub(super) enum AnswerOutcome { Unknown, } -/// A setting a control request asked for, held until the CLI says -/// whether it took. -/// -/// The CLI answers `set_model` with a bare success -- no value -- so the +/// A setting a control request asked for, held until the CLI says whether it +/// took. The CLI answers `set_model` with a bare success -- no value -- so the /// only way to report what was accepted is to remember what was asked. -/// `set_permission_mode` does echo its mode back, and so does a -/// `system/status` line a moment later; both are handled where they -/// arrive, and this covers the one that says nothing. +/// `set_permission_mode` does echo its mode back. pub(super) enum Setting { Model(String), PermissionMode(String), } -/// A `can_use_tool` request we've surfaced to the phone and not yet -/// answered. For plain permissions there is one implicit question -/// (Allow/Deny); for AskUserQuestion, one per entry in `questions`. +/// A `can_use_tool` request we've surfaced to the phone and not yet answered. +/// For plain permissions there is one implicit question (Allow/Deny); for +/// AskUserQuestion, one per entry in `questions`. struct PendingRequest { request_id: String, input: Value, - /// Question text per sub-question, in order -- the keys the answers - /// map uses. Empty for a plain permission request. + /// Question text per sub-question, in order -- the keys the answers map + /// uses. Empty for a plain permission request. questions: Vec, answers: HashMap, } -/// Translation state: stream-json lines in, common events out. The one -/// side effect is saving images a tool result carries into the session -/// dir (they'd bloat the transcript as base64); everything else is pure, -/// so the dialect mapping is unit-testable from recorded lines. +/// Translation state: stream-json lines in, common events out. pub(super) struct Translator { pub(super) session_id: Option, pending: HashMap, - /// Settings asked for and not yet answered, by request id. Its path - /// out is the response: every entry is removed when one arrives, - /// whether it succeeded or failed. + /// Settings asked for and not yet answered, by request id. Its path out is + /// the response: every entry is removed when one arrives, whether it + /// succeeded or failed. asked: HashMap, /// Whether this side asked the turn to stop. /// /// The CLI reports an interrupted turn the same way it reports one that - /// broke -- a `result` with `is_error` set -- so the line itself cannot - /// tell them apart, and a person who pressed Stop was shown "the turn - /// ended with an error" for doing exactly what the button says. What - /// separates them is not in the message at all: it is that *we* asked. - /// So the driver says so before the request goes out, the same way it - /// does for a setting, and this remembers it until the result lands. + /// broke -- a `result` with `is_error` set -- so the line cannot tell them + /// apart, and somebody who pressed Stop was shown "the turn ended with an + /// error". What separates them is that *we* asked. /// - /// Its path out is that result -- set by `expect_interrupt`, cleared by - /// the next `result` whichever way it went, so a genuine failure in a - /// later turn is still reported. + /// Its path out is that result, so a genuine failure in a later turn is + /// still reported. interrupting: bool, - /// The input side of the newest assistant message, waiting for the - /// `result` that ends the turn to carry it out. + /// The input side of the newest assistant message, waiting for the `result` + /// that ends the turn to carry it out. /// - /// Read from the assistant message rather than from the result's own - /// usage, which is the whole turn added up: measured on 2026-08-30 - /// against CLI 2.1.237, a two-message turn reported - /// `cache_read_input_tokens` of 40,211 in its result, being 14,259 and - /// 25,952 from the two messages -- the same conversation counted - /// twice. The model never held 40,211; it held 26,131, which is the - /// last message's three input figures. A turn with ten tool calls - /// would overstate it tenfold. + /// Read from the assistant message rather than the result's own usage, + /// which is the whole turn added up: measured on 2026-08-30 against 2.1.237, + /// a two-message turn reported `cache_read_input_tokens` of 40,211, being + /// 14,259 and 25,952 -- the same conversation counted twice. The model held + /// 26,131. A turn with ten tool calls would overstate it tenfold. /// - /// Its path out is that result, which takes it -- so a turn whose - /// messages carried no usage reports none rather than repeating the - /// previous turn's. + /// Its path out is that result, so a turn whose messages carried no usage + /// reports none rather than repeating the previous turn's. context: Option, session_dir: PathBuf, } @@ -128,17 +107,15 @@ impl Translator { } /// Remembers what a control request was for, so its answer can say so. - /// - /// Called before the request goes out, not after: the reader thread is - /// already running and a fast CLI can answer before this side gets - /// back to it. + /// Called before the request goes out: the reader thread is already running + /// and a fast CLI can answer before this side gets back to it. pub(super) fn expect_setting(&mut self, request_id: String, setting: Setting) { self.asked.insert(request_id, setting); } - /// Says that the turn about to end was stopped on purpose -- see - /// [`Translator::interrupting`]. Called before the request goes out, - /// for the reason [`Translator::expect_setting`] gives. + /// Says that the turn about to end was stopped on purpose. Called before + /// the request goes out, for the reason [`Translator::expect_setting`] + /// gives. pub(super) fn expect_interrupt(&mut self) { self.interrupting = true; } @@ -154,14 +131,11 @@ impl Translator { } match message.get("type").and_then(Value::as_str) { Some("system") => self.translate_system(message), - // The CLI's own announcement that `/clear` took effect, sent - // just before the fresh `init` that carries the new - // session_id. Measured against 2.1.237 rather than inferred: - // this used to watch for the id being *replaced*, which is the - // same event seen through one of its side effects. Taking the - // announcement instead means the transcript's divider is the - // CLI saying "I did this", and it lands before the new init - // rather than after it. + // The CLI's own announcement that `/clear` took effect, sent just + // before the fresh `init` carrying the new session_id. Measured + // against 2.1.237: this used to watch for the id being *replaced*, + // which is the same event seen through a side effect. The + // announcement lands before the new init rather than after it. Some("conversation_reset") => vec![Event::Cleared], Some("stream_event") => self.translate_stream_event(&message["event"]), Some("assistant") => self.translate_assistant(&message["message"]), @@ -170,8 +144,8 @@ impl Translator { Some("control_response") => { let response = &message["response"]; // Answered either way, so the request stops being pending - // either way -- a rejected setting that stayed here would - // be applied by the next request that reused its id. + // either way -- a rejected setting that stayed here would be + // applied by the next request that reused its id. let asked = response .get("request_id") .and_then(Value::as_str) @@ -185,9 +159,9 @@ impl Translator { message: format!("claude rejected a request: {error}"), }]; } - // Success, so the setting this request asked for is now - // the session's, and this is the only place that says so: - // the response carries no value of its own for a model. + // Success, so the setting this request asked for is now the + // session's, and this is the only place that says so: the + // response carries no value of its own for a model. match asked { Some(Setting::Model(model)) => vec![Event::Settings { model: Some(model), @@ -195,11 +169,10 @@ impl Translator { }], Some(Setting::PermissionMode(mode)) => vec![Event::Settings { model: None, - // The CLI echoes this one, and its answer wins: - // `auto` and `manual` are names it accepts on the - // way in and reports back under another name, so - // repeating the request here would show a mode the - // session is not in. + // The CLI echoes this one, and its answer wins: `auto` + // and `manual` are names it accepts on the way in and + // reports back under another name, so repeating the + // request would show a mode the session is not in. permission_mode: Some( response["response"]["mode"] .as_str() @@ -223,28 +196,22 @@ impl Translator { let mut events = Vec::new(); // A turn another agent started, which is only knowable here. // - // Measured against CLI 2.1.237 (2026-08-31) by sending a - // real cross-session message to a real stream-json session: - // the CLI emits no `user` record for it, and nothing in the - // partial-message stream mentions it either. The whole of - // it arrives as an `origin` object on the turn's `result`, - // in the same shape the session file records -- so this is - // `import::peer_message` reading a different record. + // Measured against 2.1.237 (2026-08-31) by sending a real + // cross-session message to a real stream-json session: the CLI + // emits no `user` record for it and nothing in the + // partial-message stream mentions it. The whole of it arrives as + // an `origin` object on the turn's `result`, in the same shape + // the session file records -- so this is `import::peer_message` + // reading a different record. // - // The cost is the position: the note lands after the reply - // it caused rather than above it, because at no earlier - // point in the turn does the CLI say why the turn started. - // Taken deliberately over the alternative, which is a - // second reader tailing the CLI's own session file for the - // one record stdout does not carry -- two sources of truth - // for one conversation, and a poll per live session. What - // it buys is the thing that was missing entirely: a session - // that starts working on something nobody on this phone - // asked for is otherwise unexplainable from the phone. + // The cost is the position: the note lands after the reply it + // caused, because at no earlier point does the CLI say why the + // turn started. Taken deliberately over a second reader tailing + // the CLI's own session file, which is two sources of truth for + // one conversation and a poll per live session. // - // Only peer-caused turns carry it: measured over a real - // session's stdout, four ordinary results and no `origin` - // between them. + // Only peer-caused turns carry it: four ordinary results over a + // real session's stdout had no `origin` between them. if let Some(peer) = crate::session::import::peer_message(message) { events.push(peer); } @@ -278,37 +245,35 @@ impl Translator { } } - /// The CLI's own notices: which session this is, and what it is doing - /// that is not a turn. + /// The CLI's own notices: which session this is, and what it is doing that + /// is not a turn. /// - /// Compaction is the whole of that second kind, and it is announced - /// rather than inferred. Measured against CLI 2.1.237 (2026-08-29) by - /// driving a session through `/compact`, one produces in order: + /// Compaction is the whole of that second kind, and it is announced rather + /// than inferred. Measured against 2.1.237 (2026-08-29) by driving a session + /// through `/compact`, one produces in order: /// /// - `{"subtype":"status","status":"compacting"}` -- the start; - /// - `{"subtype":"status","status":null,"compact_result":"success"}`, - /// or `"failed"` with a `compact_error` saying why -- the end; + /// - `{"subtype":"status","status":null,"compact_result":"success"}`, or + /// `"failed"` with a `compact_error` -- the end; /// - a fresh `init` carrying the same `session_id`; - /// - `{"subtype":"compact_boundary","compact_metadata":{…}}` with the - /// token counts, and only when it succeeded; - /// - the turn's ordinary `result`, which is what returns it to idle. + /// - `{"subtype":"compact_boundary","compact_metadata":{…}}` with the token + /// counts, and only when it succeeded; + /// - the turn's ordinary `result`, which returns it to idle. /// - /// The keys are snake_case here and camelCase in the CLI's own - /// transcript file, which records the same events. Reading the shape - /// off that file -- the obvious place to find one, since it is on - /// disk -- gets every field name wrong and silently yields a - /// compaction with no numbers in it. + /// The keys are snake_case here and camelCase in the CLI's own transcript + /// file, which records the same events -- so reading the shape off that + /// file, the obvious place to look, gets every field name wrong and + /// silently yields a compaction with no numbers in it. fn translate_system(&mut self, message: &Value) -> Vec { match message.get("subtype").and_then(Value::as_str) { Some("init") => { if let Some(id) = message.get("session_id").and_then(Value::as_str) { self.session_id = Some(id.to_string()); } - // The CLI's own account of what it is set to, and the only - // one that resolves an alias: a session launched with - // `--model haiku` reports `claude-haiku-4-5-20251001` - // here. It arrives again after a compaction, which is - // free -- the manager drops a setting it is already in. + // The CLI's own account of what it is set to, and the only one + // that resolves an alias: a session launched with + // `--model haiku` reports `claude-haiku-4-5-20251001` here. It + // arrives again after a compaction, which is free. vec![Event::Settings { model: message .get("model") @@ -336,22 +301,19 @@ impl Translator { } } - /// A `system/status` line: the CLI entering or leaving a state that is - /// not a turn. + /// A `system/status` line: the CLI entering or leaving a state that is not + /// a turn. /// - /// A null `status` is the leaving edge, and it carries how the thing - /// went. Whatever it was, the turn it happened inside is still going - /// when it ends -- the `result` has not arrived yet -- so leaving says - /// `Running`, which is also the only place in this file that does. A - /// state this build does not recognise is left alone rather than - /// mapped onto the nearest one we do. + /// A null `status` is the leaving edge, and it carries how the thing went. + /// The turn it happened inside is still going when it ends -- the `result` + /// has not arrived -- so leaving says `Running`. A state this build does + /// not recognise is left alone rather than mapped onto the nearest one. fn translate_status(&self, message: &Value) -> Vec { - // A mode change the CLI has made, announced a moment after it - // answers the request that asked for it. Measured on 2.1.237: - // `{"subtype":"status","status":null,"permissionMode":"plan"}`, - // which is a leaving edge carrying no compaction result -- so it - // is checked before the compaction reading below, which would - // otherwise fall through to nothing. + // A mode change the CLI has made, announced a moment after it answers + // the request. Measured on 2.1.237: + // `{"subtype":"status","status":null,"permissionMode":"plan"}`, which + // is a leaving edge carrying no compaction result -- so it is checked + // before the compaction reading below. if let Some(mode) = message.get("permissionMode").and_then(Value::as_str) { return vec![Event::Settings { model: None, @@ -371,8 +333,8 @@ impl Translator { }; let mut events = Vec::new(); if result != "success" { - // The CLI's own sentence, because it is specific enough to act - // on: "Not enough messages to compact." is a complete answer. + // The CLI's own sentence, because it is specific enough to act on: + // "Not enough messages to compact." is a complete answer. events.push(Event::Error { message: match message.get("compact_error").and_then(Value::as_str) { Some(why) => format!("compaction failed: {why}"), @@ -386,9 +348,9 @@ impl Translator { events } - /// Raw API streaming: only text deltas become events. Consolidated - /// blocks arriving later re-carry the same text, so those are skipped - /// in `translate_assistant` -- one source per fact. + /// Raw API streaming: only text deltas become events. Consolidated blocks + /// arriving later re-carry the same text, so those are skipped in + /// `translate_assistant` -- one source per fact. fn translate_stream_event(&mut self, event: &Value) -> Vec { if event.get("type").and_then(Value::as_str) == Some("content_block_delta") && let Some(delta) = event["delta"].get("text") @@ -448,9 +410,8 @@ impl Translator { .and_then(Value::as_str) .unwrap_or("a tool"); let input = request.get("input").cloned().unwrap_or(Value::Null); - // Measured, not matched: the request names the call it is about, so - // the phone never has to guess which tool row a permission belongs - // to by comparing inputs. + // Measured, not matched: the request names the call it is about, so the + // phone never has to guess which tool row a permission belongs to. let about = request .get("tool_use_id") .and_then(Value::as_str) @@ -471,11 +432,10 @@ impl Translator { .and_then(Value::as_str) .unwrap_or("(question)") .to_string(); - // Everything the reader decides on, carried in the event. - // The alternative -- and what this was -- is the phone - // reaching into the tool call's input for the parts the - // event dropped, which puts this dialect's schema in the - // app where no other dialect can reach it. + // Everything the reader decides on, carried in the event. The + // alternative -- and what this was -- is the phone reaching into + // the tool call's input for the parts the event dropped, which + // puts this dialect's schema where no other dialect can reach it. let options = question .get("options") .and_then(Value::as_array) @@ -499,12 +459,10 @@ impl Translator { .and_then(Value::as_bool) .unwrap_or(false), // The call that is asking, so all of this draws as one - // thing. It used to be `None` on the grounds that a - // question the model asked is not permission for a - // call -- true, and beside the point: the reader was - // shown the AskUserQuestion call *and* its questions - // as two separate cards for one event, and the call - // itself said nothing they could act on. + // thing. It used to be `None` on the grounds that a question + // the model asked is not permission for a call -- true, and + // beside the point: the reader was shown the AskUserQuestion + // call *and* its questions as two separate cards. about: about.clone(), }); questions.push(text); @@ -515,8 +473,8 @@ impl Translator { events.push(Event::Question { id: request_id.clone(), prompt: format!("Allow {tool_name}?\n{summary}"), - // No header: the question is about the call it names, and - // the phone draws it on that call's own row. + // No header: the question is about the call it names, and the + // phone draws it on that call's own row. header: None, options: vec![ QuestionOption::plain("Allow"), @@ -541,13 +499,13 @@ impl Translator { events } - /// Applies one answer from the phone. Question ids are the control - /// request id, suffixed `#i` for AskUserQuestion sub-questions. + /// Applies one answer from the phone. Question ids are the control request + /// id, suffixed `#i` for AskUserQuestion sub-questions. pub(super) fn answer(&mut self, question_id: &str, answers: &[String]) -> AnswerOutcome { // Where this dialect's shape is put on: the CLI's `answers` map is - // string-valued whatever the question, so several choices become - // one line here rather than everything upstream pretending a - // question can only ever have one answer. + // string-valued whatever the question, so several choices become one + // line here rather than everything upstream pretending a question can + // only ever have one answer. let answer = answers.join(", "); let answer = answer.as_str(); let (request_id, sub) = match question_id.split_once('#') { @@ -583,17 +541,15 @@ impl Translator { })) } - /// `user` messages: tool results become ToolEnd, with any image parts - /// saved into the session dir and referenced by an Image event (the - /// phone fetches them from `/sessions/{id}/files/{ref}`). Replayed and + /// `user` messages: tool results become ToolEnd, with any image parts saved + /// into the session dir and referenced by an Image event. Replayed and /// synthetic user text is skipped -- the manager already recorded the /// user's side. fn translate_user(&self, message: &Value) -> Vec { // Only tool results are here. The CLI never echoes a person's own - // message back on stdout -- measured, because the obvious way to - // learn that a queued message had been taken was to watch for it - // coming back -- so nothing in this function marks one as read. - // The driver reports that itself, at the line it writes. + // message back on stdout -- measured, because the obvious way to learn + // that a queued message had been taken was to watch for it coming back + // -- so the driver reports that itself, at the line it writes. let Some(content) = message["message"].get("content").and_then(Value::as_array) else { return Vec::new(); }; @@ -603,9 +559,8 @@ impl Translator { continue; } let mut texts = Vec::new(); - // Held until the call's id is in hand a few lines below: an - // image is drawn under the call that produced it, so it has to - // carry that id rather than merely arrive next to it. + // Held until the call's id is in hand a few lines below: an image is + // drawn under the call that produced it, so it has to carry that id. let mut images = Vec::new(); match block.get("content") { Some(Value::String(text)) => texts.push(text.clone()), @@ -648,11 +603,9 @@ impl Translator { } } -/// A string field that is there and not empty, or `None`. -/// -/// The CLI omits these rather than sending them empty, but a caller that -/// sends `""` means the same thing and should not produce a description -/// that draws as a blank line. +/// A string field that is there and not empty, or `None`. The CLI omits these +/// rather than sending them empty, but a caller that sends `""` means the same +/// thing and should not produce a description that draws as a blank line. fn text_field(value: &Value, name: &str) -> Option { value .get(name) @@ -663,11 +616,9 @@ fn text_field(value: &Value, name: &str) -> Option { /// Decodes one base64 image block into `files/` and returns its ref. /// -/// A free function rather than a method because the import replay needs -/// exactly this too: a session's history carries the same image blocks as -/// its live output, and a reader who can see a screenshot while it happens -/// should still see it after a restart. Two copies of this would be two -/// naming schemes for one directory. +/// A free function rather than a method because the import replay needs exactly +/// this too: a session's history carries the same image blocks as its live +/// output. Two copies would be two naming schemes for one directory. pub(in crate::session) fn save_image(session_dir: &Path, part: &Value) -> Option { let source = part.get("source")?; let data = source.get("data")?.as_str()?; @@ -675,8 +626,8 @@ pub(in crate::session) fn save_image(session_dir: &Path, part: &Value) -> Option let bytes = base64::engine::general_purpose::STANDARD .decode(data) .ok()?; - // Screenshots are the overwhelming case, and they are PNG; an - // unrecognized type is more likely a dialect change than a JPEG. + // Screenshots are the overwhelming case and they are PNG; an unrecognized + // type is more likely a dialect change than a JPEG. let extension = source .get("media_type") .and_then(Value::as_str) @@ -726,8 +677,7 @@ mod tests { ); assert_eq!(translator.session_id.as_deref(), Some("5ecf21da-d53f")); // The resolved model, which is the point: a session launched with - // `--model haiku` is reported by its full name here, and that is - // the name the phone should be showing. + // `--model haiku` is reported by its full name here. assert_eq!( events, vec![Event::Settings { @@ -749,8 +699,8 @@ mod tests { Setting::PermissionMode("plan".to_string()), ); - // Success carries no model of its own -- measured on 2.1.237 -- - // so what was asked for is the only answer available. + // Success carries no model of its own -- measured on 2.1.237 -- so what + // was asked for is the only answer available. let events = translate_lines( &mut translator, &[ @@ -765,9 +715,8 @@ mod tests { }] ); - // A mode the CLI answers with a value of its own is taken from - // that value: `auto` on the way in is `default` coming back, and - // the request is not the answer. + // A mode the CLI answers with a value of its own is taken from that + // value: `auto` on the way in is `default` coming back. translator.expect_setting( "req-c".to_string(), Setting::PermissionMode("auto".to_string()), @@ -801,8 +750,8 @@ mod tests { }] ); - // And neither request is still waiting: a second answer to either - // id reports nothing at all. + // And neither request is still waiting: a second answer to either id + // reports nothing at all. let events = translate_lines( &mut translator, &[ @@ -815,8 +764,8 @@ mod tests { #[test] fn a_mode_the_cli_announces_is_taken_from_the_announcement() { - // The line it sends just after answering `set_permission_mode`, - // which is also how a mode changed from the terminal arrives. + // The line it sends just after answering `set_permission_mode`, which is + // also how a mode changed from the terminal arrives. let dir = tempfile::tempdir().expect("tempdir"); let mut translator = Translator::new(dir.path().to_path_buf()); let events = translate_lines( @@ -916,8 +865,8 @@ mod tests { panic!("expected a question, got {events:?}"); }; assert_eq!(id, "req-1"); - // The call being asked about, so the phone draws the ask on that - // tool's row instead of as a second card repeating its input. + // The call being asked about, so the phone draws the ask on that tool's + // row instead of as a second card repeating its input. assert_eq!(about.as_deref(), Some("toolu_03")); assert!(prompt.contains("Bash") && prompt.contains("rm -rf /tmp/x")); assert_eq!(labels(options), ["Allow", "Deny"]); @@ -1013,10 +962,9 @@ mod tests { #[test] fn a_question_carries_what_it_takes_to_answer_it() { // Descriptions and previews are what the reader decides on, and a - // multi-select is how many answers the question takes. All of it - // travels in the event: a phone that had to read this dialect's - // tool input to find them would be the only place that knew how, - // and no other provider could reach it. + // multi-select is how many answers the question takes. All of it travels + // in the event: a phone that had to read this dialect's tool input to + // find them would be the only place that knew how. let dir = tempfile::tempdir().expect("tempdir"); let mut translator = Translator::new(dir.path().to_path_buf()); let events = translate_lines( @@ -1049,8 +997,8 @@ mod tests { .contains("dev-updater") ); - // Two choices, one answer: the joining is this dialect's shape, - // done where it is spoken. The CLI's answers map holds strings. + // Two choices, one answer: the joining is this dialect's shape, done + // where it is spoken. The CLI's answers map holds strings. let AnswerOutcome::Respond(response) = translator.answer( "req-9#0", &["Tool calls".to_string(), "Peer messages".to_string()], @@ -1078,8 +1026,8 @@ mod tests { panic!("expected an image event, got {events:?}"); }; assert!(image.ends_with(".png")); - // Named as belonging to the call that produced it, so a phone draws - // it under that row rather than beside it. + // Named as belonging to the call that produced it, so a phone draws it + // under that row rather than beside it. assert_eq!(about.as_deref(), Some("toolu_05")); let saved = dir.path().join("files").join(image); assert!(saved.is_file(), "image not saved at {}", saved.display()); @@ -1118,19 +1066,15 @@ mod tests { /// A turn another agent started says so, on the record that carries it. /// - /// The line is the real shape, taken from a real cross-session message - /// sent to a real stream-json session on CLI 2.1.237 (2026-08-31) -- - /// including the `from` socket path, which is deliberately *not* what a - /// reader is shown: the sending session's `name` is what they recognise - /// it by. The `body` is the message as it was written; the content the - /// model is given beside it wraps the same text in a preamble and a - /// `` tag, which is written for the model rather - /// than for a person. + /// The line is the real shape, taken from a real cross-session message sent + /// to a real stream-json session on 2.1.237 (2026-08-31) -- including the + /// `from` socket path, which is deliberately *not* what a reader is shown: + /// the sending session's `name` is what they recognise it by. The `body` is + /// the message as written; the content the model is given wraps the same + /// text in a preamble written for the model rather than for a person. /// - /// The note comes before the usage and the idle, so it sits as close to - /// the turn it explains as the wire allows -- which is after the reply, - /// not above it. See the comment at the callsite for why that is the - /// best available position rather than an oversight. + /// The note comes before the usage and the idle, so it sits as close to the + /// turn it explains as the wire allows. #[test] fn a_turn_started_by_another_agent_records_who_and_what() { let dir = tempfile::tempdir().expect("tempdir"); @@ -1147,8 +1091,8 @@ mod tests { Event::PeerMessage { from: "ai-app-2-fb".to_string(), text: "Reply with just the word ACK.".to_string(), - // Stamped by the pump, which is the only place that - // knows what seq the turn started at. + // Stamped by the pump, which is the only place that knows + // what seq the turn started at. turn_start: None, }, Event::UsageDelta { @@ -1162,9 +1106,9 @@ mod tests { ); } - /// And an ordinary turn does not, which is the half that decides - /// whether the check above is a check or a rubber stamp. Measured over - /// a real session's stdout: four results, no `origin` between them. + /// And an ordinary turn does not, which is the half that decides whether + /// the check above is a check or a rubber stamp. Measured over a real + /// session's stdout: four results, no `origin` between them. #[test] fn an_ordinary_turn_carries_no_peer_note() { let dir = tempfile::tempdir().expect("tempdir"); @@ -1186,12 +1130,10 @@ mod tests { /// The context is the last assistant message's, not the result's. /// /// Real figures from a two-message haiku turn on 2.1.237, captured - /// 2026-08-30. The result adds the turn up -- its - /// `cache_read_input_tokens` of 40,211 is 14,259 and 25,952, the same - /// conversation counted twice -- so reading the context off it would - /// report a size the model never held, and by more the more tool calls - /// a turn makes. The last message's three input figures are what it - /// was holding when the turn ended. + /// 2026-08-30. The result adds the turn up -- its `cache_read_input_tokens` + /// of 40,211 is 14,259 and 25,952, the same conversation counted twice -- so + /// reading the context off it would report a size the model never held, by + /// more the more tool calls a turn makes. #[test] fn the_context_is_what_the_last_message_held_not_the_turn_added_up() { let dir = tempfile::tempdir().expect("tempdir"); @@ -1231,9 +1173,9 @@ mod tests { #[test] fn a_compaction_reports_its_start_and_what_it_recovered() { - // Real lines (trimmed) from a 2.1.237 session driven through - // `/compact`. Note the snake_case keys -- the CLI's transcript - // file writes the same records in camelCase. + // Real lines (trimmed) from a 2.1.237 session driven through `/compact`. + // Note the snake_case keys -- the CLI's transcript file writes the same + // records in camelCase. let dir = tempfile::tempdir().expect("tempdir"); let mut translator = Translator::new(dir.path().to_path_buf()); let events = translate_lines( @@ -1333,16 +1275,14 @@ mod tests { ); } - /// Pressing Stop is not a failure, and the CLI cannot tell you which it - /// was. + /// Pressing Stop is not a failure, and the CLI cannot tell you which it was. /// - /// An interrupted turn arrives as exactly the same shape a broken one - /// does -- `is_error` set, on a `result` -- so somebody who pressed the - /// button was shown "the turn ended with an error" for doing what the - /// button says. What separates the two is not in the line: it is that - /// this side asked. The second half of this test is the one that - /// matters, because the naive fix -- never reporting an error result -- - /// passes the first half and silences every genuine failure afterwards. + /// An interrupted turn arrives as exactly the same shape a broken one does, + /// so somebody who pressed the button was shown "the turn ended with an + /// error". What separates the two is that this side asked. The second half + /// of this test is the one that matters, because the naive fix -- never + /// reporting an error result -- passes the first half and silences every + /// genuine failure afterwards. #[test] fn a_turn_stopped_on_purpose_is_not_an_error() { let dir = tempfile::tempdir().expect("tempdir"); diff --git a/server/src/session/driver.rs b/server/src/session/driver.rs index 8bd0f6a..c0e53ad 100644 --- a/server/src/session/driver.rs +++ b/server/src/session/driver.rs @@ -9,26 +9,22 @@ use serde::{Deserialize, Serialize}; use tokio::sync::mpsc; -/// The name a session's image is stored and served under -- returned by -/// `POST /attachments` for an upload, minted by a driver for one a tool -/// produced, and fetched back from `/sessions/{id}/files/{ref}`. Both -/// directions use the one id so the transcript renders them identically. +/// The name a session's image is stored and served under -- minted for an +/// upload or for one a tool produced, and fetched back from +/// `/sessions/{id}/files/{ref}`. One id both directions, so the transcript +/// renders them identically. pub type ImageRef = String; -/// The name an upload from the phone is stored and served under: an image -/// is `.` and is an [`ImageRef`] like any other; any other -/// file keeps its own name after the hex, `-`, because the name -/// is what the reader attached and what the session is told. The two are -/// told apart by `crate::media::media_type_for`, which knows every image -/// extension this server writes. +/// The name an upload is stored and served under: an image is +/// `.` and is an [`ImageRef`] like any other; any other file +/// keeps its own name after the hex, `-`, because the name is what +/// the reader attached and what the session is told. Told apart by +/// `crate::media::media_type_for`. pub type AttachmentRef = String; -/// One choice offered in answer to a [`Event::Question`]. -/// -/// More than a label because the reader is deciding, not confirming: what -/// an option means, and what picking it would produce, are the things that -/// decide it. Both are optional -- a permission's Allow and Deny mean -/// exactly what they say. +/// One choice offered in answer to a [`Event::Question`]. More than a label +/// because the reader is deciding rather than confirming: what an option +/// means, and what picking it would produce, are what decide it. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct QuestionOption { @@ -42,7 +38,6 @@ pub struct QuestionOption { } impl QuestionOption { - /// An option that is only its label, which is most of them. pub fn plain(label: impl Into) -> Self { Self { label: label.into(), @@ -52,70 +47,52 @@ impl QuestionOption { } } -/// Everything a session can tell the outside world. Every event is -/// appended to the session's transcript with a sequence number, then fanned -/// out to SSE subscribers; the phone renders purely from this stream, so -/// reconnecting is just "events after seq N" -- no separate history path -/// to drift from the live one. +/// Everything a session can tell the outside world. Every event is appended +/// to the transcript with a sequence number, then fanned out to SSE +/// subscribers, so reconnecting is just "events after seq N" -- no separate +/// history path to drift from the live one. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] // `rename_all` renames the variants; `rename_all_fields` renames what is // inside them. Both are needed and only the first is obvious: every field -// here was a single lowercase word until `pre_tokens` arrived, so a -// multi-word field went out as snake_case, the app looked for camelCase and -// found nothing, and the event still rendered -- as the "no counts were -// reported" case, which is a state it is allowed to be in. A wire mismatch -// that lands on a plausible state is invisible; anything added below with a -// two-word field would have hit the same thing. +// here was one lowercase word until `pre_tokens` arrived, so a multi-word +// field went out as snake_case, the app looked for camelCase and found +// nothing, and the event still rendered -- as the "no counts reported" case, +// which is a state it is allowed to be in. #[serde( tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase" )] pub enum Event { - /// What the user sent, written into the transcript by the manager (not - /// by drivers) so every device renders the full conversation from the - /// one stream. Recorded when the session reads the message, which is - /// what `MessageTaken` reports. + /// What the user sent, written into the transcript by the manager (not by + /// drivers) so every device renders the conversation from one stream. + /// Recorded when the session reads it, which is what `MessageTaken` reports. UserMessage { - /// The [`Event::MessageQueued`] this resolves, when it waited. - /// - /// A message sent between turns is read at once and never queued, - /// so this is `None` for most of them. It is the pair to the id on - /// `MessageQueued` and exists for the same reason `CommandSent` - /// carries one: the phone has a bubble on screen for the waiting - /// message and needs to know *which* one this is, rather than - /// matching on the text and clearing the wrong one when the same - /// thing was sent twice. + /// The [`Event::MessageQueued`] this resolves, when it waited. The + /// phone has a bubble on screen for the waiting message and needs to + /// know *which* one this is, rather than matching on the text and + /// clearing the wrong one when the same thing was sent twice. #[serde(default, skip_serializing_if = "Option::is_none")] id: Option, text: String, - /// What was attached to it, by the ref the files route serves. - /// - /// On the message rather than beside it. These used to be their own - /// `Image` events emitted just before, which drew a person's - /// screenshot as a row of its own floating above the bubble that - /// sent it -- and left the phone to decide, from nothing but - /// adjacency, which message an image belonged to. Belonging is not - /// something to infer when the sender knew. - /// - /// `images` on disk until 2026-09-03, when files joined them; - /// the alias reads the rows written before that. + /// What was attached, by the ref the files route serves. On the + /// message rather than beside it: these used to be their own `Image` + /// events just before, which left the phone deciding from adjacency + /// which message an image belonged to. `images` on disk until + /// 2026-09-03, when files joined them; the alias reads the older rows. #[serde(default, alias = "images", skip_serializing_if = "Vec::is_empty")] attachments: Vec, }, /// A message accepted from the phone that the session cannot read yet. /// - /// Recorded, unlike the message itself, and that difference is the - /// point. The *message* belongs in the transcript where the session - /// read it -- see `MessageTaken` -- but something has to say it is - /// waiting, and it has to be the server that says it: the phone used - /// to remember its own outgoing messages, so leaving the session - /// screen or restarting the app showed nothing pending when something - /// was, which reads as "nothing queued" rather than "I have forgotten". + /// Recorded, unlike the message itself, and that difference is the point: + /// the message belongs in the transcript where the session read it, but + /// something has to say it is waiting, and it has to be the server. The + /// phone used to remember its own outgoing messages, so leaving the + /// screen showed nothing pending when something was. /// - /// Carries no row of its own. It is resolved by the `UserMessage` - /// bearing the same id, exactly as `CommandQueued` is resolved by - /// `CommandSent`. + /// Carries no row of its own; resolved by the `UserMessage` bearing the + /// same id, as `CommandQueued` is resolved by `CommandSent`. MessageQueued { id: String, text: String, @@ -125,38 +102,31 @@ pub enum Event { #[serde(default, alias = "images", skip_serializing_if = "Vec::is_empty")] attachments: Vec, }, - /// A message taken out of the queue before the session read it, by - /// somebody tapping the bubble that was waiting for it. + /// A message taken out of the queue before the session read it. /// /// Recorded for the same reason `MessageQueued` is: the queue is the - /// server's, so what is waiting has to be answerable from the - /// transcript alone. Without it a phone that reconnects replays the - /// `MessageQueued` and puts back a bubble for a message that will - /// never arrive -- and nothing later would ever resolve it, since the - /// `UserMessage` that normally does is exactly what is not coming. + /// server's, so what is waiting has to be answerable from the transcript + /// alone. Without it a phone that reconnects replays the `MessageQueued` + /// and puts back a bubble nothing will ever resolve -- the `UserMessage` + /// that normally does is exactly what is not coming. /// - /// Only ever sent for a message that had not been handed over. One - /// that has is not droppable and says so instead; see + /// Only ever sent for a message that had not been handed over; see /// [`Unqueued::AlreadySent`]. MessageDropped { id: String, }, - /// A driver has taken one of the user's messages and started reading - /// it. The manager turns this into the `UserMessage` above, so it - /// never reaches a phone itself. + /// A driver has taken one of the user's messages and started reading it. + /// The manager turns this into the `UserMessage` above, so it never + /// reaches a phone itself. /// /// It exists because sending and being read are not the same moment. A - /// message sent into a running turn waits for that turn to finish, and - /// until then the session has not seen it -- so recording it among - /// things already read puts it in the transcript above output that - /// predates it, and leaves a phone drawing it as still waiting with - /// nothing coming to say otherwise. + /// message sent into a running turn waits, and recording it among things + /// already read puts it in the transcript above output that predates it. MessageTaken { - /// The `MessageQueued` this answers, or `None` when it never - /// waited. Carried through onto the `UserMessage`. + /// The `MessageQueued` this answers, or `None` when it never waited. + /// Carried through onto the `UserMessage`. id: Option, text: String, - /// Carried through onto the `UserMessage` with everything else. #[serde(default, alias = "images", skip_serializing_if = "Vec::is_empty")] attachments: Vec, }, @@ -183,13 +153,10 @@ pub enum Event { Image { #[serde(rename = "ref")] image: ImageRef, - /// The tool call whose result carried it, when one did. - /// - /// A screenshot belongs under the call that took it, not floating - /// beside it -- the reader has to pair them by position otherwise, - /// and position is exactly what a page boundary breaks. `None` for - /// an image a person attached to their own message, which belongs - /// to no call. + /// The tool call whose result carried it, when one did. A screenshot + /// belongs under the call that took it, not floating beside it -- the + /// reader has to pair them by position otherwise, and position is + /// exactly what a page boundary breaks. #[serde(default, skip_serializing_if = "Option::is_none")] about: Option, }, @@ -199,71 +166,54 @@ pub enum Event { id: String, prompt: String, /// A few words naming what the question is about, when the asker - /// offered one -- a tag beside the question rather than part of - /// it. `None` for a permission, which is about the call above it. - #[serde(default, skip_serializing_if = "Option::is_none")] + /// offered one. `None` for a permission, which is about the call + /// above it. header: Option, options: Vec, - /// Whether several options may be chosen at once. - /// - /// Here rather than left for a phone to work out from the dialect - /// underneath: how many answers a question takes is a fact about - /// the question, and the alternative was the app parsing Claude - /// Code's tool input to find out -- one dialect's schema, written - /// out a second time in Kotlin, where no other dialect could - /// reach it. + /// Whether several options may be chosen at once. Here rather than + /// left for a phone to work out from the dialect underneath: how many + /// answers a question takes is a fact about the question, and the + /// alternative was Claude Code's tool-input schema written out a + /// second time in Kotlin, where no other dialect could reach it. #[serde(default, skip_serializing_if = "std::ops::Not::not")] multi_select: bool, - /// The tool call this is permission for, when it is one. - /// - /// The CLI's `can_use_tool` request carries the `tool_use_id` of - /// the call it is asking about, so a phone can draw the ask on the - /// tool's own row rather than as a second card repeating its - /// input. `None` for anything that is not about a tool -- - /// AskUserQuestion, and an echo session's question. + /// The tool call this is permission for, when it is one, so a phone + /// can draw the ask on the tool's own row rather than as a second + /// card repeating its input. `None` for anything not about a tool. #[serde(default, skip_serializing_if = "Option::is_none")] about: Option, }, /// A message another agent sent this session. /// - /// Its own kind rather than a `UserMessage`, because it is not - /// something the reader said and a transcript that renders it in their - /// voice is claiming they did. It also explains what would otherwise - /// be inexplicable: a session that starts working on something nobody - /// on this phone asked for. + /// Its own kind rather than a `UserMessage`, because it is not something + /// the reader said and a transcript that renders it in their voice is + /// claiming they did. It also explains what would otherwise be + /// inexplicable: a session working on something nobody here asked for. PeerMessage { /// The sending session's own name, which is what the reader /// recognises it by -- the socket path it came from is not. from: String, text: String, - /// The seq of the `Status::Running` that opened the turn this - /// message started, so a reader can draw it above that turn. + /// The seq of the `Status::Running` that opened the turn this message + /// started, so a reader can draw it above that turn. /// - /// It exists because the live Claude Code path cannot record the - /// message where it belongs. The CLI says nothing about a peer - /// message until the turn's `result` -- see - /// `claude::translate` -- so the event is appended after - /// everything it caused, and an append-only transcript cannot go - /// back and insert it. Carrying the position instead keeps one - /// order on the wire and one order on screen without a second - /// source for either. + /// The CLI says nothing about a peer message until the turn's + /// `result`, so the event is appended after everything it caused, and + /// an append-only transcript cannot go back and insert it. Carrying + /// the position instead keeps one order on the wire and one on screen. /// - /// Filled in by the pump, which is the only place that knows a - /// seq, and only where a turn was open: `None` for a message read - /// out of a session file by `import`, which already has it in the - /// right place, and for one that started no turn. + /// Filled in by the pump, the only place that knows a seq, and only + /// where a turn was open: `None` for a message replayed by `import`, + /// which already has it in the right place. #[serde(default, skip_serializing_if = "Option::is_none")] turn_start: Option, }, /// The manager's record of a question being answered, so a rendered - /// question card resolves on every device, not just the one that - /// answered it. + /// question card resolves on every device rather than only the one that + /// answered. /// - /// A list because a question can take several answers, and one that - /// took one is the list of length one rather than a different shape. - /// What a dialect makes of that -- Claude Code's answers map holds a - /// string, so several become one line -- is that dialect's business - /// and is done where it talks to it. + /// A list because a question can take several answers, and one that took + /// one is the list of length one rather than a different shape. Answered { id: String, answers: Vec, @@ -273,17 +223,13 @@ pub enum Event { }, /// What the session is set to, as the session itself reports it. /// - /// Asking for a change and having one are different things, and only - /// this one is a measurement: a model name the dialect does not know, - /// a mode it refuses, or a driver whose model is fixed at startup all - /// leave a request that was sent and nothing that changed. Reporting - /// from the request instead put the answer on the phone before the - /// question had been answered, and left it there when the answer was - /// no. + /// Asking for a change and having one are different things, and only this + /// is a measurement: a model name the dialect does not know, a mode it + /// refuses, or a driver whose model is fixed at startup all leave a + /// request that was sent and nothing that changed. Reporting from the + /// request put the answer on the phone before the question was answered. /// - /// Either field alone, because the two are confirmed separately and - /// by different things -- the CLI echoes a mode change, and names the - /// model it resolved an alias to when a session starts. + /// Either field alone, because the two are confirmed separately. Settings { #[serde(default, skip_serializing_if = "Option::is_none")] model: Option, @@ -295,58 +241,44 @@ pub enum Event { /// What this turn cost: the tokens it was charged for. tokens: u64, /// What the model was holding when the turn ended -- see - /// [`context_tokens`] for what goes into it. + /// [`context_tokens`]. /// - /// Carried on the event rather than summed by whoever is reading, - /// because it is not a sum: a conversation's context goes *down* - /// at a compaction and a clear, so adding turns up would report a - /// figure the session stopped being true of long ago. It is also - /// the number a reader is asking about -- how much room is left - /// before the next compaction -- rather than what has been spent - /// getting here. + /// Carried rather than summed by whoever is reading, because it is + /// not a sum: context goes *down* at a compaction and a clear, so + /// adding turns up would report a figure the session stopped being + /// true of long ago. /// - /// `None` where the dialect did not say, which every reader has to - /// be able to draw: a turn whose usage the CLI omitted leaves the - /// context unmeasured rather than unchanged, and entries written - /// before this existed have no answer at all. + /// `None` where the dialect did not say, which every reader has to be + /// able to draw. #[serde(default, skip_serializing_if = "Option::is_none")] context: Option, }, /// A compaction that finished, and how much context it recovered. /// - /// The counts are the point, and a spinner is not: what a reader wants - /// afterwards is that the session went from a million tokens to ten - /// thousand, which is measured rather than estimated. They are - /// optional because the record has shipped without them, and "the - /// compaction happened, we don't know by how much" is a state this - /// has to be able to say -- filling in a plausible number would make - /// it indistinguishable from one that was counted. + /// The counts are the point, and a spinner is not. They are optional + /// because the record has shipped without them, and "the compaction + /// happened, we don't know by how much" is a state this has to be able to + /// say -- a plausible number would be indistinguishable from a counted one. Compacted { #[serde(default, skip_serializing_if = "Option::is_none")] pre_tokens: Option, #[serde(default, skip_serializing_if = "Option::is_none")] post_tokens: Option, /// What asked for it, in the dialect's own word -- `auto` when the - /// session compacted on its own. Carried rather than reduced to a - /// bool so an unrecognised trigger stays unrecognised: an - /// automatic compaction is the one worth naming, because it - /// explains a wait nobody asked for, and defaulting the unknown - /// case to "you asked for this" would explain it away. + /// session compacted on its own. Carried rather than reduced to a bool + /// so an unrecognised trigger stays unrecognised: an automatic + /// compaction is the one worth naming, because it explains a wait + /// nobody asked for. #[serde(default, skip_serializing_if = "Option::is_none")] trigger: Option, }, /// A command the session was asked to run on itself, held because it - /// cannot run yet. - /// - /// These are not messages: `/compact` and `/rename` are instructions - /// to the session about itself, and a session in the middle of a turn - /// reads a line written to it as something the model should see. So - /// they wait for the turn to end, and this is what a phone draws - /// while they do -- otherwise pressing Compact during a long turn - /// does nothing visible for minutes and looks like it was missed. + /// cannot run yet. These are not messages: `/compact` and `/rename` are + /// instructions about the session, and a session mid-turn reads a line + /// written to it as something the model should see. So they wait, and + /// this is what a phone draws while they do. CommandQueued { id: String, - /// What to show for it: the command as a person would type it. text: String, }, /// The same command, now handed to the session. Its [`CommandQueued`] @@ -356,73 +288,55 @@ pub enum Event { id: String, text: String, }, - /// The conversation was cleared: everything above this is still in - /// the record but is no longer in the session's context. + /// The conversation was cleared: everything above this is still in the + /// record but is no longer in the session's context. /// - /// Nothing is deleted. A transcript is the thing a person scrolls - /// back through, and a session that dropped its history from the - /// screen as well as from the model would lose the only copy the - /// phone has -- so this is a divider, not a truncation, and the - /// events before it stay exactly where they were. - /// - /// It is also what makes clearing mean the same thing for every - /// driver, which is why the marker lives here rather than in one - /// dialect: `llama` folds its conversation out of the transcript and - /// simply folds from the last one of these, and `claude` starts a new - /// CLI conversation behind it. + /// Nothing is deleted. A transcript is the thing a person scrolls back + /// through, so this is a divider, not a truncation. /// /// **Load-bearing, not decorative.** For any driver that rebuilds its - /// conversation from the transcript, this marker decides what the - /// model is given -- dropping it, or treating it as something only - /// the phone draws, silently puts a cleared conversation back in - /// front of the model at full cost. Today `llama::conversation` is - /// the only fold that reads it, which is the reason to write this - /// down rather than leave it to be inferred from a second example - /// that does not exist yet. + /// conversation from the transcript, this marker decides what the model + /// is given -- dropping it, or treating it as something only the phone + /// draws, silently puts a cleared conversation back in front of the model + /// at full cost. Today `llama::conversation` is the only fold that reads + /// it, which is why this is written down rather than left to be inferred + /// from a second example that does not exist. Cleared, Error { message: String, }, } -/// How much the model was holding, from the three figures a turn reports. +/// How much the model was holding, from the three figures a turn reports: +/// the input side only, prompt plus both cache figures. A cached token is +/// cheaper but it is still one the model was given; output is what the turn +/// produced rather than what continuing has to carry. /// -/// The input side only -- prompt plus both cache figures. A cached token -/// is cheaper but it is still one the model was given, so all three count; -/// output is left out because it is what the turn produced rather than -/// what continuing from here has to carry. -/// -/// One function so the definition cannot drift, because it is extracted in -/// two quite different ways: the live translators have the usage object -/// parsed, and `import::context_tokens` scans it out of a raw line without -/// parsing, since those files reach tens of megabytes. +/// One function so the definition cannot drift, because it is extracted two +/// quite different ways -- the live translators have the usage object parsed, +/// and `import::context_tokens` scans it out of a raw line without parsing. pub fn context_tokens(input: u64, cache_creation: u64, cache_read: u64) -> u64 { input + cache_creation + cache_read } /// The context after `event`, given what it was before. /// -/// The whole rule in one place, because three readers need the same -/// answer: the pump keeping a live session's figure, the transcript -/// seeding it at startup, and the phone folding the same events into what -/// it draws. Written here beside the events it reads so a fourth reader -/// finds it. +/// The whole rule in one place, because three readers need the same answer: +/// the pump keeping a live session's figure, the transcript seeding it at +/// startup, and the phone folding the same events into what it draws. /// -/// The two that *lower* it are the point. A clear takes the conversation -/// away and a compaction replaces it with a summary, so a figure measured -/// before either stopped being true at that moment -- and carrying it -/// forward is how a session that had just been cleared went on reporting -/// the context it no longer had. +/// The two that *lower* it are the point. A clear takes the conversation away +/// and a compaction replaces it with a summary, so a figure measured before +/// either stopped being true at that moment -- and carrying it forward is how +/// a session that had just been cleared went on reporting the context it no +/// longer had. /// -/// `None` is "we don't know", which is a state each of them can reach: -/// nothing has been measured yet, a compaction finished without saying -/// how much it recovered, or a clear left a conversation nobody has -/// counted since. +/// `None` is "we don't know", which each of them can reach. pub fn context_after(current: Option, event: &Event) -> Option { match event { // `or`, so a turn the dialect reported no usage for leaves the last - // measurement standing: it is stale by a turn, which every context - // figure is, rather than wrong. + // measurement standing: stale by a turn, which every context figure + // is, rather than wrong. Event::UsageDelta { context, .. } => context.or(current), Event::Compacted { post_tokens, .. } => *post_tokens, Event::Cleared => None, @@ -433,11 +347,10 @@ pub fn context_after(current: Option, event: &Event) -> Option { /// Something a session can be asked to do to itself. /// /// A closed set rather than a string, because the two that are not -/// dialect-specific have to reach every provider: compaction is a -/// capability an llama session may one day have, and a name is this -/// server's own. `Raw` is the escape for a dialect's own commands -- -/// `/context`, `/usage` -- which only the thing running the session can -/// interpret. +/// dialect-specific have to reach every provider: compaction is a capability +/// an llama session may one day have, and a name is this server's own. `Raw` +/// is the escape for a dialect's own commands, which only the thing running +/// the session can interpret. #[derive(Debug, Clone, PartialEq)] pub enum SessionCommand { Compact, @@ -447,8 +360,8 @@ pub enum SessionCommand { } impl SessionCommand { - /// What a person would have typed to ask for this, which is what a - /// phone shows while it waits. + /// What a person would have typed to ask for this, which is what a phone + /// shows while it waits. pub fn label(&self) -> String { match self { Self::Compact => "/compact".to_string(), @@ -477,32 +390,28 @@ pub enum SessionStatus { AwaitingInput, Compacting, Exited, - /// There is a process recorded for this session and the machine will - /// not say whether it is still running. + /// There is a process recorded for this session and the machine will not + /// say whether it is still running. /// /// Its own state rather than the nearest of the others, because both /// neighbours are lies with consequences: `Exited` invites starting a - /// second process against a conversation that may already have one, - /// and `Idle` claims a session is waiting for you when nobody has - /// checked. It resolves itself -- the driver keeps asking -- so what - /// it means to a reader is "wait", not "act". + /// second process against a conversation that may already have one, and + /// `Idle` claims a session is waiting for you when nobody has checked. Unknown, } /// What became of a request to take a queued message back. /// -/// Three states rather than a bool because the two failures are not the -/// same fact. A driver that writes into its session the moment a message -/// arrives -- which is what `ClaudeDriver` does, so that a steer reaches -/// the model at the next tool boundary rather than at the end of the turn -/// -- can never take one back, and a phone that was told only "no" would -/// have to guess whether it had asked too late or asked about nothing. +/// Three states rather than a bool because the two failures are not the same +/// fact. A driver that writes into its session the moment a message arrives +/// -- which is what `ClaudeDriver` does, so a steer reaches the model at the +/// next tool boundary -- can never take one back, and a phone told only "no" +/// would have to guess whether it asked too late or asked about nothing. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Unqueued { /// Out of the queue; the session will never read it. Dropped, - /// Already handed to the session, so there is nothing left to take - /// back. The message is on its way into the conversation. + /// Already handed to the session, so there is nothing left to take back. AlreadySent, /// Nothing is waiting under that id. Unknown, @@ -522,110 +431,93 @@ pub type EventSink = mpsc::UnboundedSender; pub trait Driver: Send + Sync { /// Takes a message, now or once the session is free for it. /// - /// Every driver owes exactly one `MessageTaken` per message, at the - /// moment it actually starts reading it: that event is what puts the - /// message in the transcript, so a driver that never sends it drops - /// the message from the conversation entirely. + /// Every driver owes exactly one `MessageTaken` per message, at the moment + /// it actually starts reading it: that event is what puts the message in + /// the transcript, so a driver that never sends it drops the message from + /// the conversation entirely. fn send_user_message(&self, text: String, attachments: Vec); /// Takes back a message that is still waiting, named by the id its /// [`Event::MessageQueued`] carried. /// - /// Answering is the whole of the contract: a driver that drops the - /// message owes an [`Event::MessageDropped`], and one that cannot must - /// say which of the two reasons it is, because they are different - /// things to a reader -- "the session has already been told" is worth - /// knowing, and "there is nothing under that id" means the bubble on - /// screen is stale. The default is the honest answer for a driver with - /// no queue at all: nothing of yours is waiting. + /// Answering is the whole of the contract: a driver that drops the message + /// owes an [`Event::MessageDropped`], and one that cannot must say which + /// of the two reasons it is -- "the session has already been told" is + /// worth knowing, and "there is nothing under that id" means the bubble on + /// screen is stale. The default is the honest answer for a driver with no + /// queue at all. fn unqueue(&self, _id: &str) -> Unqueued { Unqueued::Unknown } - /// Answers one question with everything that was chosen, in the order - /// it was offered. One answer is a list of one; a driver whose dialect - /// takes a single value joins them where it writes it. + /// Answers one question with everything that was chosen, in the order it + /// was offered. A driver whose dialect takes a single value joins them + /// where it writes it. fn answer_question(&self, id: &str, answers: &[String]); /// Stop mid-run; the session survives. fn interrupt(&self); fn set_model(&self, model: &str); /// How much the session asks about before acting. Live rather than - /// spawn-only: the answer changes with what is being done, and a phone - /// is the worst place to answer "may I run this?" forty times. + /// spawn-only: the answer changes with what is being done, and a phone is + /// the worst place to answer "may I run this?" forty times. fn set_permission_mode(&self, mode: &str); // Both of the above are requests, and neither reports the outcome by - // returning. A driver that actually changes the setting owes an - // [`Event::Settings`] once it has -- that event, and not the request, - // is what the manager and the phone read. One that cannot change it - // owes an [`Event::Error`] saying why; saying nothing leaves a phone - // showing a setting nobody applied. + // returning. A driver that changes the setting owes an [`Event::Settings`] + // once it has -- that event, not the request, is what the manager and the + // phone read. One that cannot owes an [`Event::Error`] saying why. /// Tells the process what this conversation is called, when it has /// somewhere to put it. /// - /// Unlike the two above, this is not a request that can fail: the - /// rename has already happened in this server's own config, which is - /// what a phone lists and the only place the name has to be. So a - /// driver whose process has no notion of a name does nothing here and - /// says nothing -- there is no failure to report, and an error beside - /// a rename that plainly worked would be a puzzle rather than a - /// warning. - /// - /// Claude Code has one: `--name` when a session is created and + /// Unlike the two above, this is not a request that can fail: the rename + /// has already happened in this server's config, which is what a phone + /// lists. So a driver whose process has no notion of a name does nothing + /// and says nothing. Claude Code has one: `--name` at creation and /// `/rename` afterwards, which is what puts the same name in its own /// session picker and in what other agents see. fn set_title(&self, title: &str); - /// Runs a command this session's own dialect understands, verbatim. + /// Runs a command this session's own dialect understands, verbatim -- + /// `/context`, `/usage`, anything a CLI adds next month. A driver with no + /// such vocabulary says so with an [`Event::Error`] rather than sending it + /// as a message, which would put a line meant for the session in front of + /// the model. /// - /// For the ones this app has no opinion about -- `/context`, `/usage`, - /// anything a CLI adds next month. A driver whose process has no such - /// vocabulary says so with an [`Event::Error`] rather than sending it - /// as a message, which would put a line meant for the session in front - /// of the model instead. - /// - /// Like [`Driver::compact`] and [`Driver::set_title`], this is called - /// only when the session is between turns; the waiting is done above, - /// once, for every driver. + /// Called only when the session is between turns; the waiting is done + /// above, once, for every driver. fn run_command(&self, text: &str); - /// pi: native compaction; claude: `/compact`. + /// llama: not built, and refused; claude: `/compact`. fn compact(&self); /// Drops the conversation so far without ending the session. /// - /// The cheap half of managing a long session, and the reason it is a - /// driver operation rather than a manager one: compaction *reads* the - /// whole conversation in order to summarise it, so on a large context - /// it is itself one of the most expensive requests the session will - /// make -- measured at 1.7 million tokens for a single automatic - /// compaction on 2026-08-29. Clearing costs nothing, because nothing - /// is sent. + /// The cheap half of managing a long session, and why it is a driver + /// operation rather than a manager one: compaction *reads* the whole + /// conversation in order to summarise it, so on a large context it is + /// itself one of the most expensive requests the session will make -- + /// measured at 1.7 million tokens for one automatic compaction on + /// 2026-08-29. Clearing costs nothing, because nothing is sent. /// - /// Every implementation emits [`Event::Cleared`] so the transcript - /// carries the divider whatever the dialect did behind it. + /// Every implementation emits [`Event::Cleared`] so the transcript carries + /// the divider whatever the dialect did behind it. fn clear(&self); /// Stop attending to the process but leave it running, because this - /// server is going away and means to adopt it again when it comes - /// back. + /// server is going away and means to adopt it again. /// - /// This is deliberately not a shutdown. A backend restart -- a - /// rebuild, a service restart, a crash -- must not end a turn that is - /// in flight, so a session's process outlives the server that started - /// it and is found again through `session::process`. A driver with no - /// process of its own has nothing to do here. + /// Deliberately not a shutdown: a backend restart must not end a turn that + /// is in flight, so a session's process outlives the server that started + /// it and is found again through `session::process`. /// - /// Its counterpart is [`Driver::stop`]. Every driver owes exactly one - /// of the two on the way out, and which one is the difference between - /// "back shortly" and "this conversation is over". - /// Whether a line written *now* would start a turn of its own, rather - /// than landing inside one already in flight. + /// Its counterpart is [`Driver::stop`]. Every driver owes exactly one of + /// the two on the way out, and which one is the difference between "back + /// shortly" and "this conversation is over". + /// Whether a line written *now* would start a turn of its own, rather than + /// landing inside one already in flight. /// - /// Asked of the driver because the driver is the only thing that knows: - /// it sees every line it wrote and every line that came back, and it - /// updates this the instant it writes rather than when output returns. - /// The manager's `SessionStatus` cannot answer it -- that is built from - /// what has been *recorded*, so between writing a line and the CLI's - /// first output it still reads idle, and a second line sent in that gap - /// lands inside the turn the first one started. For a command that is - /// the difference between being executed and being read to the model as - /// text, which is silent both ways. + /// Asked of the driver because the driver is the only thing that knows: it + /// updates this the instant it writes rather than when output returns. The + /// manager's `SessionStatus` is built from what has been *recorded*, so + /// between writing a line and the CLI's first output it still reads idle, + /// and a second line sent in that gap lands inside the turn the first one + /// started. For a command that is the difference between being executed + /// and being read to the model as text, which is silent both ways. /// /// Defaults to true for a driver with no turn of its own to be inside. fn between_turns(&self) -> bool { @@ -633,16 +525,12 @@ pub trait Driver: Send + Sync { } fn detach(&self); - /// End the process for good, because it must not survive this. The - /// path out for everything [`detach`] preserves. + /// End the process for good, because it must not survive this. The path + /// out for everything [`Driver::detach`] preserves. /// - /// Two callers, and the difference between them is only what is being - /// ended: a session being deleted, whose conversation goes with it, and - /// a throwaway session at a server's exit, whose transcript stays and - /// whose process does not (see [`SessionConfig::throwaway`]). - /// - /// [`detach`]: Driver::detach - /// [`SessionConfig::throwaway`]: crate::config::SessionConfig::throwaway + /// Two callers, differing only in what is being ended: a session being + /// deleted, whose conversation goes with it, and a throwaway session at a + /// server's exit, whose transcript stays and whose process does not. fn stop(&self); } @@ -650,11 +538,10 @@ pub trait Driver: Send + Sync { mod tests { use super::*; - /// A tripwire for the wire format, not for serde. - /// - /// The app reads these names, and getting one wrong does not fail - /// loudly: a field the app cannot find reads as a field the server - /// chose not to send, which several of them are allowed to be. + /// A tripwire for the wire format, not for serde. The app reads these + /// names, and getting one wrong does not fail loudly: a field the app + /// cannot find reads as a field the server chose not to send, which + /// several of them are allowed to be. #[test] fn multi_word_fields_go_out_in_camel_case() { let json = serde_json::to_value(Event::Compacted { @@ -674,11 +561,10 @@ mod tests { ); } - /// The two events that take the context *down* are the point of the - /// fold: a figure measured before a compaction or a clear stopped being - /// true at that moment, and carrying it forward is how a session that - /// had just been cleared went on reporting the context it no longer - /// had. + /// The two events that take the context *down* are the point of the fold: + /// a figure measured before a compaction or a clear stopped being true at + /// that moment, and carrying it forward is how a session that had just + /// been cleared went on reporting the context it no longer had. #[test] fn a_compaction_and_a_clear_move_the_context_a_turn_cannot() { let after = |current, event| context_after(current, &event); @@ -707,8 +593,7 @@ mod tests { assert_eq!(after(Some(9_617), Event::Cleared), None); // A compaction that did not say how much it recovered leaves the - // context unknown rather than stale: it definitely moved, and the - // one thing that is certainly wrong is the figure from before it. + // context unknown rather than stale: it definitely moved. assert_eq!( after( Some(128_402), diff --git a/server/src/session/echo.rs b/server/src/session/echo.rs index e731b99..8822b1f 100644 --- a/server/src/session/echo.rs +++ b/server/src/session/echo.rs @@ -1,56 +1,44 @@ -//! The phase-1 fake driver: no child process, just events. It exists to -//! prove the whole pipe -- spawn, transcript, SSE cursors, questions, -//! interrupts, compaction -- before any AI is involved, and stays useful afterwards as -//! a connectivity check that costs no tokens. +//! The fake driver: no child process, just events. It proves the whole pipe -- +//! spawn, transcript, SSE cursors, questions, interrupts, compaction -- and +//! stays useful afterwards as a connectivity check that costs no tokens. It +//! produces exactly the event vocabulary the real drivers do, so a UI that +//! renders echo sessions correctly renders the real thing. //! -//! Behavior: every message is echoed back as a few streamed text deltas. -//! A leading word asks for something more specific: +//! Every message is echoed back as a few streamed text deltas. A leading word +//! asks for something more specific: //! //! - `/tool [input]` -- a full tool run, start through end. //! - `/bash [command]` -- a Bash call carrying that command, for what the //! phone's shell highlighting does to a particular line. -//! - `/tools [n] [gap]` -- n calls back to back, for what a run of them -//! looks like when a screen groups them. `gap` is seconds between one -//! call and the next, default none: it is what makes a run *grow* while -//! somebody is looking at it, which is the only way to reach the state -//! where a call opened on its own gains a neighbour. The first call -//! carries a screenshot, so that state can also be reached with an image -//! open full screen -- which is where it used to close itself. +//! - `/tools [n] [gap]` -- n calls back to back. `gap` is seconds between one +//! call and the next, which is what makes a run *grow* while somebody is +//! looking at it -- the only way to reach the state where a call opened on +//! its own gains a neighbour. The first call carries a screenshot, so that +//! state is also reachable with an image open full screen. //! - `/question [text]` -- a question, exercising the answer path. -//! - `/ask` -- an AskUserQuestion call: two questions on one tool call, -//! with descriptions, a preview and a multi-select, which is the shape -//! that is awkward to get a real model to produce on demand. Wrapped in -//! a run of ordinary calls on each side, because being asked something -//! happens in the middle of work and the screen has to keep it out of -//! the collapsed group around it. -//! - `/slow [seconds]` -- a turn that stays running (default 30), so states that only -//! exist *while* something is happening can be looked at. +//! - `/ask` -- an AskUserQuestion call: two questions on one tool call, with +//! descriptions, a preview and a multi-select, which is the shape that is +//! awkward to get a real model to produce on demand. Wrapped in a run of +//! ordinary calls on each side, because being asked something happens in the +//! middle of work. +//! - `/slow [seconds]` -- a turn that stays running (default 30), so states +//! that only exist *while* something is happening can be looked at. //! - `/error [text]` -- a failure, which is otherwise awkward to cause. -//! - `/peer [text]` -- a message from another agent, which otherwise takes -//! two live sessions and one of them deciding to write. -//! - `/compact` -- a compaction, start to finish. Typed rather than -//! pressed, because the real dialects take it as a typed command too and -//! the phone no longer has a button for it. +//! - `/peer [text]`, `/peer-turn` -- a message from another agent, in the +//! in-place and the live shapes. +//! - `/compact` -- a compaction, start to finish. +//! - `/stream N` -- one long answer in N small pieces, 50ms apart: the shape a +//! real model's reply arrives in, and the one where the row a reader is +//! anchored to is the row that keeps changing height. +//! - `/mixed N` -- N beats of an interleaved transcript: rows of every shape +//! and height the app draws, in one session, which is what a scrolling +//! problem needs in order to be reproduced twice the same way. +//! - `/table [columns]` -- a markdown table with cells too long for one line. //! -//! This is exactly the event vocabulary the real drivers produce, so a UI -//! that renders echo sessions correctly renders the real thing. -//! -//! - `/stream N` -- one long answer in N small pieces, 50ms apart: the -//! shape a real model's reply arrives in, and the one where the row a -//! reader is anchored to is the row that keeps changing height. -//! - `/mixed N` -- N beats of an interleaved transcript: paragraphs of -//! different lengths, single tool calls, runs of adjacent ones, attachments -//! and a peer message. Rows of every shape and height the app draws, in -//! one session, which is what a scrolling problem needs in order to be -//! reproduced twice the same way. -//! -//! `/slow` earns its place: a queued message, a Stop button, a spinner -//! where the answer will go are all states that only exist mid-turn, and -//! the obvious way to get one -- ask a real model to sleep -- does not -//! work. It declines, reasonably, and answers instantly instead, so the -//! state never arrives and the attempt still costs a turn on somebody's -//! account. A driver that can be *told* to take its time costs nothing and -//! is the same every run. +//! `/slow` earns its place: a queued message, a Stop button and a spinner are +//! states that only exist mid-turn, and the obvious way to get one -- ask a +//! real model to sleep -- does not work. It declines and answers instantly, so +//! the state never arrives and the attempt still costs a turn. use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; @@ -62,24 +50,18 @@ use super::driver::{ }; /// Delay between streamed deltas -- long enough that streaming is visibly -/// streaming in the UI, short enough that tests waiting on a full turn -/// stay fast. +/// streaming, short enough that tests waiting on a full turn stay fast. const DELTA_DELAY: Duration = Duration::from_millis(50); -/// How long a fake compaction takes. -/// -/// A measured one, near enough: driving a real session through `/compact` -/// on 2026-08-29 took 13 seconds for a small conversation, and a large one -/// takes minutes. Three seconds -- what this was -- is too short to look -/// at the row that only exists while a compaction is running, and too -/// short to watch its elapsed count reach two digits. +/// How long a fake compaction takes. A measured one, near enough: driving a +/// real session through `/compact` on 2026-08-29 took 13 seconds for a small +/// conversation. Three seconds -- what this was -- is too short to look at the +/// row that only exists while a compaction is running. const COMPACT_TIME: Duration = Duration::from_secs(13); -/// A question echo is waiting on, and the tool call it belongs to. -/// -/// `call` is `None` for `/question`, which asks on its own the way a -/// permission does; `Some` for `/ask`, where several questions share one -/// call and the call ends when the last of them is answered. +/// A question echo is waiting on, and the tool call it belongs to. `call` is +/// `None` for `/question`, which asks on its own the way a permission does; +/// `Some` for `/ask`, where several questions share one call. struct PendingQuestion { id: String, call: Option, @@ -89,37 +71,31 @@ pub struct EchoDriver { sink: EventSink, /// Whether a turn is in flight, and what arrived during it. /// - /// A real CLI holds a message sent mid-turn and injects it at the next - /// tool boundary; echo used to answer it on the spot, which made it - /// the wrong shape for testing anything about queueing -- the status - /// dropped to idle immediately, so a phone had nothing to show as - /// pending. Holding it here is what makes echo able to stand in. + /// A real CLI holds a message sent mid-turn and injects it at the next tool + /// boundary; echo used to answer it on the spot, which made it the wrong + /// shape for testing anything about queueing. busy: Arc, - /// Held messages with the id of the `MessageQueued` each one announced, - /// so the announcement can say which waiting bubble it resolves. + /// Held messages with the id of the `MessageQueued` each one announced, so + /// the announcement can say which waiting bubble it resolves. queued: Arc>>, /// Where `/mixed` writes the attachments it references, which is the same /// directory the files route serves them from. session_dir: PathBuf, - /// Ids of the questions awaiting an answer, in the order they were - /// asked. A list because `/ask` puts up to four on one tool call, the - /// way AskUserQuestion does, and the turn resumes when the last of - /// them is answered rather than the first. + /// Ids of the questions awaiting an answer, in the order asked. A list + /// because `/ask` puts up to four on one tool call, and the turn resumes + /// when the last is answered rather than the first. pending_questions: Mutex>, - /// A pretend context, so the status row has something that behaves the - /// way a real one does: it grows with each turn, drops to what the - /// compaction says it recovered, and a clear leaves it unmeasured. The - /// numbers are invented like everything else here; what is real is - /// which way they move. + /// A pretend context, so the status row has something that behaves the way + /// a real one does: it grows with each turn, drops to what the compaction + /// says it recovered, and a clear leaves it unmeasured. What is real is + /// which way the numbers move. context: Arc, } impl EchoDriver { - /// A short run of ordinary calls, to sit either side of something. - /// - /// Three, because two is the fewest that groups and three makes it - /// obvious the group is a group -- and because the point of the - /// fixture is what a question looks like with work around it. + /// A short run of ordinary calls, to sit either side of something. Three, + /// because two is the fewest that groups and three makes it obvious the + /// group is a group. fn some_calls(&self, label: &str) { for index in 0..3 { let id = format!("echo-{label}-{index}-{}", super::random_hex()); @@ -137,16 +113,15 @@ impl EchoDriver { /// An AskUserQuestion call, in the shape the CLI sends one. /// - /// Two questions on one call, because that is where the display is - /// hardest and where it was wrong: one question with four options - /// reads fine even when the options are laid out badly. Written out - /// in full rather than generated so it carries the parts that are - /// easy to leave out of a fixture -- a header, an option with a - /// description, an option with a preview block, and a multi-select. + /// Two questions on one call, because that is where the display is hardest + /// and where it was wrong. Written out in full rather than generated so it + /// carries the parts that are easy to leave out of a fixture -- a header, an + /// option with a description, an option with a preview block, and a + /// multi-select. fn ask_user_question(&self) { - // Written once, in the shape the events carry, and turned into - // the tool call's own input below -- the CLI sends both, and two - // hand-written copies of one question would drift. + // Written once, in the shape the events carry, and turned into the tool + // call's own input below -- the CLI sends both, and two hand-written + // copies of one question would drift. let asked = [ ( "Theme", @@ -236,8 +211,7 @@ impl EchoDriver { header: Some(header.to_string()), options, multi_select: multi, - // The call that asked, so all of it draws as one thing -- - // which is the whole point of the fixture. + // The call that asked, so all of it draws as one thing. about: Some(call.clone()), }); } @@ -248,23 +222,21 @@ impl EchoDriver { /// One typed line, whether it arrived as a message or as a command. /// - /// `announce` is the difference and it is the whole of it: a message - /// is announced with `MessageTaken`, which is what puts it in the - /// transcript, and a command is not -- the manager has already - /// recorded that one was sent, and saying so twice drew the same - /// line in both colours. + /// `announce` is the whole difference: a message is announced with + /// `MessageTaken`, which is what puts it in the transcript, and a command is + /// not -- the manager has already recorded that one was sent, and saying so + /// twice drew the same line in both colours. fn handle(&self, text: String, attachments: Vec, announce: bool) { let sink = self.sink.clone(); - // Mid-turn messages are held rather than answered, the way a real - // CLI holds them until the next tool boundary. Without this the - // session went idle the instant one arrived, and every state that - // only exists while something is queued was untestable. + // Mid-turn messages are held rather than answered, the way a real CLI + // holds them until the next tool boundary. Without this the session went + // idle the instant one arrived, and every state that only exists while + // something is queued was untestable. if self.busy.load(Ordering::SeqCst) { - // The waiting is recorded, exactly as the real driver records - // it: the phone draws its pending bubbles from the server, so - // an echo session has to produce the same events or the states - // it exists to exercise are not the app's real ones. + // The waiting is recorded, exactly as the real driver records it: + // the phone draws its pending bubbles from the server, so an echo + // session has to produce the same events. let id = super::random_hex(); self.queued .lock() @@ -280,17 +252,11 @@ impl EchoDriver { return; } - // Answered on the spot rather than in the turn below, because a - // peer message is not a turn: it is something that arrives, and - // what is being exercised is the row it becomes. The message that - // asked for it is still announced -- every driver owes exactly one - // `MessageTaken` per message, and a command that quietly vanishes - // from the transcript is the one thing echo must not model. // The live Claude Code shape, which is the one the ordering has to - // survive: the CLI says nothing about a peer message until the - // turn's `result`, so the event arrives below the whole reply it - // caused and the phone has to put it back. Checked before `/peer`, - // which would otherwise take the rest of this word as the body. + // survive: the CLI says nothing about a peer message until the turn's + // `result`, so the event arrives below the whole reply it caused and the + // phone has to put it back. Checked before `/peer`, which would + // otherwise take the rest of this word as the body. if let Some(rest) = text.strip_prefix("/peer-turn") { if announce { self.emit(Event::MessageTaken { @@ -345,9 +311,9 @@ impl EchoDriver { return; } - // The same word the real CLI takes, so a phone drives both the same - // way. `Driver::compact` is what the manager's own route calls; - // this is the typed path onto it. + // The same word the real CLI takes, so a phone drives both the same way. + // `Driver::compact` is what the manager's route calls; this is the typed + // path onto it. if text.trim() == "/compact" { if announce { self.emit(Event::MessageTaken { @@ -403,23 +369,20 @@ impl EchoDriver { return; } - // Checked before `/tool`, which is a prefix of it: matching the - // shorter one first would read "/tools 4" as a single tool whose - // input is "s 4". + // Checked before `/tool`, which is a prefix of it: matching the shorter + // one first would read "/tools 4" as a single tool whose input is "s 4". let many_tools = text.strip_prefix("/tools").map(|rest| { let mut words = rest.split_whitespace(); - // At least two, because one call is not a run of them and this - // exists to produce a run. + // At least two, because one call is not a run of them. let count = words .next() .and_then(|w| w.parse().ok()) .unwrap_or(3usize) .clamp(2, 12); - // How long to wait between calls, default none. A run that - // arrives all at once cannot exercise anything about a run - // *growing*: the case worth watching is a call somebody has - // opened and is reading when the next one turns it into a - // group, and 50ms apart is faster than anybody can open one. + // How long to wait between calls, default none. A run that arrives + // all at once cannot exercise a run *growing*: the case worth + // watching is a call somebody has opened and is reading when the + // next one turns it into a group. let gap = Duration::from_secs( words .next() @@ -438,21 +401,19 @@ impl EchoDriver { let run_bash = text .strip_prefix("/bash") .map(|rest| rest.trim().to_string()); - // Seconds to stay running before answering, default 30. Clamped - // rather than trusted: this is a test affordance, and a session - // pinned running for an hour by a typo is a worse outcome than a - // short wait. + // Seconds to stay running before answering, default 30. Clamped rather + // than trusted: a session pinned running for an hour by a typo is a + // worse outcome than a short wait. let stream = text .strip_prefix("/stream") .map(|rest| rest.trim().parse::().unwrap_or(400).clamp(1, 4000)); let mixed = text .strip_prefix("/mixed") .map(|rest| rest.trim().parse::().unwrap_or(12).clamp(1, 400)); - // How many columns wide a fixture table should be, default six. - // The count is the parameter because it is the thing the phone - // has to react to: a narrow table lays itself out across the - // screen and a wide one has to start scrolling sideways, and the - // boundary between the two is where the layout is wrong. + // How many columns wide a fixture table should be, default six. The + // count is the parameter because it is what the phone has to react to: a + // narrow table lays itself out across the screen and a wide one has to + // scroll sideways, and the boundary is where the layout is wrong. let table = text .strip_prefix("/table") .map(|rest| rest.trim().parse::().unwrap_or(6).clamp(1, 12)); @@ -472,10 +433,9 @@ impl EchoDriver { let _ = sink.send(event); }; let finish = || finish_turn(&sink, &queued, &busy); - // Echo takes a message the instant it gets one, but it says so - // anyway: a driver that skips this leaves the phone holding a - // message it thinks is still queued, and the point of an echo - // provider is that it behaves like the real ones. + // Echo takes a message the instant it gets one, but says so anyway: + // a driver that skips this leaves the phone holding a message it + // thinks is still queued. if announce { send(Event::MessageTaken { id: None, @@ -488,8 +448,7 @@ impl EchoDriver { }); if let Some(linger) = linger { - // A delta a second: visibly alive rather than merely slow, - // which is what the states being looked at accompany. + // A delta a second: visibly alive rather than merely slow. let seconds = linger.as_secs(); for remaining in (1..=seconds).rev() { send(Event::AssistantText { @@ -532,16 +491,14 @@ impl EchoDriver { "timeout": 5000, }), }); - // The first call carries a screenshot, and only the - // first. That is what makes this rig cover the case a - // growing run is actually about: an image opened full - // screen from a call that is alone, and then a second - // call arriving and turning that row into a group. The - // dialog used to be inside the row, so the reader was - // thrown back to the transcript by the session making - // another tool call. Any of the calls would do; the - // first is the one that is on its own for a whole - // `gap`, which is the window somebody can open it in. + // The first call carries a screenshot, and only the first. + // That is what makes this rig cover the case a growing run + // is about: an image opened full screen from a call that is + // alone, and then a second call turning that row into a + // group. The dialog used to be inside the row, so the reader + // was thrown back to the transcript by the session making + // another tool call. The first call is the one that is on + // its own for a whole `gap`. if i == 1 { let part = serde_json::json!({ "source": {"media_type": "image/png", "data": SAMPLE_PNG} @@ -565,9 +522,9 @@ impl EchoDriver { // One long answer arriving in small pieces, which is what a real // model does and what `/slow` does not: `/slow` emits a line a - // second, so its message grows in steps a reader can watch one - // at a time. A jump caused by the *anchor row itself* changing - // height needs growth that is continuous. + // second, so its message grows in steps a reader can watch one at a + // time. A jump caused by the *anchor row itself* changing height + // needs growth that is continuous. if let Some(pieces) = stream { for i in 0..pieces { let len = 3 + (i * 7) % 14; @@ -632,7 +589,6 @@ impl EchoDriver { }); } - // Word-at-a-time so streaming is visibly streaming. for word in format!("You said: {text}").split_inclusive(' ') { send(Event::AssistantText { delta: word.to_string(), @@ -640,8 +596,7 @@ impl EchoDriver { tokio::time::sleep(DELTA_DELAY).await; } // A conversation gets bigger, so the pretend context does too: - // roughly a hundred tokens a turn plus the words themselves, - // which is enough to watch it climb between compactions. + // roughly a hundred tokens a turn plus the words themselves. let spent = text.split_whitespace().count() as u64; send(Event::UsageDelta { tokens: spent, @@ -667,36 +622,32 @@ impl EchoDriver { } /// Sends are infallible from the driver's point of view: a closed sink - /// means the session is being torn down, and there is nobody left to - /// report to. + /// means the session is being torn down. fn emit(&self, event: Event) { let _ = self.sink.send(event); } } -/// A 16x10 checkerboard, the smallest thing that is recognisably an image -/// rather than a blank rectangle. -/// -/// Embedded rather than generated because the alternative is a PNG encoder -/// in a test rig, and drawn at the transcript's fixed thumbnail height -/// anyway -- what a scroll test needs from an image is that it occupies an -/// image's worth of space, not that it is pretty. +/// A 16x10 checkerboard, the smallest thing recognisably an image rather than a +/// blank rectangle. Embedded rather than generated because the alternative is a +/// PNG encoder in a test rig, and what a scroll test needs from an image is +/// that it occupies an image's worth of space. const SAMPLE_PNG: &str = "iVBORw0KGgoAAAANSUhEUgAAABAAAAAKCAIAAAAy3EnLAAAAIklEQVR42mPo3PILiOTk9ICIGDYDyRqIVwphk65h1A9EsAGCYdJRj+JH4wAAAABJRU5ErkJggg=="; -/// One beat of `/mixed`: a row shape chosen by position, so the same N -/// always produces the same transcript. +/// One beat of `/mixed`: a row shape chosen by position, so the same N always +/// produces the same transcript. /// /// Repeatable on purpose. A scrolling fault is judged by watching the same -/// content behave differently, and a rig that produced a different -/// transcript each run would make every comparison an argument about -/// whether the content changed. +/// content behave differently, and a rig that produced a different transcript +/// each run would make every comparison an argument about whether the content +/// changed. async fn write_beat(sink: &EventSink, session_dir: &Path, beat: usize) { let send = |event: Event| { let _ = sink.send(event); }; match beat % 5 { - // A paragraph, of three lengths, because a list of uniform rows - // hides exactly the faults that uneven ones expose. + // A paragraph, of three lengths, because a list of uniform rows hides + // exactly the faults that uneven ones expose. 1 => { let words = match beat % 3 { 0 => 12, @@ -705,9 +656,8 @@ async fn write_beat(sink: &EventSink, session_dir: &Path, beat: usize) { }; // Deliberately ragged: each word's length is a function of its // position, so no two lines wrap the same way. A paragraph of - // uniform tokens is a wall that looks identical at every - // offset, which makes it impossible to tell a scroll of one - // line from a scroll of ten -- by eye or by comparing frames. + // uniform tokens looks identical at every offset, which makes it + // impossible to tell a scroll of one line from a scroll of ten. let body: String = (0..words) .map(|w| { let len = 3 + (w * 7 + beat * 3) % 14; @@ -731,8 +681,8 @@ async fn write_beat(sink: &EventSink, session_dir: &Path, beat: usize) { output: format!("beat {beat}: forty-two lines of nothing in particular"), }); } - // A run of three, which the app folds into one collapsed group -- - // the row whose identity depends on what is next to it. + // A run of three, which the app folds into one collapsed group -- the + // row whose identity depends on what is next to it. 3 => { for i in 1..=3 { let id = format!("t-{}", super::random_hex()); @@ -779,30 +729,27 @@ async fn write_beat(sink: &EventSink, session_dir: &Path, beat: usize) { }); } } - // Slow enough that the phone renders each beat as it arrives rather - // than composing the whole run in one frame -- which is the condition - // a scrolling fault actually happens under. + // Slow enough that the phone renders each beat as it arrives rather than + // composing the whole run in one frame -- which is the condition a scrolling + // fault actually happens under. tokio::time::sleep(Duration::from_millis(120)).await; } /// A message written during a turn and waiting for it to end: the id of the -/// `MessageQueued` that announced it, what it said, and what was attached to -/// it. All three, because all three are what the `MessageTaken` at the other -/// end owes -- named rather than written out at each of the four places that -/// mention it. +/// `MessageQueued` that announced it, what it said, and what was attached. All +/// three, because all three are what the `MessageTaken` at the other end owes. type Held = (String, String, Vec); /// A markdown table [columns] wide, with cells too long for one line. /// -/// Both halves of that matter. Long cells are what the renderer used to cut -/// off with an ellipsis, and a cut cell looks exactly like a short one, so -/// a fixture of tidy one-word values would have rendered perfectly while -/// the defect was still there. The column count is what decides whether -/// the table fits the screen or has to scroll sideways. +/// Both halves matter. Long cells are what the renderer used to cut off with an +/// ellipsis, and a cut cell looks exactly like a short one, so a fixture of +/// tidy one-word values would have rendered perfectly while the defect was +/// still there. The column count decides whether the table fits the screen. /// -/// Written out as markdown rather than assembled from a grid type because -/// what is being tested is the renderer's parse of the syntax a model -/// actually writes, pipes and alignment row included. +/// Written out as markdown rather than assembled from a grid type because what +/// is being tested is the renderer's parse of the syntax a model actually +/// writes, pipes and alignment row included. fn markdown_table(columns: usize) -> String { let headings = [ "What it is", @@ -854,17 +801,16 @@ fn markdown_table(columns: usize) -> String { out } -/// Ending a turn is also when anything held during it is taken up -- the -/// moment a real CLI would have injected it. One place, because a turn has -/// several ways to end (a reply, an interrupt, a compaction) and every one -/// of them owes the same answer. +/// Ending a turn is also when anything held during it is taken up -- the moment +/// a real CLI would have injected it. One place, because a turn has several +/// ways to end (a reply, an interrupt, a compaction) and every one of them owes +/// the same answer. fn finish_turn(sink: &EventSink, queued: &Mutex>, busy: &AtomicBool) { let held = std::mem::take(&mut *queued.lock().unwrap()); for (id, text, attachments) in held { - // Announced before it is answered, in that order: a phone showing - // the message as pending needs the signal that it has been read, - // and the answer is meaningless above a message still drawn as - // waiting. + // Announced before it is answered, in that order: a phone showing the + // message as pending needs the signal that it has been read, and the + // answer is meaningless above a message still drawn as waiting. let _ = sink.send(Event::MessageTaken { id: Some(id), text: text.clone(), @@ -885,12 +831,10 @@ impl Driver for EchoDriver { !self.busy.load(Ordering::SeqCst) } - /// Really droppable, which is what makes this the rig for the phone's - /// side of it: the held message is this driver's own and nothing has - /// been written anywhere, so a tap here exercises the whole path - /// through to the bubble disappearing on every device. The Claude - /// driver can only ever refuse -- see its own `unqueue` -- so it - /// cannot exercise the case where the drop succeeds. + /// Really droppable, which is what makes this the rig for the phone's side + /// of it: the held message is this driver's own and nothing has been written + /// anywhere, so a tap here exercises the whole path through to the bubble + /// disappearing on every device. The Claude driver can only ever refuse. fn unqueue(&self, id: &str) -> Unqueued { let mut queued = self.queued.lock().unwrap(); let Some(at) = queued.iter().position(|(waiting, ..)| waiting == id) else { @@ -903,18 +847,15 @@ impl Driver for EchoDriver { } fn send_user_message(&self, text: String, attachments: Vec) { - // Announced, because this is a message: every driver owes exactly - // one `MessageTaken` per message, and one that quietly vanishes - // from the transcript is the thing echo must not model. A command - // owes none -- the manager has already recorded that it was sent, - // and announcing it again drew the same line twice, once in each - // colour. + // Announced, because this is a message: every driver owes exactly one + // `MessageTaken` per message, and one that quietly vanishes from the + // transcript is the thing echo must not model. A command owes none. self.handle(text, attachments, true); } - /// Echo's commands *are* its messages -- `/tool`, `/slow`, `/ask` -- - /// so this is the same path with the same parsing, and the fixture - /// behaves like a real session driven the same way. + /// Echo's commands *are* its messages -- `/tool`, `/slow`, `/ask` -- so + /// this is the same path with the same parsing, and the fixture behaves like + /// a real session driven the same way. fn run_command(&self, text: &str) { self.handle(text.to_string(), Vec::new(), false); } @@ -930,8 +871,8 @@ impl Driver for EchoDriver { return; }; let answered = pending.remove(at); - // Whether anything on the same call is still unanswered: a - // tool that asked four questions ends once, not four times. + // Whether anything on the same call is still unanswered: a tool that + // asked four questions ends once, not four times. let waiting = answered .call .as_ref() @@ -946,9 +887,9 @@ impl Driver for EchoDriver { id: call, output: format!("answered: {answer}"), }); - // The work carries on where it left off, which is what makes - // the asked-here row a boundary with a group on each side - // rather than the last thing in the turn. + // The work carries on where it left off, which is what makes the + // asked-here row a boundary with a group on each side rather than + // the last thing in the turn. self.some_calls("after"); } else { self.emit(Event::AssistantText { @@ -961,17 +902,16 @@ impl Driver for EchoDriver { } fn interrupt(&self) { - // Nothing real to stop; a pending question is abandoned so the - // session isn't stuck awaiting input forever. + // Nothing real to stop; a pending question is abandoned so the session + // isn't stuck awaiting input forever. self.pending_questions.lock().unwrap().clear(); self.emit(Event::Status { state: SessionStatus::Idle, }); } - // Nothing to forward: this process has no notion of what the - // conversation is called, and the rename it belongs to has already - // happened where the name lives. See `Driver::set_title`. + // Nothing to forward: this process has no notion of what the conversation + // is called, and the rename has already happened where the name lives. fn set_title(&self, _title: &str) {} fn set_permission_mode(&self, mode: &str) { @@ -986,14 +926,11 @@ impl Driver for EchoDriver { }); } - /// A compaction with nothing to compact. - /// - /// The counts are invented, like everything else this driver says -- - /// what is real is the shape and the order: busy, a pause long enough - /// to see, then the result. `Compacting` and `Compacted` are states a - /// screen has to draw, and the only other way to reach them is to fill - /// a real session's context and spend two minutes of somebody's - /// account getting it back. + /// A compaction with nothing to compact. The counts are invented, like + /// everything else this driver says -- what is real is the shape and the + /// order: busy, a pause long enough to see, then the result. The only other + /// way to reach those states is to fill a real session's context and spend + /// two minutes of somebody's account getting it back. fn compact(&self) { let sink = self.sink.clone(); let queued = Arc::clone(&self.queued); @@ -1005,10 +942,10 @@ impl Driver for EchoDriver { state: SessionStatus::Compacting, }); tokio::time::sleep(COMPACT_TIME).await; - // What it says it recovered is what the pretend context becomes, - // so the figure on the status row and the one on the divider - // agree -- two numbers about the same moment disagreeing is the - // thing this rig exists to catch. + // What it says it recovered is what the pretend context becomes, so + // the figure on the status row and the one on the divider agree -- + // two numbers about the same moment disagreeing is the thing this + // rig exists to catch. context.store(9_617, Ordering::SeqCst); let _ = sink.send(Event::Compacted { pre_tokens: Some(128_402), diff --git a/server/src/session/import.rs b/server/src/session/import.rs index 103c92e..b9bfc74 100644 --- a/server/src/session/import.rs +++ b/server/src/session/import.rs @@ -28,20 +28,15 @@ use super::transport::{Launch, Transport}; /// How much of a transcript's tail is replayed into the phone's view. /// -/// The imported conversation is for reading; *continuing* it is the CLI's -/// job through `--resume`, and it reads the whole file itself regardless -/// of what is shown here. So this is a display budget, not a fidelity one -/// -- and it needs to be a budget, because these files reach tens of -/// megabytes (the session this feature was written in was 39 MB) and every -/// line of it would otherwise cross a WireGuard link to a phone. +/// The imported conversation is for reading; *continuing* it is the CLI's job +/// through `--resume`, and it reads the whole file itself. So this is a +/// display budget, and it needs to be one: these files reach tens of megabytes +/// and every line would otherwise cross a WireGuard link to a phone. const REPLAY_LINES: usize = 2000; -/// Whether a session is open in a CLI somewhere. -/// -/// Three answers, because "nobody could check" is not "nobody is using -/// it". Collapsing them would put the dangerous case behind the safe -/// word, which is how the expensive version of this happens: an import -/// that looks permitted, of a session that is being written to. +/// Whether a session is open in a CLI somewhere. Three answers, because +/// "nobody could check" is not "nobody is using it" -- collapsing them puts +/// the dangerous case behind the safe word. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "camelCase")] pub enum InUse { @@ -49,8 +44,8 @@ pub enum InUse { No, /// Checked, and a live CLI has it open. Yes, - /// The machine does not keep the record this is read from, so there is - /// no answer to be had -- not an answer of "no". + /// The machine does not keep the record this is read from, so there is no + /// answer to be had -- not an answer of "no". Unknown, } @@ -61,113 +56,87 @@ pub struct Importable { /// The CLI's own session id, which is both the file name and the /// `--resume` token. pub id: String, - /// Where that session was working, offered as the imported session's - /// cwd so it resumes pointing at the same tree. + /// Where that session was working, offered as the imported session's cwd + /// so it resumes pointing at the same tree. pub cwd: String, /// The first thing a person said in it, for recognising it in a list. pub title: String, /// Epoch seconds, for ordering by "what I was last doing". pub modified: f64, pub lines: usize, - /// How many tokens the model was holding at the last turn. - /// - /// The input side of the most recent assistant message's usage -- - /// prompt plus both cache figures -- which is the closest thing to - /// "what continuing this costs", and unlike the size it is a number - /// the CLI itself recorded rather than one inferred from the file. + /// How many tokens the model was holding at the last turn: the input side + /// of the most recent assistant message's usage, which is the closest thing + /// to "what continuing this costs" and is a number the CLI recorded rather + /// than one inferred from the file. /// /// Size and this disagree in the direction that matters. Most of a big - /// transcript is usually history from before a compaction, which the - /// model is no longer given: of the 133 MB session behind the - /// 2026-08-29 incident, 99% of the bytes sat before its last - /// compaction summary. A 77 MB file whose context is 10k tokens is - /// cheap to continue; a smaller one that has never compacted may not - /// be. + /// transcript is usually history from before a compaction, which the model + /// is no longer given: of the 133 MB session behind the 2026-08-29 + /// incident, 99% of the bytes sat before its last compaction summary. /// - /// `None` when no assistant turn has recorded usage yet -- which is - /// not zero, and is why this is an option rather than a default. + /// `None` when no assistant turn has recorded usage yet -- which is not + /// zero, and is why this is an option. pub context_tokens: Option, - /// Size of the file, in bytes. + /// Size of the file, in bytes. Reported because it predicts what + /// continuing the session will cost and lines do not: these transcripts + /// embed screenshots as base64, so one line can be a megabyte. The session + /// behind the 2026-08-29 incident was 65 MB across 13,000 lines. /// - /// Reported because it is the only thing on a row that predicts what - /// continuing the session will cost, and lines do not: these - /// transcripts embed screenshots as base64, so one line can be a - /// megabyte. The session behind the 2026-08-29 incident was 65 MB - /// across 13,000 lines, which is a line count that looks unremarkable. - /// - /// Shown rather than warned about. Importing a large session is a - /// choice somebody is entitled to make, and marking it would be the - /// interface nagging about a decision already taken -- but they should - /// be able to see what they are taking on. + /// Shown rather than warned about: importing a large session is a choice + /// somebody is entitled to make. pub bytes: u64, /// Whether [`title`](Self::title) is a name somebody chose rather than - /// something read out of the conversation. Sorted on, and worth the - /// reader knowing: a name is a claim about what a session *is*, and a - /// last message is only the last thing that happened in it. + /// something read out of the conversation. Worth the reader knowing: a name + /// is a claim about what a session *is*, and a last message is only the + /// last thing that happened in it. pub named: bool, /// Whether a CLI is running this session right now. /// - /// The load-bearing field on this struct. Importing a session that is - /// already open puts a second `--resume` on one file: the whole - /// conversation gets duplicated into it, both copies then read each - /// other's writes as work done elsewhere, and the adopted one is - /// billed for re-reading everything -- measured on 2026-08-29 at 65 MB - /// and 154 screenshots, from importing the session the importing agent - /// was itself running in. + /// The load-bearing field on this struct. Importing a session already open + /// puts a second `--resume` on one file: the conversation gets duplicated + /// into it, both copies read each other's writes as work done elsewhere, + /// and the adopted one is billed for re-reading everything -- measured on + /// 2026-08-29 at 65 MB and 154 screenshots. pub in_use: InUse, - /// Where it lives. Not serialized: the phone chooses by id and the - /// server resolves the path, so a path never crosses the wire in - /// either direction. + /// Where it lives. Not serialized: the phone chooses by id and the server + /// resolves the path, so a path never crosses the wire either direction. #[serde(skip)] pub path: String, } /// Asks `transport`'s machine which Claude Code sessions it has. /// -/// One command rather than one per file, for the reason `setups::discover` -/// gives: over ssh each would be its own connection and handshake. -/// -/// `stat -c` is GNU-specific, which is fine for the machines here and is -/// the thing to change first if this ever meets a BSD. +/// One command rather than one per file: over ssh each would be its own +/// connection and handshake. `stat -c` is GNU-specific, which is the thing to +/// change first if this ever meets a BSD. pub async fn list(transport: &Transport) -> Result> { // Which sessions are open right now, before the files themselves. // // Claude Code writes a descriptor per live session at - // `~/.claude/sessions/.json`, and the pid is the file name. It - // also records `procStart` -- the kernel's start time for that pid -- - // for the same reason `session::process` does: a pid on its own is - // reused, so a descriptor left behind by a CLI that crashed would - // otherwise mark a session as open for as long as something else held - // its number. Checking both is what makes this a measurement. + // `~/.claude/sessions/.json`, and records `procStart` -- the kernel's + // start time for that pid -- for the same reason `session::process` does: a + // pid on its own is reused, so a descriptor left by a crashed CLI would + // otherwise mark a session as open for as long as something else held its + // number. Checking both is what makes this a measurement. // - // The `LIVEKNOWN` line says the directory was there to be read at - // all. Without it an old CLI that keeps no descriptors would look - // exactly like a machine with nothing running, which is the one - // mistake this check exists to prevent. + // The `LIVEKNOWN` line says the directory was there to be read at all. + // Without it an old CLI that keeps no descriptors would look exactly like a + // machine with nothing running. // - // Then two questions per file, both answered from the end of it. + // Then two questions per file, both answered from the end of it. A rename + // if there was one, grepped over the whole file rather than its tail + // because a session can be named early and talked in for hours after. Then + // the last several things a person said -- the *last*, because the question + // this answers is "which one was I just in", and several because the final + // ones are often the CLI's own. // - // A rename, if there was one: `/rename` appends a `custom-title` - // record, and a name somebody chose beats anything inferred from the - // conversation. Grepped over the whole file rather than its tail, - // because a session can be named early and talked in for hours after. - // - // Then the last several things a person said. The *last*, not the - // first: the question a list like this answers is "which one was I - // just in", and every session's opening line is the least distinctive - // thing about it. Several, because the final ones are often the CLI's - // own -- a slash command, the caveat wrapped around its output -- and - // one of those identifies nothing. - // - // Tool results are excluded rather than typed messages included, and - // the difference matters: a tool result is *also* a user record -- - // it is how the API models one -- so grepping the type alone gave a - // session that ended mid-tool a tail of empty records and a row - // saying nothing was said, when plenty was. But matching only a - // string `content` was worse: a message carrying an attachment stores - // its text in a list, so that reading lost twenty rows rather than - // two. Excluding `tool_use_id` keeps both shapes of a real message - // and drops the one that is not. + // Tool results are excluded rather than typed messages included, and the + // difference matters: a tool result is *also* a user record, so grepping + // the type alone gave a session that ended mid-tool a tail of empty records. + // But matching only a string `content` was worse -- a message carrying an + // attachment stores its text in a list, so that reading lost twenty rows + // rather than two. Excluding `tool_use_id` keeps both shapes of a real + // message and drops the one that is not. let script = listing_script(r#""$HOME"/.claude/projects/*/*.jsonl"#); let launch = Launch::new("sh", vec!["-c".to_string(), script], None); parse_listing(&transport.capture(&launch).await?) @@ -175,13 +144,10 @@ pub async fn list(transport: &Transport) -> Result> { /// The same listing, for one session named by id. /// -/// Importing needs everything a row holds -- the path to follow, how many -/// lines have already been written, what it is called, where it was working -/// and whether something else has it open -- and used to get them by -/// listing *every* session and searching the result. That is a full read of -/// every transcript on the machine, seconds of it, to answer a question -/// about one file; a batch of imports paid it once each. Same script, same -/// parsing, one glob narrower. +/// Importing needs everything a row holds, and used to get it by listing +/// *every* session and searching the result -- a full read of every transcript +/// on the machine, seconds of it, to answer a question about one file, paid +/// once per import in a batch. Same script, same parsing, one glob narrower. pub async fn find(transport: &Transport, id: &str) -> Result> { if !is_session_id(id) { return Ok(None); @@ -199,18 +165,14 @@ pub async fn find(transport: &Transport, id: &str) -> Result> /// What the machine is asked, over whichever set of files `glob` names. /// -/// One script with the glob substituted rather than two that drift: the -/// per-file half decides what a row *is*, and a row has to mean the same -/// thing whether it arrived from a listing or from a lookup. The glob is -/// this module's own text; the only thing that ever crosses from outside is -/// the id, which stays an argument (`$1`) and is checked by -/// [`is_session_id`] first. +/// One script with the glob substituted rather than two that drift: a row has +/// to mean the same thing whether it came from a listing or a lookup. The glob +/// is this module's own text; the only thing that crosses from outside is the +/// id, which stays an argument and is checked by [`is_session_id`] first. fn listing_script(glob: &str) -> String { - // `replace` rather than `format!`: this is shell, so it is full of - // braces -- `${s##*/}`, an awk program, the `{[^}]*` that finds a usage - // record -- and every one of them would have to be doubled to survive a - // format string. Doubling braces inside a script is exactly the kind of - // edit that looks right and changes what the shell runs. + // `replace` rather than `format!`: this is shell, so it is full of braces, + // and every one would have to be doubled to survive a format string -- + // exactly the kind of edit that looks right and changes what the shell runs. SCRIPT.replace("{glob}", glob) } @@ -260,21 +222,18 @@ fn parse_listing(found: &str) -> Result> { }; } // One row per session id, because the id is what everything downstream - // addresses: `--resume` takes it, deleting globs for it, and the - // in-flight registry is keyed on it. So two rows sharing an id are two - // rows that no operation can tell apart -- and the phone keys its list - // on it too, which turned this into a crash rather than a confusion. + // addresses: `--resume` takes it, deleting globs for it, the in-flight + // registry is keyed on it, and the phone keys its list on it -- which + // turned two rows sharing an id into a crash rather than a confusion. // - // It is a real state of the machine, not corruption: resuming a session - // from a different working directory makes the CLI write a second file - // under that directory's project folder with the same id. One of the two - // is then usually a stub of a few hundred bytes and the other is the - // conversation somebody means. + // It is a real state of the machine, not corruption: resuming from a + // different working directory makes the CLI write a second file under that + // directory's project folder with the same id. One is then usually a stub + // of a few hundred bytes. // - // So the copy with the most in it wins, and the row's `cwd` comes from - // that same copy -- which is the directory `--resume` will find it under. - // Ties go to the more recent, and the *stub* is often the more recent, so - // the size has to be the first key rather than the tie-break. + // So the copy with the most in it wins, and the row's `cwd` comes from that + // same copy. Ties go to the more recent, and the *stub* is often the more + // recent, so size has to be the first key rather than the tie-break. sessions.sort_by(|a, b| { b.lines .cmp(&a.lines) @@ -283,12 +242,10 @@ fn parse_listing(found: &str) -> Result> { let mut seen = std::collections::HashSet::new(); sessions.retain(|session| seen.insert(session.id.clone())); - // Most recent first, and only that. Naming was tried as the first key - // and is a worse list: it buries what somebody was just doing under - // everything they ever named, and the reason to open this screen is - // almost always to pick up where they left off. A name still shows, - // as the row's title and as a word beside it -- being easier to - // recognise is what a name is for, and it does not need the order too. + // Most recent first, and only that. Naming was tried as the first key and + // is a worse list: it buries what somebody was just doing under everything + // they ever named. A name still shows, as the row's title and as a word + // beside it. sessions.sort_by(|a, b| b.modified.total_cmp(&a.modified)); Ok(sessions) } @@ -322,23 +279,21 @@ fn parse_row(line: &str) -> Option { if !is_hidden(&record) && let Some(text) = first_line_of(&record) { - // Kept rather than broken out of: these arrive oldest first, - // so the last one to survive the filter is the most recent - // thing that was actually said. + // Kept rather than broken out of: these arrive oldest first, so the + // last to survive the filter is the most recent thing said. said = Some(text); } } Some(Importable { id, - // Filled in by `list`, which is the only thing that knows: it - // takes one command to ask a machine, and asking per row would be - // one ssh connection each. + // Filled in by `list`, which is the only thing that knows: it takes one + // command to ask a machine, and asking per row would be one ssh + // connection each. in_use: InUse::Unknown, cwd: cwd.unwrap_or_default(), - // A name somebody typed outranks anything read out of the - // conversation, because they chose it to answer this exact - // question. + // A name somebody typed outranks anything read out of the conversation, + // because they chose it to answer this exact question. named: named.is_some(), title: named .or(said) @@ -351,24 +306,21 @@ fn parse_row(line: &str) -> Option { }) } -/// The input tokens named in one `usage` object, added up. -/// -/// Prompt plus cache creation plus cache read: all three are context the -/// model was given -- the definition is [`driver::context_tokens`]; this -/// is the same three figures dug out of a raw line rather than a parsed -/// one, because these files reach tens of megabytes. +/// The input tokens named in one `usage` object, added up: prompt plus cache +/// creation plus cache read, all three being context the model was given. The +/// definition is [`driver::context_tokens`]; this is the same three figures dug +/// out of a raw line rather than a parsed one, because these files reach tens +/// of megabytes. /// /// `None` for an empty blob, meaning no assistant turn has recorded usage. -/// Missing individual fields count as zero, which is what an absent -/// category means; an unparseable one does the same rather than -/// discarding the figures that did read. +/// Missing fields count as zero, which is what an absent category means. fn context_tokens(usage: &str) -> Option { if usage.trim().is_empty() { return None; } // The leading quote matters: without it `"input_tokens"` also matches - // inside `"cache_read_input_tokens"`, and the same number gets counted - // three times. + // inside `"cache_read_input_tokens"`, and the same number is counted three + // times. let field = |name: &str| -> u64 { usage .split_once(&format!("\"{name}\":")) @@ -388,12 +340,10 @@ fn context_tokens(usage: &str) -> Option { /// The first line of what a person typed, short enough for a list row. /// -/// None for the CLI's own plumbing. A slash command, the caveat wrapped -/// around a local command's output, and an injected reminder are all -/// stored as ordinary user records without the `isMeta` flag -- so titling -/// by "first user record" gave a list where most rows read -/// `/clear`, which identifies nothing. The -/// caller offers several candidates for exactly this reason. +/// None for the CLI's own plumbing. A slash command, the caveat wrapped around +/// a local command's output, and an injected reminder are all stored as +/// ordinary user records without `isMeta` -- so titling by "first user record" +/// gave a list where most rows read `/clear`. fn first_line_of(record: &Value) -> Option { let text = text_of(record.get("message")?.get("content")?); let first = text.lines().find(|line| !line.trim().is_empty())?.trim(); @@ -404,19 +354,15 @@ fn first_line_of(record: &Value) -> Option { (!trimmed.is_empty()).then_some(trimmed) } -/// Records the transcript should not show: a subagent's private -/// conversation, and the CLI's own injected notes. -/// -/// The same rule the live translator applies -- a sidechain is another -/// agent talking to itself, and duplicating it into this transcript would +/// Records the transcript should not show: a subagent's private conversation, +/// and the CLI's own injected notes. The same rule the live translator applies +/// -- a sidechain is another agent talking to itself, and duplicating it would /// show the reader two conversations interleaved as one. fn is_hidden(record: &Value) -> bool { record.get("isSidechain").and_then(Value::as_bool) == Some(true) || record.get("isMeta").and_then(Value::as_bool) == Some(true) } -/// Concatenated text of a message's content, which is either a bare string -/// or the API's list of blocks. fn text_of(content: &Value) -> String { match content { Value::String(text) => text.clone(), @@ -432,36 +378,31 @@ fn text_of(content: &Value) -> String { /// Whether a directory the machine recorded is still there. /// -/// Asked because a session's recorded cwd can outlive the directory: these -/// files go back months, and a checkout that moved leaves every session -/// from before the move pointing at a path that is gone. Resuming into one -/// fails at `cd` before the CLI starts, which is a confusing way to meet a -/// feature whose whole promise is "carry on where you left off". +/// A session's recorded cwd can outlive the directory: these files go back +/// months, and a checkout that moved leaves every session from before it +/// pointing at a path that is gone. Resuming into one fails at `cd` before the +/// CLI starts. pub async fn directory_exists(transport: &Transport, path: &str) -> bool { if path.is_empty() { return false; } // Asked by *entering* it rather than by `test -d `, because the - // question this is standing in for is "can a session start here" and - // because a path is only expanded where it is a working directory -- - // `~/repos/ai-app` as an argument stays four literal characters on - // both transports (`ssh::quote_path`, `ssh::expand_home`), so the old - // form answered "no such directory" about every home-relative path - // somebody typed. + // question this stands in for is "can a session start here" and because a + // path is only expanded where it is a working directory -- `~/repos/ai-app` + // as an argument stays literal on both transports, so the old form answered + // "no such directory" about every home-relative path somebody typed. let launch = Launch::new("true", Vec::new(), Some(std::path::Path::new(path))); transport.capture(&launch).await.is_ok() } /// Reads the tail of one session's file, as the raw JSONL. /// -/// `tail` rather than the whole file, and as [`Launch`] arguments rather -/// than a shell string, so the path is an argument and never syntax. +/// `tail` rather than the whole file, and as [`Launch`] arguments rather than a +/// shell string, so the path is an argument and never syntax. /// -/// Returns text rather than events because turning records into events has -/// a side effect -- writing out the images they carry -- and it needs the -/// session directory to write them into. That directory does not exist -/// until the session is created, which is after this runs, so the -/// conversion happens there instead. See [`events_from`]. +/// Returns text rather than events because turning records into events has a +/// side effect -- writing out the images they carry -- and that needs the +/// session directory, which does not exist until after this runs. pub async fn read_tail(transport: &Transport, path: &str) -> Result { let launch = Launch::new( "tail", @@ -477,32 +418,28 @@ pub async fn read_tail(transport: &Transport, path: &str) -> Result { /// Claude Code's stored JSONL as this project's events. /// /// A partial first line is expected and ignored: `tail -n` cuts at a line -/// boundary, but the *file* may have been appended to since, and a line -/// that does not parse is one this reader has no opinion about. +/// boundary, but the *file* may have been appended to since. /// /// `session_dir` is where images found along the way are written, the same -/// place and by the same function the live translator uses -- so a -/// screenshot looks identical whether it was watched as it happened or -/// replayed afterwards. It is only the *reference* that reaches the phone; -/// the bytes are fetched from `/sessions/{id}/files/{ref}` when something -/// actually draws them, and none of this is ever sent back to the CLI, -/// which reads its own session file. +/// place and by the same function the live translator uses -- so a screenshot +/// looks identical whether it was watched happening or replayed afterwards. +/// Only the *reference* reaches the phone. pub fn events_from(text: &str, session_dir: &std::path::Path) -> Vec { let mut events = Vec::new(); - // What the newest record that had an opinion says the session is - // doing. Kept to the end rather than pushed as it is found, because - // the answer is the last one and everything before it is history. + // What the newest record that had an opinion says the session is doing. + // Kept to the end rather than pushed as it is found, because the answer is + // the last one and everything before it is history. let mut state = None; for line in text.lines() { let Ok(record) = serde_json::from_str::(line) else { continue; }; if let Some(peer) = peer_message(&record) { - // Before `is_hidden`, which these records are: the CLI marks - // them meta because they are not the user's own words, and - // that is the reason to draw them differently rather than the - // reason to drop them. A session working on something a phone - // never asked for is otherwise unexplainable from the phone. + // Before `is_hidden`, which these records are: the CLI marks them + // meta because they are not the user's own words, and that is the + // reason to draw them differently rather than to drop them. A + // session working on something a phone never asked for is otherwise + // unexplainable from the phone. state = turn_state(&record).or(state); events.push(peer); continue; @@ -531,20 +468,18 @@ pub fn events_from(text: &str, session_dir: &std::path::Path) -> Vec { /// A message from another agent, as the CLI reports one. /// -/// Measured from a real session file (2026-08-29): the record is a `user` -/// one marked `isMeta`, and its `origin` carries `kind: "peer"`, the -/// sending session's `name`, and the message itself as `body`. The -/// message content beside it is the same text wrapped in an explanatory -/// preamble and a `` tag, which is written for the -/// model that has to read it rather than for a person -- so the body is -/// what a reader is shown, and the name is who they are told sent it. +/// Measured from a real session file (2026-08-29): the record is a `user` one +/// marked `isMeta`, and its `origin` carries `kind: "peer"`, the sending +/// session's `name`, and the message as `body`. The message content beside it +/// is the same text wrapped in a preamble written for the model rather than for +/// a person, so the body is what a reader is shown. /// -/// Shared with the live driver (`claude::translate`), which finds the same -/// `origin` object on a different record -- so this reads the object and -/// not the record around it. One function because it is one wire format: -/// two copies would drift the first time the CLI renames a field, and the -/// half that drifted would go on producing nothing at all, which is -/// indistinguishable from nobody having sent anything. +/// Shared with the live driver, which finds the same `origin` object on a +/// different record -- so this reads the object and not the record around it. +/// One function because it is one wire format: two copies would drift the first +/// time the CLI renames a field, and the half that drifted would produce +/// nothing at all, which is indistinguishable from nobody having sent +/// anything. pub(in crate::session) fn peer_message(record: &Value) -> Option { let origin = record.get("origin")?; if origin.get("kind").and_then(Value::as_str) != Some("peer") { @@ -562,27 +497,22 @@ pub(in crate::session) fn peer_message(record: &Value) -> Option { }) } -/// Whether this record means the session is working, as far as it can be -/// told from the file. +/// Whether this record means the session is working, as far as the file can +/// say. /// -/// The one thing a session file does not contain is the CLI saying "this -/// turn is over": there is no `result` record, only the messages. What -/// there is instead is why the last assistant message stopped, and that -/// answers it -- `tool_use` means a call is being made and more is coming, -/// anything else means the model has finished talking. Anything on the -/// user's side of the conversation -- a person, a tool's result, another -/// agent -- means the session has something to answer and is answering it. +/// The one thing a session file does not contain is the CLI saying "this turn +/// is over": there is no `result` record. What there is instead is why the last +/// assistant message stopped -- `tool_use` means a call is being made and more +/// is coming, anything else means the model has finished talking. Anything on +/// the user's side means the session has something to answer. /// -/// `None` is the third answer and it matters: a record that says nothing -/// about the turn leaves the status alone rather than voting for idle. The -/// same goes for a record whose reason for stopping is missing, which is -/// what a future CLI adding a shape we do not know looks like. +/// `None` is the third answer and it matters: a record that says nothing about +/// the turn leaves the status alone rather than voting for idle. /// /// What this cannot see is a session that stopped existing mid-turn -- its -/// file's last record still says `tool_use`, so it reads as working -/// forever. Nothing in the file distinguishes that from a model thinking, -/// and inventing a timeout here would replace a stale reading with a -/// confident wrong one. +/// file's last record still says `tool_use`, so it reads as working forever. +/// Nothing in the file distinguishes that from a model thinking, and inventing +/// a timeout would replace a stale reading with a confident wrong one. fn turn_state(record: &Value) -> Option { use super::driver::SessionStatus; match record.get("type").and_then(Value::as_str)? { @@ -600,20 +530,19 @@ fn turn_state(record: &Value) -> Option { fn push_user(events: &mut Vec, content: &Value, session_dir: &std::path::Path) { // A tool result arrives as a user record, because that is how the API - // models it -- but it is the other half of a tool call, not something - // a person said, and showing it as a message would put the reader's - // own words and a command's output in the same voice. + // models it -- but it is the other half of a tool call, and showing it as a + // message would put the reader's own words and a command's output in the + // same voice. if let Value::Array(blocks) = content { for block in blocks { - // A picture the person attached to their own message, rather - // than one a tool produced. Same block shape, one level up. + // A picture the person attached to their own message rather than + // one a tool produced. Same block shape, one level up. push_images(events, std::slice::from_ref(block), session_dir, None); if block.get("type").and_then(Value::as_str) == Some("tool_result") && let Some(id) = block.get("tool_use_id").and_then(Value::as_str) { - // Before the tool's own row, matching the live translator: - // a screenshot belongs to the call that took it, and after - // the result it reads as belonging to whatever came next. + // Before the tool's own row, matching the live translator: a + // screenshot belongs to the call that took it. if let Some(Value::Array(parts)) = block.get("content") { push_images(events, parts, session_dir, Some(id)); } @@ -626,12 +555,10 @@ fn push_user(events: &mut Vec, content: &Value, session_dir: &std::path:: } let text = text_of(content); if !text.trim().is_empty() { - // Replayed from the CLI's own file: it was read long ago, so - // there is no waiting bubble for it to resolve. - // The images in this record are saved and referenced separately just - // above, because a replayed message's pictures came out of somebody - // else's file rather than out of this app's composer -- there is no - // upload here whose refs could ride on the message. + // Replayed from the CLI's own file: it was read long ago, so there is + // no waiting bubble for it to resolve. Its images are saved and + // referenced separately just above, because they came out of somebody + // else's file rather than this app's composer. events.push(Event::UserMessage { id: None, text, @@ -640,11 +567,10 @@ fn push_user(events: &mut Vec, content: &Value, session_dir: &std::path:: } } -/// Saves every image block in `parts` and references each one. -/// -/// `about` is the call the images came out of, or `None` for one a person attached -/// to their own message -- the same distinction the live translator makes, so replayed -/// history draws a screenshot under the call that took it exactly as a live one does. +/// Saves every image block in `parts` and references each one. `about` is the +/// call the images came out of, or `None` for one a person attached to their +/// own message -- the same distinction the live translator makes, so replayed +/// history draws a screenshot under the call that took it. fn push_images( events: &mut Vec, parts: &[Value], @@ -698,21 +624,17 @@ fn push_assistant(events: &mut Vec, content: &Value) { /// Removes each id it is given and prints one `\t` line per id. /// /// The three states are every way removing one id can end: `deleted` if at -/// least one file went, `missing` if the glob matched nothing, `failed` if -/// an `rm` refused. "Not there" is deliberately kept apart from "it broke" -/// rather than folded together by the shell -- only one of them is worth -/// retrying, and the caller is what knows how to word either. +/// least one file went, `missing` if the glob matched nothing, `failed` if an +/// `rm` refused. "Not there" is deliberately kept apart from "it broke" -- +/// only one of them is worth retrying. /// -/// Every copy of each id, not the first. The same id can name a file under -/// two project directories -- see the de-duplication in `parse_listing` -- -/// and stopping at the first left the other behind, so the row came back on -/// the next listing after a delete that had reported success. `failed` -/// therefore sticks once set: one copy removed and another refused is not a -/// success. +/// Every copy of each id, not the first. The same id can name a file under two +/// project directories, and stopping at the first left the other behind, so the +/// row came back on the next listing after a delete that reported success. +/// `failed` therefore sticks once set. /// -/// Ids arrive as arguments rather than in the script text, so nothing here -/// is shell syntax; `is_session_id` is what keeps one from globbing its way -/// out of the projects directory. +/// Ids arrive as arguments rather than in the script text; `is_session_id` is +/// what keeps one from globbing its way out of the projects directory. const DELETE_SCRIPT: &str = r#" for id do state=missing @@ -730,37 +652,30 @@ done /// Deletes sessions [`list`] reported, and says what happened to each. /// -/// By id, resolved on the machine against what it actually has, so the -/// caller never names a file -- the same rule importing follows, and it -/// matters more here: this one removes something. +/// By id, resolved on the machine against what it actually has, so the caller +/// never names a file -- the same rule importing follows, and it matters more +/// here: this one removes something. /// -/// Irreversible, and the caller is expected to have said so. Claude Code -/// keeps no copy: the JSONL *is* the session, so deleting it ends any -/// chance of resuming that conversation, including from an ai-app session -/// that was already importing it. +/// Irreversible, and the caller is expected to have said so. Claude Code keeps +/// no copy: the JSONL *is* the session. /// -/// The whole batch in one invocation, which over ssh is the difference -/// between one connection and one per session. Six deletes started in the -/// same tick were six `ssh` processes racing to authenticate, and a batch -/// big enough to pass the remote sshd's `MaxStartups` (10 unauthenticated -/// connections, by default, before it begins refusing) had rows come back -/// as `Connection closed by … port 2222` -- a row reporting a delete that -/// never ran, for a reason that has nothing to do with the session. One -/// connection cannot exceed that however many ids are selected. +/// The whole batch in one invocation, which over ssh is the difference between +/// one connection and one per session. Six deletes started in the same tick +/// were six `ssh` processes racing to authenticate, and a batch past the remote +/// sshd's `MaxStartups` had rows come back as `Connection closed by …` -- a row +/// reporting a delete that never ran, for a reason nothing to do with the +/// session. /// -/// Still one outcome per id, because a batch is not a transaction: six -/// removals that must all succeed or all roll back is not something a -/// filesystem offers, and the caller settles each row from its own line. -/// Every requested id gets an entry, so an id the machine said nothing -/// about is reported as such rather than defaulting to either answer. +/// Still one outcome per id, because a batch is not a transaction. Every +/// requested id gets an entry, so an id the machine said nothing about is +/// reported as such rather than defaulting to either answer. pub async fn delete( transport: &Transport, ids: &[String], ) -> Result>> { - // Refused here rather than on the machine: `is_session_id` is what - // keeps an id from walking out of the projects directory, and a bad - // one must never reach the glob. It fails only itself -- one malformed - // id is not a reason to leave the other five in place. + // Refused here rather than on the machine: `is_session_id` is what keeps an + // id from walking out of the projects directory. It fails only itself -- + // one malformed id is not a reason to leave the other five in place. let (safe, mut outcomes): (Vec<&String>, HashMap>) = ids.iter().fold( (Vec::new(), HashMap::new()), @@ -780,23 +695,16 @@ pub async fn delete( return Ok(outcomes); } - // The file name *is* the id, so the machine can find it by name. This - // used to call `list` and search its output, which is correct and costs - // a full read of every transcript on the machine -- around four seconds - // against a gigabyte of them, per delete, so a batch of ten took the - // best part of a minute doing nothing but re-reading the same files. - // `context_of` below already resolved an id the cheap way; this is the - // same lookup, and the two now agree. + // The file name *is* the id, so the machine can find it by name. This used + // to call `list` and search its output, which is correct and costs a full + // read of every transcript on the machine -- around four seconds against a + // gigabyte of them, per delete. // - // Every copy of each id, not the first. The same id can name a file - // under two project directories -- see the de-duplication in - // `parse_listing` -- and stopping at the first left the other behind, - // so the row came back on the next listing after a delete that had - // reported success. + // Every copy of each id, not the first: the same id can name a file under + // two project directories, and stopping at the first left the other behind. // - // Each id prints its own verdict rather than the loop exiting on the - // first failure: with a batch, exiting would leave every id after it - // unexplained. See [`DELETE_SCRIPT`] for what the words mean. + // Each id prints its own verdict rather than the loop exiting on the first + // failure, which would leave every id after it unexplained. let mut args = vec![ "-c".to_string(), DELETE_SCRIPT.to_string(), @@ -806,8 +714,7 @@ pub async fn delete( let launch = Launch::new("sh", args, None); // A failure to run the script at all is the machine being unreachable, - // which is true of every id in the batch rather than of any one of - // them -- so it is returned as the error, not written into each row. + // which is true of every id in the batch rather than of any one of them. let reported = transport .capture(&launch) .await @@ -826,10 +733,10 @@ pub async fn delete( }, ); } - // Anything the machine did not mention. The connection can drop - // part-way through the loop, and an id whose line never arrived is one - // nobody knows the fate of -- which is its own answer, and must not be - // read as either a success or a clean "not there". + // Anything the machine did not mention. The connection can drop part-way + // through the loop, and an id whose line never arrived is one nobody knows + // the fate of -- which is its own answer, and must not read as either a + // success or a clean "not there". for id in safe { outcomes.entry(id.clone()).or_insert_with(|| { Err(format!( @@ -843,40 +750,34 @@ pub async fn delete( /// Whether an id is one of ours to put in a shell glob. /// -/// Both places that resolve an id to a file interpolate it into +/// Both places that resolve an id interpolate it into /// `$HOME/.claude/projects/*/"$1".jsonl`. That is an argument rather than -/// script text, so a shell cannot be talked into running something -- but a -/// `/` or a `..` inside it still walks the glob out of the directory the id -/// is supposed to name. [`delete`] is where that would be fatal, because it -/// removes whatever it lands on, and it is exactly the reason `delete` used -/// to resolve ids by searching a listing instead. +/// script text, so a shell cannot be talked into running something -- but a `/` +/// or a `..` inside it still walks the glob out of the directory the id is +/// supposed to name, and [`delete`] removes whatever it lands on. /// /// Claude Code names each transcript with a uuid, so hex and dashes is the -/// whole alphabet. Refused rather than escaped: an id that is not one of -/// these did not come from the list this app showed. +/// whole alphabet. Refused rather than escaped. fn is_session_id(id: &str) -> bool { !id.is_empty() && id.len() <= 64 && id.bytes().all(|b| b.is_ascii_hexdigit() || b == b'-') } /// How often an imported session checks whether its source file grew. /// -/// A poll rather than a watch, because the file may be on another machine -/// and there is no portable way to be told. Ten seconds is chosen against -/// the cost of an ssh round trip rather than against how fast a person -/// types: nothing here is waiting on it, and the events arrive on the same -/// stream as everything else once they do. +/// A poll rather than a watch, because the file may be on another machine and +/// there is no portable way to be told. Ten seconds is chosen against the cost +/// of an ssh round trip rather than against how fast a person types. pub const SYNC_INTERVAL: std::time::Duration = std::time::Duration::from_secs(10); /// Where an imported session came from, and how much of it has been shown. -/// -/// Kept beside the session rather than in its config, because it is a -/// position in someone else's file rather than anything the person chose, -/// and it changes constantly. +/// Kept beside the session rather than in its config, because it is a position +/// in someone else's file rather than anything the person chose, and it changes +/// constantly. #[derive(Debug, Clone, Serialize, serde::Deserialize)] #[serde(rename_all = "camelCase")] pub struct Cursor { - /// Server-side only, and resolved once at import. Nothing accepts a - /// path from the phone; this is the path *we* found. + /// Server-side only, resolved once at import. Nothing accepts a path from + /// the phone; this is the path *we* found. pub path: String, /// Lines of that file already accounted for -- whether replayed into /// the transcript or skipped because this session wrote them itself. diff --git a/server/src/session/llama.rs b/server/src/session/llama.rs index dca5eb3..0b8a18e 100644 --- a/server/src/session/llama.rs +++ b/server/src/session/llama.rs @@ -1,29 +1,22 @@ -//! The llama.cpp driver: a `llama-server` process per session, spoken to -//! over its OpenAI-compatible HTTP API and translated into the common -//! event model. +//! The llama.cpp driver: a `llama-server` process per session, spoken to over +//! its OpenAI-compatible HTTP API and translated into the common event model. //! -//! Two things make this shaped differently from the Claude driver, and -//! both are worth knowing before changing anything here. +//! Two things make this shaped differently from the Claude driver. //! //! **It is spawned but not spoken to over stdio.** The process is started -//! through the same [`Transport`] as any other, and then reached over -//! HTTP on a loopback port. That is the case the transport's doc comment -//! flags: a remote llama-server would need its port forwarded as well as -//! its command wrapped, which is not built, so a session on an ssh host -//! is refused rather than silently talking to the wrong machine. +//! through the same [`Transport`] as any other and then reached over HTTP on a +//! loopback port. A remote llama-server would need its port forwarded as well +//! as its command wrapped, which is not built, so a session on an ssh host is +//! refused rather than silently talking to the wrong machine. //! -//! **The server is stateless between requests**, so the whole -//! conversation goes with every one. It is rebuilt from the session's -//! transcript rather than kept in this struct, which is not tidiness: a -//! copy in driver memory is invisible to a second device and gone when -//! this process restarts, and the app is meant to work across devices. -//! The transcript is already the source of truth for everything else, and -//! this makes it the source of truth for the prompt too. +//! **The server is stateless between requests**, so the whole conversation goes +//! with every one. It is rebuilt from the session's transcript rather than kept +//! in this struct, which is not tidiness: a copy in driver memory is invisible +//! to a second device and gone when this process restarts. //! -//! That leaves the Claude driver as the odd one out rather than this one: -//! the CLI's own memory of a conversation is a cache in front of the same -//! transcript, not a second truth. Anyone tempted to "fix" the -//! inconsistency should resolve it in this direction. +//! That leaves the Claude driver as the odd one out rather than this one -- the +//! CLI's own memory of a conversation is a cache in front of the same +//! transcript. Resolve any inconsistency in this direction. use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -38,10 +31,9 @@ use super::process; use super::transport::{Launch, Streams, Transport}; use crate::config::{ProviderConfig, SessionConfig}; -/// How long to wait for a model to load before giving up on it. Loading -/// is mostly disk, and a large quantised model on a cold cache is -/// genuinely slow, so this is generous -- the failure it exists for is a -/// server that will never answer, not one that is taking its time. +/// How long to wait for a model to load before giving up. Loading is mostly +/// disk, and a large quantised model on a cold cache is genuinely slow, so this +/// is generous -- the failure it exists for is a server that will never answer. const READY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300); /// One turn in the conversation this driver keeps on the server's behalf. @@ -62,20 +54,19 @@ pub struct LlamaDriver { /// Set by [`Driver::interrupt`]; the streaming loop checks it between /// chunks and stops, leaving what was generated in the transcript. cancel: Arc, - /// Where this session's process record lives, so [`Driver::stop`] can - /// find the server it has to end. + /// Where this session's process record lives, so [`Driver::stop`] can find + /// the server it has to end. session_dir: PathBuf, } impl LlamaDriver { - /// Takes charge of this session's `llama-server`: the one already - /// loaded if there is one, otherwise a new one. + /// Takes charge of this session's `llama-server`: the one already loaded if + /// there is one, otherwise a new one. /// - /// One entry point, for the reason `ClaudeDriver::launch` gives -- the - /// choice is not the caller's and a second process is the expensive - /// mistake. Here it is expensive in a different currency: two servers - /// holding the same model is twice the memory, and the second would - /// bind a different port while the phone kept talking to the first. + /// One entry point, for the reason `ClaudeDriver::launch` gives, expensive + /// in a different currency: two servers holding the same model is twice the + /// memory, and the second would bind a different port while the phone kept + /// talking to the first. pub fn launch( meta: &SessionConfig, provider: &ProviderConfig, @@ -96,10 +87,10 @@ impl LlamaDriver { )?; let path = model_path(models_dir, model)?; - // Already loaded and still running: keep talking to it. The - // health poll below is what confirms it is really answering, so - // adopting a pid whose server has wedged still reports as a - // failure rather than as a session that silently never replies. + // Already loaded and still running: keep talking to it. The health poll + // below confirms it is really answering, so adopting a pid whose server + // has wedged still reports as a failure rather than as a session that + // silently never replies. if let Some(process::Record { detail: process::Detail::Http { port }, pid, @@ -129,9 +120,9 @@ impl LlamaDriver { "--port".into(), port.to_string(), ]; - // Settings that belong to the server because they decide how the - // model is loaded; the sampling ones ride on each request instead, - // so changing them later needn't reload anything. + // Settings that belong to the server because they decide how the model + // is loaded; the sampling ones ride on each request instead, so changing + // them later needn't reload anything. for (key, flag) in [ ("contextSize", "-c"), ("gpuLayers", "-ngl"), @@ -147,8 +138,8 @@ impl LlamaDriver { let launch = Launch::new(program, args, meta.cwd.as_deref()); // Its output goes to files, not pipes. Not only so the process can // outlive this server: nothing ever read those pipes, so a chatty - // llama-server filled the 64 KB buffer and blocked mid-load with - // no sign of why. + // llama-server filled the 64 KB buffer and blocked mid-load with no sign + // of why. let child = transport.spawn( &launch, Streams::Detached { @@ -164,10 +155,9 @@ impl LlamaDriver { "session {} running {program} for {model} on 127.0.0.1:{port} as pid {pid}", meta.id ); - // Reaped so it does not become a zombie while this server is still - // its parent; the health poll and the record are what actually say - // whether the session is alive, because after a restart there is no - // `Child` here to ask. + // Reaped so it does not become a zombie while this server is still its + // parent; the health poll and the record are what say whether the + // session is alive, because after a restart there is no `Child` to ask. tokio::spawn(async move { let mut child = child; let _ = child.wait().await; @@ -189,10 +179,10 @@ impl LlamaDriver { /// The driver for a `llama-server` at `endpoint`, however it got there. /// - /// Shared by starting one and adopting one, because everything after - /// "there is a server at this address" is identical -- including - /// waiting for it to answer, which an adopted one still owes: a - /// recorded pid says a process exists, not that its model is loaded. + /// Shared by starting one and adopting one, because everything after "there + /// is a server at this address" is identical -- including waiting for it to + /// answer, which an adopted one still owes: a recorded pid says a process + /// exists, not that its model is loaded. fn attached( endpoint: String, meta: &SessionConfig, @@ -201,9 +191,9 @@ impl LlamaDriver { session_dir: &Path, sink: EventSink, ) -> Self { - // Loading is slow enough to be worth saying so: the session shows - // as running until the model is in memory, then goes idle, rather - // than looking ready and refusing the first message. + // Loading is slow enough to be worth saying so: the session shows as + // running until the model is in memory, rather than looking ready and + // refusing the first message. let _ = sink.send(Event::Status { state: SessionStatus::Running, }); @@ -262,11 +252,9 @@ impl LlamaDriver { /// terminal anyway. const SERVER_LOG: &str = "llama-server.log"; -/// How often a loaded server is checked for still being there. -/// -/// Slower than the Claude driver's stdout poll because nothing is waiting -/// on it: this only has to notice a server that has gone, and a few -/// seconds late costs nothing. +/// How often a loaded server is checked for still being there. Slower than the +/// Claude driver's stdout poll because nothing is waiting on it: this only has +/// to notice a server that has gone. const WATCH_INTERVAL: std::time::Duration = std::time::Duration::from_secs(2); /// An owner-only log opened for appending, so the two streams pointed at @@ -281,22 +269,21 @@ fn log_file(path: &Path) -> Result { .with_context(|| format!("opening {}", path.display())) } -/// Reports the server going away, for as long as the session is there to -/// report it to. +/// Reports the server going away, for as long as the session is there to report +/// it to. /// -/// Polled rather than waited on, for the reason the Claude driver gives: -/// after a restart this server is not the process's parent and has nothing -/// to wait on, so liveness has to be a question asked of the record -- and -/// asking it two different ways is how the two answers come to disagree. +/// Polled rather than waited on, for the reason the Claude driver gives: after a +/// restart this server is not the process's parent, so liveness has to be a +/// question asked of the record -- and asking it two different ways is how the +/// two answers come to disagree. fn watch(session_dir: PathBuf, sink: EventSink) { std::thread::spawn(move || { loop { std::thread::sleep(WATCH_INTERVAL); match process::recorded(&session_dir) { Some((_, process::Liveness::Alive)) => {} - // Nothing recorded means the session was stopped or - // deleted deliberately, and whoever did that has already - // said so. + // Nothing recorded means the session was stopped or deleted + // deliberately, and whoever did that has already said so. None => return, Some((_, process::Liveness::Dead)) => { let _ = sink.send(Event::Error { @@ -335,34 +322,33 @@ impl Driver for LlamaDriver { let cancel = Arc::clone(&self.cancel); cancel.store(false, Ordering::Relaxed); - // Its own thread: the request blocks for as long as the model - // takes to generate, which is the whole point of streaming it. + // Its own thread: the request blocks for as long as the model takes to + // generate, which is the whole point of streaming it. std::thread::spawn(move || { - // Nothing is ever held back here -- there is no queue to wait - // in -- so the message is taken the moment it arrives. Said - // anyway, because this is what records it: see `MessageTaken`. + // Nothing is ever held back here -- there is no queue to wait in -- + // so the message is taken the moment it arrives. Said anyway, + // because this is what records it: see `MessageTaken`. let _ = sink.send(Event::MessageTaken { id: None, text: text.clone(), - // Never any: this driver refuses attachments above, and - // saying so is what the refusal above is for. + // Never any: this driver refuses attachments above. attachments: Vec::new(), }); let _ = sink.send(Event::Status { state: SessionStatus::Running, }); - // Everything before this message, plus this message. Read - // rather than remembered, and `text` is appended here rather - // than waited for, because the message's own transcript entry - // is still on its way when this runs. + // Everything before this message, plus this message. Read rather + // than remembered, and `text` is appended here rather than waited + // for, because the message's own transcript entry is still on its + // way when this runs. let mut messages = conversation(&transcript); messages.push(Message { role: "user".into(), content: text, }); - // The reply is not stored: the deltas below are the durable - // record, so the next turn reads back exactly what the phone - // was shown -- including a partial one that was interrupted. + // The reply is not stored: the deltas below are the durable record, + // so the next turn reads back exactly what the phone was shown -- + // including a partial one that was interrupted. if let Err(err) = generate(&endpoint, &messages, &sampling, &cancel, &sink) { let _ = sink.send(Event::Error { message: format!("{err:#}"), @@ -375,17 +361,15 @@ impl Driver for LlamaDriver { } fn answer_question(&self, _id: &str, _answers: &[String]) { - // Nothing here asks questions: this driver has no tools, so no - // permission prompts and no AskUserQuestion. + // Nothing here asks questions: this driver has no tools. } fn interrupt(&self) { self.cancel.store(true, Ordering::Relaxed); } - // Nothing to forward: this process has no notion of what the - // conversation is called, and the rename it belongs to has already - // happened where the name lives. See `Driver::set_title`. + // Nothing to forward: this process has no notion of what the conversation + // is called, and the rename has already happened where the name lives. fn set_title(&self, _title: &str) {} fn set_permission_mode(&self, _mode: &str) { @@ -420,23 +404,21 @@ impl Driver for LlamaDriver { } fn clear(&self) { - // All of it. `conversation` folds from the last of these, so - // recording the marker *is* the reset -- there is no driver state - // to keep in step with it, which is the same property that makes - // a second device see the same conversation this one does. + // All of it. `conversation` folds from the last of these, so recording + // the marker *is* the reset -- there is no driver state to keep in step + // with it, which is the same property that makes a second device see the + // same conversation this one does. let _ = self.sink.send(Event::Cleared); } /// Stops generating and leaves the server loaded. /// - /// Worth being deliberate about, because the cost is asymmetric and - /// points the other way from the Claude driver's: a `llama-server` - /// holds its whole model in memory, so a leaked one is gigabytes - /// nobody is using. It is left anyway, because the alternative is - /// unloading and reloading that model on every backend restart -- - /// minutes of disk, for a session somebody is in the middle of. The - /// record is what keeps it from being *nobody's*: the next run of this - /// server adopts it rather than starting a second one. + /// Worth being deliberate about, because the cost points the other way from + /// the Claude driver's: a `llama-server` holds its whole model in memory, so + /// a leaked one is gigabytes nobody is using. It is left anyway, because the + /// alternative is unloading and reloading that model on every backend + /// restart -- minutes of disk, for a session somebody is in the middle of. + /// The record is what keeps it from being *nobody's*. fn detach(&self) { self.cancel.store(true, Ordering::Relaxed); } @@ -452,16 +434,15 @@ impl Driver for LlamaDriver { /// The conversation so far, folded out of the transcript. /// -/// Consecutive `AssistantText` deltas are one assistant turn, closed by -/// the next user message -- which is also what makes an interrupted reply -/// come back as the partial text the phone actually saw, rather than -/// vanishing or being invented. +/// Consecutive `AssistantText` deltas are one assistant turn, closed by the next +/// user message -- which is also what makes an interrupted reply come back as +/// the partial text the phone actually saw. /// -/// This must stay a pure function of the transcript and must never -/// re-render earlier turns. llama.cpp caches the prompt prefix, so a -/// growing conversation reprocesses almost nothing -- but only while -/// every turn is byte-identical to last time. Changing how an old turn is -/// rendered silently reprocesses the whole history on every message. +/// This must stay a pure function of the transcript and must never re-render +/// earlier turns. llama.cpp caches the prompt prefix, so a growing conversation +/// reprocesses almost nothing -- but only while every turn is byte-identical to +/// last time. Changing how an old turn is rendered silently reprocesses the +/// whole history on every message. fn conversation(path: &Path) -> Vec { let Ok(events) = crate::session::transcript::read_after(path, 0) else { return Vec::new(); @@ -469,8 +450,8 @@ fn conversation(path: &Path) -> Vec { let mut messages: Vec = Vec::new(); let mut pending = String::new(); // Everything before the last clear is still in the transcript and is - // deliberately not in the conversation. Folding from zero here would - // put it back, which is the whole of what clearing had to undo. + // deliberately not in the conversation. Folding from zero would put it back, + // which is the whole of what clearing had to undo. let events = match events.iter().rposition(|e| e.event == Event::Cleared) { Some(at) => &events[at + 1..], None => &events[..], @@ -518,12 +499,10 @@ fn model_path(models_dir: &Path, key: &str) -> Result { Ok(path) } -/// An unused loopback port, by asking the OS for one and letting it go. -/// -/// Racy in principle: something else could take it between here and -/// llama-server binding. In practice nothing on this machine is hunting -/// for ports, and the alternative -- parsing the port back out of the -/// server's log -- couples us to its output format for no real gain. +/// An unused loopback port, by asking the OS for one and letting it go. Racy in +/// principle, but nothing on this machine is hunting for ports, and the +/// alternative -- parsing the port back out of the server's log -- couples us to +/// its output format for no real gain. fn free_port() -> Result { let listener = std::net::TcpListener::bind("127.0.0.1:0")?; Ok(listener.local_addr()?.port()) @@ -547,8 +526,8 @@ fn wait_until_ready(endpoint: &str) -> Result<()> { } /// One streamed completion: posts the conversation, emits each delta as it -/// arrives. Emits rather than returns: the transcript those events land -/// in is what the next turn reads back, so there is nothing to hand up. +/// arrives. Emits rather than returns, because the transcript those events land +/// in is what the next turn reads back. fn generate( endpoint: &str, messages: &[Message], @@ -574,16 +553,15 @@ fn generate( let reader = std::io::BufReader::new(response.body_mut().as_reader()); let mut tokens = 0u64; // The prompt side only, which is what the model is holding -- the same - // definition the other dialects report, so one word on the phone means - // one thing whichever kind of session it is. + // definition the other dialects report, so one word on the phone means one + // thing whichever kind of session it is. let mut context = None; for line in std::io::BufRead::lines(reader) { if cancel.load(Ordering::Relaxed) { break; } let line = line.context("reading the generation stream")?; - // Server-sent events: the payload lines are the ones that matter, - // and blank lines separate events. + // Server-sent events: the payload lines are the ones that matter. let Some(payload) = line.strip_prefix("data: ") else { continue; }; @@ -631,8 +609,8 @@ mod tests { use super::*; use crate::session::transcript::Transcript; - /// Writes a transcript the way the pump does, so the fold is tested - /// against the real file format rather than a hand-built vector. + /// Writes a transcript the way the pump does, so the fold is tested against + /// the real file format rather than a hand-built vector. fn transcript_with(events: &[Event]) -> (tempfile::TempDir, PathBuf) { let dir = tempfile::tempdir().expect("tempdir"); let path = dir.path().join("transcript.jsonl"); @@ -685,11 +663,10 @@ mod tests { } #[test] - /// The interrupted case, which decides what a resumed conversation is - /// built from: whatever the phone was shown. The deltas that arrived - /// before the stop are in the transcript, so they are in the prompt -- - /// the model is never told it said something the user did not see, and - /// never has a turn silently dropped from under it. + /// The interrupted case, which decides what a resumed conversation is built + /// from: whatever the phone was shown. The deltas that arrived before the + /// stop are in the transcript, so they are in the prompt -- the model is + /// never told it said something the user did not see. fn an_interrupted_reply_stays_in_the_conversation() { let (_dir, path) = transcript_with(&[ Event::UserMessage { @@ -741,9 +718,9 @@ mod tests { } #[test] - /// Clearing decides what the *model* is given, not just what the - /// phone draws. Everything above the marker stays in the transcript - /// -- a person can still scroll back to it -- and none of it is sent. + /// Clearing decides what the *model* is given, not just what the phone + /// draws. Everything above the marker stays in the transcript and none of it + /// is sent. fn the_conversation_starts_after_the_last_clear() { let (_dir, path) = transcript_with(&[ Event::UserMessage { diff --git a/server/src/session/mod.rs b/server/src/session/mod.rs index f21241e..adcedd2 100644 --- a/server/src/session/mod.rs +++ b/server/src/session/mod.rs @@ -1,13 +1,12 @@ -//! The live session registry. Every session mutation -- spawn, delete, -//! token changes -- funnels through [`SessionManager`] under one lock, so -//! in-memory state and `config.ron` can't come apart (the same pattern as -//! dev-updater's `registry.rs`). +//! The live session registry. Every session mutation funnels through +//! [`SessionManager`] under one lock, so in-memory state and `config.ron` +//! can't come apart (dev-updater's `registry.rs` pattern). //! //! A live session is a driver plus one event pump: the driver reports //! [`Event`]s into an mpsc channel; the pump assigns each a sequence -//! number, appends it to the session's transcript file, and fans it out to -//! SSE subscribers. The transcript is the source of truth -- subscribers -//! that fall behind or reconnect catch up from the file by cursor. +//! number, appends it to the transcript, and fans it out to SSE +//! subscribers. The transcript is the source of truth -- subscribers that +//! fall behind or reconnect catch up from the file by cursor. pub mod claude; pub mod driver; @@ -40,17 +39,14 @@ use llama::LlamaDriver; use transcript::{SeqEvent, Transcript}; use transport::Transport; -/// Fan-out buffer per session. A subscriber that falls further behind than -/// this is caught up from the transcript file instead (see `routes`), so -/// the size only bounds memory, not correctness. +/// Fan-out buffer per session. A subscriber further behind than this is +/// caught up from the transcript file instead, so the size only bounds +/// memory, not correctness. const EVENT_BUFFER: usize = 256; -/// Fan-out buffer for notifications, across every session. -/// -/// Small, and deliberately: a subscriber that falls this far behind on a -/// stream carrying two events per turn is not one whose backlog is worth -/// delivering. Lagging drops the oldest, which is the right end to lose -- -/// the newest "your turn" is the one still true. +/// Fan-out buffer for notifications, across every session. Small +/// deliberately: lagging drops the oldest, which is the right end to lose +/// -- the newest "your turn" is the one still true. const NOTIFICATION_BUFFER: usize = 64; pub fn now() -> f64 { @@ -60,9 +56,7 @@ pub fn now() -> f64 { .as_secs_f64() } -/// What the phone needs to spawn a session -- the spawn screen's fields. pub struct SpawnSpec { - /// Which machine, and which of its providers. pub setup: String, pub provider: String, pub title: Option, @@ -73,17 +67,13 @@ pub struct SpawnSpec { pub params: std::collections::BTreeMap, } -/// A moment worth interrupting somebody for, as `GET /notifications` -/// sends it. -/// -/// Two kinds, and the pair is the whole feature: a session that has *asked* -/// something cannot continue until it is answered, and one that has -/// *finished* is work somebody walked away from. Everything else a session -/// does is progress they did not ask to be told about. +/// A moment worth interrupting somebody for, as `GET /notifications` sends +/// it. Two kinds, and the pair is the whole feature: a session that has +/// *asked* something cannot continue until it is answered, and one that has +/// *finished* is work somebody walked away from. /// /// Carries the title rather than only the id, so the phone can write the -/// notification without a round trip -- it may well be showing no screen at -/// all when this arrives. +/// notification without a round trip. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct Notification { @@ -110,57 +100,39 @@ pub enum NotificationKind { pub struct SessionInfo { pub id: String, pub provider: String, - /// Id of the machine it runs on, which is what the session stored. pub setup: String, - /// That machine's current label, resolved when this row is built -- - /// so renaming a setup renames it everywhere it appears, rather than - /// leaving old sessions showing the old name. + /// That machine's current label, resolved when this row is built, so + /// renaming a setup renames it everywhere rather than leaving old + /// sessions showing the old name. pub setup_name: String, pub title: String, #[serde(skip_serializing_if = "Option::is_none")] pub model: Option, - /// Whether the conversation would survive deleting this session -- - /// see [`DriverKind::keeps_own_transcript`]. - /// - /// Reported rather than worked out on the phone, because the phone - /// has the provider's *name* and this is a property of its *kind*: a - /// provider can be called anything, so a client deciding by name - /// would get the answer wrong for anyone who renamed one. It decides - /// what the delete confirmation says will happen, so it is not a - /// field to guess at. + /// Whether the conversation would survive deleting this session. + /// Reported rather than worked out on the phone, because the phone has + /// the provider's *name* and this is a property of its *kind*. pub keeps_own_transcript: bool, /// How much this session asks before acting. Reported so the phone can /// *show* the current mode rather than assume one -- a picker that - /// guesses its own value is how you end up changing something you - /// thought you were confirming. + /// guesses its own value is how you change something you thought you + /// were confirming. #[serde(skip_serializing_if = "Option::is_none")] pub permission_mode: Option, /// Whether this session continues one the machine already had. - /// /// Reported because it changes what deleting *means*: an imported /// session's real transcript belongs to the CLI and survives, so - /// removing it here is undoing a view. A session started here has no - /// copy anywhere else, and removing it ends the conversation. Saying - /// "this cannot be undone" of both makes the warning worthless on the - /// one where it is true. + /// removing it here is undoing a view. pub imported: bool, #[serde(skip_serializing_if = "Option::is_none")] pub cwd: Option, - /// How much context this session is holding, so a phone does not have - /// to fold a transcript it only holds part of. - /// - /// Absent rather than zero where nothing has been measured -- a - /// session that has not run a turn, a dialect that does not report - /// usage, or a clear nobody has run a turn since. "Empty" and "we did - /// not find out" are different answers and the phone draws them - /// differently. + /// How much context this session is holding. Absent rather than zero + /// where nothing has been measured -- "empty" and "we did not find out" + /// are different answers and the phone draws them differently. #[serde(skip_serializing_if = "Option::is_none")] pub context_tokens: Option, - /// The longest edge an image should have by the time it gets here, or - /// absent where this provider has no limit -- see - /// [`DriverKind::max_image_edge`]. Absent rather than a large number, - /// because "no limit" and "a limit that happens to be big" are different - /// answers and only one of them stays true. + /// The longest edge an image should have by the time it gets here. + /// Absent rather than a large number, because "no limit" and "a limit + /// that happens to be big" are different answers. #[serde(skip_serializing_if = "Option::is_none")] pub max_image_edge: Option, /// Whether this session announces itself -- reported for the same @@ -175,29 +147,21 @@ pub struct SessionInfo { /// What is running a session at this moment, and `None` when nothing is. /// /// Behind a lock because a session outlives its process: stopping one and -/// starting it again replaces the driver while the transcript, the event -/// pump and the stream every open phone is reading stay exactly where they -/// were. Shared with [`Commands`] rather than copied into it, because two -/// holders of "the driver" are two answers to that question the moment one -/// of them is replaced. -/// -/// An option because a session outlives its process in the other direction -/// too: one that was stopped, or whose process died while this server was -/// down, is a session with a transcript, a pump and a phone reading it, and -/// nothing running it. A driver is how a process is spoken to, so where -/// there is no process there is no driver -- rather than a driver whose -/// requests go nowhere, which is the same thing with nobody able to say so. -/// See [`Launching`]. +/// starting it again replaces the driver while the transcript, the pump and +/// every open stream stay where they were. Shared with [`Commands`] rather +/// than copied, because two holders of "the driver" are two answers the +/// moment one is replaced. An option because where there is no process +/// there is no driver -- rather than a driver whose requests go nowhere, +/// which is the same thing with nobody able to say so. type DriverCell = Arc>>>; -/// A running session: its driver plus the shared state the event pump -/// keeps current. Cheap to clone-by-`Arc` into request handlers. +/// A running session: its driver plus the shared state the pump keeps +/// current. Cheap to clone-by-`Arc` into request handlers. pub struct LiveSession { meta: SessionConfig, driver: DriverCell, - /// Commands asked for and not yet run, oldest first, with the pump - /// that will run them. Shared with that pump, which is what notices - /// the boundary. + /// Commands asked for and not yet run, oldest first. Shared with the + /// pump, which is what notices the boundary. commands: Arc, /// The same channel the driver reports into; the manager injects /// `UserMessage`/`Answered` here so they take a sequence number in @@ -211,10 +175,9 @@ pub struct LiveSession { /// Commands waiting for the session to be between turns. /// /// One implementation for every provider, because the rule is about -/// sessions rather than about a dialect: a line written into a running -/// turn is read by the model, so anything meant for the *session* waits -/// for the turn to end. Drivers therefore never have to think about it, -/// and a new provider cannot get it wrong by omission. +/// sessions rather than a dialect: a line written into a running turn is +/// read by the model, so anything meant for the *session* waits for the +/// turn to end. A new provider cannot get it wrong by omission. struct Commands { driver: DriverCell, sink: EventSink, @@ -222,51 +185,36 @@ struct Commands { } impl Commands { - /// Whatever is driving the session now, if anything -- see - /// [`DriverCell`]. fn driver(&self) -> Option> { self.driver.lock().unwrap().clone() } /// Runs `command` now if the session is between turns, holds it until - /// it is, and refuses it outright if there will never be one. Whichever - /// happened, the phone is told. + /// it is, and refuses it outright if there will never be one. /// - /// "Between turns" is asked of the *driver*, not of `status`. They are - /// two views of the same fact and only one of them is current: the + /// "Between turns" is asked of the *driver*, not of `status`: the /// driver sets its flag the instant it writes a line, while `status` is /// built from what has been recorded, so it still reads idle for the /// whole round trip of a command that produces no assistant text. Two /// commands in a row therefore both went out, the second landing inside - /// the turn the first had started, where the CLI reads it as text - /// instead of running it -- silently, since a message read as text - /// looks like a message. - /// - /// `status` is still passed, for the one question the driver's flag - /// cannot answer: whether there will ever *be* another boundary. + /// the turn the first started, where the CLI reads it as text. + /// `status` answers the one question the flag cannot: whether there + /// will ever *be* another boundary. fn submit(&self, command: SessionCommand, status: SessionStatus) { let id = random_hex(); let text = command.label(); - // A session whose process is gone has no next boundary, so holding - // this would hold it forever: the phone draws a waiting bubble that - // nothing will ever resolve, and nothing anywhere says why. The - // message path has always answered this case -- see - // `ClaudeDriver::send_user_message` -- and a command owes the same - // answer, since what makes it unanswerable is the same fact. - // - // `Unknown` is not refused. It means nobody could find out whether - // the process is alive, and it resolves itself, so refusing on it - // would turn "we don't know" into "it's gone". + // No next boundary, so holding this would hold it forever: a + // waiting bubble nothing will ever resolve. `Unknown` is not + // refused -- it resolves itself, and refusing would turn "we don't + // know" into "it's gone". if status == SessionStatus::Exited { let _ = self.sink.send(Event::Error { message: format!("this session's process has exited, so it can't run {text}"), }); return; } - // The same answer for the same reason one step earlier: a session - // with no driver has no process to have a boundary. The status - // above is what says so in the ordinary case; this is the session - // whose process went between that word being written and now. + // The same answer one step earlier, for the session whose process + // went between that word being written and now. let Some(driver) = self.driver() else { let _ = self.sink.send(Event::Error { message: format!("this session has no process running, so it can't run {text}"), @@ -285,19 +233,15 @@ impl Commands { self.waiting.lock().unwrap().push_back((id, command)); } - /// The turn ended, so the oldest waiting command can go. One, not all - /// of them: running a command starts a turn of its own, and the next - /// boundary is where the one after it belongs. + /// The turn ended, so the oldest waiting command can go. One, not all: + /// running a command starts a turn of its own. /// - /// Asks the driver again rather than trusting the idle that called this. - /// The recorded idle is a moment in the past by the time it gets here, - /// and the driver may have started something since -- a turn the CLI - /// began by itself, which it does: a background task finishing makes it - /// pick the conversation back up with nothing written to it. + /// Asks the driver again rather than trusting the idle that called + /// this, which is already a moment in the past -- the CLI starts turns + /// by itself when a background task finishes. fn take_one(&self) { - // Nothing to run it against. Held rather than abandoned: what ends - // a session's process announces `Exited`, and that is what empties - // the queue -- see `abandon`. + // Held rather than abandoned: what ends a session's process + // announces `Exited`, and that is what empties the queue. let Some(driver) = self.driver() else { return; }; @@ -315,9 +259,7 @@ impl Commands { } /// Gives up on everything held, because the session cannot run them. - /// - /// Reported rather than dropped, for the reason the message queue in - /// `claude.rs` reports its own: somebody asked for these and nothing + /// Reported rather than dropped: somebody asked for these and nothing /// else would ever say they did not happen. fn abandon(&self, why: &str) { let lost: Vec = self @@ -337,69 +279,40 @@ impl Commands { } /// The pump-maintained view of a session, read by the list endpoint. -/// `model` also lives here (not in the immutable meta) because it can -/// change mid-session via `set_model`. +/// Everything here can change mid-session, which is why none of it is read +/// from `meta` -- `meta` is how the session was *launched*. struct Shared { status: Mutex, - /// What this conversation is called. Here rather than in `meta` for - /// the same reason the model is: `meta` is how the session was - /// *launched*, so reporting a name from it would show the one a - /// rename had already replaced. title: Mutex, last_activity: Mutex, model: Mutex>, - /// Beside the model and for the same reason: `meta` is the shape the - /// session was *launched* with, so reporting from it would show the - /// mode a change had already replaced. permission_mode: Mutex>, - /// How much context this session is holding. - /// /// Kept here because only the pump sees every event, and reported on /// the session row so a phone opening a long conversation has the real - /// figure rather than whatever its newest page happens to mention. + /// figure rather than whatever its newest page mentions. context_tokens: Mutex>, - /// Whether this session's attention-wanting moments are announced. - /// /// Mirrored out of the config so the pump can read it without taking - /// the manager's lock -- the pump runs underneath the manager and - /// reaching back up for a field would invert that. `set_session_notify` - /// writes both, in that order, which is the same shape every other - /// live-and-persisted setting here uses. + /// the manager's lock -- the pump runs underneath the manager, and + /// reaching back up would invert that. notify: Mutex, - /// How many events this session has ever recorded. - /// - /// Only the import sync reads it, and only to answer one question: - /// "did *we* write anything since I last looked?" A session and a - /// terminal append to the same file, so that is the whole of what - /// separates lines worth replaying from lines already shown. Status - /// cannot answer it -- a turn that starts and finishes between two - /// polls is idle at both, and its output then gets replayed on top of + /// How many events this session has ever recorded. Only the import + /// sync reads it, to answer "did *we* write anything since I last + /// looked?" Status cannot: a turn that starts and finishes between two + /// polls is idle at both, and its output gets replayed on top of /// itself. written: Mutex, } impl LiveSession { - /// Whatever is driving this session now, if anything -- see - /// [`DriverCell`]. fn driver(&self) -> Option> { self.driver.lock().unwrap().clone() } /// Asks whatever is running this session to do something, and says so - /// when nothing is. - /// - /// Every caller here is relaying a request from a person, and a - /// request that reaches no process has to be reported rather than - /// swallowed: `Event::Error` is where the session screen shows what - /// did not happen, and silence would leave somebody watching for a - /// reply to a message nothing was ever given. The requests that mean - /// "do this now" start a process before they get here -- see - /// [`SessionManager::start_if_exited`] -- so what lands in the `None` - /// arm is the one that arrived just as the process went, or one aimed - /// at a session nobody has started. - /// - /// `what` completes "this session has no process running, so it - /// can't ...". + /// when nothing is. A request that reaches no process is reported + /// rather than swallowed, or somebody is left watching for a reply to a + /// message nothing was ever given. `what` completes "this session has + /// no process running, so it can't ...". fn ask(&self, what: &str, request: impl FnOnce(&dyn Driver)) { match self.driver() { Some(driver) => request(driver.as_ref()), @@ -413,16 +326,13 @@ impl LiveSession { /// Hands the user's message to the driver, which records it in the /// transcript by reporting that it has taken it -- see `MessageTaken`. - /// - /// The message is deliberately not recorded here. Sent into a running - /// turn it waits, and writing it down on the way past would put it - /// above output that happened before the session ever saw it. + /// Deliberately not recorded here: sent into a running turn it waits, + /// and writing it down on the way past would put it above output that + /// happened before the session ever saw it. pub fn send_message(&self, text: String, attachments: Vec) { // The attachments ride *on* the message rather than as `Image` - // events emitted just before it. They used to be the latter, which - // drew a person's screenshot as a row floating above the bubble - // that sent it, and left the phone inferring from adjacency which - // message an image went with -- a thing the sender already knew. + // events just before it: the latter drew a screenshot as a row + // floating above the bubble that sent it. self.ask("take a message", |driver| { driver.send_user_message(text, attachments) }); @@ -443,13 +353,9 @@ impl LiveSession { } /// Takes back a message the session has not read yet, named by the id - /// its `MessageQueued` carried. See [`Driver::unqueue`] for why the - /// answer has three states. - /// - /// A session with no process answers `Unknown` rather than being - /// reported as a failure, and that is the true answer: a driver on its - /// way out already said what it was holding (`Queue::close`), so there - /// is nothing waiting to take back. + /// its `MessageQueued` carried. See [`Driver::unqueue`] for the three + /// states. A session with no process answers `Unknown`, which is true: + /// a driver on its way out already said what it was holding. pub fn unqueue(&self, message_id: &str) -> Unqueued { match self.driver() { Some(driver) => driver.unqueue(message_id), @@ -458,12 +364,8 @@ impl LiveSession { } /// Leaves this session's process running and stops attending to it, - /// for a server that is going away and means to come back. See - /// [`Driver::detach`]. + /// for a server that is going away and means to come back. pub fn detach(&self) { - // Nothing to let go of is not worth reporting: this is the server - // shutting down, and a session with no process is already in the - // state detaching leaves one in. if let Some(driver) = self.driver() { driver.detach(); } @@ -486,18 +388,13 @@ impl LiveSession { } /// Reserves the name and path for one uploaded attachment; the caller - /// writes the bytes, since a trace is bigger than this should hold. - /// The name is the id `POST /message` references it by. Removed with - /// the session directory on delete -- the same path out as everything - /// else in it. + /// writes the bytes, since a trace is bigger than this should hold. The + /// name is the id `POST /message` references it by. /// /// An image is named `.` and nothing else, since the /// model is shown the picture rather than told its name. Anything else - /// keeps the name it arrived with after the hex: the session is told - /// the path, and a trace called `trace-komodo-….perfetto-trace` says - /// more to it than `3f9a…` would. The name is cleaned to characters a - /// path and a URL both take unquoted, and the hex keeps two uploads of - /// the same name apart. `AttachmentRef` documents the two shapes. + /// keeps the name it arrived with after the hex, since the session is + /// told the path. `AttachmentRef` documents the two shapes. pub fn new_attachment( &self, content_type: &str, @@ -515,22 +412,14 @@ impl LiveSession { /// `setup_name` and `cwd` are passed in rather than read from the /// snapshot this session launched with: only the manager holds the - /// config, and both of them can change under a running session. The - /// label changes when a setup is renamed; the directory changes when - /// somebody moves the session, and reading the snapshot reported the - /// old one for as long as the process lived -- a screen showing a - /// directory the next launch will not use, with nothing saying so. + /// config, and both can change under a running session. Passed rather + /// than mirrored into `Shared`, so there is one answer, read where the + /// row is built. /// - /// Passed rather than mirrored into `Shared`, which is where `title` - /// and `notify` live: a second copy is a second thing to keep level, - /// and this way there is one answer, read where the row is built. - /// - /// `kind` rather than the facts derived from it: two of this row's - /// fields are answers about the provider's *kind*, and passing them - /// separately meant every caller deriving each one and a third arriving - /// as a third parameter. `None` where the provider has been edited away, - /// which is a session that cannot run -- so both answers are the - /// cautious one rather than a guess. + /// `kind` rather than the facts derived from it, or every caller + /// derives each one separately. `None` where the provider has been + /// edited away -- a session that cannot run -- so both answers are the + /// cautious one. fn info( &self, setup_name: &str, @@ -569,35 +458,28 @@ pub struct SessionManager { /// Per-session directories (transcript, attachments, produced images) /// live under here, each named by session id. data_dir: PathBuf, - /// Downloaded GGUF models, shared by every session that names one -- - /// which is why they live beside the session directories rather than - /// inside one. + /// Downloaded GGUF models, shared by every session that names one, + /// which is why they sit beside the session directories. models_dir: PathBuf, /// Where every session's pump sends what a phone should be told about. - /// Held here rather than per session for the reason - /// [`SessionManager::subscribe_notifications`] gives. notifications: broadcast::Sender, /// Imports and deletes running against a machine's Claude Code - /// sessions. Beside the notification channel above because it is the - /// same kind of thing: state the phone reads but does not own. + /// sessions: like the notifications, state the phone reads but does not + /// own. pending: Arc, /// What to mark sessions spawned here as -- see - /// [`SessionManager::marking_new_sessions_throwaway`] and - /// [`SessionConfig::throwaway`]. + /// [`SessionManager::marking_new_sessions_throwaway`]. spawn_throwaway: bool, inner: RwLock, } impl SessionManager { /// Loads the config and brings every persisted session back: its - /// transcript, its event pump, and the process it left running, where - /// it left one. Sessions with no process are listed as what they are - /// and nothing is started for them -- see [`Launching`], which is the - /// difference between a backend that restarts and one that restarts - /// everything it finds. + /// transcript, its pump, and the process it left running where it left + /// one. Sessions with no process are listed as what they are and + /// nothing is started for them -- see [`Launching`]. /// - /// Must be called inside a tokio runtime (each session spawns its - /// event pump). + /// Must be called inside a tokio runtime (each session spawns a pump). pub fn new(config_path: PathBuf, data_dir: PathBuf, models_dir: PathBuf) -> Result { let config = Config::load(&config_path)?; wg_app_link::private::create_dir(&data_dir)?; @@ -606,9 +488,8 @@ impl SessionManager { let mut live = HashMap::new(); for meta in &config.sessions { // One unlaunchable session -- a corrupt transcript, an - // unreachable ssh host, a provider that was edited away -- - // shows as exited rather than taking the whole server down - // with it, and can still be deleted from the phone. + // unreachable host, a provider edited away -- shows as exited + // rather than taking the server down, and can still be deleted. match resolve(&config, meta).and_then(|(setup, provider)| { launch( meta.clone(), @@ -617,9 +498,7 @@ impl SessionManager { &data_dir, &models_dir, notifications.clone(), - // Nothing is started here. See `Launching`: a restart - // picks up the processes that are still running and - // leaves the rest as it found them. + // Nothing is started here; see `Launching`. Launching::Restart, ) }) { @@ -644,18 +523,10 @@ impl SessionManager { } /// Marks every session spawned from here on as one whose process is - /// stopped when this server exits -- see [`SessionConfig::throwaway`] - /// and [`SessionManager::stop_throwaway_sessions`]. - /// - /// Set from `--throwaway-sessions`, which a debug build defaults to on. - /// It decides only what a *new* session is marked as; what happens on - /// the way out is decided by the mark, which is the session's own and - /// outlives the server that made it. - /// - /// Consuming rather than a fourth constructor parameter: it is one - /// caller's business, and every test and every other caller would - /// otherwise have to say "no, not that" at a constructor that is - /// already about three paths. + /// stopped when this server exits. Set from `--throwaway-sessions`, + /// which a debug build defaults to on. It decides only what a *new* + /// session is marked as; what happens on the way out is decided by the + /// mark, which outlives the server that made it. pub fn marking_new_sessions_throwaway(mut self, throwaway: bool) -> Self { self.spawn_throwaway = throwaway; self @@ -664,19 +535,15 @@ impl SessionManager { /// Writes this machine into a config that has no setups, with the /// providers actually found on it. /// - /// Discovered rather than assumed. Until 2026-08-28 this wrote a - /// `claude-cli` provider unconditionally, so a fresh install on a - /// machine without `claude` -- which is every machine but the dev VM - /// -- offered a spawn option that could not work, and said so with the - /// same confidence as a provider that had been checked for. Providers - /// are discovered by asking the machine, and the local machine is not - /// an exception to that. + /// Discovered rather than assumed. This used to write a `claude-cli` + /// provider unconditionally, so a fresh install on a machine without + /// `claude` offered a spawn option that could not work, with the same + /// confidence as one that had been checked for. /// /// A discovery that fails seeds only `echo`, which is true wherever - /// this server runs, and says so in the log. Seeding the hardcoded - /// list on failure would be the original bug with an extra step, and - /// seeding nothing would leave a fresh install with nothing to prove - /// the pipe with. + /// this server runs, and says so in the log. Seeding the hardcoded list + /// would be the original bug with an extra step, and seeding nothing + /// leaves a fresh install with nothing to prove the pipe with. pub async fn seed_setup(&self) -> Result<()> { if !self.inner.read().unwrap().config.setups.is_empty() { return Ok(()); @@ -710,13 +577,10 @@ impl SessionManager { Ok(()) } - /// The one path by which the config changes. - /// - /// Clone, apply, save, and only then commit: a failed write leaves - /// what was already there and reports why, so what this server - /// believes and what is on disk cannot come apart. The ordering is - /// the whole trick -- mutating in place and then saving would leave a - /// server that had accepted a change nothing on disk records. + /// The one path by which the config changes: clone, apply, save, and + /// only then commit, so a failed write leaves what was already there. + /// Mutating in place and then saving would leave a server that had + /// accepted a change nothing on disk records. fn update(&self, apply: impl FnOnce(&mut Config) -> Result) -> Result { let mut inner = self.inner.write().unwrap(); let mut candidate = inner.config.clone(); @@ -728,10 +592,8 @@ impl SessionManager { /// Adds a machine with the providers it was found to have. /// - /// `providers` comes from probing rather than from the caller (see - /// `crate::setups`), which is why this takes them as an argument: the - /// probe is async and this is not, so the route does the asking and - /// this does the writing. + /// `providers` comes from probing rather than from the caller: the + /// probe is async and this is not, so the route asks and this writes. pub fn add_setup( &self, name: &str, @@ -763,7 +625,6 @@ impl SessionManager { }) } - /// Renames a machine, or replaces what was discovered on it. pub fn update_setup( &self, id: &str, @@ -796,10 +657,8 @@ impl SessionManager { } /// Removes a machine, provided nothing is still running on it. - /// - /// Refused rather than cascaded: deleting a machine should not - /// silently kill conversations, and the person asking is better placed - /// to decide which of those sessions they still want. + /// Refused rather than cascaded: the person asking is better placed to + /// decide which of those sessions they still want. pub fn delete_setup(&self, id: &str) -> Result<()> { self.update(|config| { if config.setup(id).is_none() { @@ -838,8 +697,6 @@ impl SessionManager { Ok(()) } - /// Adds one enrolled device, keeping the others -- the other half of - /// [`Self::set_tokens`], which replaces them all. pub fn add_token(&self, token: TokenEntry) -> Result<()> { let mut inner = self.inner.write().unwrap(); let mut candidate = inner.config.clone(); @@ -853,30 +710,20 @@ impl SessionManager { crate::config::pending_enrollments_dir(&self.config_path) } - /// Lets go of every session's process, for a server that is going - /// away and means to adopt them again when it comes back. - /// - /// Deliberately not a shutdown, and this is the load-bearing half of - /// it: a backend restart -- a rebuild, a service restart, a crash -- - /// must not end a turn somebody is waiting on. Each process keeps its - /// record in the session directory, and `launch` finds it there rather - /// than starting a second one against the same conversation. - /// - /// What this did before was ask them all to stop and then exit - /// immediately, which stopped nothing reliably -- the grace timer died - /// with the runtime -- and orphaned whatever survived with nothing - /// written down to find it by. Processes leaked either way; what is - /// different now is that they are left on purpose and can be picked - /// back up. + /// Lets go of every session's process, for a server that is going away + /// and means to adopt them again. Deliberately not a shutdown: a + /// backend restart must not end a turn somebody is waiting on. Each + /// process keeps its record in the session directory, and `launch` + /// finds it there rather than starting a second one against the same + /// conversation. pub fn detach_all(&self) { let inner = self.inner.read().unwrap(); for session in inner.live.values() { session.detach(); } // Counted from the records rather than from the sessions: the - // throwaway ones have just been stopped and their records cleared - // (see `stop_throwaway_sessions`), so the number of *sessions* - // would promise the next start processes that are not there. + // throwaway ones have just been stopped and their records cleared, + // so a session count would promise processes that are not there. let left = inner .live .values() @@ -886,25 +733,17 @@ impl SessionManager { } /// Ends the process of every session marked throwaway, and waits for - /// them to actually go. - /// - /// The counterpart to [`SessionManager::detach_all`], and the two are - /// called in that order on the way out: this one deals with the - /// sessions nobody meant to keep, and everything else is let go of - /// still running, as it always was. + /// them to go. Called before [`SessionManager::detach_all`] on the way + /// out, which lets everything else go still running. /// /// Which sessions those are is read from the *mark*, never from what - /// this server was told at startup -- see - /// [`SessionConfig::throwaway`]. A session spawned by a test run is - /// something to clean away whichever server happens to be up when it - /// ends, and a server started without the flag must not adopt a pile - /// of test sessions and then be the one thing keeping them alive. + /// this server was told at startup -- a server started without the flag + /// must not adopt a pile of test sessions and be the one thing keeping + /// them alive. /// - /// Waiting is the part that cannot be skipped. `process::stop` leaves - /// its SIGKILL on a tokio timer, and a runtime that is shutting down - /// never runs it -- so without [`process::wait_gone`] this would report - /// stopping processes that go on running, which is how the original - /// `shutdown_all` leaked them. + /// Waiting cannot be skipped: `process::stop` leaves its SIGKILL on a + /// tokio timer, and a shutting-down runtime never runs it, which is how + /// the original `shutdown_all` leaked them. pub fn stop_throwaway_sessions(&self) { let inner = self.inner.read().unwrap(); let throwaway: Vec<&SessionConfig> = inner @@ -914,8 +753,7 @@ impl SessionManager { .filter(|meta| meta.throwaway) .collect(); // Taken before anything is asked to stop: `Driver::stop` forgets - // the record, and what has to be waited for is exactly what was - // signalled. + // the record, and what has to be waited for is what was signalled. let records: Vec = throwaway .iter() .filter_map(|meta| process::live(&self.data_dir.join(&meta.id))) @@ -928,11 +766,10 @@ impl SessionManager { .and_then(|session| session.driver()) { Some(driver) => driver.stop(), - // No driver is either a session with no process -- nothing - // to do -- or one whose launch failed with a process still - // running, which is the case worth covering: the record is - // the session's rather than any dialect's, which is the - // same reason `stop_session` signals it directly. + // No driver is either a session with no process, or one + // whose launch failed with a process still running -- the + // case worth covering, and the same reason `stop_session` + // signals the record directly. None => { if let Some(record) = process::live(&dir) { process::stop(&record, process::STOP_GRACE); @@ -954,18 +791,16 @@ impl SessionManager { /// The session already driving `source`, if there is one. /// /// Two `--resume` processes on one transcript each see the other's - /// writes as work done elsewhere and replay them, so both sessions - /// show a conversation neither is having -- worse than a refusal. + /// writes as work done elsewhere and replay them, so both sessions show + /// a conversation neither is having -- worse than a refusal. /// /// Two ways to already be driving one, and only the first used to /// count. An **imported** session records a cursor naming the file it - /// follows. A session this app **spawned** has no cursor at all, but - /// it has a resume token, which is the CLI's own id for the - /// conversation and is exactly the thing being asked about. Matching - /// only the cursor left every spawned session looking like somebody - /// else's: it appeared in the import list, marked as in use, telling - /// the reader to go and close it somewhere -- and the somewhere was - /// this app. + /// follows; a session this app **spawned** has no cursor but has a + /// resume token, which is the CLI's own id for the conversation. + /// Matching only the cursor left every spawned session looking like + /// somebody else's, telling the reader to go and close it somewhere -- + /// and the somewhere was this app. pub fn session_driving(&self, source: &str) -> Option { let inner = self.inner.read().unwrap(); inner.config.sessions.iter().find_map(|meta| { @@ -975,14 +810,6 @@ impl SessionManager { }) } - /// The Claude Code session this one is the app's copy of, as the setup - /// it lives on and the id the importer knows it by -- or `None` where - /// the driver keeps no record of its own. - /// - /// This is [`session_driving`](Self::session_driving) read in the other - /// direction, and it exists for the same delete the phone offers a - /// toggle for: removing a session here can also remove the machine's - /// own transcript of it, and only the server knows which file that is. /// The machine a session runs on when that is not this one, with the /// session's working directory there: what an upload needs to put a /// file where the session can read it. `None` for a local session. @@ -1002,8 +829,7 @@ impl SessionManager { let meta = inner.config.sessions.iter().find(|meta| meta.id == id)?; let (followed, resuming) = foreign_ids(&self.data_dir.join(&meta.id)); // The cursor first: an imported session follows a file that exists - // whether or not a CLI has resumed it yet, so it is the answer that - // is true earliest. + // whether or not a CLI has resumed it yet. followed .or(resuming) .map(|foreign| (meta.setup.clone(), foreign)) @@ -1051,19 +877,15 @@ impl SessionManager { .collect() } - /// Every session's attention-wanting moments, on one stream. - /// - /// One connection for the whole backend rather than one per session: - /// the phone subscribes to this while showing no session at all, and a - /// connection per session would mean opening one for every session that - /// exists in order to hear about any of them. + /// Every session's attention-wanting moments, on one stream. One + /// connection for the whole backend rather than one per session: the + /// phone subscribes while showing no session at all. pub fn subscribe_notifications(&self) -> broadcast::Receiver { self.notifications.subscribe() } /// Imports and deletes running against importable sessions -- see - /// [`pending::Registry`], which is also where the reason it lives on - /// the server rather than in the phone is written down. + /// [`pending::Registry`]. pub fn pending(&self) -> &Arc { &self.pending } @@ -1072,7 +894,6 @@ impl SessionManager { self.inner.read().unwrap().live.get(id).cloned() } - /// Every provider this server offers, built-in echo included. /// Every machine this server can run something on, each with what it /// can run. One list rather than two, because the pair is the choice. pub fn setups(&self) -> Vec { @@ -1083,13 +904,11 @@ impl SessionManager { self.spawn_seeded(spec, None) } - /// Spawns a session that continues one the machine already had. - /// - /// The same path as any other spawn, with a [`Seed`] written into the - /// session directory before the driver starts -- which is all an - /// import is, because `claude.rs` already resumes when it finds a - /// resume token. A separate spawn path would be a second way to start - /// a session, and the driver would have to learn which one it was. + /// Spawns a session that continues one the machine already had: the + /// same path as any other spawn, with a [`Seed`] written into the + /// session directory before the driver starts, since `claude.rs` already + /// resumes when it finds a resume token. A separate spawn path would be + /// a second way to start a session. pub fn spawn_imported(&self, spec: SpawnSpec, seed: Seed) -> Result { self.spawn_seeded(spec, Some(seed)) } @@ -1103,10 +922,9 @@ impl SessionManager { format!( "no setup with id \"{}\" -- configured: {}", spec.setup, - // Ids, since that is what was looked up. Listing the - // labels made the failure read as a contradiction: - // "no setup named X -- configured: X", when X was a - // label and the id was something else. + // Ids, since that is what was looked up. Labels made + // the failure read as a contradiction: "no setup named + // X -- configured: X". names(inner.config.setups.iter().map(|s| s.id.as_str())), ) })? @@ -1132,28 +950,22 @@ impl SessionManager { setup: setup.id.clone(), provider: provider.name.clone(), title, - // No model unless one was chosen. This used to fall back to - // the provider's first listed model, which sounds like a - // default and is not one: that list is a shortcut for the - // spawn screen, written in whatever order somebody typed it, - // and its first entry happened to be `fable`. Every session - // spawned without a model -- every import, since importing - // asks for none -- silently became a fable session. Absent - // means absent, and the CLI then uses whatever the person - // configured for themselves. + // No model unless one was chosen. This used to fall back to the + // provider's first listed model, which sounds like a default and + // is not one: that list is the spawn screen's shortcut, in + // whatever order somebody typed it, and its first entry was + // `fable` -- so every session spawned without a model, every + // import included, silently became a fable session. model: spec.model, cwd: spec.cwd, permission_mode: spec.permission_mode, params: spec.params, - // On by default -- see `SessionConfig::notify`. Not offered at - // spawn: a session's first turn is exactly the one somebody is - // waiting for, and a switch on the spawn screen would be a - // decision asked before there is anything to decide about. + // On by default. Not offered at spawn: a session's first turn + // is exactly the one somebody is waiting for. notify: true, - // What this server was told to mark new sessions as. Recorded - // on the session rather than remembered here, so whichever - // server is running when the time comes knows what to do with - // it -- see `SessionConfig::throwaway`. + // Recorded on the session rather than remembered here, so + // whichever server is running when the time comes knows what to + // do with it -- see `SessionConfig::throwaway`. throwaway: self.spawn_throwaway, created: now(), }; @@ -1170,16 +982,15 @@ impl SessionManager { let mut candidate = inner.config.clone(); candidate.sessions.push(meta); if let Err(err) = candidate.save(&self.config_path) { - // The path out of everything the launch created, taken in the - // same change: drop the session and its directory so a failed - // save leaves no orphan. + // The path out of everything the launch created: drop the + // session and its directory so a failed save leaves no orphan. drop(session); let _ = std::fs::remove_dir_all(self.data_dir.join(&id)); return Err(err); } inner.config = candidate; - // Whether this one was seeded, which is the same question the - // listing asks of the directory a moment later. + // Whether this one was seeded, the same question the listing asks + // of the directory a moment later. let info = session.info( &setup.name, session.meta.cwd.as_deref(), @@ -1190,16 +1001,10 @@ impl SessionManager { Ok(info) } - /// Changes a session's model: persisted (so a respawn keeps it and the - /// list shows it) and handed to the driver, which switches in place - /// where its dialect can. Through the manager, not the session, so the - /// config and the live view can't disagree. /// Changes how much a session asks before acting, live and persisted. - /// - /// Alongside the model rather than folded into it: they are set at the - /// same moment and by the same screen, but they answer different - /// questions, and a caller changing one must not have to restate the - /// other. + /// Alongside the model rather than folded into it: they are set by the + /// same screen but answer different questions, and a caller changing one + /// must not have to restate the other. pub fn set_session_permission_mode(&self, id: &str, mode: &str) -> Result<()> { let mut inner = self.inner.write().unwrap(); if !inner.config.sessions.iter().any(|meta| meta.id == id) { @@ -1214,9 +1019,8 @@ impl SessionManager { if let Some(session) = inner.live.get(id) { // Asked for, not recorded: what the session is actually set to // comes back as an `Event::Settings` if the driver makes the - // change, and as an error if it cannot. The config above is a - // different question -- what to launch this session with next - // time -- and it is answered by the request. + // change, and as an error if it cannot. The config above answers + // a different question -- what to launch with next time. announce_or_ask( session, &self.data_dir.join(id), @@ -1231,16 +1035,12 @@ impl SessionManager { Ok(()) } - /// Turns this session's notifications on or off, live and persisted. + /// Turns this session's notifications on or off, live and persisted -- + /// both, or the switch moves back on its own at the next restart. /// - /// Both, in that order, for the reason every setting here writes both: - /// the config decides what a restart believes and the live copy decides - /// what the running pump does, and a change that lands in one of them is - /// a switch that moves back on its own. - /// - /// Nothing is told to the driver. Unlike the model or the permission - /// mode, this changes nothing about how the session runs -- it is about - /// who gets told, and the session is not the one being told. + /// Nothing is told to the driver: unlike the model or the permission + /// mode, this is about who gets told, and the session is not the one + /// being told. pub fn set_session_notify(&self, id: &str, notify: bool) -> Result<()> { let mut inner = self.inner.write().unwrap(); if !inner.config.sessions.iter().any(|meta| meta.id == id) { @@ -1261,25 +1061,22 @@ impl SessionManager { /// Renames a session: persisted, shown, and passed on to whatever is /// running it. /// - /// The name is this server's own -- it is what a phone lists, it - /// exists before any process does, and every provider has one. So - /// unlike the model and the permission mode, this is settled here and - /// the driver is *told*, rather than asked and believed: see - /// [`Driver::set_title`]. + /// The name is this server's own -- it is what a phone lists, it exists + /// before any process does, and every provider has one. So unlike the + /// model and the permission mode, this is settled here and the driver is + /// *told* rather than asked and believed. /// /// Telling it is not decoration, which is why this starts a stopped - /// session like any other command. Claude Code keeps its own copy of - /// the name, and that copy is what its session picker shows and what - /// other agents see when they list sessions -- and a session is only - /// ever *given* a name at birth, since every later start is a - /// `--resume`. So a rename that reached no process would leave the two - /// lists disagreeing permanently, with the app's the only one that had - /// moved. + /// session like any other command: Claude Code keeps its own copy of the + /// name, that copy is what its session picker and other agents' session + /// lists show, and a session is only ever *given* a name at birth since + /// every later start is a `--resume`. A rename that reached no process + /// would leave the two lists disagreeing permanently. pub fn rename_session(&self, id: &str, title: &str) -> Result<()> { let title = title.trim(); // An empty name is not a name, and it is what a cleared field - // sends. Refused rather than accepted and papered over with the - // provider's name, which would look like the rename was ignored. + // sends. Refused rather than papered over with the provider's name, + // which would look like the rename was ignored. if title.is_empty() { bail!("a session needs a name"); } @@ -1295,16 +1092,13 @@ impl SessionManager { candidate.save(&self.config_path)?; inner.config = candidate; // The name is this server's and changes now, whatever happens - // next: the list shows it immediately, and the process is told - // at the next boundary. + // next; the process is told at the next boundary. if let Some(session) = inner.live.get(id) { *session.shared.title.lock().unwrap() = title.to_string(); } } - // Dropped the lock first -- `run_command` takes it again to decide - // whether anything needs starting, and this is not a reentrant one. - // - // The context matters more than it looks: the rename above is saved + // Dropped the lock first -- `run_command` takes it again, and this + // is not a reentrant one. The context matters: the rename is saved // by the time this can fail, so a bare error would report a rename // that did not happen. What failed is only the telling. self.run_command(id, SessionCommand::SetTitle(title.to_string())) @@ -1316,6 +1110,10 @@ impl SessionManager { }) } + /// Changes a session's model: persisted, so a respawn keeps it and the + /// list shows it, and handed to the driver, which switches in place + /// where its dialect can. Through the manager rather than the session, + /// so the config and the live view cannot disagree. pub fn set_session_model(&self, id: &str, model: &str) -> Result<()> { let mut inner = self.inner.write().unwrap(); if !inner.config.sessions.iter().any(|meta| meta.id == id) { @@ -1328,8 +1126,8 @@ impl SessionManager { candidate.save(&self.config_path)?; inner.config = candidate; if let Some(session) = inner.live.get(id) { - // See `set_session_permission_mode`: the driver reports what - // it is set to, this only asks. + // See `set_session_permission_mode`: the driver reports what it + // is set to, this only asks. announce_or_ask( session, &self.data_dir.join(id), @@ -1344,23 +1142,6 @@ impl SessionManager { Ok(()) } - /// Ends this session's process, leaving the session -- its transcript, - /// its place in the list, everything a phone is watching -- exactly - /// where it is. [`SessionManager::start_session`] is the way back. - /// - /// The signal is all this does. Whether the process actually went, what - /// it said on the way out, and the `Exited` that follows are reported by - /// the path a session that died on its own already takes: the driver's - /// own reader notices within a poll, drains what was still unread, and - /// records it. Announcing it from here would be this side's guess - /// arriving ahead of the measurement, and it would be wrong for the five - /// seconds a process that ignores SIGTERM keeps running. - /// - /// Deliberately not routed through the driver. The record is the - /// session's rather than any dialect's -- `session::process` writes it - /// for every provider that has a process at all -- so asking it here - /// stops a session whose driver is in no state to be asked, and adds no - /// method a new driver could implement wrongly. /// Moves a session to a different working directory. /// /// The directory is settled at spawn -- the CLI is launched with it as @@ -1374,16 +1155,11 @@ impl SessionManager { /// /// Nothing of Claude Code's own is moved, and that is a measurement /// rather than an omission: `claude --resume ` finds a session from - /// any working directory (checked against 2.1.237 on 2026-08-31 -- an - /// id that does not exist says "No conversation found with session ID" - /// and a real one resumed from an unrelated directory did not), so the - /// conversation continues in the new place with nothing relocated. The - /// file stays under the project directory the CLI made for it, which is - /// where the CLI itself looks. Reimplementing that directory's name to - /// move it would mean reproducing a rule this app cannot see the whole - /// of -- the CLI truncates at 200 characters and appends a hash of its - /// own, and an override can replace the name entirely -- to relocate a - /// file the CLI is still writing. + /// any working directory (checked against 2.1.237 on 2026-08-31). + /// Relocating the file would mean reproducing a rule this app cannot see + /// the whole of -- the CLI truncates the project directory's name at 200 + /// characters and appends a hash of its own, and an override can replace + /// it entirely. /// /// Whether the directory exists is the caller's question, because /// asking it is an ssh round trip on a remote setup; see the route. @@ -1415,6 +1191,21 @@ impl SessionManager { Ok(()) } + /// Ends this session's process, leaving the session -- its transcript, + /// its place in the list, everything a phone is watching -- exactly + /// where it is. [`SessionManager::start_session`] is the way back. + /// + /// The signal is all this does. Whether the process actually went and + /// the `Exited` that follows are reported by the path a session that + /// died on its own already takes: the driver's own reader notices within + /// a poll and records it. Announcing it here would be a guess arriving + /// ahead of the measurement, and wrong for the grace period a process + /// that ignores SIGTERM keeps running. + /// + /// Deliberately not routed through the driver: the record is the + /// session's rather than any dialect's, so asking it here stops a + /// session whose driver is in no state to be asked, and adds no method a + /// new driver could implement wrongly. pub fn stop_session(&self, id: &str) -> Result<()> { if !self .inner @@ -1427,10 +1218,10 @@ impl SessionManager { { bail!("no session {id}"); } - // Three answers, and they are three different things to tell - // somebody: it is running (stop it), it is not (nothing to do), and - // nobody could find out (nothing was signalled, and saying "nothing - // is running" would be inventing the answer). + // Three answers, and three different things to tell somebody: it is + // running (stop it), it is not (nothing to do), and nobody could + // find out (nothing was signalled, and saying "nothing is running" + // would be inventing the answer). let record = match process::recorded(&self.data_dir.join(id)) { Some((record, process::Liveness::Alive)) => record, Some((_, process::Liveness::Unknown)) => bail!( @@ -1447,20 +1238,15 @@ impl SessionManager { } /// Starts a process for a session whose process has ended, for somebody - /// who asked for exactly that. - /// - /// Anything other than a session known to have exited is a refusal to - /// report, because the person pressing this expects a process to appear - /// and is owed the reason one did not. [`SessionManager::send_message`] - /// asks the same question of [`SessionManager::start_if_exited`] and - /// wants the opposite answer. - /// - /// Only the driver is new. The transcript, the event pump and the stream - /// every open phone is reading stay as they were, so this is not a - /// reconnect for anybody watching -- and there is still exactly one - /// writer of the transcript, which relaunching the whole session would - /// not be. + /// who asked for exactly that. Anything else is a refusal to report, + /// because the person pressing this expects a process to appear. + /// [`SessionManager::send_message`] asks the same question of + /// [`SessionManager::start_if_exited`] and wants the opposite answer. /// + /// Only the driver is new. The transcript, the pump and every open + /// stream stay as they were, so this is not a reconnect for anybody + /// watching -- and there is still exactly one writer of the transcript, + /// which relaunching the whole session would not be. pub fn start_session(&self, id: &str) -> Result<()> { match self.start_if_exited(id)? { SessionStatus::Exited => Ok(()), @@ -1474,13 +1260,10 @@ impl SessionManager { /// Hands a message to a session, starting its process first if that /// session has none. /// - /// Sending is the one instruction that plainly means "do this now", so a - /// session whose CLI has ended starts it rather than answering that it - /// cannot -- which left the person holding the phone to read a status - /// word, find a second button, press it, and type the message again. - /// `--resume` puts the new process on the same conversation, so nothing - /// about the message changes; only whether there was anything there to - /// read it. + /// Sending plainly means "do this now", so a session whose CLI has ended + /// starts it rather than handing back the work of reading a status word, + /// finding a second button and typing the message again. `--resume` puts + /// the new process on the same conversation. /// /// Started before the message rather than after, because starting /// replaces the driver and the driver that takes the message has to be @@ -1492,8 +1275,7 @@ impl SessionManager { attachments: Vec, ) -> Result<()> { // Only `Exited` starts anything -- see `start_if_exited`. A session - // this cannot say has exited keeps the behaviour it always had: the - // message goes to the driver, which answers for it. + // this cannot say has exited keeps the behaviour it always had. self.start_if_exited(id)?; self.session(id) .with_context(|| format!("no session {id}"))? @@ -1502,25 +1284,17 @@ impl SessionManager { } /// Runs one of the session's own commands, starting its process first - /// if that session has none. - /// - /// The same reasoning as [`SessionManager::send_message`], and for the - /// same reason it is not left to each caller: a command is something - /// somebody asked the session to do, and answering "its process has - /// exited" hands back the work of starting one. `/compact` on a - /// stopped session is the case that shows it -- the thing being asked - /// for is exactly what a stopped session needs before it is useful - /// again. - /// + /// if that session has none -- the same reasoning as + /// [`SessionManager::send_message`]. `/compact` on a stopped session is + /// the case that shows it: the thing being asked for is exactly what a + /// stopped session needs before it is useful again. pub fn run_command(&self, id: &str, command: SessionCommand) -> Result<()> { // Judged against the status *after* the start, not the one that // caused it. A driver that has just started a process announces - // `idle` through the sink and the pump may not have recorded it - // yet, so reading the session's own status here would refuse the - // command the start was for -- `Commands::submit` refuses on - // `Exited`, which is exactly the word that has just stopped being - // true. `start_if_exited` returning `Exited` is what says a process - // was started; anything else is a status nothing has invalidated. + // `idle` through the sink and the pump may not have recorded it yet, + // so reading the session's own status would refuse the command the + // start was for. `start_if_exited` returning `Exited` is what says a + // process was started. let status = match self.start_if_exited(id)? { SessionStatus::Exited => SessionStatus::Idle, found => found, @@ -1538,23 +1312,19 @@ impl SessionManager { /// /// One decision with two callers who want opposite things from it: a /// Start button treats "there is already a process" as a refusal worth - /// showing, and a message being sent treats it as nothing at all. - /// Deciding it here, under the one write lock, is also what stops two - /// requests that arrive together from starting two CLIs on one - /// conversation. + /// showing, and a message treats it as nothing at all. Deciding it here + /// under the one write lock is also what stops two requests arriving + /// together from starting two CLIs on one conversation. /// - /// Nothing is started on `Unknown`. That means nobody could find out - /// whether the process is alive, and starting one on that is precisely - /// the second-CLI-on-one-conversation fault `session::process` exists to - /// prevent. + /// Nothing is started on `Unknown`: that means nobody could find out + /// whether the process is alive, and starting on it is precisely the + /// second-CLI fault `session::process` exists to prevent. /// /// What the session then *reports* is the driver's to say, not this - /// function's: the phone's list reads the manager's status and the - /// session screen replays the transcript, so a status written in one and - /// not the other is two screens disagreeing about one session -- which - /// is what a status set here without an event produced, visible as a - /// stop button that turned into a play button a moment after the screen - /// opened. + /// function's -- the list reads the manager's status and the session + /// screen replays the transcript, so a status written in one and not the + /// other is two screens disagreeing, visible as a stop button that + /// turned into a play button a moment after the screen opened. fn start_if_exited(&self, id: &str) -> Result { let mut inner = self.inner.write().unwrap(); let meta = inner @@ -1571,13 +1341,11 @@ impl SessionManager { let last = *session.shared.status.lock().unwrap(); let now = corrected(last, &dir); if now != last { - // Published, not merely acted on. The phone is drawing a + // Published, not merely acted on: the phone is drawing a // Start button on the strength of the word this has just - // disproved, and it learns what a session is doing from - // the stream like everything else -- so a correction - // nobody sends leaves that button there to be pressed - // again, and again. Through the sink, which keeps the - // pump the only writer of the status. + // disproved, and learns what a session is doing from the + // stream like everything else. Through the sink, which + // keeps the pump the only writer of the status. let _ = session.sink.send(Event::Status { state: now }); } now @@ -1593,11 +1361,10 @@ impl SessionManager { let (setup, provider) = resolve(&inner.config, &meta)?; match existing { Some(session) => { - // The driver being replaced is still reading this session's - // output, and replacing the value it lives in does not end - // the tasks that do it. Its process has exited -- that is - // how this line was reached -- so there is nothing left to - // preserve and `detach` is the whole of what it is owed. + // Replacing the value a driver lives in does not end the + // tasks it is running. Its process has exited -- that is how + // this line was reached -- so `detach` is the whole of what + // it is owed. if let Some(driver) = session.driver() { driver.detach(); } @@ -1612,8 +1379,7 @@ impl SessionManager { )?); } // Nothing is live for this one -- a session whose launch failed - // when the server started, which has no pump either. That is the - // whole of `launch`, and the same call the server start makes. + // when the server started, which has no pump either. None => { let session = launch( meta, @@ -1629,10 +1395,9 @@ impl SessionManager { } // Nothing is announced from here. A driver that starts a process // reports the session idle itself, in order with everything else it - // says about that process -- see `ClaudeDriver::launch`. Saying it - // here as well would be a second writer of the same fact, and the - // one that cannot see whether the process it is describing is still - // there. + // says about that process. Saying it here too would be a second + // writer of the same fact, and the one that cannot see whether the + // process it describes is still there. Ok(SessionStatus::Exited) } @@ -1648,9 +1413,9 @@ impl SessionManager { candidate.save(&self.config_path)?; inner.config = candidate; if let Some(session) = inner.live.remove(id) { - // Stopped, not detached: this is the one exit where the - // process must not survive, because the conversation it - // belongs to is being removed. See `Driver::stop`. + // Stopped, not detached: this is the one exit where the process + // must not survive, because the conversation it belongs to is + // being removed. if let Some(driver) = session.driver() { driver.stop(); } @@ -1663,60 +1428,17 @@ impl SessionManager { } } -/// What to report for a session that is in the config but has no live -/// entry -- one that failed to relaunch, or whose process this server -/// never took charge of. -/// -/// This said `Exited` for all of them, which is the enumeration mistake in -/// its most consequential form. `Exited` reads as "this conversation is -/// over", and the thing a reader does about it is start a new session -- -/// which, if the process is in fact still running, is a second CLI against -/// a conversation that already has one. That is the fault the whole -/// `process` module exists to prevent, arriving through the status field. -/// -/// So it is only said when the process is known to be gone. A record that -/// cannot be checked reports `Unknown`, and a record that is still alive -/// reports `Unknown` too: this server is not driving it, so it genuinely -/// does not know what it is doing -- and that is worth a word that means -/// "wait", not one that means "act". -/// The last word about a session, with the one status that cannot be taken -/// on trust checked against the only authority on it. -/// -/// `Exited` is not just a description: it is the word that offers a phone a -/// Start button and lets [`SessionManager::start_session`] build a second -/// CLI against a conversation. So before it is believed it is checked -/// against the process record, and a record that is not known to be dead -/// makes it false. What replaces it is `Unknown` -- there is a process, and -/// nothing here has heard from it -- which is the same answer -/// [`status_of_unlaunched`] gives to the same question. -/// -/// Every other status is left exactly as it was. Those are the pump's, -/// written from what the process itself said, and none of them authorises -/// starting anything. -/// -/// This was reachable and did happen: a session adopted at server start -/// keeps the transcript's last word, so one whose process was reported gone -/// and then found again read as `exited` while it was running. Start was -/// accepted every time it was pressed, each press attaching another reader -/// to the one process, and every line it wrote was then translated once per -/// reader -- three presses put three interleaved copies of one reply on -/// screen. /// A setting change: asked of the driver, or announced as the session's own /// where there is no process for a driver to speak for. /// -/// The pair that [`LiveSession::ask`] cannot serve. Everything else it -/// covers genuinely needs a process -- a message sent to a session that is -/// not running has nowhere to go -- but a setting is held in the config as -/// well, and a session with nothing running *is* what the config says: the -/// value is applied the moment it next starts. So `ask`'s "this session has -/// no process running, so it can't change model" was true of the driver and -/// false of the session, and it left the phone showing the old model over a -/// config that had already taken the new one, with no way to change it -/// short of starting the session first. +/// The pair [`LiveSession::ask`] cannot serve. Everything else it covers +/// genuinely needs a process, but a setting is held in the config as well, +/// and a session with nothing running *is* what the config says. So `ask`'s +/// "this session has no process running, so it can't change model" was true +/// of the driver and false of the session, and it left the phone showing the +/// old model over a config that had already taken the new one. /// -/// `Exited` and nothing else, for the reason [`start_if_exited`] gives: -/// `Unknown` means nobody could find out, and a session whose process may -/// well be reading its fifo is one to ask rather than to answer for. +/// `Exited` and nothing else, for the reason [`start_if_exited`] gives. fn announce_or_ask( session: &LiveSession, session_dir: &Path, @@ -1732,6 +1454,22 @@ fn announce_or_ask( } } +/// The last word about a session, with the one status that cannot be taken +/// on trust checked against the only authority on it. +/// +/// `Exited` is not just a description: it is the word that offers a phone a +/// Start button and lets [`SessionManager::start_session`] build a second CLI +/// against a conversation. So a record that is not known to be dead makes it +/// false, and what replaces it is `Unknown` -- there is a process, and +/// nothing here has heard from it. Every other status is left exactly as it +/// was; those are the pump's, written from what the process itself said. +/// +/// This did happen: a session adopted at server start kept the transcript's +/// last word, so one whose process was reported gone and then found again +/// read as `exited` while it was running. Start was accepted every press, +/// each attaching another reader to the one process, so every line it wrote +/// was translated once per reader -- three presses put three interleaved +/// copies of one reply on screen. fn corrected(status: SessionStatus, session_dir: &Path) -> SessionStatus { if status == SessionStatus::Exited && adoptable(session_dir) { SessionStatus::Unknown @@ -1740,6 +1478,18 @@ fn corrected(status: SessionStatus, session_dir: &Path) -> SessionStatus { } } +/// What to report for a session that is in the config but has no live entry +/// -- one that failed to relaunch, or whose process this server never took +/// charge of. +/// +/// This said `Exited` for all of them, which is the enumeration mistake in +/// its most consequential form: `Exited` reads as "this conversation is +/// over", and what a reader does about it is start a new session -- a second +/// CLI against a conversation that already has one. So it is only said when +/// the process is known to be gone. A record that cannot be checked reports +/// `Unknown`, and so does one that is still alive: this server is not +/// driving it, so it genuinely does not know what it is doing, and that is +/// worth a word that means "wait" rather than one that means "act". fn status_of_unlaunched(session_dir: &Path) -> SessionStatus { if adoptable(session_dir) { SessionStatus::Unknown @@ -1750,17 +1500,15 @@ fn status_of_unlaunched(session_dir: &Path) -> SessionStatus { /// Whether this session has a process worth taking charge of. /// -/// "Running" and "this machine will not say" are one answer here, and that -/// is the module's central rule wearing its third hat: starting a second -/// CLI against a conversation that may already have one is the expensive -/// fault, so anything short of *known to be gone* is treated as a process. -/// `None` -- nothing ever recorded -- is an echo session, or one whose -/// process was stopped and cleaned up: gone, and known to be. +/// "Running" and "this machine will not say" are one answer here: starting a +/// second CLI against a conversation that may already have one is the +/// expensive fault, so anything short of *known to be gone* is treated as a +/// process. `None` -- nothing ever recorded -- is an echo session, or one +/// whose process was stopped and cleaned up. /// /// One function because it is one question asked in three places: what a /// [`launch`] can adopt, what a session nobody launched reports, and which -/// `Exited` is a lie. Three copies of it would be three chances to answer -/// the same thing differently. +/// `Exited` is a lie. fn adoptable(session_dir: &Path) -> bool { matches!( process::recorded(session_dir), @@ -1897,19 +1645,18 @@ fn unique_id(config: &Config) -> String { /// writes. /// /// Both this app and a terminal append to one file -- `--resume` continues -/// the same transcript rather than forking, measured rather than assumed -/// -- so the only hard question is which new lines are *ours*. They are +/// the same transcript rather than forking, measured rather than assumed -- +/// so the only hard question is which new lines are *ours*. Those are /// already in the transcript, having arrived through the driver, and /// replaying them shows every message twice. /// /// Answered by counting what this session has recorded rather than by /// looking at its status. Status is the obvious signal and it is wrong: a /// turn that begins and ends between two polls reads as idle at both, and -/// its output is then replayed on top of itself. The count cannot miss -/// that, because the events went through the same pump either way. +/// its output is then replayed on top of itself. /// /// Its path out: the sink belongs to the session, so once that is dropped -/// every send fails and this returns. Nothing else has to remember it. +/// every send fails and this returns. fn spawn_import_sync( transport: Transport, dir: PathBuf, @@ -1927,8 +1674,7 @@ fn spawn_import_sync( } let Ok(lines) = import::line_count(&transport, &cursor.path).await else { // A file that cannot be counted is not worth reporting: it - // is usually a machine briefly away, and the next poll asks - // again. + // is usually a machine briefly away. continue; }; let written_now = *shared.written.lock().unwrap(); @@ -1960,7 +1706,7 @@ fn spawn_import_sync( cursor.lines = lines; // The pump is about to record exactly these, so account // for them rather than reading a count that may not have - // caught up yet. + // caught up. written_at_cursor = written_now + count; import::write_cursor(&dir, &cursor); } @@ -1979,36 +1725,30 @@ pub struct Seed { /// the session can keep itself up to date afterwards. pub cursor: import::Cursor, /// The tail of that session's file, as the raw JSONL. - /// /// Turned into events in `launch`, not before, because doing so writes - /// out the images the records carry and that needs the session - /// directory to write them into -- which does not exist until the - /// session does. The CLI reads the real file itself, so this only ever - /// decides what the *reader* sees. + /// out the images the records carry and that needs the session directory + /// to write them into. The CLI reads the real file itself, so this only + /// ever decides what the *reader* sees. pub records: String, } -/// Why a session is being launched, which is what decides whether a -/// process may be started for one that has none. +/// Why a session is being launched, which is what decides whether a process +/// may be started for one that has none. /// -/// That distinction is the whole of what a backend restart is allowed to do -/// to the sessions it finds, and starting the server is not something a -/// session should be able to tell happened. A session whose process is gone -/// is usually gone because somebody pressed Stop, so starting one back -/// because the server was rebuilt undoes that decision silently -- and, -/// since a driver announces `Idle` for a process it started, it also moves -/// the session's last-activity time to the restart, so every row on the -/// phone reads "just now" and a list sorted by that time means nothing. +/// Starting the server is not something a session should be able to tell +/// happened. A session whose process is gone is usually gone because somebody +/// pressed Stop, so starting one back because the server was rebuilt undoes +/// that decision silently -- and since a driver announces `Idle` for a +/// process it started, it also moves the session's last-activity time to the +/// restart, so every row reads "just now" and a list sorted by that time +/// means nothing. /// -/// What starts a process is somebody asking for one: spawning a session, -/// pressing Start, or sending it anything at all -- see -/// [`SessionManager::start_if_exited`], which is the one place that -/// decides. +/// What starts a process is somebody asking for one -- see +/// [`SessionManager::start_if_exited`], the one place that decides. /// -/// The import's history rides on the asked-for variant rather than beside -/// it because it belongs to exactly that case: a seed is a session being -/// created, and a restart re-seeding a transcript would write the imported -/// conversation into it a second time. +/// The import's history rides on the asked-for variant because it belongs to +/// exactly that case: a restart re-seeding a transcript would write the +/// imported conversation into it a second time. enum Launching { /// Somebody asked for this session to be running -- it was just /// spawned, or its Start button was pressed. Takes charge of a process @@ -2038,8 +1778,8 @@ fn launch( let transcript_path = dir.join("transcript.jsonl"); let mut transcript = Transcript::open(&transcript_path)?; let last_status = transcript.last_status().unwrap_or(SessionStatus::Idle); - // Before the driver starts, so the token is there when it looks and - // the history is already in the transcript a phone will read. + // Before the driver starts, so the token is there when it looks and the + // history is already in the transcript a phone will read. if let Launching::Asked(Some(seed)) = &why { claude::write_resume_token(&dir, &seed.resume); import::write_cursor(&dir, &seed.cursor); @@ -2051,42 +1791,34 @@ fn launch( // Whether this launch is to have a process behind it. Answered before // anything else is built, because it is also what the session's status - // is: a driver is how a process is spoken to, and a session with - // neither is one somebody has to start. + // is: a session with neither is one somebody has to start. let driving = match why { Launching::Asked(_) => true, Launching::Restart => adoptable(&dir), }; - // What this server can say the session is, which is not always what - // the transcript last said about it. - // - // Adopting, the transcript's word stands except for the one that a - // live process disproves -- see `corrected`. Taking charge of nothing, - // every word except `Exited` is disproved at once: `Idle` and - // `Running` are claims about a process, and this session has none, so - // a transcript left saying `Running` by a backend that was killed - // mid-turn would otherwise draw a stop button for a turn that ended - // hours ago. + // What this server can say the session is, which is not always what the + // transcript last said. Adopting, the transcript's word stands except for + // the one a live process disproves -- see `corrected`. Taking charge of + // nothing, every word except `Exited` is disproved at once: `Idle` and + // `Running` are claims about a process, and this session has none, so a + // transcript left saying `Running` by a backend killed mid-turn would + // draw a stop button for a turn that ended hours ago. let status = if driving { corrected(last_status, &dir) } else { SessionStatus::Exited }; - // Written into the transcript rather than sent through the sink, and - // written at the time of the last thing the session actually did. + // Written into the transcript rather than sent through the sink, and at + // the time of the last thing the session actually did. // - // In the transcript because the session list reads the status below - // and the session screen replays the transcript, so a correction that - // reaches one of them is two screens describing one session - // differently -- which is what a stop button that turns into a play - // button a moment after the screen opens is. + // In the transcript because the list reads the status below and the + // session screen replays the transcript, so a correction reaching one of + // them is two screens describing one session differently. // - // At the old time because this is not something the session did. It is - // this server noticing, at a moment of its own choosing, and stamping - // it `now` says the session was active the instant the server started - // -- the same lie in the same field that `Transcript::last_activity` - // exists to prevent, arriving by the other route. + // At the old time because this is not something the session did: it is + // this server noticing, and stamping it `now` is the same lie in the same + // field that `Transcript::last_activity` exists to prevent. if status != last_status { let at = transcript.last_activity().unwrap_or(meta.created); transcript.append(Event::Status { state: status }, at)?; @@ -2096,23 +1828,17 @@ fn launch( let (events, _) = broadcast::channel(EVENT_BUFFER); let shared = Arc::new(Shared { // What it was last known to be doing, not an assumption. A driver - // that has something to say corrects this within its first poll; - // one adopting a process that has been quiet says nothing, and - // this is then the only true answer available. + // that has something to say corrects this within its first poll. status: Mutex::new(status), title: Mutex::new(meta.title.clone()), // What the transcript last recorded, not the clock: this server has // just been told nothing, and `now()` claimed every relaunched - // session had been active this instant -- see - // `Transcript::last_activity`. + // session had been active this instant. // - // A session that has never done anything has an empty transcript, - // and its answer is when it was created rather than when this - // server last started. The clock was the fallback here, which meant - // a session nobody had sent anything to climbed back to the top of - // a list sorted by activity at every rebuild -- the same lie in the - // same field, reached by the one route that had no line to read it - // from. + // A session that has never done anything has an empty transcript, so + // its answer is when it was created. The clock was the fallback, + // which meant a session nobody had sent anything to climbed back to + // the top of a list sorted by activity at every rebuild. last_activity: Mutex::new(transcript.last_activity().unwrap_or(meta.created)), model: Mutex::new(meta.model.clone()), permission_mode: Mutex::new(meta.permission_mode.clone()), @@ -2123,11 +1849,10 @@ fn launch( // Nothing here has measured this session's context: the transcript // predates the figure being recorded, or the last turn happened before - // this server was watching. The CLI wrote it down at the time, so ask - // its file rather than leaving the row saying "unknown" until somebody - // sends a message. In the background, because it is a file read on a - // machine that may be at the other end of an ssh connection, and a - // server start must not wait on one. + // this server was watching. The CLI wrote it down at the time, so ask its + // file rather than leaving the row saying "unknown" until somebody sends + // a message. In the background, because it is a file read on a machine + // that may be at the far end of an ssh connection. if provider.kind == DriverKind::ClaudeCli && shared.context_tokens.lock().unwrap().is_none() && let Some(session_id) = claude::read_resume_token(&dir) @@ -2136,9 +1861,9 @@ fn launch( let shared = Arc::clone(&shared); tokio::spawn(async move { if let Some(context) = import::context_of(&transport, &session_id).await { - // Only if nothing else has answered in the meantime: a turn - // that finished while this was in flight measured the - // context after the one this read. + // Only if nothing else has answered meanwhile: a turn that + // finished while this was in flight measured the context + // after the one this read. let mut held = shared.context_tokens.lock().unwrap(); if held.is_none() { *held = Some(context); @@ -2147,10 +1872,9 @@ fn launch( }); } - // An imported session shares its transcript file with the CLI -- - // `--resume` appends to the same one rather than forking, measured - // rather than assumed -- so work done at a terminal belongs in this - // session too, and arrives without anybody pressing anything. + // An imported session shares its transcript file with the CLI, so work + // done at a terminal belongs in this session too and arrives without + // anybody pressing anything. if let Some(cursor) = import::read_cursor(&dir) { spawn_import_sync( Transport::for_setup(setup), @@ -2207,11 +1931,10 @@ fn launch( /// Whatever runs this session's provider, pointed at the session's own /// directory and reporting into `sink`. /// -/// Split out of [`launch`] because a session outlives its process: it is -/// also what [`SessionManager::start_session`] builds when somebody starts a -/// stopped session again. That path replaces the driver and nothing else, so -/// it has to construct one the same way rather than becoming a second answer -/// to "what runs this". +/// Split out of [`launch`] because a session outlives its process: it is also +/// what [`SessionManager::start_session`] builds. That path replaces the +/// driver and nothing else, so it has to construct one the same way rather +/// than becoming a second answer to "what runs this". fn make_driver( meta: &SessionConfig, setup: &SetupConfig, @@ -2244,19 +1967,16 @@ fn make_driver( /// The one writer of a session's transcript: assigns sequence numbers, /// appends, updates the shared status/activity view, fans out. Ends when -/// every sender is dropped -- i.e. when the session is deleted and its -/// last in-flight task finishes. +/// every sender is dropped. /// -/// The appends are synchronous file writes from an async task, -/// deliberately: each is one small line on a local disk, and funneling -/// them through one task is what makes the sequence numbering safe. -/// Whether this event tells anyone anything they do not already know. -/// -/// Only the two events that report state rather than something that -/// happened can fail this: everything else is an occurrence, and an -/// occurrence is news by existing. A `Settings` naming one field is -/// judged on that field alone, since the other is not a claim that it is -/// unset. +/// The appends are synchronous file writes from an async task, deliberately: +/// each is one small line on a local disk, and funneling them through one +/// task is what makes the sequence numbering safe. +/// Whether this event tells anyone anything they do not already know. Only +/// the two events that report state rather than something that happened can +/// fail this: an occurrence is news by existing. A `Settings` naming one +/// field is judged on that field alone, since the other is not a claim that +/// it is unset. fn is_news(event: &Event, shared: &Shared) -> bool { match event { Event::Status { state } => *shared.status.lock().unwrap() != *state, @@ -2276,22 +1996,19 @@ fn is_news(event: &Event, shared: &Shared) -> bool { /// Whether moving from `was` to `now` is worth interrupting somebody for. /// /// The asymmetry is the point. *Waiting on a person* is worth saying however -/// the session got there -- it is a question that will sit unanswered until -/// somebody sees it. *Finished* is only worth saying when this server +/// the session got there. *Finished* is only worth saying when this server /// watched the work happen: a session settling into idle because it was -/// adopted at startup, or because a driver announced itself, is not news -/// that anything ended, and sending it would put "finished" on the phone for -/// every session in the config every time the backend restarts. +/// adopted at startup is not news that anything ended, and sending it would +/// put "finished" on the phone for every session in the config at every +/// restart. /// -/// `unread` is how many messages the session has been handed and not yet -/// started reading, and it suppresses *Finished* for the same reason: with -/// one waiting, the turn ending is not the work ending. A message written -/// into the tail of a turn is read as soon as that turn's `result` lands, so -/// the session goes idle and immediately runs again -- and the phone that -/// sent it was told its work had finished, seconds before anything of it had -/// been done. It cannot suppress *AwaitingInput*: a question is worth saying -/// whatever else is queued behind it, and the queue is precisely what will -/// not move until it is answered. +/// `unread` is how many messages the session has been handed and not started +/// reading, and it suppresses *Finished* for the same reason: with one +/// waiting, the turn ending is not the work ending. A message written into +/// the tail of a turn is read as soon as that turn's `result` lands, so the +/// session goes idle and immediately runs again -- and the phone that sent it +/// was told its work had finished. It cannot suppress *AwaitingInput*: a +/// question is worth saying whatever is queued behind it. fn notification_for( was: SessionStatus, now: SessionStatus, @@ -2318,28 +2035,23 @@ async fn pump( notifications: broadcast::Sender, ) { // Messages the session has been given and not started reading, which is - // what makes a turn ending not the same thing as the work ending; see - // `notification_for`. Counted from the recorded events rather than asked - // of the driver, because this is the one place that sees every event in - // the order the transcript has them -- and because the answer has to - // survive being asked a moment later than the driver would have said it. + // what makes a turn ending not the same thing as the work ending. + // Counted from the recorded events because this is the one place that + // sees every event in the order the transcript has them. let mut unread: usize = 0; - // Where the turn currently running began: the seq of the `Status` - // that opened it, which is recorded before any of the turn's own - // output. Held here because the pump is the only place that knows a - // seq at all, and the only one that sees every driver's turns. + // Where the turn currently running began: the seq of the `Status` that + // opened it. Held here because the pump is the only place that knows a + // seq, and the only one that sees every driver's turns. let mut turn_start: Option = None; while let Some(event) = source.recv().await { let ts = now(); // Taking a message is how it enters the conversation, and the // conversation is what a phone renders -- so the event becomes the - // message here rather than being carried alongside it. One rule - // for where a user's message sits: where the session read it. + // message here rather than being carried alongside it. // - // A peer message is stamped with the same knowledge for the - // opposite reason: it arrives *after* everything it caused, and - // the position is the only way a reader can put it back where it - // happened -- see `Event::PeerMessage::turn_start`. + // A peer message is stamped with the same knowledge for the opposite + // reason: it arrives *after* everything it caused, and the position is + // the only way a reader can put it back where it happened. let event = match event { Event::MessageTaken { id, @@ -2358,20 +2070,18 @@ async fn pump( other => other, }; // Where the session row's figure comes from. Kept here rather than - // at each driver because a clear and a compaction move it as much - // as a turn does, and only the pump sees all three. + // at each driver because a clear and a compaction move it as much as + // a turn does, and only the pump sees all three. { let mut context = shared.context_tokens.lock().unwrap(); *context = context_after(*context, &event); } // Nothing changed, so there is nothing to record. Both of these // repeat: an imported session reads the turn state off its file's - // newest record on every sync and mostly finds the answer it found - // last time, and the CLI restates its model and mode at every - // `init`, which includes the one after every compaction. Recording - // those would be a transcript entry, a broadcast and a - // recomposition on every phone, several times a minute, to say - // nothing at all. + // newest record on every sync, and the CLI restates its model and + // mode at every `init`. Recording those would be a transcript entry, + // a broadcast and a recomposition on every phone, several times a + // minute, to say nothing at all. if !is_news(&event, &shared) { continue; } @@ -2380,9 +2090,9 @@ async fn pump( permission_mode, } = &event { - // The session's own account of what it is set to, which is - // what the list and the session screen show. Not written - // where the change is *asked for* -- see `Event::Settings`. + // The session's own account of what it is set to, which is what + // the list and the session screen show. Not written where the + // change is *asked for* -- see `Event::Settings`. if let Some(model) = model { *shared.model.lock().unwrap() = Some(model.clone()); } @@ -2411,11 +2121,10 @@ async fn pump( } *shared.last_activity.lock().unwrap() = ts; *shared.written.lock().unwrap() += 1; - // The turn's own first line, kept for whatever arrives at - // the end of it needing to say where it started. Only the - // *opening* status counts: a turn that pauses for a - // question or a compaction and resumes is still the turn - // that began where it began. + // The turn's own first line, kept for whatever arrives at the + // end of it needing to say where it started. Only the + // *opening* status counts: a turn that pauses for a question + // and resumes is still the turn that began where it began. match &entry.event { Event::Status { state: SessionStatus::Running, @@ -2425,10 +2134,10 @@ async fn pump( } => turn_start = None, _ => {} } - // The boundary a held command was waiting for, and the one - // place that sees every driver's. Done after the status is - // recorded, so the command that runs next sees an idle - // session and goes out rather than queueing behind itself. + // The boundary a held command was waiting for. Done after the + // status is recorded, so the command that runs next sees an + // idle session and goes out rather than queueing behind + // itself. match &entry.event { Event::Status { state: SessionStatus::Idle, @@ -2437,7 +2146,7 @@ async fn pump( state: SessionStatus::Exited, } => commands.abandon("this session's process has exited"), // The two ends of a message's wait. A `UserMessage` with - // no id never waited -- it is one sent between turns, and + // no id never waited -- it was sent between turns, and // counting it would take the total below zero. Event::MessageQueued { .. } => unread += 1, Event::UserMessage { id: Some(_), .. } | Event::MessageDropped { .. } => { @@ -2521,8 +2230,7 @@ mod tests { /// Collects one full echo turn: everything up to the idle that follows /// the turn's `UsageDelta`. Stopping at the first idle would be racy -- - /// the driver emits an idle at construction, and a subscriber attached - /// just before the pump processes it would stop there, mid-spawn. + /// the driver emits one at construction. async fn collect_turn(rx: &mut broadcast::Receiver) -> Vec { let mut saw_usage = false; collect_until(rx, |event| { @@ -2533,12 +2241,9 @@ mod tests { } /// Writes this machine into `config_path` with echo and nothing else. - /// - /// Explicit rather than letting the manager seed itself: seeding now - /// asks the machine what it has, so a test that relied on it would - /// pass or fail depending on whether `claude` happens to be installed - /// on whoever is running it. Echo is the only provider that is true - /// everywhere, and the only one these tests need. + /// Explicit rather than letting the manager seed itself, which asks the + /// machine what it has -- so a test relying on it would pass or fail + /// depending on whether `claude` happens to be installed. fn seed_echo_only(config_path: &std::path::Path) { Config { setups: vec![Config::seed(vec![Config::echo_provider()])], @@ -2548,13 +2253,10 @@ mod tests { .expect("seed config"); } - /// Deleting an echo session ends the conversation; deleting a - /// claude-cli one does not, because the CLI keeps its own transcript - /// whether this app spawned the session or imported it. - /// - /// The delete confirmation is worded off this, so getting it backwards - /// either loses a conversation somebody was told they could recover, - /// or cries wolf about one they can. + /// Deleting an echo session ends the conversation; deleting a claude-cli + /// one does not. The delete confirmation is worded off this, so getting + /// it backwards either loses a conversation somebody was told they could + /// recover, or cries wolf about one they can. #[test] fn only_a_driver_that_keeps_its_own_record_survives_deletion() { assert!(DriverKind::ClaudeCli.keeps_own_transcript()); @@ -2562,20 +2264,17 @@ mod tests { assert!(!DriverKind::LlamaCpp.keeps_own_transcript()); } - /// A command sent to a session whose process is gone says so, rather - /// than waiting for a boundary that will never come. + /// A command sent to a session whose process is gone says so, rather than + /// waiting for a boundary that will never come. /// - /// Held commands drain at the next boundary, and an exited session has - /// none -- so this used to leave a `/clear` in the queue forever, drawn - /// on the phone as a waiting bubble with nothing to resolve it and - /// nothing anywhere saying why. A *message* sent to the same session - /// reported the exit at once, which is what made the silence on the - /// command path visible: one session answered one and swallowed the - /// other. + /// Held commands drain at the next boundary and an exited session has + /// none, so this used to leave a `/clear` in the queue forever, drawn as + /// a waiting bubble with nothing to resolve it. A *message* to the same + /// session reported the exit at once, which is what made the silence on + /// the command path visible. /// - /// `Unknown` still waits, deliberately: nobody could find out whether - /// the process is there, and refusing on it would turn "we don't know" - /// into "it's gone". + /// `Unknown` still waits, deliberately: refusing on it would turn "we + /// don't know" into "it's gone". #[test] fn a_command_is_refused_when_there_can_be_no_boundary() { let dir = tempfile::tempdir().expect("tempdir"); @@ -2604,12 +2303,10 @@ mod tests { } /// A peer message is stamped with where its turn began, so a phone can - /// draw it above the reply it caused rather than below it. - /// - /// The live CLI reveals the message only on the turn's `result`, and an - /// append-only transcript cannot go back and insert it -- so the - /// position has to travel with the event. Without it the note is drawn - /// at the end of the turn, which reads as an answer printed above its + /// draw it above the reply it caused rather than below it. The live CLI + /// reveals it only on the turn's `result`, and an append-only transcript + /// cannot go back and insert it -- so the position travels with the + /// event. Without it the note reads as an answer printed above its /// question. #[tokio::test] async fn a_peer_message_carries_the_seq_its_turn_started_at() { @@ -2647,8 +2344,8 @@ mod tests { // turn it explains. assert!(note.seq > opened, "{seen:?}"); - // A message that opened no turn is left where it arrived: an - // import replays those in place already. + // A message that opened no turn is left where it arrived: an import + // replays those in place already. session.send_message("/peer".to_string(), Vec::new()); let alone = collect_until(&mut rx, |event| matches!(event, Event::PeerMessage { .. })).await; @@ -2660,14 +2357,12 @@ mod tests { } /// A command waits for the *driver* to be between turns, not for the - /// recorded status to say idle. - /// - /// The two are the same fact seen at different moments, and only the - /// driver's is current: it moves when a line is written, while the - /// status moves when output comes back. Gating on the status meant two - /// commands in a row both went out, the second landing inside the turn - /// the first had started -- where the CLI reads it as text instead of - /// running it, which looks exactly like nothing happening. + /// recorded status to say idle. The two are the same fact seen at + /// different moments, and only the driver's is current. Gating on the + /// status meant two commands in a row both went out, the second landing + /// inside the turn the first had started -- where the CLI reads it as + /// text instead of running it, which looks exactly like nothing + /// happening. #[tokio::test] async fn a_command_waits_for_the_driver_rather_than_the_recorded_status() { let dir = tempfile::tempdir().expect("tempdir"); @@ -2720,16 +2415,14 @@ mod tests { ); } - /// The two transitions worth interrupting somebody for, and the ones - /// that look like them and are not. + /// The two transitions worth interrupting somebody for, and the ones that + /// look like them and are not. /// - /// The idle cases are the whole reason this is a function rather than a - /// pair of `if`s at the callsite. A session settles into idle for - /// several reasons that are not "your work finished": it was adopted at - /// startup, its driver announced itself, it came back from a state - /// nobody could read. Announcing those would put "finished" on the phone - /// for every session in the config every time the backend restarts, - /// which is the failure that makes somebody turn the whole feature off. + /// The idle cases are why this is a function rather than a pair of `if`s + /// at the callsite. A session settles into idle for several reasons that + /// are not "your work finished", and announcing those would put + /// "finished" on the phone for every session in the config at every + /// restart -- the failure that makes somebody turn the feature off. #[test] fn only_a_watched_turn_ending_counts_as_finished() { use NotificationKind::{AwaitingInput, Finished}; @@ -2777,13 +2470,11 @@ mod tests { ); } - /// The switch reaches the running pump, not just the config file. - /// - /// The failure this exists for is silent in the direction that matters: - /// a `set_session_notify(false)` that wrote only the config would look - /// correct on the settings screen and in the file, and keep notifying - /// until the backend was restarted. Nothing on screen would say so, and - /// the person who turned it off is by definition not watching. + /// The switch reaches the running pump, not just the config file. The + /// failure is silent in the direction that matters: a + /// `set_session_notify(false)` writing only the config looks correct on + /// the settings screen and keeps notifying until the backend restarts, + /// and the person who turned it off is by definition not watching. #[tokio::test] async fn turning_notifications_off_stops_them_without_a_restart() { let dir = tempfile::tempdir().expect("tempdir"); @@ -3583,13 +3274,11 @@ mod tests { /// A session spawned while testing is cleaned away on the way out, and /// the sessions beside it are not. /// - /// The two halves are one rule. Leaving processes running is the whole - /// design -- a rebuild must not end a turn -- and it is exactly wrong - /// for a session nobody meant to keep: those leave a `claude` behind - /// that every later server adopts, and they accumulate unnoticed. So - /// the mark decides, and it is the session's own rather than the - /// running server's, which is what this asks: the manager that stops - /// them is not the one that spawned the session it must not touch. + /// The two halves are one rule: leaving processes running is the design, + /// and it is exactly wrong for a session nobody meant to keep. So the + /// mark decides, and it is the session's own rather than the running + /// server's -- which is what this asks, since the manager that stops them + /// is not the one that spawned the session it must not touch. #[tokio::test] async fn only_sessions_marked_throwaway_are_stopped_on_the_way_out() { let dir = tempfile::tempdir().expect("tempdir"); @@ -3625,8 +3314,8 @@ mod tests { manager.stop_throwaway_sessions(); // Both answers taken before anything is asserted, and the keeper - // ended here: a failing assertion must not be what decides whether - // this test leaves a process behind. + // ended here: a failing assertion must not decide whether this test + // leaves a process behind. let throwaway_after = throwaway_process.liveness(); let keeper_after = keeper_process.liveness(); manager.delete_session(&keeper.id).expect("delete keeper"); @@ -3660,16 +3349,11 @@ mod tests { std::fs::write(path, rewritten).expect("write transcript"); } - /// A setting changed on a session with nothing running is recorded as - /// the session's own, rather than refused because there is no driver. - /// - /// The config already took it -- that is what a session starts with next - /// time -- so the refusal was about the driver while reading as though - /// it were about the session, and the phone went on showing the old - /// model over a stored new one. Asked of a session told it has exited, - /// since the rule is about the status rather than about which driver it - /// is; the same is true of the permission mode, which is why they go - /// through one function. + /// A setting changed on a session with nothing running is recorded as the + /// session's own, rather than refused because there is no driver. The + /// config already took it, so the refusal was about the driver while + /// reading as though it were about the session, and the phone went on + /// showing the old model over a stored new one. #[tokio::test] async fn a_stopped_session_takes_a_setting_for_the_next_time_it_starts() { let dir = tempfile::tempdir().expect("tempdir"); @@ -3729,15 +3413,11 @@ mod tests { } /// Stopping and starting a session is about its *process*, and the two - /// refusals are the whole of what keeps starting one from becoming a - /// second one on the same conversation. - /// - /// Echo has no process, which makes it the right session to ask the - /// first question of: "there is nothing to stop" is an answer, and - /// reporting success would leave a phone showing a session it believes - /// it stopped. The second question is asked of a session that has been - /// told it exited, since the guard is on the *status* rather than on - /// which driver it is. + /// refusals are what keeps starting one from becoming a second one on the + /// same conversation. Echo has no process, which makes it the right + /// session to ask the first question of: "there is nothing to stop" is an + /// answer, and reporting success would leave a phone showing a session it + /// believes it stopped. #[tokio::test] async fn a_session_is_started_again_only_once_it_is_known_to_have_exited() { let dir = tempfile::tempdir().expect("tempdir"); @@ -3763,9 +3443,8 @@ mod tests { "said: {refused:#}" ); - // What a driver reports when its process goes, without a process - // to go: the guard reads the recorded status, so this is the same - // state a stopped claude session reaches. + // What a driver reports when its process goes, without a process to + // go: the guard reads the recorded status. let _ = session.sink.send(Event::Status { state: SessionStatus::Exited, }); @@ -3782,11 +3461,9 @@ mod tests { manager.start_session(&info.id).expect("start again"); collect_until(&mut rx, is_idle).await; // Idle rather than exited, and *recorded* -- said by the driver that - // was just built, like every driver says what state it is starting - // in. The manager writing it directly is what made the phone's list - // and its session screen disagree: one reads this status and the - // other replays the transcript, so a status in only one of them is - // two screens describing one session differently. + // was just built. The manager writing it directly is what made the + // list and the session screen disagree: one reads this status and the + // other replays the transcript. assert_eq!(manager.sessions()[0].status, SessionStatus::Idle); assert_eq!( Transcript::open(&data_dir.join(&info.id).join("transcript.jsonl")) @@ -3794,8 +3471,8 @@ mod tests { .last_status(), Some(SessionStatus::Idle), ); - // The same live session throughout: only the driver was replaced, - // so nothing a phone is reading was interrupted. + // The same live session throughout: only the driver was replaced, so + // nothing a phone is reading was interrupted. assert!(Arc::ptr_eq( &session, &manager.session(&info.id).expect("still live") @@ -3805,17 +3482,11 @@ mod tests { /// A message and a command both mean "now", so neither answers that the /// session's process has gone -- they start one and go to it. /// - /// Refusing was the old behaviour and it was work handed back: read the - /// status word, find the other button, press it, type the thing again. - /// `--resume` puts the new process on the same conversation, so what it - /// reads is what was typed. - /// /// Both halves in one test because they are one rule. A command is the - /// half that can fail on its own: `Commands::submit` refuses on - /// `Exited`, and the start it has just been given announces `Idle` - /// through the sink rather than writing it -- so a command judged - /// against the session's own status would be refused by the word the - /// start replaced, in a window a test is the only thing likely to hit. + /// half that can fail on its own: `Commands::submit` refuses on `Exited`, + /// and the start it has just been given announces `Idle` through the sink + /// rather than writing it -- so a command judged against the session's + /// own status would be refused by the word the start replaced. #[tokio::test] async fn an_instruction_starts_the_process_a_stopped_session_has_not_got() { let dir = tempfile::tempdir().expect("tempdir"); @@ -3874,14 +3545,11 @@ mod tests { assert_ne!(manager.sessions()[0].status, SessionStatus::Exited); } - /// A rename is not decoration, so it starts a stopped session too. - /// - /// Claude Code keeps its own copy of the name; that copy is what its - /// session picker shows and what other agents read when they list - /// sessions, and a session is only ever *given* a name at birth, since - /// every later start is a `--resume`. So a rename that reached no - /// process would leave the two lists disagreeing permanently, with this - /// app's the only one that had moved. + /// A rename is not decoration, so it starts a stopped session too. Claude + /// Code keeps its own copy of the name, that copy is what its session + /// picker and other agents' session lists show, and a session is only + /// ever *given* one at birth -- so a rename that reached no process would + /// leave the two lists disagreeing permanently. #[tokio::test] async fn a_rename_reaches_the_process_even_when_one_has_to_be_started() { let dir = tempfile::tempdir().expect("tempdir"); @@ -3921,15 +3589,12 @@ mod tests { assert_ne!(manager.sessions()[0].status, SessionStatus::Exited); } - /// The status is a claim about a process, and the process record is - /// what settles it. - /// - /// Without this the phone offered Start on a session whose CLI was - /// running, and taking it up attached a second reader to that one - /// process rather than failing -- so the session went on saying - /// `exited`, the button stayed, and each further press added another - /// reader. On screen that was one reply written as many times as the - /// button had been pressed, interleaved word by word. + /// The status is a claim about a process, and the process record is what + /// settles it. Without this the phone offered Start on a session whose CLI + /// was running, and taking it up attached a second reader to that one + /// process -- so the session went on saying `exited`, the button stayed, + /// and each further press added another reader. On screen that was one + /// reply written as many times as the button had been pressed. #[tokio::test] async fn a_stale_exited_does_not_start_anything_while_a_process_is_recorded() { let dir = tempfile::tempdir().expect("tempdir"); @@ -3942,8 +3607,8 @@ mod tests { let session = manager.session(&info.id).expect("live session"); let mut rx = session.subscribe(); - // A live process for this session: this test's own, which is the - // one process certain to still be there when the guard looks. + // A live process for this session: this test's own, which is the one + // process certain to still be there when the guard looks. let record = process::Record::of( std::process::id(), process::Detail::Stdio { stdout_read: 0 }, @@ -3970,9 +3635,9 @@ mod tests { refused.to_string().contains("still a process recorded"), "said: {refused:#}" ); - // And the word that was wrong is taken back, on the stream and in - // the transcript -- otherwise the button that asked for this is - // still there, still saying Start. + // And the word that was wrong is taken back, on the stream and in the + // transcript -- otherwise the button that asked for this is still + // there, still saying Start. collect_until(&mut rx, |event| { matches!( event, @@ -4025,9 +3690,9 @@ mod tests { let session = manager.session(&info.id).expect("relaunched session"); let mut rx = session.subscribe(); // Through the manager, which is the message path a phone takes and - // the one that starts a process for a session that has none -- see - // `Launching`. A restart adopts what is running and starts nothing, - // and echo has nothing to adopt. + // the one that starts a process for a session that has none. A restart + // adopts what is running and starts nothing, and echo has nothing to + // adopt. manager .send_message(&info.id, "second".to_string(), Vec::new()) .expect("send after restart"); diff --git a/server/src/session/pending.rs b/server/src/session/pending.rs index 86a3487..6ed2915 100644 --- a/server/src/session/pending.rs +++ b/server/src/session/pending.rs @@ -1,20 +1,18 @@ //! What is being done to a machine's Claude Code sessions right now. //! -//! Importing and deleting used to be whatever the phone was in the middle -//! of: the request was the work, so leaving the screen cancelled it and -//! coming back showed no sign it had ever started. Sessions half-imported -//! that way are the expensive kind of missing -- the row is back in the -//! list looking untouched, and taking it again is the second `--resume` the -//! whole import path exists to prevent. +//! Importing and deleting used to be whatever the phone was in the middle of: +//! the request was the work, so leaving the screen cancelled it and coming back +//! showed no sign it had ever started. Sessions half-imported that way are the +//! expensive kind of missing -- the row is back in the list looking untouched, +//! and taking it again is the second `--resume` the import path exists to +//! prevent. //! //! So the work runs here, on the server, and this is the record of it. The -//! phone reads that record two ways, and needs both: every row of `GET -//! /setups/{id}/importable` carries what is happening to it, which is what -//! a phone that was asleep, out of range, or freshly opened has to go on; -//! and [`Registry::subscribe`] is the live stream, which is what makes a -//! screen somebody is looking at change by itself. Neither is sufficient -//! alone -- a broadcast has no memory, and a listing is only true when it -//! was fetched. +//! phone reads that record two ways and needs both: every row of the importable +//! listing carries what is happening to it, which is what a phone that was +//! asleep has to go on; and [`Registry::subscribe`] is the live stream, which is +//! what makes a screen change by itself. A broadcast has no memory, and a +//! listing is only true when it was fetched. use std::collections::HashMap; use std::sync::{Arc, Mutex}; @@ -31,8 +29,8 @@ pub enum Operation { } impl Operation { - /// The word a row shows while this runs. Fixed here rather than in the - /// app so the two ends cannot disagree about what a state is called. + /// The word a row shows while this runs. Fixed here rather than in the app + /// so the two ends cannot disagree about what a state is called. pub fn label(self) -> &'static str { match self { Self::Importing => "importing", @@ -43,11 +41,9 @@ impl Operation { /// One change to what is in flight, as it goes out on the stream. /// -/// The three states are every way an operation ends, including the two that -/// are easy to leave out: it can still be running, it can have finished, -/// and it can have failed. There is deliberately no "unknown" -- this is -/// the server's own work, so not knowing would be a bug rather than a -/// state. +/// The three states are every way an operation ends, including the two easy to +/// leave out: still running, finished, and failed. There is deliberately no +/// "unknown" -- this is the server's own work, so not knowing would be a bug. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase", tag = "state")] pub enum Change { @@ -68,9 +64,8 @@ pub enum Change { } impl Change { - /// Which machine this is about, so a stream scoped to one can drop the - /// rest. Every variant carries it; matching here rather than at the - /// filter keeps that fact in one place. + /// Which machine this is about, so a stream scoped to one can drop the rest. + /// Every variant carries it; matching here keeps that fact in one place. pub fn setup(&self) -> &str { match self { Self::Started { setup, .. } @@ -84,11 +79,10 @@ impl Change { #[derive(Debug)] pub struct Registry { running: Mutex>, - /// Kept after the operation ends, because a phone that was not looking - /// when it failed has no other way to find out. Replaced when the next - /// operation on that session starts, and dropped by [`Registry::prune`] - /// when the session is no longer on the machine -- an error about a - /// transcript that is gone has nothing left to be about. + /// Kept after the operation ends, because a phone that was not looking when + /// it failed has no other way to find out. Replaced when the next operation + /// on that session starts, and dropped by [`Registry::prune`] when the + /// session is no longer on the machine. failures: Mutex>, changes: broadcast::Sender, } @@ -98,8 +92,8 @@ impl Default for Registry { Self { running: Mutex::new(HashMap::new()), failures: Mutex::new(HashMap::new()), - // Enough that a phone watching one screen cannot lag behind a - // batch of any size somebody would start by hand. + // Enough that a phone watching one screen cannot lag behind a batch + // of any size somebody would start by hand. changes: broadcast::channel(256).0, } } @@ -109,10 +103,9 @@ impl Registry { /// Marks an operation as running and announces it. /// /// The returned guard is how it stops being marked: settle it with - /// [`InFlight::succeeded`] or [`InFlight::failed`], or drop it and it - /// reports a failure. Dropping without settling means the task was - /// cancelled or panicked, and a row stuck on "importing" for ever is a - /// worse answer than one that says it did not finish. + /// [`InFlight::succeeded`] or [`InFlight::failed`], or drop it and it reports + /// a failure. Dropping without settling means the task was cancelled or + /// panicked, and a row stuck on "importing" for ever is a worse answer. pub fn begin(self: &Arc, setup: &str, session: &str, operation: Operation) -> InFlight { let key = (setup.to_string(), session.to_string()); self.running.lock().unwrap().insert(key.clone(), operation); @@ -141,11 +134,8 @@ impl Registry { self.failures.lock().unwrap().get(&key).cloned() } - /// Forgets failures against sessions the machine no longer has. - /// - /// Called from the listing, which is the only place that knows what is - /// still there. A deleted session's failure would otherwise outlive - /// everything it referred to. + /// Forgets failures against sessions the machine no longer has. Called from + /// the listing, which is the only place that knows what is still there. pub fn prune(&self, setup: &str, present: &[String]) { self.failures .lock() @@ -155,8 +145,8 @@ impl Registry { }); } - /// Every change as it happens. See the module note on why this is not - /// the only way the phone finds out. + /// Every change as it happens. See the module note on why this is not the + /// only way the phone finds out. pub fn subscribe(&self) -> broadcast::Receiver { self.changes.subscribe() } @@ -229,8 +219,8 @@ mod tests { assert!(matches!(changes.try_recv(), Ok(Change::Finished { .. }))); } - /// A failure outlives the operation, because the phone that needs it may - /// not have been listening when it happened. + /// A failure outlives the operation, because the phone that needs it may not + /// have been listening when it happened. #[test] fn a_failure_is_kept_until_something_replaces_or_prunes_it() { let registry = Arc::new(Registry::default()); @@ -256,8 +246,8 @@ mod tests { assert!(registry.failure("local", "abc").is_none()); } - /// Trying again clears the last failure, so a row cannot show an error - /// from before the attempt somebody is currently watching. + /// Trying again clears the last failure, so a row cannot show an error from + /// before the attempt somebody is currently watching. #[test] fn starting_again_clears_the_previous_failure() { let registry = Arc::new(Registry::default()); @@ -270,8 +260,8 @@ mod tests { second.succeeded(); } - /// A task that is cancelled or panics must not leave a row saying - /// something is still happening to it. + /// A task that is cancelled or panics must not leave a row saying something + /// is still happening to it. #[test] fn dropping_an_unsettled_operation_reports_a_failure() { let registry = Arc::new(Registry::default()); diff --git a/server/src/session/process.rs b/server/src/session/process.rs index 2aca578..202b1a2 100644 --- a/server/src/session/process.rs +++ b/server/src/session/process.rs @@ -2,31 +2,26 @@ //! written down so a *later* run of this server can find the same process //! rather than start a second one. //! -//! The server deliberately outlives its own restarts badly and its -//! children well: stopping the backend must not kill a turn that is in -//! flight, so session processes are left running and adopted again on the -//! way back up. That only works if "is this still mine?" has an answer, -//! which is what this module is. +//! Stopping the backend must not kill a turn that is in flight, so session +//! processes are left running and adopted again on the way back up. That only +//! works if "is this still mine?" has an answer, which is what this module is. //! -//! **A pid is not an identity.** Pids are reused, so adopting one by -//! number alone eventually means treating a stranger's process as a -//! session -- never resuming the real conversation, and signalling -//! something unrelated when the session is deleted. The kernel's start -//! time for that pid is recorded beside it; the pair is unique for as long -//! as the machine has been up, which is longer than any of this lives. +//! **A pid is not an identity.** Pids are reused, so adopting one by number +//! alone eventually means treating a stranger's process as a session -- never +//! resuming the real conversation, and signalling something unrelated when the +//! session is deleted. The kernel's start time for that pid is recorded beside +//! it; the pair is unique for as long as the machine has been up. //! -//! **How to reach it again belongs here too**, in the same record and the -//! same write, because it answers the other half of the same question: not -//! just "is my process still there" but "where do I pick it up". Splitting -//! them would be two files that can disagree about one process. What that -//! takes differs by driver -- a reading position into a log for one spoken -//! to over stdio, a port for one spoken to over HTTP -- so it is a typed -//! [`Detail`] rather than a union of every driver's fields. +//! **How to reach it again belongs here too**, in the same record and the same +//! write, because it answers the other half of the same question. Splitting +//! them would be two files that can disagree about one process. What it takes +//! differs by driver, so it is a typed [`Detail`] rather than a union of every +//! driver's fields. //! -//! The record is rewritten in place as reading advances. A crash during -//! that write leaves a record that does not parse, which is read as "no -//! live process" -- so the failure is the old behaviour (start one with -//! `--resume`) rather than a wrong adoption. +//! The record is rewritten in place as reading advances. A crash during that +//! write leaves a record that does not parse, which is read as "no live +//! process" -- so the failure is the old behaviour rather than a wrong +//! adoption. use std::os::unix::fs::OpenOptionsExt; use std::path::{Path, PathBuf}; @@ -40,8 +35,8 @@ const RECORD_FILE: &str = "process.json"; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct Record { pub pid: u32, - /// The kernel's start time for `pid`, in clock ticks since boot. See - /// the module comment: this is what makes the pid an identity. + /// The kernel's start time for `pid`, in clock ticks since boot. See the + /// module comment: this is what makes the pid an identity. pub started: u64, /// What the driver needs in order to pick this process back up. #[serde(flatten)] @@ -52,24 +47,21 @@ pub struct Record { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum Detail { - /// Spoken to over stdio, which outlives the server as files in the - /// session directory. `stdout_read` is how many bytes of the stdout - /// log have already become events: everything before it is in the - /// transcript, everything after it is what a reattaching server owes - /// the conversation. + /// Spoken to over stdio, which outlives the server as files in the session + /// directory. `stdout_read` is how many bytes of the stdout log have + /// already become events: everything after it is what a reattaching server + /// owes the conversation. Stdio { stdout_read: u64 }, - /// Spoken to over HTTP on a loopback port, which is all it takes to - /// find it again -- there is no stream to be partway through. + /// Spoken to over HTTP on a loopback port, which is all it takes to find + /// it again -- there is no stream to be partway through. Http { port: u16 }, } /// Whether a recorded process is still there. /// -/// Three answers rather than a boolean, because "I could not find out" is -/// a real one and is not the same as "no". Treating it as "no" is what -/// would start a second process against a conversation that already has -/// one -- the expensive mistake this whole module exists to prevent -- so -/// it has to be sayable. +/// Three answers rather than a boolean, because "I could not find out" is a +/// real one and is not the same as "no". Treating it as "no" is what would +/// start a second process against a conversation that already has one. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Liveness { Alive, @@ -78,9 +70,9 @@ pub enum Liveness { } impl Record { - /// The record for a process this server just started, or `None` when - /// the kernel will not say when it started -- which is the same - /// answer as "do not adopt this later", and the safe one. + /// The record for a process this server just started, or `None` when the + /// kernel will not say when it started -- which is the same answer as "do + /// not adopt this later", and the safe one. pub fn of(pid: u32, detail: Detail) -> Option { Some(Self { pid, @@ -89,12 +81,9 @@ impl Record { }) } - /// Whether the process this describes is still the one running under - /// that pid. pub fn liveness(&self) -> Liveness { match stat_of(self.pid) { - // A different start time is a reused pid, which is a different - // process and so definitely not ours. + // A different start time is a reused pid, so definitely not ours. Ok(Some(stat)) if stat.started == self.started => { if stat.exited { Liveness::Dead @@ -112,12 +101,10 @@ fn path(session_dir: &Path) -> PathBuf { session_dir.join(RECORD_FILE) } -/// The recorded process and whether it is still there, or `None` when -/// nothing usable is recorded. -/// -/// A record that does not parse reads as no record: the only way to get -/// one is a crash partway through writing it, and the safe reading of that -/// is that this server has no claim on anything. +/// The recorded process and whether it is still there, or `None` when nothing +/// usable is recorded. A record that does not parse reads as no record: the +/// only way to get one is a crash partway through writing it, and the safe +/// reading is that this server has no claim on anything. pub fn recorded(session_dir: &Path) -> Option<(Record, Liveness)> { let text = std::fs::read_to_string(path(session_dir)).ok()?; let record: Record = serde_json::from_str(text.trim_end()).ok()?; @@ -125,10 +112,8 @@ pub fn recorded(session_dir: &Path) -> Option<(Record, Liveness)> { Some((record, liveness)) } -/// The recorded process if it is definitely still running. -/// -/// One function rather than a read plus a liveness check at each caller: -/// every caller wants the same question answered, and the one that forgets +/// The recorded process if it is definitely still running. One function rather +/// than a read plus a liveness check at each caller: the caller that forgets /// the second half is the one that starts a duplicate. pub fn live(session_dir: &Path) -> Option { match recorded(session_dir) { @@ -137,25 +122,20 @@ pub fn live(session_dir: &Path) -> Option { } } -/// Writes `record` where [`live`] will find it, atomically. +/// Writes `record` where [`live`] will find it, atomically -- to a neighbouring +/// file, renamed over the real name, so a reader sees either the whole old +/// record or the whole new one. /// -/// Written to a neighbouring file and renamed over the real name. The -/// rename is what makes this safe: a reader sees either the whole old -/// record or the whole new one, never a partial. +/// Writing in place would not be, and the consequence is severe rather than +/// untidy. `fs::write` truncates before it fills, so a crash inside that window +/// leaves no readable record -- and a missing record reads as "nothing is +/// running", which is the single answer that makes the next launch start a +/// *second* process against a conversation that already has one. The window is +/// not rare: this runs on every read that makes progress, so many times a +/// second while a turn is producing output. /// -/// Writing in place would not be, and the consequence is severe rather -/// than untidy. `fs::write` truncates before it fills, so a crash inside -/// that window leaves no readable record -- and a missing record reads as -/// "nothing is running", which is the single answer that makes the next -/// launch start a *second* process against a conversation that already has -/// one. That is the fault this whole module exists to prevent, and writing -/// the record carelessly would reintroduce it at its own save point. The -/// window is not rare either: this runs on every read that makes progress, -/// so many times a second while a turn is producing output. -/// -/// Errors are logged rather than returned: this runs on the reading path, -/// and a session that cannot save its position is still worth having -- it -/// just cannot be reattached to, which is what the log says. +/// Errors are logged rather than returned: this runs on the reading path, and a +/// session that cannot save its position is still worth having. pub fn write(session_dir: &Path, record: &Record) { let path = path(session_dir); let text = match serde_json::to_string(record) { @@ -165,8 +145,8 @@ pub fn write(session_dir: &Path, record: &Record) { return; } }; - // Beside the real file so the rename stays within one filesystem, - // which is what makes it atomic. + // Beside the real file so the rename stays within one filesystem, which is + // what makes it atomic. let temp = path.with_extension("json.new"); let written = std::fs::OpenOptions::new() .create(true) @@ -190,11 +170,8 @@ pub fn write(session_dir: &Path, record: &Record) { } } -/// How many bytes `path` holds, or 0 if it is not there. -/// -/// Exists so a caller wanting only the length does not have to read the -/// file to find it -- [`read_from`] with a large offset answers the -/// question, but allocates the whole file on the way. +/// How many bytes `path` holds, or 0 if it is not there. Exists so a caller +/// wanting only the length does not have to read the file to find it. pub fn size_of(path: &Path) -> u64 { std::fs::metadata(path).map(|meta| meta.len()).unwrap_or(0) } @@ -211,19 +188,16 @@ pub fn clear(session_dir: &Path) { } /// Grace period between asking a session's process to stop and killing it. -/// -/// Here rather than beside each caller: it is a property of stopping one of -/// these, and two drivers plus the manager had written the same five seconds -/// down separately, which is three places for it to drift. +/// Here rather than beside each caller: two drivers plus the manager had +/// written the same five seconds down separately. pub const STOP_GRACE: std::time::Duration = std::time::Duration::from_secs(5); -/// Asks it to stop, then makes sure. Used where a leaked process must -/// actually end: a deleted session, or one being replaced. +/// Asks it to stop, then makes sure. Used where a leaked process must actually +/// end: a deleted session, or one being replaced. /// -/// SIGTERM first because the CLI writes its own session file on the way -/// out and a SIGKILL would cost whatever it had not flushed; SIGKILL after -/// the grace period because a session the phone has deleted must not still -/// be running when it looks again. +/// SIGTERM first because the CLI writes its own session file on the way out and +/// a SIGKILL would cost whatever it had not flushed; SIGKILL after the grace +/// period because a session the phone has deleted must not still be running. pub fn stop(record: &Record, grace: std::time::Duration) { if record.liveness() != Liveness::Alive { return; @@ -236,23 +210,20 @@ pub fn stop(record: &Record, grace: std::time::Duration) { }); } -/// Waits for processes already asked to stop, and kills whichever have -/// not, for a caller that is about to exit. +/// Waits for processes already asked to stop, and kills whichever have not, for +/// a caller that is about to exit. /// -/// The waiting cannot be [`stop`]'s here, and that is the whole reason -/// this exists: the kill it leaves behind is a timer inside the tokio -/// runtime, and a runtime that is shutting down never runs it. That is -/// how the backend's original `shutdown_all` leaked the processes it had -/// just asked to stop -- it reported them stopped, too, which is worse -/// than not asking. +/// The waiting cannot be [`stop`]'s here, and that is the whole reason this +/// exists: the kill it leaves behind is a timer inside the tokio runtime, and a +/// runtime that is shutting down never runs it. That is how the original +/// `shutdown_all` leaked the processes it had just asked to stop -- it reported +/// them stopped, too, which is worse than not asking. /// /// One deadline for all of them rather than one each: they were signalled -/// together, so waiting is bounded by the grace period however many there -/// are, and a server does not sit for a minute on the way out. +/// together, so waiting is bounded by the grace period however many there are. pub fn wait_gone(records: &[Record], grace: std::time::Duration) { - /// How often to look. Short enough that the ordinary case -- a - /// process that goes at once -- costs nothing noticeable, and long - /// enough not to spin. + /// How often to look. Short enough that a process that goes at once costs + /// nothing noticeable, and long enough not to spin. const LOOK: std::time::Duration = std::time::Duration::from_millis(20); let deadline = std::time::Instant::now() + grace; @@ -264,11 +235,10 @@ pub fn wait_gone(records: &[Record], grace: std::time::Duration) { } } -/// The end of both paths above: a process that was asked to stop and did -/// not is killed. Written once because the two callers differ only in how -/// they wait, and a grace period that means one thing in one of them and -/// something else in the other is exactly the drift `STOP_GRACE` was -/// gathered here to prevent. +/// The end of both paths above: a process that was asked to stop and did not is +/// killed. Written once because the two callers differ only in how they wait, +/// and a grace period meaning one thing in one and something else in the other +/// is exactly the drift `STOP_GRACE` was gathered here to prevent. fn kill_if_still_there(record: &Record, grace: std::time::Duration) { if record.liveness() == Liveness::Alive { tracing::warn!( @@ -281,61 +251,56 @@ fn kill_if_still_there(record: &Record, grace: std::time::Duration) { } fn signal(pid: u32, signal: libc::c_int) { - // SAFETY: `kill` with a positive pid touches only that process, and - // the pid came from a record whose start time was just confirmed to - // match -- so it is still the process this server started, not a - // reused number. A failure (already gone) is nothing to act on. + // SAFETY: `kill` with a positive pid touches only that process, and the pid + // came from a record whose start time was just confirmed to match -- so it + // is still the process this server started, not a reused number. A failure + // (already gone) is nothing to act on. unsafe { libc::kill(pid as libc::pid_t, signal); } } -/// The kernel's start time for `pid`, in clock ticks since boot. -/// -/// Field 22 of `/proc//stat`, counted from the closing parenthesis of -/// field 2 rather than from the start of the line: a process's name is -/// field 2, it is wrapped in parentheses, and it may itself contain spaces -/// and parentheses. Splitting the whole line on whitespace therefore reads -/// the wrong field for anything with a space in its name. -/// -/// Three outcomes, and they are not the same: `Ok(None)` is "no such -/// process", `Err` is "could not find out". Collapsing the second into the -/// first is what would let a machine without a readable `/proc` look like -/// a machine with nothing running on it. Linux-specific, like `import`'s -/// use of GNU `stat`. /// What `/proc` says about a pid. struct Stat { /// The kernel's start time in clock ticks since boot -- see /// [`Record::started`]. started: u64, - /// State `Z`: the process has ended, and the kernel is keeping its - /// entry only until somebody collects the exit status. + /// State `Z`: the process has ended, and the kernel is keeping its entry + /// only until somebody collects the exit status. /// - /// Read rather than ignored, because the entry it leaves behind has - /// the same pid *and* the same start time, so a process that has - /// plainly finished goes on answering "still there" for as long as - /// nothing reaps it. None of this module's callers want that answer: a - /// session whose CLI has exited is over whether or not the status has - /// been collected, and reporting it alive makes `Exited` unsayable -- - /// the session shows `unknown`, its Start button never appears, and - /// stopping it says there is nothing to stop. + /// Read rather than ignored, because that entry has the same pid *and* the + /// same start time, so a finished process goes on answering "still there" + /// for as long as nothing reaps it -- which makes `Exited` unsayable: the + /// session shows `unknown`, its Start button never appears, and stopping it + /// says there is nothing to stop. exited: bool, } +/// The kernel's start time for `pid`, in clock ticks since boot. +/// +/// Field 22 of `/proc//stat`, counted from the closing parenthesis of +/// field 2 rather than from the start of the line: a process's name is field 2, +/// it is wrapped in parentheses, and it may itself contain spaces and +/// parentheses. Splitting the whole line on whitespace reads the wrong field +/// for anything with a space in its name. +/// +/// Three outcomes, and they are not the same: `Ok(None)` is "no such process", +/// `Err` is "could not find out". Collapsing the second into the first is what +/// would let a machine without a readable `/proc` look like a machine with +/// nothing running on it. Linux-specific, like `import`'s use of GNU `stat`. fn stat_of(pid: u32) -> std::io::Result> { let stat = match std::fs::read_to_string(format!("/proc/{pid}/stat")) { Ok(stat) => stat, Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None), Err(err) => return Err(err), }; - // A `/proc` entry that exists but does not have the shape this reads - // is not a process that has gone away; it is a reading this code - // cannot make, which is the other thing entirely. + // A `/proc` entry that exists but does not have the shape this reads is not + // a process that has gone away; it is a reading this code cannot make. let unreadable = || std::io::Error::new(std::io::ErrorKind::InvalidData, "unreadable /proc stat"); let after_name = stat.rsplit_once(')').ok_or_else(unreadable)?.1; - // Field 3 is the first after the name, so the state is the first here - // and field 22 is the 20th. + // Field 3 is the first after the name, so the state is the first here and + // field 22 is the 20th. let mut fields = after_name.split_whitespace(); let exited = fields.next().ok_or_else(unreadable)? == "Z"; let started = fields @@ -346,9 +311,9 @@ fn stat_of(pid: u32) -> std::io::Result> { Ok(Some(Stat { started, exited })) } -/// Reads `path` from `from`, returning what is there and where reading -/// reached. A file that has been truncated or replaced under us reads from -/// the start, since the offset no longer means anything in it. +/// Reads `path` from `from`, returning what is there and where reading reached. +/// A file truncated or replaced under us reads from the start, since the offset +/// no longer means anything in it. pub fn read_from(path: &Path, from: u64) -> Result<(Vec, u64)> { use std::io::{Read, Seek, SeekFrom}; let mut file = match std::fs::File::open(path) { @@ -380,8 +345,8 @@ mod tests { .expect("this process has a start time"); assert_eq!(mine.liveness(), Liveness::Alive); - // The same pid with a different start time is a different process - // -- which is the whole reason the start time is recorded. + // The same pid with a different start time is a different process -- + // which is the whole reason the start time is recorded. let recycled = Record { started: mine.started + 1, ..mine.clone() @@ -416,8 +381,8 @@ mod tests { let dir = tempfile::tempdir().expect("tempdir"); let mut record = Record::of(std::process::id(), Detail::Stdio { stdout_read: 0 }).expect("start time"); - // Rewritten the way the reader rewrites it: constantly, as the - // position advances. Each one must land whole. + // Rewritten the way the reader rewrites it: constantly, as the position + // advances. Each one must land whole. for read in [1u64, 4096, 2, 999_999] { record.detail = Detail::Stdio { stdout_read: read }; write(dir.path(), &record); @@ -427,8 +392,8 @@ mod tests { "after offset {read}" ); } - // The rename is what makes it atomic; a leftover neighbour would - // mean it had not happened. + // The rename is what makes it atomic; a leftover neighbour would mean it + // had not happened. let stray: Vec<_> = std::fs::read_dir(dir.path()) .expect("read dir") .filter_map(Result::ok) @@ -468,8 +433,8 @@ mod tests { assert_eq!(bytes, b"world"); assert_eq!(read, 11); - // An offset past the end means the file was replaced, so the - // offset describes a file that no longer exists. + // An offset past the end means the file was replaced, so the offset + // describes a file that no longer exists. std::fs::write(&path, b"new").expect("truncate"); let (bytes, read) = read_from(&path, 11).expect("read"); assert_eq!(bytes, b"new"); diff --git a/server/src/session/transcript.rs b/server/src/session/transcript.rs index 019ba04..90a5c32 100644 --- a/server/src/session/transcript.rs +++ b/server/src/session/transcript.rs @@ -41,9 +41,8 @@ impl Transcript { /// the last line if one exists. pub fn open(path: &Path) -> Result { // One pass for all three answers. They are wanted at the same moment - // by the same caller, and reading the file again for each doubled - // the cost of starting every session -- which is paid per session, - // at the point a restart is trying to be quick. + // by the same caller, and reading the file again for each doubled the + // cost of starting every session. let existing = read_after(path, 0)?; let last_seq = existing.last().map(|entry| entry.seq).unwrap_or(0); let last_status = existing.iter().rev().find_map(|entry| match entry.event { @@ -63,9 +62,9 @@ impl Transcript { next_seq: last_seq + 1, last_status, last_activity: existing.last().map(|entry| entry.ts), - // Folded rather than read off the newest usage entry: a clear - // or a compaction after it is what the answer is, and those - // events carry no usage of their own. + // Folded rather than read off the newest usage entry: a clear or + // a compaction after it is what the answer is, and those events + // carry no usage of their own. context_tokens: existing .iter() .fold(None, |current, entry| context_after(current, &entry.event)), @@ -74,53 +73,43 @@ impl Transcript { /// The state the session was last reported to be in, as of opening. /// - /// Read from the file rather than assumed, because a server that has - /// just restarted has been told nothing yet and this is the only thing - /// it knows. Assuming idle claimed a session was waiting for you when - /// it had exited hours earlier, and would now also claim it of one - /// whose process is still mid-turn. + /// Read from the file rather than assumed, because a server that has just + /// restarted has been told nothing. Assuming idle claimed a session was + /// waiting for you when it had exited hours earlier. /// - /// `None` for a transcript that never carried a status, which is a new - /// session and genuinely has no prior state. + /// `None` for a transcript that never carried a status. pub fn last_status(&self) -> Option { self.last_status } /// When this session last did anything, as of opening. /// - /// Read from the file for the same reason [`Transcript::last_status`] - /// is, and it is the same mistake in the other direction: a restarting - /// server has been told nothing, and taking the clock instead said every - /// session it relaunched had been active this second. On the phone that - /// is every row reading "just now" and the list -- which is sorted by - /// this -- coming back in an order that means nothing, with the - /// conversation somebody was in the middle of buried among sessions - /// untouched for days. + /// Read from the file for the reason [`Transcript::last_status`] is, and it + /// is the same mistake in the other direction: taking the clock instead + /// said every session it relaunched had been active this second. On the + /// phone that is every row reading "just now" and the list -- sorted by + /// this -- in an order that means nothing. /// - /// `None` for a transcript with no lines in it, which is a session that - /// genuinely has not done anything yet. Its caller answers that with - /// when the session was created -- not with the clock, which would say - /// a session nobody has ever sent anything to was active a moment ago, - /// every time this server started. + /// `None` for a transcript with no lines, which is a session that genuinely + /// has not done anything. Its caller answers with when the session was + /// created, not with the clock. pub fn last_activity(&self) -> Option { self.last_activity } /// How much context the session was holding, as of opening. /// - /// `None` for a transcript nothing has been measured in -- a new - /// session, one whose dialect never reported usage, or one whose last - /// word on the subject was a clear. That is not zero, and it is why - /// this is an option: a server that has just restarted has been told - /// nothing, and answering zero would draw an empty context for a - /// conversation that may be nearly full. + /// `None` for a transcript nothing has been measured in. That is not zero: + /// a server that has just restarted has been told nothing, and answering + /// zero would draw an empty context for a conversation that may be nearly + /// full. pub fn context_tokens(&self) -> Option { self.context_tokens } /// Appends `event`, assigning it the next sequence number. Flushed per - /// event: each line is tiny, and the transcript is the source of truth - /// a crash must not lose the tail of. + /// event: each line is tiny, and the transcript is the source of truth a + /// crash must not lose the tail of. pub fn append(&mut self, event: Event, ts: f64) -> Result { let entry = SeqEvent { seq: self.next_seq, @@ -139,23 +128,19 @@ impl Transcript { /// A window of the transcript ending just before `before`, newest-biased. /// -/// The screen opens on the end of a conversation, not the start of it, and -/// the end is all it can show at once. Replaying the whole file to get -/// there costs one network frame per event -- on an 863-event import that -/// was several seconds of messages arriving oldest-first, which reads as -/// the app loading top-down because that is exactly what it was doing. +/// The screen opens on the end of a conversation, and the end is all it can +/// show at once. Replaying the whole file to get there costs one network frame +/// per event -- on an 863-event import that was several seconds of messages +/// arriving oldest-first, which reads as the app loading top-down. /// -/// `before` pages backwards for history somebody actually scrolls to. Only -/// the window is parsed; see [`Indexed`] for why that is the whole cost of -/// this call. +/// `before` pages backwards for history somebody actually scrolls to. Only the +/// window is parsed; see [`Indexed`] for why that is the whole cost. /// -/// `after` is a floor: nothing at or below it is returned, and the page -/// stops there rather than at `limit`. A phone holding a cached run of the -/// transcript passes the end of what it already has, so the page it gets -/// back is exactly the gap and never overlaps its copy -- an overlap it -/// cannot store, since a coalesced event cannot be cut at a seq inside its -/// own delta run. A run cut by this floor is emitted as the partial it is, -/// exactly as one cut by `limit` already is. +/// `after` is a floor: nothing at or below it is returned, and the page stops +/// there rather than at `limit`. A phone holding a cached run passes the end of +/// what it already has, so the page is exactly the gap and never overlaps its +/// copy -- an overlap it cannot store, since a coalesced event cannot be cut at +/// a seq inside its own delta run. pub fn read_window( path: &Path, before: Option, @@ -176,11 +161,11 @@ pub fn read_window( }; // A floor above the window is an empty page, not a walk backwards past it. let start = start.min(end); - // Coalescing counts *rows*, not events, and would misread the newest window: a message still - // streaming there would fold to one event whose seq is its first delta, and the phone resumes - // its live stream from the newest seq it applied -- so the deltas the coalesced event hid - // would replay and double. Only settled history (`before` set) is safe, and it is the only - // place the phone asks for it. See `parse_coalesced`. + // Coalescing counts *rows*, not events, and would misread the newest + // window: a message still streaming there would fold to one event whose seq + // is its first delta, and the phone resumes its live stream from the newest + // seq it applied -- so the deltas the coalesced event hid would replay and + // double. Only settled history (`before` set) is safe. if coalesce && before.is_some() { indexed.parse_coalesced(start, end, limit) } else { @@ -191,41 +176,34 @@ pub fn read_window( /// How far behind a reconnecting subscriber can be and still be handed the /// backlog one event at a time. /// -/// Past this it is served better by rebuilding its view from the newest -/// window than by receiving everything it missed. The events are the same -/// either way; what differs is that one arrives as a single window and the -/// other as thousands of frames a screen renders one by one. Set well -/// above a screenful (`transcript`'s page is 80) so an ordinary blip -- a -/// phone asleep, a tunnel reconnecting, a backend restart -- still streams -/// continuously, and only a genuine backlog changes mode. +/// Past this it is served better by rebuilding from the newest window. The +/// events are the same either way; what differs is that one arrives as a single +/// window and the other as thousands of frames a screen renders one by one. Set +/// well above a screenful so an ordinary blip still streams continuously. pub const CATCH_UP_LIMIT: usize = 200; /// What a subscriber asking for "everything after my cursor" gets back. /// -/// Two answers rather than one list, because they mean different things to -/// the screen holding the cursor: one continues what it already has, the -/// other replaces it. Collapsing them into a list would leave the client -/// splicing a window onto rows it has no way to know are no longer -/// adjacent to it -- a seam that looks exactly like ordinary output. +/// Two answers rather than one list, because they mean different things to the +/// screen holding the cursor: one continues what it has, the other replaces it. +/// Collapsing them would leave the client splicing a window onto rows it has no +/// way to know are no longer adjacent -- a seam that looks like ordinary output. #[derive(Debug, Clone, PartialEq)] pub enum CatchUp { /// The events after the cursor, continuing what the subscriber holds. Continue(Vec), - /// The subscriber was further behind than [`CATCH_UP_LIMIT`]: the - /// newest window, replacing whatever it holds. Earlier history is - /// still there to be paged backwards through, exactly as it is when a - /// session is first opened. + /// The subscriber was further behind than [`CATCH_UP_LIMIT`]: the newest + /// window, replacing whatever it holds. Earlier history is still there to be + /// paged backwards through. Restart(Vec), } -/// Everything after `after`, or the newest `limit` when that is more than -/// `limit` events. +/// Everything after `after`, or the newest `limit` when that is more. /// -/// The window is chosen before anything is parsed, which matters most in -/// the case that looks least interesting: a subscriber with no cursor at -/// all asks for the whole conversation and is going to be handed the last -/// [`CATCH_UP_LIMIT`] events of it. Parsing the discarded prefix first is -/// the whole file's worth of work to produce a screenful. +/// The window is chosen before anything is parsed, which matters most in the +/// case that looks least interesting: a subscriber with no cursor asks for the +/// whole conversation and is handed the last [`CATCH_UP_LIMIT`] events of it, +/// so parsing the discarded prefix is the whole file's work for a screenful. pub fn catch_up(path: &Path, after: u64, limit: usize) -> Result { let Some(indexed) = Indexed::read(path)? else { return Ok(CatchUp::Continue(Vec::new())); @@ -249,26 +227,22 @@ pub fn read_after(path: &Path, after: u64) -> Result> { indexed.parse(start..indexed.lines.len()) } -/// The transcript's lines located but not read, so that a reader can find -/// the range it wants and parse only that. +/// The transcript's lines located but not read, so a reader can find the range +/// it wants and parse only that. /// -/// Both readers above want a *range* of the file -- everything after a -/// cursor, or the window before one -- and both used to reach it by parsing -/// every line and discarding the ones outside it. That is the cost that -/// grows with the conversation rather than with the answer: measured on a -/// 21 MB, 24,000-event transcript, one page took **500 ms of server time to -/// return 600 KB**, and it took the same 500 ms whichever page was asked -/// for, since the work was the file rather than the window. A phone paging -/// back through history pays it per page, and every stream reconnect pays -/// it again to discover there is nothing new. +/// Both readers above want a *range* of the file, and both used to reach it by +/// parsing every line and discarding the ones outside it -- the cost that grows +/// with the conversation rather than with the answer. Measured on a 21 MB, +/// 24,000-event transcript, one page took **500 ms of server time to return +/// 600 KB**, and the same 500 ms whichever page was asked for. A phone paging +/// back pays it per page, and every stream reconnect pays it again to discover +/// there is nothing new. /// -/// Sequence numbers only ever increase -- the writer assigns them, one per -/// appended line, continuing from the last on reopen -- so the boundary of -/// a range is a bisection. This parses one line per halving, and the caller -/// parses only what it is going to return. The file is still read whole, -/// which is a deliberate stop: finding the tail without reading forwards -/// means a chunked backwards reader, and locating a line is not what the -/// half-second was going to. +/// Sequence numbers only ever increase, so the boundary of a range is a +/// bisection: this parses one line per halving, and the caller parses only what +/// it returns. The file is still read whole, which is a deliberate stop -- +/// going further means a chunked backwards reader, and locating a line is not +/// what the half-second was going to. struct Indexed<'a> { path: &'a Path, text: String, @@ -302,14 +276,13 @@ impl<'a> Indexed<'a> { Ok(Some(Self { path, text, lines })) } - /// The index of the first line numbered `seq` or higher, or the end - /// when every line is older than that. + /// The index of the first line numbered `seq` or higher, or the end when + /// every line is older than that. /// - /// A bisection, which is only correct because the file is in sequence - /// order; it is append-only and nothing else writes it. A line that - /// cannot be read is reported here rather than silently treated as - /// out of range, because the answer would be a window off by however - /// much of the file the bad line hid. + /// A bisection, which is only correct because the file is in sequence order. + /// A line that cannot be read is reported here rather than silently treated + /// as out of range, because the answer would be a window off by however much + /// of the file the bad line hid. fn first_at_or_after(&self, seq: u64) -> Result { let (mut low, mut high) = (0, self.lines.len()); while low < high { @@ -350,23 +323,20 @@ impl<'a> Indexed<'a> { .with_context(|| format!("bad transcript line in {}", self.path.display())) } - /// The newest `limit` *rows* ending at line `end`, with each run of consecutive streamed - /// [`Event::AssistantText`] deltas concatenated into one. + /// The newest `limit` *rows* ending at line `end`, with each run of + /// consecutive [`Event::AssistantText`] deltas concatenated into one. /// - /// A reply is stored a token at a time -- hundreds of `AssistantText` events for one message -- - /// so a window counted in events is a fraction of a row for a reply and a whole row for a tool - /// call, and the phone can neither predict how much a page will show nor fill a screen without - /// folding a page's worth of near-duplicate events. Counted in rows, a page is a page: this - /// walks back from `end`, joining each delta run into the single event the phone would fold it - /// into anyway, and stops once `limit` of them are gathered. + /// A reply is stored a token at a time, so a window counted in events is a + /// fraction of a row for a reply and a whole row for a tool call, and the + /// phone can neither predict how much a page will show nor fill a screen + /// without folding a page of near-duplicate events. Counted in rows, a page + /// is a page. /// - /// A run takes the seq and time of its *oldest* delta, matching the phone's own rule that a - /// streamed message keeps the seq of its first delta -- so anchors, and the `before` cursor the - /// next page pages from, land where they always did. A run cut by the `limit` (its older - /// deltas beyond this page) is emitted as the partial it is; the next page carries the rest and - /// the phone's `healSplitMessage` welds the two, exactly as it does for a run cut by any page - /// boundary. `start` is the same kind of cut from the other end -- the floor `read_window`'s - /// `after` computes -- and a run reaching it is partial in the same way. + /// A run takes the seq and time of its *oldest* delta, matching the phone's + /// own rule -- so anchors and the `before` cursor land where they always + /// did. A run cut by the `limit` is emitted as the partial it is, and the + /// phone's `healSplitMessage` welds it to the next page. `start` is the same + /// kind of cut from the other end. fn parse_coalesced(&self, start: usize, end: usize, limit: usize) -> Result> { // Newest first while walking back, reversed to transcript order at the end. let mut out: Vec = Vec::new(); @@ -386,9 +356,9 @@ impl<'a> Indexed<'a> { }; let mut index = end; while index > start { - // A row is counted when it lands in `out`; an open run is the row being gathered, so - // stopping while one is open would drop the deltas already read. Break only between - // rows, and flush the last run after the loop. + // A row is counted when it lands in `out`; an open run is the row + // being gathered, so stopping while one is open would drop the + // deltas already read. Break only between rows. if out.len() >= limit && run.is_none() { break; } @@ -487,9 +457,9 @@ mod tests { assert_eq!(events[0].seq, 6); assert_eq!(events[4].seq, 10); - // Exactly at the limit is still a continuation: the boundary - // belongs to the cheaper answer, so a client is not reset for - // being one event behind the threshold. + // Exactly at the limit is still a continuation: the boundary belongs to + // the cheaper answer, so a client is not reset for being one event + // behind the threshold. assert!(matches!( catch_up(&path, 5, 5).expect("catch up"), CatchUp::Continue(_) @@ -501,8 +471,8 @@ mod tests { let dir = tempfile::tempdir().expect("tempdir"); let path = dir.path().join("transcript.jsonl"); - // Nothing recorded yet: no prior state to report, which is not the - // same as reporting idle. + // Nothing recorded yet: no prior state to report, which is not the same + // as reporting idle. assert_eq!(Transcript::open(&path).expect("open").last_status(), None); let mut transcript = Transcript::open(&path).expect("open"); diff --git a/server/src/session/transport.rs b/server/src/session/transport.rs index cf2262b..5271faf 100644 --- a/server/src/session/transport.rs +++ b/server/src/session/transport.rs @@ -1,23 +1,19 @@ //! Where a session's process runs, and the only place that knows how. //! -//! A driver says *what* to run -- a [`Launch`] -- and hands it here. -//! Whether that becomes a child of this process or an `ssh host …` -//! invocation is settled in this module, so a driver carries no transport -//! knowledge and a second one cannot forget to handle the remote case. It -//! also means the wrapping is honest about drivers that run nothing at -//! all: `EchoDriver` builds no [`Launch`], so there is nothing to wrap and -//! no host for it to appear to honour. +//! A driver says *what* to run -- a [`Launch`] -- and hands it here. Whether +//! that becomes a child of this process or an `ssh host …` invocation is +//! settled in this module, so a driver carries no transport knowledge and a +//! second one cannot forget to handle the remote case. It also means the +//! wrapping is honest about drivers that run nothing at all: `EchoDriver` +//! builds no [`Launch`], so there is no host for it to appear to honour. //! //! The quoting, the forced ssh options and the remote script are -//! `crate::ssh`'s, which this dispatches to. That split is deliberate: -//! this module decides *which* transport, that one knows what a correct -//! ssh invocation is. +//! `crate::ssh`'s: this module decides *which* transport, that one knows what a +//! correct ssh invocation is. //! -//! Known second operation, not built because nothing needs it yet: a -//! managed `llama-server` is spawned as a process but then spoken to over -//! HTTP, so a remote one needs a forwarded port (`ssh -L`) as well. A -//! transport is eventually "run this" plus "reach this port", where the -//! second is a no-op locally. See PLAN.md's SSH section. +//! Known second operation, not built because nothing needs it yet: a managed +//! `llama-server` is spawned as a process but then spoken to over HTTP, so a +//! remote one needs a forwarded port (`ssh -L`) as well. use std::path::{Path, PathBuf}; use std::process::Stdio; @@ -27,11 +23,9 @@ use tokio::process::Child; use crate::config::SshConfig; -/// What a driver needs run in order to exist as a process. -/// -/// Deliberately just the three things every transport can carry. Anything -/// a particular machine needs -- a port, a key, extra ssh options -- is -/// the transport's own configuration, not something a driver states. +/// What a driver needs run in order to exist as a process. Deliberately just +/// the three things every transport can carry; anything a particular machine +/// needs is the transport's own configuration, not something a driver states. pub struct Launch { pub program: String, pub args: Vec, @@ -51,22 +45,20 @@ impl Launch { /// How a launched process's standard streams are connected. /// /// The choice is not the transport's and not the driver's dialect: it is -/// whether the process is expected to outlive this server. A probe is -/// asked a question and answers within one call, so pipes this server -/// drains are right and dying with it is right. A session is a +/// whether the process is expected to outlive this server. A probe answers +/// within one call, so pipes this server drains are right. A session is a /// conversation somebody is having, so its streams live in the session -/// directory where a later run of this server can pick them up again -- -/// see `session::process`. +/// directory where a later run of this server can pick them up. pub enum Streams { /// Pipes owned by this server; the child is killed when they drop. Piped, - /// The same, except that stdin is already open on something this - /// server holds -- the file being copied to another machine. Bytes - /// this process has in memory do not need this: [`Streams::Piped`] - /// gives a pipe to write them into as the child reads. + /// The same, except that stdin is already open on something this server + /// holds -- the file being copied to another machine. Bytes this process has + /// in memory do not need this: [`Streams::Piped`] gives a pipe to write them + /// into as the child reads. PipedFrom(Stdio), - /// Files -- and, for stdin, a fifo the child itself holds open so it - /// never reads EOF -- that outlast this process. + /// Files -- and, for stdin, a fifo the child itself holds open so it never + /// reads EOF -- that outlast this process. Detached { stdin: Stdio, stdout: Stdio, @@ -80,8 +72,7 @@ pub enum Transport { Here, /// Reached with the system `ssh` client. Owns its entry rather than /// borrowing it, so a session keeps working against the config it was - /// spawned with even if the setup is edited afterwards. Carries the - /// setup's name only to say where things are running. + /// spawned with even if the setup is edited afterwards. Ssh { name: String, ssh: SshConfig }, } @@ -97,12 +88,10 @@ impl Transport { } } - /// Starts `launch` with its streams connected as `streams` says. - /// - /// The failure names what to check, and the two transports fail for - /// genuinely different reasons -- a missing ssh client here versus a - /// program that is not on the remote PATH -- so each says its own - /// thing rather than one message hedging between them. + /// Starts `launch` with its streams connected as `streams` says. The failure + /// names what to check, and the two transports fail for genuinely different + /// reasons -- a missing ssh client here versus a program not on the remote + /// PATH -- so each says its own thing. pub fn spawn(&self, launch: &Launch, streams: Streams) -> Result { let host = match self { Self::Here => None, @@ -135,11 +124,9 @@ impl Transport { stderr, } => { command.stdin(stdin).stdout(stdout).stderr(stderr); - // No `kill_on_drop`: outliving this server is the point. - // Its own process group as well, so a signal sent to the - // server's group -- which is how a terminal or a - // supervisor stops it -- does not travel to a session that - // is meant to survive being stopped. + // No `kill_on_drop`: outliving this server is the point. Its own + // process group as well, so a signal sent to the server's group + // does not travel to a session meant to survive being stopped. command.process_group(0); } } @@ -157,13 +144,10 @@ impl Transport { }) } - /// Runs `launch` to completion and returns its stdout, blocking. - /// - /// The synchronous twin of `capture`, for callers that are already on a - /// blocking task and would otherwise need a runtime to ask a machine a - /// question. Both build the invocation the same way -- see - /// `crate::ssh::command` -- so there is still only one description of - /// what running something on another machine means. + /// Runs `launch` to completion and returns its stdout, blocking. The + /// synchronous twin of `capture`, for callers already on a blocking task that + /// would otherwise need a runtime to ask a machine a question. Both build the + /// invocation the same way. pub fn capture_blocking(&self, launch: &Launch) -> Result { let host = match self { Self::Here => None, @@ -189,21 +173,19 @@ impl Transport { /// Runs `launch` with `input` on its stdin and reports everything it /// produced -- stdout as bytes, stderr as text, and the exit status. /// - /// The one description of "run this there, with this on stdin", so - /// that shipping an attachment and writing a file through the explorer - /// are the same operation rather than two. It is also the only capture - /// that hands back the **status**: a script can then answer with an - /// exit code the caller distinguishes (the explorer's write says - /// `exit 3` for "this file is not the one you read"), which - /// [`Transport::capture`] cannot express because it turns every - /// failure into one error. + /// The one description of "run this there, with this on stdin", so that + /// shipping an attachment and writing a file through the explorer are the + /// same operation rather than two. It is also the only capture that hands + /// back the **status**: a script can answer with an exit code the caller + /// distinguishes (the explorer's write says `exit 3` for "this file is not + /// the one you read"), which [`Transport::capture`] cannot express. /// - /// Bytes rather than a `String`, because a file's contents are not - /// text until something has checked, and lossy decoding would replace - /// the evidence that they are not. + /// Bytes rather than a `String`, because a file's contents are not text + /// until something has checked, and lossy decoding would replace the + /// evidence that they are not. /// - /// `Err` means the process could not be started at all; a process that - /// ran and failed is a [`Captured`] with a status saying so. + /// `Err` means the process could not be started at all; a process that ran + /// and failed is a [`Captured`] with a status saying so. pub async fn capture_with_input(&self, launch: &Launch, input: Input) -> Result { let (streams, to_write) = match input { Input::None => (Streams::Piped, None), @@ -212,13 +194,11 @@ impl Transport { }; let mut child = self.spawn(launch, streams)?; if let Some(bytes) = to_write { - // Written from a task rather than before the wait, because the - // child may not read all of it -- the write script exits - // without reading when the file has changed underneath -- and - // a caller blocked on filling a pipe nobody is draining would - // deadlock instead of getting that answer. The broken pipe is - // the expected end of this write, so it is dropped: what - // happened is the exit status below. + // Written from a task rather than before the wait, because the child + // may not read all of it -- the write script exits without reading + // when the file has changed underneath -- and a caller blocked on + // filling a pipe nobody is draining would deadlock instead of getting + // that answer. The broken pipe is the expected end of this write. let mut stdin = child.stdin.take().context("the child has no stdin")?; tokio::spawn(async move { use tokio::io::AsyncWriteExt; @@ -248,11 +228,11 @@ impl Transport { /// What a command is given on its standard input. /// -/// Three cases rather than an `Option` because they are three -/// genuinely different arrangements and only this knows which: nothing to -/// say, bytes this process is holding, or a file it has open. The last one -/// is how a several-hundred-megabyte attachment reaches another machine -/// without passing through this server's memory. +/// Three cases rather than an `Option` because they are three genuinely +/// different arrangements and only this knows which: nothing to say, bytes this +/// process is holding, or a file it has open. The last is how a +/// several-hundred-megabyte attachment reaches another machine without passing +/// through this server's memory. pub enum Input { None, Bytes(Vec), @@ -263,10 +243,9 @@ pub enum Input { pub struct Captured { pub status: std::process::ExitStatus, pub stdout: Vec, - /// Trimmed, and what a failure is reported as: ssh's own refusals and - /// a tool's own message about the file it could not open are both the - /// useful half of why something did not work, and both are written to - /// name the thing. + /// Trimmed, and what a failure is reported as: ssh's own refusals and a + /// tool's own message about the file it could not open are both the useful + /// half of why something did not work. pub stderr: String, } diff --git a/server/src/setups.rs b/server/src/setups.rs index 0937bdb..219850b 100644 --- a/server/src/setups.rs +++ b/server/src/setups.rs @@ -1,50 +1,43 @@ //! Finding out what a machine can run, rather than being told. //! -//! The phone adds a machine by giving connection details; this asks the -//! machine itself which of the known programs it has, and the answer -//! becomes its providers. That is a security property, not a convenience: -//! **no route accepts a command from the phone.** If it did, the enrolled -//! token would be able to introduce arbitrary programs to run on every -//! machine a setup names, and the transport already reaches those over -//! ssh. Here the phone's authority is "add this machine", never "run -//! this". +//! The phone adds a machine by giving connection details; this asks the machine +//! itself which of the known programs it has, and the answer becomes its +//! providers. That is a security property, not a convenience: **no route accepts +//! a command from the phone.** If it did, the enrolled token could introduce +//! arbitrary programs to run on every machine a setup names. //! -//! It is also the better interface. Nobody wants to type an absolute path -//! on a phone keyboard, and a machine that has moved its binaries answers -//! correctly on the next probe without anyone editing anything. +//! It is also the better interface: nobody wants to type an absolute path on a +//! phone keyboard, and a machine that has moved its binaries answers correctly +//! on the next probe. //! -//! The cost is that a program somewhere unusual is invisible. That is a -//! deliberate trade rather than an oversight: the escape hatch is editing -//! `config.ron` on the backend, which is exactly the authority the phone -//! is not being given. +//! The cost is that a program somewhere unusual is invisible. The escape hatch +//! is editing `config.ron` on the backend, which is exactly the authority the +//! phone is not being given. use anyhow::Result; use crate::config::{DriverKind, ProviderConfig}; use crate::session::transport::{Launch, Transport}; -/// What is looked for, and what finding it makes. -/// -/// Extending this is how a new driver becomes discoverable -- one row, not -/// a branch anywhere. The name is what the provider gets called, so it is -/// what the phone shows and what a session stores. +/// What is looked for, and what finding it makes. Extending this is how a new +/// driver becomes discoverable -- one row, not a branch anywhere. The name is +/// what the provider gets called, so it is what the phone shows and what a +/// session stores. const PROBES: &[(&str, &str, DriverKind)] = &[ ("claude-cli", "claude", DriverKind::ClaudeCli), ("local-llama", "llama-server", DriverKind::LlamaCpp), ]; -/// Models offered for a discovered Claude CLI. A shortcut list for the -/// spawn screen, not a restriction -- the field stays free text. +/// Models offered for a discovered Claude CLI. A shortcut list for the spawn +/// screen, not a restriction -- the field stays free text. const CLAUDE_MODELS: &[&str] = &["fable", "opus", "sonnet", "haiku"]; /// Asks `transport`'s machine which of [`PROBES`] it has. /// -/// One round trip rather than one per program: over ssh each would be a -/// separate connection and handshake, and a person waiting on "test this -/// setup" notices. `command -v` is POSIX and a shell builtin, so it works -/// whatever is installed -- and `|| true` keeps a missing program from -/// ending the loop, since the caller wants the whole answer rather than -/// the first failure. +/// One round trip rather than one per program: over ssh each would be a separate +/// connection and handshake. `command -v` is POSIX and a shell builtin, so it +/// works whatever is installed -- and `|| true` keeps a missing program from +/// ending the loop, since the caller wants the whole answer. pub async fn discover(transport: &Transport) -> Result> { let wanted: Vec<&str> = PROBES.iter().map(|(_, binary, _)| *binary).collect(); let script = format!( @@ -55,9 +48,9 @@ pub async fn discover(transport: &Transport) -> Result> { let found = transport.capture(&launch).await.map_err(explain)?; let mut providers = Vec::new(); - // Echo runs inside this server, so it exists exactly where this server - // does and nowhere else. Nothing to probe for, and offering it on a - // remote machine would be a choice that changes nothing. + // Echo runs inside this server, so it exists exactly where this server does + // and nowhere else. Offering it on a remote machine would be a choice that + // changes nothing. if matches!(transport, Transport::Here) { providers.push(ProviderConfig { name: crate::config::ECHO_PROVIDER.to_string(), @@ -78,8 +71,8 @@ pub async fn discover(transport: &Transport) -> Result> { name: (*name).to_string(), kind: *kind, // The resolved path rather than the bare name: PATH under a - // non-interactive ssh session is not the one a person sees - // when they log in, so "it is on my PATH" is not enough. + // non-interactive ssh session is not the one a person sees when they + // log in, so "it is on my PATH" is not enough. command: Some(path.to_string()), models: match kind { DriverKind::ClaudeCli => CLAUDE_MODELS.iter().map(|m| (*m).to_string()).collect(), @@ -92,16 +85,14 @@ pub async fn discover(transport: &Transport) -> Result> { /// Adds what to do to failures whose own wording does not say. /// -/// ssh's messages are written for someone at a terminal on the backend, -/// which is exactly who is not reading this one. Host key verification is -/// the case that matters: **every** machine fails it the first time, -/// because its key is not in `known_hosts` yet -- so without this, adding -/// a machine from the phone looks broken rather than unfinished. +/// ssh's messages are written for someone at a terminal on the backend, which is +/// exactly who is not reading this one. Host key verification is the case that +/// matters: **every** machine fails it the first time, so without this, adding a +/// machine from the phone looks broken rather than unfinished. /// -/// Deliberately not fixed by relaxing the check. `StrictHostKeyChecking` -/// stays at its default, so a first connection is a decision somebody -/// makes on the backend with the key in front of them, rather than -/// something this app quietly accepts on their behalf. +/// Deliberately not fixed by relaxing the check. `StrictHostKeyChecking` stays +/// at its default, so a first connection is a decision somebody makes on the +/// backend with the key in front of them. fn explain(err: anyhow::Error) -> anyhow::Error { let message = format!("{err:#}"); if message.contains("Host key verification failed") { @@ -120,11 +111,9 @@ fn explain(err: anyhow::Error) -> anyhow::Error { err } -/// A short, stable, filename-safe id derived from a label. -/// -/// Derived once when a setup is added and then fixed, so the label stays -/// editable. Collisions are resolved by the caller, which is the only -/// place that knows what already exists. +/// A short, stable, filename-safe id derived from a label. Derived once when a +/// setup is added and then fixed, so the label stays editable. Collisions are +/// resolved by the caller, which is the only place that knows what exists. pub fn id_from(label: &str) -> String { let slug: String = label .chars() @@ -160,25 +149,20 @@ pub fn tidy(value: &str) -> Option { }) } -/// The inverse of [`tidy`]'s expansion: an absolute path under this -/// machine's home, written back as `~/…`. +/// The inverse of [`tidy`]'s expansion: an absolute path under this machine's +/// home, written back as `~/…`, so that a working directory reads on a phone the +/// way it is written by hand. /// -/// So that a working directory reads on a phone the way it is written by -/// hand. `/home/bob/repos/ai-app-2` is most of a line on that screen and -/// almost all of it is the part nobody is reading. -/// -/// Applied only to paths on **this** machine. `$HOME` here says nothing -/// about the home directory of a machine reached over ssh, so a remote -/// path is stored exactly as it was typed -- where a `~` somebody wrote -/// stays a `~`, and the remote shell is what expands it -/// (`ssh::quote_path`). +/// Applied only to paths on **this** machine. `$HOME` here says nothing about +/// the home directory of a machine reached over ssh, so a remote path is stored +/// exactly as it was typed and the remote shell is what expands it. pub fn shorten_home(path: &str) -> String { let Some(home) = std::env::home_dir() else { return path.to_string(); }; let home = home.to_string_lossy(); - // The separator has to be part of the match, or `/home/bobby` would be - // read as a path inside `/home/bob`. + // The separator has to be part of the match, or `/home/bobby` would be read + // as a path inside `/home/bob`. match path.strip_prefix(home.as_ref()) { Some("") => "~".to_string(), Some(rest) if rest.starts_with('/') => format!("~{rest}"), @@ -188,11 +172,10 @@ pub fn shorten_home(path: &str) -> String { /// Runs a launch to completion and returns its stdout as text. /// -/// The common case of [`Transport::capture_with_input`]: nothing on stdin, -/// a failure reported as the machine's own words (ssh's "Permission -/// denied" or "Could not resolve hostname" is the useful half of why a -/// setup cannot be reached), and the output read as text because every -/// caller here is asking a question whose answer is words. +/// The common case of [`Transport::capture_with_input`]: nothing on stdin, a +/// failure reported as the machine's own words (ssh's "Permission denied" is the +/// useful half of why a setup cannot be reached), and the output read as text +/// because every caller here is asking a question whose answer is words. impl Transport { pub async fn capture(&self, launch: &Launch) -> Result { let captured = self @@ -206,9 +189,9 @@ impl Transport { mod tests { use super::*; - /// The two halves of a home-relative path, which have to be inverses: - /// what is stored is what the phone draws, and what the phone sends - /// back is what a process is started in. + /// The two halves of a home-relative path, which have to be inverses: what + /// is stored is what the phone draws, and what the phone sends back is what + /// a process is started in. #[test] fn a_home_path_shortens_and_expands_back() { let Some(home) = std::env::home_dir() else { @@ -220,8 +203,8 @@ mod tests { assert_eq!(shorten_home(&home.to_string_lossy()), "~"); assert_eq!(tidy("~/repos/ai-app-2").as_deref(), Some(full.as_ref())); - // Not a prefix match on the characters: a sibling directory whose - // name merely starts with the home directory's is not inside it. + // Not a prefix match on the characters: a sibling directory whose name + // merely starts with the home directory's is not inside it. let sibling = format!("{}-backup/notes", home.to_string_lossy()); assert_eq!(shorten_home(&sibling), sibling); assert_eq!(shorten_home("/etc/hosts"), "/etc/hosts"); diff --git a/server/src/ssh.rs b/server/src/ssh.rs index e47a511..179ee80 100644 --- a/server/src/ssh.rs +++ b/server/src/ssh.rs @@ -1,45 +1,39 @@ //! Building the command a driver actually spawns -- locally, or wrapped in //! `ssh` when the session names a host to run on. //! -//! The whole point of the session design is that a driver speaks JSONL over -//! a child process's stdio and doesn't care what that child is. A remote -//! session is therefore the identical command with `ssh host …` in front: -//! stdio doesn't care, so nothing downstream of here changes. +//! A driver speaks JSONL over a child process's stdio and doesn't care what +//! that child is, so a remote session is the identical command with `ssh host …` +//! in front. //! //! Uses the system `ssh` client rather than a Rust SSH library, so -//! `~/.ssh/config`, agents, and jump hosts all keep working and there is -//! only one place to configure connections (PLAN.md, rule 23). +//! `~/.ssh/config`, agents and jump hosts all keep working and there is only +//! one place to configure connections. use std::path::{Path, PathBuf}; use std::process::Command; use crate::config::SshConfig; -/// Options forced onto every connection. `BatchMode` makes a missing key -/// fail immediately with a readable message instead of hanging on a -/// password prompt that nothing can answer; the keepalives turn a silently -/// dropped link into a process exit, which the session reports as `exited` -/// rather than appearing to hang forever. +/// Options forced onto every connection. `BatchMode` makes a missing key fail +/// immediately with a readable message instead of hanging on a password prompt +/// nothing can answer; the keepalives turn a silently dropped link into a +/// process exit, which the session reports as `exited` rather than hanging. const SSH_OPTIONS: [&str; 3] = [ "BatchMode=yes", "ServerAliveInterval=30", "ServerAliveCountMax=3", ]; -/// Builds the child process for `program args…`, run in `cwd`, either on -/// this machine (`ssh` absent) or on the machine it describes. +/// Builds the child process for `program args…`, run in `cwd`, either on this +/// machine (`ssh` absent) or on the machine it describes. /// -/// Stdio is left alone: how the streams are connected is the caller's -/// decision and differs by more than the transport does -- a probe wants -/// pipes it will drain, a session wants files that outlive this server -- -/// so `Transport::spawn` applies it rather than this. +/// Stdio is left alone: how the streams are connected is the caller's decision +/// and differs by more than the transport does -- a probe wants pipes it will +/// drain, a session wants files that outlive this server. /// -/// A plain [`std::process::Command`], which `tokio` converts from, because -/// not every caller is async: the usage fetch is blocking by nature (it -/// makes a blocking HTTP call) and reads a file from the same machine on -/// the way, and it should not have to build an ssh invocation of its own -/// to do that. One place knows what a correct invocation is; how it is run -/// is the caller's business. +/// A plain [`std::process::Command`], which `tokio` converts from, because not +/// every caller is async: the usage fetch is blocking by nature and should not +/// have to build an ssh invocation of its own. pub fn command( remote: Option<&SshConfig>, program: &str, @@ -50,22 +44,19 @@ pub fn command( let mut command = Command::new(program); command.args(args); if let Some(cwd) = cwd { - // Expanded here for the same reason `quote_path` expands it on - // the far side: a working directory typed as `~/repos/ai-app` - // has to mean the same thing whichever machine runs it. There - // is no shell in this branch, so nothing else would -- - // `current_dir` would be handed the literal one-character - // directory `~`, and the session would fail to start with an - // error naming a path nobody typed. Only the cwd, matching - // the remote side, where arguments stay literal. + // Expanded here for the same reason `quote_path` expands it on the + // far side: a working directory typed as `~/repos/ai-app` has to + // mean the same thing whichever machine runs it. There is no shell + // in this branch, so nothing else would -- `current_dir` would be + // handed the literal one-character directory `~`. command.current_dir(expand_home(cwd)); } return command; }; let mut command = Command::new("ssh"); - // -T: no pty. This carries JSONL, and a pty would rewrite it (echo, - // CRLF translation, ^C handling) into something the parser can't read. + // -T: no pty. This carries JSONL, and a pty would rewrite it (echo, CRLF + // translation, ^C handling) into something the parser can't read. command.arg("-T"); for option in SSH_OPTIONS { command.args(["-o", option]); @@ -78,9 +69,8 @@ pub fn command( } if let Some(identity) = &ssh.identity_file { command.arg("-i").arg(identity); - // Without this, ssh may offer an agent key first and authenticate - // as somebody else entirely -- silently, and with different - // permissions than intended. + // Without this, ssh may offer an agent key first and authenticate as + // somebody else entirely -- silently, and with different permissions. command.args(["-o", "IdentitiesOnly=yes"]); } command.arg(&ssh.address); @@ -88,11 +78,10 @@ pub fn command( command } -/// The single argument handed to the remote login shell. -/// -/// `exec` so the CLI replaces that shell: the process the connection is -/// attached to is then the CLI itself, and dropping the connection takes -/// it down rather than leaving an orphan behind a live wrapper. +/// The single argument handed to the remote login shell. `exec` so the CLI +/// replaces that shell: the process the connection is attached to is then the +/// CLI itself, and dropping the connection takes it down rather than leaving an +/// orphan behind a live wrapper. fn remote_script(program: &str, args: &[String], cwd: Option<&Path>) -> String { let mut script = String::new(); if let Some(cwd) = cwd { @@ -112,11 +101,9 @@ fn remote_script(program: &str, args: &[String], cwd: Option<&Path>) -> String { /// A path with a leading `~` replaced by this machine's home directory. /// /// The local half of the rule [`quote_path`] states for the remote one, and -/// the two are deliberately the same shape: the tilde is expanded, `~user` -/// is not (there is no portable expansion for another account's home), and -/// nothing else in the path gains a meaning. A machine with no home -/// directory at all leaves the path alone, which fails with the operating -/// system's own message rather than with a guess. +/// deliberately the same shape: the tilde is expanded, `~user` is not, and +/// nothing else in the path gains a meaning. A machine with no home directory +/// leaves the path alone, which fails with the operating system's own message. pub(crate) fn expand_home(path: &Path) -> PathBuf { let Some(rest) = path.to_str().and_then(|p| { if p == "~" { @@ -136,23 +123,19 @@ pub(crate) fn expand_home(path: &Path) -> PathBuf { /// Quotes a path, expanding a leading `~` and nothing else. /// /// [`quote`] is right for every other word crossing to the remote side and -/// wrong for exactly one character. `~` means "expand me", and single -/// quotes are what stop expansion -- so a working directory typed as -/// `~/repos/ai-app` arrived as the literal four-character directory `~`, -/// and the remote shell said it did not exist. Which is true, and reads -/// like the path being wrong rather than the quoting. +/// wrong for exactly one character. `~` means "expand me", and single quotes +/// are what stop expansion -- so a working directory typed as `~/repos/ai-app` +/// arrived as the literal four-character directory `~`, and the remote shell +/// said it did not exist, which reads like the path being wrong. /// -/// `"$HOME"` rather than handing the tilde to the shell unquoted: the -/// variable is expanded, the expansion is not re-split or globbed because -/// it is double-quoted, and everything after it stays single-quoted and -/// literal. So the one character that has to mean something keeps meaning -/// it, and nothing else gains a meaning. `$HOME` is set by every shell -/// this can land in, including the fish login shell on the dev VM, which -/// is why this does not depend on the remote shell being POSIX. +/// `"$HOME"` rather than handing the tilde to the shell unquoted: the variable +/// is expanded, the expansion is not re-split or globbed because it is +/// double-quoted, and everything after it stays single-quoted and literal. +/// `$HOME` is set by every shell this can land in, including the fish login +/// shell on the dev VM, so this does not depend on the remote shell being POSIX. /// -/// `~user` is deliberately not handled: there is no portable expansion for -/// it, and inventing one would mean guessing another account's home -/// directory. It stays literal and fails with the shell's own message. +/// `~user` is deliberately not handled: there is no portable expansion for it, +/// and inventing one would mean guessing another account's home directory. pub(crate) fn quote_path(path: &str) -> String { if path == "~" { return "\"$HOME\"".to_string(); @@ -163,15 +146,13 @@ pub(crate) fn quote_path(path: &str) -> String { } } -/// Single-quotes one word for a POSIX shell. -/// -/// Everything crossing to the remote side goes through here: paths, model -/// names, and prompts-as-arguments are all attacker-adjacent input in a -/// server whose whole job is running commands, and unquoted they would be -/// shell syntax rather than data. +/// Single-quotes one word for a POSIX shell. Everything crossing to the remote +/// side goes through here: paths, model names and prompts-as-arguments are all +/// attacker-adjacent input in a server whose whole job is running commands, and +/// unquoted they would be shell syntax rather than data. pub(crate) fn quote(word: &str) -> String { - // Inside single quotes every character is literal except `'` itself, - // which is closed, escaped, and reopened. + // Inside single quotes every character is literal except `'` itself, which + // is closed, escaped, and reopened. format!("'{}'", word.replace('\'', r"'\''")) } @@ -192,8 +173,8 @@ mod tests { } /// A host with nothing configured but a name to dial, so `~/.ssh/config` - /// decides everything else -- the case that proves this adds no flags of - /// its own when it was not told to. + /// decides everything else -- the case that proves this adds no flags of its + /// own when it was not told to. fn bare_host() -> SshConfig { SshConfig { address: "vm".to_string(), @@ -256,19 +237,17 @@ mod tests { assert!(!rendered.contains(&"IdentitiesOnly=yes".to_string())); } - /// The one character quoting must not swallow. - /// - /// A working directory typed as `~/repos/ai-app` was arriving as the - /// literal directory `~`, and the remote shell reported it missing -- - /// which reads as the path being wrong rather than the quoting being - /// wrong, and cost an evening on exactly that misreading. + /// The one character quoting must not swallow. A working directory typed as + /// `~/repos/ai-app` was arriving as the literal directory `~`, and the + /// remote shell reported it missing -- which reads as the path being wrong + /// rather than the quoting being wrong, and cost an evening. #[test] fn a_leading_tilde_expands_and_nothing_else_does() { assert_eq!(quote_path("~"), "\"$HOME\""); assert_eq!(quote_path("~/repos/ai-app"), "\"$HOME\"/'repos/ai-app'"); - // Only leading, and only its own segment: a tilde anywhere else is - // an ordinary character in a filename, and `~user` has no portable - // expansion so it stays literal and fails with the shell's message. + // Only leading, and only its own segment: a tilde anywhere else is an + // ordinary character in a filename, and `~user` has no portable + // expansion so it stays literal. assert_eq!(quote_path("/tmp/~/x"), "'/tmp/~/x'"); assert_eq!(quote_path("~user/x"), "'~user/x'"); @@ -279,13 +258,11 @@ mod tests { ); } - /// The same character, on the transport with no shell to expand it. - /// - /// The local branch runs the program directly, so a working directory - /// of `~/repos/ai-app` would reach `current_dir` as the literal - /// one-character directory `~` -- a session that fails to start, - /// naming a path nobody typed. The two transports have to agree about - /// what a tilde means or a path is only portable by accident. + /// The same character, on the transport with no shell to expand it. The + /// local branch runs the program directly, so a working directory of + /// `~/repos/ai-app` would reach `current_dir` as the literal one-character + /// directory `~`. The two transports have to agree about what a tilde means + /// or a path is only portable by accident. #[test] fn a_local_cwd_expands_its_tilde_the_same_way() { let Some(home) = std::env::home_dir() else { @@ -306,9 +283,9 @@ mod tests { #[test] fn shell_metacharacters_cross_as_data_not_syntax() { - // Expanding $HOME must not open a door for anything else: the rest - // stays single-quoted, so this remains one absurd path rather than - // three commands. + // Expanding $HOME must not open a door for anything else: the rest stays + // single-quoted, so this remains one absurd path rather than three + // commands. assert_eq!( quote_path("~/'; touch /tmp/pwned; '"), r#""$HOME"/''\''; touch /tmp/pwned; '\'''"#, @@ -320,8 +297,8 @@ mod tests { assert_eq!(quote("$(whoami)"), "'$(whoami)'"); assert_eq!(quote("it's"), r"'it'\''s'"); - // The end-to-end version of the same worry: a working directory - // that tries to close the quote and start a new command. + // The end-to-end version of the same worry: a working directory that + // tries to close the quote and start a new command. let ssh = bare_host(); let evil = Path::new("/tmp/'; touch /tmp/pwned; '"); let rendered = argv(&command(Some(&ssh), "claude", &[], Some(evil))); diff --git a/server/src/usage.rs b/server/src/usage.rs index 929f855..4bcc94a 100644 --- a/server/src/usage.rs +++ b/server/src/usage.rs @@ -3,36 +3,31 @@ //! 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. +//! 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. +//! Two rules learned from others hitting this endpoint: 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. //! -//! One [`UsageProvider`] per paid service, so a second service later is a -//! new impl behind the same snapshot shape, not a parallel screen. +//! One [`UsageProvider`] per paid service, so a second service later is a new +//! impl behind the same snapshot shape, not a parallel screen. //! -//! **Asked of the machine that spends the tokens, not of this one.** A -//! session runs wherever its setup says, so the account being billed is -//! that machine's, and reading this machine's credentials reports on an -//! account that may have run nothing. In the layout this project is aiming -//! at that is not a rounding error: `ai-server` belongs on the host, the -//! host has no `claude` CLI, and the CLI machine is a remote -- so the one -//! set of numbers the screen could show would be the numbers of an account -//! with no sessions. Credentials are therefore read through the session -//! `Transport`, one snapshot per setup that offers Claude. +//! **Asked of the machine that spends the tokens, not of this one.** A session +//! runs wherever its setup says, so the account being billed is that machine's. +//! In the layout this project aims at, `ai-server` is on the host, the host has +//! no `claude` CLI, and the CLI machine is a remote -- so the one set of numbers +//! the screen could show would be an account with no sessions. Credentials are +//! read through the session `Transport`, one snapshot per setup that offers +//! Claude. //! -//! The token is read *to* the backend and the HTTP call is made from here, -//! rather than running the request on the far machine: it needs no tooling -//! there beyond a shell, and it keeps the one place that knows the wire -//! format in one place. The cost is that a remote machine's token is in -//! this process's memory for the length of a fetch, which is the same -//! trust the backend already has over that machine (it can start processes -//! on it). +//! The token is read *to* the backend and the HTTP call is made from here, so +//! the far machine needs nothing beyond a shell and the wire format stays in +//! one place. The cost is that a remote machine's token is in this process's +//! memory for the length of a fetch, which is the same trust the backend +//! already has over that machine. use std::collections::HashMap; use std::sync::Mutex; @@ -54,15 +49,13 @@ const USER_AGENT: &str = "claude-code/2.1.237"; #[serde(rename_all = "camelCase")] pub struct UsageWindow { /// The API's own word for which window this is -- `session` for the - /// five-hour one, `weekly_all`, `weekly_scoped`, or whatever new kind - /// it starts sending. + /// five-hour one, `weekly_all`, `weekly_scoped`, or whatever new kind it + /// starts sending. /// - /// Carried beside the label because a caller that wants one - /// particular window has to be able to ask for it without matching on - /// display text: the label is written for a person, is translated the - /// moment anybody translates this app, and would silently select - /// nothing the day it changes. The session screen's bar picks - /// `session` by this field. + /// Carried beside the label because a caller that wants one particular + /// window has to ask for it without matching on display text: the label is + /// written for a person and would silently select nothing the day it + /// changes. pub kind: String, pub label: String, /// 0-100. @@ -77,13 +70,11 @@ pub struct UsageWindow { /// What came back when a machine was asked about its limits. /// /// Four answers rather than a flag and a message, because the screen has to -/// treat them differently and a reader has to. "Nobody is logged in here" -/// is a machine working exactly as configured -- somebody chose not to put -/// an account on it -- while "I could not reach it" is a fault worth -/// chasing, and "the endpoint refused me" is a third thing that says -/// nothing about the machine at all. Collapsing them into one `error` -/// string made the first look like the last, so a perfectly healthy setup -/// read as broken. +/// treat them differently. "Nobody is logged in here" is a machine working +/// exactly as configured, while "I could not reach it" is a fault worth +/// chasing, and "the endpoint refused me" says nothing about the machine at +/// all. Collapsing them into one `error` string made the first look like the +/// last, so a perfectly healthy setup read as broken. #[derive(Debug, Clone, Serialize, PartialEq)] #[serde(tag = "state", rename_all = "camelCase")] pub enum UsageState { @@ -102,11 +93,11 @@ pub enum UsageState { #[serde(rename_all = "camelCase")] pub struct UsageSnapshot { pub provider: String, - /// Which machine these are the numbers for. The point of the whole - /// module: they belong to an account on a particular box. + /// Which machine these are the numbers for. The point of the whole module: + /// they belong to an account on a particular box. pub setup: String, - /// That machine's current label, resolved when the snapshot is built, - /// so renaming a setup renames it here too. + /// That machine's current label, resolved when the snapshot is built, so + /// renaming a setup renames it here too. pub setup_name: String, #[serde(flatten)] pub state: UsageState, @@ -122,9 +113,9 @@ pub trait UsageProvider: Send + Sync { fn fetch(&self) -> UsageSnapshot; } -/// Reads the numbers behind Claude Code's `/usage` from one machine, using -/// the credentials that machine stores -- nothing to configure, and it -/// reports on exactly the account whose CLI runs the sessions there. +/// Reads the numbers behind Claude Code's `/usage` from one machine, using the +/// credentials that machine stores -- nothing to configure, and it reports on +/// exactly the account whose CLI runs the sessions there. pub struct ClaudeUsage { pub setup: String, pub setup_name: String, @@ -132,9 +123,9 @@ pub struct ClaudeUsage { pub transport: Transport, } -/// Where Claude Code keeps its credentials, as a shell word rather than a -/// path: `$HOME` is expanded by the shell on the machine being asked, -/// which is the only place that knows what it is. +/// Where Claude Code keeps its credentials, as a shell word rather than a path: +/// `$HOME` is expanded by the shell on the machine being asked, which is the +/// only place that knows what it is. const CREDENTIALS: &str = "$HOME/.claude/.credentials.json"; impl ClaudeUsage { @@ -149,12 +140,9 @@ impl ClaudeUsage { } } - /// The machine's stored OAuth token, or which of the two ways of not - /// having one this is. - /// - /// Read through `sh -c` so `$HOME` resolves on the far machine; a path - /// built here would be this machine's home directory, which over ssh - /// is somebody else's. + /// The machine's stored OAuth token, or which of the two ways of not having + /// one this is. Read through `sh -c` so `$HOME` resolves on the far machine; + /// a path built here would be this machine's home directory. fn access_token(&self) -> Result { let launch = Launch::new( "sh", @@ -174,8 +162,8 @@ impl ClaudeUsage { .as_str() .map(String::from) }) - // A file that exists but carries no token is the same situation - // as no file: nobody has logged in here yet. + // A file that exists but carries no token is the same situation as + // no file: nobody has logged in here yet. .ok_or(UsageState::NotLoggedIn) } } @@ -225,19 +213,17 @@ impl UsageProvider for ClaudeUsage { /// Which kind of "no credentials" a failed read was. /// -/// The distinction is the point of having both states. `cat` failing -/// because the file is not there is a machine nobody has logged in on -- -/// a decision somebody made, with nothing to fix. Anything else is a -/// machine this server could not ask, which is a fault and reads as one. +/// The distinction is the point of having both states. `cat` failing because +/// the file is not there is a machine nobody has logged in on -- a decision +/// somebody made, with nothing to fix. Anything else is a machine this server +/// could not ask, which is a fault and reads as one. /// -/// Matched on the shell's own words rather than an exit status because -/// there is only one: `cat` exits 1 for a missing file and ssh exits 255 -/// for a connection it could not make, but the message is what survives -/// being wrapped in `sh -c` and passed back through ssh. +/// Matched on the shell's own words rather than an exit status because there is +/// only one that survives being wrapped in `sh -c` and passed back through ssh. fn why_no_credentials(detail: &str) -> UsageState { - // "No such file or directory" is GNU and BSD coreutils; busybox says - // "can't open". Anything unrecognised is treated as unreachable, - // which is the answer that gets looked at rather than ignored. + // "No such file or directory" is GNU and BSD coreutils; busybox says "can't + // open". Anything unrecognised is treated as unreachable, which is the + // answer that gets looked at rather than ignored. let missing = ["No such file", "no such file", "can't open", "cannot open"]; if missing.iter().any(|phrase| detail.contains(phrase)) { UsageState::NotLoggedIn @@ -248,10 +234,9 @@ fn why_no_credentials(detail: &str) -> UsageState { } } -/// 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. +/// Pulls the `limits` array apart, defensively: entries with no percent are +/// skipped, and 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(); @@ -295,10 +280,10 @@ fn parse_windows(body: &Value) -> Vec { /// Which paid services a machine can be asked about. /// -/// Derived from what the setup says it can run, so a machine with no -/// Claude provider is not asked about Claude limits -- it has none, and a -/// row saying so would be a fact about nothing. A second service later -/// adds a branch here and an impl beside [`ClaudeUsage`], not a screen. +/// Derived from what the setup says it can run, so a machine with no Claude +/// provider is not asked about Claude limits -- it has none, and a row saying +/// so would be a fact about nothing. A second service later adds a branch here +/// and an impl beside [`ClaudeUsage`], not a screen. fn providers_for(setup: &SetupConfig) -> Vec> { let mut found: Vec> = Vec::new(); if setup @@ -315,19 +300,17 @@ fn providers_for(setup: &SetupConfig) -> Vec> { found } -/// The cache in front of whatever machines exist: at most one real fetch -/// per machine per service per [`MIN_POLL_INTERVAL`], no matter how often -/// the phone asks. -/// /// One machine's numbers for one service, and when they were fetched. /// -/// Keyed by the machine and the service rather than by position: the set -/// is no longer fixed at startup -- setups are added, renamed and removed -/// from the phone -- and a positional cache would hand one machine's -/// numbers to another the moment the list shifted. +/// Keyed by the machine and the service rather than by position: the set is not +/// fixed at startup -- setups are added, renamed and removed from the phone -- +/// and a positional cache would hand one machine's numbers to another the +/// moment the list shifted. type Cached = HashMap<(String, &'static str), (Instant, UsageSnapshot)>; #[derive(Default)] +/// The cache in front of whatever machines exist: at most one real fetch per +/// machine per service per [`MIN_POLL_INTERVAL`], however often the phone asks. pub struct UsageMonitor { cache: Mutex, } @@ -337,12 +320,12 @@ impl UsageMonitor { Self::default() } - /// One snapshot per machine that offers a paid service, in the order - /// the machines are configured. + /// One snapshot per machine that offers a paid service, in the order the + /// machines are configured. /// /// Blocking -- call via `spawn_blocking`. Takes the setups rather than - /// holding the manager, so this module stays below the session layer - /// rather than reaching up into it. + /// holding the manager, so this module stays below the session layer rather + /// than reaching up into it. pub fn snapshots(&self, setups: &[SetupConfig]) -> Vec { let mut fresh = Vec::new(); for setup in setups { @@ -351,19 +334,17 @@ impl UsageMonitor { if let Some((fetched, snapshot)) = self.cache.lock().unwrap().get(&key) && fetched.elapsed() < MIN_POLL_INTERVAL { - // Cached numbers, but the machine's *name* is read - // fresh: a rename should show immediately rather than - // waiting out the poll interval it has nothing to do - // with. + // Cached numbers, but the machine's *name* is read fresh: a + // rename should show immediately rather than waiting out a + // poll interval it has nothing to do with. let mut snapshot = snapshot.clone(); snapshot.setup_name = setup.name.clone(); fresh.push(snapshot); continue; } - // Fetched without the lock held: this makes a network call - // per machine, and holding the cache across them would - // serialise every phone asking for the screen behind the - // slowest ssh connection. + // Fetched without the lock held: this makes a network call per + // machine, and holding the cache across them would serialise + // every phone asking for the screen behind the slowest ssh. let snapshot = provider.fetch(); self.cache .lock() @@ -412,8 +393,8 @@ mod tests { assert_eq!(windows[3].resets_at, None); } - /// A setup naming a machine that cannot be dialled, so nothing here - /// touches the network beyond ssh failing to resolve it. + /// A setup naming a machine that cannot be dialled, so nothing here touches + /// the network beyond ssh failing to resolve it. fn unreachable_setup() -> SetupConfig { SetupConfig { id: "far".to_string(), @@ -442,9 +423,9 @@ mod tests { transport: Transport::for_setup(&unreachable_setup()), }; let snapshot = provider.fetch(); - // The distinction the old single `error` string could not make: - // this machine was never reached, which is not the same as a - // machine that answered and has nobody logged in. + // The distinction the old single `error` string could not make: this + // machine was never reached, which is not the same as a machine that + // answered and has nobody logged in. assert!( matches!(snapshot.state, UsageState::Unreachable { .. }), "{:?}", @@ -457,8 +438,8 @@ mod tests { #[test] fn a_missing_credential_file_is_a_choice_and_anything_else_is_a_fault() { - // What a real shell says when nobody has logged in on that - // machine. Nothing to fix, so it must not read as an error. + // What a real shell says when nobody has logged in on that machine. + // Nothing to fix, so it must not read as an error. assert_eq!( why_no_credentials("cat: /home/x/.claude/.credentials.json: No such file or directory"), UsageState::NotLoggedIn @@ -468,16 +449,16 @@ mod tests { UsageState::NotLoggedIn ); - // What ssh says when the machine is not there. Worth chasing, and - // the detail is carried so somebody can. + // What ssh says when the machine is not there. Worth chasing, and the + // detail is carried so somebody can. let refused = why_no_credentials("ssh: connect to host vm port 22: Connection refused"); assert!( matches!(&refused, UsageState::Unreachable { detail } if detail.contains("refused")), "{refused:?}" ); - // Anything unrecognised errs towards the state that gets looked - // at, rather than silently claiming nobody is logged in. + // Anything unrecognised errs towards the state that gets looked at, + // rather than silently claiming nobody is logged in. assert!(matches!( why_no_credentials("something nobody has seen before"), UsageState::Unreachable { .. } From edc39c73711d8538f29557b57a927d1b27730876 Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Fri, 4 Sep 2026 16:20:16 -0400 Subject: [PATCH 3/4] Thin the app's comments The same pass the server had, on the Kotlin side: comments restating what the code says are gone, and the ones recording a measurement, a constraint or an incident are kept but cut to a few lines each. 6540 comment lines to 5674, and 920 lines off the app. Two doc comments had drifted onto the item above the one they describe -- `contextAfter`'s onto `sessionWorking` in Events.kt, and `UsageMonitor`'s equivalent on the server was fixed in the previous commit. Each is back on its own item, which is the only non-comment line this diff moves. The comments are reflowed to the column limit at their own indentation: several were written wide, and ktfmt re-wrapped them into lines holding a single orphan word. `/tmp` script, not kept -- ktfmt is idempotent over the result, which is the check. Left alone deliberately: this codebase's remaining comment density is high because the comments carry things the code cannot say -- what a null means, what a number was measured against, which bug a guard exists for. Of the 238 one-line doc comments in the app, five were pure restatement of the name and were removed; the rest each say something the signature does not. ktfmtFormat, compileDebugKotlin, lintDebug and testDebugUnitTest pass; cargo test (127), clippy --all-targets and fmt still clean. Co-Authored-By: Claude Opus 5 --- .../src/main/kotlin/com/example/aiapp/Ansi.kt | 42 +- .../src/main/kotlin/com/example/aiapp/Api.kt | 241 ++- .../main/kotlin/com/example/aiapp/AppRoot.kt | 71 +- .../kotlin/com/example/aiapp/AskQuestion.kt | 76 +- .../kotlin/com/example/aiapp/Attachment.kt | 7 +- .../kotlin/com/example/aiapp/Attachments.kt | 38 +- .../main/kotlin/com/example/aiapp/Bubble.kt | 19 +- .../main/kotlin/com/example/aiapp/BusyItem.kt | 28 +- .../main/kotlin/com/example/aiapp/Chevron.kt | 9 +- .../kotlin/com/example/aiapp/CodeFence.kt | 45 +- .../main/kotlin/com/example/aiapp/Commands.kt | 14 +- .../kotlin/com/example/aiapp/Compaction.kt | 28 +- .../main/kotlin/com/example/aiapp/CrashLog.kt | 13 +- .../kotlin/com/example/aiapp/DebugStats.kt | 20 +- .../main/kotlin/com/example/aiapp/Dividers.kt | 11 +- .../main/kotlin/com/example/aiapp/Drafts.kt | 17 +- .../kotlin/com/example/aiapp/Durations.kt | 9 +- .../kotlin/com/example/aiapp/EventStream.kt | 13 +- .../main/kotlin/com/example/aiapp/Events.kt | 104 +- .../kotlin/com/example/aiapp/FileEditor.kt | 31 +- .../kotlin/com/example/aiapp/FileLines.kt | 41 +- .../kotlin/com/example/aiapp/FileViewer.kt | 102 +- .../kotlin/com/example/aiapp/FilesScreen.kt | 44 +- .../kotlin/com/example/aiapp/FrameStats.kt | 14 +- .../kotlin/com/example/aiapp/Highlighter.kt | 14 +- .../kotlin/com/example/aiapp/ImportScreen.kt | 198 +-- .../kotlin/com/example/aiapp/Languages.kt | 35 +- .../kotlin/com/example/aiapp/LoadState.kt | 2 +- .../kotlin/com/example/aiapp/MainActivity.kt | 77 +- .../kotlin/com/example/aiapp/MainScreen.kt | 55 +- .../main/kotlin/com/example/aiapp/Markdown.kt | 235 ++- .../kotlin/com/example/aiapp/MarkdownLinks.kt | 72 +- .../com/example/aiapp/MarkdownPieces.kt | 59 +- .../com/example/aiapp/MarkdownSyntax.kt | 97 +- .../kotlin/com/example/aiapp/MemoryNote.kt | 29 +- .../kotlin/com/example/aiapp/ModelName.kt | 17 +- .../kotlin/com/example/aiapp/ModelsScreen.kt | 39 +- .../kotlin/com/example/aiapp/NerdIcons.kt | 88 +- .../kotlin/com/example/aiapp/Notifications.kt | 104 +- .../kotlin/com/example/aiapp/PeerMessage.kt | 31 +- .../com/example/aiapp/PendingAttachments.kt | 29 +- .../kotlin/com/example/aiapp/PinnedCert.kt | 14 +- .../main/kotlin/com/example/aiapp/RawBlock.kt | 11 +- .../com/example/aiapp/ResetCountdown.kt | 17 +- .../kotlin/com/example/aiapp/ScrollAnchor.kt | 28 +- .../kotlin/com/example/aiapp/ServerConfig.kt | 7 +- .../kotlin/com/example/aiapp/SessionAlerts.kt | 58 +- .../kotlin/com/example/aiapp/SessionImage.kt | 57 +- .../com/example/aiapp/SessionListScreen.kt | 125 +- .../kotlin/com/example/aiapp/SessionScreen.kt | 1321 +++++++---------- .../example/aiapp/SessionSettingsDialog.kt | 68 +- .../com/example/aiapp/SessionUsageBar.kt | 67 +- .../com/example/aiapp/SettingsScreen.kt | 33 +- .../kotlin/com/example/aiapp/SetupsScreen.kt | 33 +- .../main/kotlin/com/example/aiapp/Share.kt | 2 +- .../main/kotlin/com/example/aiapp/Sizes.kt | 9 +- .../kotlin/com/example/aiapp/SpawnScreen.kt | 85 +- .../src/main/kotlin/com/example/aiapp/Sse.kt | 22 +- .../main/kotlin/com/example/aiapp/Theme.kt | 109 +- .../kotlin/com/example/aiapp/ToolInput.kt | 33 +- .../main/kotlin/com/example/aiapp/ToolRows.kt | 119 +- .../com/example/aiapp/TranscriptCache.kt | 196 +-- .../com/example/aiapp/TranscriptItems.kt | 241 ++- .../com/example/aiapp/TranscriptList.kt | 31 +- .../com/example/aiapp/TranscriptSource.kt | 36 +- .../com/example/aiapp/TranscriptUnits.kt | 55 +- .../kotlin/com/example/aiapp/UniqueItems.kt | 16 +- .../kotlin/com/example/aiapp/UsageDialog.kt | 63 +- 68 files changed, 2077 insertions(+), 2997 deletions(-) diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Ansi.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Ansi.kt index 2e47cc7..d652645 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Ansi.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Ansi.kt @@ -13,9 +13,8 @@ import androidx.compose.ui.text.style.TextDecoration * * Its own palette rather than the syntax one: a program that prints in red has chosen red, where a * highlighter's colours are this app's reading of somebody else's code. They come out of the same - * Catppuccin values (see `ansiPalette` in `Theme.kt`) so nothing on screen is a colour from - * somewhere else, but the two are not one table and must not become one -- adding a syntax role to - * this list would silently move `ls`'s directory blue. + * Catppuccin values so nothing on screen is a colour from somewhere else, but the two are not one + * table -- adding a syntax role to this list would silently move `ls`'s directory blue. */ data class AnsiPalette( /** Indexes 0-7, then 8-15 bright, in the terminal's own order. */ @@ -30,19 +29,18 @@ data class AnsiPalette( * What a tool printed, with its terminal styling applied and everything else taken out. * * Bash output arrives exactly as the program wrote it, escape sequences included, and drawn - * verbatim those are line noise in the middle of the thing being read: `ESC[0;32m` in front of - * every green word. Stripping them all would be the other half-answer -- colour is often the whole - * of what a diff, a test run or a linter is saying. + * verbatim those are line noise in the middle of the thing being read. Stripping them all would be + * the other half-answer -- colour is often the whole of what a diff or a test run is saying. * - * So the sequences that decide how text *looks* become spans, and every other one is dropped. - * Dropped rather than shown, because the rest move a cursor around a grid this is not: a transcript - * is a scrolling document, and "go to column 40" has no meaning here that is better than nothing. + * So the sequences that decide how text *looks* become spans, and every other one is dropped rather + * than shown: the rest move a cursor around a grid this is not, and "go to column 40" has no + * meaning in a scrolling document. * * A carriage return is honoured the way a terminal honours it: what was written since the last line * break is thrown away and the line starts again. That is what makes a progress bar show its final - * state rather than every state it passed through, which was tens of lines run together. + * state rather than every state it passed through. * - * Not a composable, and the palette is a parameter: this can then be remembered against the text it + * Not a composable, and the palette is a parameter, so this can be remembered against the text it * parsed rather than re-run on every recomposition of the card holding it. */ fun ansiStyled(text: String, palette: AnsiPalette): AnnotatedString { @@ -71,10 +69,9 @@ fun ansiStyled(text: String, palette: AnsiPalette): AnnotatedString { if (final == 'm') sgr = sgr.apply(params, palette) } } - // A bare carriage return rewrites the line. One before a newline is the other half - // of a Windows line ending: it rewrites nothing, and it is dropped rather than kept, - // since that pair is one line break and the return itself would draw as a stray - // control character. + // A bare carriage return rewrites the line. One before a newline is the other half of a + // Windows line ending: it rewrites nothing, and it is dropped rather than kept, since + // that pair is one line break. c == '\r' && text.getOrNull(at + 1) != '\n' -> { flush() dropLine(runs) @@ -82,8 +79,8 @@ fun ansiStyled(text: String, palette: AnsiPalette): AnnotatedString { } c == '\r' -> at++ // Everything printable, plus the two control characters that are layout rather than - // terminal commands. A stray bell or backspace goes for the same reason a cursor - // move does. + // terminal commands. A stray bell or backspace goes for the same reason a cursor move + // does. c >= ' ' || c == '\n' || c == '\t' -> { plain.append(c) at++ @@ -129,9 +126,8 @@ private const val BELL = '\u0007' * Steps over the escape sequence starting at [at], reporting a CSI's parameters and final byte. * * One reader for every kind, because the point is to *leave* them all behind: a sequence this did - * not recognise would otherwise have its body printed as ordinary text, which is worse than the - * escape it was meant to remove. Three shapes -- the CSI (`ESC [ … letter`), the string escapes - * (OSC, DCS, APC, PM) which run to a terminator, and the two-character ones. + * not recognise would otherwise have its body printed as ordinary text. Three shapes -- the CSI + * (`ESC [ … letter`), the string escapes which run to a terminator, and the two-character ones. */ private inline fun skipEscape(text: String, at: Int, onCsi: (String, Char) -> Unit): Int { val next = text.getOrNull(at + 1) ?: return at + 1 @@ -140,9 +136,9 @@ private inline fun skipEscape(text: String, at: Int, onCsi: (String, Char) -> Un var end = at + 2 while (end < text.length && text[end] !in CSI_FINAL) end++ if (end >= text.length) { - // Cut off mid-sequence, which is what a stream that has not finished arriving - // looks like: drop the fragment rather than printing it, and the whole sequence - // arrives with the next delta. + // Cut off mid-sequence, which is what a stream that has not finished arriving looks + // like: drop the fragment rather than printing it, and the whole sequence arrives + // with the next delta. text.length } else { onCsi(text.substring(at + 2, end), text[end]) 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 a5024b0..f1b1466 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt @@ -6,26 +6,23 @@ import java.net.URL import org.json.JSONArray import org.json.JSONObject -// The REST half of the backend's surface (see server/src/routes.rs for the -// table); the SSE half is EventStream.kt. All blocking network calls -- -// invoke from a background dispatcher. Each throws ApiException on failure, -// carrying the server's own explanation where it sent one, since those -// messages are written to be read on this screen. +// The REST half of the backend's surface (see server/src/routes.rs for the table); the SSE half is +// EventStream.kt. All blocking network calls -- invoke from a background dispatcher. Each throws +// ApiException carrying the server's own explanation where it sent one, since those messages are +// written to be read on this screen. -// Shared with EventStream.kt, which connects the same way but then reads -// without a deadline. +// Shared with EventStream.kt, which connects the same way but then reads without a deadline. const val CONNECT_TIMEOUT_MS = 5000 private const val READ_TIMEOUT_MS = 5000 /** * A request that did not produce what it asked for, carrying the server's own wording where it sent - * some -- those messages are written to be read on the screen that made the call. + * some. * * [status] is the HTTP status where there was a response at all, and null where the server was * never reached. Callers that need it need it because the *same* failure is two different things to - * do: a 409 from a write is "somebody else changed this, here are three ways out", where every - * other refusal is a message to show. Nothing should branch on it to decide what to *say* -- the - * message is what says that. + * do: a 409 from a write is "somebody else changed this, here are three ways out". Nothing should + * branch on it to decide what to *say* -- the message is what says that. */ class ApiException(message: String, val status: Int? = null, cause: Throwable? = null) : Exception(message, cause) @@ -33,10 +30,10 @@ class ApiException(message: String, val status: Int? = null, cause: Throwable? = /** * Runs one request against the backend, with the pinned TLS setup, the bearer token, and the * failure translation every call needs. [readBody] gets the connected, already-status-checked - * connection to read from. + * connection. * * @param readTimeoutMs how long to wait on the response body. The SSE stream doesn't come through - * here -- an event stream has no bounded read time (see EventStream.kt). + * here -- an event stream has no bounded read time. */ fun requestFromServer( settings: ServerSettings, @@ -44,9 +41,9 @@ fun requestFromServer( method: String = "GET", jsonBody: String? = null, /** - * A request body written as it is produced -- the upload path. Content type, and a writer - * handed the connection's stream. Sent chunked, since what a writer will produce is not known - * up front and the point is that a file never sits whole in memory on this side. + * A request body written as it is produced -- the upload path. Sent chunked, since what a + * writer will produce is not known up front and the point is that a file never sits whole in + * memory. */ streamBody: Pair Unit>? = null, readTimeoutMs: Int = READ_TIMEOUT_MS, @@ -87,9 +84,8 @@ fun requestFromServer( } catch (e: ApiException) { throw e } catch (e: IOException) { - // Surfacing the real exception (rather than one canned message for - // every failure mode) is what lets this be diagnosed on a device - // with no logcat access. + // Surfacing the real exception rather than one canned message for every failure mode is + // what lets this be diagnosed on a device with no logcat access. throw ApiException( "Couldn't reach the server at ${settings.baseUrl} " + "(${e::class.simpleName}: ${e.message}) -- is ai-server running, and is " + @@ -107,11 +103,9 @@ fun requestFromServer( } } -/** The response body as one JSON object. */ private fun HttpURLConnection.jsonObject(): JSONObject = JSONObject(inputStream.bufferedReader().readText()) -/** The response body as a JSON array of objects, each mapped through [parse]. */ private fun HttpURLConnection.jsonObjects(parse: (JSONObject) -> T): List = JSONArray(inputStream.bufferedReader().readText()).mapObjects(parse) @@ -120,21 +114,16 @@ private fun JSONArray.mapObjects(parse: (JSONObject) -> T): List = private fun JSONArray.strings(): List = (0 until length()).map { getString(it) } -/** Percent-encodes a value going into a query string. */ private fun String.urlEncoded(): String = java.net.URLEncoder.encode(this, Charsets.UTF_8.name()) -// One row of GET /sessions. A session names the machine it runs on and -// which of that machine's providers it runs. +// One row of GET /sessions. A session names the machine it runs on and which of that machine's +// providers it runs. data class SessionSummary( val id: String, /** * Id of the machine this session runs on. Only ever used to *address* that machine -- to pick - * this session's row out of the per-machine usage snapshots for the header's five-hour bar. - * - * It was deliberately left out until 2026-08-29, on the grounds that nothing here addressed a - * setup and holding both the id and the name invited showing the wrong one, which had already - * happened once. Something addresses one now, so the reason lapsed rather than being overruled. - * The guard that replaces it is the rule below: never show this. + * this session's row out of the per-machine usage snapshots. Never shown; [setupName] is what a + * reader sees, and holding both invites showing the wrong one. */ val setup: String, /** The machine's current label. This is the one to display; [setup] is never shown. */ @@ -147,9 +136,8 @@ data class SessionSummary( * provider's kind rather than here from its name. * * What it licenses is narrow, and the delete dialog is worded to match: the driver keeps its - * own record of the conversation somewhere this app's delete does not reach. It is not a - * promise that the file is still there, and re-importing is not a restore -- this app's - * transcript holds things that record does not. + * own record somewhere this app's delete does not reach. It is not a promise that the file is + * still there, and re-importing is not a restore. */ val keepsOwnTranscript: Boolean, /** How much the session asks before acting; null when it was never set. */ @@ -162,35 +150,32 @@ data class SessionSummary( * Whether this session announces itself when it wants attention. * * Reported rather than assumed, for the same reason [permissionMode] is: a switch that draws - * itself from a default is one you can turn off while believing you are reading it. Defaults to - * on when a backend is too old to say, which matches what that backend actually does. + * itself from a default is one you can turn off while believing you are reading it. */ val notify: Boolean, /** * The directory the session works in, or null where it was never given one. * - * Null is not "the home directory": it is the session never having been told, and what the - * process then starts in belongs to whatever launches it. Shown as unset rather than filled in - * with a guess, so a reader changing it is choosing rather than confirming. + * Null is not "the home directory": it is the session never having been told. Shown as unset + * rather than filled in with a guess, so a reader changing it is choosing rather than + * confirming. */ val cwd: String?, /** - * How much context this session is holding, as the server last measured it -- see - * `SessionEvent.UsageDelta`. + * How much context this session is holding, as the server last measured it. * * Null where nothing has been measured: a session that has not run a turn, a provider that does * not report usage, or a clear nobody has run a turn since. That is not zero, and the status * row says so in words rather than drawing an empty context for a conversation that may be - * nearly full. + * full. */ val contextTokens: Long?, /** * The longest edge an image should have when it reaches this session, or null where the * provider has no limit. * - * Null and "a big number" are different answers, and only the first stays true: a provider that - * does not care about size should not be given a threshold this app invented. Decided by the - * server because that is where a provider's kind is known -- see `uploadPickedImage`. + * Null and "a big number" are different answers, and only the first stays true. Decided by the + * server because that is where a provider's kind is known. */ val maxImageEdge: Int?, val status: String, @@ -225,18 +210,16 @@ fun fetchSessions(settings: ServerSettings): List = * * For screens whose controls are *set to* something rather than merely showing it. A screen opened * from a list row carries the row the list last fetched, which is a snapshot: fine for a title, - * wrong for a switch, since a switch drawn from a stale row shows a position that may have been - * changed since -- here or on another device -- and nothing on screen says which. + * wrong for a switch, since a stale row shows a position that may have been changed since. */ fun fetchSession(settings: ServerSettings, sessionId: String): SessionSummary = requestFromServer(settings, "/sessions/$sessionId") { parseSession(it.jsonObject()) } -// What the server offers, so the spawn screen has no hardcoded lists: a -// setup added to the server's config.ron appears here with no app rebuild. +// What the server offers, so the spawn screen has no hardcoded lists: a setup added to the server's +// config.ron appears here with no app rebuild. // -// One list rather than two. A provider only exists on a machine that has -// it installed, so offering machines and providers as independent choices -// would offer pairs that cannot work. +// One list rather than two. A provider only exists on a machine that has it installed, so offering +// machines and providers as independent choices would offer pairs that cannot work. data class Provider(val name: String, val kind: String, val models: List) /** @@ -275,7 +258,7 @@ fun fetchSetups(settings: ServerSettings): List = * A Claude Code session already on a machine, which can be continued here. * * Identified by [id] and never by a path. The server resolves which file that is, so this app has - * no way to ask it to read one -- the same rule that keeps a provider's command out of this client. + * no way to ask it to read one. */ data class Importable( val id: String, @@ -284,19 +267,15 @@ data class Importable( val modified: Double, val lines: Int, /** - * Size of the session file in bytes. - * - * Worth a place on the row because it is the only thing there that predicts what continuing the - * session costs, and the line count does not: these transcripts embed screenshots as base64, so - * a single line can be a megabyte. + * Size of the session file in bytes. Worth a place on the row because it is the only thing + * there that predicts what continuing the session costs, and the line count does not: these + * transcripts embed screenshots as base64, so a single line can be a megabyte. */ val bytes: Long, /** - * Tokens the model was holding at the last turn, or null if no turn has recorded any. - * - * The number that predicts what continuing this session costs. It disagrees with [bytes] in the - * direction that matters: most of a large transcript is usually history from before a - * compaction, which the model is no longer given. + * Tokens the model was holding at the last turn, or null if no turn has recorded any. It + * disagrees with [bytes] in the direction that matters: most of a large transcript is usually + * history from before a compaction, which the model is no longer given. */ val contextTokens: Long?, /** Whether [title] is a name somebody chose rather than the last thing said in the session. */ @@ -305,17 +284,15 @@ data class Importable( * Whether a Claude Code is running this session right now. * * "unknown" is a third answer and not a synonym for "no": the machine may keep no record of - * what is running, and a session that cannot be checked is not a session that is free. The - * server refuses an import of a "yes"; the row says so before you press it. + * what is running. The server refuses an import of a "yes"; the row says so before you press + * it. */ val inUse: String, /** - * What this server is doing to the session right now -- "importing" or "deleting" -- or null - * when nothing is. + * What this server is doing to the session right now -- "importing" or "deleting" -- or null. * * The server's answer rather than the phone's, because the work outlives the screen that asked - * for it: leaving the import list and coming back has to show what is still running, and a - * phone that was asleep or out of range never saw the events that said so. + * for it: a phone that was asleep never saw the events that said so. */ val pending: String?, /** @@ -328,7 +305,7 @@ data class Importable( /** * One frame of `GET /setups/{id}/importable/events`: an operation starting, finishing or failing. * - * [operation] is only set by a start, and [message] only by a failure -- the three states are every + * [operation] is only set by a start and [message] only by a failure -- the three states are every * way an operation can be, and each carries exactly what that state knows. */ data class ImportableChange( @@ -348,20 +325,18 @@ fun parseImportableChange(payload: String): ImportableChange? = message = frame.optString("message").takeIf { it.isNotEmpty() }, ) } catch (_: org.json.JSONException) { - // A frame this build does not understand is not a reason to drop the stream: the listing - // is the truth and will say what happened whatever this missed. + // A frame this build does not understand is not a reason to drop the stream: the listing is + // the truth and will say what happened whatever this missed. null } /** * What a machine has that could be continued. * - * The slowest call this app makes, and it was the only expensive one left on the 5 second default — - * which is how it came to time out against a server that was answering perfectly well. Listing - * means reading every transcript Claude Code has ever written: about four seconds against a - * gigabyte of them before the tunnel adds anything, and that figure grows with every session - * anybody has. A timeout is for a server that has stopped answering, so it is set well clear of how - * long the work takes rather than just above it. + * The slowest call this app makes, and it was the only expensive one left on the 5 second default + * -- which is how it came to time out against a server answering perfectly well. Listing means + * reading every transcript Claude Code has ever written: about four seconds against a gigabyte of + * them before the tunnel adds anything. A timeout is for a server that has stopped answering. */ fun fetchImportable(settings: ServerSettings, setup: String): List = requestFromServer(settings, "/setups/$setup/importable", readTimeoutMs = 60000) { @@ -373,13 +348,11 @@ fun fetchImportable(settings: ServerSettings, setup: String): List = modified = session.optDouble("modified", 0.0), lines = session.optInt("lines", 0), bytes = session.optLong("bytes", 0L), - // Absent means nothing has been measured -- which is not a context of zero, so - // it stays null and the row simply does not claim a figure. + // Absent means nothing has been measured, which is not a context of zero. contextTokens = if (session.isNull("contextTokens")) null else session.optLong("contextTokens").takeIf { it > 0L }, - // Absent means an older backend that cannot answer, which is exactly what - // "unknown" says -- so the default is the honest one rather than "no". + // Absent means an older backend that cannot answer, which is what "unknown" says. inUse = session.optString("inUse", "unknown"), named = session.optBoolean("named", false), pending = session.optString("pending").takeIf { it.isNotEmpty() }, @@ -528,8 +501,7 @@ fun sendMessage( * * Throws rather than returning an outcome, because both ways of failing are things the reader has * to be told: 409 means the session was already given it, and 404 means nothing is waiting under - * that id. The bubble disappearing is the success case and it arrives on the event stream, not from - * here -- every device drops it, not only the one that tapped. + * that id. The bubble disappearing arrives on the event stream, so every device drops it. */ fun unqueueMessage(settings: ServerSettings, sessionId: String, messageId: String) { requestFromServer( @@ -543,13 +515,11 @@ fun unqueueMessage(settings: ServerSettings, sessionId: String, messageId: Strin /** * Moves a session to a different working directory. * - * The server checks the directory is there on that machine and refuses if it is not -- a mistyped - * path accepted here would surface much later, as a session that would not start, with nothing - * pointing at the typo. + * The server checks the directory is there and refuses if it is not -- a mistyped path accepted + * here would surface much later, as a session that would not start. * * Its process is **stopped**, because a working directory is settled when the process is spawned. - * The next thing said to the session starts it again in the new one, which is this app's rule for a - * session with no process everywhere else. + * The next thing said to the session starts it again in the new one. */ fun setSessionCwd(settings: ServerSettings, sessionId: String, cwd: String) { requestFromServer( @@ -593,8 +563,8 @@ fun uploadAttachment( write(out) out.write(tail) }, - // Long: a trace is hundreds of megabytes, and the server copies it on to a remote - // machine before answering. + // Long: a trace is hundreds of megabytes, and the server copies it on to a remote machine + // before answering. readTimeoutMs = 600000, ) { connection -> connection.jsonObject().getString("id") @@ -605,8 +575,7 @@ fun uploadAttachment( * One entry of a directory on the machine a setup names. * * [kind] is the *target's* where the entry is a symlink, so a link to a directory descends; [link] - * still says it is one. Neither is worked out here -- the machine answers both, because it is the - * only thing that can. + * still says it is one. Neither is worked out here -- the machine answers both. */ data class DirEntry( val name: String, @@ -625,10 +594,10 @@ data class Listing(val path: String, val entries: List) /** * What reading a file produced. * - * Four cases, because they are four different things to draw and none of them is an error the - * screen can shrug off: content, something that is not text, something too big to have sent, and - * (as [ApiException], not a case here) the machine's own refusal. A file with nothing in it is - * [FileContent.Text] with an empty string -- which is what it is, and not the same as any of these. + * Four cases, because they are four different things to draw and none is an error the screen can + * shrug off: content, something that is not text, something too big to have sent, and (as + * [ApiException]) the machine's own refusal. A file with nothing in it is [FileContent.Text] with + * an empty string, which is what it is. */ sealed class FileContent { abstract val path: String @@ -688,9 +657,8 @@ fun fetchFile(settings: ServerSettings, setup: String, path: String): FileConten requestFromServer( settings, "/setups/${setup.urlEncoded()}/file?path=${path.urlEncoded()}", - // A megabyte over the tunnel, and a `stat` plus a `sha256sum` on the far machine before - // any of it moves. Well clear of that rather than just above it -- a timeout is for a - // server that has stopped answering. + // A megabyte over the tunnel, and a `stat` plus a `sha256sum` on the far machine before any + // of it moves. Well clear of that rather than just above it. readTimeoutMs = 60000, ) { connection -> val body = connection.jsonObject() @@ -709,8 +677,7 @@ fun fetchFile(settings: ServerSettings, setup: String, path: String): FileConten "binary" -> FileContent.Binary(at, size, modified) "tooBig" -> FileContent.TooBig(at, size, modified) // A backend that has learned a fifth answer. Reported rather than guessed at: picking - // the nearest of the four would draw something confident about a state this app has - // never seen. + // the nearest of the four would draw something confident about a state never seen. else -> throw ApiException( "The server described this file as \"$kind\", which this app does not know how to show." @@ -721,9 +688,8 @@ fun fetchFile(settings: ServerSettings, setup: String, path: String): FileConten /** * Replaces a file's contents, but only while it still hashes to [ifSha256]. * - * The refusal is a 409 and arrives as an [ApiException] carrying the server's wording, which is - * what the conflict dialog shows -- an agent editing the same file while somebody reads it is the - * ordinary case here, not the exotic one. + * The refusal is a 409 carrying the server's wording, which is what the conflict dialog shows -- an + * agent editing the same file while somebody reads it is the ordinary case here. */ fun writeFile( settings: ServerSettings, @@ -770,7 +736,6 @@ fun createDir(settings: ServerSettings, setup: String, path: String) { ) {} } -/** Fetches an image the transcript references (produced or uploaded). */ fun fetchSessionFile(settings: ServerSettings, sessionId: String, name: String): ByteArray = requestFromServer(settings, "/sessions/$sessionId/files/$name", readTimeoutMs = 30000) { it.inputStream.readBytes() @@ -779,10 +744,9 @@ fun fetchSessionFile(settings: ServerSettings, sessionId: String, name: String): // One rate-limit window, rendered as a labeled bar on the usage screen. data class UsageWindow( /** - * The API's own word for which window this is -- "session" for the five-hour one. - * - * How to find a particular window. The label beside it is written for a person to read, so - * matching on it would select nothing the day its wording changes. + * The API's own word for which window this is -- "session" for the five-hour one. The label + * beside it is written for a person to read, so matching on it would select nothing the day its + * wording changes. */ val kind: String, val label: String, @@ -801,8 +765,8 @@ data class UsageSnapshot( * 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. + * machine somebody chose not to put an account on -- a fact, not a fault. Collapsing them made + * a healthy setup read as broken. */ val state: String, /** Why, for the two states that are faults. Absent otherwise. */ @@ -818,8 +782,8 @@ fun fetchUsage(settings: ServerSettings): List = provider = snapshot.getString("provider"), 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. + // 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 = @@ -840,8 +804,7 @@ fun fetchUsage(settings: ServerSettings): List = * Answers one question with everything that was chosen. * * A list even when one thing was picked, because that is the shape of the answer rather than a - * special case of it. What a provider makes of several answers is its own business and is decided - * on the server; nothing here joins, splits or reformats them for one. + * special case of it. What a provider makes of several answers is decided on the server. */ fun answerQuestion( settings: ServerSettings, @@ -868,9 +831,8 @@ fun interruptSession(settings: ServerSettings, sessionId: String) { /** * Ends the process behind a session, leaving the session and its transcript. * - * Not a delete and not an interrupt: the conversation stays exactly where it is and [startSession] - * picks it back up. The server reports what it could not do -- there was nothing running, or the - * machine would not say whether there was -- rather than answering the same way either way. + * Not a delete and not an interrupt: the conversation stays where it is and [startSession] picks it + * back up. The server reports what it could not do rather than answering the same way either way. */ fun stopSession(settings: ServerSettings, sessionId: String) { requestFromServer(settings, "/sessions/$sessionId/stop", method = "POST") {} @@ -888,12 +850,10 @@ fun startSession(settings: ServerSettings, sessionId: String) { * caller confirms first; see ImportScreen. * * The work runs on the server, so this returning is not the same as it being done -- what says that - * is each row's own state, through [fetchImportable] and the change stream. That is the point: - * leaving the screen used to cancel the delete it had started. + * is each row's own state. That is the point: leaving the screen used to cancel the delete. * - * One request for the whole batch, which is what makes a handover all-or-nothing. Sending one per - * row meant a batch could half-arrive -- four deleted, two never asked for -- and the two that were - * missed looked exactly like two that had not been picked. + * One request for the whole batch, which is what makes a handover all-or-nothing. One per row meant + * a batch could half-arrive, and the rows that were missed looked exactly like rows not picked. */ fun deleteImportable(settings: ServerSettings, setup: String, sessionIds: List) { requestFromServer( @@ -910,8 +870,7 @@ fun deleteImportable(settings: ServerSettings, setup: String, sessionIds: List> { val query = buildString { @@ -970,9 +927,8 @@ fun fetchTranscript( } return requestFromServer(settings, "/sessions/$sessionId/transcript$query") { connection -> val body = JSONArray(connection.inputStream.bufferedReader().readText()) - // The text as well as the event: the transcript cache stores the one and the fold needs - // the other, and they have to be the same line -- a second entry point differing only in - // return type would be two answers to one question. + // The text as well as the event: the transcript cache stores the one and the fold needs the + // other, and they have to be the same line. (0 until body.length()).map { val line = body.getJSONObject(it).toString() line to parseSeqEvent(line) @@ -986,7 +942,7 @@ fun fetchTranscript( * The name is the backend's own -- it is what the list shows and it exists before any process does * -- so this settles it rather than asking. Where the thing running the session has a name of its * own, the backend passes it on, which is what makes a session the same session in Claude Code's - * picker and to any other agent that lists it. + * picker. */ fun renameSession(settings: ServerSettings, sessionId: String, title: String) { requestFromServer( @@ -1078,10 +1034,9 @@ fun deleteSession(settings: ServerSettings, sessionId: String, deleteForeign: Bo requestFromServer(settings, "/sessions/$sessionId$query", method = "DELETE") {} } -// Models: what this backend has downloaded, what it is downloading, and -// what HuggingFace offers. Browsing is proxied by the server rather than -// done here, because this app trusts exactly one certificate and has no -// general internet trust to spend on huggingface.co. +// Models: what this backend has downloaded, what it is downloading, and what HuggingFace offers. +// Browsing is proxied by the server rather than done here, because this app trusts exactly one +// certificate and has no general internet trust to spend on huggingface.co. data class LocalModel(val key: String, val repo: String, val file: String, val bytes: Long) 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 ae3a9c4..c1b000a 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/AppRoot.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/AppRoot.kt @@ -29,11 +29,9 @@ import kotlinx.coroutines.withContext * One `when` rather than a navigation library: a handful of screens, with [Screen.Main] as the root * and the back button the only other way between them. * - * Import, models and setups are not here any more. They are tabs inside [MainScreen] -- four views - * of the same backend, none of them a step down from another -- and what is left in this `when` is - * only what genuinely is a step down: one session, spawning one, and settings. A session's own - * settings are not among them: they are a dialog over the session, which is where the thing they - * change is. + * Import, models and setups are tabs inside [MainScreen] -- four views of the same backend, none of + * them a step down from another -- and what is left here is only what genuinely is a step down: one + * session, spawning one, and settings. */ private sealed class Screen { data object Main : Screen() @@ -43,10 +41,8 @@ private sealed class Screen { * * The explorer is a layer on this screen rather than a screen of its own, so the session under * it stays composed: its event stream keeps flowing, its scroll position and draft stay put, - * and coming back from a file costs nothing. As a sibling `Screen` it would be disposed and - * re-created on every return, refetching the transcript over the tunnel -- which is exactly the - * flip between "what did it change" and "what is it saying" that this feature exists for. The - * image viewer already made the same choice for the same reason. + * and coming back from a file costs nothing. As a sibling `Screen` it would be disposed and re- + * created on every return, refetching the transcript over the tunnel. */ data class Session(val summary: SessionSummary, val files: FilesTarget? = null) : Screen() @@ -60,7 +56,7 @@ private sealed class Screen { * * The notification names an id and nothing else, so opening it means fetching the session first. * [serial] tells two taps on the same session's notification apart, since they are two requests and - * would otherwise compare equal -- see MainActivity, which counts them. + * would otherwise compare equal. */ data class SessionOpenRequest(val sessionId: String, val serial: Int) @@ -68,8 +64,8 @@ data class SessionOpenRequest(val sessionId: String, val serial: Int) private data class FailedOpen(val request: SessionOpenRequest, val message: String) /** - * [settingsVersion] bumps when enrollment lands via an `aiapp://` intent (see MainActivity), - * re-reading the stored settings -- a plain `remember` would keep serving the pre-enrollment null. + * [settingsVersion] bumps when enrollment lands via an `aiapp://` intent (see MainActivity), re- + * reading the stored settings -- a plain `remember` would keep serving the pre-enrollment null. * * [openRequest] is the session a notification tap asked for, likewise from MainActivity. * @@ -89,8 +85,8 @@ fun AppRoot( // A notification tap this could not follow, and why. Null both before one is asked for and // after one succeeds, since success is a screen rather than a message. var failedOpen by remember { mutableStateOf(null) } - // Bumped whenever another screen changes something the list shows, so - // returning to it refetches instead of showing a stale list. + // Bumped whenever another screen changes something the list shows, so returning to it + // refetches. var reloadToken by remember { mutableIntStateOf(0) } // Cleared by the session screen that attached it, not when a newer request arrives: a share // must be attached exactly once, and only the screen that did it knows that it has. @@ -104,9 +100,8 @@ fun AppRoot( } } - // A standing condition rather than a per-request failure, so it is - // stated once here instead of appended to every error that might be - // caused by it. Without this the app is simply unreachable and every + // A standing condition rather than a per-request failure, so it is stated once here instead of + // appended to every error it might cause. Without this the app is simply unreachable and every // screen blames the server or the tunnel for it. if (!localNetworkAllowed(context)) { Text( @@ -121,8 +116,8 @@ fun AppRoot( val current = settings if (current == null) { - // Not enrolled yet: settings is the only usable screen. The QR - // path lands in MainActivity and recomposes from the top. + // Not enrolled yet: settings is the only usable screen. The QR path lands in MainActivity + // and recomposes from the top. Box(Modifier.imePadding()) { SettingsScreen( existing = null, @@ -136,10 +131,9 @@ fun AppRoot( return } - // The one way back, whichever screen is showing and whether it was - // reached by the system back gesture or a screen's own Back button. - // Every leaf screen can have changed something the list shows, so it - // always refetches. + // The one way back, whichever screen is showing and whether it was reached by the system back + // gesture or a screen's own Back button. Every leaf screen can have changed something the list + // shows, so it always refetches. val goToMain = { reloadToken++ screen = Screen.Main @@ -149,8 +143,8 @@ fun AppRoot( } // Turning a notification into the screen it points at. The id has to be resolved to a session - // first, because that is what SessionScreen is given -- and unlike a list row, which is a - // snapshot the list already fetched, there is nothing here to seed it from. + // first, because that is what SessionScreen is given -- and unlike a list row, there is nothing + // here to seed it from. // // A failure is reported rather than swallowed: somebody deliberately tapped a notification, so // an app that opens to the session list with no explanation looks like the tap missed. @@ -180,10 +174,9 @@ fun AppRoot( ) } - // Every screen but the session takes the keyboard as bottom padding here. The session - // screen deliberately does not: resizing a whole screen on every frame of the keyboard - // animation is the cost that made it lag, so it moves only its composer and transcript -- - // see the layout note in SessionScreen. + // Every screen but the session takes the keyboard as bottom padding here. The session screen + // deliberately does not: resizing a whole screen on every frame of the keyboard animation is + // the cost that made it lag, so it moves only its composer and transcript. when (val here = screen) { is Screen.Main -> Box(Modifier.imePadding()) { @@ -202,15 +195,14 @@ fun AppRoot( } is Screen.Session -> // Keyed on the id, because a different session is a different screen rather than this - // one showing other rows. SessionScreen remembers a transcript, an open event stream, a - // draft and a scroll position, and without the key Compose keeps all of it across the - // change and merges two conversations -- which crashes the list on the first duplicate - // row key. Only reachable since a notification can move straight from one session to - // another; every other way here passes through [Screen.Main], which disposes it anyway. + // one showing other rows. SessionScreen remembers a transcript, an open stream, a draft + // and a scroll position, and without the key Compose keeps all of it across the change + // and merges two conversations -- which crashes the list on the first duplicate row + // key. Only reachable since a notification can move straight from one session to + // another. key(here.summary.id) { - // A Box so the explorer can be drawn *over* the session rather than instead of - // it; the session stays composed underneath. No imePadding here, for the reason - // above -- the explorer adds its own, since it has a text field. + // A Box so the explorer can be drawn *over* the session rather than instead of it. + // No imePadding here, for the reason above -- the explorer adds its own. Box { SessionScreen( settings = current, @@ -257,8 +249,7 @@ fun AppRoot( // Last, so it draws over the screen above rather than under it: these are stacked in the Box // the activity puts around this, and that Box paints in the order it was given. A session - // wanting attention is not a fact about the page somebody happens to be on, so it is not the - // page's job to leave room for it. Tapping one is the same act as tapping a notification, so - // it goes through the same `open`, failure dialog included. + // wanting attention is not a fact about the page somebody happens to be on. Tapping one is the + // same act as tapping a notification, so it goes through the same `open`. SessionAlerts(onOpen = { request -> scope.launch { open(request) } }) } diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/AskQuestion.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/AskQuestion.kt index 3e09769..6bf3d11 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/AskQuestion.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/AskQuestion.kt @@ -41,13 +41,12 @@ data class QuestionAnswer(val questionId: String, val answers: List) * What the reader has settled on for one question, before any of it is sent. * * Held here rather than inferred from the transcript, which is what made picking an option feel - * broken: the mark used to appear only when the answer had crossed the tunnel, been recorded and - * come back as an event, so on a phone the card sat unchanged for most of a second after a tap and - * the natural response was to tap again. + * broken: the mark used to appear only when the answer had crossed the tunnel and come back as an + * event, so the card sat unchanged for most of a second after a tap. * * Picked options and typed words are one field each because they are alternatives rather than - * parts: answering in the reader's own words is the case no option covers, so typing puts the picks - * away and picking puts the words away, and there is never a draft that means two things. + * parts: typing puts the picks away and picking puts the words away, so there is never a draft that + * means two things. */ data class Draft(val picked: Set = emptySet(), val other: String = "") { val settled: Boolean @@ -65,29 +64,26 @@ data class Draft(val picked: Set = emptySet(), val other: String = "") { /** * Every question one tool call is waiting on, one at a time. * - * All of it comes from the question events themselves -- what each option means, what picking it - * would produce, whether several may be picked at once. None of it is read out of the call's own + * All of it comes from the question events themselves. None of it is read out of the call's own * input, which is one provider's JSON: parsing that here would put that provider's schema in the * app, where no other provider can reach it and where it drifts the first time the schema moves. * * One question on screen with arrows to the others, rather than all of them stacked. A card asking * three questions with four options and a description each is several screens tall, so the reader - * scrolls past the question they are answering to reach the button that sends it, and never sees - * the whole of any one of them. Paged, each question is a screen and the count says how many are - * left -- which is also what makes "not all of them are answered" something the reader can act on - * rather than something to go hunting for. + * scrolls past the question they are answering to reach the button that sends it. Paged, each + * question is a screen and the count says how many are left. * * Nothing is sent until Submit. Answering is one act even when it is several questions: the tool - * asked them together and is waiting on all of them, and sending each as it was tapped meant the - * reader could not change their mind about the first after reading the third. + * asked them together, and sending each as it was tapped meant the reader could not change their + * mind about the first after reading the third. */ @Composable fun AskUserQuestionBody( asks: List, onAnswer: (List, onSettled: () -> Unit) -> Unit, ) { - // Seeded from what was already answered, so a card the reader comes back to shows their - // answers rather than an empty draft over them. + // Seeded from what was already answered, so a card the reader comes back to shows their answers + // rather than an empty draft over them. var drafts by remember(asks.map { it.id }) { mutableStateOf( @@ -125,7 +121,7 @@ fun AskUserQuestionBody( modifier = Modifier.weight(1f), ) // Disabled at the ends rather than absent, so the pair keeps its place and the - // reader can see that there is nothing further that way. + // reader can see there is nothing further that way. MarkButton("Previous question", { at-- }, enabled = at > 0) { Chevron(Pointing.Left, colour = LocalContentColor.current) } @@ -143,7 +139,7 @@ fun AskUserQuestionBody( if (outstanding.isNotEmpty()) { Spacer(Modifier.height(12.dp)) // Greyed until every question has an answer, because the tool is waiting on all of - // them: a submit that sent two of three would leave the third one asked and the card + // them: a submit that sent two of three would leave the third asked and the card // looking dealt with. val ready = outstanding.all { drafts[it.id]?.settled == true } Button( @@ -155,8 +151,8 @@ fun AskUserQuestionBody( } ) { // Back to a button whatever happened. A refusal is reported by the screen - // around this, and the draft is still here to send again -- a spinner - // that never stops would be the only sign of a failure this card cannot + // around this, and the draft is still here to send again -- a spinner that + // never stops would be the only sign of a failure this card cannot // describe. sending = false } @@ -165,8 +161,8 @@ fun AskUserQuestionBody( modifier = Modifier.fillMaxWidth(), ) { if (sending) { - // In the button rather than beside it, so the row does not change height at - // the moment it is pressed. + // In the button rather than beside it, so the row does not change height at the + // moment it is pressed. CircularProgressIndicator( Modifier.height(18.dp).width(18.dp), strokeWidth = 2.dp, @@ -186,8 +182,7 @@ fun AskUserQuestionBody( * One question: what is being asked, what can be answered, and what was. * * The same body wherever a question appears -- on the call that asked it, or as a card of its own - * when nothing did. A question is the same thing either way, and two renderings of it would be two - * places for an answer to go missing. + * when nothing did. Two renderings of it would be two places for an answer to go missing. * * [draft] is what the reader has picked so far and [onDraft] is how they change it; nothing here * sends anything. An answered question ignores both and draws what was answered. @@ -214,10 +209,10 @@ fun AskedQuestion( // replacing them with a line repeating it. The options are what the question *was*, and // dropping them leaves an answer with nothing to have been an answer to -- "Sonnet" says // very little without the three it was chosen over. Marked in the same purple that says - // "picked" while the question is still open, so it is one appearance learned once. + // "picked" while the question is open, so it is one appearance learned once. val answered = ask.answers.isNotEmpty() - // What is marked: what was answered once there is an answer, and what the finger has - // chosen until then. + // What is marked: what was answered once there is an answer, and what the finger has chosen + // until then. val marked = if (answered) ask.answers.toSet() else draft.picked // Null once the question is answered: the options stay and stop being pressable. val onPick: ((String) -> Unit)? = @@ -233,9 +228,9 @@ fun AskedQuestion( } } } - // What was answered in the reader's own words, which no option can mark -- see - // [OtherAnswer]. Only ever the answers that match nothing offered, so a question answered - // by picking says it by the mark alone. + // What was answered in the reader's own words, which no option can mark. Only ever the + // answers that match nothing offered, so a question answered by picking says it by the + // mark. val inWords = ask.answers.filterNot { answer -> ask.options.any { it.label == answer } } if (inWords.isNotEmpty()) { Text( @@ -252,10 +247,8 @@ fun AskedQuestion( } /** - * [label] added to, or taken out of, what [draft] has picked. - * - * A single-answer question replaces rather than accumulates, and either way picking puts any typed - * words away -- see [Draft]. + * [label] added to, or taken out of, what [draft] has picked. A single-answer question replaces + * rather than accumulates, and either way picking puts any typed words away -- see [Draft]. */ private fun pick(draft: Draft, label: String, multiSelect: Boolean): Draft = when { @@ -269,8 +262,7 @@ private fun pick(draft: Draft, label: String, multiSelect: Boolean): Draft = * * Outlined rather than tinted. Drawn first as a card one step up the surface ladder, it was * indistinguishable from the card behind it -- three paragraphs of text where three things to press - * should have been, which is the failure a tint step routinely produces on a dark theme. A border - * is one cue and it is unambiguous. + * should have been. A border is one cue and it is unambiguous. */ @Composable private fun OptionCard(option: QuestionOption, selected: Boolean, onPick: () -> Unit) { @@ -324,8 +316,8 @@ private fun Preview(preview: String) { preview, style = MaterialTheme.typography.bodySmall, fontFamily = FontFamily.Monospace, - // Not wrapped: these are mockups and diffs, where a wrapped line reads as two lines - // of the thing being previewed. + // Not wrapped: these are mockups and diffs, where a wrapped line reads as two lines of + // the thing being previewed. softWrap = false, modifier = Modifier.padding(8.dp).horizontalScroll(rememberScrollState()), ) @@ -336,8 +328,7 @@ private fun Preview(preview: String) { * The choice the asker always leaves open, and the app has to as well. * * Every AskUserQuestion carries an implicit "Other" -- the reader may answer in their own words - * rather than pick. Leaving it out narrows a question that was never that narrow, and the reader - * cannot tell that it was ever open. + * rather than pick. Leaving it out narrows a question that was never that narrow. */ @Composable private fun OtherAnswer(text: String, onText: (String) -> Unit) { @@ -358,8 +349,7 @@ private fun OtherAnswer(text: String, onText: (String) -> Unit) { * * A Row hands out intrinsic widths in order and clips whatever runs past the edge, so a question * with four options showed the first one or two and dropped the rest off the side of the screen. - * That does not read as a bug: it reads as those having been the only choices, which is the worst - * way for a list of choices to be wrong. + * That reads as those having been the only choices. */ @Composable fun AnswerOptions( @@ -379,8 +369,8 @@ fun AnswerOptions( OutlinedButton( onClick = { onPick?.invoke(option.label) }, // Disabled rather than removed, so an answered question still shows what it - // offered. Material dims a disabled button's own border and label, which would - // take the mark with it -- both are stated here instead. + // offered. Material dims a disabled button's own border and label, which would take + // the mark with it -- both are stated here instead. enabled = onPick != null, border = BorderStroke( diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Attachment.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Attachment.kt index 4fbf975..b53d82e 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Attachment.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Attachment.kt @@ -28,8 +28,8 @@ fun attachmentName(ref: String): String = ref.substringAfter('-', ref) /** * One attachment on a sent message, drawn as what it is: an image inline, a file as its name. A - * file is not fetched -- there is nothing on this phone to open a trace or a log with -- so the - * name is the whole of it. + * file is not fetched -- there is nothing on this phone to open a trace with -- so the name is all + * of it. */ @Composable fun Attachment( @@ -50,8 +50,7 @@ fun Attachment( /** * A file's name, one line, in the face names are read in. Overlong names lose their middle: a name - * is identified by both ends -- what it is at the front, what kind at the back -- and either - * ellipsis alone takes away one of them. + * is identified by both ends -- what it is at the front, what kind at the back. */ @Composable fun FileName(name: String, modifier: Modifier = Modifier) { diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Attachments.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Attachments.kt index f37f3f5..2a00cf2 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Attachments.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Attachments.kt @@ -20,10 +20,8 @@ import kotlin.math.max * to be either thrown away or rejected -- which is what "sending an image is broken" was. * * Shrunk here rather than on the backend, so the bytes that never mattered are never sent: the - * expensive part of this on a phone is the upload, not the decode. What the limit *is* comes from - * the server, per session -- see `DriverKind::max_image_edge` -- because that is where a provider's - * requirements are known, and a phone that carried its own copy of them would be a second place to - * update when one changes. + * expensive part on a phone is the upload, not the decode. What the limit *is* comes from the + * server, per session, because that is where a provider's requirements are known. */ suspend fun uploadPickedImage( context: Context, @@ -53,17 +51,16 @@ suspend fun uploadPicked( if (mime != null && mime.startsWith("image/")) { return uploadPickedImage(context, settings, sessionId, uri, maxEdge) } - // Opened before the request starts, so a provider that refuses says so here and not from - // inside the connection; then streamed, since a trace or a log is bigger than this process - // should hold at once. + // Opened before the request starts, so a provider that refuses says so here and not from inside + // the connection; then streamed, since a trace is bigger than this process should hold at once. val source = openSource(resolver, uri) val name = displayName(resolver, uri) return uploadAttachment(settings, sessionId, mime ?: "application/octet-stream", name) { out -> try { source.use { it.copyTo(out, COPY_BUFFER) } } catch (e: java.io.IOException) { - // Either side of the copy can fail; the message names the file, which is the - // part the reader can do something about. + // Either side of the copy can fail; the message names the file, which is the part the + // reader can do something about. throw ApiException("couldn't send $name: ${e.message}", cause = e) } } @@ -76,7 +73,7 @@ private const val COPY_BUFFER = 64 * 1024 * * A share arrives with whatever access the other app granted, and a provider that refuses says so * with a `SecurityException`; a file gone between the pick and the read is an `IOException`. Both - * are things the reader can act on, so neither is left to end the process. + * are things the reader can act on. */ private fun openSource(resolver: ContentResolver, uri: Uri): java.io.InputStream = try { @@ -110,9 +107,9 @@ private fun displayName(resolver: ContentResolver, uri: Uri): String { /** * The bytes to upload and what they are, scaled down only if they need to be. * - * An image already inside the limit is uploaded exactly as it came, rather than decoded and - * re-encoded to the same size: a round trip through JPEG loses a little every time, and there is - * nothing to gain from it. This is also the path a provider with no limit always takes. + * An image already inside the limit is uploaded exactly as it came, rather than decoded and re- + * encoded to the same size: a round trip through JPEG loses a little every time. This is also the + * path a provider with no limit always takes. */ private fun readForUpload(context: Context, uri: Uri, maxEdge: Int?): Pair { val resolver = context.contentResolver @@ -128,9 +125,9 @@ private fun readForUpload(context: Context, uri: Uri, maxEdge: Int?): Pair 0f } } catch (_: java.io.IOException) { - // No EXIF, or none this can read. Upright is the assumption every - // image without the tag is displayed under anyway. + // No EXIF, or none this can read. Upright is the assumption every image without the tag is + // displayed under anyway. 0f } diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Bubble.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Bubble.kt index 2da613f..a2e72f9 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Bubble.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Bubble.kt @@ -8,18 +8,16 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Shape import androidx.compose.ui.unit.dp -// The composer's row of settings and pickers, and the menus they open. One file because the -// outline and the corner are one appearance: a control shaped like this opens a surface shaped -// like this, and a reader learns the pair once. +// The composer's row of settings and pickers, and the menus they open. One file because the outline +// and the corner are one appearance: a control shaped like this opens a surface shaped like this. /** * A bordered pill: a control that can be seen without being pressed. * * The composer's row -- attach, model, permission mode -- was text buttons, which draw nothing at - * all until they are touched. Three bare words sitting under the message field read as a caption - * about the field rather than as three things to press, and the only way to find out otherwise was - * to press one. The outline says "control" without the weight of a filled button, which is reserved - * here for the two that act on the session (send, and start/stop). + * all until they are touched. Three bare words under the message field read as a caption about the + * field rather than as three things to press. The outline says "control" without the weight of a + * filled button, which is reserved for the two that act on the session. */ @Composable fun BubbleButton( @@ -32,8 +30,8 @@ fun BubbleButton( onClick = onClick, enabled = enabled, shape = BubbleShape, - // A text button's padding rather than a filled button's 24dp: these sit three across - // under the message field, and the wider padding is what decides whether the row fits. + // A text button's padding rather than a filled button's 24dp: these sit three across under + // the message field, and the wider padding is what decides whether the row fits. contentPadding = ButtonDefaults.TextButtonContentPadding, modifier = modifier, ) { @@ -48,7 +46,6 @@ val BubbleShape: Shape = RoundedCornerShape(percent = 50) * The corner on a menu one of these opens. * * A radius rather than [BubbleShape]'s half-height: a menu is as tall as its options, and rounding - * ends that tall would bow its sides. This is the roundest corner that still leaves a straight edge - * beside a one-line option, which is the shortest menu here. + * ends that tall would bow its sides. */ val BubbleMenuShape: Shape = RoundedCornerShape(20.dp) diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/BusyItem.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/BusyItem.kt index 67786cb..6dfad69 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/BusyItem.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/BusyItem.kt @@ -26,20 +26,16 @@ import androidx.compose.ui.unit.dp * of the operation over it. * * One composable rather than a pattern each list repeats, because "this row is busy" has to look - * the same in the import list and the session list or the appearance becomes a per-screen dialect - * rather than something the reader learns once. + * the same in the import list and the session list or the appearance becomes a per-screen dialect. * * [label] names the operation and `null` means none is running. One parameter rather than a boolean - * beside a string, which can disagree: there is no such thing as busy with nothing happening. It is - * a *word* because a spinner alone cannot say which operation this is — deleting and importing are - * different in kind, and losing a session to the wrong one is not recoverable by waiting. + * beside a string, which can disagree. It is a *word* because a spinner alone cannot say which + * operation this is -- deleting and importing are different in kind. * * It does **not** make the row inert; the caller disables its own click handling while it passes a - * label. That was the other way round at first — an overlay consuming pointer events, so no caller - * had to remember — and it swallowed the drag along with the tap, which meant a list could not be - * scrolled while anything in it was busy. Consuming taps but not drags means re-deciding what a - * gesture is above the components that already decide it; disabling the click is the platform's own - * answer and leaves the scroll where it belongs. + * label. That was the other way round at first -- an overlay consuming pointer events -- and it + * swallowed the drag along with the tap, so a list could not be scrolled while anything in it was + * busy. */ @Composable fun BusyItem(label: String?, content: @Composable () -> Unit) { @@ -71,14 +67,12 @@ fun BusyItem(label: String?, content: @Composable () -> Unit) { /** * How an item looks while it is being acted on: darker, and nearly grey. * - * Both, rather than either alone. Dimming by itself is what this app already used for a row on its - * way out, and it is the same cue as a disabled control, so a busy row read as one more thing that - * could not be tapped. Draining the colour is what says the row is *suspended* — the status word, - * the accent on a warning and everything else that means something by its colour stop meaning it - * for as long as the operation runs, which is exactly true: none of them is being kept up to date. + * Both, rather than either alone. Dimming by itself is the same cue as a disabled control, so a + * busy row read as one more thing that could not be tapped. Draining the colour is what says the + * row is *suspended* -- the status word and everything else that means something by its colour stop + * meaning it for as long as the operation runs, which is exactly true. * - * Not all the way to grey. A row with no colour left is hard to find again in a list, and the - * reader is watching this one. + * Not all the way to grey: a row with no colour left is hard to find again in a list. */ private fun Modifier.busy(busy: Boolean): Modifier = if (!busy) this diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Chevron.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Chevron.kt index 36cda0c..088312a 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Chevron.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Chevron.kt @@ -27,13 +27,10 @@ enum class Pointing { * * One composable for all four directions rather than one per axis that differ by which coordinate * gets the minus sign -- the copies would drift, and the drift would be a bug in exactly one - * direction. The shape is written once in its own coordinates, where x runs across the opening and - * y runs from the open side to the tip, and [Pointing] is only a table of how those two map onto - * the box. + * direction. The shape is written once in its own coordinates, and [Pointing] is only a table of + * how those map onto the box. * - * It draws no label of its own, so every caller owes it a `contentDescription`: this is the whole - * of what assistive technology has to go on, and it is also the answer to "what was that arrow for" - * six months from now. + * It draws no label of its own, so every caller owes it a `contentDescription`. */ @Composable fun Chevron( diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/CodeFence.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/CodeFence.kt index 5bcf9ad..072f4e7 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/CodeFence.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/CodeFence.kt @@ -30,14 +30,12 @@ import org.intellij.markdown.ast.getTextInNode * sits on, scrolling sideways rather than wrapping. * * The renderer's own fence drew the same block in plain text. The scanner that colours a tool - * call's command colours a reply's code the same way, through [highlighted] and one palette, so a - * `kotlin` fence and the Kotlin a tool wrote are the same colours. A fence in a language [scan] has - * no rules for is plain rather than wrongly coloured: [fenceLanguage] answers null for those, and - * plain is what the reader would have seen before. + * call's command colours a reply's code the same way, so a `kotlin` fence and the Kotlin a tool + * wrote are the same colours. A fence in a language [scan] has no rules for is plain rather than + * wrongly coloured. * * Finding the code is still the library's: which children of the node are the fence markers, the - * language word and the code between them is its knowledge of the parser, and [MarkdownCodeFence] - * hands out the code and the language and leaves the drawing to the block it is given. + * language word and the code between them is its knowledge of the parser. */ @Composable fun CodeFence( @@ -67,14 +65,12 @@ fun CodeBlock( /** * The code inside a fence or indented block, and the highlighter's language for its info word. * - * Which children of the node are the fence markers, the language word and the code between them is - * the library's knowledge of the parser, copied from its `MarkdownCodeFence` rather than called: - * that one is a composable, and the whole point of this function is that [warm] can run it on a - * background thread and highlight the same string the drawing will ask for. Two extractions would - * be two keys, and the warmed answer would be silently missed at every fence. + * Copied from the library's `MarkdownCodeFence` rather than called: that one is a composable, and + * the whole point here is that [warm] can run this on a background thread and highlight the same + * string the drawing will ask for. Two extractions would be two keys, and the warmed answer would + * be silently missed at every fence. * - * Null for a fence too short to hold anything -- an unterminated one still arriving, which the - * library skips as invalid. + * Null for a fence too short to hold anything -- an unterminated one still arriving. */ fun fenceContent(content: String, node: ASTNode): Pair? { val word = @@ -97,7 +93,6 @@ fun fenceContent(content: String, node: ASTNode): Pair? { * * The renderer's own block, less what nothing here needs: the same background, corner, padding and * sideways scroll, without the shadow, the border and the empty pointer handler it also carried. - * The vertical margin is the renderer's too, kept so a reply's fences sit where they always have. */ @Composable private fun CodeBlockText( @@ -117,8 +112,7 @@ private fun CodeBlockText( .semantics { isTraversalGroup = true } ) { BasicText( - // No language while the block is still being written, which is what draws it plain; - // see [MarkdownRoot]'s `streaming`. + // No language while the block is still being written, which is what draws it plain. replies.highlighted(code, language.takeUnless { streaming }), style = style, modifier = Modifier.horizontalScroll(rememberScrollState()).padding(padding.codeBlock), @@ -141,14 +135,12 @@ fun fenceLanguage(name: String?): Language? = * The highlighter's language for a *file*, from its name. * * The same table [fenceLanguage] reads, deliberately: it already keys on the extensions people - * write after the backticks -- `kt`, `rs`, `py` -- because the extension is as often what gets - * written there as the language's name. One table rather than two, so a language added for fences - * is a language added for files and neither can be the one somebody forgot. + * write after the backticks. One table rather than two, so a language added for fences is a + * language added for files and neither can be the one somebody forgot. * - * The extension is the part after the *last* dot, which is what makes `build.gradle.kts` Kotlin and - * `Cargo.toml` TOML. A leading dot is not one: `.bashrc` has no extension, it has a name that - * starts with a dot, and reading `bashrc` as an extension would look up a word no table has. A name - * with no dot at all -- `Makefile`, `LICENSE` -- is likewise null, and null is drawn plain. + * The extension is the part after the *last* dot, which is what makes `build.gradle.kts` Kotlin. A + * leading dot is not one: `.bashrc` has no extension, it has a name that starts with a dot. A name + * with no dot at all -- `Makefile` -- is likewise null, and null is drawn plain. */ fun fileLanguage(name: String): Language? { val dot = name.lastIndexOf('.') @@ -206,10 +198,9 @@ private val FENCE_LANGUAGES: Map = ) /** - * Every fence in [parse], as the code and language [highlight] will be asked for. - * - * Walks the whole tree rather than the top level: a fence inside a list item or a quote is drawn - * the same way and costs the same to lex. + * Every fence in [parse], as the code and language [highlight] will be asked for. Walks the whole + * tree rather than the top level: a fence inside a list item or a quote is drawn the same way and + * costs the same to lex. */ fun fences(parse: State): List> { val success = parse as? State.Success ?: return emptyList() diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Commands.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Commands.kt index fe2a7a6..f8da571 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Commands.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Commands.kt @@ -25,8 +25,7 @@ import androidx.compose.ui.unit.dp * These are the two this app understands, and understanding them is what lets it show them: a * suggestion while one is being typed, a name in the settings screen that sends one, and a bubble * that stays up while the session is too busy to run it. Anything else beginning with "/" is passed - * through to whatever runs the session, because a dialect's own vocabulary is its own and grows - * without this list -- it just arrives unannounced and unexplained. + * through, because a dialect's own vocabulary grows without this list. */ data class SessionCommand( /** With the slash, as it is typed and as it is sent. */ @@ -90,8 +89,8 @@ fun CommandSuggestions( verticalAlignment = Alignment.CenterVertically, ) { Text( - // The command in the colour commands are, so the suggestion and the - // bubble it becomes are visibly the same thing. + // The command in the colour commands are, so the suggestion and the bubble + // it becomes are visibly the same thing. if (command.argument == null) command.name else "${command.name} <${command.argument}>", style = MaterialTheme.typography.titleSmall, @@ -117,8 +116,7 @@ fun CommandSuggestions( * anything appearing here. * * [waiting] is a command the session is too busy to run yet, which is a state with a spinner and a - * reason: pressing Compact in the middle of a long turn otherwise does nothing visible for minutes - * and reads as having been missed. + * reason: pressing Compact in the middle of a long turn otherwise does nothing visible for minutes. */ @Composable fun CommandBubble(text: String, waiting: Boolean = false) { @@ -128,8 +126,8 @@ fun CommandBubble(text: String, waiting: Boolean = false) { modifier = Modifier.align(Alignment.CenterEnd).padding(start = 48.dp), ) { Column(Modifier.padding(12.dp)) { - // Stated beside the fill rather than inherited: a semantic colour has to carry - // its own contrast, because the surface under it will not change to rescue it. + // Stated beside the fill rather than inherited: a semantic colour has to carry its + // own contrast, because the surface under it will not change to rescue it. Text(text, color = MaterialTheme.colorScheme.inverseOnSurface) if (waiting) { Spacer(Modifier.height(6.dp)) diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Compaction.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Compaction.kt index 7ed3e64..0b199b9 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Compaction.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Compaction.kt @@ -7,12 +7,10 @@ import androidx.compose.ui.Modifier * The mark a compaction leaves in the transcript. * * A divider rather than something anybody said: everything above it is out of the session's context - * now, and that is a fact about the conversation, not a turn in it. It has no collapsed form -- it - * is already one line, and there is nothing behind it to open. Drawn by [TranscriptDivider], which - * a clear also uses, so the two marks cannot drift apart. + * now, and that is a fact about the conversation, not a turn in it. Drawn by [TranscriptDivider], + * which a clear also uses, so the two marks cannot drift apart. * - * Blue is [commandColor]: the session acting on itself rather than working on what was asked of it, - * which is the same thing the status line says while the compaction runs. + * Blue is [commandColor]: the session acting on itself rather than working on what was asked of it. */ @Composable fun CompactedRow(item: TranscriptItem.CompactedNote, modifier: Modifier = Modifier) { @@ -23,9 +21,8 @@ fun CompactedRow(item: TranscriptItem.CompactedNote, modifier: Modifier = Modifi * What to say about a compaction: the two sizes, and nothing else. * * The counts are the whole point -- "a million tokens became ten thousand" is the reader's answer - * to why the wait was worth it -- and they are all this says, because a divider is read in passing. - * When they were not reported this says only that a compaction happened, rather than filling in a - * plausible number or explaining at length what was missing. + * to why the wait was worth it. When they were not reported this says only that a compaction + * happened, rather than filling in a plausible number. */ fun compactionSummary(item: TranscriptItem.CompactedNote): String { val pre = item.preTokens @@ -41,8 +38,8 @@ fun compactionSummary(item: TranscriptItem.CompactedNote): String { * A token count as a reader reads one. * * Shared with the status row rather than formatted at each: the divider and the row report the same - * quantity about the same moment, and one of them grouping its thousands while the other did not - * read as two different measurements. + * quantity about the same moment, and one grouping its thousands while the other did not read as + * two different measurements. */ fun tokens(count: Long): String = "%,d".format(count) @@ -50,15 +47,12 @@ fun tokens(count: Long): String = "%,d".format(count) * What the working indicator says while a compaction is running. * * Elapsed time and nothing else, because elapsed time is all there is: the CLI announces that a - * compaction has begun and then says nothing until it has finished, so any bar, percentage or - * estimate here would be this screen's guess wearing a measurement's clothes. Knowing it has been - * going forty seconds is what a reader actually wants -- it is the difference between waiting and - * going to look at why. + * compaction has begun and then says nothing until it has finished, so any bar or estimate here + * would be this screen's guess wearing a measurement's clothes. * * [seconds] is null when this device did not see the compaction start, which is what opening a - * session that is already compacting looks like. That case says only "compacting": no number is the - * honest answer, and a number counted from the moment the screen opened would be wrong in the - * direction that matters, since a compaction somebody is asking about is a long one. + * session that is already compacting looks like. That case says only "compacting": a number counted + * from the moment the screen opened would be wrong in the direction that matters. */ fun compactingLabel(seconds: Long?): String = when { diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/CrashLog.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/CrashLog.kt index a174267..8ef3037 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/CrashLog.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/CrashLog.kt @@ -12,10 +12,9 @@ import java.util.Locale * The last crash, kept so the debug button can hand it over. * * The alternative is asking somebody to reproduce a crash with the phone plugged into a computer - * and `logcat` running, which is the one thing nobody has set up at the moment it happens -- and a - * crash report that arrives a day later, without the stack, is a guess. This costs one file write - * on a process that is already dying, and it turns "it crashes when I open that chat" into the - * frame it crashed in. + * and `logcat` running, which is the one thing nobody has set up at the moment it happens. This + * costs one file write on a process that is already dying, and it turns "it crashes when I open + * that chat" into the frame it crashed in. * * Kept until it is read rather than cleared on the next launch: the app restarts before anybody can * ask about it, so a log that lives for one session is a log that is never read. @@ -26,8 +25,7 @@ private const val CRASH_FILE = "last-crash.txt" * How much of a stack is kept. * * This is pasted into a conversation, so it has a budget like any other output written for a - * reader. The top of a stack is what identifies a crash and the bottom is framework plumbing, so - * what gets cut is the part nobody reads. + * reader. The top of a stack is what identifies a crash and the bottom is framework plumbing. */ private const val CRASH_LIMIT = 4000 @@ -35,8 +33,7 @@ private const val CRASH_LIMIT = 4000 * Records uncaught exceptions, then lets the platform do what it was going to do. * * Chained rather than replacing: the default handler is what shows the "app has stopped" dialog and - * ends the process, and an app that swallows that instead sits there in an unknown state. This only - * adds a witness. + * ends the process, and an app that swallows that instead sits there in an unknown state. */ fun installCrashLog(context: Context) { val app = context.applicationContext diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/DebugStats.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/DebugStats.kt index dcc85ea..d1d7b48 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/DebugStats.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/DebugStats.kt @@ -12,10 +12,9 @@ import java.util.concurrent.atomic.AtomicLong * * Here because the emulator cannot answer the question this is for. Its own scroll sits at the same * frame times as the stock Settings app -- 21ms at the median for both -- so every app-level cost - * is under the floor of what it can measure, and a frame number taken in it says nothing about a - * 120Hz phone. Counts do not have that problem: how many times a row was composed, or a reply - * parsed, is the same number on any machine, and it is the number that says whether the work is - * proportional to what is on screen or to everything ever loaded. + * is under the floor of what it can measure. Counts do not have that problem: how many times a row + * was composed, or a reply parsed, is the same number on any machine, and it is the number that + * says whether the work is proportional to what is on screen or to everything ever loaded. * * Always on rather than behind a build flag. What is measured is an atomic increment on paths that * already allocate lists and parse markdown, and a counter that is only compiled into the build @@ -90,14 +89,12 @@ object DebugStats { * * The draw phase is where Compose's measurement lands as well as its recording -- the platform * calls `measureAndLayout()` from `dispatchDraw` -- so "draw is high" has never said which of three - * different things is high. The transcript times its own measure, its own placement and its own - * recording, and this is the subtraction that was otherwise done by hand in a conversation every - * time a report arrived. What is left over is the framework's per-frame bookkeeping after a layout, - * which grows with how many nodes are alive rather than with how many are on screen. + * different things is high. The transcript times its own measure, placement and recording, and this + * is the subtraction. What is left over is the framework's per-frame bookkeeping after a layout, + * which grows with how many nodes are alive rather than how many are on screen. * * Per frame rather than in total, because the budget it has to fit in is per frame. The recordings - * are not themselves per-frame -- a measurement happens on the frames that need one -- so these are - * shares of an average frame, not a claim about any particular one. + * are not themselves per-frame, so these are shares of an average frame. */ fun drawAccounting(drawNanos: Long, frames: Int): List { if (frames == 0 || drawNanos == 0L) return emptyList() @@ -122,8 +119,7 @@ fun drawAccounting(drawNanos: Long, frames: Int): List { * frames went, and what the app did to produce them. * * Written for somebody to paste into a conversation, so it is plain text with the units on every - * number -- a report whose reader has to ask what the columns mean costs another round trip, and - * the whole point of it is to save one. + * number -- a report whose reader has to ask what the columns mean costs another round trip. */ fun debugReport( device: String, diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Dividers.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Dividers.kt index 92633c3..01a13ab 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Dividers.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Dividers.kt @@ -21,11 +21,7 @@ import androidx.compose.ui.unit.dp * reader scrolling back, both mean "the session no longer has what is above this", and which of the * two it was is said by the words and the colour. * - * The rules take [color] too, so the whole divider reads as one mark of one kind rather than a - * coloured phrase sitting in an unrelated grey line. - * - * Written once here rather than styled at each of them, so the two cannot drift into looking like - * different kinds of thing. + * The rules take [color] too, so the whole divider reads as one mark of one kind. */ @Composable fun TranscriptDivider(text: String, color: Color, modifier: Modifier = Modifier) { @@ -44,9 +40,8 @@ fun TranscriptDivider(text: String, color: Color, modifier: Modifier = Modifier) * The mark a clear leaves. * * Red, and no counts: a clear takes the conversation out of what the session is given, and unlike a - * compaction it summarises nothing and measures nothing, so there is nothing to report but the - * fact. Everything above stays on screen and stays scrollable -- the reader can see that, which is - * why this does not say it. + * compaction it summarises nothing and measures nothing. Everything above stays on screen and stays + * scrollable -- the reader can see that, which is why this does not say it. */ @Composable fun ClearedRow(modifier: Modifier = Modifier) { diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Drafts.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Drafts.kt index 7ba2635..75f7c0c 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Drafts.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Drafts.kt @@ -10,12 +10,11 @@ private const val DRAFTS = "session-drafts" * * On this device rather than on the backend, which is where this app otherwise keeps state so that * every device sees it. A draft is the case that rule is not about: it is the contents of a text - * box on the phone somebody is holding, written on every keystroke, and half a sentence surfacing - * on another device would be a surprise rather than a convenience. What has been *sent* is the - * server's, and that is the part which has to outlive this phone. + * box on the phone somebody is holding, and half a sentence surfacing on another device would be a + * surprise. What has been *sent* is the server's. * - * Kept per session id, because the thing being typed belongs to the conversation it is aimed at: - * one shared box would hand a message meant for one session to whichever was opened next. + * Kept per session id: one shared box would hand a message meant for one session to whichever was + * opened next. */ fun loadDraft(context: Context, sessionId: String): String = context.getSharedPreferences(DRAFTS, Context.MODE_PRIVATE).getString(sessionId, "").orEmpty() @@ -23,11 +22,9 @@ fun loadDraft(context: Context, sessionId: String): String = /** * Records [text] as the draft for [sessionId], or forgets it when there is nothing left to keep. * - * The path out is emptying the box, which is what sending does -- so a sent message removes its own - * entry and nothing accumulates for a session in ordinary use. A session *deleted* while it held a - * draft does leave its key behind: pruning those means a pass over the live session list, which - * this file would otherwise have no reason to know about, and the residue is a few bytes per - * session ever abandoned mid-sentence. That is a trade rather than an oversight. + * The path out is emptying the box, which is what sending does. A session *deleted* while it held a + * draft does leave its key behind: pruning those means a pass over the live session list, and the + * residue is a few bytes per session ever abandoned mid-sentence. */ fun saveDraft(context: Context, sessionId: String, text: String) { context.getSharedPreferences(DRAFTS, Context.MODE_PRIVATE).edit { diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Durations.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Durations.kt index bc18ad8..30db84c 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Durations.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Durations.kt @@ -5,13 +5,12 @@ package com.example.aiapp * * A tool's timeout arrives as `480000`, which nobody reads as eight minutes. The rule has two * halves, because a short span and a long one are read for different things. Under a minute the - * question is "roughly how long", so only the largest unit is shown and a fraction of it carries - * the rest -- `2.5s`, `30ms`. At a minute or more the question is "how long exactly", so every unit - * that has something in it is written out -- `5d 12h 4m`. Units that are empty are left out rather - * than written as zero, since the labels say which is which and `5d 0h 4m` is only longer. + * question is "roughly how long", so only the largest unit is shown and a fraction carries the rest + * -- `2.5s`. At a minute or more the question is "how long exactly", so every unit with something + * in it is written out -- `5d 12h 4m`. Empty units are left out rather than written as zero. * * Sub-second precision is dropped past a minute: nothing that takes days is measured in - * milliseconds, and carrying them would make the common case the widest one. + * milliseconds. */ fun formatMillis(ms: Long): String { if (ms < 0) return "-" + formatMillis(-ms) diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/EventStream.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/EventStream.kt index 4176411..748b3fc 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/EventStream.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/EventStream.kt @@ -12,7 +12,7 @@ private const val RESET_EVENT = "reset" * * The connection and its framing belong to [Sse]; what stays here is what this stream's frames * mean. [close] from any thread ends it, and the caller owns reconnecting -- with the last seq it - * saw as the new cursor. See SessionScreen. + * saw as the new cursor. */ class EventStream(settings: ServerSettings, private val sessionId: String) { private val stream = Sse(settings) @@ -24,20 +24,19 @@ class EventStream(settings: ServerSettings, private val sessionId: String) { * * [onReset] fires when the server answers that the cursor is too far behind to continue from: * everything already displayed is stale and the events that follow are a fresh window, so the - * caller drops what it holds and rebuilds -- the same thing it does when the screen opens. It - * arrives before those events, so a caller that clears on it stays in order. + * caller drops what it holds and rebuilds. It arrives before those events, so a caller that + * clears on it stays in order. */ fun run( after: Long, onOpen: () -> Unit, onReset: () -> Unit, - // The frame's own text as well as the event parsed from it: the transcript cache stores - // the one and the screen folds the other, and they have to be the same line. + // The frame's own text as well as the event parsed from it: the transcript cache stores the + // one and the screen folds the other, and they have to be the same line. onEvent: (raw: String, event: SeqEvent) -> Unit, ) { stream.run("/sessions/$sessionId/events?after=$after", onOpen) { name, data -> - // A named frame carries no payload and a data frame has no name, so this is one or - // the other. + // A named frame carries no payload and a data frame has no name. if (name == RESET_EVENT) onReset() else if (data.isNotEmpty()) onEvent(data, parseSeqEvent(data)) } diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Events.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Events.kt index 661409d..f266f70 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Events.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Events.kt @@ -2,10 +2,9 @@ package com.example.aiapp import org.json.JSONObject -// The common event model, mirrored from server/src/session/driver.rs -- -// the app renders purely from this stream (replayed from the transcript by -// cursor, then live), so there is no separate "load history" shape to keep -// in sync with it. +// The common event model, mirrored from server/src/session/driver.rs -- the app renders purely from +// this stream (replayed from the transcript by cursor, then live), so there is no separate "load +// history" shape to keep in sync with it. /** One transcript line: the event plus its resume cursor and time. */ data class SeqEvent(val seq: Long, val ts: Double, val event: SessionEvent) @@ -13,8 +12,7 @@ data class SeqEvent(val seq: Long, val ts: Double, val event: SessionEvent) /** * One choice offered in answer to a question. * - * More than a label because the reader is deciding rather than confirming: what an option means, - * and what picking it would produce, are the things that decide it. Both are absent on a + * More than a label because the reader is deciding rather than confirming. Both are absent on a * permission, whose Allow and Deny mean exactly what they say. */ data class QuestionOption(val label: String, val description: String?, val preview: String?) @@ -30,13 +28,12 @@ sealed class SessionEvent { */ val id: String?, /** - * What was attached to it, by the ref the files route serves: images, and since 2026-09-03 - * any file, told apart by [isImageRef]. + * What was attached to it, by the ref the files route serves: images, and any file, told + * apart by [isImageRef]. * * On the message rather than beside it: these arrived as separate image events until * 2026-08-30, which drew somebody's screenshot as a row floating above the bubble that sent - * it, and left this app deciding from adjacency alone which message an image went with -- - * something the sender knew and could simply have said. + * it, and left this app deciding from adjacency which message an image went with. */ val attachments: List, ) : SessionEvent() @@ -45,11 +42,10 @@ sealed class SessionEvent { * A message the server has accepted and the session has not read yet. * * From the server, not from this app's memory of what it sent. The pending bubble used to be - * screen state, so leaving the session or restarting the app drew nothing waiting while the - * message was still queued -- and nothing waiting is what "there is nothing" looks like. + * screen state, so leaving the session drew nothing waiting while the message was still queued + * -- and nothing waiting is what "there is nothing" looks like. * - * Resolved by the [UserMessage] carrying the same id, exactly as [CommandQueued] is resolved by - * [CommandSent]. + * Resolved by the [UserMessage] carrying the same id. */ data class MessageQueued(val id: String, val text: String, val attachments: List) : SessionEvent() @@ -59,8 +55,7 @@ sealed class SessionEvent { * * Recorded by the server for the same reason [MessageQueued] is: a phone that reconnects * replays both, and without this one it would put back a bubble for a message that is never - * coming -- with nothing left to resolve it, since the [UserMessage] that normally does is - * exactly what was cancelled. + * coming. */ data class MessageDropped(val id: String) : SessionEvent() @@ -108,17 +103,14 @@ sealed class SessionEvent { * * The live Claude Code path only learns a turn was somebody else's when the turn ends, so * the event arrives below everything it caused; this is what puts it back above it. Null - * for a message read out of a session file, which is already in the right place, and for - * one that started no turn. See the server's `Event::PeerMessage`. + * for a message read out of a session file, and for one that started no turn. */ val turnStart: Long? = null, ) : SessionEvent() /** - * A command the session was asked to run on itself and cannot run yet. - * - * Resolved by [CommandSent] with the same id. A command that ran straight away has only that - * one, so nothing here ever draws a bubble that resolves in the same frame. + * A command the session was asked to run on itself and cannot run yet. Resolved by + * [CommandSent] with the same id; a command that ran straight away has only that one. */ data class CommandQueued(val id: String, val text: String) : SessionEvent() @@ -130,29 +122,26 @@ sealed class SessionEvent { /** * What the session is set to, as the session itself reports it. * - * Either field alone: the two are confirmed separately and by different things. Asking for a - * change is not having one, so this -- not the request -- is what the pickers show. + * Either field alone: the two are confirmed separately. Asking for a change is not having one, + * so this -- not the request -- is what the pickers show. */ data class Settings(val model: String?, val permissionMode: String?) : SessionEvent() /** * What a turn cost, and how much the model was holding when it ended. * - * [context] is prompt plus both cache figures, measured by the backend from the turn's own - * usage. Carried on the event rather than summed by the reader, because it is not a sum: a - * conversation's context drops at a compaction and a clear, so adding turns up would report a - * figure the session stopped being true of. Null where the dialect did not say, and on entries - * recorded before the backend sent it -- which leaves the context unmeasured rather than - * unchanged. + * [context] is prompt plus both cache figures. Carried on the event rather than summed by the + * reader, because it is not a sum: a conversation's context drops at a compaction and a clear, + * so adding turns up would report a figure the session stopped being true of. Null where the + * dialect did not say, which leaves the context unmeasured rather than unchanged. */ data class UsageDelta(val tokens: Long, val context: Long?) : SessionEvent() /** * A compaction that finished, and how much context it recovered. * - * The counts are nullable because the server sends them only when it was told them: a - * compaction whose size nobody measured has to be able to say so, since a zero here would read - * as "recovered nothing" and a made-up number would read as a measurement. + * The counts are nullable because the server sends them only when it was told them: a zero here + * would read as "recovered nothing" and a made-up number would read as a measurement. */ data class Compacted( val preTokens: Long?, @@ -163,9 +152,7 @@ sealed class SessionEvent { /** * The conversation was cleared. Everything above this is still here to read and is no longer in - * the session's context. - * - * An object rather than a class because it carries nothing: what it means is entirely its + * the session's context. An object rather than a class because what it means is entirely its * position in the transcript. */ data object Cleared : SessionEvent() @@ -173,17 +160,15 @@ sealed class SessionEvent { data class Error(val message: String) : SessionEvent() /** - * An event type this app build doesn't know -- a newer server. Kept (not thrown) so one new - * event kind degrades to a placeholder row instead of killing the stream. + * An event type this app build doesn't know -- a newer server. Kept rather than thrown so one + * new event kind degrades to a placeholder row instead of killing the stream. */ data class Unknown(val type: String) : SessionEvent() } /** - * A JSON array of strings under [name], empty when the field is absent. - * - * Absent is the ordinary case -- most messages carry no attachment, and the server omits the field - * rather than sending an empty list -- so this is the shape every caller wants. + * A JSON array of strings under [name], empty when the field is absent -- the ordinary case, since + * the server omits the field rather than sending an empty list. */ private fun JSONObject.stringList(name: String): List { val array = optJSONArray(name) ?: return emptyList() @@ -212,8 +197,8 @@ fun parseSeqEvent(json: String): SeqEvent { SessionEvent.ToolStart( id = body.getString("id"), tool = body.getString("tool"), - // Kept as raw JSON text: the input shape is the tool's own - // business, and the UI only ever shows it verbatim. + // Kept as raw JSON text: the input shape is the tool's own business, and the UI + // only ever shows it verbatim. input = body.get("input").toString(), ) "toolUpdate" -> SessionEvent.ToolUpdate(body.getString("id"), body.getString("output")) @@ -282,36 +267,33 @@ fun parseSeqEvent(json: String): SeqEvent { return SeqEvent(seq = body.getLong("seq"), ts = body.getDouble("ts"), event = event) } +/** + * Whether [state] is one the session is doing work in -- the states a turn is still open under. + * + * One predicate because two readers have to agree on the list: the session screen's working + * indicator, and the fold's decision that the newest reply is finished. Two copies would drift the + * first time the server grows a state, and the drift would be a reply that never splits or one + * split mid-stream. + */ +fun sessionWorking(state: String): Boolean = state == "running" || state == "compacting" + /** * The context after [event], given what it was before. * * The same rule the server folds with, because the screen has to keep up between page loads: the - * summary it opened with is a measurement from before this stream started, and every event that - * moves the figure arrives here. + * summary it opened with is a measurement from before this stream started. * * The two that lower it are the point. A clear takes the conversation away and a compaction * replaces it with a summary, so a figure measured before either stopped being true at that moment * -- and carrying it forward is how a session that had just been cleared went on reporting the * context it no longer had. * - * Null is "we don't know", which is a state each of them can reach: nothing measured yet, a - * compaction that finished without saying how much it recovered, or a clear nobody has run a turn - * since. + * Null is "we don't know", which each of them can reach. */ -/** - * Whether [state] is one the session is doing work in -- the states a turn is still open under. - * - * One predicate because two readers have to agree on the list: the session screen's working - * indicator, and the fold's decision that the newest reply is finished - * ([TranscriptItem.AssistantMsg.settled]). Two copies would drift the first time the server grows a - * state, and the drift would be a reply that never splits or one split mid-stream. - */ -fun sessionWorking(state: String): Boolean = state == "running" || state == "compacting" - fun contextAfter(current: Long?, event: SessionEvent): Long? = when (event) { - // Falls back to what we had, so a turn the dialect reported no usage for is stale by a - // turn -- which every context figure is -- rather than unknown. + // Falls back to what we had, so a turn the dialect reported no usage for is stale by a turn + // -- which every context figure is -- rather than unknown. is SessionEvent.UsageDelta -> event.context ?: current is SessionEvent.Compacted -> event.postTokens is SessionEvent.Cleared -> null diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/FileEditor.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/FileEditor.kt index ddbc11f..a467296 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/FileEditor.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/FileEditor.kt @@ -26,7 +26,7 @@ import androidx.compose.ui.text.style.TextAlign /** * The largest file this app will open in the editor, in bytes. * - * Measured on the emulator on 2026-09-04, in a debug build, on generated Rust: + * Measured on the emulator 2026-09-04, in a debug build, on generated Rust: * * | file | lines | scan per keystroke | worst frame record | typing | * |--------|--------|--------------------|--------------------|-------------------| @@ -35,15 +35,13 @@ import androidx.compose.ui.text.style.TextAlign * | 1 MB | 28,660 | -- | -- | stops responding | * * The number that decides this is the **frame record**, not the scan: highlighting a 128 kB file - * costs 40ms a keystroke, which is noticeable and survivable, while laying the same text out in one - * `BasicTextField` costs two seconds. So switching highlighting off above a size -- which is what - * EXPLORER.md expected to have to decide -- would not have saved it; the cost is Compose laying out - * one enormous text, and every arrangement of a single text field pays it. A line-by-line editor is - * the way past this and is a good deal more than this feature needed. + * costs 40ms a keystroke, which is survivable, while laying the same text out in one + * `BasicTextField` costs two seconds. So switching highlighting off above a size -- what + * EXPLORER.md expected to have to decide -- would not have saved it; every arrangement of a single + * text field pays that cost. A line-by-line editor is the way past this. * - * 32 kB rather than something between it and 128 kB, because 32 kB is the largest size that was - * actually measured as usable. The viewer's own limit stays the server's `FILE_LIMIT` of 1 MiB: - * reading a big file is fine, and it is only editing one that is not. + * 32 kB because it is the largest size actually measured as usable. The viewer's own limit stays + * the server's `FILE_LIMIT` of 1 MiB: reading a big file is fine, and only editing one is not. */ const val EDIT_LIMIT = 32L * 1024 @@ -53,18 +51,15 @@ const val EDIT_LIMIT = 32L * 1024 * `BasicTextField(TextFieldValue)` with a [VisualTransformation] is the one Compose arrangement * that colours a field's own text rather than replacing the field with something that only looks * like one: the transformation returns the text unchanged and the scanner's spans as styles, so - * [OffsetMapping.Identity] is correct by construction -- no character moves, so no offset does. The - * newer `TextFieldState` API has no hook for styles at all, which is why this is the older one. + * [OffsetMapping.Identity] is correct by construction. The newer `TextFieldState` API has no hook + * for styles at all. * - * The cost is that the whole file is re-scanned on every keystroke. For a file under the server's - * limit that is expected to be a few milliseconds; see EXPLORER.md's "Numbers to measure", which is - * where a size below which highlighting is switched off would be decided if it turns out to be - * needed. + * The cost is that the whole file is re-scanned on every keystroke, which is what [EDIT_LIMIT] is + * sized against. * * The gutter is one `Text` of `1\n2\n…` beside the field rather than a number per row, because - * there are no rows here -- the field is one text object. It stays put while the text scrolls - * sideways, and it lines up for the same reason the viewer's does: nothing wraps, so a logical line - * is a visual line. + * there are no rows here -- the field is one text object. It lines up for the same reason the + * viewer's does: nothing wraps, so a logical line is a visual line. */ @Composable fun FileEditor( diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/FileLines.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/FileLines.kt index f5fa96c..e9129d6 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/FileLines.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/FileLines.kt @@ -12,10 +12,8 @@ import androidx.compose.ui.text.buildAnnotatedString * and again on every recomposition. * * Why per line at all: the viewer is a `LazyColumn` of lines rather than one `Text`, because text - * layout is linear in the text and a twenty-thousand-line file in one `Text` measures all of it to - * draw a screenful. That means each row needs *its* colours, and the scanner answers in offsets - * into the whole file -- so the spans are bucketed here, once, in one pass over an already-ordered - * list, rather than each row searching the whole list for the part that is its. + * layout is linear in the text. That means each row needs *its* colours, and the scanner answers in + * offsets into the whole file -- so the spans are bucketed here, once, in one pass. */ class FileLines private constructor( @@ -37,11 +35,9 @@ private constructor( get() = lines.size /** - * One line, coloured. - * - * Built when the row is composed rather than up front: a file has far more lines than a screen - * shows, and an `AnnotatedString` per line for all of them is the cost the lazy list exists to - * avoid. + * One line, coloured. Built when the row is composed rather than up front: a file has far more + * lines than a screen shows, and an `AnnotatedString` per line for all of them is the cost the + * lazy list exists to avoid. */ fun line(index: Int): AnnotatedString { val text = lines[index] @@ -60,16 +56,13 @@ private constructor( * * Exactly one trailing newline is dropped before splitting, so a file that ends the way * text files are supposed to end has the number of lines its author would count -- `wc -l` - * agrees, and so does every editor. Without that, every well-formed file gained a phantom - * empty last line, which is a wrong line number on every file in the repository. An empty - * file is one empty line numbered 1, which is what it is: a file with nothing in it still - * has somewhere for a cursor to go. + * agrees. Without that, every well-formed file gained a phantom empty last line. An empty + * file is one empty line numbered 1, which is what it is. */ fun of(text: String, language: Language?): FileLines = - // Timed, and always, for the same reason everything else here is: the cost of opening - // a large file is the number that decides whether the server's size limit is right, - // and an instrument that is only in the build nobody is running answers nothing. It - // lands in the render report beside the transcript's own figures. + // Timed, and always, for the reason everything else here is: the cost of opening a + // large file is the number that decides whether the server's size limit is right, and + // an instrument that is only in the build nobody is running answers nothing. DebugStats.timed("file scanned and cut into lines") { val body = text.removeSuffix("\n") val lines = body.split('\n') @@ -80,10 +73,9 @@ private constructor( /** * How many columns a line occupies. * - * A tab counts as eight rather than as one, and deliberately upwards: this decides how far - * the viewer can scroll, and over-estimating leaves a little empty space past the longest - * line where under-estimating makes the end of that line unreachable. Compose draws a tab - * as a single advance, so eight is the generous reading rather than the accurate one. + * A tab counts as eight rather than one, and deliberately upwards: this decides how far the + * viewer can scroll, and over-estimating leaves a little empty space past the longest line + * where under-estimating makes the end of that line unreachable. */ private fun columnsOf(line: String): Int { var count = 0 @@ -95,10 +87,9 @@ private constructor( * The scanner's spans, in file offsets, as spans per line in line offsets. * * One walk down both lists, which is what the scanner's guarantee buys: its spans come out - * ordered, non-overlapping and inside the text, so a span can only belong to the line the - * walk has reached or to ones after it. A span crossing a line break -- a block comment, a - * multi-line string -- is cut at each break and appears in each line it covers, because a - * row is drawn on its own and cannot inherit a colour from the row above. + * ordered, non-overlapping and inside the text. A span crossing a line break is cut at each + * break and appears in each line it covers, because a row is drawn on its own and cannot + * inherit a colour from the row above. */ private fun bucket(lines: List, spans: List): List> { val out = ArrayList>(lines.size) diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/FileViewer.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/FileViewer.kt index 58d2501..d60c5dc 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/FileViewer.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/FileViewer.kt @@ -50,15 +50,12 @@ fun codeStyle(): TextStyle = /** * [content] scanned off the main thread, then drawn. * - * Measured on the emulator on 2026-09-04: [FileLines.of] takes **460ms** on a 1 MiB Rust file - * (28,660 lines) and 11ms on 32 kB. Called from a `remember` inside the composition, as it was - * first written, that is 460ms of frozen screen at the size the server is willing to send -- long - * enough that the accessibility tree cannot be read, which is what "the app has stopped" looks like - * from outside. So it runs on [Dispatchers.Default] and the spinner is what the reader sees - * meanwhile, in the place the file will appear. + * Measured on the emulator 2026-09-04: [FileLines.of] takes **460ms** on a 1 MiB Rust file (28,660 + * lines) and 11ms on 32 kB. Called from a `remember` inside the composition, as it was first + * written, that is 460ms of frozen screen at the size the server is willing to send -- long enough + * that the accessibility tree cannot be read, which is what "the app has stopped" looks like. * - * Keyed on the text and the language, so re-reading the same file does not rescan it and a file - * that changed does. + * Keyed on the text and the language, so re-reading the same file does not rescan it. */ @Composable fun ScannedFile(content: String, language: Language?, modifier: Modifier = Modifier) { @@ -76,39 +73,31 @@ fun ScannedFile(content: String, language: Language?, modifier: Modifier = Modif * A file, one line per row, coloured by the same scanner that colours a reply's code fences. * * A `LazyColumn` of lines rather than one `Text`, because text layout is linear in the text: a - * twenty-thousand-line file in a single `Text` measures all of it to draw a screenful, and the - * scroll never recovers. The cost of the choice is that each row needs its own colours, which is - * what [FileLines] works out once and off this thread. + * twenty-thousand-line file in a single `Text` measures all of it to draw a screenful. The cost is + * that each row needs its own colours, which is what [FileLines] works out once and off this + * thread. * * Lines do not wrap. They share one horizontal scroll state, so the whole file moves sideways as a * block and a long line does not silently become three -- which would put the gutter's numbers - * against the wrong text, the one thing a numbered listing must never do. Because nothing wraps, a - * logical line is one visual line and the two cannot drift. + * against the wrong text. * * **Every row is given the same content width**, and that is what makes the shared scroll state * behave. `Modifier.horizontalScroll` is a node per row, and each one coerces the shared offset * into *its own* range -- `content width - viewport` -- so with rows of their natural widths a - * short line's range is zero and it never moves at all while a long one beside it does. Each row - * also writes `maxValue` on the shared state as it measures, so how far the file could be dragged - * was decided by whichever row happened to measure last and changed as the list scrolled. Both - * disappear once every row is [FileLines.columns] wide: one range, one maximum, and the file moves - * as the block this comment always claimed it was. Reported by Iris on 2026-09-04 as "it seems to - * affect different rows differently", which is exactly what a per-row range looks like. + * short line's range is zero and it never moves while a long one beside it does. Each row also + * writes `maxValue` as it measures, so how far the file could be dragged was decided by whichever + * row measured last. Both disappear once every row is [FileLines.columns] wide. Reported by Iris on + * 2026-09-04 as "it seems to affect different rows differently", which is what a per-row range + * looks like. * * The stretch at the ends of the travel is **one** effect for the whole file, rendered on the box - * around the list rather than by each row. `horizontalScroll` makes its own per node otherwise, so - * only the line under the finger stretched and the rest of the file sat still beside it -- the same - * complaint as the offsets above, one layer further out. Handing every row the same effect and - * rendering it once is what makes the file bend as the block it scrolls as. Only possible because - * every row now has the same range: rows that disagreed about where the end was would disagree - * about when to stretch. + * around the list rather than by each row -- `horizontalScroll` makes its own per node otherwise, + * so only the line under the finger stretched. Only possible because every row now has the same + * range. * * The gutter is **beside** the scrolling box rather than inside its rows, which is what keeps the - * numbers out of both effects: they do not travel with the text and they do not bend with it. The - * rows leave a spacer where the numbers will go and [LineGutter] draws them there. Its width is - * measured from the digit count of the line count in the very style it is drawn in, so a nine-line - * file and a twelve-thousand-line file each get exactly what they need and nothing is nudged by - * hand. + * numbers out of both effects. The rows leave a spacer and [LineGutter] draws them there; its width + * is measured from the digit count of the line count in the style it is drawn in. * * Moving them out also takes them out of the [SelectionContainer], so selecting part of a file and * copying it gives the code rather than the code with a number in front of every line. @@ -139,8 +128,8 @@ fun FileViewer(lines: FileLines, modifier: Modifier = Modifier) { softWrap = false, // The scroll outside the width: the scrolling node's viewport is // what the row has room for, and its content is the whole file's - // widest line. The shared effect is given to every row and - // rendered by none of them -- see the box above. + // widest line. The shared effect is given to every row and rendered + // by none of them -- see the box above. modifier = Modifier.horizontalScroll(scroll, overscroll).width(content), ) @@ -157,24 +146,20 @@ fun FileViewer(lines: FileLines, modifier: Modifier = Modifier) { * The line numbers, drawn beside the file rather than in it. * * They have to be outside the box the stretch is rendered on, or they bend with the text; and they - * have to stay exactly level with the lines they number, which is the one thing a numbered listing - * may never get wrong. Those two pull in opposite directions -- out of the list, but pinned to it. + * have to stay exactly level with the lines they number. Those two pull in opposite directions. * * A [SubcomposeLayout] is what settles it. *Which* numbers exist and *where* each goes both come * from the list's own `layoutInfo`, read in the measure block -- and subcomposition happens during - * measurement, so this is not composing from a value it read a frame ago, it is composing from the - * answer the list has just produced. A `Column` translated by the scroll position could not do - * that: the translation would be a layout read and current while the set of numbers would be a - * composition behind it, so during a fling the numbers would slide against their lines. + * measurement, so this composes from the answer the list has just produced rather than one it read + * a frame ago. A `Column` translated by the scroll position could not: the translation would be + * current while the set of numbers was a composition behind, so during a fling the numbers would + * slide against their lines. * - * The list is measured before this is -- they are siblings in a `Box` and it is declared first -- - * and a scroll that remeasures the list on its own does so synchronously, ahead of the layout pass, - * which is the same reason a lazy list does not lag its own content. + * The list is measured before this is -- they are siblings in a `Box` and it is declared first. * - * `onSurfaceVariant`, because a number is not part of the file: it is this app numbering it, and - * the text's own colour would put it in the same voice as the code. The background is painted - * because the stretch can carry the text sideways under this column, and a digit with a smear of - * code behind it reads as a rendering fault. + * `onSurfaceVariant`, because a number is not part of the file. The background is painted because + * the stretch can carry the text sideways under this column, and a digit with a smear of code + * behind it reads as a rendering fault. */ @Composable private fun LineGutter(rows: LazyListState, width: Dp, style: TextStyle) { @@ -205,10 +190,9 @@ private fun LineGutter(rows: LazyListState, width: Dp, style: TextStyle) { /** * How wide the widest line number is, measured rather than guessed. * - * `9` repeated, because digits in a monospace face are all one width and the count's own digits - * would measure the same -- what matters is how many there are. Measuring in the style the numbers - * are drawn in is what makes this survive a font size, a density or a display scale nobody here - * chose. + * `9` repeated, because digits in a monospace face are all one width -- what matters is how many + * there are. Measuring in the style the numbers are drawn in is what makes this survive a font + * size, a density or a display scale nobody here chose. */ @Composable fun gutterWidth(lineCount: Int, style: TextStyle): Dp { @@ -225,15 +209,15 @@ fun gutterWidth(lineCount: Int, style: TextStyle): Dp { /** * How wide to make every row: the widest line in the file, in this style. * - * One character measured rather than the line itself, because the face is monospace -- every - * advance is the same -- and measuring the actual widest line of a twenty-thousand-line file is - * work for an answer arithmetic already has. Sixty-four of them, divided, so the answer does not - * carry a whole character's worth of rounding. + * One character measured rather than the line itself, because the face is monospace and measuring + * the actual widest line of a twenty-thousand-line file is work for an answer arithmetic already + * has. Sixty-four of them, divided, so the answer does not carry a whole character's worth of + * rounding. * * Capped, because this becomes a fixed width in a layout and Compose cannot represent an arbitrary - * one: a minified file is a single line of a hundred thousand characters, and asking to lay that - * out as one row is a crash rather than a slow scroll. Past the cap the far end of such a line - * cannot be reached, which is the tolerable half of that trade. + * one: a minified file is a single line of a hundred thousand characters, and laying that out as + * one row is a crash rather than a slow scroll. Past the cap the far end of such a line cannot be + * reached, which is the tolerable half of that trade. */ @Composable private fun contentWidth(columns: Int, style: TextStyle): Dp { @@ -252,9 +236,7 @@ private fun contentWidth(columns: Int, style: TextStyle): Dp { private const val MAX_CONTENT_PX = 100_000f /** - * The space between the numbers and the code. - * - * A gap, not an alignment: the two are already aligned by the row, and this is only so the digits - * and the first character of the line are not touching. + * The space between the numbers and the code. A gap, not an alignment: the two are already aligned + * by the row, and this is only so the digits and the first character are not touching. */ val GUTTER_GAP = 8.dp diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/FilesScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/FilesScreen.kt index e2ebac4..c4bfe25 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/FilesScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/FilesScreen.kt @@ -45,7 +45,7 @@ import kotlinx.coroutines.withContext * Which machine's files to show, and where to start. * * A **setup**, not a session: a filesystem is a property of a machine, and a session only says - * where it was working. That is what makes a second way in -- from the setups tab, say -- one more + * where it was working. That is what makes a second way in -- from the setups tab -- one more * caller rather than any new code here. */ data class FilesTarget(val setup: String, val setupName: String, val start: String) @@ -61,13 +61,13 @@ private sealed class Spot(val path: String) { * The files on the machine a session runs on: browse them, read one, change one. * * Drawn **over** the session rather than instead of it (see [AppRoot]), so its event stream keeps - * flowing, its draft and scroll position stay where they were, and coming back from a file costs - * nothing. Back steps one level inside here -- editor to viewer, viewer to the directory it came - * from, directory to the one above it -- and only closes from where it opened. + * flowing and coming back from a file costs nothing. Back steps one level inside here -- editor to + * viewer, viewer to the directory it came from, directory to the one above -- and only closes from + * where it opened. * - * Every directory that has been visited is kept for as long as this is open, so stepping back is - * instant; the refresh glyph is how a directory gets asked again on purpose, and creating something - * refetches the directory it was created in, since that is the one thing that changed. + * Every directory that has been visited is kept for as long as this is open; the refresh glyph is + * how one gets asked again on purpose, and creating something refetches the directory it was + * created in. */ @Composable fun FilesScreen(settings: ServerSettings, target: FilesTarget, onClose: () -> Unit) { @@ -121,17 +121,16 @@ fun FilesScreen(settings: ServerSettings, target: FilesTarget, onClose: () -> Un Box( Modifier.fillMaxSize() .background(MaterialTheme.colorScheme.background) - // The session under this deliberately takes no keyboard inset (see SessionScreen's - // layout note), so the explorer adds its own -- otherwise the editor types under the - // keyboard. + // The session under this deliberately takes no keyboard inset, so the explorer adds its + // own -- otherwise the editor types under the keyboard. .imePadding() ) { Column(Modifier.fillMaxSize()) { when (val spot = here) { is Spot.Dir -> { val state = listings[spot.path] ?: LoadState.Loading - // The resolved path once there is one: a directory opened as `~` is called - // what it turned out to be, not what it was asked for. + // The resolved path once there is one: a directory opened as `~` is called what + // it turned out to be, not what it was asked for. val at = (state as? LoadState.Loaded)?.value?.path ?: spot.path FilesHeader( title = baseName(at), @@ -304,9 +303,8 @@ private fun ColumnScope.DirectoryBody(state: LoadState, onOpen: (Spot) * * A symlink says so instead of giving a size, because the size a listing reports for one is the * length of the path it points at -- a number that looks exactly like a file size and is about - * something else entirely. `other` covers a fifo, a device, and a link whose target is gone: the - * row still appears, because a directory that hid what it held would be lying about being empty, - * and the word is there because a colour cannot say "this is a different kind of thing". + * something else. `other` covers a fifo, a device, and a link whose target is gone: the row still + * appears, because a directory that hid what it held would be lying about being empty. */ private fun trailingOf(entry: DirEntry): String? = when { @@ -350,8 +348,7 @@ private fun EntryRow(glyph: String, name: String, trailing: String?, onClick: () * * Its own composable so that everything about one file -- what came back, what has been typed, and * whether a save is out -- is remembered under that file's path and thrown away when the reader - * moves to another. What is *not* here is edit mode itself: back has to know about it, and back - * belongs to the screen. + * moves to another. What is *not* here is edit mode itself: back has to know about it. */ @Composable private fun ColumnScope.DocPane( @@ -423,8 +420,8 @@ private fun ColumnScope.DocPane( onDirty(false) onEditing(false) } catch (e: ApiException) { - // The one refusal that is a question rather than a message: somebody else's edit - // is on the machine, and which of the two survives is not this app's to decide. + // The one refusal that is a question rather than a message: somebody else's edit is + // on the machine, and which of the two survives is not this app's to decide. if (e.status == 409) conflict = e.message ?: "It changed on the machine." else saveError = e.message } finally { @@ -467,10 +464,10 @@ private fun ColumnScope.DocPane( ) } - // Why the pencil is off. A disabled control teaches what the thing can do, but it cannot say - // why it is disabled -- and a reader who cannot edit a file they can plainly read will - // otherwise conclude the app is broken. Said once, here, rather than waiting for a tap that a - // disabled button never receives. + // Why the pencil is off. A disabled control teaches what the thing can do but cannot say why it + // is disabled -- and a reader who cannot edit a file they can plainly read will otherwise + // conclude the app is broken. Said once, here, rather than waiting for a tap a disabled button + // never gets. if (loaded != null && !editable) { Text( "Too big to edit here (${humanSize(loaded.size)}; the limit is " + @@ -687,6 +684,5 @@ internal fun baseName(path: String): String { return if (trimmed.isEmpty()) "/" else trimmed.substringAfterLast('/') } -/** A resolved directory and a name in it, as one path. */ internal fun join(directory: String, name: String): String = if (directory.endsWith("/")) "$directory$name" else "$directory/$name" diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/FrameStats.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/FrameStats.kt index 61ffae5..2bd210c 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/FrameStats.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/FrameStats.kt @@ -19,19 +19,15 @@ import androidx.compose.ui.platform.LocalContext * The point of splitting it up is that "the scroll is laggy" has two completely different causes * and one appearance. If the layout-and-measure and draw figures are small and the total is large, * the time is going into rasterising and compositing, and no amount of doing less work per row will - * move it. If they are large, the work per row is the problem and it is ours to fix. Guessing - * between those two is how a day gets spent rewriting the half that was already fast. + * move it. If they are large, the work per row is the problem and it is ours to fix. * * The phases are the platform's own: [FrameMetrics] reports each frame's cost in nanoseconds, - * broken down into the parts the UI thread is responsible for -- handling input, running - * animations, measuring and laying out, recording the draw -- and the parts after it. + * broken into the parts the UI thread is responsible for and the parts after it. * * One of these for the app, like [DebugStats], because the two are read as one report and * [drawAccounting] divides one by the other. Held per screen it was emptied by leaving a session * and the counters were not, so a report copied after visiting two sessions divided every session's - * work by the newest one's frame count -- and printed the result as a per-frame measurement. It - * said 36.8 seconds of placement inside a 13.5 second window, and left "everything else" clamped at - * 0.00ms (0%), which reads as a screen whose whole cost is this app's own code. + * work by the newest one's frame count -- 36.8 seconds of placement inside a 13.5 second window. */ object FrameStats { private val total = ArrayList() @@ -54,7 +50,7 @@ object FrameStats { total += metrics.getMetric(FrameMetrics.TOTAL_DURATION) // How long the frame waited for the UI thread to be free before it could start. Reported // because the phases otherwise do not add up to the total, and the gap is the interesting - // part: it is the frame being held up by work that is not the frame's. + // part: the frame being held up by work that is not the frame's. waited += metrics.getMetric(FrameMetrics.UNKNOWN_DELAY_DURATION) input += metrics.getMetric(FrameMetrics.INPUT_HANDLING_DURATION) animation += metrics.getMetric(FrameMetrics.ANIMATION_DURATION) @@ -123,7 +119,7 @@ private const val CAP = 20_000 * Records into [FrameStats] for as long as this screen is on it. * * The listener is what comes and goes; what it writes into does not, so a report covers the same - * stretch of time as the counters beside it. See [FrameStats]. + * stretch of time as the counters beside it. * * The listener is handed its own thread because the platform calls it for every frame and the * documentation is explicit that doing that on the main thread taxes the very thing being measured. diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Highlighter.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Highlighter.kt index 9dc2597..b3fcefd 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Highlighter.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Highlighter.kt @@ -47,12 +47,11 @@ data class SyntaxPalette( /** * [code] with its keywords, strings and comments coloured, or plain if there is no language for it. * - * Shared by a tool call's input ([ToolInputView]) and a reply's fences ([CodeFence]), so the same - * code is the same colours wherever it appears. + * Shared by a tool call's input and a reply's fences, so the same code is the same colours wherever + * it appears. * * Not a composable, and it takes no colour from the theme, because that is what lets [warm] run it - * off the drawing thread: the syntax palette is fixed, and a fence with no language is plain text - * which needs no colour of its own -- the style the caller draws it with carries that. + * off the drawing thread. * * The timing is the number the highlighter is judged by: the library this replaced took **174ms** * on the emulator for a two-hundred-line Kotlin fence, which is why [ParsedReplies.highlighted] @@ -82,8 +81,7 @@ fun highlight(code: String, language: Language?): AnnotatedString { * to the end of the code, which is also what it looks like while a fence is still being written. * * In ordinary code the order of recognition is comment, string, attribute, number, word, and - * finally a single punctuation or mark character. Punctuation and marks are coloured only in - * ordinary code, never inside a string or a comment. + * finally a single punctuation or mark character, which are coloured only in ordinary code. */ fun scan(code: String, rules: Rules): List = Scanner(code, rules).run() @@ -156,8 +154,8 @@ private class Scanner(private val code: String, private val rules: Rules) { at += comment.open.length var depth = 1 while (at < code.length && depth > 0) { - // The closer is tried first so that a language whose two delimiters are the same - // string -- CoffeeScript's `###` -- closes rather than nesting forever. + // The closer is tried first so that a language whose two delimiters are the same string + // -- CoffeeScript's `###` -- closes rather than nesting forever. if (starts(comment.close)) { depth-- at += comment.close.length diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/ImportScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/ImportScreen.kt index 6f817e9..c8845c7 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/ImportScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/ImportScreen.kt @@ -49,11 +49,9 @@ private const val DELETING = "deleting" /** * What the rows further down a batch say while they wait their turn. * - * Its own word rather than the operation's, because it is its own state and the difference is the - * kind that matters: nothing has been done to this session yet, so a batch stopped here leaves it - * exactly as it was. Marked from the moment the batch is handed over all the same -- a queued row - * that still looked ordinary was still tappable, and tapping it would import it a second time - * behind the batch already coming for it. + * Its own word rather than the operation's, because nothing has been done to this session yet, so a + * batch stopped here leaves it exactly as it was. Marked from the moment the batch is handed over + * all the same -- a queued row that still looked ordinary was still tappable. */ private const val WAITING = "waiting" @@ -62,25 +60,22 @@ private const val WAITING = "waiting" * * A batch takes rows out of the list as each one lands, so everything below the one that went * slides up -- and a tap already on its way then arrives at whichever row moved into that place. On - * this screen that means importing a session nobody chose, which is not something a second tap can - * undo. + * this screen that means importing a session nobody chose. * * Swallowed silently rather than shown, because anything drawn on every row a batch passes would be - * a flicker running down the list. Half a second: long enough to cover a tap already travelling - * when the row moved, short enough that it is not in the way of a deliberate one. + * a flicker running down the list. */ private const val SETTLE_MS = 500L /** * Continuing a Claude Code session the machine already has. * - * The list is the machine's answer, not this app's: it asks a setup what sessions it holds and - * shows them. Choosing one sends its **id**, never a path, so an enrolled phone cannot turn this - * screen into a file reader. + * The list is the machine's answer, not this app's. Choosing one sends its **id**, never a path, so + * an enrolled phone cannot turn this screen into a file reader. * * Holding a row selects it and puts the screen in selection mode, where the options that act on a - * selection appear along the bottom. That exists because these arrive in bulk — a machine - * accumulates dozens of abandoned sessions — and one confirmation dialog per row is the reason + * selection appear along the bottom. That exists because these arrive in bulk -- a machine + * accumulates dozens of abandoned sessions -- and one confirmation dialog per row is the reason * clearing them out was not worth doing. */ @OptIn(ExperimentalFoundationApi::class) @@ -91,28 +86,25 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio var chosen by remember { mutableStateOf(null) } var sessions by remember { mutableStateOf>>(LoadState.Loading) } - // What is happening to each row right now, as the word the row shows: "importing" or - // "deleting". A map keyed by id rather than a flag per row, because the rows are rebuilt from - // whatever the server last said and this belongs to the request rather than to the session -- - // the same arrangement the session list uses for its deletes. + // What is happening to each row right now, as the word the row shows. A map keyed by id rather + // than a flag per row, because the rows are rebuilt from whatever the server last said and this + // belongs to the request rather than to the session. var running by remember { mutableStateOf>(emptyMap()) } - // Which rows the reader has picked out. Empty means selection mode is off: there is no - // separate flag, because a selection mode with nothing selected is a state with no controls - // in it and no way to leave except Back. + // Which rows the reader has picked out. Empty means selection mode is off: a selection mode + // with nothing selected is a state with no controls in it and no way to leave except Back. var selected by remember { mutableStateOf>(emptySet()) } // Failures that belong to one row rather than to the screen, shown on that row. A batch is - // exactly where a single banner fails: nine deletes succeeded and one did not, and the - // banner cannot say which. + // exactly where a single banner fails: nine deletes succeeded and one did not, and the banner + // cannot say which. var rowErrors by remember { mutableStateOf>(emptyMap()) } // Deleting a transcript cannot be undone, so it is asked rather than done. Held as the rows // themselves, not a flag, so the dialog can say what it is about. var confirming by remember { mutableStateOf?>(null) } - // Same default as the spawn screen, and for the same reason: a phone - // is the wrong place to answer "allow Bash?" forty times. + // Same default as the spawn screen: a phone is the wrong place to answer "allow Bash?" forty + // times. var permissionMode by remember { mutableStateOf("auto") } - // When each row last slid upwards, as a plain map rather than state: nothing is drawn from - // it, so a tap reading it needs no recomposition and there is no timer to cancel when a - // second removal lands on top of the first. + // When each row last slid upwards, as a plain map rather than state: nothing is drawn from it, + // so a tap reading it needs no recomposition. val movedAt = remember { mutableMapOf() } fun settling(id: String) = System.currentTimeMillis() - (movedAt[id] ?: 0L) < SETTLE_MS @@ -120,8 +112,7 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio * Fetches the list and takes the row states from it. * * Taken from the answer rather than kept across the load: the server is what knows what is - * running, and this screen may be opening on work another screen -- or another phone -- - * started. Anything held locally would be a second version of that, and the stale one. + * running, and this screen may be opening on work another phone started. */ suspend fun fetchInto(setup: Setup): LoadState> = try { @@ -167,13 +158,11 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio * Hands [targets] to the server in one request, marking every row it covers. * * The request only *starts* the work -- the server runs it and says how each row went on the - * change stream, which is what lets this screen be left while a batch is still going. So there - * is nothing here to wait for and nothing to sequence: the rows are marked, the batch goes, and - * everything after that arrives as an event. + * change stream, which is what lets this screen be left while a batch is still going. * * Marked [WAITING] rather than with the operation's own word until the server confirms. Between * the request leaving and the `started` event coming back, "we have asked" is the truth and "it - * is importing" is a guess -- and the row is inert either way, which is the part that matters. + * is importing" is a guess. * * The selection is dropped as the work is handed over, not when it finishes: the screen goes * back to how it started, and what says the work is happening is the rows it is happening to. @@ -186,17 +175,14 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio val ids = targets.map { it.id } scope.launch { // One request for the whole batch, not one per row. Sent row by row, a handover was - // only as atomic as the network: the fourth of six could fail, or the screen could be - // left with two still unsent, and what came back was some rows running and some - // untouched -- indistinguishable, on the list, from rows nobody had picked. Now - // either the server has the batch or it has none of it, and this is the one place - // that can be true. + // only as atomic as the network, and what came back was some rows running and some + // untouched -- indistinguishable, on the list, from rows nobody had picked. try { withContext(Dispatchers.IO) { send(ids) } } catch (err: Exception) { // The server never took it, so nothing is running and no event will arrive to say - // so. This is the one failure the screen must report itself -- and it is now the - // whole batch's failure, which is the point: no row was singled out. + // so. This is the one failure the screen must report itself -- and it is the whole + // batch's failure, which is the point: no row was singled out. running = running - ids.toSet() rowErrors = rowErrors + ids.associateWith { err.message ?: "Couldn't ask" } return@launch @@ -205,19 +191,17 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio // Then ask what actually happened, if anything still looks outstanding. // // The change stream is a broadcast with no memory, so an operation that started and - // finished while it was still connecting is one nothing will ever be said about -- - // and the row sits marked for ever. That is not hypothetical: with responses held - // back far enough for the stream to open late, one row of a pair of deletes cleared - // and the other stayed on "waiting". + // finished while it was still connecting is one nothing will ever be said about -- and + // the row sits marked for ever. That is not hypothetical: with responses held back far + // enough, one row of a pair of deletes cleared and the other stayed on "waiting". // - // The listing is the repair, because it carries the same state the events do. Only - // when something still looks outstanding, so the ordinary case -- where the events - // arrived and the rows are already gone -- does not pay for a second listing, which - // is the most expensive call this screen makes. + // The listing is the repair, because it carries the same state the events do. Only when + // something still looks outstanding, so the ordinary case does not pay for a second + // listing, which is the most expensive call this screen makes. if (setup != null && targets.any { running.containsKey(it.id) }) { - // Quietly: no Loading, because blanking the list to report on rows that are - // already saying what is happening to them is the flicker this screen avoids - // everywhere else. + // Quietly: no Loading, because blanking the list to report on rows that are already + // saying what is happening to them is the flicker this screen avoids everywhere + // else. sessions = fetchInto(setup) } } @@ -225,13 +209,6 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio val provider = chosen?.providers?.firstOrNull { it.kind == "claude_cli" } - /** - * Imports [targets], and goes to the session it made when [thenOpen]. - * - * One function for the tap and for the bar, differing in that one flag: continuing a session - * and then looking at it is what a tap on a row means, and a batch has several results and no - * reason to pick one of them to become the screen. - */ /** Continues [targets] in the background, leaving the screen where it is. */ fun importAll(targets: List) { val setup = chosen ?: return @@ -283,13 +260,12 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio } } - // Live changes to what the server is doing to these sessions, for as long as this screen is - // up. The listing already carried the same state when the screen opened -- this is what keeps - // it current afterwards, including for work another screen or another phone started. + // Live changes to what the server is doing to these sessions, for as long as this screen is up. + // The listing already carried the same state when the screen opened -- this is what keeps it + // current afterwards, including for work another phone started. // // Failures here are deliberately quiet. There is nothing for a reader to do about a dropped - // event stream, and nothing is lost by one: every state it would have carried is in the next - // listing, which is what Refresh and re-entering the tab already fetch. + // event stream, and every state it would have carried is in the next listing. val liveChanges = remember { java.util.concurrent.atomic.AtomicReference(null) } @@ -307,8 +283,7 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio running = running + (change.session to (change.operation ?: WAITING)) // Gone from the machine either way: a delete removed the - // transcript, an import made it a session, and neither is - // something this list still has to offer. + // transcript, an import made it a session. "finished" -> { running = running - change.session forget(change.session) @@ -323,17 +298,14 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio } } } catch (e: kotlinx.coroutines.CancellationException) { - // The screen leaving, not a failure -- and swallowing it would leave this - // loop reconnecting to a stream nobody is watching. + // The screen leaving, not a failure -- and swallowing it would leave this loop + // reconnecting to a stream nobody is watching. throw e } catch (_: Exception) { - // Retried below; the listing is the truth in the meantime. - // - // Any failure, not only an [ApiException]. A stream is an optimisation over - // the listing here, so nothing it can do is worth taking the app down for -- - // and catching only the failure that was expected means an unexpected one - // reaches the top of the app and closes it, from a screen that is merely - // loading a list. + // Retried below; the listing is the truth in the meantime. Any failure, not + // only an [ApiException]: a stream is an optimisation over the listing here, + // and catching only the expected failure means an unexpected one closes the app + // from a screen that is merely loading a list. } finally { stream.close() } @@ -351,9 +323,8 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio // Nested inside MainScreen's own handler, so it wins while there is a selection. BackHandler(enabled = selected.isNotEmpty()) { selected = emptySet() } - // Measured rather than assumed: the list reserves exactly what the bar covers, so the last - // row can still be scrolled to while it is up, and nothing is nudged by a number that was - // right for one font size. + // Measured rather than assumed: the list reserves exactly what the bar covers, so the last row + // can still be scrolled to while it is up. var barHeight by remember { mutableStateOf(0.dp) } val density = LocalDensity.current @@ -428,8 +399,8 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio } } - // Beside nothing in particular, because a selection is not one row: the options that act - // on it belong to the screen, and the bottom is where a thumb already is. + // Beside nothing in particular, because a selection is not one row: the options that act on + // it belong to the screen, and the bottom is where a thumb already is. if (selected.isNotEmpty()) { val picked = (sessions as? LoadState.Loaded)?.value?.filter { it.id in selected }.orEmpty() @@ -486,8 +457,7 @@ fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (Sessio * What can be done to the rows that are selected. * * Delete and Import only, for now: they are the two things this screen has ever done to a session, - * and an option that appears here has to work on every row in a selection rather than on the one - * somebody was thinking of. + * and an option that appears here has to work on every row in a selection. */ @Composable private fun SelectionBar( @@ -567,26 +537,23 @@ private fun ImportableList( Modifier.fillMaxWidth() .padding(vertical = 4.dp) .combinedClickable( - // Off while something is happening to this row -- - // see [BusyItem], which draws that but deliberately - // leaves the gestures alone so the list still - // scrolls. + // Off while something is happening to this row -- see + // [BusyItem], which draws that but leaves the gestures + // alone so the list still scrolls. enabled = running[session.id] == null, onClick = { if (settling(session.id)) return@combinedClickable // In selection mode a tap is a selection, so the - // reader is never one mis-tap away from starting - // a CLI they were only picking rows for. + // reader is never one mis-tap away from starting a + // CLI they were only picking rows for. // - // Outside it, a tap continues the session -- - // except on a row that cannot be continued, - // where it selects instead. That row's only - // remaining action is Delete, and a tap that - // did nothing at all would be a worse answer - // than one that offers the thing it can do. - // Two `--resume` processes on one transcript - // each replay the other's writes, which is why - // this must not simply try. + // Outside it, a tap continues the session -- except + // on a row that cannot be continued, where it + // selects instead. That row's only remaining action + // is Delete, and a tap that did nothing at all + // would be a worse answer. Two `--resume` processes + // on one transcript each replay the other's writes, + // which is why this must not simply try. if (selecting || session.inUse == "yes") onToggle(session) else onOpen(session) @@ -606,8 +573,7 @@ private fun ImportableList( Spacer(Modifier.width(8.dp)) // Beside the title, because "which one was I just in" is // the question this list answers and the order already - // reflects it -- the reader should be able to see the - // ordering they are being given rather than infer it. + // reflects it. Text( relativeTime(session.modified), style = MaterialTheme.typography.bodySmall, @@ -616,12 +582,11 @@ private fun ImportableList( } Spacer(Modifier.height(4.dp)) // The path first, and the only thing here that is cut: it is - // one long value with no natural break, where the lines below - // it are short enough to wrap readably. Cut at the head, - // because a path is identified by its tail and these all - // share a long prefix. By the row's real width rather than a - // character count, which was one guess for every font size - // and screen. + // one long value with no natural break. Cut at the head, + // because a path is identified by its tail and these all share + // a long prefix. By the row's real width rather than a + // character count, which was one guess for every font size and + // screen. session.cwd .takeIf { it.isNotEmpty() } ?.let { cwd -> @@ -648,8 +613,7 @@ private fun ImportableList( color = warningColor, ) } - // Reported where it happened, in the server's own words, the - // way every other failure in this app is shown. + // Reported where it happened, in the server's own words. errors[session.id]?.let { message -> Spacer(Modifier.height(4.dp)) Text( @@ -673,14 +637,14 @@ private fun statsOf(session: Importable): String = // Said, because a name and a last message are different claims: one describes the // session, the other is only what happened last in it. if (session.named) "named" else null, - // What continuing it costs, which is the question this list is really asked. First - // of the measurements for that reason, and absent rather than zero when nothing has - // been measured -- a session with no turns yet has no figure, not a figure of none. + // What continuing it costs, which is the question this list is really asked. Absent + // rather than zero when nothing has been measured -- a session with no turns yet has no + // figure, not a figure of none. session.contextTokens?.let { "${it / 1000}k context" }, "${session.lines} lines", // Kept beside the context figure because the two disagree usefully: most of a large - // transcript is history from before a compaction, which the model is no longer - // given, so a big file can be cheap to continue and a small one expensive. + // transcript is history from before a compaction, so a big file can be cheap to + // continue. humanSize(session.bytes), ) .joinToString(" · ") @@ -689,16 +653,14 @@ private fun statsOf(session: Importable): String = * Why this session might not be safe to take, if it isn't. * * Words rather than only a colour: "open somewhere else" and "we could not check" differ in kind, - * and no shade distinguishes them. The colour is what makes it findable; the words are what make it - * actionable. + * and no shade distinguishes them. */ private fun warningOf(session: Importable): String? = when (session.inUse) { // What was measured is that a live process on that machine holds this session open. Which - // process is not measured, so it isn't claimed: "a terminal — close it there first" sent - // people looking for a window that need not exist. It is just as likely another agent, or - // this app on a session it spawned. Naming a place the reader then can't find turns a - // correct refusal into a wrong instruction. + // process is not measured, so it isn't claimed: "a terminal -- close it there first" sent + // people looking for a window that need not exist. Naming a place the reader then can't + // find turns a correct refusal into a wrong instruction. "yes" -> "something on that machine is running it" "unknown" -> "can't tell if it's open" else -> null diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Languages.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Languages.kt index 2c8d109..a1d54b1 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Languages.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Languages.kt @@ -49,10 +49,9 @@ data class Rules( /** Tokens that open a comment running to the end of the line. */ val lineComments: List = emptyList(), /** - * Whether [lineComments] count only at the start of a word. - * - * The shells need it: `$#`, `${#x}` and `a#b` are not comments, and greying the rest of those - * lines is one of the mistakes this scanner exists to stop. + * Whether [lineComments] count only at the start of a word. The shells need it: `$#`, `${#x}` + * and `a#b` are not comments, and greying the rest of those lines is one of the mistakes this + * scanner exists to stop. */ val lineCommentsAtWordStart: Boolean = false, val blockComment: BlockComment? = null, @@ -63,8 +62,8 @@ data class Rules( val rawStrings: Boolean = false, /** * Rust: `'` opens a character literal only when a backslash or one character and a `'` follow. - * Otherwise it is a lifetime or a label and no string starts -- without this, `'a` opens a - * string that runs to the next apostrophe in the block. + * Otherwise it is a lifetime or a label -- without this, `'a` opens a string that runs to the + * next apostrophe in the block. */ val lifetimes: Boolean = false, ) @@ -91,11 +90,10 @@ enum class Attributes { * The spans [language] colours in [code] -- the one way to ask, whatever the language turns out to * be made of. * - * Nearly every language here is tokens: keywords, strings and comments, which is a row of [RULES] - * and the one shared scanner in [scan]. Markdown has none of those, and what a character means - * there depends on where on the line it sits, so it brings a scanner of its own ([scanMarkdown]). - * That is the whole extension point -- a new language is a row of rules or an entry in [SCANNERS], - * and no caller learns which one it got. + * Nearly every language here is tokens, which is a row of [RULES] and the one shared scanner. + * Markdown has none of those, and what a character means there depends on where on the line it + * sits, so it brings a scanner of its own. That is the whole extension point -- a new language is a + * row of rules or an entry in [SCANNERS], and no caller learns which one it got. */ fun spansOf(code: String, language: Language): List = SCANNERS.getValue(language)(code) @@ -140,8 +138,8 @@ private val RULES: Map by lazy { blockComment = C_STYLE, quotes = listOf(DOUBLE, SINGLE), ), - // `###` opens and closes a block comment and `#` opens a line one, which is why the - // scanner tries the block opener first. + // `###` opens and closes a block comment and `#` opens a line one, which is why the scanner + // tries the block opener first. Language.COFFEESCRIPT to Rules( keywords = KEYWORDS_COFFEESCRIPT, @@ -162,8 +160,8 @@ private val RULES: Map by lazy { keywords = KEYWORDS_FISH, lineComments = listOf("#"), lineCommentsAtWordStart = true, - // fish's single quotes escape only `\'` and `\\`, which is what "skip the - // character after a backslash" already does. + // fish's single quotes escape only `\'` and `\\`, which is what "skip the character + // after a backslash" already does. quotes = listOf(DOUBLE, SINGLE), ), Language.GO to @@ -288,10 +286,9 @@ private val RULES: Map by lazy { * The keyword sets. * * Every list below other than RON, TOML, fish and JSON came from dev.snipme:highlights 1.1.0 - * (`SyntaxTokens.kt`, Apache-2.0), the library this scanner replaced, so that no fence which is - * coloured today turns plain. Entries that are not plain words were dropped -- Kotlin's `as?`, - * `!in` and `!is`, Swift's `#if` family, Ruby's `defined?`, CoffeeScript's `=` and `->` -- because - * the word scanner cannot reach them and the library only matched them by luck. + * (Apache-2.0), the library this scanner replaced, so that no fence which is coloured today turns + * plain. Entries that are not plain words were dropped -- Kotlin's `as?`, Swift's `#if` family, + * Ruby's `defined?` -- because the word scanner cannot reach them. */ private fun words(list: String): Set = list.split(Regex("\\s+")).filterNot(String::isEmpty).toSet() diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/LoadState.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/LoadState.kt index 25362c0..e42aaaa 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/LoadState.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/LoadState.kt @@ -8,7 +8,7 @@ package com.example.aiapp * empty list, which is the one wrong answer that looks like a right one. * * [Loading] and [Error] carry no payload, so they are `LoadState` and this is covariant in - * [T]: one `LoadState.Loading` serves every screen rather than each needing its own. + * [T]: one `LoadState.Loading` serves every screen. */ sealed class LoadState { data object Loading : LoadState() diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/MainActivity.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/MainActivity.kt index c998e89..f16b3c5 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/MainActivity.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/MainActivity.kt @@ -28,14 +28,13 @@ import androidx.compose.ui.layout.layout import androidx.core.view.WindowCompat class MainActivity : ComponentActivity() { - // Bumped whenever enrollment lands via an aiapp:// intent so the - // composition below re-reads the stored settings. + // Bumped whenever enrollment lands via an aiapp:// intent so the composition below re-reads the + // stored settings. private var settingsVersion by mutableIntStateOf(0) - // The session a notification tap asked for, or null if nothing has. The - // serial is what makes a second tap on the same session's notification a - // second request: without it the two compare equal and the composition - // below has nothing to react to. + // The session a notification tap asked for, or null if nothing has. The serial is what makes a + // second tap on the same session's notification a second request: without it the two compare + // equal and the composition below has nothing to react to. private var openRequest by mutableStateOf(null) private var opens = 0 @@ -43,8 +42,8 @@ class MainActivity : ComponentActivity() { private var shareRequest by mutableStateOf(null) private var shares = 0 - // Registered up front since permission launchers must be registered - // before the activity reaches STARTED. + // Registered up front since permission launchers must be registered before the activity reaches + // STARTED. private val requestLocalNetworkPermission = registerForActivityResult(ActivityResultContracts.RequestPermission()) {} @@ -52,8 +51,8 @@ class MainActivity : ComponentActivity() { * The service starts either way, and posts nothing if this is refused. * * Deliberately not gated on the answer: the permission can be granted later from Android's own - * settings, and a service that only ever started at the moment it was granted would then stay - * down until the app was launched again -- which is the case notifications exist to avoid. + * settings, and a service that only ever started at the moment it was granted would stay down + * until the app was launched again. */ private val requestNotificationPermission = registerForActivityResult(ActivityResultContracts.RequestPermission()) {} @@ -64,21 +63,17 @@ class MainActivity : ComponentActivity() { // Before anything else that could throw, so the first crash of a launch is caught too. installCrashLog(this) - // Transparent status bar on every version; the Surface below paints - // through underneath it and content insets itself. Same reasoning - // as dev-updater's MainActivity. + // Transparent status bar on every version; the Surface below paints through underneath it + // and content insets itself. Same reasoning as dev-updater's MainActivity. enableEdgeToEdge() - // Dark status-bar icons only over a light background, decided from the scheme rather - // than fixed. It was hardcoded to `true` -- dark icons -- which was right against the - // default light surface and became unreadable the moment the app wore Catppuccin Mocha. - // Asking the colour means a future palette change cannot reintroduce that: whatever - // `background` becomes, the icons follow it. + // Dark status-bar icons only over a light background, decided from the scheme rather than + // fixed. It was hardcoded to `true`, which was right against the default light surface and + // became unreadable the moment the app wore Catppuccin Mocha. WindowCompat.getInsetsController(window, window.decorView).isAppearanceLightStatusBars = AiAppColors.background.luminance() > 0.5f - // Android 17+ silently drops local-network traffic without this; - // requested up front because a denial is invisible at the socket - // layer (it just times out). + // Android 17+ silently drops local-network traffic without this; requested up front because + // a denial is invisible at the socket layer (it just times out). if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.CINNAMON_BUN) { requestLocalNetworkPermission.launch(Manifest.permission.ACCESS_LOCAL_NETWORK) } @@ -88,16 +83,14 @@ class MainActivity : ComponentActivity() { } handleIntent(intent) - // After enrollment, so a first launch that arrives with a token - // starts the service with something to connect to rather than - // stopping it and waiting for the next launch. + // After enrollment, so a first launch that arrives with a token starts the service with + // something to connect to rather than stopping it and waiting for the next launch. NotificationService.sync(this) setContent { // Selection colours with the theme rather than at each place text is drawn: the - // transcript is one selection container, and a selection that ran from a reply into - // the code block under it would otherwise change colour halfway. See - // [AiAppSelectionColors]. + // transcript is one selection container, and a selection that ran from a reply into the + // code block under it would otherwise change colour halfway. MaterialTheme(colorScheme = AiAppColors) { CompositionLocalProvider(LocalTextSelectionColors provides AiAppSelectionColors) { Surface(modifier = Modifier.fillMaxSize()) { @@ -107,8 +100,7 @@ class MainActivity : ComponentActivity() { // the frame's draw phase is where Compose's measurement lands, and // a report saying "draw is high" cannot otherwise say whether the // cost is the transcript or the chrome around it. The keyboard is - // the case that made it matter -- every frame of the IME animation - // relays out and re-records this whole box. + // the case that made it matter. Modifier.layout { measurable, constraints -> val started = System.nanoTime() val placeable = measurable.measure(constraints) @@ -135,19 +127,16 @@ class MainActivity : ComponentActivity() { } .fillMaxSize() .statusBarsPadding() - // The gesture strip at the bottom of most - // phones. Without it the send row sits under - // the swipe area, where a tap is as likely to - // navigate away as to press a button. + // The gesture strip at the bottom of most phones. Without it + // the send row sits under the swipe area, where a tap is as + // likely to navigate away as to press a button. // // No imePadding here, deliberately: applied at the root it // resizes this whole box on every frame of the keyboard // animation, which re-measures, re-places and re-records every - // screen's entire tree per frame -- measured above as most of - // the frame budget. Each screen takes the keyboard itself - // (AppRoot wraps the ordinary ones; the session screen moves - // only its composer and transcript), so the per-frame cost is - // scoped to what actually moves. + // screen's entire tree per frame. Each screen takes the + // keyboard itself, so the per-frame cost is scoped to what + // actually moves. .navigationBarsPadding() ) { AppRoot(settingsVersion, openRequest, shareRequest) @@ -158,9 +147,8 @@ class MainActivity : ComponentActivity() { } } - // launchMode="singleTop": an enrollment scan, or a notification tapped - // while the app is open, lands here rather than in a second activity - // instance. + // launchMode="singleTop": an enrollment scan, or a notification tapped while the app is open, + // lands here rather than in a second activity instance. override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) handleIntent(intent) @@ -171,8 +159,7 @@ class MainActivity : ComponentActivity() { * * Three things arrive this way -- a share from another app, and an `aiapp://` URI that is * either an enrollment code or a notification naming a session. The URIs are told apart by host - * rather than by two entry points, so a further kind is a branch here rather than another - * intent to remember to handle. + * rather than by two entry points, so a further kind is a branch here. */ private fun handleIntent(intent: Intent?) { intent ?: return @@ -195,8 +182,8 @@ class MainActivity : ComponentActivity() { } saveServerSettings(this, settings) settingsVersion++ - // Enrolling is the moment there is a backend to watch, and - // re-enrolling elsewhere is the moment the old one stops being it. + // Enrolling is the moment there is a backend to watch, and re-enrolling elsewhere is the + // moment the old one stops being it. NotificationService.sync(this) Toast.makeText(this, "Enrolled with ${settings.baseUrl}", Toast.LENGTH_LONG).show() } diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/MainScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/MainScreen.kt index a4f8596..f18ef22 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/MainScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/MainScreen.kt @@ -29,12 +29,10 @@ import androidx.lifecycle.repeatOnLifecycle * The app's root: one title, and four views of the backend behind it. * * These were four screens reached by four words in a row under the title, and the row was already - * full -- the comment it replaced recorded that a fifth would have to go somewhere else. Tabs say - * the same thing in less space and say one more thing besides: that these are places to be rather - * than errands to run. Sessions, the machine's importable history, the models on it and the - * machines themselves are all *the same backend*, looked at four ways, and none of them is a step - * down from another. Settings still is a step down, which is why it stays a pushed screen and keeps - * its own Back. + * full. Tabs say the same thing in less space and say one more thing besides: that these are places + * to be rather than errands to run. Sessions, the machine's importable history, the models on it + * and the machines themselves are all *the same backend*, looked at four ways, and none is a step + * down from another. Settings still is, which is why it stays a pushed screen with its own Back. */ private enum class MainTab(val label: String) { Sessions("Sessions"), @@ -61,17 +59,12 @@ fun MainScreen( // // What these four draw is a snapshot of a backend they are not connected to, so it is only as // fresh as the last answer -- and a *failed* answer is the one that outstays its welcome. A - // phone that was away while the tunnel was down, or that fetched before the network came up, - // came back to "Couldn't reach the server" sitting at the top of a list the server would now - // answer for perfectly well, and nothing took it off until somebody pressed Refresh. A stale - // failure is worse than a stale list: it is a claim about right now. + // phone that was away while the tunnel was down came back to "Couldn't reach the server" + // sitting at the top of a list the server would now answer for perfectly well. A stale failure + // is worse than a stale list: it is a claim about right now. // // Through the same token the Refresh button uses, so this is one instruction the tabs already - // understand rather than a second path into each of them -- which is also what makes it cover - // all four rather than the one the report came from. - // - // Not on the first entry: the tab composing already asks, and bumping here would make every - // cold start fetch twice. + // understand. Not on the first entry: the tab composing already asks. val lifecycleOwner = LocalLifecycleOwner.current LaunchedEffect(lifecycleOwner) { var opening = true @@ -81,9 +74,8 @@ fun MainScreen( } } - // A tab the app put over the list has to step back to it rather than fall through to the - // system default, which closes the app -- that reads as a crash to somebody who only meant to - // get back to their sessions. Nested inside AppRoot's handler, so it wins while it is enabled. + // A tab the app put over the list has to step back to it rather than fall through to the system + // default, which closes the app. Nested inside AppRoot's handler, so it wins while enabled. BackHandler(enabled = tab != MainTab.Sessions) { tab = MainTab.Sessions } Column(Modifier.fillMaxSize()) { @@ -98,20 +90,18 @@ fun MainScreen( ) // Glyphs rather than the words they replaced: neither ever changes, both are read // faster than they are spelled, and together they take the width that let the title - // keep its own line. They sit on the title's row because they act on the whole - // screen -- everything below this row is one tab's business, and a control belongs - // with the thing it acts on. - // Flush against each other: a glyph button carries its own padding, so two of them - // side by side already have two rings between their marks and one ring plus this - // row's padding to the screen edge. + // keep its own line. They sit on the title's row because they act on the whole screen. + // + // Flush against each other: a glyph button carries its own padding, so two side by side + // already have two rings between their marks. Row { GlyphButton(REFRESH_GLYPH, "Refresh", { refreshToken++ }) GlyphButton(SETTINGS_GLYPH, "Settings", onSettings) } } - // What is waiting to be attached, and what to do about it. Said here because the list - // below is where the choice is made, and a share that arrived with nothing on screen - // saying so would read as a tap that did nothing. + // What is waiting to be attached, and what to do about it. Said here because the list below + // is where the choice is made, and a share that arrived with nothing on screen saying so + // would read as a tap that did nothing. share?.let { Text( it.summary() + " -- open the session it belongs in.", @@ -127,8 +117,8 @@ fun MainScreen( .padding(12.dp), ) } - // Primary rather than the plain TabRow, which is deprecated in favour of the two that - // say where they sit: these are the app's top-level destinations. + // Primary rather than the plain TabRow, which is deprecated in favour of the two that say + // where they sit: these are the app's top-level destinations. PrimaryTabRow(selectedTabIndex = tab.ordinal) { MainTab.entries.forEach { entry -> Tab( @@ -139,10 +129,9 @@ fun MainScreen( } } - // Refreshing means "ask again about what I am looking at", so the button feeds the tab - // that is showing. The token from above means something else already changed what these - // show; the two are the same instruction to the tab below, so they are summed rather than - // tracked apart -- either one moving moves the sum, which is all a tab watches. + // Refreshing means "ask again about what I am looking at", so the button feeds the tab that + // is showing. The token from above means something else already changed what these show; + // the two are the same instruction, so they are summed rather than tracked apart. val token = reloadToken + refreshToken when (tab) { MainTab.Sessions -> diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Markdown.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Markdown.kt index 1509efd..07512dd 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Markdown.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Markdown.kt @@ -69,13 +69,10 @@ import org.intellij.markdown.flavours.gfm.GFMTokenTypes * * [live] is the reply still arriving, and two things are different for it. Its parse is incremental * -- see [LiveParse] -- so a delta costs a parse of the block it landed in rather than of the whole - * message. And its pieces get a layer each: when drawing is invalidated, only the piece that - * changed is re-recorded instead of the whole reply, which is worth a great deal while every delta - * invalidates the message and a finished one can be twenty-five screens tall. It is worth nothing - * once the message stops changing -- measured on a Pixel 9 Pro XL, whole rows were re-recorded 65 - * times in fifty seconds of reading -- and it is not free: each layer is a layout node and a - * display list held for the life of the row, and live node count is what the per-frame cost of the - * transcript scales with. + * message. And its pieces get a layer each, so only the piece that changed is re-recorded. That is + * worth a great deal while every delta invalidates the message and worth nothing once it stops + * changing -- and it is not free: each layer is a layout node and a display list held for the life + * of the row, and live node count is what the transcript's per-frame cost scales with. */ @Composable fun MarkdownText( @@ -92,8 +89,8 @@ fun MarkdownText( var previousSegment: Segment? = null segments.forEachIndexed { at, segment -> val nextContinues = segments.getOrNull(at + 1)?.continues == true - // Only the tail is still being written; a frozen segment is finished text that - // happens to sit in a live reply, and it takes its colours now. See [MarkdownRoot]. + // Only the tail is still being written; a frozen segment is finished text that happens + // to sit in a live reply, and it takes its colours now. MarkdownRoot(segment.parse, replies, streaming = live && at == segments.lastIndex) { segment.pieces.forEachIndexed { index, piece -> val gap = @@ -103,9 +100,9 @@ fun MarkdownText( if (segment.continues) 0.dp else BLOCK_SPACING else -> gapBefore(previous, piece) } - // Keyed by where the piece starts in the message rather than by its position - // in this column, so a delta landing in the last block leaves every other - // piece's composition alone -- and a block keeps its key when it freezes. + // Keyed by where the piece starts in the message rather than by its position in + // this column, so a delta landing in the last block leaves every other piece's + // composition alone -- and a block keeps its key when it freezes. key(segment.start, piece) { MarkdownPiece( segment.parse, @@ -137,8 +134,7 @@ fun MarkdownText( * A stretch of a message with a parse of its own: the whole of a settled message, or one block, the * finished items of one list, or the unfinished tail of a live one. [start] is where [text] begins * in the message. [continues] says the first piece is an item of the list the segment before it - * ended with, so the two draw as one list: no block gap between them, and neither the item above - * the seam nor the one below it takes the padding of a list's edge. + * ended with, so the two draw as one list. */ private class Segment( val text: String, @@ -154,14 +150,11 @@ private class Segment( * * The first parse has to be inline. The renderer's own asynchronous path draws an empty loading * slot until its result arrives, so a row is measured at nothing before it is measured at its real - * height, and the transcript above it collapses and springs back. Seen with five replies on screen - * at once, every one of them blank, the whole conversation shrunk to fit a single screen; a moment - * later it was all there again. That is the "skipping up and down" this list must never do. + * height, and the transcript above it collapses and springs back -- seen with five replies on + * screen at once, the whole conversation shrunk to fit a single screen. * * Every parse after the first is off the composing thread, and the row keeps drawing the parse it - * already has until the new one lands, so there is never a frame without a height. What is on - * screen is always a real prefix of the reply rather than a guess at it; it is simply one parse - * behind. + * already has until the new one lands, so there is never a frame without a height. */ @Composable private fun liveSegments(text: String): List { @@ -187,24 +180,18 @@ private fun liveSegments(text: String): List { * Reparsing the whole message per delta was fine for a short reply and not for a long one: a * twenty-five-screen reply parses in tens of milliseconds, hundreds of times, and although that ran * off the composing thread it was every core busy while the frame's own thread waited for one. - * Markdown's blocks make the cut safe: a top-level block that another block has started *after* is - * finished -- nothing appended later can reach back into it, since a paragraph ends at the blank - * line or the block that interrupts it, a fence at its closing fence, a list at the first line that - * is neither an item nor indented under one. So every block but the last is [frozen] with the parse - * that finished it, and only the tail -- the last block and whatever has arrived since -- is parsed - * again. * - * A list is cut once more, at its last item, by the same reasoning one level down: an item is - * finished once the next item has begun, since a line can only continue the item it is indented - * under or start a new one. Without this a reply that is one long list -- forty sources -- parsed - * the whole list per delta, and a list streams as forty paragraphs would. The item the cut lands on - * has to have begun in earnest: a bare `-` is an empty item now and the first character of a - * paragraph line once `-x` arrives, and cutting on it would draw that line as a new item. + * Markdown's blocks make the cut safe: a top-level block that another block has started *after* is + * finished -- nothing appended later can reach back into it. So every block but the last is + * [frozen] with the parse that finished it, and only the tail is parsed again. + * + * A list is cut once more, at its last item, by the same reasoning one level down. Without this a + * reply that is one long list -- forty sources -- parsed the whole list per delta. The item the cut + * lands on has to have begun in earnest: a bare `-` is an empty item now and the first character of + * a paragraph line once `-x` arrives. * * What the cut gives up is one thing: a reference definition arriving later than a link that uses - * it, since the frozen block's parse never sees it. The link draws as its brackets until the reply - * settles and is parsed whole by [warm], which is the same moment every other transient of - * streaming is put right. + * it. The link draws as its brackets until the reply settles and is parsed whole by [warm]. */ private class LiveParse( val text: String, @@ -217,8 +204,8 @@ private class LiveParse( get() = frozen + tail fun advanceTo(next: String): LiveParse { - // Anything but an append to what was frozen -- a message replaced, a stream reset -- - // starts over. + // Anything but an append to what was frozen -- a message replaced, a stream reset -- starts + // over. if (!next.regionMatches(0, text, 0, consumed)) return whole(next) val tailText = next.substring(consumed) val parse = parseMarkdown(tailText) @@ -264,8 +251,7 @@ private class LiveParse( /** * The piece of the tail still being written: the last item of a list of several, or the first - * piece of the last block when there is more than one block. Null when nothing before it is - * finished, so the tail stays whole. + * piece of the last block when there is more than one. Null when nothing before it is finished. */ private fun openPiece(parse: State.Success, all: List): Piece? { val last = all.lastOrNull() ?: return null @@ -315,28 +301,24 @@ fun MarkdownPiece( * The renderer's own environment -- its colours, type scale, dimensions, component table and * reference links -- around whatever draws pieces of [parse]. * - * The parsing is the library's. Markdown is somebody else's specification, and a hand-written + * The parsing is the library's: markdown is somebody else's specification, and a hand-written * parser would get the edge cases wrong one case at a time. So is the environment: the element * composables its dispatch reaches read these locals, and providing them once here is what lets a * piece be drawn anywhere -- in a message's column, or as one item of the transcript list. - * Everything below this is the mapping onto the app's palette and type scale. * * The locals are provided directly rather than through the renderer's `Markdown()` composable, - * which was the last of its composables on the hot path and was here only to provide them. What - * that buys is that nothing between a piece and the screen is the library's but the leaf - * composables named in the component table, so a different parser could stand behind [State] - * without the renderer's entry point being involved. + * which was the last of its composables on the hot path and was here only to provide them. So + * nothing between a piece and the screen is the library's but the leaf composables named in the + * component table. * - * Colours come from the theme rather than from the renderer's defaults, so code, links and rules - * are the same Catppuccin values the rest of the app uses. Nothing here picks a colour of its own. + * Colours come from the theme rather than the renderer's defaults. Nothing here picks one of its + * own. * * [streaming] says this parse is the part of a reply still being written, which only the fences - * care about: lexing is proportional to how much code there is, and a fence still arriving is - * re-lexed at every delta on the composing thread. Measured streaming a two-hundred-line Kotlin - * fence: **13.7 seconds** of lexing across the turn, 211 of them, the worst 177ms -- for colours on - * text that was being replaced as fast as they were computed. So a fence still being written is - * drawn plain and takes its colours when the block freezes, which is the same bargain [LiveParse] - * already makes for a reference link defined at the foot of a message. + * care about: lexing is proportional to how much code there is. Measured streaming a two-hundred- + * line Kotlin fence: **13.7 seconds** of lexing across the turn, 211 of them, the worst 177ms -- + * for colours on text being replaced as fast as they were computed. So a fence still being written + * is drawn plain and takes its colours when the block freezes. */ @Composable private fun MarkdownRoot( @@ -354,45 +336,40 @@ private fun MarkdownRoot( CompositionLocalProvider( LocalReferenceLinkHandler provides parse.referenceLinkHandler, LocalMarkdownPadding provides markdownPadding(), - // Read by the renderer's own text composable, which no paragraph reaches any more, and - // by its checkbox. Provided so a path that does reach them draws no image rather than - // failing to compose. + // Read by the renderer's own text composable, which no paragraph reaches any more, and by + // its checkbox. Provided so a path that does reach them draws no image rather than failing + // to compose. LocalImageTransformer provides remember { NoOpImageTransformerImpl() }, LocalMarkdownAnimations provides markdownAnimations(), LocalMarkdownColors provides markdownColor( text = MaterialTheme.colorScheme.onSurface, dividerColor = MaterialTheme.colorScheme.outlineVariant, - // The dark surface every verbatim thing in this app sits on -- see [rawSurface], - // and the tool call above this reply, which now matches. `surfaceVariant` was - // exactly a card's own fill, so a fenced block inside a tool call had no - // background at all and one in a reply read as a step *up* out of the page. + // The dark surface every verbatim thing in this app sits on -- and the tool call + // above this reply, which now matches. `surfaceVariant` was exactly a card's own + // fill, so a fenced block inside a tool call had no background at all. codeBackground = rawSurface, // The same colour. Not drawn by the renderer as a span background but by - // [LinkedText] behind the text, so a selection lands on top of it as it does on a - // fenced block -- see `appendCodeChip`. + // [LinkedText] behind the text, so a selection lands on top of it -- see + // `appendCodeChip`. inlineCodeBackground = rawSurface, - // The same tint a code block gets, rather than the renderer's 2%-alpha default: - // two adjacent tints that differ by a fiftieth read as one flat block on a phone, - // so the table would have had a border-less grid and nothing saying where it began. + // The same tint a code block gets, rather than the renderer's 2%-alpha default: two + // adjacent tints that differ by a fiftieth read as one flat block on a phone. tableBackground = MaterialTheme.colorScheme.surfaceVariant, ), LocalMarkdownTypography provides markdownTypography( // A ladder that starts near the body text and descends, because these are headings // inside a chat message rather than the top of a document. The renderer's defaults - // are the Material *display* styles -- `#` came out at 57sp and `##` at 45sp, which - // is bigger than this app's own screen titles and reads as the reply shouting. - // - // Every step is a different size, so two levels of nesting never draw the same: - // one clear step per level is the whole job of a heading. + // are the Material *display* styles -- `#` came out at 57sp, bigger than this app's + // own screen titles. Every step is a different size, so two levels of nesting never + // draw the same. h1 = MaterialTheme.typography.headlineSmall, h2 = MaterialTheme.typography.titleLarge, h3 = MaterialTheme.typography.titleMedium, h4 = MaterialTheme.typography.titleSmall, h5 = MaterialTheme.typography.labelMedium, h6 = MaterialTheme.typography.labelSmall, - // Body text at the size everything else in the transcript uses. text = body, paragraph = body, ordered = body, @@ -400,16 +377,10 @@ private fun MarkdownRoot( list = body, table = body, // Code in a monospace face, in the ordinary text colour. The face and the tinted - // background are what say "this is code"; colour is not, and it used to be green - // -- the palette's colour for a *literal*. A block of code is not a literal, it - // is text that happens to be code, and painting all of it green said the whole - // block was one. Where a literal really does appear inside code, the thing that - // should colour it is a syntax highlighter looking at the code, which is exactly - // what a tool call's input already gets from `catppuccinSyntax`. - // - // The colour rides on the style here rather than in `markdownColor`, which - // stopped carrying `codeText`/`inlineCodeText`/`linkText` when the renderer moved - // them onto the typography. + // background are what say "this is code"; colour is not, and it used to be green -- + // the palette's colour for a *literal*. A block of code is not a literal, and + // painting all of it green said the whole block was one. Where a literal really + // does appear inside code, what should colour it is a syntax highlighter. code = MaterialTheme.typography.bodyMedium.copy( fontFamily = FontFamily.Monospace, @@ -436,31 +407,26 @@ private fun MarkdownRoot( LocalMarkdownDimens provides markdownDimens( // Half the renderer's 16dp. Padding is charged on both sides of every cell, so at - // the default a fifth of the narrowest column went on space rather than on words - // -- and the narrowest column is where the wrapping below has the least room. + // the default a fifth of the narrowest column went on space rather than on words. tableCellPadding = 8.dp, // What a column narrows to before the table starts scrolling sideways instead. It // is the floor, not the width: a table with room to spare spreads across it. // // Down from the renderer's 160dp, and the number is a measurement rather than a // taste. A phone is about 410-450dp wide and a card takes some of that, so 160dp - // makes even a three-column table -- the commonest shape there is -- scroll, while - // 136dp fits three across the phone this app is read on. Four and up still scroll, - // which is the right answer for genuinely too many columns: squeezing six columns - // into a phone would give every cell one word per line. - // - // Narrower would fit more, and stop being readable. This is the widest minimum - // that keeps three columns on screen, which is the trade the number is making. + // makes even a three-column table scroll, while 136dp fits three across the phone + // this app is read on. Four and up still scroll, which is the right answer for + // genuinely too many columns. This is the widest minimum that keeps three on + // screen. tableCellWidth = 136.dp, ), LocalMarkdownComponents provides markdownComponents( // The m3 renderer's own default, restored: supplying `components` at all replaces - // the whole set, and this is the only member of it the Material layer overrides. + // the whole set, and this is the only member the Material layer overrides. checkbox = { MarkdownCheckBox(it.content, it.node, it.typography.text) }, // Everything that draws a run of text, so a link is a span rather than a node -- - // see [LinkedText]. Setext headings take the same styles as `#` and `##`, which - // is the renderer's own pairing. + // see [LinkedText]. Setext headings take the same styles as `#` and `##`. text = { LinkedText(it, it.typography.text) }, paragraph = { LinkedText(it, it.typography.paragraph) }, heading1 = { LinkedHeading(it, it.typography.h1) }, @@ -471,8 +437,8 @@ private fun MarkdownRoot( heading6 = { LinkedHeading(it, it.typography.h6) }, setextHeading1 = { LinkedHeading(it, it.typography.h1) }, setextHeading2 = { LinkedHeading(it, it.typography.h2) }, - // Lists are ours wherever the renderer's dispatch meets one -- inside a quote -- - // so they draw like the top-level ones the transcript cuts into items. + // Lists are ours wherever the renderer's dispatch meets one -- inside a quote -- so + // they draw like the top-level ones the transcript cuts into items. orderedList = { MarkdownList(it.content, it.node, it.listDepth) }, unorderedList = { MarkdownList(it.content, it.node, it.listDepth) }, table = { LinkedTable(it.content, it.node, it.typography.table) }, @@ -491,14 +457,12 @@ private fun MarkdownRoot( /** * A table: its rows, on the renderer's tinted, rounded background, as wide as its columns need. * - * Each column has a floor ([markdownDimens]'s `tableCellWidth`), so the table is at least - * columns-times-floor wide; narrower than the room it has, it spreads to fill it, and wider, it - * scrolls sideways rather than squeezing. The renderer decided that with a `BoxWithConstraints`, - * which is a subcomposition; here it is one layout modifier, and the trick is where it sits. - * `fillMaxWidth` fixes the minimum width to the room available, the horizontal scroll passes that - * minimum through to its content while lifting the maximum to unbounded, and the modifier after it - * reads the minimum back as the room and sizes the rows to the larger of that and the floor. The - * scroll then has exactly the overflow to scroll, which is none when the table fits. + * Each column has a floor, so the table is at least columns-times-floor wide; narrower than the + * room it has, it spreads to fill it, and wider, it scrolls sideways rather than squeezing. The + * renderer decided that with a `BoxWithConstraints`, which is a subcomposition; here it is one + * layout modifier. `fillMaxWidth` fixes the minimum width to the room available, the horizontal + * scroll passes that minimum through while lifting the maximum to unbounded, and the modifier after + * it reads the minimum back and sizes the rows to the larger of that and the floor. */ @Composable private fun LinkedTable(content: String, node: ASTNode, style: TextStyle) { @@ -539,19 +503,15 @@ private fun LinkedTable(content: String, node: ASTNode, style: TextStyle) { * One row of a table -- the header when [rowIndex] is zero -- with every cell a [LinkedText]. * * The renderer's own rows draw each cell at `maxLines = 1` with an ellipsis, which on a phone means - * most of a table is simply not readable: anything past about twenty characters ends in "..." with - * no way to see the rest, and an elided cell looks like a short one, so a table of measurements - * reads as a table of plausible shorter measurements. And they draw a link in a cell as its own - * layout node, the cost [LinkedText] exists to avoid. + * most of a table is simply not readable: an elided cell looks like a short one, so a table of + * measurements reads as a table of plausible shorter measurements. And they draw a link in a cell + * as its own layout node, the cost [LinkedText] exists to avoid. * * So: as many lines as the cell needs, cells aligned to the top of the row, because a two-line cell - * beside a one-line one centred the short one against the middle of the tall one and lost the line - * the reader was reading across. What the wrapping does *not* do is make a wide table fit; - * [LinkedTable] scrolls it instead, which is the right answer for too many columns -- wrapping a - * six-column table into the width of a phone would give every cell one word per line. + * beside a one-line one centred the short one against the middle of the tall one. What the wrapping + * does *not* do is make a wide table fit; [LinkedTable] scrolls it instead. * - * The semantics are the renderer's: each cell is an item of the table's collection, and a header - * cell is a heading. + * The semantics are the renderer's: each cell is an item of the table's collection. */ @Composable private fun LinkedTableRow(content: String, row: ASTNode, style: TextStyle, rowIndex: Int) { @@ -587,19 +547,14 @@ private fun LinkedTableRow(content: String, row: ASTNode, style: TextStyle, rowI * Parsing is the expensive half of drawing a reply, and it is expensive in proportion to how much * was written. Measured against a real Claude Code transcript on the emulator, one message took * **51ms** and several took 10-25ms, against 4.6ms for the short synthetic replies this was first - * tuned on -- so a page of history landing composed several rows that each stalled the frame they - * appeared in. That is the lag when a block loads. + * tuned on -- so a page of history landing composed several rows that each stalled the frame. * - * Nothing here changes what a row does when it has no answer waiting: it parses inline, on the - * composing thread, because a row measured at nothing before it is measured at its real height - * collapses the transcript above it. The point is only that by the time the reader scrolls to a - * row, the answer is usually already made -- [warm] runs on a background thread as each page of - * history arrives, which is seconds before anybody reaches the rows it brought. + * Nothing here changes what a row does when it has no answer waiting: it parses inline, because a + * row measured at nothing before its real height collapses the transcript above it. The point is + * only that by the time the reader scrolls to a row, the answer is usually already made. * * A miss is not stored, and that is what bounds this: the map holds one entry per message a page - * warmed and nothing else, so a reply still streaming cannot fill it with hundreds of copies of - * itself on the way to being finished. It is dropped with the screen, and emptied by the stream - * reset that drops the rows it describes. + * warmed, so a reply still streaming cannot fill it with hundreds of copies of itself. */ @Stable class ParsedReplies { @@ -607,14 +562,13 @@ class ParsedReplies { /** * How each message divides into pieces, cached beside its parse: [transcriptUnits] asks per - * fold, and walking the tree again each time is proportional to the message where a lookup is - * proportional to nothing. + * fold, and walking the tree again each time is proportional to the message. */ private val pieces = ConcurrentHashMap>() /** - * How each message divides into prose and memory notes, cached for the same reason as - * [piecesOf]: the regex scan behind [messageParts] is proportional to the message. + * How each message divides into prose and memory notes, cached for the same reason: the regex + * scan behind [messageParts] is proportional to the message. */ private val parts = ConcurrentHashMap>() @@ -627,7 +581,7 @@ class ParsedReplies { * much code was written -- a two-hundred-line Kotlin fence measured 174ms on the emulator -- * and a lazy list drops the composition of a block that scrolls away, so a `remember` inside * the fence paid that again every time the reader came back to it. Six times in one scroll, - * measured. [warm] fills this off the drawing thread before the row is reached. + * measured. */ private val highlights = ConcurrentHashMap() @@ -649,11 +603,10 @@ class ParsedReplies { * Whether [warm] has made everything drawing [text] as pieces will look up. * * What the flatten asks before drawing a reply that way. Cutting costs a parse of the whole - * message and the flatten runs on the composing thread -- so a reply not marked yet stays - * whole, drawing the parse it already has, until the screen has warmed it and re-flattens. An - * explicit mark rather than a peek into the parse cache, because a message with memory notes is - * warmed as its *parts*: nothing ever parses its full text, and inferring readiness from the - * cache left exactly that message unsplittable forever, re-warmed on every fold. + * message and the flatten runs on the composing thread, so a reply not marked yet stays whole + * until the screen has warmed it. An explicit mark rather than a peek into the parse cache, + * because a message with memory notes is warmed as its *parts*: nothing ever parses its full + * text, and inferring readiness from the cache left exactly that message unsplittable forever. */ fun splitReady(text: String): Boolean = text in ready @@ -668,9 +621,8 @@ class ParsedReplies { } /** - * [code] coloured for [language] -- the answer made ahead, or one made now. - * - * The key carries the language, because the same code lexes differently under two of them. + * [code] coloured for [language] -- the answer made ahead, or one made now. The key carries the + * language, because the same code lexes differently under two of them. */ fun highlighted(code: String, language: Language?): AnnotatedString = if (language == null) AnnotatedString(code) @@ -686,9 +638,9 @@ class ParsedReplies { * * Suspending, and yielding between messages, because "off the composing thread" is not the same * as "free". A page of history arrives as hundreds of parses at once -- 1.5 seconds of them in - * a twelve second scroll, measured on a Pixel 9 Pro XL -- and on the default dispatcher that is - * every core busy, with the frame's own thread waiting for one. That showed up as 21ms of - * `waited` at the 90th percentile: the frame could not start, rather than taking too long. + * a twelve second scroll on a Pixel 9 Pro XL -- and on the default dispatcher that is every + * core busy, with the frame's own thread waiting for one: 21ms of `waited` at the 90th + * percentile. */ suspend fun warm(texts: List) { texts.forEach { text -> @@ -697,9 +649,8 @@ class ParsedReplies { DebugStats.timed("markdown warmed") { parseMarkdown(it) } } // The fences too, and here rather than in a pass of its own: they are found in the - // parse this just made, and lexing one is the same kind of cost as parsing the - // message it is in -- proportional to what was written, and charged to the frame - // that first draws it if nobody paid it earlier. + // parse this just made, and lexing one is the same kind of cost as parsing the message + // it is in. fences(parse).forEach { (code, language) -> highlighted(code, language) } } } diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/MarkdownLinks.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/MarkdownLinks.kt index 9ea7ca5..417fb86 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/MarkdownLinks.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/MarkdownLinks.kt @@ -44,27 +44,22 @@ import org.intellij.markdown.flavours.gfm.GFMTokenTypes * * Compose turns every `LinkAnnotation` in a text into a layout node: a clipped, focusable, * hoverable, clickable box laid out against the glyphs, with its outline recomputed from the text - * layout. A paragraph of eight links is therefore nine nodes, and the renderer emits one of those - * annotations per link. Measured on the emulator against the same paragraphs with each link - * replaced by its label and address as plain words -- *more* text, the same gestures -- the linked - * version cost five times the worst measure (26.3ms against 5.2ms) and 1.7x the place time. On a - * Pixel 9 Pro XL that was the bump at the list of sources in a reply, and nowhere else in it. + * layout. A paragraph of eight links is therefore nine nodes, and the renderer emits one annotation + * per link. Measured on the emulator against the same paragraphs with each link replaced by its + * label and address as plain words -- *more* text, the same gestures -- the linked version cost + * five times the worst measure (26.3ms against 5.2ms) and 1.7x the place time. * * Here a link is the link colour and underline, a string annotation carrying its address, and one * tap detector for the whole text that asks the layout which character was under the finger. What * that gives up is a link being its own accessibility node with a pressed state; the app's link * style never defined a pressed style, so nothing visible changes. * - * Every block the renderer dispatches through its component table comes here, which includes the - * paragraphs inside lists, quotes and alerts, and so does every table cell through - * [LinkedTableRow]. Reference-style links are the one kind still drawn the renderer's way; it - * resolves those against its definitions. + * Every block the renderer dispatches through its component table comes here, and so does every + * table cell. Reference-style links are the one kind still drawn the renderer's way. * * An image is a link too, carrying its alt text. The app has no image loader and the renderer's * transformer was the no-op one, so an image in a reply drew as nothing at all -- a hole where the - * model put something, with no sign of what fell out. The link says what was there and where, and - * opens it. It also means no paragraph needs the renderer's own text composable, which existed to - * place inline images and charged every paragraph for the possibility. + * model put something. The link says what was there and where, and opens it. */ @Composable fun LinkedText(model: MarkdownComponentModel, style: TextStyle) { @@ -74,8 +69,7 @@ fun LinkedText(model: MarkdownComponentModel, style: TextStyle) { /** * A heading. Its words are a child of the heading node -- `ATX_CONTENT` after the `#`s, or * `SETEXT_CONTENT` above the underline -- and the inline builder draws nothing for a node type it - * does not know, so handed the heading node itself it draws an empty line. Which is what this did - * for a week. + * does not know, so handed the heading node itself it draws an empty line. */ @Composable fun LinkedHeading(model: MarkdownComponentModel, style: TextStyle) { @@ -113,18 +107,18 @@ fun LinkedText(content: String, node: ASTNode, style: TextStyle, modifier: Modif BasicText( text = text, modifier = - // A tap here is either a link or the card's; see [LocalMarkdownTap] for why the - // second one has to be answered from inside the text rather than left to the card. + // A tap here is either a link or the card's; see [LocalMarkdownTap] for why the second + // one has to be answered from inside the text rather than left to the card. modifier.then(chipFill).pointerInput(text, onPlainTap) { awaitEachGesture { // Unconsumed is not required: something outside may already be tracking this // press, and it is still the press that may land on a link. awaitFirstDown(requireUnconsumed = false) - // A tap and nothing else. Null when the gesture became something somebody - // else's -- a scroll, or a press held past the long-press timeout, which is - // how a selection starts. The timeout is the load-bearing half: without it a - // press held for a second and released was still an up with nothing consumed, - // so holding a peer message to select from it shut the card instead. + // A tap and nothing else. Null when the gesture became somebody else's -- a + // scroll, or a press held past the long-press timeout, which is how a selection + // starts. The timeout is the load-bearing half: without it a press held for a + // second and released was still an up with nothing consumed, so holding a peer + // message to select from it shut the card instead. val up = withTimeoutOrNull(viewConfiguration.longPressTimeoutMillis) { waitForUpOrCancellation() @@ -156,27 +150,24 @@ fun LinkedText(content: String, node: ASTNode, style: TextStyle, modifier: Modif * usually -- or null where a plain tap means nothing. * * A composition local because there is nowhere else to put it. The paragraphs of a message are - * composed by the renderer's own dispatch out of its component table, so nothing between a card and - * the text inside it is ours to pass a parameter through; the renderer already hands its colours, - * its typography and its components down the same way. + * composed by the renderer's own dispatch, so nothing between a card and the text inside it is ours + * to pass a parameter through. * * It exists because a pointer-input node over the glyphs takes the tap and the card's own click - * handler never sees it. Measured on the emulator against an opened peer message: with a handler on - * the text -- consuming or not -- a tap on its words did nothing at all, and with the handler - * removed entirely the same tap shut the card. So a card whose body is markdown cannot be shut by - * pressing its words unless the words do the shutting, and "nothing happens when I press it" is - * indistinguishable from a card that has stopped working. + * handler never sees it. Measured against an opened peer message: with a handler on the text -- + * consuming or not -- a tap on its words did nothing at all, and with the handler removed the same + * tap shut the card. So a card whose body is markdown cannot be shut by pressing its words unless + * the words do the shutting. * - * Provided as a value that outlives a recomposition (see [rememberMarkdownTap]), since a fresh - * lambda per composition would invalidate every paragraph reading it. + * Provided as a value that outlives a recomposition, since a fresh lambda per composition would + * invalidate every paragraph reading it. */ val LocalMarkdownTap = compositionLocalOf<(() -> Unit)?> { null } /** - * [onTap] as a stable value to provide for [LocalMarkdownTap]. - * - * The identity stays put while the behaviour follows the latest [onTap], which is what keeps - * providing it from invalidating the text under it on every recomposition of the card. + * [onTap] as a stable value to provide for [LocalMarkdownTap]. The identity stays put while the + * behaviour follows the latest [onTap], which is what keeps providing it from invalidating the text + * under it on every recomposition of the card. */ @Composable fun rememberMarkdownTap(onTap: () -> Unit): () -> Unit { @@ -212,9 +203,8 @@ private const val LINK_URL = "url" * The chip's fill is drawn by [LinkedText] from the layout instead, behind the text. A span's * background is part of the text's own drawing, and the text node draws the selection first and the * glyphs over it, so a chip painted as a span background covered the selection: selecting a - * sentence highlighted every word of it except the ones in backticks. Anything drawn by a modifier - * on the text is under both, which is where a fenced block's box already is and why one of those - * always looked right. The [CODE_CHIP] annotation is what says where the fill goes. + * sentence highlighted every word except the ones in backticks. Anything drawn by a modifier on the + * text is under both, which is where a fenced block's box already is. */ private fun appendCodeChip( builder: AnnotatedString.Builder, @@ -242,13 +232,11 @@ private const val CODE_CHIP = "code" * Not `getPathForRange`, which is the geometry of a *selection* and runs to the right edge of every * line but the last, so a chip whose code wrapped left a full-width empty box behind on the line * above. Each line is taken as far as `visibleEnd`, which is where that line's own trailing space - * stops being drawn: the same rule the selection rectangle obeys, so the two agree rather than the - * chip sticking a space out past the end of a selected line. It is also what leaves nothing behind - * when the only thing to reach a line is the space a chip is padded with. + * stops being drawn -- the same rule the selection rectangle obeys, so the two agree. * * A run's extent is taken from the boxes of its first and last characters, which is exact while a * line reads in one direction; mixed directions inside a code span would draw one box across the - * whole run rather than one per direction, and code spans are code. + * whole run, and code spans are code. */ private fun TextLayoutResult.chipRects(start: Int, end: Int): List { val rects = mutableListOf() diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/MarkdownPieces.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/MarkdownPieces.kt index c21ffc0..fd62492 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/MarkdownPieces.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/MarkdownPieces.kt @@ -34,22 +34,18 @@ import org.intellij.markdown.flavours.gfm.GFMTokenTypes * * The point is the draw phase and the lazy list. A reply's display list holds every glyph of it and * is re-recorded whenever drawing is invalidated, so one long message costs as much to draw as a - * hundred short ones; and the list composes an item whole in the frame it scrolls into, so an item - * has to be bounded for the worst frame to be. Measured on a Pixel 9 Pro XL, the tallest row still - * being drawn was 36,982px, twenty-five screens in one message. A piece is a paragraph, a fence, a - * table, one bullet: bounded, so both costs are. + * hundred short ones; and the list composes an item whole in the frame it scrolls into. Measured on + * a Pixel 9 Pro XL, the tallest row still being drawn was 36,982px -- twenty-five screens in one + * message. A piece is a paragraph, a fence, a table, one bullet: bounded, so both costs are. * - * Cut where the parser says the blocks are, which is the whole reason this is safe: a fence, a - * table and a nested list are each one node whatever is inside them, so nothing is ever split down - * the middle. A list is the one block that is not bounded -- a reply's list of sources can be forty - * items -- so it is cut once more, into its items, and a nested list stays inside the item that - * holds it. + * Cut where the parser says the blocks are, which is what makes it safe: a fence, a table and a + * nested list are each one node whatever is inside them. A list is the one block that is not + * bounded -- a reply's list of sources can be forty items -- so it is cut once more, into its + * items. * - * A piece is an *address* into the message's one parse ([block] indexes the root's children, [item] - * the list items of that child) rather than a substring of the message. Every piece of a message is - * drawn from the same tree, so a message is parsed once however many pieces it is drawn as, and a - * reference definition at its foot still resolves the links above it -- the two costs of cutting a - * message into strings and parsing each on its own. + * A piece is an *address* into the message's one parse rather than a substring of it. Every piece + * is drawn from the same tree, so a message is parsed once however many pieces it is drawn as, and + * a reference definition at its foot still resolves the links above it. */ @Immutable data class Piece(val block: Int, val item: Int = WHOLE_BLOCK) { @@ -59,8 +55,7 @@ data class Piece(val block: Int, val item: Int = WHOLE_BLOCK) { } /** - * The pieces of [parse], in reading order. Blank nodes between blocks -- the parser keeps the - * newlines -- are not pieces. + * The pieces of [parse], in reading order. Blank nodes between blocks are not pieces. * * A parse that failed yields one piece, so [MarkdownPiece] can still say what the message was: a * message that drew as nothing would be a hole in the transcript with no sign of what fell out. @@ -90,17 +85,15 @@ fun gapBefore(previous: Piece?, piece: Piece): Dp = val BLOCK_SPACING: Dp = 6.dp /** - * [piece] of [parse], drawn. Must be inside [MarkdownRoot] for the parse, which is what carries the - * theme, the components and the reference links to the renderer's element composables. + * [piece] of [parse], drawn. Must be inside [MarkdownRoot] for the parse, which carries the theme, + * the components and the reference links to the renderer's element composables. * - * A whole block goes to the renderer's own dispatch with this app's component table, so a paragraph - * or heading is a [LinkedText], a table is [LinkedTableRow]s, and a nested list comes back here - * through [MarkdownList]. Only the list item is drawn directly, because a list item is the one - * piece the renderer has no element for. + * A whole block goes to the renderer's own dispatch with this app's component table. Only the list + * item is drawn directly, because a list item is the one piece the renderer has no element for. * - * [continuesList] and [listContinues] are for a list cut across the segments of a live reply (see - * `LiveParse`): an item that is the first or last of its own parse but not of the list the reader - * sees keeps an inner item's padding, so nothing moves when the seam between segments does. + * [continuesList] and [listContinues] are for a list cut across the segments of a live reply: an + * item that is the first or last of its own parse but not of the list the reader sees keeps an + * inner item's padding, so nothing moves when the seam between segments does. */ @Composable fun MarkdownPiece( @@ -112,8 +105,8 @@ fun MarkdownPiece( listContinues: Boolean = false, ) { if (parse !is State.Success) { - // The parser threw. Nothing else in the app has seen this happen; if it does, the words - // are still worth more than a blank. + // The parser threw. Nothing else in the app has seen this happen; if it does, the words are + // still worth more than a blank. Text(text, modifier, style = MaterialTheme.typography.bodyLarge) return } @@ -144,8 +137,7 @@ fun MarkdownPiece( /** * A whole list, for the places the renderer's dispatch reaches one it cannot hand to a piece: a - * list inside a quote, and the nested lists an item holds. Top-level lists never come here; they - * are drawn an item at a time as pieces. + * list inside a quote, and the nested lists an item holds. Top-level lists never come here. */ @Composable fun MarkdownList(content: String, list: ASTNode, depth: Int, modifier: Modifier = Modifier) { @@ -170,9 +162,8 @@ fun MarkdownList(content: String, list: ASTNode, depth: Int, modifier: Modifier * list drawn as pieces looks exactly like one drawn whole. The list's own padding goes on its first * and last items, since there is no list column to carry it. * - * The marker is the renderer's bullet and number, and a checkbox for a task item. It is drawn here - * rather than by a handler because it is the thing a reader might one day want styled -- a - * different glyph per depth, a colour -- and this is the one place it is drawn. + * The marker is drawn here rather than by a handler because it is the thing a reader might one day + * want styled -- a different glyph per depth, a colour -- and this is the one place it is drawn. */ @Composable private fun MarkdownListItem( @@ -231,8 +222,8 @@ private fun Marker(text: String, style: TextStyle) { /** * The bullet at each depth, cycling past the third: a disc, a ring, a square -- the ladder a * browser draws, so a nested list is told from its parent by the glyph as well as by the indent. - * Checked on the emulator's system fonts, which is what makes them safe to rely on; a glyph the - * platform lacks draws as a box, and that check is the price of adding one here. + * Checked on the emulator's system fonts; a glyph the platform lacks draws as a box, and that check + * is the price of adding one here. */ private val BULLETS = listOf("• ", "◦ ", "▪ ") diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/MarkdownSyntax.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/MarkdownSyntax.kt index 470438a..d82f06b 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/MarkdownSyntax.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/MarkdownSyntax.kt @@ -7,23 +7,19 @@ package com.example.aiapp * Its own scanner rather than a row of [Rules] because markdown has neither keywords nor strings: * what a character means depends on where it sits. A `#` opens a heading at the start of a line and * is an ordinary character three words in; a `*` opens emphasis only if something closes it on the - * same line. The token scanner cannot ask either question, and answering them with its rules is how - * a highlighter comes to grey out the second half of a paragraph. + * same line. The token scanner cannot ask either question. * - * Structure is read a line at a time and each line's prose is then read left to right, so every - * decision is made inside one line -- except the two things that are not one line. A fenced block - * is state carried forward, so an unclosed fence colours the rest of the text, which is also what - * it looks like while somebody is still writing it. A table is found by its delimiter row - * (`|---|---|`), which is the only line of one that cannot be anything else, and its header is the - * line before that -- the one place here that looks ahead. + * Structure is read a line at a time and each line's prose left to right, so every decision is made + * inside one line -- except the two that are not. A fenced block is state carried forward, so an + * unclosed fence colours the rest of the text, which is what it looks like while somebody is + * writing it. A table is found by its delimiter row (`|---|---|`), the only line of one that cannot + * be anything else, and its header is the line before that -- the one place here that looks ahead. * * What is deliberately *not* recognised: an indented code block. Four spaces after a blank line is - * one, and four spaces after a bullet is a list item's second paragraph, and the two are told apart - * by what came before rather than by the line itself. Colouring the wrong one of those as code is a - * mistake the reader cannot see, so both are left plain, which is the safe answer. + * one, four spaces after a bullet is a list item's second paragraph, and the two are told apart by + * what came before. Colouring the wrong one as code is a mistake the reader cannot see. * - * Like [scan], the spans come out ordered, non-overlapping and inside the text by construction: - * every one is emitted by a pass that only moves forward, and nothing here throws. + * Like [scan], the spans come out ordered, non-overlapping and inside the text by construction. */ fun scanMarkdown(code: String): List = MarkdownScanner(code).run() @@ -36,7 +32,7 @@ private const val RULE_MARKERS = "-*_=" /** The characters that can open emphasis, strong emphasis or a strikethrough. */ private const val EMPHASIS = "*_~" -/** Characters that end a bare URL wherever they appear in it, and ones only trimmed off the end. */ +/** Characters that end a bare URL wherever they appear, and ones only trimmed off the end. */ private const val URL_STOPS = "<>\"'`|" private const val URL_TRAILING = ".,:;!?" @@ -53,8 +49,8 @@ private class MarkdownScanner(private val code: String) { val end = lineEnd(at) val open = fence if (open != null) { - // The content and the closing line alike: a fence is one block of code, and its - // own delimiters belong to it the way a string's quotes belong to the string. + // The content and the closing line alike: a fence is one block of code, and its own + // delimiters belong to it the way a string's quotes belong to the string. emit(at, end, Kind.STRING) if (closesFence(at, end, open)) fence = null } else { @@ -77,11 +73,10 @@ private class MarkdownScanner(private val code: String) { /** * One line that is not inside a fence, and whether the table it may be part of is still open. * - * A table is recognised by its delimiter row (`|---|---|`), which is the only line of one that - * cannot be anything else. That row comes *after* the header it belongs to, so the header is - * found by looking one line ahead -- the single piece of lookahead here, and cheaper than the - * alternative of colouring every `|` in the document, which would mark the pipes in a shell - * command written in a paragraph. + * A table is recognised by its delimiter row, the only line of one that cannot be anything + * else. That row comes *after* the header it belongs to, so the header is found by looking one + * line ahead -- the single piece of lookahead here, and cheaper than colouring every `|` in the + * document, which would mark the pipes in a shell command written in a paragraph. */ private fun row(start: Int, end: Int, table: Boolean): Boolean { if (tableDelimiter(start, end)) { @@ -142,10 +137,9 @@ private class MarkdownScanner(private val code: String) { } /** - * Spans, coalesced with the one before when they touch and agree. - * - * Worth doing here rather than leaving it to the caller: the line scanner emits per marker and - * per word, so a heading would otherwise arrive as a dozen abutting spans of one colour. + * Spans, coalesced with the one before when they touch and agree. Worth doing here rather than + * leaving it to the caller: the line scanner emits per marker and per word, so a heading would + * otherwise arrive as a dozen abutting spans of one colour. */ private fun emit(start: Int, end: Int, kind: Kind) { if (end <= start) return @@ -179,17 +173,16 @@ private class MarkdownScanner(private val code: String) { private fun opensFence(start: Int, end: Int): String? { val run = fenceRun(start, end) ?: return null emit(run.first, run.last + 1, Kind.STRING) - // The info word is what the fence is a fence *of*, which is metadata about the block - // rather than part of it -- the same reading as a Rust attribute above a struct. + // The info word is what the fence is a fence *of*, which is metadata about the block rather + // than part of it. emit(indented(run.last + 1, end), end, Kind.METADATA) return code.substring(run.first, run.last + 1) } /** - * Whether this line closes a fence opened by [open]. - * - * The same character, at least as many of them, and nothing else on the line -- so a longer run - * closes a shorter one and a line of backticks with a word after it does not close anything. + * Whether this line closes a fence opened by [open]: the same character, at least as many of + * them, and nothing else on the line -- so a longer run closes a shorter one and a line of + * backticks with a word after it does not close anything. */ private fun closesFence(start: Int, end: Int, open: String): Boolean { val run = fenceRun(start, end) ?: return false @@ -227,10 +220,9 @@ private class MarkdownScanner(private val code: String) { * A line made of one repeated rule character and nothing else. * * `---`, `***` and `___` are thematic breaks; `===` and `---` are also the underline of a - * setext heading. The two are the same line to look at and mean the same thing to a reader -- a - * rule drawn across the page -- so they get one appearance rather than a lookback to tell them - * apart. One `=` is enough because a setext underline may be a single character; a break needs - * three, which is what keeps a `- ` bullet out of here. + * setext heading. The two are the same line to look at and mean the same thing to a reader, so + * they get one appearance rather than a lookback. One `=` is enough because a setext underline + * may be a single character; a break needs three, which keeps a `- ` bullet out of here. */ private fun thematicBreak(start: Int, end: Int): Boolean { val marker = code[start] @@ -292,10 +284,9 @@ private class MarkdownScanner(private val code: String) { } /** - * `` `code` ``, closed by a run of exactly as many backticks as opened it. - * - * That count is what lets a span hold a backtick of its own (``` ``a ` b`` ```), and it is why - * the search skips over a shorter or longer run rather than stopping at the first backtick. + * `` `code` ``, closed by a run of exactly as many backticks as opened it. That count is what + * lets a span hold a backtick of its own, and why the search skips over a shorter or longer run + * rather than stopping at the first backtick. */ private fun codeSpan(start: Int, end: Int): Int { var open = start @@ -323,9 +314,8 @@ private class MarkdownScanner(private val code: String) { * `[text](destination)`, and the same with a leading `!` for an image. * * The text is drawn as prose -- it is what the reader reads -- so only the brackets around it - * are marked, and the destination is metadata: the place the link goes rather than anything - * said to the reader. A `[text]` with no destination after it is left plain, because that is - * what a reference link and a bracketed aside look like, and neither is worth guessing at. + * are marked, and the destination is metadata. A `[text]` with no destination after it is left + * plain, because that is what a reference link and a bracketed aside look like. */ private fun link(start: Int, bracket: Int, end: Int): Int { var depth = 0 @@ -357,8 +347,7 @@ private class MarkdownScanner(private val code: String) { * `` and ``, drawn as the destination they are. * * The angle brackets have to hold no whitespace and something that makes an address of it -- a - * scheme's colon or an at sign -- which is what keeps an HTML tag out: `
` has neither, and - * `` has the colon but also a space. + * scheme's colon or an at sign -- which is what keeps an HTML tag out. */ private fun autolink(start: Int, end: Int): Int { var at = start + 1 @@ -380,13 +369,12 @@ private class MarkdownScanner(private val code: String) { /** * A bare `scheme://…` written in prose, or null if one does not start here. * - * A scheme and `://` rather than a list of them, so `ftp`, `file` and `ssh` need no entry, and - * the pair of colons is what makes the match unambiguous enough to draw without a closer. + * A scheme and `://` rather than a list of them, so `ftp`, `file` and `ssh` need no entry. * * Where it ends is the part worth stating: the sentence's punctuation is not the address, so a * trailing `.` or `,` is given back, and so is a closing bracket unless one opened inside the - * URL -- otherwise a link in parentheses loses its `)` to the address. A pipe stops it too, - * because a URL in a table cell must not swallow the cell's edge. + * URL -- otherwise a link in parentheses loses its `)`. A pipe stops it too, because a URL in a + * table cell must not swallow the cell's edge. */ private fun url(start: Int, end: Int): Int? { if (start > 0 && isWord(code[start - 1])) return null @@ -415,14 +403,13 @@ private class MarkdownScanner(private val code: String) { } /** - * `*emph*`, `**strong**`, `_emph_` and `~~struck~~`, drawn markers and all. + * `*emph*`, `**strong**`, `_emph_` and `~~struck~~`, drawn markers and all -- which is how the + * token scanner draws a string: the quotes are part of the thing. * - * Markers and all because that is how the token scanner draws a string: the quotes are part of - * the thing. The two guards are what keep this off code that happens to be in a paragraph -- - * the opener must be followed by something to emphasise and the closer preceded by something - * emphasised, so `a * b * c` opens nothing and neither does the `*p = *q` of a C fragment. - * Underscores additionally may not start or end inside a word, or every `snake_case_name` in a - * document would be half emphasised. + * The two guards keep this off code that happens to be in a paragraph: the opener must be + * followed by something to emphasise and the closer preceded by something emphasised, so `a * b + * * c` opens nothing and neither does the `*p = *q` of a C fragment. Underscores may not start + * or end inside a word, or every `snake_case_name` would be half emphasised. */ private fun emphasis(start: Int, end: Int): Int { val marker = code[start] diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/MemoryNote.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/MemoryNote.kt index 5def147..117fccc 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/MemoryNote.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/MemoryNote.kt @@ -25,12 +25,11 @@ import androidx.compose.ui.unit.dp * Claude Code marks a sentence that came from its stored memory by wrapping it in ``. Markdown has nothing to say about that, so it arrived on screen as literal * angle brackets in the middle of a sentence -- which reads as the model having emitted broken - * HTML. It is really the opposite: a claim about where something came from, which is worth showing, - * because "I was told this before" and "I worked this out just now" are different things and the - * reader cannot otherwise tell them apart. + * HTML. It is really the opposite: a claim about where something came from, and "I was told this + * before" and "I worked this out just now" are different things the reader cannot otherwise tell + * apart. * - * A tag that has not finished arriving is left alone. Streaming means the closing tag may be - * seconds away, and a half-written marker is not a marker yet. + * A tag that has not finished arriving is left alone: a half-written marker is not a marker yet. */ @Composable fun AssistantMessage( @@ -66,12 +65,10 @@ fun AssistantMessage( * A reply carrying no notes is drawn from the message as it arrived rather than from the trimmed * prose part made while looking for them -- inspecting a message must not change it. That belongs * here rather than at the places that need the answer, because [warm] has to name the same strings - * the rows draw: a string warmed under a key no row ever looks up is a miss that nothing reports, - * and the row pays the parse in the frame it appears, which is the cost being removed. + * the rows draw: a string warmed under a key no row ever looks up is a miss nothing reports. * * Public because [transcriptUnits] flattens settled replies into the same parts; go through - * [ParsedReplies.partsOf] on any path that runs per fold or per page, so the scan happens once per - * message. + * [ParsedReplies.partsOf] on any path that runs per fold or per page. */ fun messageParts(text: String): List { val parts = splitMemoryNotes(text) @@ -83,16 +80,14 @@ fun messageParts(text: String): List { * * Closed by default, like a tool call and a peer message and for the same reason: it is not part of * what was said to the reader, it is a note about where a claim came from. Left open it breaks the - * reply in half around a card, which reads as the answer having stopped and restarted -- and these - * arrive several to a message. + * reply in half around a card, and these arrive several to a message. * * What stays visible is which file it came from, because that is the whole of what the note claims - * and it is the part a reader scanning for "why does it think that" is looking for. + * and the part a reader scanning for "why does it think that" is looking for. * * Open-ness is the screen's, keyed by the note's own text: a note opened and scrolled past has to * still be open on the way back, and a card that remembered for itself would forget the moment the - * list stopped composing it. The text is a good enough name -- it does not change once the closing - * tag has arrived, so a note stays open across the moment its reply settles. + * list stopped composing it. */ @Composable fun MemoryNote( @@ -148,10 +143,8 @@ private val MEMORY_NOTE = Regex("""(.*?)""", RegexOption.DOT_MATCHES_ALL) /** - * Splits [text] into prose and memory notes, in order. - * - * Always returns at least one part, so a message with no notes in it is one piece of prose and - * costs nothing extra to draw. + * Splits [text] into prose and memory notes, in order. Always returns at least one part, so a + * message with no notes is one piece of prose and costs nothing extra to draw. */ fun splitMemoryNotes(text: String): List { val parts = mutableListOf() diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/ModelName.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/ModelName.kt index c372d2d..bb887f1 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/ModelName.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/ModelName.kt @@ -5,26 +5,23 @@ package com.example.aiapp * * One constant rather than a literal in each place, because the two have to agree: a picker whose * options cannot say every state its button can display is one you can leave and not get back to. - * It is also the Claude CLI's own word for "whatever is configured", so choosing it is a request - * the session can act on rather than a name this app made up. + * It is also the Claude CLI's own word for "whatever is configured". */ const val DEFAULT_MODEL = "default" /** * A model's name as a person reads it. * - * Providers answer with their own full identifier -- Claude Code resolves `haiku` to - * `claude-haiku-4-5-20251001` and reports that, which is the honest answer to "what is this session - * using" and far too long for a button in a row that also has to hold Stop and Send. + * Providers answer with their own full identifier -- Claude Code resolves `haiku` to `claude- + * haiku-4-5-20251001` and reports that, which is the honest answer to "what is this session using" + * and far too long for a button in a row that also holds Stop and Send. * * So the two ends that identify nothing are dropped and nothing else is: the vendor prefix, which * is the same on every model this app can show, and the release date, which distinguishes builds of - * one model rather than one model from another. What is left is the part somebody chose -- - * `haiku-4-5` -- and anything that does not look like that is returned untouched, since a name this - * does not recognise is a name it has no business editing. + * one model rather than one model from another. Anything that does not look like that is returned + * untouched. * - * A display decision, not a correction: the full name is what the session reports and what a reader - * is shown when there is room for it. + * A display decision, not a correction: the full name is what the session reports. */ fun modelLabel(model: String?): String { val name = model?.takeIf { it.isNotBlank() } ?: return DEFAULT_MODEL diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/ModelsScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/ModelsScreen.kt index 234fc9a..0521f9f 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/ModelsScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/ModelsScreen.kt @@ -57,12 +57,9 @@ fun ModelsScreen(settings: ServerSettings, reloadToken: Int) { } } - // Polled rather than pushed: a download belongs to the machine, not to - // any session, so it has no event stream of its own. Slow enough not - // to matter, frequent enough that a bar moves. - // Keyed on the token as well, so the header's Refresh restarts the loop with a read now - // rather than leaving the reader watching for up to a second and a half to see whether - // anything happened. + // Polled rather than pushed: a download belongs to the machine, not to any session, so it has + // no event stream of its own. Keyed on the token as well, so the header's Refresh restarts the + // loop with a read now rather than leaving the reader watching for a second and a half. LaunchedEffect(reloadToken) { while (true) { reload() @@ -186,11 +183,9 @@ fun ModelsScreen(settings: ServerSettings, reloadToken: Int) { } } } - // Inside the expanded repository's own item - // rather than as a section after the list: - // drawn after every card, a repository's files - // read as belonging to whichever card happened - // to be last. + // Inside the expanded repository's own item rather than as a section + // after the list: drawn after every card, a repository's files read as + // belonging to whichever card happened to be last. if (open) { when (val files = repoFiles) { null -> {} @@ -257,15 +252,15 @@ private fun DownloadCard(download: Download, onCancel: () -> Unit) { color = MaterialTheme.colorScheme.onSurfaceVariant, ) Spacer(Modifier.height(8.dp)) - // A determinate bar only when the size is known. The server - // sends no total when it was never told one, and a bar drawn - // from a guess is worse than one that admits it is counting. + // A determinate bar only when the size is known. The server sends no total when it was + // never told one, and a bar drawn from a guess is worse than one that admits it is + // counting. if (download.total != null && download.total > 0) { LinearProgressIndicator( progress = { download.done.toFloat() / download.total.toFloat() }, // Blue at every value, unlike a quota bar: a download nearing its end is - // nearing success, and colouring it like a limit being approached would say - // the opposite of what is happening. + // nearing success, and colouring it like a limit being approached would say the + // opposite. color = progressColor, modifier = Modifier.fillMaxWidth(), ) @@ -327,8 +322,8 @@ private fun RepoRow(repo: RemoteRepo, expanded: Boolean, onToggle: () -> Unit) { repo.id, style = MaterialTheme.typography.titleSmall, maxLines = 1, - // The owner is the part that repeats; the model name at - // the end is what tells two entries apart. + // The owner is the part that repeats; the model name at the end is what tells + // two entries apart. overflow = TextOverflow.StartEllipsis, ) Text( @@ -356,11 +351,9 @@ private fun RepoFileRow(file: RemoteFile, downloading: Boolean, onDownload: () - color = MaterialTheme.colorScheme.onSurfaceVariant, ) } - // Disabled rather than absent, so the row reads the same whether - // this one is absent, already here, or on its way. Offering - // "Download" for a file that is downloading would be a button that - // does nothing anyone can see -- the server joins the running - // download rather than starting a second. + // Disabled rather than absent, so the row reads the same whether this one is absent, + // already here, or on its way. Offering "Download" for a file that is downloading would be + // a button that does nothing anyone can see. TextButton(enabled = !file.have && !downloading, onClick = onDownload) { Text( when { diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/NerdIcons.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/NerdIcons.kt index 4ba88c0..b7985bc 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/NerdIcons.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/NerdIcons.kt @@ -26,26 +26,21 @@ import androidx.compose.ui.unit.sp * set and kept in step by hand. * * This replaced a hand-drawn canvas gear, whose doc comment argued against icon fonts on the - * grounds that a system font may not have the glyph and whoever gets the empty box instead is never - * the person who wrote it. That objection is about *relying* on a system font, and it is exactly - * right: the answer is not to avoid glyphs but to ship them. The font here is - * `app/build-icon-font.sh`'s output -- seventeen glyphs, 2.8 KB, subset out of the 3 MB symbols - * font and committed -- so the codepoints below are resolved by an asset in the APK and cannot come - * back as tofu. Adding one means adding its codepoint in *both* places; a codepoint here that the - * script did not subset is a glyph that silently isn't there. + * grounds that a system font may not have the glyph. That objection is about *relying* on a system + * font, and it is exactly right: the answer is not to avoid glyphs but to ship them. The font here + * is `app/build-icon-font.sh`'s output -- seventeen glyphs, 2.8 KB, subset out of the 3 MB symbols + * font and committed. Adding one means adding its codepoint in *both* places; a codepoint here that + * the script did not subset is a glyph that silently isn't there. * * The subset is the font's **Mono** face, where every glyph is exactly one em wide and one em tall. - * That is what makes two icons the same size without either of them being given a size: the - * proportional face's advances run from 0.46 em to 0.92 em, so a Send button and a Stop button side - * by side came out visibly different widths, and matching them at the call site would have meant - * one hardcoded measurement per pair. [GLYPH_SIZE] carries the cost. + * That is what makes two icons the same size without either being given a size: the proportional + * face's advances run from 0.46 em to 0.92 em, so a Send button and a Stop button side by side came + * out visibly different widths. [GLYPH_SIZE] carries the cost. * * The same arrangement as dev-updater, down to the cog and the refresh arrow being the same two - * Material Design codepoints. Those two must not drift: an icon that means "settings" in one app - * and something else in the other is the failure this is worth preventing. The script is copied - * rather than shared because most of what looks like duplication is the `GLYPHS` list, which has to - * differ -- the point of subsetting is to ship only the codepoints one app draws. All Material - * Design bar one, so they read as one family; the exception is noted where it is declared. + * Material Design codepoints. Those two must not drift. The script is copied rather than shared + * because most of what looks like duplication is the `GLYPHS` list, which has to differ -- the + * point of subsetting is to ship only the codepoints one app draws. */ val NerdIcons = FontFamily(Font(R.font.nerd_icons)) @@ -74,8 +69,7 @@ val STOP_GLYPH = glyph(0xF04DB) * * The pair with [STOP_GLYPH] and [PLAY_GLYPH] is the point: one button in the composer says what * pressing it now would do to the process, and the three marks are the three answers. An interrupt - * ends a turn and nothing else -- the CLI is still there and still holds the conversation -- which - * is a pause, not a stop, and drawing it as a square said otherwise. + * ends a turn and nothing else, which is a pause, not a stop. */ val PAUSE_GLYPH = glyph(0xF03E4) @@ -86,8 +80,8 @@ val PLAY_GLYPH = glyph(0xF040A) * `md-send_clock` -- the same paper plane with a clock on it: this message will wait its turn. * * The pair with [SEND_GLYPH] is the point. Sending during a turn queues the message rather than - * starting one, and the two buttons have to be told apart at a glance -- one glyph doing both jobs - * while looking identical would promise something immediate and do something that waits. + * starting one, and one glyph doing both jobs would promise something immediate and do something + * that waits. */ val QUEUE_GLYPH = glyph(0xF1163) @@ -104,8 +98,7 @@ val BELL_GLYPH = glyph(0xF009A) * `fa-line_chart` -- how much of the account's rate limits is gone. * * Font Awesome's rather than Material's, which is the one break in the family above: it was asked - * for by name, and Material's chart glyphs are a bare line where this one has its axes, which is - * what makes it read as a measurement rather than as a trend. + * for by name, and Material's chart glyphs are a bare line where this one has its axes. */ val USAGE_GLYPH = glyph(0xF201) @@ -121,9 +114,8 @@ val SPEED_GLYPH = glyph(0xF04C5) * `md-folder` -- the files on the machine this session runs on. * * The same codepoint dev-updater uses, and it must not drift from it, for the reason the cog and - * the refresh arrow must not: a folder that meant something else in one of the two apps is exactly - * the confusion sharing them prevents. Doubles as the mark on a directory row inside the explorer, - * which is what makes the button say where it leads. + * the refresh arrow must not. Doubles as the mark on a directory row inside the explorer, which is + * what makes the button say where it leads. */ val FOLDER_GLYPH = glyph(0xF024B) @@ -148,10 +140,9 @@ val SAVE_GLYPH = glyph(0xF0193) * The size an icon draws at beside a line of text. * * 17 rather than the 20 it was while the font was the proportional face. A glyph there filled at - * most 0.83 em of its point size and most filled a good deal less, so the number was standing in - * for the headroom above the tallest one; in the Mono face every glyph fills its em exactly, and - * keeping 20 would have made every icon in the app step up by a fifth for no reason anybody asked - * for. This is what the largest of them already drew at. + * most 0.83 em of its point size, so the number was standing in for the headroom above the tallest + * one; in the Mono face every glyph fills its em exactly, and keeping 20 would have stepped every + * icon in the app up by a fifth. */ private val GLYPH_SIZE = 17.sp @@ -165,16 +156,14 @@ private val GLYPH_EXTENT = GLYPH_SIZE.value.dp * * The ring is the whole spacing rule. Every gap around a header icon comes out of it -- one ring to * the screen edge, two where a button meets its neighbour -- so nothing outside has to add a gap of - * its own, and a mark cannot end up further from the button beside it than from the edge of the - * screen. That is what it was: the box was the size of the mark (28dp) and the separation was + * its own. That is what it was: the box was the size of the mark (28dp) and the separation was * bolted on beside it, which left the two header icons 31dp apart and the outer one 14dp from the - * edge, so a pair that acts on one screen read as two unrelated marks with one falling off it. + * edge. * * 48dp is the platform's minimum touch target, so the square is also the whole of what a finger has - * to find. It is what the pressed-state ripple draws, too: at 28dp that circle was inscribed in the - * mark's own corners, and beside a title it arrived at the first letter. And it is taller than any - * header's text, which is what lets the button fill a header row rather than sit in the middle of - * one -- the rows add no vertical padding of their own for the same reason they add no gap. + * to find, and what the pressed-state ripple draws: at 28dp that circle was inscribed in the mark's + * own corners and beside a title it arrived at the first letter. And it is taller than any header's + * text, which is what lets the button fill a header row rather than sit in the middle of one. */ private val GLYPH_BUTTON_SIZE = 48.dp @@ -182,11 +171,9 @@ private val GLYPH_BUTTON_SIZE = 48.dp * The ring itself, for putting something that is *not* a glyph button next to one -- a title beside * a back arrow. * - * Two glyph buttons need nothing between them: each brings its own ring and the two add up, which - * is why a row of them sets no spacing. Text brings none, so the second ring has to be asked for. - * Without it the pressed-state circle, which fills the whole square, arrives at the first letter of - * the title -- and the gap a reader sees between the mark and that title is then half the one - * between the two marks at the other end of the same row. + * Two glyph buttons need nothing between them: each brings its own ring and the two add up. Text + * brings none, so the second ring has to be asked for -- without it the pressed-state circle + * arrives at the first letter of the title. */ val GLYPH_BUTTON_MARGIN = (GLYPH_BUTTON_SIZE - GLYPH_EXTENT) / 2 @@ -195,8 +182,7 @@ val GLYPH_BUTTON_MARGIN = (GLYPH_BUTTON_SIZE - GLYPH_EXTENT) / 2 * * Its own composable so that every icon button in the app is one size and one colour without each * caller saying so, and so the [label] none of them displays is still there for a screen reader -- - * which is all assistive technology has to go on, and also the answer to "what was that button for" - * six months from now. + * which is also the answer to "what was that button for" six months from now. * * [enabled] is passed through rather than left to callers hiding the button: a control that comes * and goes makes its own absence the signal, and absence cannot say whether there was nothing to do @@ -220,9 +206,8 @@ fun GlyphButton( * The same square, around a mark that is not a glyph. * * A [Chevron] is drawn rather than set in a font, and a pair of them used as buttons has to be the - * size, spacing and touch target every other icon button on this app's headers already is -- so - * this is [GlyphButton] with the mark left to the caller rather than a second set of measurements - * beside it. The caller still owes it a [label]: nothing here draws a word. + * size, spacing and touch target every other icon button already is. The caller still owes it a + * [label]: nothing here draws a word. */ @Composable fun MarkButton( @@ -245,9 +230,7 @@ fun MarkButton( * The square a glyph button occupies, with a spinner in it instead of a mark. * * For a button whose work is under way. It takes the button's whole box rather than the mark's, so - * swapping one for the other leaves everything in the row exactly where it was -- a control that - * changed the width of its header while it worked would move its neighbours at the moment somebody - * was pressing them. + * swapping one for the other leaves everything in the row exactly where it was. */ @Composable fun GlyphSpinner(label: String, modifier: Modifier = Modifier) { @@ -273,10 +256,9 @@ fun Glyph( size: TextUnit = GLYPH_SIZE, ) { // Line height of the point size, which for this font is the square the glyph draws in: its - // ascent and descent add up to exactly one em, and every glyph in the Mono face fills that em. - // Left to the inherited body style the line box was 24sp tall around a 17sp-wide mark, so a - // glyph took a seventh more vertical space than horizontal wherever one is drawn without a box - // around it -- and where there is a box, that leading is what its padding is measured through. + // ascent and descent add up to exactly one em. Left to the inherited body style the line box + // was 24sp tall around a 17sp-wide mark, so a glyph took a seventh more vertical space than + // horizontal. Text( glyph, fontFamily = NerdIcons, diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Notifications.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Notifications.kt index 9970668..050b6eb 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Notifications.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Notifications.kt @@ -34,12 +34,11 @@ import org.json.JSONObject * gets a push from Google's servers, which would mean this backend talking to Google about * somebody's coding sessions, and the whole point of the tunnel is that it does not. * - * The cost Android charges for it is a notification of its own that cannot be dismissed. That is - * made as quiet as the platform allows: [ONGOING_CHANNEL] is `IMPORTANCE_MIN`, so it makes no - * sound, shows no status-bar icon, and sits at the bottom of the shade -- the same arrangement - * Syncthing's "hide the persistent notification" option produces. It is not hidden outright, - * because it cannot be and because it should not be: it is the honest indicator that something is - * holding a connection open. + * The cost Android charges is a notification of its own that cannot be dismissed. That is made as + * quiet as the platform allows: [ONGOING_CHANNEL] is `IMPORTANCE_MIN`, so it makes no sound, shows + * no status-bar icon, and sits at the bottom of the shade. It is not hidden outright, because it + * cannot be and because it should not be: it is the honest indicator that something is holding a + * connection open. */ class NotificationService : Service() { @Volatile private var stream: HttpURLConnection? = null @@ -50,18 +49,18 @@ class NotificationService : Service() { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { val settings = loadServerSettings(this) if (settings == null) { - // Nothing to connect to. Stopping rather than idling: a service - // holding no connection still costs the ongoing notification, - // which would then be announcing work that is not happening. + // Nothing to connect to. Stopping rather than idling: a service holding no connection + // still costs the ongoing notification, which would be announcing work that is not + // happening. stopSelf() return START_NOT_STICKY } - // Through ServiceCompat so the type is stated once and ignored on - // the versions that predate types, rather than branching here. + // Through ServiceCompat so the type is stated once and ignored on the versions that predate + // types, rather than branching here. ServiceCompat.startForeground(this, ONGOING_ID, ongoingNotification(), foregroundType()) thread(isDaemon = true, name = "ai-app-notifications") { follow(settings) } - // Restarted if Android kills it, which is the whole point: the - // window this covers is exactly the one where nobody is watching. + // Restarted if Android kills it, which is the whole point: the window this covers is + // exactly the one where nobody is watching. return START_STICKY } @@ -73,11 +72,10 @@ class NotificationService : Service() { /** * Follows the backend's notification stream, reconnecting until stopped. * - * A dropped connection is the ordinary case here rather than an error -- a phone changes - * networks, the tunnel comes and goes, the backend restarts -- so it retries quietly and - * forever. Nothing is shown when it cannot connect: a notification saying "I could not tell you - * whether anything happened" on a phone in somebody's pocket is noise about a condition they - * cannot act on, and the session list already says what is waiting when they next look. + * A dropped connection is the ordinary case here rather than an error, so it retries quietly + * and forever. Nothing is shown when it cannot connect: a notification saying "I could not tell + * you whether anything happened" is noise about a condition nobody can act on, and the session + * list already says what is waiting when they next look. */ private fun follow(settings: ServerSettings) { while (!stopping) { @@ -102,8 +100,8 @@ class NotificationService : Service() { try { connection.applyPinnedTls() connection.connectTimeout = CONNECT_TIMEOUT_MS - // No read timeout, for the reason EventStream gives: between - // notifications there is nothing to read, possibly for hours. + // No read timeout, for the reason EventStream gives: between notifications there is + // nothing to read, possibly for hours. connection.readTimeout = 0 connection.setRequestProperty("Authorization", "Bearer ${settings.token}") connection.setRequestProperty("Accept", "text/event-stream") @@ -134,28 +132,23 @@ class NotificationService : Service() { * * Keyed by session id rather than accumulating: two sessions wanting attention are two things * to know about, but one session that finished and then asked a question is one thing -- the - * question. A stack of stale rows for the same conversation is how a notification drawer - * becomes something to clear rather than read. + * question. A stack of stale rows is how a drawer becomes something to clear rather than read. */ private fun show(notification: SessionNotification) { // Nothing to tell somebody about the session they are reading. The transcript in front of - // them is already saying it, and a sound over the top of it would be this app announcing - // what the screen is showing. + // them is already saying it. if (isOnScreen(notification.sessionId)) return - // The app is up: it says this itself, as a banner over whatever screen they are on. See - // [forTheScreen]. Never both -- one thing happened, and a drawer filling up behind an - // app that already showed you each one is a drawer nobody reads. + // The app is up: it says this itself, as a banner over whatever screen they are on. Never + // both -- one thing happened, and a drawer filling up behind an app that already showed you + // each one is a drawer nobody reads. if (handOver(notification)) return val manager = NotificationManagerCompat.from(this) // Two different noes, and both are answers rather than faults: the runtime permission - // refused, and notifications switched off for the app in Android's own settings. Neither - // is reported anywhere -- the person said no, and saying it back to them through the - // channel they closed is not available anyway. + // refused, and notifications switched off for the app in Android's own settings. // // The permission only exists from Android 13. Asking an older version about it gets - // "denied" for a name it does not know, which read as the person having said no -- so - // every notification on Android 12 and below was silently dropped. Before 13 the - // switch in Android's own settings, checked below, is the whole of the answer. + // "denied" for a name it does not know, which read as the person having said no -- so every + // notification on Android 12 and below was silently dropped. val allowed = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) == @@ -187,9 +180,8 @@ class NotificationService : Service() { * The type Android 14+ requires a foreground service to declare, and nothing before it. * * Named behind a version check rather than passed as a constant: the value is inlined at - * compile time and would be handed to platforms that have no concept of it, which is exactly - * the case lint's InlinedApi exists to catch. Zero is what ServiceCompat wants where types do - * not apply. + * compile time and would be handed to platforms that have no concept of it, which is what + * lint's InlinedApi exists to catch. */ private fun foregroundType(): Int = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { @@ -226,11 +218,10 @@ class NotificationService : Service() { /** * Two channels, because they are two different things to be told. * - * The alerts are what somebody turned this on for, so they get the default importance and - * whatever sound and heads-up display the person has chosen for the app. The ongoing one is - * the platform's tax for staying connected, so it takes the lowest importance that exists. - * Both are created before the service starts, since posting to a channel that does not - * exist is silently dropped. + * The alerts are what somebody turned this on for, so they get the default importance. The + * ongoing one is the platform's tax for staying connected, so it takes the lowest + * importance that exists. Both are created before the service starts, since posting to a + * channel that does not exist is silently dropped. */ private fun createChannels(context: Context) { val manager = NotificationManagerCompat.from(context) @@ -256,12 +247,10 @@ class NotificationService : Service() { * The session somebody is looking at, or null when no screen is showing one. * * Process-wide state, which the rest of this app does without: Android constructs the - * service and the composition draws the screen, so the two have no common owner a value - * could be passed through. [showing] and [stoppedShowing] are the pair, both called from - * the one composable that shows a session. Clearing names the session rather than setting - * null outright, because moving from one session to another composes the new screen before - * the old one's coroutine is cancelled -- an unconditional clear would then throw away the - * new screen's claim and start notifying about what is on it. + * service and the composition draws the screen, so the two have no common owner. Clearing + * names the session rather than setting null outright, because moving from one session to + * another composes the new screen before the old one's coroutine is cancelled -- an + * unconditional clear would throw away the new screen's claim. */ @Volatile private var onScreen: String? = null @@ -271,10 +260,9 @@ class NotificationService : Service() { * The way a notification reaches the app instead of Android's drawer. * * Whether there is an app to reach is the subscriber count rather than a flag of its own: - * [SessionAlerts] collects this exactly while it is on screen, so there is nothing that - * could be left saying the app is up after it has gone. `tryEmit` neither suspends nor - * blocks the thread reading the stream, and the buffer is there so a handful of sessions - * finishing together all land rather than the last one winning. + * [SessionAlerts] collects this exactly while it is on screen. `tryEmit` neither suspends + * nor blocks the thread reading the stream, and the buffer is there so a handful of + * sessions finishing together all land rather than the last one winning. */ private val toApp = MutableSharedFlow(extraBufferCapacity = 8) @@ -287,9 +275,8 @@ class NotificationService : Service() { /** Somebody is looking at [sessionId]; nothing is posted about it until they stop. */ fun showing(context: Context, sessionId: String) { onScreen = sessionId - // Whatever was posted about it before is about to be read, so it has nothing left - // to say -- and a row in the drawer for the conversation on screen is the same - // duplication this whole rule is about. + // Whatever was posted about it before is about to be read, so it has nothing left to + // say. NotificationManagerCompat.from(context).cancel(sessionId, ALERT_ID) } @@ -311,8 +298,8 @@ class NotificationService : Service() { * The intent that opens one session, and the id it carries back out. * * The two halves are written together so neither can be changed without the other, and the scheme - * is enrollment's `aiapp://` under a different host so that [MainActivity] has one thing to look at - * when an intent arrives rather than two. + * is enrollment's `aiapp://` under a different host so that [MainActivity] has one thing to look + * at. * * The id rides in the intent's **data** rather than in an extra, which is not a style choice: * PendingIntent identity is `Intent.filterEquals`, and that compares the data while ignoring @@ -345,9 +332,8 @@ data class SessionNotification( * What a notification asks of the reader, in the words they see. * * What they have to do, not what the session did: "awaitingInput" is the wire's word and says - * nothing to somebody reading a lock screen. One function because the same fact is now shown in two - * places -- Android's drawer and the app's own banner -- and two mappings of one word drift. The - * banner colours the line as well, which is its own decision and stays with the drawing. + * nothing to somebody reading a lock screen. One function because the same fact is shown in two + * places -- Android's drawer and the app's own banner -- and two mappings of one word drift. */ fun attentionLine(kind: String): String = when (kind) { diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/PeerMessage.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/PeerMessage.kt index 0da0622..f70e66b 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/PeerMessage.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/PeerMessage.kt @@ -31,13 +31,12 @@ import androidx.compose.ui.unit.dp * * Drawn as its own kind rather than as the reader's own bubble. They did not say this, and a * transcript that puts it in their voice is making a claim about who asked for the work that - * follows -- which is exactly the question a peer message is usually the answer to. + * follows. * * Opened, the card is drawn in *pieces* -- this heading and one [PeerBlockRow] per markdown block, - * each its own item of the transcript list. See [TranscriptUnit.PeerHead] for the measurements that - * bought; what matters here is that the pieces have to add up to the card that was there before, so - * the fill, the corner radius and the padding all live in [peerSurface] rather than being written - * out at each piece. + * each its own item of the transcript list. See [TranscriptUnit.PeerHead] for what that bought; + * what matters here is that the pieces have to add up to the card that was there before, so the + * fill, the corner radius and the padding all live in [peerSurface]. */ @Composable fun PeerHeadRow( @@ -91,9 +90,9 @@ fun PeerBlockRow(unit: TranscriptUnit.PeerBlock, replies: ParsedReplies, onToggl // The words shut the card too, and have to do it themselves -- see [LocalMarkdownTap]. // Without this the card closes everywhere except on the text, which is most of it. CompositionLocalProvider(LocalMarkdownTap provides rememberMarkdownTap(onToggle)) { - // The gap the card's own column used to provide between its heading and its prose, - // and between one block and the next -- inside the piece, so the card's fill runs - // through it. + // The gap the card's own column used to provide between its heading and its prose, and + // between one block and the next -- inside the piece, so the card's fill runs through + // it. MarkdownPiece(unit.text, unit.piece, replies, Modifier.padding(top = unit.spacing)) } } @@ -102,16 +101,14 @@ fun PeerBlockRow(unit: TranscriptUnit.PeerBlock, replies: ParsedReplies, onToggl /** * One piece of a card drawn in slices: the fill, the corners it owns, and the room inside it. * - * A filled Material card is elevation zero ([CardDefaults] takes it from `FilledCardTokens`, which - * is `Level0`), so there is no shadow that a seam would show through -- which is the whole reason a - * card can be cut up at all. Each piece paints the caller's container colour the way a - * [androidx.compose .material3.Card] would and rounds only the corners at the ends of the message, - * so the pieces abut into one continuous card. Shared by the two rows that are cut this way -- an - * opened peer message and a long user message -- because two copies of the corner logic is how one - * of them grows a seam. + * A filled Material card is elevation zero, so there is no shadow that a seam would show through -- + * which is the whole reason a card can be cut up at all. Each piece paints the caller's container + * colour and rounds only the corners at the ends of the message, so the pieces abut into one + * continuous card. Shared by the two rows cut this way -- an opened peer message and a long user + * message -- because two copies of the corner logic is how one of them grows a seam. * - * The padding is the other half of it: 12dp all round was the card's own, so the top piece keeps - * the top of it, the bottom piece the bottom, and the middle pieces neither. + * The padding is the other half: 12dp all round was the card's own, so the top piece keeps the top + * of it, the bottom piece the bottom, and the middle pieces neither. */ @Composable fun Modifier.cardPiece( diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/PendingAttachments.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/PendingAttachments.kt index 6665132..3a0c5e2 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/PendingAttachments.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/PendingAttachments.kt @@ -34,13 +34,10 @@ import androidx.compose.ui.unit.sp * What is about to be sent, directly above the box it will be sent from. * * The count on the "+" button was the whole of what said an image was attached, so the only way to - * find out *which* image was to send it. A control belongs with the thing it acts on, and what - * these are attached to is the message being typed -- which is why they sit here rather than - * anywhere else on the screen. + * find out *which* image was to send it. A control belongs with the thing it acts on. * * Scrolls sideways rather than wrapping or shrinking: the row keeps one thumbnail size whatever is - * in it, so four attachments look like four of the same thing rather than four smaller ones. A file - * is a tile of the same height carrying its name, since a name is all there is to show of it. + * in it, so four attachments look like four of the same thing rather than four smaller ones. */ @Composable fun PendingAttachments( @@ -67,8 +64,7 @@ fun PendingAttachments( * * Removal is here because there is nowhere else it could be: an image picked by mistake could * otherwise only be dealt with by sending it. The whole thumbnail is the target rather than a - * corner cross -- a cross small enough to sit on a 64dp square is smaller than a fingertip -- and - * the label is what says so, since nothing about the picture does. + * corner cross -- a cross small enough to sit on a 64dp square is smaller than a fingertip. */ @Composable private fun PendingThumbnail( @@ -84,8 +80,7 @@ private fun PendingThumbnail( .clip(shape) // An outline as well as a fill. Most of what gets attached here is a screenshot of a // dark app, and cropped to a square its middle is often near-black -- against this - // background the tile then had no edge at all, and the only thing saying an image was - // attached was the cross drawn on top of nothing. + // background the tile then had no edge at all. .border(1.dp, MaterialTheme.colorScheme.outlineVariant, shape) // Behind the picture as well as under a missing one, so the tile is a tile before // anything has arrived to fill it. @@ -105,9 +100,9 @@ private fun PendingThumbnail( color = MaterialTheme.colorScheme.onSurfaceVariant, ) } else { - // A spinner, as the transcript's images have: one appearance for "a picture - // is on its way", learned once. An ellipsis had to be read as a spinner that - // was not moving. + // A spinner, as the transcript's images have: one appearance for "a picture is + // on its way", learned once. An ellipsis had to be read as a spinner not + // moving. CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp) } else -> @@ -118,14 +113,12 @@ private fun PendingThumbnail( modifier = Modifier.size(THUMBNAIL), ) } - // The whole square removes it, and this only says so. A cross small enough to sit in - // the corner of a 64dp thumbnail is smaller than a fingertip, so making it the target - // would be a control drawn at a size nobody can hit. + // The whole square removes it, and this only says so. A cross small enough to sit in the + // corner of a 64dp thumbnail is smaller than a fingertip. // // The disc is sized here and the mark centred inside it, rather than the glyph being - // aligned directly: a glyph's box is wider than the cross it draws, so aligning the box - // to the corner hung the visible mark over the edge and put its backing somewhere the - // eye reads as a second, misplaced square. + // aligned directly: a glyph's box is wider than the cross it draws, so aligning the box to + // the corner hung the visible mark over the edge. Box( Modifier.align(Alignment.TopEnd) .padding(2.dp) diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/PinnedCert.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/PinnedCert.kt index cce7d2d..be44e7e 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/PinnedCert.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/PinnedCert.kt @@ -3,15 +3,13 @@ package com.example.aiapp import com.example.wgapplink.PinnedTls import java.net.HttpURLConnection -// PINNED_CA_PEM is generated at build time from the CA on the machine doing -// the build -- see the generatePinnedCert task in build.gradle.kts. It is -// deliberately not a checked-in constant: the private key that signs against -// it must never be anywhere this repo is, and an APK should pin whatever CA -// the backend it was built for actually serves. +// PINNED_CA_PEM is generated at build time from the CA on the machine doing the build -- see the +// generatePinnedCert task in build.gradle.kts. It is deliberately not a checked-in constant: the +// private key that signs against it must never be anywhere this repo is, and an APK should pin +// whatever CA the backend it was built for actually serves. // -// The pinning itself lives in wg-app-link, since dev-updater needs exactly -// the same thing. What stays here is the one product-specific fact -- which -// certificate this app pins. +// The pinning itself lives in wg-app-link, since dev-updater needs exactly the same thing. What +// stays here is which certificate this app pins. private val pinned = PinnedTls(PINNED_CA_PEM) /** Every request this app makes goes through this -- there is no unpinned path. */ diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/RawBlock.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/RawBlock.kt index 201847d..2b3df2c 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/RawBlock.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/RawBlock.kt @@ -16,20 +16,17 @@ import androidx.compose.ui.unit.dp * * A composable rather than a modifier repeated at each site, because the inset is part of it -- * monospace text drawn hard against the edge of a tinted block reads as a clipping fault, and three - * copies of "clip, fill, pad" drift apart the first time one of them is adjusted. + * copies of "clip, fill, pad" drift apart the first time one is adjusted. * - * The colour is [rawSurface], which is also what a code block inside a reply is given; that is the - * point of having one name for it. Markdown's blocks are painted by the renderer rather than by - * this, since it draws its own, but they are the same colour on purpose. + * The colour is [rawSurface], which is also what a code block inside a reply is given. */ @Composable fun RawBlock(modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit) { Column( modifier .fillMaxWidth() - // Smaller than a card's radius, and deliberately: this sits *inside* one, and a - // rounded rectangle drawn at the same radius as the rounded rectangle behind it reads - // as a misprint rather than as nesting. + // Smaller than a card's radius, and deliberately: this sits *inside* one, and a rounded + // rectangle drawn at the same radius as the one behind it reads as a misprint. .clip(MaterialTheme.shapes.extraSmall) .background(rawSurface) .padding(horizontal = 8.dp, vertical = 6.dp), diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/ResetCountdown.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/ResetCountdown.kt index 1db6b3a..4316b71 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/ResetCountdown.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/ResetCountdown.kt @@ -4,17 +4,16 @@ import java.time.Duration import java.time.OffsetDateTime // How long is left in a usage window. Shared by the session bar and the usage screen: the -// arithmetic is the same in both and only the sentence around it differs, so everything here -// returns the span or the state on its own and leaves the wording to the caller. +// arithmetic is the same in both, so everything here returns the span or the state on its own and +// leaves the wording to the caller. /** * "1d 4h", "3h 12m", "12m" -- the span alone, with no leading or trailing words. * * Rounded **up** to the whole minute, rather than truncated as it was. A window with 3h 12m 50s * left is nearer four minutes past the twelve than it is to twelve, and truncating also parks the - * figure on a minute it has already spent -- so the reader watching the number decide whether to - * start something was consistently told less headroom than they had. One rule, so the session bar - * and the usage dialog cannot round a shared measurement two different ways. + * figure on a minute it has already spent. One rule, so the session bar and the usage dialog cannot + * round a shared measurement two different ways. */ fun formatSpan(until: Duration): String { val up = if (until.seconds % 60 == 0L && until.nano == 0) until else until.plusMinutes(1) @@ -31,14 +30,12 @@ fun formatSpan(until: Duration): String { * Three answers rather than a nullable duration, because two of them shared `null` and they are not * the same thing at all. A window the server sent no reset time for is one that is **not running**: * the five-hour window is anchored to the block it started in, so between sessions there is nothing - * counting down and the API says so by omitting the field -- measured against a live response on - * 2026-08-31, where the five-hour window's reset was exactly five hours after the moment work - * resumed. A timestamp that did arrive and could not be read is the genuinely unknown case, and it - * is the only one worth those words. + * counting down and the API says so by omitting the field. A timestamp that did arrive and could + * not be read is the genuinely unknown case. * * Collapsing them put "reset time unknown" on the session bar for a machine behaving perfectly, on * the one row somebody reads before starting something big -- and the usage dialog, looking at the - * same field, quietly drew nothing. Two rules for one missing value; this is the rule. + * same field, quietly drew nothing. */ sealed class WindowEnd { /** No reset time was sent, so nothing is running in this window. Not a failure to find out. */ diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/ScrollAnchor.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/ScrollAnchor.kt index 7587858..140b009 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/ScrollAnchor.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/ScrollAnchor.kt @@ -10,24 +10,21 @@ private const val ANCHORS = "session-scroll" * * Named by a **sequence number** -- see [TranscriptRow.startSeq] -- rather than by an index or by * the row key the list draws with. An index means nothing across a reopen, since the transcript is - * fetched newest-first and a session that has said anything since has renumbered every position. - * The row key looks stable and is not: a tool row is named after its run, `joinPages` gives a run - * the name of its newest half, and the newest half is whatever the newest page happened to start - * with -- so an active session renames its tool runs every time it is reopened, and an anchor - * naming one is never found. A seq is the server's own numbering, assigned once and never moved. + * fetched newest-first. The row key looks stable and is not: a tool row is named after its run, + * `joinPages` gives a run the name of its newest half, and the newest half is whatever the newest + * page started with -- so an active session renames its tool runs every time it is reopened. A seq + * is the server's own numbering, assigned once and never moved. * - * [unit] is which unit of the row the viewport started at -- see [TranscriptUnit.ordinal] -- and - * [offset] how far that unit was scrolled past the viewport's newest edge, in pixels. A seq alone - * is not a place: a reply is one seq and can be forty blocks long, and a reader stopped halfway - * down it is put back at that block, not at the reply. + * [unit] is which unit of the row the viewport started at and [offset] how far that unit was + * scrolled past the viewport's newest edge. A seq alone is not a place: a reply is one seq and can + * be forty blocks long. */ data class ScrollAnchor(val seq: Long, val offset: Int, val unit: Int = 0) /** * On this device rather than on the backend, which is where this app otherwise keeps state so every * device sees it. Scroll position is the same exception a draft is: it is where the phone in - * somebody's hand is pointed, and having one device jump because another was scrolled would be a - * surprise rather than a convenience. + * somebody's hand is pointed. */ fun loadScrollAnchor(context: Context, sessionId: String): ScrollAnchor? { val stored = @@ -36,8 +33,8 @@ fun loadScrollAnchor(context: Context, sessionId: String): ScrollAnchor? { val fields = stored.split(':') val seq = fields.getOrNull(0)?.toLongOrNull() ?: return null val offset = fields.getOrNull(1)?.toIntOrNull() ?: return null - // Positions saved before the unit was recorded name the row's oldest unit, which is the - // closest older place -- the same choice [unitIndexFor] makes when a unit is gone. + // Positions saved before the unit was recorded name the row's oldest unit, which is the closest + // older place -- the same choice [unitIndexFor] makes when a unit is gone. return ScrollAnchor(seq, offset, fields.getOrNull(2)?.toIntOrNull() ?: 0) } @@ -45,9 +42,8 @@ fun loadScrollAnchor(context: Context, sessionId: String): ScrollAnchor? { * Records where [sessionId] is being read, or forgets it when [anchor] is null. * * The path out is reading to the newest end, which is what the caller passes null for: a session - * left at the bottom has nothing to restore and should open at the bottom, which is also the cheap - * case. A session *deleted* while it held an anchor leaves its key behind, for the reason and at - * the cost `Drafts.kt` describes. + * left at the bottom has nothing to restore. A session *deleted* while it held an anchor leaves its + * key behind, for the reason and at the cost `Drafts.kt` describes. */ fun saveScrollAnchor(context: Context, sessionId: String, anchor: ScrollAnchor?) { context.getSharedPreferences(ANCHORS, Context.MODE_PRIVATE).edit { diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/ServerConfig.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/ServerConfig.kt index ddb30a3..9947569 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/ServerConfig.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/ServerConfig.kt @@ -14,10 +14,9 @@ typealias ServerSettings = com.example.wgapplink.ServerSettings /** * This app's enrollment, which is the whole of what is product-specific about it. * - * Both values are load-bearing and neither may be changed casually. The scheme is what routes a - * scanned QR here rather than to Dev Updater, and the key alias names the Android Keystore key the - * token is already sealed under on every enrolled phone -- changing it would leave those phones - * reading as not enrolled, with no error to explain why. + * Both values are load-bearing. The scheme is what routes a scanned QR here rather than to Dev + * Updater, and the key alias names the Android Keystore key the token is already sealed under on + * every enrolled phone -- changing it would leave those phones reading as not enrolled. */ private val store = ServerStore(scheme = "aiapp", keyAlias = "aiapp-token-key") diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionAlerts.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionAlerts.kt index 0843418..611bf79 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionAlerts.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionAlerts.kt @@ -33,16 +33,14 @@ import androidx.lifecycle.repeatOnLifecycle /** * A session wanting attention, said over the app rather than through Android's drawer. * - * Two places can carry the same fact and only one of them is right at a time. A row in the shade is - * for somebody looking at something else: it makes a sound, it waits however long it has to, and - * acting on it means leaving whatever they were doing. Somebody with this app open needs none of - * that -- they are already here, and what a tap on the notification would have done is what a tap - * on this does. So while these are on screen the stream is delivered here instead, which is - * arranged by the collection below and nothing else; see `NotificationService.forTheScreen`. + * Two places can carry the same fact and only one is right at a time. A row in the shade is for + * somebody looking at something else: it makes a sound, it waits however long it has to, and acting + * on it means leaving whatever they were doing. Somebody with this app open needs none of that. So + * while these are on screen the stream is delivered here instead, which is arranged by the + * collection below and nothing else. * - * A banner can go three ways, and each is somebody deciding something different: tapped, which - * opens the session; pushed off either side; or left alone, in which case it goes by itself when - * the bar across its foot runs out. + * A banner can go three ways, each somebody deciding something different: tapped, which opens the + * session; pushed off either side; or left alone, in which case it goes when the bar runs out. */ @Composable fun SessionAlerts(onOpen: (SessionOpenRequest) -> Unit, modifier: Modifier = Modifier) { @@ -58,28 +56,26 @@ fun SessionAlerts(onOpen: (SessionOpenRequest) -> Unit, modifier: Modifier = Mod arrivals++ val alert = SessionAlert(notification, arrivals) // One banner per session, replacing that session's own -- the same rule the - // drawer follows, and for the same reason: a session that finished and then - // asked a question is one thing to know about, the question. It keeps its - // place in the queue rather than moving to the end, because the reader may - // already be reaching for it. + // drawer follows: a session that finished and then asked a question is one + // thing to know about, the question. It keeps its place in the queue rather + // than moving to the end, because the reader may already be reaching for it. val already = queue.indexOfFirst { it.notification.sessionId == notification.sessionId } if (already >= 0) queue[already] = alert else queue.add(alert) } } finally { - // Leaving the app hands the job back to the drawer, so nothing arriving while it - // is away is lost. What would be lost is the truth of what is already up: these - // say a session wants somebody *now*, and one still sitting here on a return - // several minutes later is a claim nobody checked. Frozen, too -- Compose stops - // the clock with the window, so the timer that was going to retire it has been - // standing still the whole time. + // Leaving the app hands the job back to the drawer, so nothing arriving while it is + // away is lost. What would be lost is the truth of what is already up: these say a + // session wants somebody *now*, and one still sitting here on a return several + // minutes later is a claim nobody checked. Frozen, too -- Compose stops the clock + // with the window. queue.clear() } } } - // Oldest at the top, so a new one appears below the ones already being read instead of - // shoving them down the screen mid-reach. + // Oldest at the top, so a new one appears below the ones already being read instead of shoving + // them down the screen mid-reach. Column(modifier.fillMaxWidth().padding(8.dp)) { queue.forEach { alert -> key(alert.arrival) { @@ -103,9 +99,7 @@ private data class SessionAlert(val notification: SessionNotification, val arriv * One banner: what wants attention, and how long this has left to say so. * * The bar and the going away are one value rather than a bar beside a timer, because two of them - * would be two accounts of the same countdown and only one can be the one that fires. What is drawn - * is therefore the thing that decides, which is the only arrangement where a bar that has emptied - * cannot be sitting under a banner that is still there. + * would be two accounts of the same countdown and only one can be the one that fires. */ @Composable private fun AlertBanner(alert: SessionAlert, onOpen: () -> Unit, onGone: () -> Unit) { @@ -135,9 +129,7 @@ private fun AlertBanner(alert: SessionAlert, onOpen: () -> Unit, onGone: () -> U ), // Outlined, because the step it needs to make is not one this palette can make with a // tint: the card under a banner on the session list is the same surface, so a banner - // relying on colour alone reads as one more row that happens to be in the way. The - // border is the one cue, and the elevation beside it is the platform's shadow rather - // than a second tint -- Material draws no tonal overlay over a container stated here. + // relying on colour alone reads as one more row in the way. The border is the one cue. border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline), elevation = CardDefaults.cardElevation(defaultElevation = 6.dp), ) { @@ -145,8 +137,8 @@ private fun AlertBanner(alert: SessionAlert, onOpen: () -> Unit, onGone: () -> U Text( alert.notification.title, style = MaterialTheme.typography.titleSmall, - // One line, cut at the tail: a session is identified by the start of its - // name, and a banner that grew with the name would move the one below it. + // One line, cut at the tail: a session is identified by the start of its name, + // and a banner that grew with the name would move the one below it. maxLines = 1, overflow = TextOverflow.Ellipsis, ) @@ -163,9 +155,8 @@ private fun AlertBanner(alert: SessionAlert, onOpen: () -> Unit, onGone: () -> U LinearProgressIndicator( progress = { life.value }, // Blue because it is reporting how much of something is left rather than passing - // judgement on it -- the reason `progressColor` exists. Stated beside the track, - // which is the card's own colour so that the spent part reads as empty rather - // than as a second bar. + // judgement on it. Stated beside the track, which is the card's own colour so that + // the spent part reads as empty rather than as a second bar. color = progressColor, trackColor = MaterialTheme.colorScheme.surfaceContainerHigh, drawStopIndicator = {}, @@ -180,7 +171,6 @@ private fun AlertBanner(alert: SessionAlert, onOpen: () -> Unit, onGone: () -> U * How long a banner stays if nobody touches it. * * Long enough to read a session name and a line, short enough that a stack of them clears itself - * while somebody is still on the screen that produced them. The bar makes the number visible, so - * this is a duration the reader can watch rather than one they have to learn. + * while somebody is still on the screen that produced them. The bar makes the number visible. */ private const val ALERT_LIFE_MS = 6_000 diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionImage.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionImage.kt index df21ca6..e440b1c 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionImage.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionImage.kt @@ -49,10 +49,9 @@ data class SessionBitmap(val bitmap: ImageBitmap?, val failed: Boolean) /** * Fetches (authenticated, pinned) and decodes one transcript image, remembered per ref so scrolling - * does not refetch. - * - * Shared by the transcript's images and the composer's pending attachments, because the fetch, the - * decode and the two-state answer are one block of logic that had been written twice. + * does not refetch. Shared by the transcript's images and the composer's pending attachments, + * because the fetch, the decode and the two-state answer are one block of logic that had been + * written twice. */ @Composable fun rememberSessionBitmap(settings: ServerSettings, sessionId: String, ref: String): SessionBitmap { @@ -75,15 +74,12 @@ fun rememberSessionBitmap(settings: ServerSettings, sessionId: String, ref: Stri * An image in the transcript: a fixed-height thumbnail that opens full screen. * * The height is decided before the bytes arrive and never changes. An image row that grew when it - * finished loading pushed everything below it, so a transcript being read scrolled itself while - * somebody was looking at it -- and in a bottom-anchored list, images loading above the viewport - * moved the text under the reader's eyes. Reserving the final height makes loading invisible, which - * is what it should be. + * finished loading pushed everything below it, so a transcript being read scrolled itself -- and in + * a bottom-anchored list, images loading above the viewport moved the text under the reader's eyes. * * Four lines of body text, so a screenshot reads as an attachment beside the conversation rather - * than as a page of its own. Full size is one tap away -- but the full-size view itself is not - * here. [onOpen] hands the ref to the screen, which draws [SessionImageViewer] outside the list; - * see that function for the reason. + * than as a page of its own. The full-size view itself is not here: [onOpen] hands the ref to the + * screen, which draws [SessionImageViewer] outside the list. */ @Composable fun SessionImage( @@ -97,9 +93,8 @@ fun SessionImage( val heightPx = with(LocalDensity.current) { height.roundToPx() } Box(Modifier.fillMaxWidth().height(height), contentAlignment = Alignment.CenterStart) { when (val image = bitmap) { - // Two states, not one: an image still arriving and an image that will never arrive - // look nothing alike to a reader who can do something about the second. So one gets a - // spinner in the space the picture is about to fill, and the other gets words. + // Two states, not one: an image still arriving and an image that will never arrive look + // nothing alike to a reader who can do something about the second. null -> if (failed) { Text( @@ -130,15 +125,12 @@ fun SessionImage( * `Read` on its own is a row of one call, and the moment the next call arrives the two become a * group -- a different composable in a different part of the tree, so everything the old subtree * remembered goes, the dialog included. Somebody looking at a screenshot was thrown back to the - * transcript because the session made another tool call. The same happens to a row regrouped by a - * page of history landing. + * transcript because the session made another tool call. * - * Held by the screen, none of that reaches it: what is open is a property of the screen, not of - * whichever row happened to draw the thumbnail. + * Held by the screen, none of that reaches it: what is open is a property of the screen. * * The cost is one fetch, since the thumbnail's decoded bitmap belongs to a row this does not go - * through. Paid deliberately rather than plumbed around: it is one request for a picture somebody - * asked to see, and the loading and unavailable states below are the same two the thumbnail draws. + * through. Paid deliberately: it is one request for a picture somebody asked to see. */ @Composable fun SessionImageViewer( @@ -157,9 +149,9 @@ fun SessionImageViewer( contentAlignment = Alignment.Center, ) { when (val image = bitmap) { - // Two states, not one, exactly as the thumbnail has them: still coming, and never - // coming. Stated in white because this box paints its own black behind them and a - // theme colour would be picked against a surface that is not there. + // Two states, not one, exactly as the thumbnail has them. Stated in white because + // this box paints its own black behind them and a theme colour would be picked + // against a surface that is not there. null -> if (failed) { Text( @@ -170,8 +162,7 @@ fun SessionImageViewer( } else { // The whole dialog is the area this picture is about to fill, so the // spinner sits in the middle of it. White for the same reason the words - // beside it are: this box paints its own black, and a theme colour would - // be chosen against a surface that is not there. + // beside it are. CircularProgressIndicator(color = Color.White) } else -> ZoomableImage(image) @@ -185,11 +176,10 @@ fun SessionImageViewer( * * A square of the row's own height rather than the full width of the transcript: the height is what * [SessionImage] reserves and the width is not known until the bytes arrive, so a full-width - * placeholder would promise a picture wider than most of them turn out to be. Square is the closest - * thing to "the size of it" that can be drawn before knowing. + * placeholder would promise a picture wider than most turn out to be. * - * Tinted, so the reader can see that something is being kept for a picture. That is also what - * distinguishes it from the failure beside it, which is words on the ordinary surface. + * Tinted, so the reader can see that something is being kept for a picture -- which is also what + * distinguishes it from the failure beside it, words on the ordinary surface. */ @Composable private fun LoadingImage(height: Dp) { @@ -210,8 +200,8 @@ private val LOADING_SPINNER = 24.dp * Four lines of the body style the transcript is set in. * * Measured from the type rather than written as a dp, so it stays four lines when the text size - * changes -- including when the reader has scaled fonts up, which is exactly when a hardcoded - * height would be wrong. + * changes -- including when the reader has scaled fonts up, which is when a hardcoded height is + * wrong. */ @Composable private fun thumbnailHeight(): Dp { @@ -226,8 +216,7 @@ private fun thumbnailHeight(): Dp { * Nearest neighbour when the image is being enlarged, smooth when it is being shrunk. * * A small image blown up with interpolation turns into a blur that hides what it is -- the same - * image with hard pixel edges stays readable. Shrinking wants the opposite, so this is a decision - * per image rather than a preference set once. + * image with hard pixel edges stays readable. Shrinking wants the opposite. */ private fun enlargingFilter(sourceHeight: Int, drawnHeight: Int): FilterQuality = if (sourceHeight < drawnHeight) FilterQuality.None else FilterQuality.High @@ -237,7 +226,7 @@ private fun enlargingFilter(sourceHeight: Int, drawnHeight: Int): FilterQuality * * Inside a dialog rather than a screen -- see [SessionImageViewer] -- so the platform's back * gesture returns to the transcript instead of leaving the app. It opens fitted, the whole image - * visible, which is the thing a reader wants first; zoom is theirs from there. + * visible. */ @Composable private fun ZoomableImage(image: ImageBitmap) { 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 5f63f55..1c3cc2b 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionListScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionListScreen.kt @@ -53,24 +53,19 @@ fun SessionListScreen( var listState by remember { mutableStateOf>>(LoadState.Loading) } var confirmingDelete by remember { mutableStateOf(null) } - // Failures that belong to one session rather than to the list, keyed by - // its id and shown on its own card. The two scopes are decided by - // whether the server answered: it answered and refused, so this says - // nothing about the other rows, where a server that has stopped - // answering leaves every row stale and is `listState`'s to report. + // Failures that belong to one session rather than to the list, keyed by its id and shown on its + // own card. The two scopes are decided by whether the server answered: it answered and refused, + // so this says nothing about the other rows. // - // Cleared on the next successful load below -- an entry outlives its - // session otherwise, and would reappear against whatever the phone - // fetched next. + // Cleared on the next successful load below -- an entry outlives its session otherwise. var deleteErrors by remember { mutableStateOf>(emptyMap()) } - // Which sessions have a delete in flight. A set of ids rather than a flag on the row, - // because the rows are rebuilt from whatever the server last said and this belongs to the - // request rather than to the session. + // Which sessions have a delete in flight. A set of ids rather than a flag on the row, because + // the rows are rebuilt from whatever the server last said and this belongs to the request. var deleting by remember { mutableStateOf>(emptySet()) } - // This phone's copies of these sessions' transcripts, pruned from here because this is where - // a session stops existing. See TranscriptCache. + // This phone's copies of these sessions' transcripts, pruned from here because this is where a + // session stops existing. See TranscriptCache. val context = LocalContext.current val transcriptCache = remember(settings) { TranscriptCache(cacheRoot(context, settings)) } @@ -83,10 +78,9 @@ fun SessionListScreen( withContext(Dispatchers.IO) { LoadState.Loaded(fetchSessions(settings)) } deleteErrors = emptyMap() // The path out for a cached transcript whose session was deleted somewhere - // else -- from another device, or at the backend. This list is the only place - // that ever learns the full set, and what the residue costs here is megabytes - // rather than a draft's few bytes. On the answer rather than in `finally`: a - // list that failed to arrive says nothing about which sessions exist. + // else. This list is the only place that ever learns the full set. On the + // answer rather than in `finally`: a list that failed to arrive says nothing + // about which sessions exist. withContext(Dispatchers.IO) { transcriptCache.retainOnly(loaded.value.map { it.id }.toSet()) } @@ -103,12 +97,9 @@ fun SessionListScreen( Column(Modifier.fillMaxSize().padding(16.dp)) { when (val state = listState) { is LoadState.Loading -> CircularProgressIndicator() - // The message as Api.kt wrote it, with nothing added: it is - // already a whole sentence naming the address and what to - // check, so a prefix here read "Couldn't reach the server: - // Couldn't reach the server at ...". It was also a guess -- - // a delete that the server itself refused had reached it - // fine. + // The message as Api.kt wrote it, with nothing added: it is already a whole + // sentence naming the address and what to check, so a prefix here read "Couldn't + // reach the server: Couldn't reach the server at ...". is LoadState.Error -> Text( state.message, @@ -122,8 +113,7 @@ fun SessionListScreen( color = MaterialTheme.colorScheme.onSurfaceVariant, ) } - // Awaiting-answer first (the point of the screen), then - // most recently active. + // Awaiting-answer first (the point of the screen), then most recently active. val ordered = state.value.sortedWith( compareByDescending { it.status == "awaitingInput" } @@ -155,40 +145,37 @@ fun SessionListScreen( confirmingDelete?.let { session -> // Reset per session, so a toggle turned on for one conversation is not still on for the - // next one somebody opens this dialog for. Off to begin with: see [deleteSession]. + // next. Off to begin with: see [deleteSession]. var alsoDeleteForeign by remember(session.id) { mutableStateOf(false) } AlertDialog( onDismissRequest = { confirmingDelete = null }, title = { Text("Delete \"${session.title}\"?") }, text = { // Two different acts behind one button, so it says which one this is. What - // separates them is whether the *driver* keeps its own record of the - // conversation -- the Claude Code CLI does, under ~/.claude/projects, whether - // this app spawned the session or imported it; echo and llama.cpp do not, and - // for those the app's transcript is the only copy there is. + // separates them is whether the *driver* keeps its own record of the conversation + // -- the Claude Code CLI does, whether this app spawned the session or imported it; + // echo and llama.cpp do not. // // This used to branch on `imported`, above a comment asserting that "a session - // started here has no copy anywhere". That was simply false for every - // claude-cli session this app spawned, and the two warnings disagreed about - // sessions that were equally recoverable. Getting it wrong in that direction - // is the expensive one: "this can't be undone", said of something that can, - // spends the credibility the sentence needs on the sessions where it is true. + // started here has no copy anywhere". That was false for every claude-cli session + // this app spawned, and getting it wrong in that direction is the expensive one: + // "this can't be undone", said of something that can, spends the credibility the + // sentence needs. // - // Neither branch promises a restore. The recoverable one says what is known -- - // the driver keeps its own record -- rather than that the file is still there, - // which nothing here checked; and it names what goes either way, because this - // app's transcript holds images, peer messages and commands that the CLI's own - // record never had. + // Neither branch promises a restore. The recoverable one says what is known -- the + // driver keeps its own record -- rather than that the file is still there, and it + // names what goes either way, because this app's transcript holds images, peer + // messages and commands the CLI's own record never had. Column { Text( when { !session.keepsOwnTranscript -> "Kills the process and deletes the conversation. Nothing else " + "keeps a copy, so this can't be undone." - // The sentence below is the one the toggle makes false, which is why - // it is written twice rather than appended to: leaving "should still - // be there to import again" on screen beside a switch that removes it - // is the reassurance being read at the moment it stops being true. + // The sentence below is the one the toggle makes false, which is why it + // is written twice rather than appended to: leaving "should still be + // there to import again" on screen beside a switch that removes it is + // the reassurance being read at the moment it stops being true. alsoDeleteForeign -> "Kills the process and deletes both copies of the conversation: " + "this app's, and Claude Code's own transcript on the " + @@ -203,12 +190,12 @@ fun SessionListScreen( ) // Only where there is a second copy to decide about. Absent rather than // disabled, because this is not a capability being withheld: for echo and - // llama.cpp there is no other transcript, and a switch offering to delete - // one would be asking about something that does not exist. + // llama.cpp there is no other transcript, and a switch offering to delete one + // would be asking about something that does not exist. if (session.keepsOwnTranscript) { Spacer(Modifier.height(16.dp)) - // Its own row rather than beside the paragraph: a switch is taller than - // a line of text and re-centres whatever shares a row with it. + // Its own row rather than beside the paragraph: a switch is taller than a + // line of text and re-centres whatever shares a row with it. Row(verticalAlignment = Alignment.CenterVertically) { Text( "Delete Claude Code's transcript too", @@ -229,8 +216,7 @@ fun SessionListScreen( onClick = { confirmingDelete = null // Marked here rather than after the request returns: the row has to say - // something is happening to it from the moment it is asked for, which - // is the whole of what this state is for. + // something is happening to it from the moment it is asked for. deleting = deleting + session.id deleteErrors = deleteErrors - session.id scope.launch { @@ -241,10 +227,9 @@ fun SessionListScreen( // session exactly as it was, and its transcript with it. transcriptCache.session(session.id).purge() } - // Only this row, and only what changed. Refetching the list - // instead put every other session back through loading and - // handed the reader an empty screen -- to report on something - // that was never in doubt. + // Only this row, and only what changed. Refetching the list instead + // put every other session back through loading and handed the + // reader an empty screen, to report on something never in doubt. val loaded = listState if (loaded is LoadState.Loaded) { listState = @@ -263,8 +248,8 @@ fun SessionListScreen( } } ) { - // Coloured by consequence: this takes something away, and does so wherever - // it appears -- the same rule the import screen's Delete follows. + // Coloured by consequence: this takes something away, and does so wherever it + // appears -- the same rule the import screen's Delete follows. Text("Delete", color = MaterialTheme.colorScheme.error) } }, @@ -286,8 +271,7 @@ private fun SessionCard( * * Suspended rather than removed while it is -- see [BusyItem] -- which says the row is on its * way out without claiming it has gone: a row removed the moment Delete is pressed is a promise - * about a request that has not been answered yet, and putting it back when the server refuses - * is worse than never having taken it away. + * about a request that has not been answered yet. */ deleting: Boolean, onOpen: () -> Unit, @@ -295,9 +279,9 @@ private fun SessionCard( ) { BusyItem(label = if (deleting) "deleting" else null) { Card( - // Off while the delete is in flight: a card that still opens a session it is - // deleting is a race the reader can start by tapping. On the card rather than in - // [BusyItem], which leaves gestures alone so the list still scrolls. + // Off while the delete is in flight: a card that still opens a session it is deleting + // is a race the reader can start by tapping. On the card rather than in [BusyItem], + // which leaves gestures alone so the list still scrolls. Modifier.fillMaxWidth() .combinedClickable( enabled = !deleting, @@ -320,9 +304,9 @@ private fun SessionCard( Spacer(Modifier.height(4.dp)) Row(modifier = Modifier.fillMaxWidth()) { Text( - // Machine, then what runs on it, then what it is set to: the same order - // and separator as the session screen's header and the usage dialog, so - // one pair of facts is not written three ways. + // Machine, then what runs on it, then what it is set to: the same order and + // separator as the session screen's header and the usage dialog, so one + // pair of facts is not written three ways. listOfNotNull( session.setupName, session.provider, @@ -341,8 +325,7 @@ private fun SessionCard( } error?.let { Spacer(Modifier.height(8.dp)) - // The server's own words, unprefixed, the way every other - // failure in this app is shown. + // The server's own words, unprefixed, the way every other failure is shown. Text( it, style = MaterialTheme.typography.bodySmall, @@ -362,16 +345,16 @@ fun StatusText(status: String) { "running" -> "running" to runningColor "compacting" -> "compacting" to commandColor "exited" -> "exited" to MaterialTheme.colorScheme.onSurfaceVariant - // Said in words, because it differs in kind from the others rather than in degree: - // the session is not idle and has not exited, nobody has been able to find out - // which. A muted colour alone would read as one of the quiet states. + // Said in words, because it differs in kind from the others rather than in degree: the + // session is not idle and has not exited, nobody has been able to find out which. A + // muted colour alone would read as one of the quiet states. "unknown" -> "can't tell" to MaterialTheme.colorScheme.onSurfaceVariant else -> status to MaterialTheme.colorScheme.onSurfaceVariant } Row(verticalAlignment = Alignment.CenterVertically) { if (sessionWorking(status)) { - // The same colour as the word beside it: the two are one signal, and a spinner in - // the theme's accent says the state is something other than what the label says. + // The same colour as the word beside it: the two are one signal, and a spinner in the + // theme's accent says the state is something other than what the label says. CircularProgressIndicator( modifier = Modifier.width(14.dp).height(14.dp), strokeWidth = 2.dp, diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt index 40086f5..12f2aff 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt @@ -90,11 +90,8 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext /** - * How big the "still loading this conversation" spinner is. - * - * Bigger than the ones inside a tool card, which are 16dp and report on one call among many, and - * smaller than a splash: this one is standing in for the whole screen while there is nothing else - * on it, and it is the only thing to look at. + * How big the "still loading this conversation" spinner is: bigger than the ones inside a tool + * card, which report on one call among many, and smaller than a splash. */ private val LOADING_SPINNER = 48.dp @@ -102,19 +99,13 @@ private val LOADING_SPINNER = 48.dp * How close, in screenfuls of estimated scroll, the reader may come to the end of loaded history * before the next page is fetched. * - * Multiplied by the viewport to give a number of *pixels* of scroll, which is the distance the - * question is actually about: how far the reader can keep going before they run out. A row is - * anything from one line to a page, so a count of rows is that distance only by accident. Eight - * rows was the number once, and on a tool-heavy transcript eight rows is less than one screen: the - * reader reached the end of what was loaded on *every* swipe and waited a round trip standing - * there, which is a list running out of transcript rather than a slow frame. + * Multiplied by the viewport to give a number of *pixels*, which is the distance the question is + * actually about. A row is anything from one line to a page, so a count of rows is that distance + * only by accident: eight rows was the number once, and on a tool-heavy transcript that is less + * than one screen, so the reader ran out of loaded transcript on every swipe. * - * Six, because the two ways to be wrong are not the same size: firing early costs a page fetched - * that nobody reads, firing late is a spinner under somebody's finger for a whole round trip over - * the tunnel -- and the distance a hard fling covers while that fetch is in flight is several - * screens on its own. The estimate this multiplies is built from measured unit sizes, so a bigger - * cushion no longer amplifies a bad guess the way it would have when the guess came from whatever - * happened to be on screen. + * Six, because the two ways to be wrong are not the same size: firing early costs a page nobody + * reads, firing late is a spinner under somebody's finger for a whole round trip over the tunnel. */ private const val HISTORY_SCREENS = 6 @@ -122,34 +113,26 @@ private const val HISTORY_SCREENS = 6 * How many *rows* a backwards page asks for. * * Rows, not events, because the two are nothing alike: a reply is stored a token at a time, and on - * one real transcript (2,426 events, 2026-08-30) the whole conversation was seven assistant - * messages, the median folding four hundred deltas into one row. A page counted in events was a - * fifth of a single row, so reaching a screenful took dozens of sequential round trips and the - * reader stood at the boundary through every one. The server now joins each delta run into the one - * event the fold makes of it (`fetchTranscript(coalesce = true)`), so a page of rows is a page of - * the screen whatever the delta density. + * one real transcript (2,426 events) the whole conversation was seven assistant messages, the + * median folding four hundred deltas into one row. A page counted in events was a fifth of a single + * row, so reaching a screenful took dozens of sequential round trips. * - * A few screens' worth, so one page clears the cushion below and the reader reaches loaded content - * without a fetch in the way. A page that still falls short is followed by another in the - * background, nobody waiting on either. + * A few screens' worth, so one page clears the cushion below. A page that still falls short is + * followed by another in the background, nobody waiting on either. * - * The opening page stays counted in events and small ([fetchTranscript]'s default): it is on the - * critical path of showing the screen, only has to fill a viewport, and is the newest window, where - * coalescing is unsafe for the live cursor anyway. + * The opening page stays counted in events and small: it is on the critical path of showing the + * screen, and it is the newest window, where coalescing is unsafe for the live cursor anyway. */ private const val HISTORY_PAGE = 40 /** * The most events one request of a restore may ask for. * - * A restore knows exactly how far back it has to reach, so it asks for that in one request rather - * than walking there a page at a time. This bounds the request anyway, because "exactly how far" is - * however far the reader had scrolled and there is no bound on that -- and a single response of - * arbitrary size is the one shape a phone on a slow tunnel handles worst. At roughly 800 bytes an - * event, measured on a real transcript, this is about three megabytes. - * - * Going past it costs another request rather than anything being missed, so the number only trades - * round trips against response size. + * A restore knows exactly how far back it has to reach, so it asks in one request rather than + * walking there a page at a time. This bounds it anyway, because "exactly how far" is however far + * the reader had scrolled -- and a single response of arbitrary size is the shape a phone on a slow + * tunnel handles worst. At roughly 800 bytes an event, this is about three megabytes. Going past it + * costs another request rather than anything being missed. */ private const val RESTORE_PAGE_MAX = 4000 @@ -157,21 +140,18 @@ private const val RESTORE_PAGE_MAX = 4000 * Events added past the anchor on a restore, so the anchor's row is never the oldest loaded one. * * The oldest loaded row is a half-row -- [joinPages] welds its other half on when the page behind - * it arrives, and it grows -- so a restore that stopped exactly at the anchor would put the reader - * a screen out once that growth landed. This is in events, like the rest of the restore span: that - * path counts events to reach a known seq and does not coalesce. + * it arrives, and it grows -- so a restore stopping exactly at the anchor would put the reader a + * screen out once that growth landed. */ private const val RESTORE_PAGE_CUSHION = 400 /** * Which row was asked to hold its top edge, and how tall it was when it last measured. * - * Deliberately *not* snapshot state, and that is the point of the whole class. Both fields are - * written from the layout phase; a snapshot write there that composition reads would schedule - * another recomposition, and the correction has to land inside the frame that is already being laid - * out rather than in a later one. Nothing observes these, so nothing needs to. - * - * [key] is cleared by the resize it was set for, so it cannot be spent on an unrelated one. + * Deliberately *not* snapshot state, which is the point of the class. Both fields are written from + * the layout phase; a snapshot write there that composition reads would schedule another + * recomposition, and the correction has to land inside the frame already being laid out. [key] is + * cleared by the resize it was set for, so it cannot be spent on an unrelated one. */ private class TopEdgeHold { var key: Any? = null @@ -183,13 +163,12 @@ private class LastHeight { } /** - * Which row the last touch landed in, and whether it landed in the row's top half -- which is the - * end that row should hold when it changes height; see [holdTopEdge]. + * Which row the last touch landed in, and whether it landed in the row's top half -- the end that + * row should hold when it changes height; see [holdTopEdge]. * - * One slot rather than a map, because only the touch that is about to toggle something matters: + * One slot rather than a map, because only the touch about to toggle something matters: * [toggleAnchored] reads it in the same gesture that wrote it. Written from a detector on each - * *visible* row -- the lazy list is what makes that affordable, since only rows on screen have one - * and it runs on touch, not per frame. Not snapshot state: nothing composes from it. + * *visible* row, so it costs only rows on screen and runs on touch rather than per frame. */ private class LastTouch { var key: Any? = null @@ -200,14 +179,12 @@ private class LastTouch { * Keeps this row's top edge where it is when the row changes height, if it was asked to. * * This runs in the *layout* phase, from the measurement that discovers the new height, and that is - * the whole reason it is a modifier rather than an effect. A correction posted to a coroutine - * arrives a frame or more after the layout it is correcting, so the wrong position is drawn once - * before the right one -- visible as a flick, and worse the faster the screen refreshes. Scrolling - * from here happens before anything is drawn, so there is no frame to see and nothing that depends - * on how quickly the correction is scheduled. + * why it is a modifier rather than an effect. A correction posted to a coroutine arrives a frame or + * more after the layout it is correcting, so the wrong position is drawn once first -- visible as a + * flick, and worse the faster the screen refreshes. * * [hold] is given the change in height. The row's bottom edge is held by the list, so a scroll of - * exactly that much is what leaves the top edge where it was. + * exactly that much leaves the top edge where it was. */ @Composable private fun Modifier.holdTopEdge(key: Any, held: TopEdgeHold, hold: (Int) -> Unit): Modifier { @@ -215,8 +192,8 @@ private fun Modifier.holdTopEdge(key: Any, held: TopEdgeHold, hold: (Int) -> Uni return onSizeChanged { size -> val previous = last.value last.value = size.height - // A first measurement has no previous height to have moved from, and a row that came - // back after being scrolled away is a first measurement again. + // A first measurement has no previous height to have moved from, and a row that came back + // after being scrolled away is a first measurement again. if (previous == null || previous == size.height || held.key != key) return@onSizeChanged held.key = null hold(size.height - previous) @@ -224,11 +201,9 @@ private fun Modifier.holdTopEdge(key: Any, held: TopEdgeHold, hold: (Int) -> Uni } // The transcript's data model -- TranscriptItem, foldEvent, joinPages, warm -- lives in -// TranscriptItems.kt: it is pure event folding with no screen in it, and the two halves changed -// for unrelated reasons while they shared this file. - -// isImeVisible: see the comment beside `imeVisible` below for why the keyboard's own -// self-correction needs it. +// TranscriptItems.kt: it is pure event folding with no screen in it. +// +// isImeVisible: see the comment beside `imeVisible` below. @OptIn(ExperimentalLayoutApi::class) @Composable fun SessionScreen( @@ -247,87 +222,72 @@ fun SessionScreen( val topEdgeHeld = remember { TopEdgeHold() } var items by remember { mutableStateOf(listOf()) } var status by remember { mutableStateOf(summary.status) } - // Seeded from the row this screen was opened from, so a conversation already under way says - // how much it is holding before any turn happens here. Null is "nobody has measured it", - // which is a different answer from an empty context and is drawn differently. + // Seeded from the row this screen was opened from, so a conversation already under way says how + // much it is holding before any turn happens here. Null is "nobody has measured it", which is a + // different answer from an empty context and is drawn differently. var contextTokens by remember(summary.id) { mutableStateOf(summary.contextTokens) } - // When the current compaction started and how long ago that is. The moment comes off the - // `compacting` status event itself -- the server timestamps every transcript line -- rather - // than off this device noticing one, which is what makes it survive leaving the session and - // reopening it. See `compactingLabel`: null is still the honest answer for a session whose - // status was never reported as compacting at all. + // When the current compaction started. The moment comes off the `compacting` status event + // itself -- the server timestamps every transcript line -- rather than off this device noticing + // one, which is what makes it survive leaving the session and reopening it. var compactingSince by remember { mutableStateOf(null) } var compactingFor by remember { mutableStateOf(null) } var streamError by remember { mutableStateOf(null) } var actionError by remember { mutableStateOf(null) } // Whether the composer's process button has a request out. What it does next is decided from - // the session's status, and the status only changes once the server has answered and the - // stream has carried it back -- so two presses in that gap are two requests, both decided - // against the state before either of them. The server refuses the second one, but a control - // that can be pressed while its own last press is still in flight is asking to be. + // the session's status, and that only changes once the server has answered and the stream has + // carried it back -- so two presses in that gap are two requests, both decided against the + // state before either of them. var processInFlight by remember { mutableStateOf(false) } val context = LocalContext.current // Seeded from what was left in the box last time and written back on every keystroke, so - // leaving the screen -- or the system reclaiming the app -- does not throw away a half-typed - // message. See `Drafts.kt` for why this one piece of state is the device's rather than the - // server's. + // leaving the screen does not throw away a half-typed message. See `Drafts.kt`. var input by remember(summary.id) { mutableStateOf(atEnd(loadDraft(context, summary.id))) } // A model the reader has chosen and not yet confirmed. See [ModelSwitchWarning]: switching - // makes the session re-read the whole conversation, which is worth asking about first. + // makes the session re-read the whole conversation. var pendingModel by remember { mutableStateOf(null) } - // What was last taken from the command suggestions, so the list closes behind it; see - // [CommandSuggestions] at its call site. + // What was last taken from the command suggestions, so the list closes behind it. var picked by remember { mutableStateOf(null) } // The transcript's selection, held here rather than inside [TranscriptList] because the rows - // have to ask whether anything is selected before they treat a tap as their own -- see - // [expanding]. + // have to ask whether anything is selected before they treat a tap as their own. val selection = rememberSelectionState() // Read here, at composition, rather than inside [expanding] at the moment of the click. The - // container clears the selection from the very press that a card then reads as its own, a few - // milliseconds earlier and in the same event -- so a card asking the live state at its click - // always hears "nothing is selected", and a tap meant to put a selection away also shut the - // tool call the words were in. This value is whatever was true as of the last frame, which is - // what the reader was looking at when they touched the screen. + // container clears the selection from the very press a card then reads as its own, a few + // milliseconds earlier and in the same event -- so a card asking the live state always hears + // "nothing is selected", and a tap meant to put a selection away also shut the tool call the + // words were in. This is whatever was true as of the last frame. val selecting = selection.selectedTexts.isNotEmpty() var expandedTools by remember { mutableStateOf(setOf()) } - // Which runs of adjacent tool calls are open. Keyed by the first call's - // id, so a group survives more calls arriving after it. + // Which runs of adjacent tool calls are open. Keyed by the first call's id, so a group survives + // more calls arriving after it. var expandedGroups by remember { mutableStateOf(setOf()) } - // Runs that have already been drawn as a group, so the transition into one is noticed exactly - // once. See the effect below. + // Runs already drawn as a group, so the transition into one is noticed exactly once. var everGrouped by remember { mutableStateOf(setOf()) } - // Which messages from other agents are open, by the seq that identifies their row. Closed - // by default, which is the rule for anything new in this transcript: a screen that opens - // everything it can is one nobody can scan. + // Which messages from other agents are open, by the seq that identifies their row. Closed by + // default, which is the rule for anything new in this transcript. var expandedNotes by remember { mutableStateOf(setOf()) } - // Which memory notes are open, by the note's own text -- see [MemoryNote]. Closed by default - // like everything else new in this transcript, and held here rather than in the card so a + // Which memory notes are open, by the note's own text. Held here rather than in the card so a // note opened and scrolled past is still open on the way back. var openMemories by remember { mutableStateOf(setOf()) } // The image being looked at full screen, by ref. Here rather than in the row that drew the - // thumbnail: a row regrouped underneath the reader takes its whole subtree with it, and the - // dialog with it -- see [SessionImageViewer]. + // thumbnail: a row regrouped underneath the reader takes its whole subtree with it. var fullImage by remember { mutableStateOf(null) } // Uploaded-but-not-yet-sent attachment ids; sent with the next message. var pendingAttachments by remember { mutableStateOf(listOf()) } - // What this session is set to now, seeded from the row that opened it and - // then owned here, because changing either is something this screen does. - // The name shown at the top. Held here rather than read from the row that opened this - // screen, because renaming is something this screen can do -- through the settings below it, - // or by typing the command -- and a header still showing the old name reads as a rename that - // did not take. + // The name shown at the top. Held here rather than read from the row that opened this screen, + // because renaming is something this screen can do -- and a header still showing the old name + // reads as a rename that did not take. var title by remember(summary.id) { mutableStateOf(summary.title) } var model by remember { mutableStateOf(summary.model) } var permissionMode by remember { mutableStateOf(summary.permissionMode ?: "auto") } - // The models this provider actually offers, asked of the server rather - // than listed here: a hardcoded list is a claim about a machine. + // The models this provider actually offers, asked of the server rather than listed here: a + // hardcoded list is a claim about a machine. var offeredModels by remember { mutableStateOf>(emptyList()) } val lifecycleOwner = LocalLifecycleOwner.current // The resume cursor, written from the stream's IO thread. val lastSeq = remember { AtomicLong(0) } // Bumped to rebuild this screen from nothing -- what Reload in the settings dialog does. It - // keys everything that describes one visit to this session: the source below, the opening - // effect, the stream, and the anchor being put back. See TRANSCRIPT_CACHE.md's decision 8. + // keys everything describing one visit: the source, the opening effect, the stream, and the + // anchor. See TRANSCRIPT_CACHE.md's decision 8. var epoch by remember(summary.id) { mutableIntStateOf(0) } // This server's cached transcripts, and this session's half of them. The cache is per server // because two servers can hold a session with the same id; the source is per visit because @@ -337,77 +297,58 @@ fun SessionScreen( remember(summary.id, epoch) { TranscriptSource(settings, summary.id, cache.session(summary.id)) } - // Whether the cached tail has been shown to still be the server's own line. Nothing is - // resumed from a cached cursor until it has -- see [TranscriptSource.probe] -- and a probe - // that could not be made leaves this false for the stream loop to try again. + // Whether the cached tail has been shown to still be the server's own line. Nothing is resumed + // from a cached cursor until it has, and a probe that could not be made leaves this false for + // the stream loop to try again. var probePassed by remember(summary.id, epoch) { mutableStateOf(false) } // Whether the opening effect is still settling that question. It draws the cached rows and // lifts [ready] before the answer arrives, which is the point of the cache -- so the stream - // below has to wait for this rather than for `ready`, or it asks the very same question a - // second time and races the answer. + // below waits for this rather than for `ready`, or it asks the same question twice. var probing by remember(summary.id, epoch) { mutableStateOf(true) } - // The oldest sequence number loaded, and whether there is more behind - // it. Paging backwards is what keeps opening a long session cheap: the - // screen starts with the end of the conversation and fetches earlier - // pages only when somebody scrolls to them. + // The oldest sequence number loaded, and whether there is more behind it. Paging backwards is + // what keeps opening a long session cheap. var oldestSeq by remember { mutableLongStateOf(0L) } - // Where this session was last being read, from this device's own store. Read once, because - // it is the question "where did I leave off" and the answer stops being interesting the - // moment the list is on screen. + // Where this session was last being read, from this device's own store. Read once, because the + // answer stops being interesting the moment the list is on screen. val savedAnchor = remember(summary.id, epoch) { loadScrollAnchor(context, summary.id) } - // Whether the saved position is still being put back -- the history it needs fetched, and the - // scroll applied. Nothing is drawn while it is: opening at the newest end and then travelling - // to the anchor is exactly the journey a reader must never see, and this transcript is not - // allowed to move under one. + // Whether the saved position is still being put back. Nothing is drawn while it is: opening at + // the newest end and then travelling to the anchor is exactly the journey a reader must never + // see. var restoring by remember(summary.id, epoch) { mutableStateOf(savedAnchor != null) } - // Sent, but not yet read by the session -- which is when the backend - // records it and it comes back as a row. Until then it is drawn below - // the working indicator, because that is where it is in the session's - // reading of events: after everything taken in, not yet taken in - // itself. // Messages the server has taken and the session has not read yet, by the id that will resolve - // them. From the event stream rather than from what this screen sent, so they are still here - // after leaving the session or restarting the app -- and so a message sent from another device - // is drawn waiting on this one too. + // them. From the event stream rather than from what this screen sent, so they survive leaving + // the session -- and a message sent from another device is drawn waiting on this one too. var queued by remember { mutableStateOf(listOf()) } - // Commands the session has been asked to run and cannot yet, by the id that will resolve - // them. From the server rather than from this screen, so a rename sent from the settings - // screen -- or from another device -- is drawn waiting here too. + // Commands the session has been asked to run and cannot yet, by the id that will resolve them. + // From the server, so a rename sent from another device is drawn waiting here too. var waitingCommands by remember { mutableStateOf(listOf>()) } val running = sessionWorking(status) var moreHistory by remember { mutableStateOf(true) } var loadingHistory by remember { mutableStateOf(false) } var ready by remember { mutableStateOf(false) } - // Replies parsed ahead of the rows that draw them; see [ParsedReplies]. Per session, because - // it describes that session's rows and nothing else. + // Replies parsed ahead of the rows that draw them; see [ParsedReplies]. val replies = remember(summary.id) { ParsedReplies() } - // Keyed like everything else that describes one session's transcript. `rememberLazyListState` - // saves through `rememberSaveable`, and this screen restores by its own anchor instead -- - // two restores would fight over the first frame. + // Keyed like everything else describing one session's transcript. `rememberLazyListState` saves + // through `rememberSaveable`, and this screen restores by its own anchor instead -- two + // restores would fight over the first frame. val listState = remember(summary.id) { LazyListState() } - // Whether the newest message is on screen right now. The list is reversed, so the newest end - // is the scrolling start: nothing behind you is exactly being at the bottom. Asked of the - // scroll state rather than of item indices, because a zero-height first item (the empty - // "below" slot) makes an index ambiguous about where the viewport actually is. - // - // This is what the jump-to-newest button watches, and the gate on recording -- see [record]. + // Whether the newest message is on screen right now. The list is reversed, so the newest end is + // the scrolling start: nothing behind you is exactly being at the bottom. Asked of the scroll + // state rather than of item indices, because a zero-height first item makes an index ambiguous. + // This is what the jump-to-newest button watches, and the gate on recording. val atNewest by remember { derivedStateOf { !listState.canScrollBackward } } // Transcript events that arrived while somebody was reading further back, in the order they - // arrived, waiting for them to return to the newest end. See [record] for why. + // arrived, waiting for them to return to the newest end. See [record]. var held by remember { mutableStateOf(listOf()) } - // What is actually drawn: the transcript with runs of adjacent tool - // calls folded into one row each, flattened into the list's units. + // What is actually drawn: the transcript with runs of adjacent tool calls folded into one row. val rows = remember(items) { groupToolRuns(items) } - // Bumped when a cold reply's parses become ready, so the flatten runs again and can split - // it; see [unwarmedReplies]. + // Bumped when a cold reply's parses become ready, so the flatten runs again and can split it. var warmedTick by remember { mutableIntStateOf(0) } val units = remember(rows, expandedNotes, warmedTick) { transcriptUnits(rows, replies, expandedNotes) } // The reply that just finished streaming is the one row whose parses nobody has made: pages - // warm before their fold lands, but nothing warms live deltas. Off the composing thread, - // then the tick re-flattens -- so settling never costs a whole-message parse in a frame. - // Re-launched per fold and almost always finds nothing; during streaming the last row is - // unsettled and not wanted. + // warm before their fold lands, but nothing warms live deltas. Off the composing thread, then + // the tick re-flattens, so settling never costs a whole-message parse in a frame. LaunchedEffect(rows) { val cold = unwarmedReplies(rows, replies) if (cold.isNotEmpty()) { @@ -416,8 +357,7 @@ fun SessionScreen( } } // The same list, readable from effects launched before this composition: an effect's closure - // keeps the values of the composition that launched it, and both the anchor saver and the - // restore need the units as they are *now*. + // keeps the values of the composition that launched it. val currentUnits by rememberUpdatedState(units) val lastTouch = remember { LastTouch() } @@ -427,14 +367,13 @@ fun SessionScreen( * * One function rather than a clearing written at each of the three places that need it -- a * stream reset, a cached transcript the server turns out not to have, and Reload -- because - * what has to go is a property of "these rows are no longer continuous with what comes next", - * not of who noticed. The two easy ones to leave out are [queued] and [waitingCommands]: both - * are folded from events, so a `messageQueued` whose resolving `userMessage` fell in the gap - * draws a bubble waiting for a message the session read long ago. [contextTokens] needs no - * clearing, because `UsageDelta.context` is absolute and the next one corrects it. + * what has to go is a property of "these rows are no longer continuous with what comes next". + * The two easy ones to leave out are [queued] and [waitingCommands]: both are folded from + * events, so a `messageQueued` whose resolving `userMessage` fell in the gap draws a bubble + * waiting for a message the session read long ago. [contextTokens] needs no clearing, because + * `UsageDelta.context` is absolute. * - * The resume cursor is deliberately *not* cleared here: a reset continues from where it was, - * and only a caller that is starting the conversation again from the server says so itself. + * The resume cursor is deliberately *not* cleared: a reset continues from where it was. */ fun dropLoadedTranscript() { items = listOf() @@ -453,44 +392,37 @@ fun SessionScreen( * leading edge of its first visible item, which in this upside-down layout is that item's * *bottom* -- so a row that grows pushes everything already on screen upwards, and the view * travels toward the newest end without anybody scrolling. Measured against a reply streamed in - * four hundred pieces: scrolling back a screen and then waiting six seconds ended at the very - * bottom, forty lines further on than where it was left. + * four hundred pieces: scrolling back a screen and waiting six seconds ended at the very + * bottom. * - * Insertions were never the problem -- the list is keyed, so a row arriving at either end - * leaves the anchor where it is, and reading back through history while a session works has - * always been still. What cannot be allowed is a row that is already there changing height, and - * the one guarantee that covers every way that happens -- a reply streaming, a tool's output - * arriving, a queued bubble appearing above the anchor -- is to change nothing at all while - * somebody is reading further back. + * Insertions were never the problem -- the list is keyed. What cannot be allowed is a row that + * is already there changing height, and the one guarantee covering every way that happens is to + * change nothing at all while somebody is reading further back. */ fun record(entry: SeqEvent) { - // The oldest event this view holds, which is what paging backwards - // starts from. Maintained here rather than by each loader: the - // first page and a stream reset both begin an empty view, and one - // of them getting it wrong is a transcript that will not scroll up. + // The oldest event this view holds, which is what paging backwards starts from. Maintained + // here rather than by each loader: the first page and a stream reset both begin an empty + // view, and one of them getting it wrong is a transcript that will not scroll up. if (oldestSeq == 0L) { oldestSeq = entry.seq moreHistory = entry.seq > 1L } val event = entry.event - // The message coming back is the session saying it has - // read it, so the bubble held below the indicator becomes - // the row `foldEvent` is about to add. - // Waiting, then read. Matched by id: the same message sent twice is two - // bubbles, and clearing by text would take away whichever matched first. + // Waiting, then read. Matched by id: the same message sent twice is two bubbles, and + // clearing by text would take away whichever matched first. if (event is SessionEvent.MessageQueued) { queued = queued + QueuedMessage(event.id, event.text, event.attachments) } if (event is SessionEvent.UserMessage) { queued = queued.filterNot { it.id == event.id } } - // Waiting, then taken back. From the server rather than from the tap, so every device - // drops the bubble and a reconnect does not put back one that was cancelled. + // Waiting, then taken back. From the server rather than from the tap, so every device drops + // the bubble and a reconnect does not put back one that was cancelled. if (event is SessionEvent.MessageDropped) { queued = queued.filterNot { it.id == event.id } } - // Waiting, then gone: a command leaves this list when the session takes it, - // and the row it becomes is added by `foldEvent` in the same pass. + // Waiting, then gone: a command leaves this list when the session takes it, and the row it + // becomes is added by `foldEvent` in the same pass. if (event is SessionEvent.CommandQueued) { waitingCommands = waitingCommands + (event.id to event.text) } @@ -510,26 +442,23 @@ fun SessionScreen( */ fun apply(entry: SeqEvent) { lastSeq.set(entry.seq) - // Before the rest, and for every event rather than only the usage ones: a compaction and - // a clear move this as much as a turn does, which is the whole reason it is a fold and - // not a running total. See `contextAfter`. + // Before the rest, and for every event rather than only the usage ones: a compaction and a + // clear move this as much as a turn does. See `contextAfter`. contextTokens = contextAfter(contextTokens, entry.event) when (val event = entry.event) { - // Nothing further: what it carries was folded into the context above, and what a - // turn cost is not something the transcript draws. + // Nothing further: what it carries was folded into the context above. is SessionEvent.UsageDelta -> {} else -> { - // What the session says it is set to now, which is the only thing that - // says it: picking from either menu asks, and the answer comes back here. + // What the session says it is set to now, which is the only thing that says it: + // picking from either menu asks, and the answer comes back here. if (event is SessionEvent.Settings) { event.model?.let { model = it } event.permissionMode?.let { permissionMode = it } } if (event is SessionEvent.Status) { // The event's own timestamp, so a compaction that began before this screen - // opened is timed from when it actually began. Timing it from the moment we - // arrived would report the wait as shorter than it was, in exactly the case - // somebody is asking about -- a compaction worth asking about is a long one. + // opened is timed from when it actually began -- and a compaction worth asking + // about is a long one. compactingSince = when { event.state != "compacting" -> null @@ -552,14 +481,11 @@ fun SessionScreen( * The transcript is one [SelectionContainer], so a reader who has selected some text puts that * selection away by tapping -- and the tap that does it lands on whatever card the text is * drawn in. Left alone, that card takes it as a press of its own: the reader clears a selection - * and the tool call under their finger collapses, which is a second thing happening for a - * gesture that meant one. So a press with a selection outstanding spends itself clearing it and - * does nothing else, and the press after that -- with nothing selected -- opens or closes as - * usual. + * and the tool call under their finger collapses. So a press with a selection outstanding + * spends itself clearing it and does nothing else. * - * Every open and close on this screen goes through here rather than each writing the check, - * since which card the finger lands on is not something the reader chose and the rule cannot - * hold for only some of them. + * Every open and close goes through here rather than each writing the check, since which card + * the finger lands on is not something the reader chose. */ fun expanding(toggle: () -> Unit) { if (selecting) { @@ -574,20 +500,13 @@ fun SessionScreen( * * The transcript is laid out from the bottom, so every row's *bottom* edge is what the list * holds still and all growth goes upward. That is what a tap in a row's lower half already - * gets, so it needs nothing: shut a group from the bar at its foot and what follows it does not - * move, which is what the reader is looking at down there. A tap in the upper half is the other - * case -- left alone it sends the heading under the reader's finger up off the screen and fills - * the space above it, so the calls appear on the far side of the control that produced them -- - * and that one asks the row to hold its top edge instead. + * gets. A tap in the upper half is the other case -- left alone it sends the heading under the + * reader's finger up off the screen -- and that one asks the row to hold its top edge instead. * - * Which half decides it, rather than which control was pressed, so that everything that opens - * behaves the same way whether or not it happens to have a control at each end. A group has two - * and its heading and foot bar land in the halves they are already in; a single call is one - * card, and tapping low on an open one shuts it downward exactly as the bar does. + * Which half decides it, rather than which control was pressed, so everything that opens + * behaves the same way whether or not it has a control at each end. * - * The correction itself belongs to the measurement -- see [holdTopEdge]. Which half was touched - * comes from the row's own detector ([LastTouch]), written by the gesture that is about to run - * [toggle]. + * The correction itself belongs to the measurement -- see [holdTopEdge]. */ fun toggleAnchored(row: TranscriptRow, toggle: () -> Unit) = expanding { if (lastTouch.key == row.key && lastTouch.high) topEdgeHeld.key = row.key @@ -597,18 +516,16 @@ fun SessionScreen( /** * Whether the row holding transcript position [seq] is loaded, with older history behind it. * - * "Behind it" is the part that is easy to leave out. The oldest loaded row is a half-row -- - * [joinPages] welds the other half onto it when the page before it arrives, and it grows -- so - * putting the reader inside one leaves them where they were only until the next page lands, - * which was a screen and a half out. Any row that is not the oldest is final. + * "Behind it" is the part easy to leave out. The oldest loaded row is a half-row -- [joinPages] + * welds the other half on when the page before it arrives, and it grows -- so putting the + * reader inside one leaves them where they were only until the next page lands, which was a + * screen and a half out. Any row that is not the oldest is final. * * The last row starting at or before [seq], rather than one starting exactly there: the events - * behind a row can be regrouped between the save and the reopen -- a run of calls folds - * differently when a page boundary moves, and two halves of a reply become one message -- and - * the reader's place is inside whichever row now holds that seq, not gone. + * behind a row can be regrouped between the save and the reopen, and the reader's place is + * inside whichever row now holds that seq. * - * Computed from `items` rather than from `rows` for the reason [loadOlderPage] gives: `rows` is - * the composition's value and does not change under a running coroutine. + * Computed from `items` rather than `rows` for the reason [loadOlderPage] gives. */ fun anchorRow(seq: Long): Long? { val ordered = groupToolRuns(items) @@ -622,49 +539,38 @@ fun SessionScreen( * * Shared by the two things that page backwards -- somebody scrolling to the far end, and * putting the list back where it was left -- because they want the same page for the same - * reason and a second copy of this would be a second answer to "what is loaded". + * reason. * * Reads `items` rather than `rows`: this runs in a coroutine, and `rows` is the composition's * value, which does not change under a running one. */ suspend fun loadOlderPage(limit: Int = HISTORY_PAGE, coalesce: Boolean = true): Boolean { - // Nothing is loaded, so there is no "before" to ask about, and asking anyway is not a - // harmless no-op: `before = 0` fetches the events before the first one, which is none, - // and an empty page is how this function is told it has reached the start of the - // conversation -- so it would latch `moreHistory` false and the session could never be - // paged back at all. + // Nothing is loaded, so there is no "before" to ask about -- and asking anyway is not a + // harmless no-op: `before = 0` fetches the events before the first one, which is none, and + // an empty page is how this function is told it has reached the start of the conversation. + // It would latch `moreHistory` false and the session could never be paged back at all. // - // The window it fires in is the first layout. `moreHistory` starts true, which puts the - // history spinner in the list, which makes `visibleItemsInfo` non-empty before a single - // event has arrived -- and with no units loaded the room ahead adds up to zero, so the - // pager fetches. On a loopback server the opening page beat it and nothing was ever - // wrong; at `--delay 150`, which is what a phone over the tunnel actually costs, it won - // the race and the transcript stopped one page from its newest end with no spinner and - // nothing to say why. + // The window it fires in is the first layout: `moreHistory` starts true, which puts the + // history spinner in the list and makes `visibleItemsInfo` non-empty before a single event + // has arrived. On a loopback server the opening page beat it; at `--delay 150`, which is + // what a phone over the tunnel costs, it won the race. // - // Guarded here rather than at the two callers because it is a fact about the question, - // not about who is asking: the post-open fetch reaches it too, on the path where the - // opening page failed and left `oldestSeq` unset. + // Guarded here rather than at the two callers because it is a fact about the question. if (oldestSeq == 0L) return false - // The fetch *and* the fold, both off the thread that draws. Only the fetch used to be, - // and the fold is the expensive half: `foldEvent` returns a new list per event, so a page - // of [HISTORY_PAGE] events is that many copies of a list growing to that length -- around - // three hundred thousand element copies for one page, run on the main thread in the - // middle of the scroll that asked for it. It was affordable at eighty events and is not - // at eight hundred, which is why the page that made scrolling back reach the top made it - // stutter to get there. + // The fetch *and* the fold, both off the thread that draws. Only the fetch used to be, and + // the fold is the expensive half: `foldEvent` returns a new list per event, so a page is + // that many copies of a growing list -- around three hundred thousand element copies, run + // on the main thread in the middle of the scroll that asked for it. // // `Dispatchers.IO` for both rather than a hop to `Default` between them: the two are one - // errand, and this way the page costs one context switch instead of three. Neither half - // touches anything the composition owns -- `older` and `earlier` are local, and the - // `items` read below happens back on the caller's thread, where the write does too. + // errand. Neither half touches anything the composition owns. val page = withContext(Dispatchers.IO) { val older = source.page(before = oldestSeq, limit = limit, coalesce = coalesce) if (older.isEmpty()) return@withContext null // Folded oldest-first into a list of their own, then put in front: `foldEvent` - // merges streaming text into the item before it, so replaying an older page - // through the live list would glue it onto the newest message rather than its own. + // merges streaming text into the item before it, so replaying an older page through + // the live list would glue it onto the newest message rather than its own. var earlier = listOf() older.forEach { entry -> if (entry.event !is SessionEvent.UsageDelta) { @@ -681,26 +587,22 @@ fun SessionScreen( oldestSeq = oldest moreHistory = oldestSeq > 1L // Joined here rather than above, because it is the one step that reads what is already - // loaded: `items` must be read where it is written, and it is a single pass over the two - // lists against the page's quadratic fold. + // loaded: `items` must be read where it is written. val joined = joinPages(earlier, items) - // After the join rather than on the page alone: a boundary that fell through a reply - // leaves `joinPages` holding a message made of both halves, and that text has existed for - // no time at all. Warming the page by itself warmed the two halves and missed the one - // thing drawn -- which showed up as a single 22ms parse surviving every page. + // After the join rather than on the page alone: a boundary that fell through a reply leaves + // `joinPages` holding a message made of both halves, and that text has existed for no time + // at all. Warming the page by itself warmed the two halves and missed the one thing drawn. warm(replies, joined) items = joined return true } - // A call opened on its own stays open when a second call in the same run turns it into a - // group. Until this, watching a Bash call and having the session make another one shut the - // one being read and folded it behind "Called 2 tools" -- the reader lost what they were - // looking at because something else happened. + // A call opened on its own stays open when a second call in the same run turns it into a group. + // Until this, watching a Bash call and having the session make another one shut the one being + // read and folded it behind "Called 2 tools". // - // Considered once per run, at the moment it first becomes a group, and never again: after - // that the group's own toggle owns it, and re-deriving this every time would re-open a group - // the reader had just shut while one of its calls was still expanded. + // Considered once per run, at the moment it first becomes a group, and never again: after that + // the group's own toggle owns it. LaunchedEffect(rows) { val fresh = rows.filterIsInstance().filter { it.id !in everGrouped } if (fresh.isEmpty()) return@LaunchedEffect @@ -710,10 +612,10 @@ fun SessionScreen( everGrouped = everGrouped + fresh.map { it.id } } - // A compaction reports nothing about its own progress -- measured against the CLI, which - // says it has started, and then says nothing at all until it is done. So what this counts is - // the one thing anybody here can measure: how long it has been going. A bar filling up would - // be this screen inventing the part the CLI does not send. + // A compaction reports nothing about its own progress -- measured against the CLI, which says + // it has started and then nothing at all until it is done. So what this counts is the one thing + // anybody here can measure: how long it has been going. A bar filling up would be this screen + // inventing the part the CLI does not send. LaunchedEffect(compactingSince) { val since = compactingSince if (since == null) { @@ -721,32 +623,26 @@ fun SessionScreen( return@LaunchedEffect } while (true) { - // Against this device's wall clock, because `since` is the server's -- the same - // comparison `relativeTime` already makes for a session's last activity. Floored at - // zero so a phone running a little behind the backend counts up from nothing rather - // than reporting a compaction that has not started yet. + // Against this device's wall clock, because `since` is the server's. Floored at zero so + // a phone running a little behind the backend counts up from nothing rather than + // reporting a compaction that has not started yet. compactingFor = (System.currentTimeMillis() / 1000.0 - since).toLong().coerceAtLeast(0) delay(1000) } } - // The stream lifecycle: connect, follow, and on any drop reconnect - // from the cursor -- so a flaky link (or a backend restart) costs - // nothing but the gap's latency. - // The newest window first, before the stream opens, so the stream starts from where that - // window ended and carries live events only -- which is what it is good at. The window comes - // from this phone's own copy when there is one, and then costs a single request to check - // that the server's transcript is still the one it came from; otherwise it is a page fetched - // as it always was. See TRANSCRIPT_CACHE.md. + // The stream lifecycle: connect, follow, and on any drop reconnect from the cursor. + // + // The newest window first, before the stream opens, so the stream starts from where that window + // ended and carries live events only. The window comes from this phone's own copy when there is + // one, and then costs a single request to check that the server's transcript is still the one + // it came from. See TRANSCRIPT_CACHE.md. LaunchedEffect(summary.id, epoch) { /** * One opening window onto the screen, whichever side it came from. * * Warmed before the fold lands rather than after: flattening the rows into units splits - * every settled reply ([transcriptUnits]), and the flatten runs in the composition that - * first sees the rows. Folded into a scratch list off this thread to find out what needs - * warming; the real fold below also maintains the queue and the cursor, so it cannot be - * reused here. + * every settled reply, and the flatten runs in the composition that first sees the rows. */ suspend fun open(page: List) { withContext(Dispatchers.IO) { @@ -762,33 +658,31 @@ fun SessionScreen( } try { - // This phone's own copy first, drawn before anything is asked of the server -- which - // is the whole point of the cache. What makes it safe to draw before it is checked is - // that a failed check replaces these rows, with the same appearance as a reset. + // This phone's own copy first, drawn before anything is asked of the server. What makes + // it safe to draw before it is checked is that a failed check replaces these rows, with + // the same appearance as a reset. val cached = withContext(Dispatchers.IO) { source.cachedOpening() } if (cached != null) { open(cached) // A replay is as old as the last visit; the row this screen was opened from was // fetched moments ago. So the transcript comes from the cache and everything that - // is not the transcript comes from the summary, which is the newer measurement of - // the same thing -- otherwise a session that finished an hour ago opens saying - // "working" until the stream connects, which is a status row lying for a round - // trip. + // is not the transcript comes from the summary -- otherwise a session that finished + // an hour ago opens saying "working" until the stream connects. status = summary.status model = summary.model permissionMode = summary.permissionMode ?: "auto" if (summary.status != "compacting") compactingSince = null - // Nothing to put back, so these rows are the screen and the probe can return - // under them. A restore still has history to fetch and is gated below. + // Nothing to put back, so these rows are the screen and the probe can return under + // them. A restore still has history to fetch and is gated below. if (savedAnchor == null) ready = true } // The one thing a cached cursor has to be shown before the stream resumes from it. val usable = cached != null && withContext(Dispatchers.IO) { source.probe() } if (usable) probePassed = true if (!usable) { - // Either there was nothing cached, or what was cached is not what the server - // has -- the file was replaced or truncated under it. Same clearing as a reset, - // then an ordinary cold open. + // Either there was nothing cached, or what was cached is not what the server has -- + // the file was replaced or truncated under it. Same clearing as a reset, then an + // ordinary cold open. if (cached != null) { dropLoadedTranscript() lastSeq.set(0) @@ -799,11 +693,10 @@ fun SessionScreen( } } catch (e: ApiException) { // Not fatal: the stream below still replays from zero, which is slow but complete. - // Saying so beats silently showing nothing. // // It is also where a probe that could not be *made* lands -- a phone with no route to - // the server. Whatever was cached stays on screen and [probePassed] stays false, so - // the stream loop asks again before it resumes from that cursor. + // the server. Whatever was cached stays on screen and [probePassed] stays false, so the + // stream loop asks again before it resumes from that cursor. streamError = e.message } finally { // However that went, the stream is free to take it from here. @@ -811,50 +704,37 @@ fun SessionScreen( } try { - // Then back where reading stopped. An anchor deeper than the newest page is exactly - // the one worth restoring -- somebody who read to the bottom has no anchor at all -- - // and the cost was already paid on the way down there. + // Then back where reading stopped. An anchor deeper than the newest page is exactly the + // one worth restoring -- somebody who read to the bottom has no anchor at all. savedAnchor?.let { anchor -> // Pages until the anchor's row is loaded and has something older behind it. The - // oldest loaded row is a half-row: `joinPages` welds the other half onto it when - // the page behind it arrives, and it grows -- so anchoring into one puts the - // reader where they were only until the next page lands, which landed a screen - // and a half out. Any row that is not the oldest is final. + // oldest loaded row is a half-row that grows when the page behind it arrives, so + // anchoring into one puts the reader where they were only until that lands. // - // This terminates because `oldestSeq` walks strictly backwards and the anchor is - // a seq: once the window reaches past it, some loaded row starts at or before it - // and [indexOfSeq] answers. Keying on the row's *name* instead could not promise - // that -- a tool run is renamed whenever the newest page starts somewhere new, - // so an anchor on one was never found and this paged to the first event of the - // conversation every time an active session was reopened. + // This terminates because `oldestSeq` walks strictly backwards and the anchor is a + // seq. Keying on the row's *name* instead could not promise that -- a tool run is + // renamed whenever the newest page starts somewhere new, so an anchor on one was + // never found and this paged to the first event of the conversation every time. while (moreHistory && anchorRow(anchor.seq) == null) { // The whole span in one request rather than a page at a time. `read_window` // counts *lines* and a transcript numbers them one per seq, so the distance // back to the anchor is the number of events to ask for -- and were seqs ever - // sparse, that difference is larger than the count, which overshoots into - // older history rather than stopping short. [HISTORY_PAGE] on top is the - // cushion that keeps the anchor's row off the oldest edge, where it would - // still grow. + // sparse, that difference overshoots into older history rather than stopping + // short. // - // Capped, and the loop is what makes the cap safe: a span past it comes back - // in several requests instead of one, which is what this did for every - // restore until now -- thirteen sequential round trips to reopen a session - // somebody had read a little way back into, and a spinner for all of them. - // The bytes are the same either way, since every row between the anchor and - // the newest end has to be there for the list to be able to count to it. - // Raw, not coalesced: this counts events back to a known seq, and a page - // measured in rows cannot be counted to a seq. `RESTORE_PAGE_MAX` and the loop - // bound it; see [loadOlderPage]. + // Capped, and the loop is what makes the cap safe: a span past it comes back in + // several requests instead of one, which is what this did for every restore + // until now -- thirteen sequential round trips to reopen a session somebody had + // read a little way back into. Raw, not coalesced: this counts events back to a + // known seq, and a page measured in rows cannot be counted to one. val behind = oldestSeq - anchor.seq val loaded = if (behind < 0) { // The anchor's row is loaded but is the oldest half-row, which - // [anchorRow] refuses; what completes it is the row before it, - // and only a page counted in rows can promise to reach that. - // Counted in events, the span here is negative and was coerced - // to one: a request per delta, walking a long reply back one word - // at a time -- six hundred round trips and a spinner for all of - // them, seen 2026-09-03 with an anchor inside a 1,400-delta reply. + // [anchorRow] refuses; what completes it is the row before it, and only + // a page counted in rows can promise to reach that. Counted in events + // the span is negative and was coerced to one: a request per delta, six + // hundred round trips for an anchor inside a 1,400-delta reply. loadOlderPage() } else { loadOlderPage( @@ -866,21 +746,16 @@ fun SessionScreen( } if (!loaded) break } - // Resolved to the row that *holds* the saved position rather than passed - // straight through, because the two are not always the same seq: the events - // behind a row regroup between the save and the reopen -- a run of calls folds - // differently when a page boundary moves, two halves of a reply become one - // message. Null is a row that is no longer in the transcript at all -- a reset - // stream, or a session cleared from elsewhere -- and means there is nothing to - // put back: the list is already at the newest end, which is where it opens. + // Resolved to the row that *holds* the saved position rather than passed straight + // through, because the two are not always the same seq: the events behind a row + // regroup between the save and the reopen. Null is a row no longer in the + // transcript at all, and means there is nothing to put back. anchorRow(anchor.seq)?.let { rowSeq -> - // The units are built by composition, and this coroutine has been loading - // rows the composition may not have seen -- so wait for the build that - // holds the anchor's row before turning it into an index. Guaranteed to - // arrive, because the row is in `items` and the units are a pure function - // of it. Nothing is drawn during the wait: [restoring] gates drawing, and - // the scroll is applied before it is lifted, so there is no frame showing - // anywhere else. One past the index, because item zero is the "below" slot. + // The units are built by composition, and this coroutine has been loading rows + // the composition may not have seen -- so wait for the build that holds the + // anchor's row before turning it into an index. Guaranteed to arrive, because + // the units are a pure function of `items`. Nothing is drawn during the wait. + // One past the index, because item zero is the "below" slot. val index = snapshotFlow { unitIndexFor(currentUnits, rowSeq, anchor.unit) } .first { it != null }!! @@ -892,16 +767,14 @@ fun SessionScreen( // than where they were, which is the state this screen opens in anyway. streamError = e.message } - // Whatever happened above, including a page that never arrived: an empty transcript is a - // state the screen can draw, and a permanently blank one is not. + // Whatever happened above: an empty transcript is a state the screen can draw, and a + // permanently blank one is not. restoring = false ready = true - // The opening page is sized for time-to-first-frame, not for reading: it fills a - // viewport or two, so the first "still loading" boundary sat barely off-screen and the - // first upward scroll met it and waited a round trip. The same reasoning that keeps the - // opening page off the critical path puts the first full page right behind it, while - // the screen is already up. A restore skips this: it has just paged as deep as the - // anchor needed. + // The opening page is sized for time-to-first-frame, not for reading: it fills a viewport + // or two, so the first "still loading" boundary sat barely off-screen and the first upward + // scroll met it and waited a round trip. So the first full page goes right behind it, while + // the screen is already up. A restore skips this: it has just paged as deep as it needed. if (savedAnchor == null && moreHistory && !loadingHistory) { loadingHistory = true try { @@ -912,41 +785,34 @@ fun SessionScreen( loadingHistory = false } } - // Last, and off this thread: this session is what must not be evicted, so it is marked - // as visited before the budget is applied, and both are a walk of the cache directory - // that nothing on screen is waiting for. + // Last, and off this thread: this session is what must not be evicted, so it is marked as + // visited before the budget is applied, and both are a walk of the cache directory. withContext(Dispatchers.IO) { source.cache.touch() cache.evictToBudget(keep = summary.id) } } - // Only while the screen is actually on screen. Android stops the - // activity when somebody switches away, and the socket dies with it -- - // which arrived as "Lost the event stream (SocketTimeoutException)" - // waiting at the top on their return. Switching apps is a choice - // somebody made, not a fault to report, and reconnecting on a phone - // that has been backgrounded is work nobody is watching. Stopping the - // stream deliberately makes the drop a close rather than an error (see - // EventStream.close), and resuming reconnects from the same cursor. + // Only while the screen is actually on screen. Android stops the activity when somebody + // switches away and the socket dies with it, which arrived as "Lost the event stream" waiting + // at the top on their return. Switching apps is a choice somebody made, not a fault to report. + // Stopping the stream deliberately makes the drop a close rather than an error, and resuming + // reconnects from the same cursor. LaunchedEffect(summary.id, ready, epoch, lifecycleOwner) { if (!ready) return@LaunchedEffect // The opening effect draws cached rows and lifts `ready` *before* it has checked that the - // cursor under them is still the server's, so `ready` is no longer the whole gate: this - // waits for that check to settle. Without it the two run at once, ask the same question - // twice, and race each other's answer -- two probes per warm open in the server's log. + // cursor under them is still the server's, so `ready` is no longer the whole gate. Without + // this the two run at once and race each other's answer -- two probes per warm open. snapshotFlow { probing }.first { !it } lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { try { while (true) { try { - // A cached cursor whose probe never got an answer, because the server - // could not be reached when the screen opened. Resuming a stream from an - // unchecked cursor is the one thing this must not do, so it is asked - // again here, on the reconnect schedule, with the cached rows still on - // screen meanwhile. False covers both answers that mean "open cold": - // the file is not the one these rows came from, and there was nothing - // cached to check. + // A cached cursor whose probe never got an answer, because the server could + // not be reached when the screen opened. Resuming from an unchecked cursor + // is the one thing this must not do, so it is asked again here with the + // cached rows still on screen. False covers both answers that mean "open + // cold". if (!probePassed) { if (withContext(Dispatchers.IO) { source.probe() }) { probePassed = true @@ -961,22 +827,17 @@ fun SessionScreen( withContext(Dispatchers.IO) { source.follow( after = lastSeq.get(), - // Connected, measured rather than inferred: this is what - // takes a failure off the screen, and nothing else does. - // Clearing on the first event instead meant an idle - // session kept displaying an error it had recovered from. + // Connected, measured rather than inferred: this is what takes a + // failure off the screen. Clearing on the first event instead meant + // an idle session kept displaying an error it had recovered from. onOpen = { streamError = null }, onReset = { - // Too far behind to continue from: what is on - // screen is a stale prefix of a conversation - // that has moved on, and the window arriving - // next is not adjacent to it. Dropping the rows - // is what makes this the same as opening the - // screen -- `apply` refills them, and scrolling - // up pages the rest back in as it always does. - // The cache needs no telling: the window's first - // seq is not the seq it was expecting, which is - // what closes its live run and starts another. + // Too far behind to continue from: what is on screen is a stale + // prefix of a conversation that has moved on, and the window + // arriving next is not adjacent to it. Dropping the rows makes + // this the same as opening the screen. The cache needs no + // telling: the window's first seq is not the one it expected, + // which closes its live run and starts another. dropLoadedTranscript() }, ) { entry -> @@ -984,13 +845,13 @@ fun SessionScreen( } } } catch (e: kotlinx.coroutines.CancellationException) { - // Leaving the screen or going below STARTED. Not a failure, and - // swallowing it would leave this loop reconnecting forever. + // Leaving the screen or going below STARTED. Not a failure, and swallowing + // it would leave this loop reconnecting forever. throw e } catch (e: Exception) { // Any failure, not only an [ApiException]: the stream reconnects from its - // cursor, so there is nothing a failure here can cost that is worth - // closing the app over. Reported on the screen either way. + // cursor, so there is nothing a failure here can cost that is worth closing + // the app over. streamError = e.message ?: e::class.simpleName } finally { source.close() @@ -998,23 +859,19 @@ fun SessionScreen( delay(RECONNECT_DELAY_MS) } } finally { - // Cancellation -- going below STARTED, or leaving the screen -- - // cannot interrupt a blocking socket read. Closing is what - // unblocks it, and what marks the drop deliberate. + // Cancellation cannot interrupt a blocking socket read. Closing is what unblocks + // it, and what marks the drop deliberate. source.close() } } } - // The screen going away entirely, which the lifecycle scope above does - // not cover: a composable can leave the composition while the activity - // stays started. Keyed on the epoch as well, so that Reload's replacement - // source is the one a later disposal closes. + // The screen going away entirely, which the lifecycle scope above does not cover: a composable + // can leave the composition while the activity stays started. Keyed on the epoch as well, so + // Reload's replacement source is the one a later disposal closes. DisposableEffect(summary.id, epoch) { onDispose { source.close() } } // Nothing gets announced about the session somebody is reading; see NotificationService. - // RESUMED rather than STARTED because "looking at it" means the foreground -- a session left - // on this screen behind another app is one whose notifications are still wanted, and STARTED - // covers that case too. + // RESUMED rather than STARTED because "looking at it" means the foreground. LaunchedEffect(summary.id, lifecycleOwner) { lifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) { NotificationService.showing(context, summary.id) @@ -1026,9 +883,9 @@ fun SessionScreen( } } - // Back at the newest end, so the backlog [apply] held can land. Everything at once rather - // than paced out: they are at the bottom, which is the one place the list is allowed to - // follow new content, and drip-feeding it would only make that following last longer. + // Back at the newest end, so the backlog [apply] held can land. Everything at once rather than + // paced out: they are at the bottom, which is the one place the list is allowed to follow new + // content. LaunchedEffect(listState) { snapshotFlow { atNewest && held.isNotEmpty() } .collect { due -> @@ -1043,14 +900,11 @@ fun SessionScreen( // Driven by the position rather than by the scroll flag, and that is the whole point: a // *programmatic* scroll moves the list within one frame, so `isScrollInProgress` never // observably changes and anything waiting for a settle never runs. Jump to latest is exactly - // that, and it left the old position recorded -- so the reader pressed the control that means - // "take me to the end", left, came back, and was put back where they had been. + // that, and it left the old position recorded. // - // The place is the first visible item -- in this reversed list, the one at the *bottom* of - // the viewport -- named by its row's seq and its unit within the row, which are the two - // things that survive a reopen. The index does not (the transcript is fetched newest-first), - // and the key does not either (a tool run is renamed when the newest page starts somewhere - // new); see [ScrollAnchor]. + // The place is the first visible item -- in this reversed list, the one at the *bottom* of the + // viewport -- named by its row's seq and its unit within the row, which are the two things that + // survive a reopen. The index does not, and the key does not either; see [ScrollAnchor]. LaunchedEffect(listState) { snapshotFlow { if (listState.isScrollInProgress) null @@ -1071,10 +925,8 @@ fun SessionScreen( saveScrollAnchor( context, summary.id, - // Nothing to restore at the newest end, which is where a session with no - // anchor opens anyway -- so the ordinary case costs a `remove` and no - // page-back on the way in. One *before* the index, because item zero is - // the "below" slot; a viewport starting inside it is at the newest end. + // Nothing to restore at the newest end, which is where a session with no anchor + // opens anyway. One *before* the index, because item zero is the "below" slot. if (!awayFromNewest) null else currentUnits.getOrNull(index - 1)?.let { @@ -1083,40 +935,35 @@ fun SessionScreen( ) } } - // Reaching within a few screens of the far end of what is loaded fetches the page before - // it. + // Reaching within a few screens of the far end of what is loaded fetches the page before it. // - // The question is pixels of scroll -- how far can the reader keep going before they run out - // -- and a lazy list cannot answer it exactly, because it has never measured the items it - // has not composed. So the room ahead is added up from the real size of every unit the - // list *has* laid out, kept by key as units pass through the viewport, with the running - // average standing in for the ones it has never seen. It used to be the average of the - // units currently on screen, and the units on screen are the worst possible sample: two - // tall blocks fill a viewport, multiply out over dozens of unseen one-line rows, and - // report screens of room when the end is one swipe away -- so the reader met the spinner - // at every boundary, which is exactly what the cushion exists to prevent. + // The question is pixels of scroll -- how far can the reader keep going before they run out -- + // and a lazy list cannot answer it exactly, because it has never measured the items it has not + // composed. So the room ahead is added up from the real size of every unit the list *has* laid + // out, kept by key as units pass through the viewport, with the running average standing in for + // the ones it has never seen. It used to be the average of the units currently on screen, which + // is the worst possible sample: two tall blocks fill a viewport, multiply out over dozens of + // unseen one-line rows, and report screens of room when the end is one swipe away. // - // There is no correction beside this one. Following the newest message is not an effect: - // the list is reversed, so an arriving message extends the end the viewport is pinned to, - // and a page of history lands past every visible index and moves nothing. + // There is no correction beside this one. Following the newest message is not an effect: the + // list is reversed, so an arriving message extends the end the viewport is pinned to. val unitSizes = remember(summary.id) { HashMap() } LaunchedEffect(listState, moreHistory) { snapshotFlow { listState.layoutInfo } .collect { info -> val visible = info.visibleItemsInfo if (visible.isEmpty()) return@collect - // Before the guards below, so sizes keep accumulating while a page is in - // flight and the next estimate starts better informed. + // Before the guards below, so sizes keep accumulating while a page is in flight and + // the next estimate starts better informed. visible.forEach { unitSizes[it.key] = it.size } if (restoring || !moreHistory || loadingHistory) return@collect val viewport = info.viewportSize.height if (viewport == 0) return@collect val loaded = currentUnits val average = unitSizes.values.sum() / unitSizes.size - // From the last visible lazy index: item zero is the "below" slot, so lazy - // index equals units index plus one -- starting the walk at `last().index` - // begins one unit past the last visible one, and a visible spinner makes the - // range empty, which is room of zero. + // From the last visible lazy index: item zero is the "below" slot, so lazy index + // equals units index plus one -- and a visible spinner makes the range empty, which + // is room of zero. var room = 0L val cushion = viewport.toLong() * HISTORY_SCREENS for (index in visible.last().index until loaded.size) { @@ -1125,10 +972,8 @@ fun SessionScreen( } loadingHistory = true try { - // One page, and then this fires again if it was not enough -- the estimate - // is re-made from what the page actually added, so a page that folds into - // almost no new units is followed by another because the room genuinely - // did not grow. + // One page, and then this fires again if it was not enough -- the estimate is + // re-made from what the page actually added. loadOlderPage() } catch (_: ApiException) { // Leave `moreHistory` alone: the next scroll asks again. @@ -1150,8 +995,7 @@ fun SessionScreen( .orEmpty() } } catch (_: Exception) { - // Not worth reporting: the picker simply has nothing to - // offer, which is visible, and the session is unaffected. + // Not worth reporting: the picker simply has nothing to offer, which is visible. emptyList() } } @@ -1161,14 +1005,13 @@ fun SessionScreen( * * Nothing is removed here. The bubble goes on the `messageDropped` the server records, which is * what makes the cancellation the session's own fact rather than this screen's opinion of it -- - * a second device watching the same session has to lose the bubble too, and this one has to - * still lose it after a reconnect. + * a second device has to lose the bubble too, and this one has to still lose it after a + * reconnect. * * The refusal is kept on the message it was about rather than in [actionError]: the error row * lives under the header, and a bubble at the foot of the transcript is the thing that was - * pressed. It is the ordinary answer here rather than the exceptional one -- a Claude session - * writes a steer into the CLI the moment it arrives, so what is on screen as "waiting" is - * waiting to be *read*, not waiting to be sent. + * pressed. It is the ordinary answer here -- a Claude session writes a steer into the CLI the + * moment it arrives, so what is on screen as "waiting" is waiting to be *read*. */ fun takeBack(messageId: String) { scope.launch { @@ -1196,14 +1039,10 @@ fun SessionScreen( /** * Opens or closes one peer message, from whichever of its pieces was pressed. * - * By seq rather than by unit, because an open message is several units and all of them shut it - * -- it was one card before it was several items, and which piece the finger landed on is not - * something the reader chose. + * By seq rather than by unit, because an open message is several units and all of them shut it. * * No [toggleAnchored] here, and that is the difference between growing a row and adding items: - * the list is keyed, so it holds the item it is anchored on wherever the new ones land. What - * the reader tapped keeps its place because the list keeps it, not because a measurement - * corrected it afterwards. + * the list is keyed, so it holds the item it is anchored on wherever the new ones land. */ fun togglePeer(seq: Long) = expanding { expandedNotes = if (seq in expandedNotes) expandedNotes - seq else expandedNotes + seq @@ -1218,8 +1057,7 @@ fun SessionScreen( actionError = e.message } finally { // Whatever happened, including the failure above: a caller that re-enables a - // control here must get it back on the path where the request was refused too, - // or the refusal is what disables the control permanently. + // control here must get it back on the path where the request was refused too. onDone() } } @@ -1229,8 +1067,7 @@ fun SessionScreen( * Sends every answer a question card handed over, and says when the last of them has settled. * * All of them in one go because a card asks its questions together and the tool is waiting on - * all of them; the completion is what turns the card's spinner back into a button, whether the - * server took them or refused. + * all of them; the completion is what turns the card's spinner back into a button. */ fun answerAll(answers: List, onSettled: () -> Unit) { act(onDone = onSettled) { @@ -1244,14 +1081,14 @@ fun SessionScreen( if (text.isEmpty() && attachments.isEmpty()) return // A command is not a message: it is an instruction to the session about itself, and one // written into a running turn is read by the model instead. The server holds it until the - // turn ends and says so, which is where its waiting bubble comes from -- so nothing is - // held here, and there is no local guess to correct when the answer arrives. + // turn ends and says so, which is where its waiting bubble comes from -- so nothing is held + // here, and there is no local guess to correct. if (text.startsWith("/") && attachments.isEmpty()) { input = atEnd("") saveDraft(context, summary.id, "") - // The one command with a visible effect outside the transcript, applied when the - // server has accepted it rather than when it was typed: the name is this app's own - // datum and changes at once, and only telling the session waits for a boundary. + // The one command with a visible effect outside the transcript, applied when the server + // has accepted it rather than when it was typed: the name is this app's own datum and + // changes at once, and only telling the session waits for a boundary. val renamed = text.removePrefix("/rename ").trim().takeIf { text.startsWith("/rename ") && it.isNotEmpty() @@ -1265,16 +1102,14 @@ fun SessionScreen( input = atEnd("") saveDraft(context, summary.id, "") pendingAttachments = emptyList() - // Nothing is added here. The server says what is waiting -- it emits `messageQueued` - // when it takes a message it cannot deliver yet -- and this screen draws that. Holding a - // local copy as well was the bug: the two agreed only until the app was restarted or the - // session left, and then the screen showed nothing pending while the queue was full. + // Nothing is added here. The server says what is waiting -- it emits `messageQueued` when + // it takes a message it cannot deliver yet -- and this screen draws that. Holding a local + // copy as well was the bug: the two agreed only until the app was restarted. act { sendMessage(settings, summary.id, text, attachments) } } - // One path for everything attached, however it arrived: the photo picker, the file chooser - // or another app's share sheet. It uploads as soon as it is chosen, so Send only has ids to - // reference. + // One path for everything attached, however it arrived: the photo picker, the file chooser or + // another app's share sheet. It uploads as soon as it is chosen, so Send only has ids. fun attach(uri: Uri) { scope.launch { try { @@ -1282,7 +1117,7 @@ fun SessionScreen( withContext(Dispatchers.IO) { // An image is shrunk to what this session's provider takes before it is // uploaded, so a twelve-megapixel photo does not cross the tunnel to be - // rejected at the far end; a file goes whole -- see `uploadPicked`. + // rejected at the far end; a file goes whole. uploadPicked(context, settings, summary.id, uri, summary.maxImageEdge) } pendingAttachments = pendingAttachments + id @@ -1301,7 +1136,7 @@ fun SessionScreen( uri?.let(::attach) } // What another app shared in, attached the moment this screen has it. Taken off the request - // first, so a recomposition or a return to this screen cannot attach it a second time. + // first, so a recomposition cannot attach it a second time. LaunchedEffect(share) { val incoming = share ?: return@LaunchedEffect onShareTaken() @@ -1312,23 +1147,19 @@ fun SessionScreen( } } - // One poll for the machines' limits, read by everything on this screen that reports them: - // the bar under the header, the colour of the button that opens the dialog, and the dialog. + // One poll for the machines' limits, read by everything on this screen that reports them. val usageFeed = rememberUsageFeed(settings) val usage = usageFeed.forSetup(summary.setup) RecordFrames() var usageOpen by remember { mutableStateOf(false) } var settingsOpen by remember { mutableStateOf(false) } - // The composer floats over the bottom of the screen instead of sitting under the transcript - // in one column, and the keyboard moves it by a layer translation rather than by relayout. - // With everything in one column under a root imePadding, every frame of the keyboard - // animation re-measured, re-placed and re-recorded the entire screen -- measured on the - // emulator at ~7.6ms of main-thread work per frame across ~34 frames per open, and on the - // Pixel as 82% late frames while the transcript itself cost 0.25ms. Scoped this way, a - // keyboard frame costs one layer transform for the composer and one re-measure of the - // transcript box, whose children skip measurement (width unchanged) and whose rows are - // already layers. + // The composer floats over the bottom of the screen instead of sitting under the transcript in + // one column, and the keyboard moves it by a layer translation rather than by relayout. With + // everything in one column under a root imePadding, every frame of the keyboard animation re- + // measured, re-placed and re-recorded the entire screen -- ~7.6ms of main-thread work per frame + // across ~34 frames per open on the emulator, and 82% late frames on the Pixel while the + // transcript itself cost 0.25ms. var composerHeight by remember { mutableIntStateOf(0) } val imeInsets = WindowInsets.ime val navInsets = WindowInsets.navigationBars @@ -1336,35 +1167,30 @@ fun SessionScreen( // rescues this from a real fault rather than merely reading the same thing twice. `imeInsets` // is driven by the animation as it interpolates and is dispatched every frame; `isImeVisible` // is dispatched once, from the platform's own start/end of the transition, over a different - // path (`onApplyWindowInsets` rather than the animation callback). + // path. // - // Reported from a phone: closing the keyboard on purpose, while a reply was streaming, left - // the composer floating above the bottom of the screen for the rest of the session, with a - // bar of background colour showing under it and nothing that closed it. The likely cause is - // the animation callback that carries `imeInsets` back to zero being interrupted mid-flight -- - // a streaming reply invalidates the view every frame, which is exactly the condition known to - // starve a running `WindowInsetsAnimationCallback` of its `onEnd` -- and once that happens the - // stale, partway value it leaves behind has nothing left to correct it: the keyboard is not - // going to move again on its own. `isImeVisible` does not share that failure mode (it is not - // interpolated, so there is nothing for a dropped frame to interrupt), so it is what both - // places below fall back to. + // Reported from a phone: closing the keyboard on purpose, while a reply was streaming, left the + // composer floating above the bottom of the screen for the rest of the session. The likely + // cause is the animation callback that carries `imeInsets` back to zero being interrupted mid- + // flight -- a streaming reply invalidates the view every frame, which is exactly the condition + // known to starve a running `WindowInsetsAnimationCallback` of its `onEnd` -- and the stale + // partway value it leaves behind has nothing left to correct it. `isImeVisible` is not + // interpolated, so there is nothing for a dropped frame to interrupt. val imeVisible = WindowInsets.isImeVisible // What this session is costing to draw, copied out to somewhere it can be read. // - // Written here rather than beside the control that runs it, because everything it measures -- - // the events, the rows, the units, what the list has on screen, which cards are open -- is this - // composable's own state, and a control in a dialog cannot reach it. The control is a row in - // [SessionSettingsDialog]: that is where the session's other about-the-session controls are, - // and the header is for what a reader presses while reading. It copies rather than opens, - // because what it produces is for somewhere else -- a message to whoever is looking at the - // code -- and a screenful of timings read on the phone is a screenful nobody can act on. + // Written here rather than beside the control that runs it, because everything it measures is + // this composable's own state and a control in a dialog cannot reach it. The control is a row + // in [SessionSettingsDialog], where the session's other about-the-session controls are. It + // copies rather than opens, because what it produces is a message to whoever is looking at the + // code. // // Whatever presses this, it is found by its **name**: `ui-trace`'s tap-by-label action resolves // "Session settings" and then "Copy render timings" from what is on screen at that moment, so - // `transcript-bench.sh` and `stream-bench.sh` keep working when this moves again. They pressed - // it at a coordinate measured once by hand until 2026-09-03, and anything that moved the header - // made that tap land on whatever now sat there -- reporting a number that was never measured. + // the bench scripts keep working when this moves again. They pressed it at a hand-measured + // coordinate until 2026-09-03, and anything that moved the header made that tap land on + // whatever now sat there -- reporting a number that was never measured. val copyRenderReport = { val report = debugReport( @@ -1390,15 +1216,12 @@ fun SessionScreen( ) context.copyToClipboard("ai-app render report", report) // Also to the log, so a session driving the app over adb can read the same report the - // button copies. The clipboard is not reachable from a shell, and a counter nobody can - // check from here is a counter that only gets checked by asking Iris to press a button - // and paste. + // button copies. The clipboard is not reachable from a shell. Log.i("ai-app", report) // Only once it is somewhere it can be read from, so a copy that never happened does not // throw the stack away with it. clearCrash(context) - // Emptied by the copy, so pressing it twice measures two separate stretches of scrolling - // rather than one and then the same one again. + // Emptied by the copy, so pressing it twice measures two separate stretches of scrolling. FrameStats.reset() DebugStats.reset() Toast.makeText(context, "Copied render report", Toast.LENGTH_SHORT).show() @@ -1410,43 +1233,37 @@ fun SessionScreen( modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), ) { GlyphButton(BACK_GLYPH, "Back", onBack) - // A ring's worth, which is what the arrow already keeps on its other three sides -- - // the pair of glyph buttons at the far end of this row get theirs from each other. + // A ring's worth, which is what the arrow already keeps on its other three sides. Spacer(Modifier.width(GLYPH_BUTTON_MARGIN)) Column(Modifier.weight(1f)) { Text(title, style = MaterialTheme.typography.titleMedium) // Machine first, then what runs on it -- the same order and the same wording - // everywhere this pair appears, so it reads as one fact rather than as two - // sentences with different grammar. The "on" that used to sit in the middle - // made it a phrase, which only works in one order and stops working the moment - // the pair is shown anywhere else. + // everywhere this pair appears, so it reads as one fact rather than two + // sentences with different grammar. // // No model. The picker in the footer already shows what this session is set to, // and showing it twice means two things to keep in step -- they disagreed for a - // moment on every model change, since one follows the request and the other the - // session's own answer. + // moment on every model change. Text( "${summary.setupName} · ${summary.provider}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } - // Beside the provider it reports on, which is the line directly to its left. + // Beside the provider it reports on, which is the line directly to its left. Its + // real home is this provider's settings, which do not exist yet. A session on a + // provider with no such service gets an honest "unavailable" rather than a hidden + // button -- a control that comes and goes makes its absence the signal, and absence + // cannot say why. // - // Its real home is this provider's settings, which do not exist yet; until they do, - // the session is the only place the provider is already named, so it is the only - // place the button can sit without inventing a scope for itself. What it shows is - // the paid service's own numbers, so a session on a provider with no such service - // gets an honest "unavailable" rather than a hidden button -- a control that comes - // and goes makes its absence the signal, and absence cannot say why. // Coloured by the worst window behind it, so the row says whether the limits are - // worth opening before anybody opens them. Blue at every ordinary level and only - // yellow or red near a limit -- and the theme's plain control colour whenever there - // is no measurement, since blue is the low end of the scale here and would read as - // "checked, and fine" about a machine nobody could reach. - // Usage, files, settings -- widest scope first, narrowing to the right, so the - // cog stays at the end where every other screen keeps it. Asked for in this order - // by Iris on 2026-09-03. + // worth opening before anybody opens them. The theme's plain control colour + // whenever there is no measurement, since blue is the low end of the scale here and + // would read as "checked, and fine" about a machine nobody could reach. + // + // Usage, files, settings -- widest scope first, narrowing to the right, so the cog + // stays at the end where every other screen keeps it. Asked for in this order by + // Iris on 2026-09-03. Row { GlyphButton( USAGE_GLYPH, @@ -1455,8 +1272,7 @@ fun SessionScreen( colour = usageGlyphColour(usage), ) // The machine's files, which is where the answer to "what did it actually - // change" is. It opens *over* this screen rather than replacing it -- see - // [Screen.Session]. + // change" is. It opens *over* this screen rather than replacing it. GlyphButton( FOLDER_GLYPH, "Files", @@ -1467,24 +1283,22 @@ fun SessionScreen( setupName = summary.setupName, // Where this session works, and the machine's own home when it // was never given a directory -- resolved there rather than - // guessed at here, since this app does not know that machine's - // home and must not invent one. + // guessed at here, since this app does not know that home. start = summary.cwd?.takeIf { it.isNotBlank() } ?: "~", ) ) }, ) // What it opens is about this session, so it sits at the end of the session's - // own row. The name is the whole of what it holds today, which is why it is a - // cog - // and not a word: there will be more, and a bar of words has nowhere to put it. + // own row. A cog and not a word because there will be more, and a bar of words + // has nowhere to put it. GlyphButton(SETTINGS_GLYPH, "Session settings", { settingsOpen = true }) } } // Under the header, above everything the session itself says: it is a fact about the // machine rather than a turn in the conversation, and it is the number that decides - // whether to keep going -- which was a screen away from where that gets decided. + // whether to keep going. SessionUsageBar(usage) (streamError ?: actionError)?.let { message -> @@ -1497,35 +1311,29 @@ fun SessionScreen( } // The transcript, reversed: item zero is the newest message and sits at the bottom, so - // the first frame of a session is already the right one, and following new content is - // where the list is rather than a correction it makes; see [TranscriptList]. + // the first frame of a session is already the right one; see [TranscriptList]. // // Drawn only once there is nothing left to put back. Held out of the drawing rather // than out of the composition, so the restore's scroll is applied against a list that - // is fully built, and there is no frame in which the transcript is somewhere other - // than where it was left. + // is fully built. val settled = !restoring Box( Modifier.weight(1f) .fillMaxWidth() // The room the floating composer needs, measured off it below -- reserving it // here is what lets the composer be an overlay without covering the newest - // message -- and then the keyboard's, per frame of its animation. This modifier - // is the whole of what the keyboard re-measures: the box's own size never - // changes, so nothing above it is touched. + // message. This modifier is the whole of what the keyboard re-measures: the + // box's own size never changes, so nothing above it is touched. .padding(bottom = with(LocalDensity.current) { composerHeight.toDp() }) // The keyboard's room, and only while the platform says there is a keyboard -- - // dropping the modifier is what coerces the stuck-open animated value to zero, - // the same guard the composer's translation applies below. It has to stay a - // *modifier* rather than a padding computed here: `imePadding` reads the inset - // in the layout phase, so a keyboard frame re-measures this box and nothing - // else, while reading `imeInsets` in this composable body subscribes the whole - // of `SessionScreen` to a value that changes every frame of the animation. - // That cost 16 full recompositions of this screen per keyboard open, against - // one, and it made the transcript's position depend on a recomposition landing - // inside the frame that the inset changed -- which the composer's does not, - // since its translation is re-read in that frame's draw phase. When the - // recomposition misses, the transcript trails the composer up the screen. + // dropping the modifier is what coerces the stuck-open animated value to zero. + // It has to stay a *modifier* rather than a padding computed here: `imePadding` + // reads the inset in the layout phase, so a keyboard frame re-measures this box + // and nothing else, while reading `imeInsets` in this composable body + // subscribes the whole of `SessionScreen` to a value that changes every frame + // -- 16 full recompositions per keyboard open against one, and the transcript's + // position behind a recomposition while the composer's stayed a draw-phase + // read. .then(if (imeVisible) Modifier.imePadding() else Modifier) ) { Box(Modifier.fillMaxSize()) { @@ -1538,11 +1346,9 @@ fun SessionScreen( Modifier.fillMaxSize().drawWithContent { if (settled) drawContent() }, below = { // The last thing in the transcript, because that is where they are in - // the - // session's reading of events: after everything it has taken in, and - // not - // yet taken in themselves. What the session is *doing* about them is a - // line below, in [SessionStatusRow]. + // the session's reading of events: after everything it has taken in, + // and not yet taken in themselves. What the session is *doing* about + // them is a line below, in [SessionStatusRow]. if (queued.isNotEmpty() || waitingCommands.isNotEmpty()) { // The gap the arrangement no longer provides: this item sits flush // against the newest message otherwise. @@ -1563,10 +1369,9 @@ fun SessionScreen( pending = true, refusal = waiting.refusal, // The bubble goes away on the `messageDropped` this - // produces, not here: the server is what knows whether - // the message was still its to take back, and the - // other devices watching this session have to be told - // by the same event. + // produces, not here: the server knows whether the + // message was still its to take back, and the other + // devices have to be told by the same event. onTakeBack = { takeBack(waiting.id) }, ) } @@ -1600,16 +1405,13 @@ fun SessionScreen( Modifier.holdTopEdge(row.key, topEdgeHeld) { grew -> // A *request*, not a raw scroll delta: this runs inside // the measure pass that discovered the new height, and - // a - // raw delta forces a synchronous remeasure from within - // measure, which is fatal - // ("performMeasureAndLayout called during measure"). - // The request is applied by the same frame's next - // remeasure, so the correction still lands before - // anything is drawn. Reads unobserved, or this row's - // measure would inherit the scroll position as a - // dependency and remeasure on every frame of every - // fling. + // a raw delta forces a synchronous remeasure from + // within measure, which is fatal. The request is + // applied by the same frame's next remeasure, so the + // correction still lands before anything is drawn. + // Reads unobserved, or this row's measure would inherit + // the scroll position as a dependency and remeasure on + // every frame. Snapshot.withoutReadObservation { listState.requestScrollToItem( listState.firstVisibleItemIndex, @@ -1619,15 +1421,10 @@ fun SessionScreen( } } // Which half of this row the touch landed in, for - // [toggleAnchored]. - // On the initial pass and consuming nothing, so every - // control - // inside - // still gets the gesture exactly as it would have; only - // visible - // rows - // have one, which is what makes a detector per row - // affordable. + // [toggleAnchored]. On the initial pass and consuming + // nothing, so every control inside still gets the gesture; + // only visible rows have one, which is what makes a + // detector per row affordable. .pointerInput(row.key) { awaitEachGesture { val down = @@ -1655,12 +1452,8 @@ fun SessionScreen( }, isToolExpanded = { it in expandedTools }, // Anchored on the group, not the call: opening one - // call - // makes - // the whole group taller, and the heading the - // reader is - // under - // is the group's. + // call makes the whole group taller, and the + // heading the reader is under is the group's. onToolToggle = { id -> toggleAnchored(row) { expandedTools = @@ -1691,15 +1484,10 @@ fun SessionScreen( ) is TranscriptItem.AssistantMsg -> // A whole assistant row is only ever the reply - // still - // arriving -- every settled reply is flattened - // into - // block units instead; see [transcriptUnits]. - // Live - // is + // still arriving -- every settled reply is + // flattened into block units instead. Live is // what earns its blocks a layer each while - // deltas - // land. + // deltas land. AssistantMessage( item.text, replies, @@ -1757,11 +1545,9 @@ fun SessionScreen( is TranscriptItem.ClearedNote -> ClearedRow() is TranscriptItem.CompactedNote -> CompactedRow(item) - // Never reached: a peer message is flattened - // into its own units, so it is not a whole row. - // Here because a `when` over the item kinds has to - // stay exhaustive, and drawing nothing is how a - // row that stopped being handled would look. + // Never reached: a peer message is flattened into + // its own units. Here because a `when` over the + // item kinds has to stay exhaustive. is TranscriptItem.PeerNote -> PeerHeadRow(item, open = false, onToggle = {}) } @@ -1775,46 +1561,34 @@ fun SessionScreen( // Still finding out what this conversation is: the newest page has not arrived, or // it has and the list is being put back where reading stopped. Both draw no rows at // all, and a blank page is what this screen otherwise means by "there is nothing - // here" -- so the state that does not know needs its own appearance rather than - // sharing one with the empty answer. + // here" -- so the state that does not know needs its own appearance. // - // In the middle of the transcript rather than at either end, because it is not - // reporting on the newest message or the oldest; it is standing in for all of them. - // `settled` and not `restoring` alone, so the spinner covers the whole wait: - // fetching - // the history a saved position needs, and then the frames between those rows - // arriving - // and the layout that measures them putting the position back. They are the two - // halves - // of the same wait and the transcript is not drawn for either. + // In the middle of the transcript rather than at either end, because it is standing + // in for all of the rows. `settled` and not `restoring` alone, so the spinner + // covers the whole wait: fetching the history a saved position needs, and then the + // frames between those rows arriving and the layout that puts the position back. if (!ready || !settled) { CircularProgressIndicator( Modifier.align(Alignment.Center).size(LOADING_SPINNER) ) } - // Only while the newest message is off-screen. Reading back - // through a conversation is a place to be, not a state to be - // rescued from, so this waits to be wanted. + // Only while the newest message is off-screen. Reading back through a conversation + // is a place to be, not a state to be rescued from. // - // Down, and the same chevron a tool group collapses with: the - // list is built upside down internally, but nobody reading it - // knows that -- on screen the newest message is at the bottom, - // which is where this goes. The name is carried in the - // description, since an arrow alone says nothing to a screen - // reader and nothing to whoever finds this in six months. + // Down, and the same chevron a tool group collapses with: the list is built upside + // down internally, but nobody reading it knows that. The name is carried in the + // description, since an arrow alone says nothing to a screen reader. if (!atNewest) { Surface( // Instantly. An animated scroll travels the whole transcript, so the // further back somebody has read the longer this takes -- the one press // whose cost grows with how much there is to skip, which is backwards. // - // Arriving there is all this has to do now. The newest end is where the - // content hangs from, so being at it is the whole of following it, and - // there - // is no separate flag to set -- which is what this press used to forget, - // landing the reader at the bottom with new messages not bringing the view - // with them. + // Arriving there is all this has to do: the newest end is where the content + // hangs from, so being at it is the whole of following it. That is what + // this press used to forget, landing the reader at the bottom with new + // messages not bringing the view with them. onClick = { scope.launch { listState.scrollToItem(0) } }, shape = CircleShape, color = MaterialTheme.colorScheme.surfaceContainerHigh, @@ -1833,13 +1607,11 @@ fun SessionScreen( } } - // Everything from here down floats: bottom-aligned over the transcript, moved up with - // the keyboard by a translation on its own layer. The translation is read inside the - // graphicsLayer block, so a keyboard frame invalidates layer properties only -- no - // measure, no recomposition, no re-recording of anything. Its height is reported to the - // transcript box above, which reserves that much room; the opaque background covers the - // one frame between this growing (a suggestion row, a second draft line) and that - // reservation catching up. + // Everything from here down floats: bottom-aligned over the transcript, moved up with the + // keyboard by a translation on its own layer. The translation is read inside the + // graphicsLayer block, so a keyboard frame invalidates layer properties only. Its height is + // reported to the transcript box above, which reserves that much room; the opaque + // background covers the one frame between this growing and that reservation catching up. Column( Modifier.align(Alignment.BottomCenter) .fillMaxWidth() @@ -1880,30 +1652,25 @@ fun SessionScreen( // Between the transcript and the box: above what is being typed, so the list does not // cover the thing the command is about, and below everything that explains it. CommandSuggestions( - // Nothing to suggest about a suggestion that was just taken. `/compact` is a - // whole command *and* a prefix of itself, so picking it left the list standing - // there with the one row already chosen -- the reader has to dismiss a list that - // has nothing left to offer, in front of the box they are about to send from. - // Held by what was picked rather than by a flag, so typing anything else brings - // the list back without needing a second thing to reset. + // Nothing to suggest about a suggestion that was just taken. `/compact` is a whole + // command *and* a prefix of itself, so picking it left the list standing there with + // the one row already chosen. Held by what was picked rather than by a flag, so + // typing anything else brings the list back without a second thing to reset. commands = if (input.text == picked) emptyList() else suggestedCommands(input.text), onPick = { command -> - // At the end of what was inserted, which is where the reader carries on - // typing: a command with an argument is put in the box half-written, and a - // cursor left at the front makes the next keystroke the first character of - // "/rename" rather than of the name. + // At the end of what was inserted, which is where the reader carries on typing: + // a command with an argument is put in the box half-written, and a cursor left + // at the front makes the next keystroke the first character of "/rename". input = atEnd(command.typed()) picked = command.typed() }, ) - // Always enabled -- a send while the session is running becomes a - // steering message injected at the next tool boundary, which is - // the point of the whole app. + // Always enabled -- a send while the session is running becomes a steering message + // injected at the next tool boundary, which is the point of the whole app. // - // The field gets a row of its own, above the buttons: sharing one - // put the full width behind three controls, so the thing being - // typed into was the narrowest thing on the row. + // The field gets a row of its own, above the buttons: sharing one put the full width + // behind three controls, so the thing being typed into was the narrowest on the row. Column(Modifier.fillMaxWidth().padding(8.dp)) { // Directly above the box they will be sent from, so what is attached is visible // rather than counted: the "+2" on the button below said how many and never which. @@ -1929,8 +1696,8 @@ fun SessionScreen( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth(), ) { - // Photo or file, asked here rather than by two buttons: the row is full, - // and attaching is one action whichever picker answers it. + // Photo or file, asked here rather than by two buttons: the row is full, and + // attaching is one action whichever picker answers it. var attaching by remember { mutableStateOf(false) } Box { // Just "+". The count it used to carry was standing in for showing them. @@ -1938,8 +1705,8 @@ fun SessionScreen( DropdownMenu( expanded = attaching, onDismissRequest = { attaching = false }, - // See PickerButton: without this the menu opens a status bar's - // height away from the button in an edge-to-edge activity. + // See PickerButton: without this the menu opens a status bar's height + // away from the button in an edge-to-edge activity. properties = PopupProperties(clippingEnabled = false), shape = BubbleMenuShape, ) { @@ -1963,12 +1730,11 @@ fun SessionScreen( ) } } - // The settings share what is left after the actions have - // taken what they need. A Row hands out intrinsic widths in - // order and clips whatever runs past the edge, so with - // these laid out first the arrival of Stop pushed Send off - // the screen entirely -- the app's central control, gone at - // exactly the moment the app is most in use. + // The settings share what is left after the actions have taken what they need. + // A Row hands out intrinsic widths in order and clips whatever runs past the + // edge, so with these laid out first the arrival of Stop pushed Send off the + // screen entirely -- the app's central control, gone at the moment it is most + // in use. Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f), @@ -1981,12 +1747,11 @@ fun SessionScreen( // "default"; until this the list could not, so leaving it was a // one-way trip. options = listOf(DEFAULT_MODEL) + offeredModels, - // Not set here. The button follows what the session reports it - // is set to, which arrives a moment later and is sometimes a - // different answer -- a name the CLI resolved, or no change at all - // on a provider whose model is fixed when it starts. - // Asked about first, unless there is nothing to lose by it -- - // see [ModelSwitchWarning]. + // Not set here. The button follows what the session reports it is + // set to, which arrives a moment later and is sometimes a different + // answer -- a name the CLI resolved, or no change at all on a + // provider whose model is fixed. Asked about first, unless there is + // nothing to lose by it -- see [ModelSwitchWarning]. onPick = { chosen -> if ( modelLabel(chosen) == modelLabel(model) || @@ -2008,17 +1773,13 @@ fun SessionScreen( ) } // The same filled shape as the button beside it, not an outlined one: these are - // two things you can do about the session, and weighting one of them as - // secondary - // said they were a primary action and its qualifier. What separates them is the + // two things you can do about the session, and weighting one as secondary said + // they were a primary action and its qualifier. What separates them is the // colour and the mark, which is what they mean. // // Always here, rather than arriving with the turn as it used to. A control that - // comes and goes makes its own presence the signal, and its absence could not - // say - // whether there was nothing to do; a button that is always in the same place - // also - // cannot push Send off the end of the row by turning up. + // comes and goes makes its own presence the signal, and a button always in the + // same place also cannot push Send off the end of the row by turning up. val process = when { running -> ProcessAction.Pause @@ -2045,13 +1806,11 @@ fun SessionScreen( // The paper plane, with a clock on it while a turn is in flight: sending then // queues the message for the next tool boundary rather than starting a turn of // its own, and the two have to be told apart at a glance. The label says the - // same - // thing to a screen reader, which has nothing else to read. + // same thing to a screen reader. // // Disabled while there is nothing to send, rather than pressable and silent: // `send` has always returned early on an empty composer, so the button promised - // something it would not do, and the only feedback was the ripple. Disabled and - // not hidden, for the reason the button beside it is always here. + // something it would not do. Disabled and not hidden, for the reason above. Button( onClick = { send() }, enabled = input.text.isNotBlank() || pendingAttachments.isNotEmpty(), @@ -2069,16 +1828,16 @@ fun SessionScreen( } } - // Beside the other two dialogs, and outside the list for the same reason as them: what is - // open is the screen's business rather than any row's. See [SessionImageViewer]. + // Beside the other two dialogs, and outside the list for the same reason as them: what is open + // is the screen's business rather than any row's. See [SessionImageViewer]. fullImage?.let { ref -> SessionImageViewer(settings, summary.id, ref) { fullImage = null } } if (usageOpen) { UsageDialog(feed = usageFeed, onDismiss = { usageOpen = false }) } if (settingsOpen) { - // Measured when the dialog opens rather than kept up to date: what the reader is being - // told is what pressing the button now would discard, and null until the walk of the - // directory returns is what not knowing looks like. + // Measured when the dialog opens rather than kept up to date: what the reader is being told + // is what pressing the button now would discard, and null until the walk of the directory + // returns is what not knowing looks like. var cachedBytes by remember(summary.id, epoch) { mutableStateOf(null) } LaunchedEffect(summary.id, epoch) { cachedBytes = withContext(Dispatchers.IO) { source.cache.bytes() } @@ -2089,9 +1848,8 @@ fun SessionScreen( title = title, cachedBytes = cachedBytes, // The purge finishes before the epoch moves, because the relaunched opening effect - // reads the same directory and would otherwise draw what is about to be deleted. - // Everything else here is the clearing a cold open needs; the epoch is what makes it - // one, by rebuilding the opening effect, the stream, and the anchor being put back. + // reads the same directory and would otherwise draw what is about to be deleted. The + // epoch is what makes the rest a cold open. onReload = { settingsOpen = false scope.launch { @@ -2102,9 +1860,9 @@ fun SessionScreen( epoch++ } }, - // The header takes the new name at once and the dialog closes on it, because the - // rename has already been accepted by the server -- see [title], which is this app's - // own datum. The list behind this refetches on the way out of the session anyway. + // The header takes the new name at once and the dialog closes on it, because the rename + // has already been accepted by the server -- see [title], which is this app's own + // datum. onRenamed = { title = it settingsOpen = false @@ -2124,8 +1882,7 @@ private fun sendLabel(running: Boolean) = if (running) "Queue" else "Send" * One value rather than four parallel conditions over the status, because the mark, the colour, the * name a screen reader is given and the request that goes out are four halves of one decision. A * button drawn as a pause that terminates the CLI is the worst bug available here, and separate - * branches over the same condition are how that happens -- these three each have to cover every - * case, and the compiler says so. + * branches over the same condition are how that happens. */ private enum class ProcessAction(val glyph: String, val label: String) { /** A turn is running: take it back, and leave the process holding the conversation. */ @@ -2155,8 +1912,7 @@ private fun ProcessAction.perform(settings: ServerSettings, sessionId: String) = * One slice of a long user message, on the same bubble the first slice starts. * * Full width, unlike the wrapping bubble: slices have to share a width to read as one card, and a - * message long enough to be sliced has lines that wrap, so its bubble was at full width anyway -- - * see [USER_SPLIT_CHARS], which is what guarantees that. + * message long enough to be sliced has lines that wrap anyway -- see [USER_SPLIT_CHARS]. */ @Composable private fun UserChunkRow( @@ -2174,8 +1930,8 @@ private fun UserChunkRow( ) ) { Text(unit.text, color = MaterialTheme.colorScheme.onPrimaryContainer) - // The same arrangement [UserBubble] gives them: under the words, on the last slice - // because that is the bubble's bottom. + // The same arrangement [UserBubble] gives them: under the words, on the last slice because + // that is the bubble's bottom. unit.attachments.forEachIndexed { index, ref -> if (index > 0 || unit.text.isNotEmpty()) Spacer(Modifier.height(4.dp)) Attachment(settings, sessionId, ref, onOpenImage) @@ -2191,13 +1947,10 @@ private fun UserChunkRow( * * A pending bubble is tappable: [onTakeBack] asks the server to drop the message before the session * reads it, and [refusal] is what came back when it would not. The refusal is drawn here rather - * than with the screen's other errors because this is where the reader pressed -- the error row is - * under the header, a screen away from the bubble they were looking at. + * than with the screen's other errors because this is where the reader pressed. * * A settled message longer than [USER_SPLIT_CHARS] is drawn as [UserChunkRow] slices instead -- one * `Text` holding a pasted log is a hundred-thousand-pixel layout in the frame the row scrolls into. - * Everything else keeps this bubble: short messages wrap their content, and the pending one keeps - * its take-back control. */ @Composable private fun UserBubble( @@ -2212,10 +1965,9 @@ private fun UserBubble( ) { Box(Modifier.fillMaxWidth()) { Card( - // A message the session has not read yet is drawn quieter than - // one it has. The difference is in degree -- said, not yet - // heard -- which is what colour alone can carry; where it sits - // is what says the rest. + // A message the session has not read yet is drawn quieter than one it has. The + // difference is in degree -- said, not yet heard -- which is what colour alone can + // carry. colors = CardDefaults.cardColors( containerColor = @@ -2229,16 +1981,16 @@ private fun UserBubble( if (onTakeBack == null) Modifier else Modifier.clickable(onClick = onTakeBack).semantics { - // The bubble is its own control and its own label; without this - // the only thing to read is the message, which does not say what + // The bubble is its own control and its own label; without this the + // only thing to read is the message, which does not say what // pressing it does. contentDescription = "Waiting to be read; tap to take it back" } ), ) { Column(Modifier.padding(12.dp)) { - // A message can be nothing but an attachment, and an empty line above a picture - // is a bubble with a gap in it for a sentence nobody wrote. + // A message can be nothing but an attachment, and an empty line above a picture is + // a bubble with a gap in it for a sentence nobody wrote. if (text.isNotEmpty()) { Text( text, @@ -2249,7 +2001,7 @@ private fun UserBubble( } // Under the words: what somebody wrote is what the bubble is, and the picture is // what they attached to it. It also keeps the first line of every bubble at the - // same place down the transcript, whether or not there is an image in it. + // same place down the transcript. attachments.forEachIndexed { index, ref -> if (index > 0 || text.isNotEmpty()) Spacer(Modifier.height(4.dp)) Attachment(settings, sessionId, ref, onOpenImage) @@ -2271,8 +2023,7 @@ private fun UserBubble( * A message the server has accepted and the session has not read yet. * * [refusal] is why taking it back did not work, kept per message rather than on the screen: two - * bubbles can be waiting at once, and an error above them both would not say which one it was - * about. + * bubbles can be waiting at once, and an error above them both would not say which. */ private data class QueuedMessage( val id: String, @@ -2285,24 +2036,20 @@ private data class QueuedMessage( * Whether a model switch has anything to warn about -- see [ModelSwitchWarning]. * * What the warning is about is a *cache* being dropped, so the question is whether there is one. - * Two answers say there is not, and both used to produce the dialog anyway: - * - * A session whose process has exited has nothing running to hold a cache, so the next turn was - * always going to re-read the conversation -- the switch adds nothing to that bill. And a session - * reporting zero context is holding nothing, which is what `/clear` leaves behind. + * Two answers say there is not, and both used to produce the dialog anyway: a session whose process + * has exited has nothing running to hold a cache, and a session reporting zero context is holding + * nothing. * * Where the figure is *unknown* rather than zero the fallback is whether anything has been said * **since the last clear**. Unknown is not nothing, and treating it as nothing would drop the - * warning on exactly the sessions -- an import, a fresh reattach -- where nobody has measured yet - * and the conversation may be enormous. But a clear is the one case that makes the whole loaded - * transcript stop counting: it leaves the conversation on screen and takes it out of the session's - * context, and the server reports the context as unmeasured afterwards rather than as zero, since - * nobody has counted what is left. So the reading that used the whole list warned about dropping a - * cache that the clear had already dropped -- on the screen where a reader has just deliberately - * emptied the thing being warned about. + * warning on exactly the sessions -- an import, a fresh reattach -- where nobody has measured yet. + * But a clear is the one case that makes the whole loaded transcript stop counting: it leaves the + * conversation on screen and takes it out of the session's context, and the server reports the + * context as unmeasured afterwards rather than as zero. So the reading that used the whole list + * warned about dropping a cache the clear had already dropped. * * With no clear anywhere in what is loaded this is the old reading exactly, which is the - * conservative answer for a clear that happened further back than the loaded window. + * conservative answer for a clear further back than the loaded window. */ private fun worthWarningAbout( status: String, @@ -2318,20 +2065,18 @@ private fun worthWarningAbout( /** * Asked before switching model, because switching is not free and the cost is invisible. * - * A model change drops the cached context: the next turn re-reads the entire conversation from the - * beginning and is charged for it. Measured on 2026-08-29 against a small session -- the turn - * before the switch read 30,771 tokens from cache and created 87; the turn after read **nothing** - * from cache and created 41,509. On a long conversation that is the whole of it, again. + * A model change drops the cached context: the next turn re-reads the entire conversation and is + * charged for it. Measured on 2026-08-29 against a small session -- the turn before the switch read + * 30,771 tokens from cache and created 87; the turn after read **nothing** from cache and created + * 41,509. * * No number is offered here, deliberately. What it will cost depends on how long *this* - * conversation is, and this screen does not know that -- the running total beside it counts what - * has been spent, which is a different quantity. A figure worked out from it would be a guess in a - * measurement's clothes, and the reader could not tell which times it was right. + * conversation is, and this screen does not know that -- a figure worked out from what has been + * spent would be a guess in a measurement's clothes. * * The permission-mode picker beside it deliberately has no equivalent, which the same measurement * decided: changing mode kept the cache (30,858 read, 75 created). Warning on both would teach the - * reader that these dialogs can be clicked through, which is what makes the one that matters stop - * working. + * reader that these dialogs can be clicked through. */ @Composable private fun ModelSwitchWarning( @@ -2358,19 +2103,15 @@ private fun ModelSwitchWarning( /** * What the session is doing, and what the conversation has cost, on one line above the box. * - * A row of its own because both of these are facts about the session rather than turns in it, and - * both were previously drawn over the transcript: the token total floated in its bottom corner, - * where a long message ran underneath it, and the working indicator was an item inside the list, so - * it scrolled away exactly when somebody reading back wanted to know whether anything was still - * happening. Here they are always in the same place, and the thing they report on -- the session - * you are about to type at -- is directly below. + * A row of its own because both are facts about the session rather than turns in it, and both were + * previously drawn over the transcript: the token total floated in its bottom corner, where a long + * message ran underneath it, and the working indicator was an item inside the list, so it scrolled + * away exactly when somebody reading back wanted to know whether anything was still happening. * * The row is drawn whether or not it has anything to say. An empty one costs a line; a row that - * came and went would move the text box under the reader's thumb every time a turn started, and - * would make its own presence the signal for a state it never names. + * came and went would move the text box under the reader's thumb every time a turn started. * - * The states are the session's own status words plus the total, and each looks different from the - * others: `exited` is here because a session whose process is gone cannot be typed at, and with the + * `exited` is here because a session whose process is gone cannot be typed at, and with the * indicator gone from the list nothing else on this screen would say so. */ @Composable @@ -2390,16 +2131,14 @@ private fun SessionStatusRow( when (status) { // A bar rather than the spinner an ordinary turn gets, and it takes the row's whole // free width: nothing arrives in the transcript during a compaction, so this is the - // only thing on screen that is moving, and at a spinner's width that reads as a - // session that has hung. + // only thing on screen that is moving, and at a spinner's width that reads as a session + // that has hung. // // Indeterminate, which is a statement rather than an omission. The CLI says a - // compaction has begun and then says nothing at all until it has finished -- measured - // against 2.1.237 again on 2026-08-29, on a real 80,346-to-2,088-token compaction that - // took 23 seconds and produced not one line in between. So there is no fraction to - // fill, and a bar creeping along at the pace of the last one would be this screen - // inventing the part nobody sent it. Elapsed time is the only honest number here, and - // [compactingLabel] is where it is worded. + // compaction has begun and then nothing at all until it has finished -- measured on a + // real 80,346-to-2,088-token compaction that took 23 seconds and produced not one line + // in between. So there is no fraction to fill, and a bar creeping along at the pace of + // the last one would be this screen inventing the part nobody sent it. "compacting" -> { Text( compactingLabel(compactingFor), @@ -2416,9 +2155,9 @@ private fun SessionStatusRow( } "running" -> { CircularProgressIndicator( - // Smaller than the line beside it, so the row keeps the text's own height: - // a control taller than a line re-centres it and knocks it out of line with - // the total on the other end. + // Smaller than the line beside it, so the row keeps the text's own height: a + // control taller than a line re-centres it and knocks it out of line with the + // total on the other end. modifier = Modifier.width(12.dp).height(12.dp), strokeWidth = 2.dp, ) @@ -2430,12 +2169,11 @@ private fun SessionStatusRow( ) Spacer(Modifier.weight(1f)) } - // Every remaining state says which one it is, including the quiet one. The row used - // to name only `exited` and leave the rest blank, so a session sitting idle and one - // whose status nobody could read looked identical -- and a turn that had just been - // stopped showed nothing at all, which reads as the app having lost the session - // rather than as the stop having worked. The words are the session list's own, so - // one state is not called two things depending which screen you are on. + // Every remaining state says which one it is, including the quiet one. The row used to + // name only `exited` and leave the rest blank, so a session sitting idle and one whose + // status nobody could read looked identical -- and a turn that had just been stopped + // showed nothing at all. The words are the session list's own, so one state is not + // called two things depending which screen you are on. else -> Text( when (status) { @@ -2450,14 +2188,13 @@ private fun SessionStatusRow( modifier = Modifier.weight(1f), ) } - // How full the session is, which is the number a reader is asking about -- how much room - // is left before the next compaction -- rather than what has been spent getting here. + // How full the session is, which is the number a reader is asking about -- how much room is + // left before the next compaction -- rather than what has been spent getting here. // - // "unknown" in words, and always drawn. A context nobody has measured is not an empty - // one, and the two used to share an appearance: a session that had just been cleared, one - // whose provider never reports usage, and one that has not run a turn all showed nothing - // at all, which reads as a conversation with room to spare. It is the same reason the - // status word beside it names the quiet state instead of leaving the row blank. + // "unknown" in words, and always drawn. A context nobody has measured is not an empty one, + // and the two used to share an appearance: a session just cleared, one whose provider never + // reports usage, and one that has not run a turn all showed nothing at all, which reads as + // a conversation with room to spare. Text( contextTokens?.let { "context ${tokens(it)}" } ?: "context unknown", style = MaterialTheme.typography.labelSmall, @@ -2491,9 +2228,8 @@ private fun QuestionRow( * * Everything that puts words in the box without the reader typing them goes through here: a * restored draft, a share arriving from another app, a slash command taken from the suggestions. - * All three leave the reader mid-sentence, and all three used to leave the cursor at whatever - * offset it happened to hold -- which for a box that has never been focused is the very start, so - * picking `/rename` and typing put the name in front of the command. + * All three used to leave the cursor at whatever offset it happened to hold -- which for a box that + * has never been focused is the very start, so picking `/rename` and typing put the name in front. */ private fun atEnd(text: String) = TextFieldValue(text, TextRange(text.length)) @@ -2502,8 +2238,7 @@ private fun atEnd(text: String) = TextFieldValue(text, TextRange(text.length)) * * Sized to one tap, because one tap is all it has to span -- [PickerButton] explains the pair of * events it separates. Deliberately not the platform's long-press timeout, which is the longest a - * tap can legally be: half a second of ignoring the button would start swallowing a deliberate - * reopen, and a press held that long to close a menu is not worth protecting at that price. + * tap can legally be: half a second of ignoring the button would swallow a deliberate reopen. */ private const val ONE_TAP_MS = 250L @@ -2518,22 +2253,18 @@ private fun PickerButton(current: String, options: List, onPick: (String var open by remember { mutableStateOf(false) } // When an outside touch last closed the menu. // - // Pressing this button while its own menu is open is such a touch. The menu is deliberately - // not focusable (see below), which means the press that dismisses it is also delivered to the - // window underneath -- and what it lands on there is this button. The dismissal arrives with - // the press and the click with the release, measured 3ms apart on the emulator, so a button - // that simply opened on every click would reopen what the same finger had just closed, and - // the menu could only be put away by tapping somewhere else. So the moment is remembered, and - // a click that follows it within one tap is read as the second half of that tap rather than - // as a new one. + // Pressing this button while its own menu is open is such a touch. The menu is deliberately not + // focusable (see below), so the press that dismisses it is also delivered to the window + // underneath -- which is this button. The dismissal arrives with the press and the click with + // the release, measured 3ms apart on the emulator, so a button that simply opened on every + // click would reopen what the same finger had just closed. var closedAt by remember { mutableLongStateOf(0L) } Box { BubbleButton( onClick = { if (SystemClock.uptimeMillis() - closedAt > ONE_TAP_MS) open = true } ) { - // One line, truncated rather than wrapped: this sits in a row - // whose height is the buttons beside it, and a second line - // would move them. + // One line, truncated rather than wrapped: this sits in a row whose height is the + // buttons beside it, and a second line would move them. Text( current, style = MaterialTheme.typography.bodySmall, @@ -2544,22 +2275,18 @@ private fun PickerButton(current: String, options: List, onPick: (String // Two departures from the defaults, both deliberate. // // Not focusable, so opening it does not take focus from the message field and dismiss the - // keyboard. Changing the model mid-sentence is an aside, not a departure from what you - // were typing. + // keyboard. Changing the model mid-sentence is an aside. // // Not clipped, which is what puts the menu on the button instead of floating above it. // Compose measures the anchor in *window* coordinates -- this app draws edge to edge, so // that window is the whole screen -- but asks whether the menu fits inside the *visible* // frame, which is the screen less the status and navigation bars. Two spaces, one - // comparison: sitting just above a button near the bottom then looks like an overflow, - // and the menu falls back to a fixed 48dp above the bottom of the visible frame. Measured - // on the emulator, that left the menu's foot 142px -- the status bar's height, exactly -- - // clear of the button that opened it. Turning clipping off makes both questions about the - // same window. What it gives up is that the keyboard stops counting as an edge: with the - // IME up the menu opens downwards over it rather than upwards over the transcript. That - // is the lesser fault -- it is still attached to the button that opened it, which is the - // whole complaint -- and correcting it would mean supplying a position provider, which - // this menu takes no parameter for. + // comparison: sitting just above a button near the bottom then looks like an overflow, and + // the menu falls back to a fixed 48dp above the bottom of the visible frame -- measured on + // the emulator as 142px, the status bar's height exactly, clear of the button that opened + // it. What this gives up is that the keyboard stops counting as an edge, so with the IME up + // the menu opens downwards over it. That is the lesser fault, and correcting it would mean + // supplying a position provider this menu takes no parameter for. DropdownMenu( expanded = open, onDismissRequest = { diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionSettingsDialog.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionSettingsDialog.kt index 2ffcdb0..4e116b2 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionSettingsDialog.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionSettingsDialog.kt @@ -39,14 +39,11 @@ import kotlinx.coroutines.withContext * controls and hid the thing they act on. * * The model and the permission mode are deliberately still on the session's own bar, because those - * are changed *while* reading a turn -- "not this model, try that one" -- and a control belongs - * with the thing it acts on. + * are changed *while* reading a turn -- "not this model, try that one". * - * Captions are for what a control costs rather than for what it is. Each control is a labelled noun - * with a switch or a field beside it, and a paragraph under every one of them made the dialog - * longer than the conversation it covers -- so Notifications has none, while Move and Reload do, - * because what those two take away is not visible from here. Failures get their words for the same - * reason: they are what the reader cannot work out by looking. + * Captions are for what a control costs rather than for what it is. A paragraph under every control + * made the dialog longer than the conversation it covers -- so Notifications has none, while Move + * and Reload do, because what those two take away is not visible from here. */ @Composable fun SessionSettingsDialog( @@ -66,7 +63,7 @@ fun SessionSettingsDialog( onDismiss: () -> Unit, /** * Copies what this session costs to draw. Built by the session screen, because everything it - * measures is that screen's own state -- see `copyRenderReport` there. + * measures is that screen's own state. */ onCopyRenderReport: () -> Unit, ) { @@ -76,16 +73,14 @@ fun SessionSettingsDialog( var error by remember { mutableStateOf(null) } // Null until the server has been asked. The row this dialog was opened over is a snapshot of // whenever the list was last fetched, so drawing the switch straight from it would show a - // position that may have been changed since -- from here or from another device -- with - // nothing to say so. Until the answer arrives the switch is disabled and a spinner sits beside - // it, which is what not knowing looks like: distinguishable from off, and from a refusal. + // position that may have been changed since. Until the answer arrives the switch is disabled + // and a spinner sits beside it, which is what not knowing looks like. var notify by remember(sessionId) { mutableStateOf(null) } var notifyError by remember { mutableStateOf(null) } - // Where the session works. Null until the server has been asked, for the same reason the - // switch above is: the row this dialog opened over is a snapshot, and a path drawn from it - // could be one somebody changed from another device. An empty answer is a session that was - // never given a directory, which is not the same as one whose directory is unknown -- the - // field is only enabled once one of those two is settled. + // Where the session works. Null until the server has been asked, for the same reason the switch + // above is. An empty answer is a session that was never given a directory, which is not the + // same as one whose directory is unknown -- the field is only enabled once one of those is + // settled. var cwd by remember(sessionId) { mutableStateOf(null) } var typedCwd by remember(sessionId) { mutableStateOf("") } var cwdError by remember { mutableStateOf(null) } @@ -98,8 +93,8 @@ fun SessionSettingsDialog( cwd = fresh.cwd.orEmpty() typedCwd = fresh.cwd.orEmpty() } catch (e: ApiException) { - // Left unknown rather than falling back to the stale row: the switch stays - // disabled, instead of offering a position nothing confirmed. + // Left unknown rather than falling back to the stale row: the switch stays disabled, + // instead of offering a position nothing confirmed. notifyError = e.message notify = null } @@ -131,8 +126,8 @@ fun SessionSettingsDialog( } // Moved optimistically so the switch answers the finger that moved it, and put back if the - // request is refused -- a switch that waits for a round trip reads as broken on a slow - // tunnel, and one that stays moved after a refusal lies. + // request is refused -- a switch that waits for a round trip reads as broken on a slow tunnel, + // and one that stays moved after a refusal lies. fun setNotify(wanted: Boolean) { val was = notify notify = wanted @@ -162,7 +157,7 @@ fun SessionSettingsDialog( onRenamed(chosen) } catch (e: ApiException) { // Reported here, where it happened, because this dialog is the only place that - // knows a rename was attempted -- the session behind it shows nothing about it. + // knows a rename was attempted. error = e.message saving = false } @@ -181,8 +176,8 @@ fun SessionSettingsDialog( singleLine = true, enabled = !saving, modifier = Modifier.fillMaxWidth(), - // The keyboard's own action does what the button does: a one-field form - // where the return key does nothing is a form people press return at anyway. + // The keyboard's own action does what the button does: a one-field form where + // the return key does nothing is a form people press return at anyway. keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), keyboardActions = KeyboardActions(onDone = { save() }), ) @@ -207,8 +202,8 @@ fun SessionSettingsDialog( enabled = notify != null, ) } - // Beside the switch that failed, not with the rename's error: they are two - // requests and a reader has to be able to tell which one the server refused. + // Beside the switch that failed, not with the rename's error: they are two requests + // and a reader has to be able to tell which one the server refused. notifyError?.let { Text( it, @@ -225,9 +220,9 @@ fun SessionSettingsDialog( value = typedCwd, onValueChange = { typedCwd = it }, label = { Text("Working directory") }, - // What the field cannot say by being empty: a session that was never - // given one starts wherever its launcher does, and this names that - // rather than showing a path nobody chose. + // What the field cannot say by being empty: a session that was never given + // one starts wherever its launcher does, and this names that rather than + // showing a path nobody chose. placeholder = { Text("wherever the session was started") }, singleLine = true, enabled = cwd != null && !movingCwd, @@ -247,9 +242,8 @@ fun SessionSettingsDialog( } } // The whole of what pressing Move does, where it is about to be pressed. A - // directory is settled when the process is spawned, so there is no changing one - // under a running session -- it is ended, and the next thing said to the session - // starts it in the new place. + // directory is settled when the process is spawned, so it is ended and the next + // thing said to the session starts it in the new place. Text( "Moving stops the session's process. It starts again in the new directory " + "with the next message, or with Start.", @@ -269,10 +263,10 @@ fun SessionSettingsDialog( modifier = Modifier.fillMaxWidth(), ) { Text("Transcript", modifier = Modifier.weight(1f)) - // The size is what the button discards, and the unknown state is drawn - // rather than guessed: a spinner while the directory is being measured, and - // words when there is nothing there, because "nothing cached" and "0 B" read - // as different claims. + // The size is what the button discards, and the unknown state is drawn rather + // than guessed: a spinner while the directory is being measured, and words when + // there is nothing there, because "nothing cached" and "0 B" read as different + // claims. when { cachedBytes == null -> CircularProgressIndicator( @@ -325,8 +319,8 @@ fun SessionSettingsDialog( } } }, - // Disabled rather than absent while there is nothing to save: a button that comes and - // goes makes its own presence the signal, and its absence cannot say why. + // Disabled rather than absent while there is nothing to save: a button that comes and goes + // makes its own presence the signal, and its absence cannot say why. confirmButton = { TextButton(onClick = { save() }, enabled = changed && !saving) { Text(if (saving) "Saving..." else "Save") diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionUsageBar.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionUsageBar.kt index c2b3695..e292ea3 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionUsageBar.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionUsageBar.kt @@ -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>, - /** - * 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, @@ -93,15 +89,15 @@ class UsageFeed( fun rememberUsageFeed(settings: ServerSettings): UsageFeed { var snapshots by remember { mutableStateOf>>(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) }) @@ -121,13 +117,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 = @@ -145,18 +139,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) { @@ -165,8 +158,8 @@ fun SessionUsageBar(usage: SessionUsage, modifier: Modifier = Modifier) { } } - // Nothing at all for a machine 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 machine that meters nothing: a row saying "unknown" there would report a + // problem about a setup somebody chose, on every screen, forever. if (usage is SessionUsage.NotMetered) { return } @@ -222,9 +215,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()}%" @@ -244,8 +236,7 @@ private fun fiveHourLabel(window: UsageWindow, now: OffsetDateTime): String { /** * One machine's snapshot, out of every machine's. * - * 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. */ diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SettingsScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SettingsScreen.kt index bb9c216..b067e57 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SettingsScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SettingsScreen.kt @@ -47,15 +47,14 @@ fun SettingsScreen( val context = LocalContext.current var host by remember { mutableStateOf(existing?.host ?: "10.66.0.1") } var port by remember { mutableStateOf((existing?.port ?: 8443).toString()) } - // Never pre-filled from the stored token: this screen shouldn't be a - // way to read the credential back off the device. + // Never pre-filled from the stored token: this screen shouldn't be a way to read the credential + // back off the device. var token by remember { mutableStateOf("") } var error by remember { mutableStateOf(null) } val scanLauncher = rememberLauncherForActivityResult(ScanContract()) { result: ScanIntentResult -> - // Null contents means the user backed out of the scanner -- not an - // error, so nothing to report. + // Null contents means the user backed out of the scanner -- not an error. val contents = result.contents ?: return@rememberLauncherForActivityResult val settings = parseEnrollmentUri(contents.toUri()) if (settings == null) { @@ -83,8 +82,8 @@ fun SettingsScreen( // left-pointing arrow at the right edge, aimed across the title it sits beside. // // Absent rather than disabled on first run, which is the one place this app lets a - // control come and go: there is no screen underneath yet, so a Back here would not be - // a capability being withheld but a promise it could not keep. + // control come and go: there is no screen underneath yet, so a Back here would not be a + // capability being withheld but a promise it could not keep. if (onBack != null) { GlyphButton(BACK_GLYPH, "Back", onBack) Spacer(Modifier.width(GLYPH_BUTTON_MARGIN)) @@ -106,14 +105,11 @@ fun SettingsScreen( OutlinedButton( onClick = { - // Hold the camera permission before the scanner starts. - // Letting its activity ask on our behalf is what the - // library does by default, and it opens the camera without - // waiting for the answer: the first-ever scan comes up as - // a live preview with "Sorry, the Android camera - // encountered a problem" over it, and works on the second - // try. Nothing is wrong with the camera, so nothing should - // say there is. + // Hold the camera permission before the scanner starts. Letting its activity ask on + // our behalf is what the library does by default, and it opens the camera without + // waiting for the answer: the first-ever scan comes up as a live preview with + // "Sorry, the Android camera encountered a problem" over it, and works on the + // second try. if ( context.checkSelfPermission(Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED @@ -187,11 +183,10 @@ fun SettingsScreen( * just been granted. * * MIXED_SCAN is the load-bearing part: ZXing otherwise looks only for a dark code on a light - * ground, and ai-server's QR is block characters in the terminal's foreground colour, so on a - * dark-themed terminal it comes out as a photographic negative the scanner silently never matches. - * Which way round it renders is the terminal's business, not something this app should depend on. - * The mixed decoder alternates normal and inverted frames, costing half the frame rate at each - * polarity and nothing else. + * ground, and ai-server's QR is block characters in the terminal's foreground colour, so on a dark- + * themed terminal it comes out as a photographic negative the scanner silently never matches. The + * mixed decoder alternates normal and inverted frames, costing half the frame rate at each + * polarity. */ private fun enrollmentScanOptions(): ScanOptions = ScanOptions() diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SetupsScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SetupsScreen.kt index 23c2dc0..8bacde8 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SetupsScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SetupsScreen.kt @@ -58,8 +58,8 @@ fun SetupsScreen(settings: ServerSettings, reloadToken: Int) { LaunchedEffect(reloadToken) { reload() } Column(Modifier.fillMaxSize().padding(16.dp)) { - // The heading and Back are the tab row's now; adding a machine is this tab's own work - // and stays with the list it adds to. + // The heading and Back are the tab row's now; adding a machine is this tab's own work and + // stays with the list it adds to. Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { TextButton(onClick = { adding = true }) { Text("Add machine") } } @@ -200,9 +200,8 @@ private fun SetupCard( Column(Modifier.padding(12.dp)) { Text(setup.name, style = MaterialTheme.typography.titleSmall) Text( - // Not "this machine": the seeded setup is *called* that, - // and the card read "this machine / this machine". The - // line has to say something the name cannot also be. + // Not "this machine": the seeded setup is *called* that, and the card read "this + // machine / this machine". setup.address ?: "runs where the backend does", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, @@ -275,9 +274,8 @@ private fun AddSetupDialog( OutlinedTextField( value = address, onValueChange = { address = it }, - // Just the shape. What a blank one means is said once, in the text above - // this form -- repeating it here wrapped the label onto a second line and - // made this field taller than the two beside it for no information. + // Just the shape. What a blank one means is said once, in the text above this + // form -- repeating it here wrapped the label onto a second line. label = { Text("user@host[:port]") }, singleLine = true, ) @@ -288,8 +286,7 @@ private fun AddSetupDialog( singleLine = true, ) // Where a file attached from the phone lands on that machine. Blank means the - // session's own directory, which is what most people want and what needs no - // path typed on a phone. + // session's own directory, which is what most people want. OutlinedTextField( value = attachmentsDir, onValueChange = { attachmentsDir = it }, @@ -309,9 +306,8 @@ private fun AddSetupDialog( }, dismissButton = { Row { - // Tried before saving, so a wrong address or an - // unauthorised key is caught while this form is still on - // screen rather than at the first spawn. + // Tried before saving, so a wrong address or an unauthorised key is caught while + // this form is still on screen rather than at the first spawn. TextButton( enabled = !testing, onClick = { @@ -377,14 +373,13 @@ private fun RenameDialog(setup: Setup, onDismiss: () -> Unit, onRename: (String) /** * Splits `user@host:port` into its two halves, with the port left null when none was typed. * - * One field rather than two because that is how an address is written and read everywhere else -- - * and because a port that is almost always 22 does not deserve a box of its own on a phone - * keyboard. Null rather than 22: the backend already decides the default, and writing 22 here would - * put a second answer to that question in a second place. + * One field rather than two because that is how an address is written and read everywhere else, and + * because a port that is almost always 22 does not deserve a box of its own on a phone keyboard. + * Null rather than 22: the backend already decides the default. * * A colon only means "port" when it can. A bracketed IPv6 literal is unwrapped as ssh writes it, - * `[::1]:22`; a bare `::1` keeps every colon, because an address with several is an address, not an - * address and a port. So the rule is: brackets, or exactly one colon followed by digits. + * `[::1]:22`; a bare `::1` keeps every colon. So the rule is: brackets, or exactly one colon + * followed by digits. */ private fun splitHostAndPort(typed: String): Pair { if (typed.startsWith("[")) { diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Share.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Share.kt index daf4773..e21d3d6 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Share.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Share.kt @@ -9,7 +9,7 @@ import androidx.core.content.IntentCompat * * Held as the URIs rather than uploaded on arrival, because an upload belongs to a session and the * share arrives before anyone has said which. [serial] makes two shares of the same thing two - * requests, for the reason [SessionOpenRequest] carries one: equal values would not recompose. + * requests, for the reason [SessionOpenRequest] carries one. */ data class ShareRequest(val uris: List, val text: String?, val serial: Int) diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Sizes.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Sizes.kt index 2e5af76..081bda6 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Sizes.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Sizes.kt @@ -4,15 +4,12 @@ package com.example.aiapp * A byte count at the coarsest unit that still says something, so rows stay comparable. * * Null at zero and below, because the screens that ask disagree about what nothing means and only - * the caller knows: a transcript of no bytes is a measurement that has not happened, and is left - * off the row; a file of no bytes is a file with nothing in it, and the explorer says `0 B` rather - * than leaving a gap the reader would have to interpret; a session with no cached transcript says - * "nothing cached", because a figure of none would read as a measurement. + * the caller knows: a transcript of no bytes is a measurement that has not happened; a file of no + * bytes is a file with nothing in it, and the explorer says `0 B`; a session with no cached + * transcript says "nothing cached", because a figure of none would read as a measurement. * * Its own file rather than the import screen's, where it started: three screens now say a size, and * a second copy of these thresholds is how one list comes to call 4 kB what the other calls 4096 B. - * `ModelsScreen`'s `gigabytes` is deliberately not folded in -- it writes a download's size to two - * decimal places, which is a different question about a much larger number. */ fun humanSize(bytes: Long): String? = when { diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SpawnScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SpawnScreen.kt index 699c81f..88a2f36 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SpawnScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SpawnScreen.kt @@ -37,7 +37,7 @@ import kotlinx.coroutines.withContext * The spawn screen: what to run, where to run it, and the per-kind fields. * * Providers and hosts both come from the server, so adding either to its config.ron shows up here - * with no app rebuild -- and because they are independent, any provider can be sent to any host. + * with no app rebuild. */ @Composable fun SpawnScreen( @@ -46,33 +46,27 @@ fun SpawnScreen( onBack: () -> Unit, ) { val scope = rememberCoroutineScope() - // What the form is made of, and whether we have it yet. A failure here - // is not the same as a server with nothing to offer, so it must not - // reach the pickers as empty lists -- see LoadState. + // What the form is made of, and whether we have it yet. A failure here is not the same as a + // server with nothing to offer, so it must not reach the pickers as empty lists. var options by remember { mutableStateOf>>(LoadState.Loading) } - // Setup first, then one of its providers. Choosing a setup can - // invalidate the provider, so the provider is stored by name and - // resolved against the current setup rather than held as an object - // that could outlive the list it came from. + // Setup first, then one of its providers. Choosing a setup can invalidate the provider, so the + // provider is stored by name and resolved against the current setup rather than held as an + // object that could outlive the list it came from. var setupName by remember { mutableStateOf(null) } var providerName by remember { mutableStateOf(null) } var title by remember { mutableStateOf("") } var model by remember { mutableStateOf("") } var cwd by remember { mutableStateOf("") } - // "auto" rather than "manual": on a phone every ask is a round trip to - // a question card, and answering "allow Bash?" dozens of times per task - // is what this app exists to avoid. Manual stays one tap away for a - // session that warrants it. + // "auto" rather than "manual": on a phone every ask is a round trip to a question card, and + // answering "allow Bash?" dozens of times per task is what this app exists to avoid. var permissionMode by remember { mutableStateOf("auto") } var busy by remember { mutableStateOf(false) } - // Only the spawn's own failure. The fetch's lives in `options`: this - // one leaves a filled-in form worth keeping, and that one leaves - // nothing to fill in. + // Only the spawn's own failure. The fetch's lives in `options`: this one leaves a filled-in + // form worth keeping, and that one leaves nothing to fill in. var spawnError by remember { mutableStateOf(null) } - // Downloaded models, for a llama provider to choose between. Fetched - // beside the setups but kept separate: a Claude session needs none, so - // failing to list them must not stop the screen rendering. + // Downloaded models, for a llama provider to choose between. Kept separate from the setups: a + // Claude session needs none, so failing to list them must not stop the screen rendering. var models by remember { mutableStateOf>(emptyList()) } var modelKey by remember { mutableStateOf(null) } var contextSize by remember { mutableStateOf("") } @@ -105,10 +99,9 @@ fun SpawnScreen( } Spacer(Modifier.height(16.dp)) - // Nothing below is fillable until the options are here, and a - // failure to fetch them leaves no form worth showing -- so this - // reports and stops, rather than offering empty pickers under an - // error message. + // Nothing below is fillable until the options are here, and a failure to fetch them leaves + // no form worth showing -- so this reports and stops, rather than offering empty pickers + // under an error message. val setups = when (val state = options) { is LoadState.Loading -> { @@ -123,10 +116,9 @@ fun SpawnScreen( } val setup = setups.firstOrNull { it.name == setupName } val current = setup?.providers?.firstOrNull { it.name == providerName } - // Only the Claude CLI has models, a working directory and - // permission modes; keying the extra fields on the kind rather - // than the provider name keeps a second Claude provider from - // needing anything here. + // Only the Claude CLI has models, a working directory and permission modes; keying the + // extra fields on the kind rather than the provider name keeps a second Claude provider + // from needing anything here. val isClaude = current?.kind == "claude_cli" val isLlama = current?.kind == "llama_cpp" @@ -137,9 +129,9 @@ fun SpawnScreen( selected = setupName, onSelect = { name -> setupName = name - // The provider list changes with the machine, so a name - // carried over from the previous one would be a selection - // that isn't in the picker. Take that machine's first. + // The provider list changes with the machine, so a name carried over from the + // previous one would be a selection that isn't in the picker. Take that machine's + // first. providerName = setups.firstOrNull { it.name == name }?.providers?.firstOrNull()?.name }, @@ -150,13 +142,13 @@ fun SpawnScreen( style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) - // The address belongs to the setup above it, not to the - // provider label below; without this they read as one block. + // The address belongs to the setup above it, not to the provider label below; without + // this they read as one block. Spacer(Modifier.height(8.dp)) } - // Only what this machine actually has. A setup with none says so - // rather than showing an empty row that reads as a failure. + // Only what this machine actually has. A setup with none says so rather than showing an + // empty row that reads as a failure. if (setup != null && setup.providers.isEmpty()) { Text( "\"${setup.name}\" has no providers configured.", @@ -183,10 +175,9 @@ fun SpawnScreen( ) if (isLlama) { - // A llama session names one of the models this backend has - // downloaded, so the choice is that list rather than free - // text -- there is nothing sensible to type here, and a name - // that is not on disk is a session that cannot start. + // A llama session names one of the models this backend has downloaded, so the choice is + // that list rather than free text -- a name that is not on disk is a session that + // cannot start. if (models.isEmpty()) { Text( "No models downloaded yet. Get one from the Models screen first.", @@ -196,9 +187,8 @@ fun SpawnScreen( } else { ChipGroup( label = "Model", - // The file, not the whole key: the repository is the - // same for every quantisation of a model, so the file - // name is what tells two of them apart. + // The file, not the whole key: the repository is the same for every + // quantisation of a model, so the file name is what tells two of them apart. options = models.map { it.file }, selected = models.firstOrNull { it.key == modelKey }?.file, onSelect = { file -> modelKey = models.first { it.file == file }.key }, @@ -280,13 +270,9 @@ fun SpawnScreen( withContext(Dispatchers.IO) { spawnSession( settings, - // The id, not the label: labels are - // editable and the server resolves by - // id. - // Non-null here: `chosen` came from - // `setup`'s own provider list, so - // reaching this point proves there was - // a setup to take it from. + // The id, not the label: labels are editable and the server + // resolves by id. Non-null here, since `chosen` came from + // `setup`'s own provider list. setup = setup.id, provider = chosen.name, title = title.trim(), @@ -294,9 +280,8 @@ fun SpawnScreen( if (isLlama) modelKey else model.trim().takeIf { isClaude }, cwd = cwd.trim().takeIf { isClaude }, permissionMode = permissionMode.takeIf { isClaude }, - // Sent only when set, so blank means - // "whatever llama.cpp does by default" - // rather than a zero. + // Sent only when set, so blank means "whatever llama.cpp does + // by default" rather than a zero. params = buildMap { if (isLlama) { diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Sse.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Sse.kt index 23c49c3..ecb75bf 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Sse.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Sse.kt @@ -17,14 +17,13 @@ const val RECONNECT_DELAY_MS = 1500L * One server-sent-events connection, framed. * * The framing is the part worth having once: `data:` and `event:` lines accumulate until a blank - * line ends the frame, comments (keep-alives) start with `:`, and a frame is either named with no - * payload or a payload with no name. Two screens follow two different streams — a session's - * transcript and what a machine's import list is doing — and neither should be re-deriving that. + * line ends the frame, comments start with `:`, and a frame is either named with no payload or a + * payload with no name. Two screens follow two different streams and neither should re-derive that. * * Blocking: [run] occupies its thread until the stream ends. [close], from any thread, is the - * cancellation path — it disconnects the socket, which unblocks the read, and [run] then returns - * rather than throwing, so a deliberate close is not reported as a connection error. Reconnecting - * belongs to the caller, which is the only one that knows where to resume from. + * cancellation path -- it disconnects the socket, which unblocks the read, and [run] then returns + * rather than throwing. Reconnecting belongs to the caller, which is the only one that knows where + * to resume from. */ class Sse(private val settings: ServerSettings) { @Volatile private var connection: HttpURLConnection? = null @@ -38,19 +37,16 @@ class Sse(private val settings: ServerSettings) { /** * Follows the stream at [path], handing each frame to [onFrame] as its name (null for an * ordinary data frame) and its payload. The path is given here rather than at construction - * because a caller that reconnects usually resumes from somewhere new -- a cursor it has - * advanced past -- and that lives in the query string. + * because a caller that reconnects usually resumes from somewhere new. * * [onOpen] fires once the server has accepted the connection. That is the measured moment the * stream is live, and the only honest thing to clear a previous failure on: clearing on the - * first *event* instead left an idle stream displaying a connection error it had already - * recovered from, indefinitely. + * first *event* instead left an idle stream displaying an error it had already recovered from. */ fun run(path: String, onOpen: () -> Unit, onFrame: (name: String?, data: String) -> Unit) { // Opening is inside the try, not before it. Everything this method can fail at owes the - // caller the same kind of failure -- both callers retry an [ApiException] and let anything - // else reach the top of the app -- and a connection that could not even be constructed - // used to escape as a raw `IOException` from a line no `catch` covered. + // caller the same kind of failure, and a connection that could not even be constructed used + // to escape as a raw `IOException` from a line no `catch` covered. var connection: HttpURLConnection? = null try { connection = diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Theme.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Theme.kt index 66c1e8f..c2d1e11 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Theme.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Theme.kt @@ -44,16 +44,14 @@ private object Mocha { * * Copied from dev-updater rather than shared, which is a deliberate line: wg-app-link is the *link* * -- the tunnel, the pinned CA, enrollment -- and a palette is not that. The two apps looking alike - * is a preference, not a contract, and the moment one wants a different accent the shared version - * becomes a thing to fight rather than a thing to use. + * is a preference, not a contract. * * The mapping that matters is the surface ladder. Mocha names its darks in order -- Crust, Mantle, - * Base, Surface 0, Surface 1 -- and Material asks for the same thing under different names, so the - * page is Base, a component's outlined card stays Base beside it, and a project's card is Surface - * 0: one visible step up, which is the whole of what the nesting has to say. + * Base, Surface 0, Surface 1 -- so the page is Base, a component's outlined card stays Base beside + * it, and a project's card is Surface 0: one visible step up, which is the whole of what the + * nesting has to say. * - * Accents on this palette are light, so anything filled with one takes Crust for its text rather - * than the near-white the roles default to. + * Accents on this palette are light, so anything filled with one takes Crust for its text. */ val AiAppColors = darkColorScheme( @@ -94,10 +92,8 @@ val AiAppColors = * What a session is doing, said in colour. * * Here rather than beside each screen that shows a status. These were separate literals in two - * other files -- an amber, a green and a red picked off Material's defaults -- so the same state - * was a slightly different colour depending which screen you looked at, and none of them belonged - * to this palette at all. A colour that carries meaning is part of the scheme, not a value typed - * where it happened to be needed. + * other files, so the same state was a slightly different colour depending which screen you looked + * at. A colour that carries meaning is part of the scheme, not a value typed where it was needed. */ val runningColor: Color @Composable get() = Mocha.Green @@ -107,7 +103,7 @@ val runningColor: Color * * The scheme's error colour, and deliberately not "the same red as a destructive button" even * though it is the same red. They are the same red for different reasons, and a state is not an - * action -- nothing here is a button. + * action. */ val failedColor: Color @Composable get() = MaterialTheme.colorScheme.error @@ -116,10 +112,9 @@ val failedColor: Color * About the session rather than about the task: a command, and the compaction one of them starts. * * Its own colour because it is its own kind of work. Everything else a session does is progress - * through what was asked of it; this is the session acting on itself -- rewriting what it - * remembers, taking a new name -- and none of it appears in the transcript as an answer to - * anything. A reader who has learned that blue means "not stuck, but not replying to you either" - * has learned the thing that distinguishes it from a session that has hung. + * through what was asked of it; this is the session acting on itself, and none of it appears in the + * transcript as an answer to anything. A reader who has learned that blue means "not stuck, but not + * replying to you either" has learned what distinguishes it from a session that has hung. */ val commandColor: Color @Composable get() = Mocha.Blue @@ -128,10 +123,9 @@ val commandColor: Color * A clear: the conversation taken out of what the session is given. * * Red because of what it does, not because anything went wrong -- somebody asked for this, and a - * deliberate choice is not a problem to report. It is the same red as [failedColor] and [stopColor] - * for a third reason, which is worth naming rather than collapsing: this is neither a fault nor a - * button, it is the mark left where something was taken away. The reader never has to tell the - * three apart, because no two of them can appear as the same kind of thing. + * deliberate choice is not a problem to report. The same red as [failedColor] and [stopColor] for a + * third reason: this is neither a fault nor a button, it is the mark left where something was taken + * away. No two of the three can appear as the same kind of thing. */ val clearedColor: Color @Composable get() = Mocha.Red @@ -148,9 +142,9 @@ val warningColor: Color * The fill of a progress bar that is only reporting how far along something is. * * Blue because a bar like this reports a quantity rather than a verdict, and the scheme's primary - * made it the loudest thing on a screen the reader opened to do something else. A download, or a - * compaction, has no limit to be near: it finishes. Only a bar measuring a *quota* escalates, and - * that one is [quotaColor]. + * made it the loudest thing on a screen the reader opened to do something else. A download has no + * limit to be near: it finishes. Only a bar measuring a *quota* escalates -- that one is + * [quotaColor]. */ val progressColor: Color @Composable get() = Mocha.Blue @@ -158,15 +152,13 @@ val progressColor: Color /** * The fill of a bar measuring how much of a quota is gone: blue, then yellow, then red. * - * One function rather than the same `when` written beside each bar, because the whole point of - * colouring by consequence is that the reader learns the step once -- two bars showing the same 80% - * in different colours teaches nothing except that the colour cannot be trusted. It reads as a - * difference in degree, which is all colour can carry: the states that differ in *kind* from this - * -- a window nobody could read, a machine that meters nothing -- are said in words elsewhere, - * because a reader has no way to tell those from an ordinary low number by colour alone. + * One function rather than the same `when` written beside each bar, because the point of colouring + * by consequence is that the reader learns the step once. It reads as a difference in degree, which + * is all colour can carry: the states that differ in *kind* -- a window nobody could read, a + * machine that meters nothing -- are said in words elsewhere. * * [percent] is the API's own 0-100 rather than a fraction, so callers pass what the server sent - * without each converting it first and one of them getting it wrong by a factor of a hundred. + * without one of them getting it wrong by a factor of a hundred. */ @Composable fun quotaColor(percent: Double): Color = @@ -185,13 +177,11 @@ private const val OVER_LIMIT_PERCENT = 90.0 /** * The surface verbatim text sits on: a command, a tool's output, a code block in a reply. * - * The darkest value in the palette rather than a step up from the page, and that is the whole point - * -- everything else on this screen is somebody's prose, and this is what a machine was handed and + * The darkest value in the palette rather than a step up from the page, and that is the point -- + * everything else on this screen is somebody's prose, and this is what a machine was handed and * what it said back, character for character. Crust sits *below* Base, so the same colour reads as - * one clear step down both on the page, where a reply is drawn, and on a card, where a tool call - * is; a tint chosen upwards has to be picked twice and still collides with the card it lands on. - * The renderer's default code background was `surfaceVariant`, which is exactly a card's own fill - * -- so a code block inside a tool call had no background at all. + * one clear step down both on the page and on a card; a tint chosen upwards has to be picked twice + * and still collides with the card it lands on. * * One colour for all three, so "this is verbatim" is learnable once. */ @@ -202,11 +192,10 @@ val rawSurface: Color * Catppuccin Mocha as the highlighter's palette; see [SyntaxPalette]. * * Here with the rest of the palette rather than beside the code that highlights: the colours a - * fence is drawn in are the same accents every other coloured thing in the app already uses, and - * splitting them out would make code the one surface whose palette came from somewhere else. + * fence is drawn in are the same accents every other coloured thing already uses. * - * Not a composable, because [highlight] runs off the drawing thread; these colours never vary with - * the theme. + * Not a composable, because [highlight] runs off the drawing thread; these never vary with the + * theme. */ fun catppuccinSyntax(): SyntaxPalette = SyntaxPalette( @@ -227,8 +216,7 @@ fun catppuccinSyntax(): SyntaxPalette = * already made for every other blue on the screen. * * Mocha's bright half is the same accents as its normal half -- only the two greys differ -- which - * is upstream's choice and not an omission here. A program that uses bright red to mean something - * other than red is relying on a distinction its own terminal may not draw either. + * is upstream's choice and not an omission here. * * The background is [rawSurface] because that is what a tool's output is drawn on, and reverse * video needs to know what it is reversing against. @@ -263,14 +251,12 @@ fun ansiPalette(): AnsiPalette = * * The default is `primary` at 40% alpha, which is a tint of whatever is behind it -- and this app * draws text on surfaces two full steps apart. Over a reply, on Base, that reads clearly. Over a - * code block or a tool's output, on Crust, the same 40% composites to a barely-there smudge, so - * selecting a line of code looks like nothing happened even though the selection is there and - * copies correctly. + * code block, on Crust, the same 40% composites to a barely-there smudge, so selecting a line of + * code looks like nothing happened even though it copies correctly. * - * Fixed and stronger, because "this is selected" is a meaning rather than decoration: a colour that - * means something must carry its own contrast instead of borrowing it from the surface it happens - * to land on. Raised only as far as it takes to read on the darkest of them -- past this the fill - * starts competing with the syntax colours it sits behind, which are the thing being read. + * Fixed and stronger, because "this is selected" is a meaning rather than decoration. Raised only + * as far as it takes to read on the darkest of them -- past this the fill starts competing with the + * syntax colours it sits behind. */ val AiAppSelectionColors = TextSelectionColors( @@ -288,11 +274,10 @@ val linkColor: Color * A list's markers: the bullets and numbers down its left edge. * * The scheme's secondary accent rather than the text colour, because a marker is structure rather - * than words: coloured, the items of a list can be counted without reading them, and a nested list - * reads as a shape before it reads as text. Lavender is not one of the colours that mean something - * here -- green, red, peach and yellow are states and actions -- and it is the same at every depth, - * since depth is said by the glyph and the indent; a colour per depth would make a difference in - * degree look like one in kind. + * than words: coloured, the items of a list can be counted without reading them. Lavender is not + * one of the colours that mean something here, and it is the same at every depth, since depth is + * said by the glyph and the indent -- a colour per depth would make a difference in degree look + * like one in kind. */ val listMarkerColor: Color @Composable get() = Mocha.Lavender @@ -305,11 +290,9 @@ val overLimitColor: Color * The composer's buttons, coloured by what pressing one does rather than by where it sits. * * Green makes something happen now, blue makes it happen later, orange takes back what is in - * flight, red ends the process. The near-collisions with the states above are deliberate and worth - * naming rather than collapsing: [runningColor] is green because a session is working, - * [failedColor] is red because one fell over, [awaitingColor] is the same orange because a session - * is waiting on somebody -- those are *states*, and these are *actions*. A reader never has to tell - * them apart, because nothing here is a state and nothing there is pressable. + * flight, red ends the process. The near-collisions with the states above are deliberate: those are + * *states*, and these are *actions*. A reader never has to tell them apart, because nothing here is + * a state and nothing there is pressable. */ val sendColor: Color @Composable get() = Mocha.Green @@ -322,9 +305,8 @@ val queueColor: Color * Interrupting the running turn: the work stops and the session stays. * * Orange rather than red because of how much it takes: only what is in flight. The process is still - * there holding the conversation, and the next message starts a turn as though nothing had - * happened. Red is spent on [stopColor], which is the same button in the same place when what it - * would end is the session's process. + * there holding the conversation. Red is spent on [stopColor], which is the same button in the same + * place when what it would end is the session's process. */ val pauseColor: Color @Composable get() = Mocha.Peach @@ -347,8 +329,7 @@ val startColor: Color * * The content colour is stated here beside the fill rather than inherited. A semantic colour has to * carry its own contrast: these fills are fixed whatever the surface under them does, so the theme - * will not change to rescue a foreground that stops being readable on one of them. Crust is what - * every accent on this palette takes, which is the same reason `onPrimary` is Crust above. + * will not change to rescue a foreground that stops being readable on one of them. */ @Composable fun actionButtonColors(fill: Color): ButtonColors = diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/ToolInput.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/ToolInput.kt index 2f305e9..029b139 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/ToolInput.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/ToolInput.kt @@ -16,11 +16,10 @@ import org.json.JSONObject /** * A tool call's input, read rather than dumped. * - * Every tool's input arrives as JSON, and showing it raw makes the reader parse `{"command":"…", - * "timeout":120000}` themselves to find the one line they care about. So the fields that carry the - * meaning are pulled out -- the command a shell will run, what it is for, how long it may take -- - * and anything left over is still shown, because dropping a field would be claiming the tool has no - * other input when it might. + * Every tool's input arrives as JSON, and showing it raw makes the reader parse + * `{"command":"…","timeout":120000}` themselves to find the one line they care about. So the fields + * that carry the meaning are pulled out, and anything left over is still shown, because dropping a + * field would be claiming the tool has no other input when it might. */ data class ToolInput( /** The thing that will actually be run or read, if this tool has one. */ @@ -30,8 +29,8 @@ data class ToolInput( /** The tool's own one-line summary, when it wrote one. */ val description: String?, /** - * How long the call may take, in the largest units it fits ([formatMillis]). Shown apart - * because it is a limit on the call rather than part of what the call does. + * How long the call may take, in the largest units it fits. Shown apart because it is a limit + * on the call rather than part of what the call does. */ val timeout: String?, /** Everything else, as `name: value` lines. Never dropped. */ @@ -47,7 +46,7 @@ data class ToolInput( * * A table rather than a chain of `if`s: adding a tool is a row, and the shape stops any of them * from being the special case that gets its own code path. Unknown tools fall through to "no - * subject, everything is rest", which is what the card always did. + * subject, everything is rest". */ private val SUBJECTS: Map> = mapOf( @@ -68,8 +67,8 @@ fun parseToolInput(tool: String, input: String): ToolInput { try { JSONObject(input) } catch (_: org.json.JSONException) { - // Not an object: older transcripts and some tools send a bare - // string. It is still the input, so it is still shown. + // Not an object: older transcripts and some tools send a bare string. It is still the + // input, so it is still shown. return ToolInput( null, null, @@ -100,9 +99,9 @@ fun parseToolInput(tool: String, input: String): ToolInput { /** * A tool call's input: its subject highlighted, then whatever else it carried. * - * On the dark surface every verbatim thing in the app sits on -- see [RawBlock]. Drawn as nothing - * at all when the call carried neither, rather than as an empty block: a tinted rectangle with - * nothing in it is a rendering fault, and it is the shape a tool with no input actually has. + * On the dark surface every verbatim thing in the app sits on. Drawn as nothing at all when the + * call carried neither, rather than as an empty block: a tinted rectangle with nothing in it is a + * rendering fault. * * The description is *not* here. It is the tool's own prose about what it is doing, so it belongs * with the reader's text rather than inside the machine's; [ToolCard] draws it above this. @@ -113,11 +112,11 @@ fun ToolInputView(tool: String, input: String, modifier: Modifier = Modifier) { if (parsed.subject == null && parsed.rest.isEmpty()) return RawBlock(modifier) { parsed.subject?.let { subject -> - // Not wrapped: a wrapped command hides where its arguments end, - // and the long one is the one being read closely. + // Not wrapped: a wrapped command hides where its arguments end, and the long one is the + // one being read closely. Text( - // Not cached: a tool's subject is one command line, which lexes in microseconds - // -- the cache exists for a fence with two hundred lines in it. + // Not cached: a tool's subject is one command line, which lexes in microseconds -- + // the cache exists for a fence with two hundred lines in it. remember(subject, parsed.language) { highlight(subject, parsed.language) }, style = MaterialTheme.typography.bodySmall, fontFamily = FontFamily.Monospace, diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/ToolRows.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/ToolRows.kt index 3ef2ddc..6fc28c4 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/ToolRows.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/ToolRows.kt @@ -42,14 +42,11 @@ import androidx.compose.ui.unit.dp * the transcript's own order is what paging and the event stream depend on, and one screen's idea * of "these belong together" must not reach back into it. * - * Immutable, and said so, because Compose cannot tell. - * - * A row is a value: it is rebuilt from the transcript rather than edited, and two rows describing - * the same events are equal. Compose infers stability from a class's fields, and a `List` field -- - * which several of these carry -- makes it assume the worst, so every composable taking one - * recomposed whenever anything above it did. A page of history landing recomposed all 148 loaded - * rows including the markdown inside them, measured as 701 compositions for 148 rows in one scroll, - * and that is what a page landing costs on top of the fetch itself. + * Immutable, and said so, because Compose cannot tell: a row is rebuilt from the transcript rather + * than edited, and two rows describing the same events are equal. Compose infers stability from a + * class's fields, and a `List` field -- which several of these carry -- makes it assume the worst, + * so a page of history landing recomposed all 148 loaded rows including the markdown inside them, + * measured as 701 compositions for 148 rows in one scroll. * * The promise this makes is real and has to stay true: nothing here is mutated after it is built. */ @@ -59,16 +56,12 @@ sealed class TranscriptRow { * This row's identity in the list, which must survive everything that can happen to the row. * * The list is keyed by this so that inserting a new message at one end, or a page of history at - * the other, moves the rows and not the reader. That makes it the load-bearing value on this - * screen: when a key changes, the list loses its anchor and the transcript steps under whoever - * is reading it. + * the other, moves the rows and not the reader. When a key changes, the list loses its anchor + * and the transcript steps under whoever is reading it. * * A tool row therefore keys on [TranscriptItem.ToolRun.runId] rather than on a sequence number, - * and it is the *same* value whether the run is drawn as one card or as a group. A lone call - * that gains a neighbour becomes a group without changing identity, which is the case a - * seq-based key got wrong: the row the reader was looking at was replaced rather than updated. - * Which value that is belongs to the item ([TranscriptItem.key]), not to a `when` here: a row - * is one item and the item is what knows what it is called. + * and it is the *same* value whether the run is drawn as one card or as a group. Which value + * that is belongs to the item ([TranscriptItem.key]), not to a `when` here. */ abstract val key: Any @@ -76,12 +69,9 @@ sealed class TranscriptRow { * Where this row starts in the transcript: the sequence number of the oldest event behind it. * * Separate from [key], and deliberately so. [key] is the list's identity and is a display - * decision -- a tool row is named after its run, and a run takes its name from whichever call - * was first when it was folded, which changes as pages arrive. A seq is the server's own - * numbering: it is assigned once, never moves, and means the same thing to every device. So - * anything that has to point at a place in the conversation and still find it later -- a saved - * scroll position is the one -- points with this, and anything that has to identify a row - * within one composition uses [key]. + * decision; a seq is the server's own numbering, assigned once and meaning the same thing to + * every device. So anything that has to point at a place in the conversation and still find it + * later -- a saved scroll position -- points with this. */ abstract val startSeq: Long @@ -133,8 +123,7 @@ private fun groupRuns(items: List): List { // Grouped by the run each call says it belongs to, not by adjacency worked out here. // Adjacency is the same answer most of the time and a worse one at the edges: a call // arriving next to an existing run, or a page of history arriving in front of one, both - // change which call is *first*, and a group named after its first member is a different - // group every time that happens. + // change which call is *first*. if (item is TranscriptItem.ToolRun && (run.isEmpty() || run.first().runId == item.runId)) { run += item } else { @@ -152,18 +141,14 @@ private fun groupRuns(items: List): List { * What says the calls belong together is the surface behind them, which is the one cue rather than * two half-cues -- rounded to the same corner every other card in the app has, so a group reads as * one object rather than as a square patch behind round things. The calls sit on it inset by - * [GROUP_INSET], which is the container's own padding rather than an indent: they are the same rows - * they would be on their own, and a rounded corner drawn hard against a rounded corner reads as a - * notch. + * [GROUP_INSET], which is the container's own padding rather than an indent. * * Inside, the calls are a connected stack. Facing corners are square and the outer ones are not, so - * the run reads as one thing broken into its parts; [GROUP_GAP] keeps the parts legible without - * separating them. See [connectedShape]. + * the run reads as one thing broken into its parts; see [connectedShape]. * * It closes from either end. A long group's header scrolls off while its last call is still on - * screen, and the reader who wants it shut is looking at the bottom, not hunting for the top. The - * bar at the foot is the same height as the heading at the top, so the surface the calls sit on is - * as thick below them as above. + * screen, and the reader who wants it shut is looking at the bottom. The bar at the foot is the + * same height as the heading at the top. */ @Composable fun ToolGroup( @@ -171,8 +156,7 @@ fun ToolGroup( expanded: Boolean, /** * Where it was pressed is the row's business rather than the control's -- a group has a control - * at each end, and only the row knows where its own ends are, so the row records the touch - * itself and this just says that one happened. + * at each end, and only the row knows where its own ends are. */ onToggle: () -> Unit, isToolExpanded: (String) -> Boolean, @@ -222,8 +206,8 @@ fun ToolGroup( ) } } - // Shutting it from here anchors the other end: the reader is at the bottom of a long - // group, and what they are looking at is what follows it. + // Shutting it from here anchors the other end: the reader is at the bottom of a long group, + // and what they are looking at is what follows it. CollapseBar(barHeight, onToggle) } } @@ -232,8 +216,8 @@ fun ToolGroup( * The height of a group's heading, and so of the bar at its foot. * * Derived from the type the heading is set in rather than written down, because the two have to - * match and a pair of numbers chosen to look equal stops being equal the moment either the style or - * the density changes. Taking the line height also means the heading cannot be clipped by it. + * match and a pair of numbers chosen to look equal stops being equal the moment the density + * changes. */ @Composable private fun groupBarHeight(): Dp { @@ -242,10 +226,9 @@ private fun groupBarHeight(): Dp { } /** - * The bottom half of a group's toggle: an arrow back up to its heading. - * - * Given the heading's height rather than padded to something that looks close, so the surface the - * calls sit on is the same thickness at both ends. See [groupBarHeight]. + * The bottom half of a group's toggle: an arrow back up to its heading. Given the heading's height + * rather than padded to something that looks close, so the surface the calls sit on is the same + * thickness at both ends. */ @Composable private fun CollapseBar(height: Dp, onToggle: () -> Unit) { @@ -266,8 +249,7 @@ private fun CollapseBar(height: Dp, onToggle: () -> Unit) { * does not. * * Written once and given an index rather than branched at each end, because a stack has three cases - * that are one rule -- and the middle one is the case a hand-written first/last pair gets wrong - * when a run turns out to have three calls in it. + * that are one rule -- and the middle one is what a hand-written first/last pair gets wrong. */ @Composable private fun connectedShape(index: Int, count: Int): CornerBasedShape { @@ -294,12 +276,10 @@ private val GROUP_GAP = 2.dp * One tool call. * * Closed, it is a single line: the tool's name and what the call is for. The command itself is not - * on it, because a wrapped command turns one row into four and a run of them into a wall -- and the - * name plus the intent is what somebody scanning the transcript is reading for. + * on it, because a wrapped command turns one row into four and a run of them into a wall. * * Open, it shows the command, whatever else the input carried, and the output. The timeout sits at - * the top right: it is a limit on the call rather than part of what the call does, and it is worth - * seeing beside the command it constrains rather than buried in the fields below it. + * the top right: it is a limit on the call rather than part of what the call does. * * A call waiting on permission is shown open whatever the reader last chose, since the command is * the thing being decided and a row saying only "Bash" cannot be decided on. @@ -342,10 +322,9 @@ fun ToolCard( ) } ?: Spacer(Modifier.weight(1f)) } - // A spinner says the machine is working. While this call is waiting on an - // answer the machine is doing nothing at all -- the turn is stopped on the - // person reading it -- so it says whose move it is instead, in the colour this - // app uses everywhere for that. + // A spinner says the machine is working. While this call is waiting on an answer + // the machine is doing nothing at all -- the turn is stopped on the person reading + // it -- so it says whose move it is instead. if (deciding) { Spacer(Modifier.width(8.dp)) Text( @@ -370,9 +349,9 @@ fun ToolCard( modifier = Modifier.padding(top = 4.dp), ) } - // Everything AskUserQuestion carries is the questions, and those are drawn - // below as something answerable; dumping the same JSON above them would be the - // decision stated twice, once unreadably. + // Everything AskUserQuestion carries is the questions, and those are drawn below as + // something answerable; dumping the same JSON above them would be the decision + // stated twice, once unreadably. if (tool.tool != ASK_USER_QUESTION) { ToolInputView(tool.tool, tool.input, Modifier.padding(top = 4.dp)) } @@ -381,14 +360,12 @@ fun ToolCard( Text("Output", style = MaterialTheme.typography.labelSmall) // What the tool printed, on the surface everything verbatim gets and in the // face it was written for: this is column-aligned far more often than it is - // prose -- a directory listing, a diff, a table of numbers -- and a - // proportional font silently destroys the alignment that carried the meaning. + // prose, and a proportional font silently destroys the alignment that carried + // the meaning. // - // Its terminal styling applied and the rest of the escapes taken out, since - // what a shell prints is written for a terminal: colour is often the whole of - // what a diff or a test run is saying, and the sequences that carry it are - // unreadable drawn verbatim. Remembered against the text, so a card that is - // open through a scroll parses once. See [ansiStyled]. + // Its terminal styling applied and the rest of the escapes taken out: colour is + // often the whole of what a diff or a test run is saying. Remembered against + // the text, so a card that is open through a scroll parses once. val palette = remember { ansiPalette() } val styled = remember(tool.output, palette) { ansiStyled(tool.output, palette) } RawBlock(Modifier.padding(top = 2.dp)) { @@ -400,10 +377,8 @@ fun ToolCard( } } } - // Shown open or closed. A call that produced a picture is one - // whose result *is* the picture, and a row that hides it says - // less than the one line it replaced -- unlike a command, which - // is what the closed line already summarises. + // Shown open or closed. A call that produced a picture is one whose result *is* the + // picture, and a row that hides it says less than the one line it replaced. tool.images.forEach { ref -> image(ref) } if (tool.asks.isNotEmpty()) { if (tool.tool == ASK_USER_QUESTION) { @@ -428,11 +403,10 @@ private fun PermissionAsk( onAnswer: (List, onSettled: () -> Unit) -> Unit, ) { // What was pressed, before the answer has been round-tripped. Two bare words with no submit - // step -- unlike a question card, where the answer is several choices and worth reviewing -- - // so the press has to be its own acknowledgement or the row sits unchanged for a round trip - // and reads as having missed the tap. Cleared when the request settles: by then either the - // answer is in `ask.answers` and the mark stands on a measurement, or it failed and the - // buttons come back rather than leaving a decision marked that nothing recorded. + // step -- unlike a question card, where the answer is worth reviewing -- so the press has to be + // its own acknowledgement or the row sits unchanged for a round trip. Cleared when the request + // settles: by then either the answer is in `ask.answers`, or it failed and the buttons come + // back. var pressed by remember(ask.id) { mutableStateOf(null) } Spacer(Modifier.height(8.dp)) Text( @@ -442,8 +416,7 @@ private fun PermissionAsk( ) // Answered or not, the options stay and the one that was taken is marked -- see // [AskedQuestion], which is the same rule on the question card. A permission is where it - // matters most: "Answered: Deny" alone does not say that Allow was the alternative, and - // whether a tool was allowed or refused is the thing a reader comes back to this row for. + // matters most: "Answered: Deny" alone does not say that Allow was the alternative. val settled = ask.answers.isNotEmpty() AnswerOptions( ask.options, diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptCache.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptCache.kt index d985416..606628e 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptCache.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptCache.kt @@ -11,30 +11,25 @@ import java.io.RandomAccessFile * This phone's copy of the transcripts it has already been sent, so reopening a session does not * download it again. * - * What is stored is the server's own JSON for one event per line, in transcript order -- the - * elements of a `/transcript` page and the payload of each SSE frame. Reading the cache means - * running the same [parseSeqEvent] the network path runs, so a cached transcript and a fetched one - * cannot draw differently, and an event type this build does not know ([SessionEvent.Unknown]) - * keeps every field it arrived with, on disk, for the build that will. Rows are deliberately *not* - * what is stored: a row is a rendering of events, its shape changes whenever the fold does, and a - * cache of rows would need throwing away on every app update that touched `foldEvent`. + * What is stored is the server's own JSON for one event per line, in transcript order. Reading the + * cache means running the same [parseSeqEvent] the network path runs, so a cached transcript and a + * fetched one cannot draw differently, and an event type this build does not know keeps every field + * it arrived with for the build that will. Rows are deliberately *not* what is stored: a row is a + * rendering, and a cache of rows would need throwing away on every update that touched `foldEvent`. * * See TRANSCRIPT_CACHE.md for the design. Four rules run through all of it: * 1. what is on screen is what the server's transcript says, in order, with nothing missing -- the * cache is a copy and is never inferred, folded or edited here; * 2. a cached line is never ahead of the live cursor, and the cursor never ahead of the cache; * 3. the cache is never load-bearing -- missing, evicted, damaged or unwritable all degrade to a - * cold open, never to a blank or a wrong screen; - * 4. a line already on the phone is not fetched again. + * cold open, never to a blank or a wrong screen; 4. a line already on the phone is not fetched + * again. * * A plain [File] root and no Compose, `Context` or network, so the whole of the file logic runs - * under the JVM unit tests. It is also why there is no JSON parser in here: what it needs off a - * line is the sequence number and whether the line is a streamed delta, and both are read with a - * regex over text the server wrote. A line it cannot read that way is treated as damage, which - * gives the same answer as having no cache at all. - * - * [warn] is where failures are said, for the same reason -- `android.util.Log` is a stub that - * throws under the JVM tests, and this file has to be exercisable there. + * under the JVM unit tests. That is also why there is no JSON parser here: what it needs off a line + * is the sequence number and whether the line is a streamed delta, both read with a regex. A line + * it cannot read that way is treated as damage. [warn] is where failures are said for the same + * reason. */ class TranscriptCache( private val root: File, @@ -44,11 +39,9 @@ class TranscriptCache( fun session(id: String): SessionCache = SessionCache(File(root, id), warn) /** - * Deletes every session directory not in [ids], called after a successful list fetch. - * - * The path out for a session deleted on another device or at the backend: nothing here would - * otherwise ever hear about it, and unlike a draft's few bytes what it leaves behind is - * megabytes. + * Deletes every session directory not in [ids], called after a successful list fetch. The path + * out for a session deleted on another device: nothing here would otherwise hear about it, and + * unlike a draft's few bytes what it leaves behind is megabytes. */ fun retainOnly(ids: Set) = guardIo(Unit, warn) { @@ -57,11 +50,9 @@ class TranscriptCache( /** * Deletes least-recently-touched session directories, never [keep], until the whole of this - * server's cache is under [budget]. - * - * Least-recently-touched rather than largest: what a reader is likely to open again is what - * they opened last, and evicting the big ones first would empty the cache for exactly the - * conversations it exists for. + * server's cache is under [budget]. Least-recently-touched rather than largest: what a reader + * is likely to open again is what they opened last, and evicting the big ones first would empty + * the cache for exactly the conversations it exists for. */ fun evictToBudget(keep: String, budget: Long = CACHE_BUDGET_BYTES) = guardIo(Unit, warn) { @@ -81,18 +72,16 @@ class TranscriptCache( } /** - * How much of this phone's cache directory all of one server's transcripts may take. - * - * A dozen of the largest transcripts seen in the dev VM (21 MB for 24,000 events) and a small - * fraction of a phone. A number to revisit against real use rather than a measurement of anything. + * How much of this phone's cache directory all of one server's transcripts may take. A dozen of the + * largest transcripts seen in the dev VM (21 MB for 24,000 events) and a small fraction of a phone. + * A number to revisit against real use rather than a measurement of anything. */ const val CACHE_BUDGET_BYTES: Long = 256L * 1000 * 1000 /** - * What the newest cached line says, which is what the probe checks against the server. - * - * Both halves are wanted together and by the same caller: the seq is what the request asks about, - * and the line is what its answer is compared with. + * What the newest cached line says, which is what the probe checks against the server. Both halves + * are wanted together: the seq is what the request asks about, and the line is what its answer is + * compared with. */ data class CachedTail(val seq: Long, val line: String) @@ -101,35 +90,31 @@ data class CachedTail(val seq: Long, val line: String) * * A chunk is a set of lines *and a claim about what they cover*, and the two are not the same * thing: a coalesced page joins each run of streamed deltas into one event carrying the seq of the - * run's oldest delta, so a page whose newest event is seq 1,200 may in fact cover everything up to - * the 1,650 it was fetched with, and nothing in the lines says so. So coverage is the half-open - * range in the file's name: + * run's oldest delta, so a page whose newest event is seq 1,200 may cover everything up to the + * 1,650 it was fetched with, and nothing in the lines says so. So coverage is the half-open range + * in the file's name: * ``` - * -.rows.jsonl a coalesced page; end is the `before` it was fetched with - * -.raw.jsonl an uncoalesced page, or a closed live run - * -open.raw.jsonl the live run; end is its last line's seq + 1 + * -.rows.jsonl a coalesced page; end is the `before` it was fetched with + * -.raw.jsonl an uncoalesced page, or a closed live run -open.raw.jsonl the + * live run; end is its last line's seq + 1 * ``` * - * Two chunks are adjacent when one's `end` is the other's `first`. Only the contiguous run of - * adjacent chunks ending at the newest chunk -- the **suffix** -- is ever served: chunks behind a - * gap are kept, because the gap is usually closed by paging back through it, but nothing is served - * across one. + * Two chunks are adjacent when one's `end` is the other's `first`. Only the contiguous run ending + * at the newest chunk -- the **suffix** -- is ever served: chunks behind a gap are kept, because + * the gap is usually closed by paging back through it, but nothing is served across one. * * **The newest chunk is always raw**, which is what makes the stream cursor and the probe well - * defined -- a raw chunk's last line is a real event at a real seq, and the server never coalesces - * the newest window. It holds by construction (the opening window and every stream frame are raw) - * and is checked on read: a `.rows` chunk at the newest end can only mean this app died between - * closing one live run and opening the next, and it discards the session. + * defined. It holds by construction (the opening window and every stream frame are raw) and is + * checked on read: a `.rows` chunk at the newest end can only mean this app died between closing + * one live run and opening the next, and it discards the session. * * Nothing here is load-bearing. Every operation that touches the disk answers as though the cache * were empty when it cannot, and a write failure disables writing for the rest of this instance's * life so that a full disk costs one log line rather than one per delta. * * Every operation is synchronized, because two of them really do run at once: the stream appends - * live events from its own IO thread while a reader scrolling back reads pages from another. The - * lock is uncontended in the ordinary case and what it buys is that the open chunk's name, its end - * and its writer are never read half-rotated -- which would show up as a page silently fetched - * again, or as a stored chunk overlapping the run it was written beside. + * live events from its own IO thread while a reader scrolling back reads pages from another. What + * it buys is that the open chunk's name, its end and its writer are never read half-rotated. */ class SessionCache( private val dir: File, @@ -141,9 +126,8 @@ class SessionCache( * The open chunk's writer, its file, and the seq that chunk now ends at. * * Buffered, and flushed on [flush], because a delta is a hundred bytes and arrives dozens of - * times a second while a reply streams -- a syscall each is the thing to avoid. What that costs - * is the unflushed tail on a crash, which is safe: a shorter cache is a longer catch-up, never - * a wrong one. + * times a second while a reply streams. What that costs is the unflushed tail on a crash, which + * is safe: a shorter cache is a longer catch-up, never a wrong one. */ private var writer: BufferedWriter? = null private var openFile: File? = null @@ -153,8 +137,7 @@ class SessionCache( * The newest line of the suffix, or null when there is none or the newest chunk is not raw. * * This is the cursor the live stream would resume from, so it is also what has to be shown to - * still be the server's own line before anything is resumed from it -- see - * `TranscriptSource.probe`. + * still be the server's own line before anything is resumed from it. */ @Synchronized fun tail(): CachedTail? = @@ -187,30 +170,26 @@ class SessionCache( * The page of lines before [before], oldest first, or null when the cache cannot answer. * * Null is a miss -- the suffix does not cover the ground immediately below [before] -- and - * means the server has to be asked. It is deliberately not an empty list: an empty page is how - * the screen is told it has reached the start of the conversation, and a cache saying that of + * means the server has to be asked. Deliberately not an empty list: an empty page is how the + * screen is told it has reached the start of the conversation, and a cache saying that of * history it merely does not hold would stop the transcript scrolling back for good. * * [before] is anywhere inside the suffix, not only at a chunk boundary. The cursor a warm open - * leaves behind is in the middle of the live run -- the screen draws the newest eighty lines of - * it -- so a cache that could only answer at a boundary would send the very first backwards - * page to the server and, since that page would overlap the run, keep none of it. + * leaves behind is in the middle of the live run, so a cache that could only answer at a + * boundary would send the very first backwards page to the server and, since that page would + * overlap the run, keep none of it. * - * A short page is fine, and is what a walk that reaches the oldest chunk of the suffix returns: - * the caller already treats a short page as a page. - * - * With [rows] the count is rows rather than lines, mirroring the server's `parse_coalesced`: - * every event that is not a streamed delta is a row, and each maximal run of deltas is one row. + * With [rows] the count is rows rather than lines, mirroring the server's `parse_coalesced`. * The deltas are not joined here -- `foldEvent` does that, and the joined row keeps the seq of - * its first delta either way, so anchors and the next `before` land where they do today. + * its first delta either way. */ @Synchronized fun page(before: Long, limit: Int, rows: Boolean): List? = guard(null) { val suffix = suffix() val newest = suffix.lastOrNull() ?: return@guard null - // Above what is held, or at or below where it starts: either way the run the caller - // is scrolling into is not continuous with this one, and only the server has it. + // Above what is held, or at or below where it starts: either way the run the caller is + // scrolling into is not continuous with this one, and only the server has it. if (before > newest.end || before <= suffix.first().first) return@guard null val taken = ArrayDeque() var counted = 0 @@ -220,14 +199,14 @@ class SessionCache( if (!wanting) break if (chunk.first >= before) continue eachLine(chunk) { line -> - // The page is what is *before* the cursor; the rows at or above it are the - // ones already on screen. + // The page is what is *before* the cursor; the rows at or above it are already + // on screen. if (seqOf(line)!! >= before) return@eachLine true if (rows) { val delta = isDelta(line) - // Stop only between rows: a delta continuing the run being gathered is - // part of a row already counted, and breaking on it would drop the half - // of that row already taken. + // Stop only between rows: a delta continuing the run being gathered is part + // of a row already counted, and breaking on it would drop the half of that + // row already taken. if (counted >= limit && !(delta && inRun)) wanting = false else { if (!delta || !inRun) counted++ @@ -248,8 +227,7 @@ class SessionCache( * asked with so that it stops where this phone's copy starts. Null when there is no such chunk. * * Any chunk, not only the suffix's: the whole point is to reach the run behind a gap, so that - * the gap is closed with exactly the bytes it is wide and the history behind it is served - * locally from then on. + * the gap is closed with exactly the bytes it is wide. */ @Synchronized fun coveredUpTo(before: Long): Long? = @@ -260,9 +238,8 @@ class SessionCache( * * Refused when it overlaps a chunk already here, because there is no clean cut: a coalesced * event cannot be split at a seq inside its own delta run. `TranscriptSource` keeps that from - * arising by bounding what it fetches, and this is the guard for a page that arrives anyway -- - * from a server without the `after` parameter, say. Such a page is still drawn; it is only not - * kept. + * arising by bounding what it fetches, and this is the guard for a page that arrives anyway. + * Such a page is still drawn; it is only not kept. * * The newest chunk is never stored through here: the opening window and every live frame go * through [append], which is what keeps the newest chunk raw and open. @@ -282,18 +259,17 @@ class SessionCache( * Appends one live event, which is also how a freshly fetched opening window is stored. * * A seq equal to the open chunk's end extends it. A larger one is a gap -- which is what a - * `reset` looks like from here -- and closes the open chunk under the end it turned out to have - * before starting a new one at [seq]. A smaller one is already covered and is ignored; the SSE - * contract is `seq > after`, so that is a guard rather than a path. + * `reset` looks like from here -- and closes the open chunk under the end it turned out to + * have. A smaller one is already covered and is ignored; the SSE contract is `seq > after`. */ @Synchronized fun append(line: String, seq: Long) = guard(Unit) { if (disabled) return@guard val writer = writerFor(seq) ?: return@guard - // Written as it arrived. A newline inside it would split one event into two - // unreadable halves, but neither source can produce one: SSE framing forbids it, and - // a page's elements are re-serialized compactly, which escapes it. + // Written as it arrived. A newline inside it would split one event into two unreadable + // halves, but neither source can produce one: SSE framing forbids it, and a page's + // elements are re-serialized compactly, which escapes it. writer.write(line) writer.write("\n") openEnd = seq + 1 @@ -329,9 +305,7 @@ class SessionCache( /** * Every chunk on disk, oldest first. A name this does not recognise is not ours and is ignored. - * - * Recomputed per operation rather than kept: another operation may have changed the directory, - * and a hundred names is a directory listing. + * Recomputed per operation rather than kept: another operation may have changed the directory. */ private fun chunks(): List { writer?.flush() @@ -354,9 +328,9 @@ class SessionCache( * is the one writing it. * * An open chunk whose last line cannot be read is this app having died mid-write. That line is - * dropped and the file truncated to the last good one before anything is served from it, which - * is the one place damage is repaired rather than discarded: the tail of an append-only file is - * the only place a partial line can be. + * dropped and the file truncated to the last good one, which is the one place damage is + * repaired rather than discarded: the tail of an append-only file is the only place a partial + * line can be. */ private fun openEndOf(file: File, first: Long): Long { if (openFile == file && openEnd > 0) return openEnd @@ -373,8 +347,7 @@ class SessionCache( * The contiguous run of adjacent chunks ending at the newest one, oldest first. * * A newest chunk that is not raw cannot happen while this code is the only writer, and means - * the directory is not to be trusted -- so the session is discarded rather than served across - * whatever else is wrong with it. + * the directory is not to be trusted -- so the session is discarded. */ private fun suffix(): List { val all = chunks() @@ -393,10 +366,9 @@ class SessionCache( /** * Each line of [chunk], newest first, until [take] says stop. * - * Backwards and lazily, because every question this cache is asked is about the newest end -- - * the tail, the opening window, the page before a cursor -- and a live run grows to the size of - * the conversation. Reading the file whole to answer with eighty lines of it is the cost the - * server's own reader was rewritten to stop paying. + * Backwards and lazily, because every question this cache is asked is about the newest end and + * a live run grows to the size of the conversation. Reading the file whole to answer with + * eighty lines of it is the cost the server's own reader was rewritten to stop paying. * * Damage anywhere but at the tail of the open chunk was not written by this code, and there is * no honest way to say what a chunk covers with a line of it unreadable -- so it discards the @@ -433,8 +405,8 @@ class SessionCache( } rename(existing.file, existing.first, existing.end) } - // A chunk that was created and never written to would otherwise be left behind under a - // name a second one is about to want; it covers nothing, so nothing is lost with it. + // A chunk that was created and never written to would otherwise be left behind under a name + // a second one is about to want; it covers nothing, so nothing is lost with it. dir.listFiles().orEmpty().forEach { if (CHUNK_NAME.matchEntire(it.name)?.groupValues?.get(2) == "open" && it.length() == 0L) it.delete() @@ -480,12 +452,12 @@ class SessionCache( * * None of this is reported on screen: none of it changes what the screen shows -- every read * here has a network path beside it producing the same result -- and the reader has nothing to - * do about it. It is logged, and damage discards this session's cache, which is what makes the - * next open an ordinary cold one. + * do about it. Damage discards this session's cache, which makes the next open an ordinary cold + * one. */ private fun guard(ifBroken: T, body: () -> T): T = - // A disk that refused once will refuse again, once per delta, so the first refusal is - // also the last: this instance stops writing rather than logging a line a token. + // A disk that refused once will refuse again, once per delta, so the first refusal is also + // the last: this instance stops writing rather than logging a line a token. guardIo( ifBroken, warn, @@ -515,8 +487,7 @@ private val TYPE_IN_LINE = Regex(""""type"\s*:\s*"([^"]*)"""") * One line's sequence number, or null when the line is not one of ours. * * A regex rather than a JSON parse, so that this file carries no parser and runs under the JVM - * tests: the seq is the first field the server writes (`SeqEvent`'s declaration order, with the - * event flattened after it), so the first match is the top-level one. + * tests: the seq is the first field the server writes, so the first match is the top-level one. */ private fun seqOf(line: String): Long? = SEQ_IN_LINE.find(line)?.groupValues?.get(1)?.toLongOrNull() @@ -536,11 +507,10 @@ private const val READ_BLOCK = 64 * 1024 * * Every question the cache is asked is about the newest end of a chunk, and a live run reaches the * size of the conversation, so reading forwards means reading a transcript to answer with the last - * eighty lines of it. This reads blocks from the end and stops where the caller stops. + * eighty lines of it. * * Splitting on bytes is safe because the separator is `\n`, which cannot occur inside a multi-byte - * UTF-8 sequence; each line is decoded whole, so nothing is cut through a character. A missing file - * yields nothing, which is the same answer as an empty one. + * UTF-8 sequence; each line is decoded whole. A missing file yields nothing. */ private fun eachLineBackwards(file: File, onLine: (offset: Long, line: String) -> Boolean) { if (!file.isFile) return @@ -581,8 +551,8 @@ private const val NEWLINE = '\n'.code.toByte() * Drops a final line that is not one of ours, by truncating the file to where it starts. * * This app having died mid-write is the one kind of damage that is repaired rather than discarded: - * the tail of an append-only file is the only place a partial line can be, and everything before it - * is intact. A second bad line is not this, and is left for the read path to notice. + * the tail of an append-only file is the only place a partial line can be. A second bad line is not + * this, and is left for the read path to notice. */ private fun repairTail(file: File) { var truncateTo = -1L @@ -598,9 +568,7 @@ private fun sizeOf(file: File): Long = /** * The disk half of [SessionCache.guard], shared with [TranscriptCache]'s own maintenance. - * - * [onFailure] is what the caller does about it beyond answering [ifBroken] -- for a session's - * cache, giving up on writing. + * [onFailure] is what the caller does about it beyond answering [ifBroken]. */ private fun guardIo( ifBroken: T, diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptItems.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptItems.kt index cb91df9..5089ca2 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptItems.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptItems.kt @@ -9,10 +9,9 @@ import kotlinx.coroutines.withContext * * Events are the only data source, and there is deliberately no second shape for history to drift * from: a page fetched backwards, a live frame, and a line read out of this phone's own cache are - * all the same events through the same parser. Since 2026-09-04 the cache is where most of them - * come from on a session opened again -- see [TranscriptCache], which stores the server's lines - * rather than these rows for exactly that reason: a row is a rendering, and its shape changes - * whenever this file does. + * all the same events through the same parser. [TranscriptCache] stores the server's lines rather + * than these rows for exactly that reason -- a row is a rendering, and its shape changes whenever + * this file does. */ @Immutable sealed class TranscriptItem { @@ -24,10 +23,8 @@ sealed class TranscriptItem { * list is addressed by position: whatever somebody had scrolled to keeps its index while the * content underneath it slides, which reads as the view scrolling on its own. * - * A seq is the right identity because it is what the transcript itself is ordered by, it never - * changes, and it is already carried by every event. A row built from several events -- a - * streaming message, a tool call and its result -- keeps the seq of the first, so it holds - * still while the rest of it arrives. + * A row built from several events keeps the seq of the first, so it holds still while the rest + * of it arrives. */ abstract val seq: Long @@ -35,10 +32,8 @@ sealed class TranscriptItem { * This item's identity on screen, which is its [seq] for everything that has one of its own. * * Here rather than in [TranscriptRow.Single] because the two items that need something else are - * the two that know why: a tool call is named after its run, and a peer note is *sorted* by the - * turn it started rather than by where it arrived. Asking each item what it is called is also - * what stops the next such item being missed -- a `when` over concrete types in the row would - * have to gain a case, silently, and nothing says when it did not. + * the two that know why. Asking each item what it is called is also what stops the next such + * item being missed -- a `when` over concrete types would have to gain a case, silently. */ open val key: Any get() = seq @@ -59,15 +54,13 @@ sealed class TranscriptItem { * What it buys is the split. [transcriptUnits] keeps the newest reply whole because a * streaming reply's text changes per delta and splitting a changing text is a parse per * delta -- but "newest" outlives the turn, so a session that ends on a long reply was - * drawing it as one item indefinitely, with every node of it alive. Measured on a Pixel 9 - * Pro XL: one 34,996px reply on screen put the frame's draw phase at 13.8ms, 79% of it the - * framework's own bookkeeping, which grows with alive nodes. + * drawing it as one item indefinitely. Measured on a Pixel 9 Pro XL: one 34,996px reply on + * screen put the frame's draw phase at 13.8ms, 79% of it framework bookkeeping. * - * Folded from the status event that ended the turn, rather than read off the screen's + * Folded from the status event that ended the turn rather than read off the screen's * status, because rows only change through the held-events gate: the split changes the * newest row's list identity, and doing that from a status flip while somebody is reading - * inside that reply would step the list under them. An event has to wait for the reader to - * be at the newest end; a screen state does not. + * inside that reply would step the list under them. */ val settled: Boolean = false, ) : TranscriptItem() @@ -79,11 +72,10 @@ sealed class TranscriptItem { * The run of adjacent calls this one belongs to, named once when the call is folded in and * never recomputed. * - * Carried rather than derived because a run can gain members at *either* end -- a new call - * arriving beside it, or a page of history arriving in front of it -- so no function of its - * current members is stable. It is the first call's id at the moment the run started, which - * is a name rather than a description: [joinPages] hands it to older calls that turn out to - * belong to the same run, instead of renaming the run they joined. + * Carried rather than derived because a run can gain members at *either* end, so no + * function of its current members is stable. It is the first call's id at the moment the + * run started, which is a name rather than a description: [joinPages] hands it to older + * calls that turn out to belong to the same run. */ val runId: String, val tool: String, @@ -94,19 +86,16 @@ sealed class TranscriptItem { * The questions this call is waiting on, in the order they were asked. * * On the call's own row rather than beside it: an ask used to arrive as a second card - * repeating the input verbatim, so the reader saw the same command twice and had to work - * out that it was one event. The backend says which call a question is about, so this is a - * fact rather than a match on the input. + * repeating the input verbatim, so the reader saw the same command twice. The backend says + * which call a question is about, so this is a fact rather than a match on the input. * - * A list because AskUserQuestion asks up to four at once, and they are one decision to make - * -- a permission is the case of exactly one, not a different shape. + * A list because AskUserQuestion asks up to four at once, and a permission is the case of + * exactly one rather than a different shape. */ val asks: List = emptyList(), /** - * Images this call's result carried, drawn under it. - * - * Beside it they had to be paired by position, and position is the thing a page boundary - * breaks -- a screenshot loaded on one page and its call on the next read as unrelated. + * Images this call's result carried, drawn under it. Beside it they had to be paired by + * position, and position is what a page boundary breaks. */ val images: List = emptyList(), ) : TranscriptItem() { @@ -134,9 +123,8 @@ sealed class TranscriptItem { data class ImageItem(override val seq: Long, val ref: String) : TranscriptItem() /** - * A message another agent sent this session. - * - * Its own row rather than a [UserMsg]: see [PeerMessageRow] for why the voice matters. + * A message another agent sent this session. Its own row rather than a [UserMsg]: see + * [PeerMessageRow] for why the voice matters. */ data class PeerNote( override val seq: Long, @@ -145,11 +133,9 @@ sealed class TranscriptItem { /** * The seq of the event this note came in on, which is what makes it itself. * - * [seq] is where the note *sorts*, and [placePeerNote] sets it to the seq the turn began at - * so the note is drawn above the reply it caused. Two messages that arrive during one turn - * therefore share a seq -- and sharing an identity as well killed the app, because the - * transcript list refuses two items with one key. Two agents writing to a session mid-turn - * is an ordinary afternoon, not a corner. + * [seq] is where the note *sorts*, and [placePeerNote] sets it to the seq the turn began + * at. Two messages that arrive during one turn therefore share a seq -- and sharing an + * identity as well killed the app, because the list refuses two items with one key. */ val arrived: Long = seq, ) : TranscriptItem() { @@ -158,10 +144,9 @@ sealed class TranscriptItem { } /** - * A command the session ran on itself -- `/compact`, `/rename`. - * - * Kept in the transcript rather than only shown while it waits, because it explains what - * follows: a conversation that suddenly has half the context, or a session with a new name. + * A command the session ran on itself -- `/compact`, `/rename`. Kept in the transcript rather + * than only shown while it waits, because it explains what follows: a conversation that + * suddenly has half the context, or a session with a new name. */ data class CommandRow(override val seq: Long, val text: String) : TranscriptItem() @@ -170,7 +155,6 @@ sealed class TranscriptItem { /** * A clear that happened: everything above it left the session's context and stayed on screen. - * * Carries only its position, because that is all it means. */ data class ClearedNote(override val seq: Long) : TranscriptItem() @@ -179,12 +163,12 @@ sealed class TranscriptItem { * A compaction that happened, and what it recovered. * * In the transcript rather than only in the status line, because the status is gone the moment - * it finishes and this is the part worth keeping: it is the explanation for a gap in the - * conversation, and for a minute or two in which the session was busy with nothing to show. + * it finishes and this is the part worth keeping: the explanation for a gap in the + * conversation. * - * The wire also says what triggered it, and this deliberately does not carry that: the row says - * the two sizes and nothing else (see [compactionSummary]), so keeping the trigger here would - * be a field nothing can read. + * The wire also says what triggered it, and this deliberately does not carry that -- the row + * says the two sizes and nothing else, so keeping the trigger would be a field nothing can + * read. */ data class CompactedNote( override val seq: Long, @@ -197,16 +181,13 @@ sealed class TranscriptItem { * The run a call joins: the one it lands next to, or a new one named after itself. * * Only ever consulted when the call is first folded in. That is what makes the name stable -- a run - * keeps whatever it was called when it started, however many calls arrive at either end of it - * afterwards. + * keeps whatever it was called when it started, however many calls arrive at either end afterwards. * * A question to the reader is in a run of its own, which is what puts it on the transcript as a row - * rather than inside a collapsed "Called 6 tools" card. Two things follow from being alone: it is - * always visible, since a run of one is drawn as itself rather than as a group; and the calls - * around it fall into a group before it and a group after it, so where the reader was asked - * something is legible in the shape of the transcript without opening anything. It ends the run - * before it as well as starting a fresh one after -- the moment somebody was asked is a boundary in - * the work, not a gap in the middle of one run. + * rather than inside a collapsed "Called 6 tools" card. Two things follow: it is always visible, + * since a run of one is drawn as itself; and the calls around it fall into a group before it and a + * group after it, so where the reader was asked something is legible in the shape of the transcript + * without opening anything. */ private fun runIdFor(items: List, id: String, tool: String): String { val previous = items.lastOrNull() as? TranscriptItem.ToolRun ?: return id @@ -219,31 +200,23 @@ private fun runIdFor(items: List, id: String, tool: String): Str * boundary cut in two. * * Two things straddle a boundary: a tool call separated from its result, and a message separated - * from the rest of itself. Both were one thing before the transcript was cut into pages, and both - * have to be one thing again -- a reply drawn as two messages is the same defect as a call drawn - * twice, arriving from the same cause. + * from the rest of itself. Both were one thing before the transcript was cut into pages. * * A boundary lands wherever it lands, and roughly half the time that is between a call and its * result. The newer page then holds a `ToolEnd` whose start it never saw, which [foldEvent] draws * as a row of its own -- correctly, because a call that renders as nothing is indistinguishable * from one that never happened. When the older page arrives it brings the real `ToolStart`, and - * concatenating the two lists left *both*: the same call twice, once as a proper card and once as a - * nameless placeholder. Visible as a run of four calls reporting "Called 5 tools", and worse than - * the miscount -- the extra row is at the join, so it also moves everything the reader was looking - * at. + * concatenating the two lists left *both*: the same call twice. * * Merged by the call's own id rather than by position, because position is exactly what a page - * boundary destroys. The older row wins on what a start knows (the tool's name, its input) and the - * newer on what an end knows (the output, and whether it finished), which is the only way round - * that loses nothing. + * boundary destroys. The older row wins on what a start knows and the newer on what an end knows, + * which is the only way round that loses nothing. * * The third thing is the *run*, and it is the one that used to be missed. Every page ends up here, * but [adoptRun] only ran on the path where a split call had been found -- so the boundary that - * falls cleanly between two finished calls, which is most of them, went straight to concatenation - * and left the older page's calls under the run name they were folded with. On screen: one run of - * tool calls drawn as two groups, with the seam wherever the reader happened to have paged. The two - * early returns were an optimisation on a list the size of one page, and they were skipping work - * rather than saving it. + * falls cleanly between two finished calls, which is most of them, left the older page's calls + * under the run name they were folded with. On screen: one run of tool calls drawn as two groups, + * with the seam wherever the reader happened to have paged. */ fun joinPages(earlier: List, later: List): List { val (older, newer) = healSplitMessage(earlier, later) @@ -276,15 +249,13 @@ fun joinPages(earlier: List, later: List): List< /** * Rejoins a message the page boundary cut, and hands back the two pages to concatenate. * - * [foldEvent] never leaves two assistant messages next to each other inside one page -- deltas - * accumulate into the message before them -- so two meeting at a join are always the two halves of - * one reply, and leaving them apart drew a single answer as two, with a paragraph break through the - * middle of a sentence. + * [foldEvent] never leaves two assistant messages next to each other inside one page, so two + * meeting at a join are always the two halves of one reply, and leaving them apart drew a single + * answer as two with a paragraph break through the middle of a sentence. * - * The newer half keeps its identity, for the reason [adoptRun] gives: it is the row already on - * screen, and renaming that is how the list loses its anchor. It grows by what the older half - * brings, which is safe here and nowhere else -- the join is at the oldest end of what is loaded, - * so the growth extends off the top of the screen, away from the row the list anchors to. + * The newer half keeps its identity, for the reason [adoptRun] gives. It grows by what the older + * half brings, which is safe here and nowhere else -- the join is at the oldest end of what is + * loaded, so the growth extends off the top of the screen. */ private fun healSplitMessage( earlier: List, @@ -302,19 +273,18 @@ private fun healSplitMessage( * Hands the older calls at the join the name of the run they are joining. * * The two pages were folded separately, so a run split by the boundary came back as two runs with - * two names. Naming the joined run after the *older* half would be the obvious way round and is the - * wrong one: the newer half is the part already on screen, and renaming it is renaming the row the - * reader is looking at, which is how a list loses its anchor and steps under them. So the arriving - * calls take the name of the ones already there, and nothing visible changes identity. + * two names. Naming the joined run after the *older* half would be the obvious way round and is + * wrong: the newer half is the part already on screen, and renaming it is renaming the row the + * reader is looking at, which is how a list loses its anchor. */ private fun adoptRun( earlier: List, later: List, ): List { val first = later.firstOrNull() as? TranscriptItem.ToolRun ?: return earlier - // A question is in a run of its own on both sides of the join, the same as it would be had - // the two pages been folded as one -- see `runIdFor`. Without this the heal would merge a - // group straight through the row the reader was asked something on. + // A question is in a run of its own on both sides of the join, the same as it would be had the + // two pages been folded as one. Without this the heal would merge a group straight through the + // row the reader was asked something on. if (first.tool == ASK_USER_QUESTION) return earlier val joining = first.runId val tail = earlier.takeLastWhile { @@ -329,10 +299,8 @@ private fun adoptRun( * A peer message goes above the turn it started, not where it happened to arrive. * * The live Claude Code path cannot record it in place: the CLI says nothing about a peer message - * until the turn's `result`, so the event lands below the whole reply it caused -- the answer - * printed above the question. The server stamps it with where that turn began - * ([SessionEvent.PeerMessage.turnStart]) and the note takes that seq, so it sorts into the list - * where it belongs rather than being drawn out of order at the end. + * until the turn's `result`, so the event lands below the whole reply it caused. The server stamps + * it with where that turn began and the note takes that seq. * * Taking the turn's opening seq as its own is also what keeps the list sorted, which anchors and * paging both depend on. It is only a *position*, though, and the note keeps its own arrival seq as @@ -340,8 +308,7 @@ private fun adoptRun( * seq belongs to a status change and a status draws no row -- true, and it answered the wrong * question: what two notes stamped with the same turn collide with is each other. * - * Without a stamp -- a message replayed out of a session file, which is already in the right place - * -- it stays where it arrived. + * Without a stamp -- a message replayed out of a session file -- it stays where it arrived. */ private fun placePeerNote( items: List, @@ -360,15 +327,14 @@ private fun placePeerNote( * The calls the note now sits in front of, renamed if they were sharing a run with the calls behind * it. * - * A run is named from what a call landed next to (see [runIdFor]), and nothing there knows about - * turns -- so a turn opening with a tool call, straight after one that ended with one, folds them - * into a single run. Left alone, [groupToolRuns] would flush at the note and hand both halves the - * same name: two rows with one key, which a keyed list cannot draw at all. + * A run is named from what a call landed next to, and nothing there knows about turns -- so a turn + * opening with a tool call, straight after one that ended with one, folds them into a single run. + * Left alone, [groupToolRuns] would flush at the note and hand both halves the same name: two rows + * with one key, which a keyed list cannot draw at all. * * The later half is the one renamed, which is the opposite of a page join ([adoptRun]) and right - * for the opposite reason. There the two halves were always one run and the newer was already on - * screen; here they were never one turn's work, and both halves change appearance at the same - * moment the note appears between them. + * for the opposite reason: there the two halves were always one run, here they were never one + * turn's work. */ private fun splitRun(tail: List, behind: String?): List { val first = tail.firstOrNull() as? TranscriptItem.ToolRun ?: return tail @@ -406,12 +372,10 @@ fun foldEvent(items: List, entry: SeqEvent): List updateTool(items, event.id) { it.copy(output = event.output) } is SessionEvent.ToolEnd -> - // Created when its start is not here, rather than dropped. A - // fold that only ever *updates* loses the whole call when the - // start fell outside the loaded window, and a tool call that - // renders as nothing is indistinguishable from one that never - // happened. The name is unknown from an end alone; loading the - // page before this one replaces the row with the real thing. + // Created when its start is not here, rather than dropped. A fold that only ever + // *updates* loses the whole call when the start fell outside the loaded window, and a + // tool call that renders as nothing is indistinguishable from one that never happened. + // Loading the page before this one replaces the row with the real thing. if (items.any { it is TranscriptItem.ToolRun && it.id == event.id }) { updateTool(items, event.id) { it.copy(output = event.output, done = true) } } else { @@ -419,9 +383,8 @@ fun foldEvent(items: List, entry: SeqEvent): List, entry: SeqEvent): List, entry: SeqEvent): List - // Resolved wherever it is drawn: a card of its own, or a tool - // row's ask. Missing the second left an Allow/Deny pair live on - // a question already answered from another device. + // Resolved wherever it is drawn: a card of its own, or a tool row's ask. Missing the + // second left an Allow/Deny pair live on a question already answered from another + // device. items.map { when { it is TranscriptItem.QuestionCard && it.id == event.id -> @@ -475,20 +437,19 @@ fun foldEvent(items: List, entry: SeqEvent): List items + TranscriptItem.CommandRow(entry.seq, event.text) // Screen-level state, not transcript rows -- see SessionScreen. is SessionEvent.CommandQueued -> items - // No row of its own: a message that is still waiting is drawn as a pending bubble below - // the transcript, and becomes an ordinary one where the session read it. + // No row of its own: a message that is still waiting is drawn as a pending bubble below the + // transcript, and becomes an ordinary one where the session read it. is SessionEvent.MessageQueued -> items - // The bubble goes away and nothing takes its place: the message was never read, so there - // is nothing it belongs above. + // The bubble goes away and nothing takes its place: the message was never read, so there is + // nothing it belongs above. is SessionEvent.MessageDropped -> items is SessionEvent.Settings -> items is SessionEvent.Status -> settleReply(items, event.state) is SessionEvent.Error -> items + TranscriptItem.ErrorMsg(entry.seq, event.message) is SessionEvent.Image -> - // Under the call that produced it when there is one, and a row of - // its own when there is not -- a person's own attachment belongs - // to no call, and neither does one whose call fell outside the - // loaded window. + // Under the call that produced it when there is one, and a row of its own when there is + // not -- a person's own attachment belongs to no call, and neither does one whose call + // fell outside the loaded window. if ( event.about != null && items.any { it is TranscriptItem.ToolRun && it.id == event.about } @@ -508,9 +469,8 @@ fun foldEvent(items: List, entry: SeqEvent): List, state: String): List { if (sessionWorking(state)) return items @@ -533,8 +493,7 @@ private fun updateTool( * The default dispatcher sizes itself to the machine, which is right for work somebody is waiting * on and wrong for work nobody is. A page of history is hundreds of parses arriving at once, and * taking every core for them leaves the thread that draws the frame queueing behind one -- measured - * on a Pixel 9 Pro XL as 21ms of `waited` at the 90th percentile, which is the frame failing to - * *start* rather than taking too long once it had. + * on a Pixel 9 Pro XL as 21ms of `waited` at the 90th percentile. */ @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) private val parsingThreads = Dispatchers.Default.limitedParallelism(2) @@ -543,37 +502,33 @@ private val parsingThreads = Dispatchers.Default.limitedParallelism(2) * Parses the markdown among [rows], off whatever thread is drawing. * * Called where a page of transcript is folded rather than where a row is composed, which is the - * whole point: the work happens seconds before the reader reaches the rows it was done for. See - * [ParsedReplies]. + * whole point: the work happens seconds before the reader reaches the rows it was done for. * * What is warmed mirrors what the rows draw -- each prose part of a reply, a memory note, a peer - * message, every one of them whole, since every piece of a message is drawn from its one parse -- - * because a string warmed under a key no row ever looks up is a miss that nothing reports; see - * [transcriptUnits], which is the flatten this has to agree with. It reads the same + * message -- because a string warmed under a key no row ever looks up is a miss that nothing + * reports; see [transcriptUnits], which is the flatten this has to agree with. It reads the same * [ParsedReplies.partsOf] cache the flatten does, so a message is scanned once however many pages - * hand it back through here, while the whole loaded transcript crosses this on every page. + * hand it back through here. * * Every kind of row that draws markdown belongs in the `when` below. That is the rule the peer * message was missing: this used to filter for assistant replies alone, so the one row type nobody - * had thought about paid its whole parse in the frame it appeared in, with no counter saying which - * row it was. + * had thought about paid its whole parse in the frame it appeared in. */ suspend fun warm(replies: ParsedReplies, rows: List) { withContext(parsingThreads) { val texts = rows.flatMap { row -> when (row) { is TranscriptItem.AssistantMsg -> replies.partsOf(row.text).map { it.text } - // A message from another agent is markdown too, and it is the longest thing - // in a transcript often enough that leaving it out was the whole of why one - // cost a fifth of a second to open: it was the only markdown in the app - // parsed on the thread that draws. + // A message from another agent is markdown too, and it is the longest thing in a + // transcript often enough that leaving it out was the whole of why one cost a fifth + // of a second to open. is TranscriptItem.PeerNote -> listOf(row.text) else -> emptyList() } } if (texts.isNotEmpty()) replies.warm(texts) - // After the parses exist, not before: [ParsedReplies.splitReady] is the flatten's - // licence to draw these as blocks on the composing thread. + // After the parses exist, not before: [ParsedReplies.splitReady] is the flatten's licence + // to draw these as blocks on the composing thread. rows.forEach { if (it is TranscriptItem.AssistantMsg) replies.markSplitReady(it.text) } } } diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptList.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptList.kt index 35ec86a..6e271fa 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptList.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptList.kt @@ -25,31 +25,24 @@ import androidx.compose.ui.unit.dp * zero is the newest content and sits at the bottom, so a message arriving extends the end the * viewport is pinned to and following it is not an effect -- and a page of older history lands at * indices past everything visible, which moves nothing on screen. The keyboard is the same case - * from the other side: the viewport shrinks and the anchored item stays against its bottom edge. A - * conversation shorter than the screen stacks from the bottom, hanging from the composer. + * from the other side: the viewport shrinks and the anchored item stays against its bottom edge. * * The lazy list is also the whole of the windowing. Only what is near the viewport is composed and * alive, so the per-frame cost is bounded by the screen rather than by how much is loaded -- the * property a plain column here had to approximate with retained ranges and stand-in spacers, each - * of which was a way to flicker. An item the framework composes is drawn the same frame it is - * placed, and an item off screen is not a node at all. + * of which was a way to flicker. * * What keeps a unit's arrival cheap enough to happen mid-fling: a unit is at most one block of a - * reply, and its parse is already made by [warm] before the fold that introduces it -- so entering - * composition costs laying out one paragraph, not parsing a message. + * reply, and its parse is already made by [warm] before the fold that introduces it. * - * The whole list sits in a [SelectionContainer], which is what makes every word in the transcript - * selectable by the platform's own press-and-hold. Here rather than at each place text is drawn: a - * transcript is one body of text to a reader, and a container per row would mean a selection could - * never cross from a reply into the tool output that follows it -- and would leave whatever was - * drawn without one silently unselectable, which is a state nothing on screen reports. Rows keep - * their tap handlers: selection is a long press, and the container passes an ordinary click through - * to the card under it. + * The whole list sits in a [SelectionContainer], which is what makes every word selectable by the + * platform's own press-and-hold. Here rather than at each place text is drawn: a transcript is one + * body of text to a reader, and a container per row would mean a selection could never cross from a + * reply into the tool output that follows it -- and would leave whatever was drawn without one + * silently unselectable. Rows keep their tap handlers: selection is a long press. * * [selection] is the container's own state, held by the caller rather than made here, because the - * rows have to be able to ask whether anything is selected before they act on a tap -- a tap whose - * job is to put a selection away is not also a tap on the card under it. See the caller's - * `expanding`. + * rows have to be able to ask whether anything is selected before they act on a tap. */ @Composable fun TranscriptList( @@ -69,8 +62,7 @@ fun TranscriptList( modifier = // Timed in two halves because the frame's draw phase is where Compose's measurement // lands, and "draw is high while nothing is being recorded" does not say which - // half; - // see [drawAccounting]. Measure includes composing the items that scrolled in. + // half. Measure includes composing the items that scrolled in. modifier .layout { measurable, constraints -> val started = System.nanoTime() @@ -104,8 +96,7 @@ fun TranscriptList( } // Standing in for everything not fetched yet. Only here while there is more -- its // appearance at the top edge is also roughly when the next page is asked for, so what - // it - // reports is a fetch in flight rather than an end reached. + // it reports is a fetch in flight rather than an end reached. if (moreHistory) { item(key = "history", contentType = "history") { Box(Modifier.fillMaxWidth().padding(vertical = 24.dp)) { diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptSource.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptSource.kt index b716009..8a7bdcd 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptSource.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptSource.kt @@ -9,14 +9,12 @@ import java.util.concurrent.atomic.AtomicReference * rest. * * One seam rather than a cache the screen has to remember to consult. Everything it fetched before - * -- the opening window, the pages it scrolls back through, the span an anchor restore reaches for - * -- is asked of this, and everything the server sends is written into the cache on the way past, - * so the screen never learns which side answered. What it does learn, through [DebugStats], is how - * often each one did, which is how the saving is measured. + * is asked of this, and everything the server sends is written into the cache on the way past, so + * the screen never learns which side answered. What it does learn, through [DebugStats], is how + * often each one did. * - * See TRANSCRIPT_CACHE.md. The one rule worth keeping in mind here: the cache is never - * load-bearing. Every read has a network path beside it producing the same result, so a missing, - * evicted or damaged cache degrades to exactly what this screen did before it existed. + * See TRANSCRIPT_CACHE.md. The one rule worth keeping in mind: the cache is never load-bearing. + * Every read has a network path beside it producing the same result. */ class TranscriptSource( private val settings: ServerSettings, @@ -51,18 +49,17 @@ class TranscriptSource( * * The screen must not resume a stream from a cached seq unless it is the same conversation. A * transcript is append-only in ordinary use, but the file can be replaced or truncated -- a - * sandbox re-seeded with the same ids, a backup restored, a directory deleted and the session - * re-imported -- and the server's catch-up on such a file would hand this phone a continuation - * of a *different* conversation, spliced onto the cached one with no seam. That is the worst - * thing this feature can do, and it is caught with one request of a few hundred bytes, in the - * slot the opening page's request used to be in. + * sandbox re-seeded with the same ids, a backup restored, a session re-imported -- and the + * server's catch-up on such a file would hand this phone a continuation of a *different* + * conversation, spliced onto the cached one with no seam. Caught with one request of a few + * hundred bytes, in the slot the opening page's request used to be in. * * False purges the cache and means "open cold". A throw is the server not being askable, which - * is neither: the cached rows stay on screen, the failure goes on the stream banner, and the - * caller tries again on the stream's own reconnect schedule. + * is neither: the cached rows stay on screen and the caller tries again on the reconnect + * schedule. * * What this cannot see is a line changed in the middle of the file with the tail intact. That - * is what the Reload button in session settings is for, and its caption says so. + * is what the Reload button in session settings is for. */ suspend fun probe(): Boolean { val tail = cache.tail() ?: return false @@ -100,8 +97,7 @@ class TranscriptSource( * row count takes it -- a single reply is hundreds of lines -- so a page fetched after the * reader has been away would run straight past the cached run and overlap it, and an * overlapping page cannot be stored. Told where this phone's copy starts, the server stops - * there instead, the gap is closed with exactly the bytes it was wide, and the history behind - * it is served locally from then on. + * there instead. */ suspend fun page(before: Long, limit: Int, coalesce: Boolean): List { cache.page(before, limit, rows = coalesce)?.let { lines -> @@ -169,10 +165,8 @@ private const val OPENING_WINDOW = 80 * * Under `cacheDir` because that is exactly what it is for: bytes the phone can regenerate from the * server, which Android may delete under storage pressure without asking. Keyed by host and port - * because two servers can hold a session with the same id -- the sandbox and the real server, or a - * re-enrolment -- and a line from one shown against the other is the whole invariant broken. `v1` - * is the layout's version: a change to it bumps the segment, and a directory of another version is - * deleted the first time this is called. + * because two servers can hold a session with the same id, and a line from one shown against the + * other is the whole invariant broken. `v1` is the layout's version. */ fun cacheRoot(context: Context, settings: ServerSettings): File { val transcripts = File(context.cacheDir, "transcripts") diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptUnits.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptUnits.kt index 520951a..2733f54 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptUnits.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptUnits.kt @@ -12,8 +12,7 @@ import androidx.compose.ui.unit.dp * at the moment it scrolls into view, and that cost is proportional to the item -- a reply can be * twenty-five screens of markdown, which as one item is a hundred-millisecond frame exactly when * the list is moving fastest. A *block* is a paragraph, a fence, a table: bounded, so the worst - * frame is bounded. This is the piece that was missing when a lazy list was last tried here; the - * block splitting existed only inside the row, where the list could not see it. + * frame is bounded. This is the piece that was missing when a lazy list was last tried here. * * Everything else about the row model is unchanged: rows come from [groupToolRuns], and a unit * points back at its row. The list draws units; anchors and paging still speak seq. @@ -27,10 +26,9 @@ sealed class TranscriptUnit { abstract val seq: Long /** - * This unit's position within its row, counted from the row's oldest end. - * - * What a saved scroll position carries besides the seq: a reply split into forty blocks needs - * more than "somewhere in this row" to put a reader back where they stopped. + * This unit's position within its row, counted from the row's oldest end. What a saved scroll + * position carries besides the seq: a reply split into forty blocks needs more than "somewhere + * in this row" to put a reader back where they stopped. */ abstract val ordinal: Int @@ -66,15 +64,13 @@ sealed class TranscriptUnit { * * A peer message is the one row whose *opened* size is unbounded -- these are the longest * things a transcript holds -- so it is flattened the same way a settled reply is, and for the - * same reason: as one item, every block of it is composed, measured, placed and kept alive - * while any part of it is on screen. Measured on the emulator, opening a 43KB one took the - * transcript's share of the draw phase from 0.81ms a frame to 3.85ms, and the framework's own - * per-frame bookkeeping -- which grows with how many nodes are *alive* -- from 0.39ms to - * 3.15ms. + * same reason. Measured on the emulator, opening a 43KB one took the transcript's share of the + * draw phase from 0.81ms a frame to 3.85ms, and the framework's own per-frame bookkeeping from + * 0.39ms to 3.15ms. * * The card is drawn in pieces rather than given up: a filled Material card is elevation zero, * so it has no shadow to break, and each piece paints the same fill with only the corners it - * owns. See [PeerHeadRow] and [PeerBlockRow]. + * owns. */ data class PeerHead( override val seq: Long, @@ -84,8 +80,7 @@ sealed class TranscriptUnit { ) : TranscriptUnit() { /** * The note's own key, so opening and shutting does not change what the list is anchored on - * -- and so two notes stamped with one turn's seq are still two items. See - * [TranscriptItem.PeerNote]. + * -- and so two notes stamped with one turn's seq are still two items. */ override val key: Any get() = item.key @@ -119,8 +114,7 @@ sealed class TranscriptUnit { * * A user message is plain text, so cutting it costs a scan rather than a parse -- but the * reason is the same as for a settled reply: as one item, a pasted log is a hundred thousand - * pixels of `Text` whose layout lands in the frame the row scrolls into. Measured as the - * `measure: the whole transcript ... 112.1ms worst` in an otherwise smooth report. + * pixels of `Text` whose layout lands in the frame the row scrolls into. */ data class UserChunk( override val seq: Long, @@ -152,14 +146,11 @@ sealed class TranscriptUnit { * The rows flattened into list units, newest first -- index zero is the item at the bottom of the * screen, which is what a reversed lazy list calls the start. * - * Every settled reply is cut into its pieces ([pieces], via the caches on [replies] so a message is - * only ever cut once), and so is an *opened* peer message -- [openNotes] is which ones those are, - * which is why the flatten needs it. A shut one is a single heading and cannot be worth splitting. - * The reply still arriving -- the newest row, until the status event that ends its turn marks it - * [TranscriptItem.AssistantMsg.settled] -- stays whole: its text changes with every delta, and - * splitting it here would parse the whole message per delta on whichever thread is composing. - * [AssistantMessage]'s own streaming path already parses deltas off the main thread and gives the - * live message a layer per piece. Once settled it splits like every other reply, which is what + * Every settled reply is cut into its pieces (via the caches on [replies] so a message is only ever + * cut once), and so is an *opened* peer message -- [openNotes] is which ones those are. A shut one + * is a single heading and cannot be worth splitting. The reply still arriving stays whole: its text + * changes with every delta, and splitting it here would parse the whole message per delta on + * whichever thread is composing. Once settled it splits like every other reply, which is what * bounds the newest row's cost after a session ends on a long one. * * Runs per fold, so it must stay proportional to what is loaded with no parsing in it on the warm @@ -200,8 +191,8 @@ fun transcriptUnits( } } } else if (item is TranscriptItem.UserMsg && item.text.length > USER_SPLIT_CHARS) { - // A scan, not a parse, so it is cheap enough for the fold path -- and cached like - // the markdown splits so the scan too happens once per message, not once per fold. + // A scan, not a parse, so it is cheap enough for the fold path -- and cached like the + // markdown splits so the scan happens once per message rather than once per fold. val chunks = replies.chunksOf(item.text) chunks.forEachIndexed { at, chunk -> units += @@ -252,8 +243,8 @@ fun transcriptUnits( } units.reverse() reportDuplicateKeys(units) - // Timed because this runs per fold on the composing thread: "loading messages feels bumpy" - // is this number growing, and it was invisible until it was written down. + // Timed because this runs per fold on the composing thread: "loading messages feels bumpy" is + // this number growing, and it was invisible until it was written down. DebugStats.record("units flattened", System.nanoTime() - started) return units } @@ -261,9 +252,8 @@ fun transcriptUnits( /** * Whether this reply should be drawn as blocks: settled, or anywhere but the newest row. * - * Wanting is not being ready -- the flatten also asks [ParsedReplies.splitReady], and the two - * questions are separate because they are answered by different things: this one by the fold, the - * other by whether [warm] has run for the text. [unwarmedReplies] is the gap between them. + * Wanting is not being ready -- the flatten also asks [ParsedReplies.splitReady], and the two are + * answered by different things: this one by the fold, the other by whether [warm] has run. */ private fun splitWanted(item: TranscriptItem.AssistantMsg, index: Int, lastIndex: Int) = item.settled || index != lastIndex @@ -272,8 +262,7 @@ private fun splitWanted(item: TranscriptItem.AssistantMsg, index: Int, lastIndex * The replies among [rows] that should draw as blocks but whose parses are not made yet. * * Normally empty: every page's rows are warmed before the fold lands. The one row that can be cold - * is the reply that just finished streaming -- nothing warms live deltas, so at the moment its turn - * ends its split would cost a whole-message parse on the composing thread. The session screen warms + * is the reply that just finished streaming -- nothing warms live deltas. The session screen warms * what this returns off-thread and re-flattens, so the whole-to-blocks swap always composes against * ready parses. */ diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/UniqueItems.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/UniqueItems.kt index d53df3b..5794ff6 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/UniqueItems.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/UniqueItems.kt @@ -12,19 +12,15 @@ import androidx.compose.runtime.Composable * on the main thread -- so it is not an error the screen can show, it closes the app. That is a * disproportionate answer to a list with a repeat in it, and it lands on the reader rather than on * whoever produced the repeat: on 2026-08-31 the import list crashed on a Claude Code session id - * recorded under two project directories, which is an ordinary state of a machine and not something - * the phone did. + * recorded under two project directories, which is an ordinary state of a machine. * * Every list in this app keyed on an id keyed it on an id *the server chose*, so all of them shared - * the hazard and none of them could rule it out locally. Hence one function they all go through - * rather than a `distinctBy` remembered at each call site. + * the hazard and none could rule it out locally. Hence one function they all go through. * - * Dropping the repeat is the right answer here because the key is the whole identity: two rows with - * one id are two rows every action would treat as the same thing, so there is nothing to show about - * the second that the first is not already showing. Where the duplicate means something -- the - * import list's did -- the fix belongs at the source, and this is only what stops a data problem - * from being a crash. It is counted so the render report says it happened rather than leaving a - * silently shorter list. + * Dropping the repeat is right here because the key is the whole identity: two rows with one id are + * two rows every action would treat as the same thing. Where the duplicate means something, the fix + * belongs at the source, and this is only what stops a data problem from being a crash. It is + * counted so the render report says it happened rather than leaving a silently shorter list. * * The transcript's own list is deliberately not on this: its keys are made here rather than * received, and it is the one list where an extra pass over the items is measurable. diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/UsageDialog.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/UsageDialog.kt index b2bf44d..a95e071 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/UsageDialog.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/UsageDialog.kt @@ -27,17 +27,14 @@ import java.time.OffsetDateTime * A dialog rather than a screen. Usage is something you check *against* what you were reading -- * "can I start this" is asked with the transcript still on screen -- and pushing a whole screen for * it took the session away to answer a question about the session. It also has no navigation of its - * own: there is nothing here to open, so the only thing its Back could ever have meant was "put - * this away", which is what dismissing does. The system back gesture dismisses it, since a `Dialog` - * handles that itself. + * own, so the only thing its Back could ever have meant was "put this away". */ @Composable fun UsageDialog(feed: UsageFeed, onDismiss: () -> Unit) { - // A plain Dialog rather than an AlertDialog, for the spacing alone. AlertDialog fixes the - // gaps between its title, its content and its buttons at sizes meant for a sentence of prose - // and a decision; this is a dense read-out, and those gaps left a band of empty dialog above - // Close that was taller than a bar. Everything else here is what AlertDialog would have - // drawn -- the same container colour, the same corner -- so nothing about it looks foreign. + // A plain Dialog rather than an AlertDialog, for the spacing alone. AlertDialog fixes the gaps + // between its title, content and buttons at sizes meant for a sentence of prose and a decision; + // this is a dense read-out, and those gaps left a band of empty dialog above Close that was + // taller than a bar. Dialog(onDismissRequest = onDismiss) { Surface( shape = MaterialTheme.shapes.extraLarge, @@ -49,11 +46,9 @@ fun UsageDialog(feed: UsageFeed, onDismiss: () -> Unit) { modifier = Modifier.fillMaxWidth(), ) { // Deliberately not subtitled with the provider this was opened from. These - // numbers 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 line reading "claude", which is a - // claim about echo that nothing measured. Each machine names itself and the - // service it came from, which is the true scope. + // numbers belong to an account on a particular machine -- naming the session's + // provider here made an echo session's screen read "echo" above a line reading + // "claude". Each machine names itself and the service it came from. Text( "Usage", style = MaterialTheme.typography.headlineSmall, @@ -69,10 +64,9 @@ fun UsageDialog(feed: UsageFeed, onDismiss: () -> Unit) { } } Spacer(Modifier.height(8.dp)) - // Scrolls rather than being trimmed: a machine can report any number of windows - // and there can be any number of machines, and a dialog is the one place where - // running out of room is silent. `fill = false` so a short read-out keeps a short - // dialog instead of stretching to the window. + // Scrolls rather than being trimmed: a machine can report any number of windows and + // there can be any number of machines, and a dialog is the one place where running + // out of room is silent. `fill = false` so a short read-out keeps a short dialog. Column(Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState())) { UsageBody(feed.snapshots) } @@ -93,8 +87,8 @@ private fun UsageBody(state: LoadState>) { is LoadState.Error -> Text(current.message, color = MaterialTheme.colorScheme.error) is LoadState.Loaded -> 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. + // 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, @@ -103,17 +97,16 @@ private fun UsageBody(state: LoadState>) { } else { // No card around each machine. A card is a step up the surface ladder, and // inside a dialog -- itself a raised surface -- the step barely renders while - // costing 16dp of padding on every side. What separates one machine from the - // next is the line naming it, which is enough for a list this short. + // costing 16dp on every side. What separates one machine from the next is the + // line naming it. current.value.forEachIndexed { index, snapshot -> if (index > 0) { Spacer(Modifier.height(20.dp)) } - // Machine and service on one line: which account these numbers belong to - // is decided by both together, and stacked as a heading over a subtitle - // they read as a section of their own rather than as the label they are. - // Small and quiet, because the numbers below are what somebody opened - // this to see. + // Machine and service on one line: which account these numbers belong to is + // decided by both together, and stacked as a heading over a subtitle they + // read as a section of their own. Small and quiet, because the numbers + // below are what somebody opened this to see. Text( "${snapshot.setupName.ifEmpty { snapshot.setup }} · ${snapshot.provider}", style = MaterialTheme.typography.bodySmall, @@ -121,8 +114,8 @@ private fun UsageBody(state: LoadState>) { ) SnapshotState(snapshot) snapshot.windows.forEachIndexed { windowIndex, window -> - // Between the bars, not after the last one: a trailing gap here is - // what put a band of empty dialog above the Close button. + // Between the bars, not after the last one: a trailing gap here is what + // put a band of empty dialog above the Close button. if (windowIndex > 0) { Spacer(Modifier.height(12.dp)) } @@ -139,8 +132,7 @@ private fun UsageBody(state: LoadState>) { * * 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. + * the interface nagging about a decision already made. Only the two faults are coloured as faults. */ @Composable private fun SnapshotState(snapshot: UsageSnapshot) { @@ -152,8 +144,8 @@ private fun SnapshotState(snapshot: UsageSnapshot) { 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". + // 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.", @@ -200,10 +192,9 @@ private fun WindowBar(window: UsageWindow) { /** * "resets in 3h 12m" -- close enough for deciding whether to start a big task -- or nothing. * - * Null for a window that is not running, which is the case this row has always drawn as nothing and - * is right to: there is no end to report. What it used to get wrong is the other missing case, a - * timestamp that arrived and could not be read: that was printed raw, so a parse failure appeared - * as an ISO string in the middle of a sentence written for a person. Both cases are named in + * Null for a window that is not running: there is no end to report. What this used to get wrong is + * the other missing case, a timestamp that arrived and could not be read -- printed raw, so a parse + * failure appeared as an ISO string in a sentence written for a person. Both are named in * [WindowEnd], and the session bar words them the same way. */ private fun resetLine(window: UsageWindow): String? = From 1fcaa2d72da3dfb1d004a36c0d883ccf69a2a3fa Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Fri, 4 Sep 2026 16:21:52 -0400 Subject: [PATCH 4/4] Complete routes.rs's table, which the docs now point at AGENTS.md and PLAN.md were both carrying their own copy of the HTTP surface, and the previous commit replaced those with a pointer to this module doc comment -- which turned out to be missing ten routes that exist: the four `/setups/{id}/importable*`, `/sessions/{id}/permission-mode` and all five under `/models`. Naming it the source of truth is only worth doing if it is one. The two "later phases add" lines at the foot are gone. Setups replaced `/hosts` in August and `/models` is the block just added above them, so both were promising work already done. cargo test (127), clippy --all-targets and fmt clean. Co-Authored-By: Claude Opus 5 --- server/src/routes.rs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/server/src/routes.rs b/server/src/routes.rs index 948ec16..6be359c 100644 --- a/server/src/routes.rs +++ b/server/src/routes.rs @@ -13,6 +13,10 @@ //! (409 when the file no longer matches ifSha256) //! POST /setups/{id}/file {path} create empty; refused if it exists //! POST /setups/{id}/dir {path} create; refused if it exists +//! GET /setups/{id}/importable Claude Code sessions on it that could be continued +//! POST /setups/{id}/importable/import {sessions} -> 202; runs on the server +//! POST /setups/{id}/importable/delete {sessions} -> 202; removes the machine's transcripts +//! GET /setups/{id}/importable/events SSE: what is in flight against them //! PUT /setups/{id} rename {name?} and/or re-probe {rediscover?} //! DELETE /setups/{id} remove, refused while sessions use it //! GET /sessions list (id, provider, title, model, status, last activity) @@ -36,6 +40,7 @@ //! POST /sessions/{id}/cwd {cwd} -- move it; stops the process, //! which starts again in the new one //! POST /sessions/{id}/model {model} +//! POST /sessions/{id}/permission-mode {permissionMode} //! POST /sessions/{id}/command {text} -- /compact, /clear, /rename x, or the dialect's own //! (starts the process first if it has exited) //! POST /sessions/{id}/compact @@ -47,9 +52,13 @@ //! GET /notifications SSE: every session's attention-wanting //! moments, live only (see `notifications`) //! GET /usage cached usage windows per provider +//! GET /models downloaded GGUFs, and what is being fetched +//! GET /models/search?q=Q HuggingFace repositories matching Q +//! GET /models/files?repo=R the GGUFs in one repository +//! POST /models/download {repo, file}; rejoins the run already going +//! POST /models/cancel {key}; the partial stays, so starting again resumes +//! POST /models/delete {key} //! ``` -//! Later phases add `GET|PUT /models` config editing from the phone. -//! 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).