diff --git a/AGENTS.md b/AGENTS.md index d46e571..cd01ade 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,982 +1,559 @@ # 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 - `./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 +- `server/` — the Rust backend (`ai-server`). `routes.rs`'s module doc + comment is the HTTP table and the surface's source of truth. + **A llama.cpp session runs on whatever machine its setup names** (built + 2026-09-04, the last of phase 5): `Transport::reserve_port` returns the + port the server binds *there* and the port that reaches it *here*, and + `Launch::reaching` puts the `-L` tunnel on the connection already carrying + the command. Three things fell out of it and are easy to get wrong again — + a forwarded launch gets a pty (`-tt`) and every other one keeps `-T`, + because `llama-server` never reads the stdin whose closing ends a CLI and + the same kill left it loaded on the far machine; the model is looked for on + the machine that will serve it, so the spawn screen offers + `GET /setups/{id}/models` rather than the backend's own downloads; and the + readiness poll watches the process as well as the port, since a model that + will not load exits in a second and was being reported as "gave up after + 300s". See PLAN.md's "Transport" and "llama-server management". +- `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. -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. +### Icons -**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. - -**Phase 4 (llama.cpp)** works end to end, phone included (2026-08-28), -**on any machine a setup names** (2026-09-04). 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. The conversation is rebuilt from the -**transcript** rather than kept in the driver, because driver memory is -invisible to a second device -- deliberate, and easy to undo by accident. - -A remote llama session is the same command through the same transport -plus the second half of what a transport is: `Transport::reserve_port` -hands back a port the server binds *there* and a port that reaches it -*here*, and the ssh connection carrying the command carries the `-L` -tunnel between them (`llama-server` binds loopback on the far machine, so -nothing is served to its network). Three things that came out of building -it, each of which is easy to get wrong again: - -- **A forwarded launch gets a pty (`-tt`); every other one keeps `-T`.** - Killing the ssh client ends a CLI because it closes the stdin that CLI - is reading. `llama-server` never reads its stdin, so the same kill left - it running on the far machine holding the model in memory -- measured - 2026-09-04, one orphan per stopped session. A pty is what makes sshd - hang the far side up. Its log then arrives through a line discipline, - which nothing parses. -- **The model is looked for on the machine that will serve it**, at that - machine's own models directory (`SshConfig::models_dir`, defaulting to - `~/.local/share/ai-app/models` expanded *there*). What this backend has - downloaded is on that machine only when they are the same machine, so - `GET /setups/{id}/models` is what the spawn screen offers rather than - `GET /models`, and a model that is not there is refused at the spawn - with a sentence saying so. Downloading *to* another machine is not - built; the file gets there however anything else does. -- **A readiness poll watches the process, not only the port.** A model - that will not load, a port already taken, a flag an older build does not - know: all of them exit within a second and none will ever answer - `/health`, so waiting out the 300s timeout turned the server's own - account of the problem into "gave up". The failure now carries the last - few lines of `llama-server.log`, which on a remote session is the only - copy anybody reading the phone can see. - -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. - -**`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. - -**llama.cpp is set up in this VM** (2026-09-04) and needs nothing typed: -the prebuilt CPU build is at `~/.local/opt/llama.cpp` (the 15 MB -`ubuntu-x64` release asset), symlinked as `/usr/local/bin/llama-server` so -that **discovery finds it over ssh too** -- `~/.local/bin` is not on the -PATH a non-interactive ssh session gets, which is why the symlink is -there and not only in `~/.local/bin`. It resolves its own libraries -through `$ORIGIN`, so no `LD_LIBRARY_PATH` is needed. One model is -downloaded, `unsloth/Qwen3-0.6B-GGUF/Qwen3-0.6B-Q8_0.gguf` (639 MB, under -`~/.local/share/ai-app/models`), which answers at usable speed on this -VM's 8 cores. - -**And the ssh path is exercisable here**, because this VM can ssh to -itself: the key is `~/.config/ai-app/ssh-self` (its public half is in -`~/.ssh/authorized_keys`, labelled removable), and a setup naming -`bob@127.0.0.1` with that `identityFile` plus -`options: ["StrictHostKeyChecking=no", "UserKnownHostsFile=/tmp/ai-app-known-hosts"]` -discovers `claude-cli` and `llama-cpp` on it. That is the whole rig for -"does a remote llama session work", since the far machine is this one and -the model file is the same file. **Do not test with a 2-bit quant**: the -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. +**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/`) + - `cargo clippy --all-targets` + `cargo fmt`. The build stays - warning-clean and rustfmt-clean at the defaults — there is no +- **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 +- **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 rate-limit bar belongs to a session's provider, not to its - machine.** One machine offers echo, the Claude CLI and a local model at - once and only the CLI spends anything, so a session says which meter - reports on it (`usageProvider`, from `DriverKind::usage_provider`, which - `usage::providers_for` reads too so the two lists cannot disagree) and - the phone matches a snapshot on machine *and* provider. Nothing meters - a llama or echo session, and the phone draws **nothing at all** for one - -- not a zero, and not "unknown". The bar also draws nothing while the - first fetch is out: "checking" under a session that turns out to meter - nothing is a row the screen then has to withdraw. -- **`/usage` in an echo session puts up an invented meter**, which is how - those screens' states are reached without spending quota: - `/usage 42`, `/usage 95 20` (minutes left), `/usage 42 never` (the - between-blocks window with no reset time), `/usage 42 unreadable`, - `/usage notloggedin`, `/usage unreachable`, `/usage failed`, - `/usage off`. The vocabulary is `usage::Fixture`'s, since those are its - states; with none set an echo session meters nothing, which is the - ordinary case. -- **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. + :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. 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. + 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 — 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 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 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. -- **`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. +- **`/usage` in an echo session puts up an invented meter**, which is how the + rate-limit screens' states are reached without spending quota: `/usage 42`, + `/usage 95 20` (minutes left), `/usage 42 never` (the between-blocks window + with no reset time), `/usage 42 unreadable`, `/usage notloggedin`, + `/usage unreachable`, `/usage failed`, `/usage off`. The vocabulary is + `usage::Fixture`'s, since those are its states. With none set an echo + session meters nothing, which is the ordinary case and draws no bar. - **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. + `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. + +### Driving the UI + +**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: + + 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. + +**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*. + +**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 + +**Both are set up here as of 2026-09-04** and need nothing typed. The +prebuilt CPU llama.cpp lives outside the repo at `~/.local/opt/llama.cpp` +(the 15 MB `ubuntu-x64` release asset) and is symlinked as +`/usr/local/bin/llama-server`, which is what makes **discovery find it over +ssh**: `~/.local/bin` is not on the PATH a non-interactive ssh session gets. +It resolves its own libraries through `$ORIGIN`, so no `LD_LIBRARY_PATH` is +needed. One model is downloaded — `unsloth/Qwen3-0.6B-GGUF/Qwen3-0.6B-Q8_0.gguf`, +639 MB under `~/.local/share/ai-app/models` — and answers at usable speed on +this VM's 8 cores. **Do not test with a 2-bit quant**: the +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. + +There is no second machine, so **ssh this VM to itself**. That is set up +too: the key is `~/.config/ai-app/ssh-self` (its public half is in +`~/.ssh/authorized_keys`, labelled removable), and the real config carries a +setup called **"this vm over ssh"** — `bob@127.0.0.1` with that +`identityFile` plus +`options: ["StrictHostKeyChecking=no", "UserKnownHostsFile=/tmp/ai-app-known-hosts"]` +so it touches nothing real — offering `claude-cli` and `llama-cpp`. It is the +whole rig for "does a remote llama session work", since the far machine is +this one and the model file is the same file. For a throwaway setup of your +own, 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. 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. + +- **A rate-limit bar belongs to a session's provider, not to its machine.** + One machine offers echo, the Claude CLI and a local model at once and only + the CLI spends anything, so a session says which meter reports on it + (`usageProvider`, from `DriverKind::usage_provider`, which + `usage::providers_for` reads too so the two lists cannot disagree) and the + phone matches a snapshot on machine *and* provider. Nothing meters a llama + or echo session, and the phone draws **nothing** for one — not a zero, and + not "unknown". Nothing while the first fetch is out either: "checking" + under a session that turns out to meter nothing is a row the screen then + has to withdraw. ## 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. + `"""\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. - **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 + 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. + 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 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`.** + 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`, 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. -- **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. + 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 7cdcd55..d8185bf 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,1136 +28,869 @@ 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 runs on whatever machine its setup names** (2026-09-04, + the last of phase 5). A transport is "run this" plus "reach this port", and + the second half is `Transport::reserve_port` — the port the server binds + *there* and the port that reaches it *here*, the same number locally — + carried by `Launch::reaching` onto the connection that already runs the + command. `llama-server` binds loopback on the far machine, so nothing is + served to its network. The far port is a guess from a range below the + ephemeral one, because no portable way to ask a machine for a free port + avoids racing the bind anyway; a collision is not silent, since the server + fails to bind and the readiness poll reports what its log said. +- **The model file lives on the machine that serves it** (2026-09-04). Each + setup names its own models directory (`SshConfig::models_dir`, default + `~/.local/share/ai-app/models` expanded *there*), and a spawn resolves the + key on that machine — one round trip answering "at /abs/path" or "missing", + so a model that is not there is refused at the spawn rather than becoming a + server that never becomes ready. The spawn screen offers + `GET /setups/{id}/models`, that machine's list, rather than `GET /models`, + which is this backend's downloads. Downloading *to* another machine is + deliberately not built: a multi-gigabyte transfer with no progress + anywhere, and the file gets there however anything else on that machine + did. +- **The readiness poll watches the process, not only the port.** A model that + will not load, a port already taken, a flag an older build does not know: + all exit within a second and none will ever answer `/health`, so waiting + out the 300s timeout turned the server's own account of the problem into + "gave up". The failure carries the tail of `llama-server.log`, which on a + remote session is the only copy anybody reading the phone can see. + +### 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. +- **A forwarded launch gets a pty and every other one does not** (measured + 2026-09-04). Killing the ssh client ends a CLI because it closes the stdin + that CLI is reading; `llama-server` never reads its stdin, so the same kill + left it running on the far machine with the model loaded — one orphan per + stopped session. With `-tt` the far side takes SIGHUP when the connection + goes. Its log then arrives through a line discipline, which nothing parses. + `-T` stays everywhere else, where a pty would rewrite the JSONL. +- **`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. Built - 2026-09-04, held by the session's own ssh client rather than by a - manager: there is one server per session (not per `(host, model)`), so - the process that runs it is the process that owns the tunnel, and the - two die together. -- **The model file lives on the machine that serves it** (2026-09-04). - Each setup names its own models directory (`SshConfig::models_dir`, - default `~/.local/share/ai-app/models` expanded on that machine), and a - spawn resolves the key there — one round trip that answers "at - /abs/path" or "missing", so a model that is not there is refused at the - spawn instead of becoming a server that never becomes ready. The spawn - screen offers `GET /setups/{id}/models`, which is that machine's list, - rather than `GET /models`, which is the backend's downloads. Downloading - *to* another machine is deliberately not built: it would be a - multi-gigabyte transfer with no progress anywhere, and the file gets - there however anything else on that machine got there. - -### 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. **Built 2026-09-04**: `Transport::reserve_port` returns - a `Forward { there, here }` — the port the program binds on its own - machine and the port that reaches it from the backend, the same number - when that machine is this one — and `Launch::reaching` carries it, so - the connection that runs the command also carries the tunnel. The far - end is a guess from a range below the ephemeral one, because no - portable way to ask a machine for a free port avoids racing with the - bind anyway; a collision is not silent, since the program fails to bind - and the readiness poll reports what its log said. -- **A forwarded launch gets a pty and every other one does not** (measured - 2026-09-04). Killing the ssh client ends a CLI because it closes the - stdin that CLI is reading; `llama-server` never reads its stdin, so the - same kill left it running on the far machine with the model loaded — - one orphan per stopped session. With `-tt` the far side takes SIGHUP - when the connection goes. Its log then arrives through a line - discipline, which nothing parses. `-T` stays everywhere else, where a - pty would rewrite the JSONL. -- 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 provider, not per machine (decided 2026-09-04).** A machine is not -what is metered; the provider a session runs is. One machine offers echo, -the Claude CLI and a local model side by side, and only the second of them -spends anything — so pairing a session with a snapshot by machine alone -drew the CLI's five-hour window under every echo session on it, reporting -a quota that session cannot spend and could never run down. A session now -names its meter (`usageProvider`, from `DriverKind::usage_provider`, which -`usage::providers_for` also reads so the two lists cannot disagree), and -`GET /usage` is matched on machine *and* provider. `None` is a session -that meters nothing, and the phone draws nothing at all for it — not a -zero, and not "unknown". +**Per provider, not per machine (2026-09-04).** A machine is not what is +metered; the provider a session runs is. One machine offers echo, the Claude +CLI and a local model side by side, and only the second spends anything — so +pairing a session with a snapshot by machine alone drew the CLI's five-hour +window under every echo session on it, a quota that session cannot spend. A +session now names its meter (`usageProvider`, from +`DriverKind::usage_provider`, which `usage::providers_for` reads too, so the +two lists cannot disagree) and `GET /usage` is matched on machine *and* +provider. `None` is a session that meters nothing, and the phone draws +nothing at all for it — not a zero, and not "unknown". -`DriverKind::Echo` names a meter of its own, and it exists only when a -test has asked for one: `/usage` in an echo session sets an invented -answer (`usage::Fixture`), and with none set there is no snapshot and no -bar. That is what makes the states of those screens reachable — a number -near the top, a window between blocks with no reset time, a machine -nobody logged into, one that could not be reached — without spending real -quota to arrange them, which is why none of them had ever been looked at. +`DriverKind::Echo` names a meter of its own that exists only when a test has +asked for one: `/usage` in an echo session sets an invented answer +(`usage::Fixture`), and with none set there is no snapshot and no bar. That +is what makes those screens' states reachable — a number near the top, a +window between blocks with no reset time, a machine nobody logged into, one +that could not be reached — without spending real quota to arrange them, +which is why none of them had ever been looked at. -**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, per machine and provider -GET /setups/:id/models GGUFs on that machine, for a llama session there -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 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. +### 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, +except for the remote `llama-server` and its port forward, which landed +2026-09-04. 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). Remote llama-server with - its port forward landed 2026-09-04 — see "Transport" above for the - forward and the pty, and "llama-server management" for where the model - file has to be. - 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 new file mode 100644 index 0000000..c4c6c28 --- /dev/null +++ b/TRANSCRIPT_CACHE.md @@ -0,0 +1,427 @@ +# The transcript cache + +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. + +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 — 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 + +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 + event, so a reply that was mid-stream when the screen closed picks up at + 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 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 (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 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`, 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 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 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 + +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 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. 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: 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, 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 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** 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**, 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; 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, 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, 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 and relying on the reload button. Invariant 1 is +not something a button restores after the fact. + +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 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 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. + +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, 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. + +### 5. A page is served locally in rows, mirroring the server's count + +`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**: 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 +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. 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, 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. 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. + +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 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 + +A row under the working directory showing what the button discards: + + [ Transcript ] 2.3 MB cached [ Reload ] + +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."* + +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. + +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. + +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 + +Bounded three ways, each with its path out written beside the path in: + +- **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, 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 + + /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 + +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 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 + 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 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 + 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 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 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. + +## Open questions + +- **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/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 7528178..166a337 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?, /** @@ -237,18 +222,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) /** @@ -287,7 +270,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, @@ -296,19 +279,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. */ @@ -317,17 +296,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?, /** @@ -340,7 +317,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( @@ -360,20 +337,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) { @@ -385,13 +360,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() }, @@ -547,8 +520,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( @@ -562,13 +534,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( @@ -612,8 +582,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") @@ -624,8 +594,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, @@ -644,10 +613,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 @@ -707,9 +676,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() @@ -728,8 +696,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." @@ -740,9 +707,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, @@ -789,7 +755,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() @@ -798,10 +763,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, @@ -820,8 +784,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. */ @@ -837,8 +801,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 = @@ -859,8 +823,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, @@ -887,9 +850,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") {} @@ -907,12 +869,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( @@ -929,8 +889,7 @@ fun deleteImportable(settings: ServerSettings, setup: String, sessionIds: 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 inside its own delta run. + 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. + (0 until body.length()).map { + val line = body.getJSONObject(it).toString() + line to parseSeqEvent(line) + } } } @@ -993,7 +961,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( @@ -1085,10 +1053,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 c031e1a..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,15 +24,21 @@ 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, 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. + // A named frame carries no payload and a data frame has no name. 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/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 832b087..1c3cc2b 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 @@ -52,22 +53,22 @@ 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. + val context = LocalContext.current + val transcriptCache = remember(settings) { TranscriptCache(cacheRoot(context, settings)) } + fun refresh() { listState = LoadState.Loading scope.launch { @@ -76,6 +77,13 @@ 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. 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()) + } loaded } catch (e: ApiException) { LoadState.failed(e) @@ -89,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, @@ -108,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" } @@ -141,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 " + @@ -189,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", @@ -215,19 +216,20 @@ 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 { 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 - // 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 = @@ -246,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) } }, @@ -269,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, @@ -278,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, @@ -303,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, @@ -324,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, @@ -345,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 75ada87..6255810 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 @@ -91,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 @@ -103,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 @@ -123,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 @@ -158,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 @@ -184,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 @@ -201,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 { @@ -216,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) @@ -225,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( @@ -248,147 +222,133 @@ 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) } - val activeStream = remember { AtomicReference(null) } - // 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. + // Bumped to rebuild this screen from nothing -- what Reload in the settings dialog does. It + // 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 + // 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, 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 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. 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. - val savedAnchor = remember(summary.id) { 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) } - // 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. + // 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. 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) } // 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()) { @@ -397,11 +357,34 @@ 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() } + /** + * 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". + * 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: a reset continues from where it was. + */ + fun dropLoadedTranscript() { + items = listOf() + replies.clear() + held = listOf() + oldestSeq = 0L + moreHistory = true + queued = listOf() + waitingCommands = listOf() + } + /** * Everything the transcript list draws, from one event. * @@ -409,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) } @@ -466,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 @@ -508,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) { @@ -530,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 @@ -553,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) @@ -578,56 +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 = - 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 - // 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) { @@ -644,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 @@ -673,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) { @@ -684,29 +623,28 @@ 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 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 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, and the flatten runs in the composition that first sees the rows. + */ + suspend fun open(page: List) { withContext(Dispatchers.IO) { var scratch = listOf() page.forEach { entry -> @@ -717,50 +655,86 @@ fun SessionScreen( warm(replies, scratch) } page.forEach { apply(it) } - // 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. + } + + try { + // 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 -- 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. + 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. + // + // 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. 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( @@ -772,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 }!! @@ -794,20 +763,18 @@ 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 - // 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 { @@ -818,81 +785,93 @@ 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. + 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. - LaunchedEffect(summary.id, ready, lifecycleOwner) { + // 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. 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) { - 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 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 + } 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. - // 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. - items = listOf() - replies.clear() - held = listOf() - oldestSeq = 0L - moreHistory = true + // 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 -> apply(entry) } } } 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 { - stream.close() + source.close() } 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. - activeStream.getAndSet(null)?.close() + // 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. - DisposableEffect(summary.id) { onDispose { activeStream.get()?.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 + // 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) @@ -904,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 -> @@ -921,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 @@ -949,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 { @@ -961,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) { @@ -1003,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. @@ -1028,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() } } @@ -1039,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 { @@ -1074,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 @@ -1096,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() } } @@ -1107,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) { @@ -1122,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() @@ -1143,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 { @@ -1160,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 @@ -1179,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() @@ -1190,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.forSession(summary) 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 @@ -1214,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( @@ -1268,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() @@ -1288,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, @@ -1333,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", @@ -1345,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 -> @@ -1375,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()) { @@ -1416,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. @@ -1441,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) }, ) } @@ -1478,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, @@ -1497,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 = @@ -1533,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 = @@ -1569,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, @@ -1635,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 = {}) } @@ -1653,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, @@ -1711,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() @@ -1758,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. @@ -1807,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. @@ -1816,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, ) { @@ -1841,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), @@ -1859,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) || @@ -1886,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 @@ -1923,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(), @@ -1947,20 +1828,41 @@ 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. + 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, - // 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. + 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. The + // epoch is what makes the rest a cold open. + 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. onRenamed = { title = it settingsOpen = false @@ -1980,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. */ @@ -2011,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( @@ -2030,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) @@ -2047,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( @@ -2068,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 = @@ -2085,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, @@ -2105,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) @@ -2127,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, @@ -2141,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, @@ -2174,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( @@ -2214,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 @@ -2246,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), @@ -2272,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, ) @@ -2286,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) { @@ -2306,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, @@ -2347,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)) @@ -2358,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 @@ -2374,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, @@ -2400,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 97998ab..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,12 +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". * - * 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. 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( @@ -55,10 +54,16 @@ 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, /** * 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, ) { @@ -68,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) } @@ -90,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 } @@ -123,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 @@ -154,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 } @@ -173,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() }), ) @@ -199,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, @@ -217,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, @@ -239,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.", @@ -255,6 +257,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( @@ -279,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 5b61d62..fcd0814 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, @@ -102,15 +98,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) }) @@ -130,13 +126,11 @@ fun rememberUsageFeed(settings: ServerSettings): UsageFeed { * Worst rather than the five-hour one, because the button it colours opens *all* of them, and a * blue icon over a weekly quota at 97% would be the interface answering a question nobody asked. * Taken over however many windows came back rather than the three Claude sends today -- the backend - * deliberately passes windows it does not recognise straight through, so a fourth one is a thing - * that happens rather than a thing to notice later. + * passes windows it does not recognise straight through. * * Every state that is not a measurement takes the ordinary control colour instead. That is the * point where colour stops being able to help: blue is the low end of a scale here, so colouring an - * unknown blue would say "measured, and fine" about a machine nobody could reach. The dialog behind - * the button is where those say, in words, which one they are. + * unknown blue would say "measured, and fine" about a machine nobody could reach. */ @Composable fun usageGlyphColour(usage: SessionUsage): Color = @@ -154,18 +148,17 @@ fun usageGlyphColour(usage: SessionUsage): Color = * going, and it was a screen away from the place that decision gets made. It reports on this * session's machine alone -- the dialog is still where every machine is compared. * - * What it shows is the paid service's own metering, fetched from the machine that holds the - * account. It is never derived from what this app has watched go past: the transcript's token - * counts are a different quantity, measured differently, and a bar shaped like a quota gauge built - * out of them would be a guess wearing a measurement's clothes. + * What it shows is the paid service's own metering, never derived from what this app has watched go + * past: the transcript's token counts are a different quantity, measured differently, and a bar + * built out of them would be a guess wearing a measurement's clothes. */ @Composable fun SessionUsageBar(usage: SessionUsage, modifier: Modifier = Modifier) { DebugStats.count("usage bar recomposed") // The countdown moves even when the numbers do not, so it is driven by a clock of its own // rather than recomputed at draw time: a percentage that comes back unchanged is an equal - // value, Compose skips the recomposition, and a "left" that only ticked when the quota - // happened to move would sit at a stale figure for hours. + // value, Compose skips the recomposition, and a "left" that only ticked when the quota moved + // would sit at a stale figure for hours. var now by remember { mutableStateOf(OffsetDateTime.now()) } LaunchedEffect(Unit) { while (true) { @@ -174,15 +167,14 @@ fun SessionUsageBar(usage: SessionUsage, modifier: Modifier = Modifier) { } } - // Nothing at all for a session that meters nothing: a row saying "unknown" there would - // report a problem about a setup somebody chose, on every screen, forever. + // Nothing at all for a session that meters nothing: a row saying "unknown" there would report + // a problem about a setup somebody chose, on every screen, forever. // - // And nothing while the first fetch is out, which is not the same kind of silence. A - // request in flight is not a state to report -- and the session that meters nothing is - // exactly the one this cannot yet tell apart, so "5-hour usage: checking" appeared under - // an echo session for half a second and was then taken away. A row that has to be - // withdrawn is worse than one that arrives late, and this is the only state here whose - // wrongness is a matter of timing rather than of fact. + // And nothing while the first fetch is out, which is a different silence. A request in flight + // is not a state to report -- and the session that meters nothing is exactly the one this + // cannot yet tell apart, so "5-hour usage: checking" appeared under an echo session for half a + // second and was then taken away. A row that has to be withdrawn is worse than one that + // arrives late. if (usage is SessionUsage.NotMetered || usage is SessionUsage.Waiting) { return } @@ -239,9 +231,8 @@ private fun UsageNote(text: String) { * The percentage on its own does not answer the question it gets asked, which is whether to start * something now; 80% with twenty minutes to go and 80% with four hours to go are opposite answers. * - * The window's end has two missing cases and they are worded differently on purpose; see - * [WindowEnd]. A window that is not running gets the percentage and nothing else, because there is - * no countdown to report and inventing one would be the same fault as inventing the number. + * The window's end has two missing cases, worded differently on purpose; see [WindowEnd]. A window + * that is not running gets the percentage and nothing else. */ private fun fiveHourLabel(window: UsageWindow, now: OffsetDateTime): String { val percent = "${window.percent.toInt()}%" @@ -265,8 +256,7 @@ private fun fiveHourLabel(window: UsageWindow, now: OffsetDateTime): String { * account and, while a test has one set, an echo session's invented one -- and a snapshot is one * service on one machine. * - * Every way of having *failed* to get numbers is [SessionUsage.Unavailable] with the reason in it: - * a machine nobody logged into, one that could not be reached, a snapshot that came back empty. + * Every way of having *failed* to get numbers is [SessionUsage.Unavailable] with the reason in it. * None of them may look like zero, and none may look like [SessionUsage.NotMetered], which is the * machine having no quota rather than the question going unanswered. */ 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 5fb4561..5b127cc 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, @@ -277,9 +276,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, ) @@ -290,8 +288,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 }, @@ -319,9 +316,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 = { @@ -387,14 +383,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 ffb055d..081bda6 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Sizes.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Sizes.kt @@ -3,13 +3,13 @@ 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 two 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. + * 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; 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: two 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. + * 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. */ 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 172c861..9807836 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,29 +46,24 @@ 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) } // The models on the *chosen machine*, 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 @@ -103,10 +98,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 -> { @@ -132,10 +126,9 @@ fun SpawnScreen( .getOrDefault(emptyList()) } 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" @@ -146,9 +139,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 }, @@ -159,13 +152,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.", @@ -193,9 +186,8 @@ fun SpawnScreen( if (isLlama) { // A llama session names one of the models on the machine it will run on, so the - // choice is that list rather than free text -- there is nothing sensible to type - // here, and a name that is not on that machine's disk is a session that cannot - // start. + // choice is that list rather than free text -- a name that is not on that machine's + // disk is a session that cannot start. if (models.isEmpty()) { Text( "No models on ${setup?.name ?: "this machine"}. The Models screen downloads " + @@ -206,9 +198,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 }, @@ -290,13 +281,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(), @@ -304,9 +291,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 new file mode 100644 index 0000000..606628e --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptCache.kt @@ -0,0 +1,589 @@ +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. 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. + * + * A plain [File] root and no Compose, `Context` or network, so the whole of the file logic runs + * 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, + 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: 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) { + 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: 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 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 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. 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. 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, + 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. 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. + */ + @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. 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, 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. + * + * 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. + */ + @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 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. + */ + @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. + * 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. 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. + 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. + */ + 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, 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. + */ + 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 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. 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. + 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, 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. + * + * 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. A missing file yields nothing. + */ +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. 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]. + */ +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..5089ca2 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,13 @@ 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. [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 { @@ -19,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 @@ -30,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 @@ -54,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() @@ -74,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, @@ -89,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() { @@ -129,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, @@ -140,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() { @@ -153,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() @@ -165,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() @@ -174,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, @@ -192,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 @@ -214,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) @@ -271,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, @@ -297,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 { @@ -324,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 @@ -335,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, @@ -355,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 @@ -401,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 { @@ -414,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 -> @@ -470,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 } @@ -503,9 +469,8 @@ fun foldEvent(items: List, entry: SeqEvent): List, state: String): List { if (sessionWorking(state)) return items @@ -528,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) @@ -538,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 new file mode 100644 index 0000000..8a7bdcd --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptSource.kt @@ -0,0 +1,177 @@ +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 + * 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: the cache is never load-bearing. + * Every read has a network path beside it producing the same result. + */ +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 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 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. + */ + 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. + */ + 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, 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") + 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/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? = 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 b993455..bf9c3bc 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/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 81b26a7..2eae1d8 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 { @@ -120,59 +108,49 @@ pub struct SshConfig { #[serde(default, skip_serializing_if = "Option::is_none")] pub models_dir: Option, /// 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), @@ -180,30 +158,22 @@ impl DriverKind { } } - /// Which paid service meters a session of this kind, and `None` for - /// one that costs nothing. + /// Which paid service meters a session of this kind, and `None` for one + /// that costs nothing. /// - /// The rate-limit bars answer a question about an *account*, and what - /// decides which account -- if any -- is the provider a session runs, - /// not the machine it runs on. Those were the same thing only for as - /// long as a machine ran one kind of session: an echo session on a - /// laptop that also has the Claude CLI was drawn with that CLI's - /// five-hour window under its header, reporting a quota it cannot - /// spend and could not run down. A llama.cpp session is the same - /// story with the model on the far side. + /// What decides which account -- if any -- a rate-limit bar is about is the + /// provider a session runs, not the machine it runs on: an echo session on + /// a machine that also has the Claude CLI was drawn with that CLI's + /// five-hour window, a quota it cannot spend. /// - /// [`DriverKind::Echo`] names a meter of its own, which exists only - /// when a test has asked for one (`/usage` in `session::echo`). That - /// is what makes the bar's states -- a number, a machine nobody - /// logged into, one that could not be reached -- reachable without an - /// account and without spending a turn on somebody else's. With no - /// fixture set there is no snapshot for it, which the phone draws as - /// nothing at all. + /// Echo names a meter of its own that exists only when a test has asked for + /// one (`/usage` in `session::echo`), which is how the bar's states are + /// reached without an account. With none set there is no snapshot, and the + /// phone draws nothing. /// - /// The string is a [`crate::usage::UsageProvider::name`], and it is - /// what pairs a session with one of the snapshots `GET /usage` - /// returns; the two lists have to agree, so `usage::providers_for` - /// reads this rather than matching on kinds a second time. + /// The string is a [`crate::usage::UsageProvider::name`], and it is what + /// pairs a session with one of `GET /usage`'s snapshots -- so + /// `usage::providers_for` reads this rather than matching on kinds again. pub fn usage_provider(self) -> Option<&'static str> { match self { Self::ClaudeCli => Some(crate::usage::CLAUDE), @@ -212,21 +182,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, @@ -238,11 +204,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, } @@ -251,73 +215,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, } @@ -326,29 +273,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") } @@ -358,22 +301,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(), @@ -383,12 +323,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(), @@ -402,8 +339,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()) @@ -414,12 +351,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 5c0a586..9fde3b7 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,18 +261,15 @@ 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. - // The fixture is the manager's, because that is where the `/usage` - // command that sets it is typed; the monitor is what serves it. + // 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. + // The fixture is the manager's, because that is where the `/usage` command + // that sets it is typed; the monitor is what serves it. let monitor = Arc::new(usage::UsageMonitor::new(manager.usage_fixture())); - // 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))) @@ -299,9 +278,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 => { @@ -318,13 +297,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")?; @@ -333,9 +310,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 5434bd2..1b91dcb 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}; @@ -42,35 +36,33 @@ use crate::session::transport::{Launch, Transport}; 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, @@ -82,20 +74,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, @@ -154,9 +143,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, } @@ -173,11 +161,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('/')) { @@ -193,11 +180,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); @@ -213,12 +198,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)?; @@ -253,8 +236,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); @@ -277,8 +260,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 { @@ -305,7 +288,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); @@ -313,9 +295,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()); @@ -333,13 +314,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", @@ -351,11 +330,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 @@ -380,12 +357,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) @@ -399,9 +374,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(); } @@ -427,12 +401,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)?; @@ -448,8 +420,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(); @@ -457,9 +429,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 = @@ -473,8 +444,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() @@ -489,9 +460,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)) } @@ -508,8 +478,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"); @@ -646,18 +616,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", @@ -685,11 +654,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()?; @@ -738,10 +705,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 a9986d9..541f2f6 100644 --- a/server/src/routes.rs +++ b/server/src/routes.rs @@ -14,6 +14,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) @@ -22,6 +26,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 @@ -34,6 +41,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 @@ -45,10 +53,14 @@ //! 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 /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). //! @@ -90,8 +102,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)) @@ -99,11 +110,11 @@ 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 models on the machine a setup names, for a llama session there. .route("/setups/{id}/models", get(setup_models)) + // 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", @@ -129,17 +140,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) } @@ -167,8 +177,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(); } @@ -177,9 +187,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:#}")) } @@ -196,12 +206,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, @@ -218,11 +227,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 { @@ -230,8 +238,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, @@ -268,9 +275,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)] @@ -278,8 +285,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)] @@ -293,8 +300,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()))?; @@ -311,9 +318,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() @@ -342,11 +348,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)] @@ -373,9 +378,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, @@ -397,9 +401,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) @@ -407,10 +411,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, @@ -475,11 +477,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, @@ -493,20 +494,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, @@ -546,10 +543,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, @@ -575,10 +571,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, } @@ -648,18 +644,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, } @@ -674,30 +668,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); @@ -716,11 +703,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 { @@ -731,35 +715,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, @@ -782,8 +759,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) => { @@ -808,9 +785,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}")), } } @@ -829,21 +806,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 { @@ -890,11 +866,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, @@ -913,28 +887,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; @@ -954,15 +926,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)?; @@ -984,12 +954,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()) @@ -1061,9 +1027,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)? @@ -1148,9 +1112,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())); @@ -1165,8 +1128,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, } @@ -1174,11 +1137,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, @@ -1200,10 +1161,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, } @@ -1229,12 +1189,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, @@ -1244,8 +1203,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, @@ -1257,8 +1216,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, @@ -1279,9 +1237,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)) @@ -1316,21 +1274,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, @@ -1342,9 +1296,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); @@ -1354,10 +1307,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) @@ -1430,10 +1382,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, @@ -1445,13 +1396,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, @@ -1483,20 +1433,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, @@ -1569,15 +1517,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>, @@ -1588,15 +1535,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(), @@ -1620,16 +1567,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 == '_') @@ -1648,12 +1595,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()) } @@ -1664,10 +1611,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 { @@ -1676,11 +1623,17 @@ 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. Exclusive, like the SSE route's `after`. + #[serde(default)] + after: Option, } fn default_window() -> usize { @@ -1689,10 +1642,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, @@ -1702,17 +1654,19 @@ async fn transcript( let events = crate::session::transcript::read_window( session.transcript_path(), query.before, + query.after, query.limit, 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, + after = ?query.after, limit = query.limit, got = events.len(), oldest = ?events.first().map(|entry| entry.seq), @@ -1751,20 +1705,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()?)) }); @@ -1809,28 +1761,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 } @@ -1868,8 +1814,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)) @@ -1881,11 +1827,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 8e8189b..454ee1d 100644 --- a/server/src/session/echo.rs +++ b/server/src/session/echo.rs @@ -1,63 +1,51 @@ -//! 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. -//! - `/usage [what]` -- puts up an invented rate-limit answer, or takes -//! it away again (`/usage off`). An echo session meters nothing, so it -//! draws no usage bar at all until this is set; what it exists for is -//! the states the bar can be in, which otherwise cost real quota to -//! reach. `/usage 42`, `/usage 95 20`, `/usage 42 never`, -//! `/usage notloggedin`, `/usage unreachable`, `/usage failed`. The -//! vocabulary is `usage::Fixture`'s, which is where the states live. -//! - `/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. +//! - `/usage [what]` -- an invented rate-limit answer, or `/usage off` to take +//! it away. An echo session meters nothing, so it draws no usage bar until +//! this is set; what it exists for is the states that bar can be in, which +//! otherwise cost real quota to reach. `/usage 42`, `/usage 95 20`, +//! `/usage 42 never`, `/usage notloggedin`, `/usage unreachable`, +//! `/usage failed`. The vocabulary is `usage::Fixture`'s, where the states +//! live. +//! - `/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}; @@ -69,24 +57,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, @@ -96,42 +78,35 @@ 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>, - /// The invented rate-limit answer `/usage` sets, shared with the - /// usage monitor that serves it. An echo session meters nothing, so - /// this is unset until a test asks for something -- see - /// [`crate::usage::Fixture`]. + /// The invented rate-limit answer `/usage` sets, shared with the usage + /// monitor that serves it. An echo session meters nothing, so this is unset + /// until a test asks for something -- see [`crate::usage::Fixture`]. usage: crate::usage::Fixture, - /// 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()); @@ -149,16 +124,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", @@ -248,8 +222,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()), }); } @@ -260,23 +233,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() @@ -292,17 +263,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 { @@ -357,11 +322,10 @@ impl EchoDriver { return; } - // Answered here rather than in the turn below, because it is not - // a turn: nothing is generated, and what is being exercised is - // the *other* screens -- the bar under the header, the button - // beside it and the dialog it opens, all of which read the usage - // route rather than this transcript. + // Answered here rather than in the turn below, because it is not a + // turn: nothing is generated, and what is being exercised is the + // *other* screens -- the bar under the header, the button beside it and + // the dialog it opens, which read the usage route, not this transcript. if let Some(rest) = text.strip_prefix("/usage") { if announce { self.emit(Event::MessageTaken { @@ -380,9 +344,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 { @@ -438,23 +402,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() @@ -473,21 +434,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)); @@ -507,10 +466,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, @@ -523,8 +481,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 { @@ -567,16 +524,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} @@ -600,9 +555,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; @@ -667,7 +622,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(), @@ -675,8 +629,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, @@ -703,36 +656,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, @@ -741,9 +690,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; @@ -767,8 +715,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()); @@ -815,30 +763,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", @@ -890,17 +835,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(), @@ -921,12 +865,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 { @@ -939,18 +881,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); } @@ -966,8 +905,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() @@ -982,9 +921,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 { @@ -997,17 +936,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) { @@ -1022,14 +960,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); @@ -1041,10 +976,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 0e86154..108467d 100644 --- a/server/src/session/llama.rs +++ b/server/src/session/llama.rs @@ -1,43 +1,32 @@ -//! 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 second half of what a transport -//! is -- "run this" plus "reach this port" -- and it is what lets a -//! session run on another machine: [`Transport::reserve_port`] hands back -//! a port the server binds *there* and a port that reaches it *here*, and -//! the ssh connection carrying the command carries the tunnel between -//! them. The far `llama-server` binds loopback only, so a model is never -//! served to that machine's network. +//! through the same [`Transport`] as any other and then reached over HTTP on a +//! loopback port. That is the second half of what a transport is -- "run this" +//! plus "reach this port" -- and it is what lets a session run on another +//! machine: [`Transport::reserve_port`] hands back a port the server binds +//! *there* and one that reaches it *here*, and the ssh connection carrying the +//! command carries the tunnel between them. The far `llama-server` binds +//! loopback only, so a model is never served to that machine's network. //! -//! **The model file is the far machine's, not this one's.** A session -//! serves a GGUF from the machine that runs `llama-server`, so a remote -//! setup names its own models directory (`SshConfig::models_dir`, -//! defaulting to the same place this backend keeps its own downloads). -//! What this backend has downloaded is on that machine only when they are -//! the same machine -- so the file is looked for *there*, and a session -//! that names a model the machine does not have says so instead of -//! starting a server that will never load one. Downloading to another -//! machine is not built; the model gets there however anything else -//! gets there. +//! **The model file is the far machine's, not this one's.** A remote setup +//! names its own models directory (`SshConfig::models_dir`, defaulting to where +//! this backend keeps its downloads), and the file is looked for *there* -- so +//! a session naming a model that machine does not have says so, instead of +//! starting a server that will never load one. Downloading to another machine +//! is not built; the model gets there however anything else does. //! -//! **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; @@ -52,10 +41,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. @@ -76,20 +64,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, @@ -104,10 +91,10 @@ impl LlamaDriver { )?; let path = model_on(transport, 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, @@ -144,9 +131,9 @@ impl LlamaDriver { "--port".into(), forward.there.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"), @@ -162,8 +149,8 @@ impl LlamaDriver { let launch = Launch::new(program, args, meta.cwd.as_deref()).reaching(forward); // 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 { @@ -183,10 +170,9 @@ impl LlamaDriver { forward.there, forward.here, ); - // 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; @@ -214,10 +200,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, @@ -226,9 +212,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, }); @@ -287,11 +273,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 @@ -306,22 +290,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 { @@ -360,34 +343,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:#}"), @@ -400,17 +382,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) { @@ -445,23 +425,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); } @@ -477,16 +455,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(); @@ -494,8 +471,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[..], @@ -543,46 +520,40 @@ fn model_path(models_dir: &Path, key: &str) -> Result { Ok(path) } -/// The model file's path **on the machine that will serve it**, confirmed -/// to be there. +/// The model file's path **on the machine that will serve it**, confirmed to be +/// there. /// -/// Local and remote answer the same question and it has to be asked of -/// two different filesystems, which is why this is one function rather -/// than a check beside the local path and hope for the other case. The -/// remote answer is measured for the same reason the local one is: a -/// missing file otherwise becomes a `llama-server` that starts, fails to -/// load, and reports as a session that never became ready -- which reads -/// as the machine being slow. +/// One function rather than a local check and hope for the other case: the same +/// question has to be asked of two filesystems. The remote answer is measured +/// for the reason the local one is -- a missing file otherwise becomes a +/// `llama-server` that starts, fails to load, and reports as a session that +/// never became ready, which reads as the machine being slow. /// -/// One blocking round trip on a remote spawn, which is the same cost the -/// spawn is already paying to start ssh. The alternative is a path built -/// here from a `~` this machine cannot expand. +/// One blocking round trip on a remote spawn, which is what the spawn is +/// already paying to start ssh. The alternative is a path built here from a `~` +/// this machine cannot expand. fn model_on(transport: &Transport, models_dir: &Path, key: &str) -> Result { let Transport::Ssh { name, .. } = transport else { return Ok(model_path(models_dir, key)?.to_string_lossy().into_owned()); }; - // The same directory the spawn screen listed for this machine, and - // for the same reason it is one function: a list from one place and a - // load from another is a model that appears and then fails. + // The same directory the spawn screen listed for this machine, and one + // function for the same reason: a list from one place and a load from + // another is a model that appears and then fails. let dir = crate::models::dir_on(transport, models_dir); - // Checked here rather than in the script: `..` in a key would walk - // out of the models directory on a machine this server can start - // processes on, and the phone is where the key comes from. + // Checked here rather than in the script: `..` in a key would walk out of + // the models directory on a machine this server can start processes on, + // and the phone is where the key comes from. for part in key.split('/') { if part.is_empty() || part == "." || part == ".." { bail!("\"{key}\" is not a model key this can resolve"); } } let path = format!("{}/{key}", dir.trim_end_matches('/')); - // `$HOME` on the far side, which is the only machine that knows what - // it is -- and the resolved path is printed back so the launch below - // hands `llama-server` something absolute. - // - // "the file is not there" is answered rather than failed, because the - // two are different things to a reader and only one of them is a - // fault: a machine that could not be asked at all has to say so in - // its own words, and it would otherwise arrive as this same sentence - // about a missing model. + // `$HOME` on the far side, which is the only machine that knows what it is, + // and the resolved path printed back so the launch hands `llama-server` + // something absolute. "Not there" is answered rather than failed, because a + // machine that could not be asked at all has to say so in its own words -- + // it would otherwise arrive as this same sentence about a missing model. let script = "p=$1; case $p in \"~\") p=$HOME;; \"~/\"*) p=$HOME/${p#\"~/\"};; esac; \ [ -f \"$p\" ] && printf 'at\\t%s\\n' \"$p\" || printf 'missing\\n'" .to_string(); @@ -664,8 +635,8 @@ fn log_tail(session_dir: &Path) -> String { const LOG_TAIL_LINES: usize = 6; /// 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], @@ -691,16 +662,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; }; @@ -748,8 +718,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"); @@ -802,11 +772,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 { @@ -858,9 +827,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 10d21ed..c7b017a 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, /// Which of `GET /usage`'s snapshots reports on this session, and @@ -186,29 +158,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 @@ -222,10 +186,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, @@ -233,51 +196,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}"), @@ -296,19 +244,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; }; @@ -326,9 +270,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 @@ -348,69 +290,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()), @@ -424,16 +337,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) }); @@ -454,13 +364,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), @@ -469,12 +375,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(); } @@ -497,18 +399,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, @@ -526,22 +423,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, @@ -581,21 +470,17 @@ 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, /// The invented rate-limit answer an echo session's `/usage` sets, /// shared with the usage monitor that serves it. Held here because @@ -607,14 +492,11 @@ pub struct SessionManager { 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)?; @@ -628,9 +510,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(), @@ -642,9 +523,7 @@ impl SessionManager { usage: &usage_fixture, }, 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, ) }) { @@ -699,18 +578,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 @@ -719,19 +590,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(()); @@ -765,13 +632,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(); @@ -783,10 +647,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, @@ -818,7 +680,6 @@ impl SessionManager { }) } - /// Renames a machine, or replaces what was discovered on it. pub fn update_setup( &self, id: &str, @@ -851,10 +712,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() { @@ -893,8 +752,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(); @@ -908,30 +765,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() @@ -941,25 +788,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 @@ -969,8 +808,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))) @@ -983,11 +821,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); @@ -1009,18 +846,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| { @@ -1030,14 +865,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. @@ -1057,8 +884,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)) @@ -1108,19 +934,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 } @@ -1129,7 +951,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 { @@ -1140,13 +961,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)) } @@ -1160,10 +979,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())), ) })? @@ -1189,28 +1007,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(), }; @@ -1226,16 +1038,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(), @@ -1246,16 +1057,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) { @@ -1270,9 +1075,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), @@ -1287,16 +1091,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) { @@ -1317,25 +1117,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"); } @@ -1351,16 +1148,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())) @@ -1372,6 +1166,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) { @@ -1384,8 +1182,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), @@ -1400,23 +1198,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 @@ -1430,16 +1211,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. @@ -1471,6 +1247,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 @@ -1483,10 +1274,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!( @@ -1503,20 +1294,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(()), @@ -1530,13 +1316,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 @@ -1548,8 +1331,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}"))? @@ -1558,25 +1340,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, @@ -1594,23 +1368,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 @@ -1627,13 +1397,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 @@ -1649,11 +1417,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(); } @@ -1668,8 +1435,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, @@ -1684,10 +1450,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) } @@ -1703,9 +1468,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(); } @@ -1718,60 +1483,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, @@ -1787,6 +1509,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 @@ -1795,6 +1533,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 @@ -1805,17 +1555,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), @@ -1952,19 +1700,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, @@ -1982,8 +1729,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(); @@ -2015,7 +1761,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); } @@ -2034,36 +1780,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 @@ -2105,8 +1845,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); @@ -2118,42 +1858,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)?; @@ -2163,23 +1895,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()), @@ -2190,11 +1916,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) @@ -2203,9 +1928,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); @@ -2214,10 +1939,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), @@ -2264,11 +1988,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, @@ -2305,19 +2028,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, @@ -2337,22 +2057,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, @@ -2379,28 +2096,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, @@ -2419,20 +2131,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; } @@ -2441,9 +2151,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()); } @@ -2472,11 +2182,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, @@ -2486,10 +2195,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, @@ -2498,7 +2207,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 { .. } => { @@ -2582,8 +2291,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| { @@ -2594,12 +2302,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()])], @@ -2609,13 +2314,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()); @@ -2623,20 +2325,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"); @@ -2666,12 +2365,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() { @@ -2709,8 +2406,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; @@ -2722,14 +2419,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"); @@ -2782,16 +2477,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}; @@ -2839,13 +2532,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"); @@ -3645,13 +3336,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"); @@ -3687,8 +3376,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"); @@ -3722,16 +3411,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"); @@ -3791,15 +3475,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"); @@ -3825,9 +3505,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, }); @@ -3844,11 +3523,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")) @@ -3856,8 +3533,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") @@ -3867,17 +3544,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"); @@ -3936,14 +3607,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"); @@ -3983,15 +3651,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"); @@ -4004,8 +3669,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 }, @@ -4032,9 +3697,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, @@ -4087,9 +3752,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 36a2d92..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,18 +128,23 @@ 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 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, + after: Option, limit: usize, coalesce: bool, ) -> Result> { @@ -161,56 +155,55 @@ pub fn read_window( Some(before) => indexed.first_at_or_after(before)?, None => indexed.lines.len(), }; - // 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`. + 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. 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) } } /// 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())); @@ -234,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, @@ -287,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 { @@ -335,23 +323,21 @@ 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. - fn parse_coalesced(&self, end: usize, limit: usize) -> Result> { + /// 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(); // The run currently being gathered: its oldest seq/ts so far, and its deltas newest-first. @@ -369,10 +355,10 @@ impl<'a> Indexed<'a> { } }; let mut index = end; - while index > 0 { - // 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. + 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. if out.len() >= limit && run.is_none() { break; } @@ -471,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(_) @@ -485,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"); @@ -528,7 +514,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 +522,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 +530,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 +658,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 +680,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"); diff --git a/server/src/session/transport.rs b/server/src/session/transport.rs index aad4f4e..5e20ff4 100644 --- a/server/src/session/transport.rs +++ b/server/src/session/transport.rs @@ -1,26 +1,23 @@ //! 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. //! -//! A transport is therefore two operations rather than one: **run this**, -//! and **reach this port**. The second is what a managed `llama-server` -//! needs -- it is spawned as a process and then spoken to over HTTP -- and -//! it is a no-op locally, where the port a program binds is already a port -//! this machine can dial. Over ssh it is an `-L` tunnel carried by the -//! same connection that runs the command, so the model server binds -//! loopback on the far machine and is never exposed to its network. See -//! [`Transport::reserve_port`] and PLAN.md's SSH section. +//! A transport is therefore two operations rather than one: **run this** and +//! **reach this port**. The second is what a managed `llama-server` needs -- it +//! is spawned as a process and then spoken to over HTTP -- and it is a no-op +//! locally, where the port a program binds is already one this machine can +//! dial. Over ssh it is an `-L` tunnel on the same connection that runs the +//! command, so the model server binds loopback on the far machine and is never +//! exposed to its network. See [`Transport::reserve_port`]. use std::path::{Path, PathBuf}; use std::process::Stdio; @@ -31,12 +28,10 @@ use tokio::process::Child; use crate::config::SshConfig; pub use crate::ssh::Forward; -/// What a driver needs run in order to exist as a process. -/// -/// Deliberately just what every transport can carry: the command, where -/// it runs, and a port the caller needs to reach. Anything a particular -/// machine needs -- a key, extra ssh options, which address to dial -- 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 +/// what every transport can carry -- the command, where it runs, and a port the +/// caller needs to reach; 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, @@ -74,22 +69,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, @@ -103,8 +96,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 }, } @@ -120,12 +112,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, @@ -159,11 +149,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); } } @@ -181,13 +169,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, @@ -216,21 +201,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), @@ -239,13 +222,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; @@ -308,11 +289,11 @@ const FAR_PORTS: std::ops::Range = 20000..30000; /// 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), @@ -323,10 +304,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 24e3437..1b2a853 100644 --- a/server/src/setups.rs +++ b/server/src/setups.rs @@ -1,33 +1,28 @@ //! 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), // Named for the program rather than for where it runs: it runs @@ -36,18 +31,16 @@ const PROBES: &[(&str, &str, DriverKind)] = &[ ("llama-cpp", "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!( @@ -58,9 +51,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(), @@ -81,8 +74,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(), @@ -95,16 +88,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") { @@ -123,11 +114,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() @@ -163,25 +152,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}"), @@ -191,11 +175,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 @@ -209,9 +192,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 { @@ -223,8 +206,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 f811227..ea4a17d 100644 --- a/server/src/ssh.rs +++ b/server/src/ssh.rs @@ -1,28 +1,26 @@ //! 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; -/// A port on the machine a command runs on, and the port that reaches it -/// from the backend. +/// A port on the machine a command runs on, and the port that reaches it from +/// the backend. /// -/// The second half of what a transport is (PLAN.md's SSH section): "run -/// this" plus "reach this port". Locally the two numbers are the same one -/// and nothing is forwarded; over ssh the connection carries an `-L` -/// tunnel, so a model server binds loopback on the far machine and is -/// never exposed to its network. +/// The second half of what a transport is (PLAN.md's SSH section): "run this" +/// plus "reach this port". Locally the two numbers are one and nothing is +/// forwarded; over ssh the connection carries an `-L` tunnel, so a model server +/// binds loopback on the far machine and is never exposed to its network. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Forward { /// What the launched program should listen on, on its own machine. @@ -32,31 +30,26 @@ pub struct Forward { pub here: u16, } -/// 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, @@ -68,14 +61,11 @@ 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; @@ -83,39 +73,28 @@ pub fn command( let mut command = Command::new("ssh"); if let Some(forward) = forward { - // A forwarded process is not spoken to over stdio, and that - // changes how it has to be shut down. Everything else here is a - // CLI reading its stdin, so killing the ssh client closes that - // stdin and the far process ends; a `llama-server` never reads - // its own, so the same kill left it running on the far machine - // holding the model in memory -- measured 2026-09-04, an orphan - // per stopped session. A pty is what makes sshd hang the far side - // up: when the connection goes, the master closes and the session - // takes SIGHUP. `-tt` because this client has no terminal of its - // own to inherit one from. - // - // The cost is that its log arrives through a line discipline - // (CRLF, and whatever the program does when it thinks it is on a - // terminal). Nothing parses that log, so it is a fair trade for a - // process that reliably goes away. + // A forwarded process is not spoken to over stdio, and that changes how + // it is shut down. Everything else here is a CLI reading its stdin, so + // killing the ssh client ends it; a `llama-server` never reads its own, + // so the same kill left it running on the far machine with the model + // loaded -- measured 2026-09-04, an orphan per stopped session. A pty + // is what makes sshd hang the far side up. `-tt` because this client + // has no terminal to inherit one from. The cost is a log that arrives + // through a line discipline, which nothing parses. command.arg("-tt"); - // Loopback on both ends: the far side binds 127.0.0.1, so the - // port it serves is reachable only through this connection and - // never from that machine's network -- and the near end is bound - // to this host alone for the same reason. + // Loopback at both ends: the far side binds 127.0.0.1, so what it + // serves is reachable only through this connection. command.args([ "-L", &format!("127.0.0.1:{}:127.0.0.1:{}", forward.here, forward.there), ]); - // Without this a forward that cannot be set up is a warning on - // stderr and a session that runs anyway, answering nothing: the - // failure would arrive as "the model never became ready", which - // is the wrong thing to go looking at. + // Without this a forward that cannot be set up is a warning on stderr + // and a session that runs anyway, answering nothing -- which would + // arrive as "the model never became ready". command.args(["-o", "ExitOnForwardFailure=yes"]); } else { - // -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 { @@ -129,9 +108,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); @@ -139,11 +117,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 { @@ -163,11 +140,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 == "~" { @@ -187,23 +162,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(); @@ -214,15 +185,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"'\''")) } @@ -243,8 +212,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(), @@ -311,13 +280,13 @@ mod tests { assert!(!rendered.contains(&"IdentitiesOnly=yes".to_string())); } - /// The second half of a transport: the connection that runs the - /// command also carries the port that reaches it. + /// The second half of a transport: the connection that runs the command also + /// carries the port that reaches it. /// - /// Both ends are pinned to loopback, which is the property that keeps - /// a model server off the far machine's network -- asserted here - /// rather than trusted, because dropping the addresses is a one-word - /// edit that still works on a machine nobody else can reach. + /// Both ends are pinned to loopback, which is what keeps a model server off + /// the far machine's network -- asserted rather than trusted, because + /// dropping the addresses is a one-word edit that still works on a machine + /// nobody else can reach. #[test] fn a_forwarded_port_rides_the_same_connection_as_the_command() { let ssh = bare_host(); @@ -337,9 +306,8 @@ mod tests { .expect("a forward"); assert_eq!(rendered[forward + 1], "127.0.0.1:41000:127.0.0.1:24242"); assert!(rendered.contains(&"ExitOnForwardFailure=yes".to_string())); - // The half that is easy to lose: without a pty the far process - // outlives the connection, because nothing closes a stdin it - // never reads. + // The half that is easy to lose: without a pty the far process outlives + // the connection, because nothing closes a stdin it never reads. assert!(rendered.contains(&"-tt".to_string())); assert!(!rendered.contains(&"-T".to_string())); // Options come before the host, or ssh reads them as part of the @@ -350,8 +318,8 @@ mod tests { "exec 'llama-server' '--port' '24242'" ); - // Nothing forwarded is nothing added: every other session is one - // of these, and an -L on it would bind a port for no reason. + // Nothing forwarded is nothing added: every other session is one of + // these, and an -L on it would bind a port for no reason. let plain = argv(&command(Some(&ssh), "claude", &args(["-p"]), None, None)); assert!(!plain.contains(&"-L".to_string())); // And a session that *is* spoken to over stdio keeps its raw pipe. @@ -359,19 +327,17 @@ mod tests { assert!(!plain.contains(&"-tt".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'"); @@ -382,13 +348,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 { @@ -415,9 +379,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; '\'''"#, @@ -429,8 +393,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), None)); diff --git a/server/src/usage.rs b/server/src/usage.rs index a63acc7..214e7a1 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::{Arc, 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, @@ -142,9 +133,9 @@ pub trait UsageProvider: Send + Sync { } } -/// 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, @@ -152,9 +143,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 { @@ -169,12 +160,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", @@ -194,8 +182,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) } } @@ -245,19 +233,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 @@ -268,10 +254,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(); @@ -529,16 +514,15 @@ impl UsageProvider for EchoUsage { /// 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. +/// 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. /// -/// Which meter a provider has is [`DriverKind::usage_provider`]'s answer -/// rather than a second match on kinds here, because the phone pairs a -/// session with one of these rows by that same name: two lists that -/// disagree would leave a session looking for a snapshot nothing -/// produces, and nothing on screen could say why. A second service later -/// is a name there and an impl beside [`ClaudeUsage`], not a screen. +/// Which meter a provider has is [`DriverKind::usage_provider`]'s answer rather +/// than a second match on kinds here, because the phone pairs a session with +/// one of these rows by that same name: two lists that disagreed would leave a +/// session looking for a snapshot nothing produces. A second service later is a +/// name there and an impl beside [`ClaudeUsage`], not a screen. fn providers_for(setup: &SetupConfig, fixture: &Fixture) -> Vec> { let mut found: Vec> = Vec::new(); for provider in &setup.providers { @@ -571,19 +555,17 @@ fn providers_for(setup: &SetupConfig, fixture: &Fixture) -> Vec; #[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, /// The invented meter an echo session can put up; empty unless one @@ -600,12 +582,12 @@ impl UsageMonitor { } } - /// 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 { @@ -614,19 +596,17 @@ impl UsageMonitor { if let Some((fetched, snapshot)) = self.cache.lock().unwrap().get(&key) && fetched.elapsed() < provider.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() @@ -676,8 +656,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(), @@ -707,9 +687,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 { .. }), "{:?}", @@ -722,8 +702,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 @@ -733,16 +713,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 { .. }