# ai-app 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. **`docs/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 design and working documents live under `docs/` — everything except this file and `CLAUDE.md`, which stay at the root because that is where Claude Code and other agent harnesses look for them. The central design point, worth not undoing by accident: **a session is a 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). The second one, for the Rust port on the `rustify` branch: **the phone app and a planned desktop app share almost all of their code.** Screens, widgets, folding, paging, config and the network client live in `app-rust/`'s `client` and `ui` modules, drawn with `iris`; `src/android` and `src/desktop` are thin entry points that own only what the platform forces (JNI and the IME on one side, winit and argv on the other). The two *layouts* will differ, to suit a phone's screen and a finger against a desktop's screen and a mouse -- but the widgets a layout is made of (a button, a text field, a list, a card) and the styling (colours, spacing, type) are one implementation with no per-platform copy. Anything that could work on both goes in `ui` the first time it is written, and a platform module growing a widget or a colour is a defect to move, not a convenience to keep. Iris said this on 2026-09-07; docs/RUST.md carries the details. The third, from Iris on 2026-09-08: **`iris/` is the UI framework and nothing else.** Nothing in it may know about a session, a transcript, a setup or a server; anything that does belongs in `app-rust/`, and the dependency runs one way only. The port's project code is **one crate** (`ai-app`) rather than the six it was scattered across — see docs/RUST.md's "One app crate" for what forced each of the splits that were removed and the two that remain. ## 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. Read dev-updater's `README.md` and `AGENTS.md` before diverging from them. Module-by-module intent is in `docs/PLAN.md`'s "Backend layout". - `server/` — the Rust backend (`ai-server`). `routes.rs`'s module doc comment is the HTTP table and the surface's source of truth. - `event-model/` — the wire shape `server/` and `app-rust/` both depend on, which is the whole reason it is a crate of its own rather than part of either: it is the contract between them, so the two agree by construction. - `app-rust/` — the Rust app, one crate (`ai-app`) with three faces. `src/ client` is everything with no UI in it (the REST and SSE clients, the transcript cache and fold, the highlighter, the ANSI parser, config and the enrolment link); `src/ui` is the screens as iris widget trees; `src/desktop` + `src/bin_desktop.rs` is the winit binary; `src/android` is the `android-view` entry point and `android-project/` its Gradle app; `src/shell` is the separate JNI bridge the Kotlin `app/shellApp` calls. Features pick which face a build is: `screens` (default) for anything that draws, `shell` for the Compose app's bridge, `bench` for P0's fixture build. See its `Cargo.toml` header. - `iris/` — the UI framework, and **only** the UI framework: `core`, `macro`, the `iris` crate itself, `tabs-ui` (its own demo widget tree) and `rig-input`. It must not mention anything this product is about. - `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. - `scripts/` — everything at the root that was neither a program nor a document: the three repo-wide shell scripts (`run-tests.sh`, `test-wg-tunnel.sh`, `wg-setup-host.sh`), `rigs/` (the `gpu-probe` and `virtgpu-probe` device probes, and `ui-profile`'s two layer-1 profiling rigs), and `xtask/`. **A project's own scripts stay with the project** — `app/*.sh`, `app-rust/*.sh`, `iris/*.sh` and `server/enroll-link.sh` did not move (Iris, 2026-09-09: "I only meant top level sh files"). `scripts/xtask/` is the [cargo-xtask](https://github.com/matklad/cargo-xtask) convention: an ordinary Rust binary that does build work a shell script would otherwise do, run as `cargo xtask apk` **from the repo root** (`.cargo/config.toml`'s alias, whose `--manifest-path` is relative to the working directory). It packages `app/shellApp` without Gradle driving it — `cargo ndk` → `javac`/`d8` → `aapt2` → `zipalign` → `apksigner` — and publishes to `scripts/build/outputs/apk//`, which is where Dev Updater looks. There is deliberately **no `target/` at the repo root** any more: there is no workspace there, and what used to be one was only xtask's own scratch space, now in `scripts/xtask/target/`. - `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. - `docs/` — every design and working document except this file and `CLAUDE.md`: - `docs/EXPLORER.md` — the file explorer's design (`server/src/files.rs` and `FilesScreen.kt` / `FileViewer.kt` / `FileEditor.kt`). - `docs/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`. - `docs/TODO.md` — the working list. - `docs/SUBAGENTS.md` — a session's subagents: the wire shape, the phone's view, and the choices behind the shape. - `docs/RUST.md` — the plan for moving the app to Rust (on the `rustify` branch of the `ai-app-2` clone): what has to be reproduced, the framework decision, and the ordered experiments with their pass conditions. Read it before touching anything under that branch. - `docs/IRIS_TODO.md`, `docs/LAYOUT.md`, `docs/TEXTURES.md`, `docs/CLIENT_CORE.md` — iris's open working list, its layout/render design, its texture-atlas design, and the design of `app-rust`'s `client` module, respectively. **These documents are pruned as the work lands, not appended to forever** (Iris, 2026-09-08: *"remove everything that's already done and decided… many with checkboxes already ticked off that just fill up context"*). A ticked box, a finished experiment and a completed review are deleted once carried out; `IRIS_TODO.md` holds only open items, and a finished document is removed rather than archived in place. What survives is what cannot be cheaply re-derived — measurements, dead ends and failed hypotheses, invariants and their reasons, and the design of what exists now rather than the route to it. **There is no decisions log and no design log, and one should not be started.** `docs/DECISIONS.md` and `docs/IRIS.md` were deleted on 2026-09-09 at Iris's instruction: *"I've decided to instead make decisions when planning with agents rather than after they do things, and they're both too long for me to wanna read, + don't cover all the decisions I'll wanna make about the code anyways. I'll just naturally run into things for now."* So raise a choice **while planning it with her**, when the direction is still cheap to change; otherwise decide it, put the reasoning at the code it governs, and carry on. TODO lists are still wanted — a list of open work is useful, a list of finished work is not. - `docs/SCROLL.md` — how anything in iris scrolls: one `ScrollController` holds the position, the gesture, the fling and the pin, and the two widgets that scroll (`ScrollArea`, `LazySpan`) own one each through the `Scrollable` trait. Read it before touching `scrollable.rs`, `scroll_area.rs`, `lazy_span.rs`, or anything that pans, flings or lays out a long 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), the Compose app and the shell APK, in parallel. **It does not publish either benchmark APK.** Phone benchmark builds live in the separate `~/repos/ai-app-bench` repository: build here, copy the verified artifact to that repo's `compose/` or `iris/` Gradle-shaped path, then commit and push that repo. Dev Updater pulls the committed APK from there; pushing `ai-app-2` alone cannot update its benchmark card. This file points at `resources.ron`, which is *ours* rather than Dev Updater's — it names `~/.local/share/ai-app` and `~/.config/ai-app` so the Uninstall dialog can offer them. Note what deleting the config directory takes with it: the CA under `certs`, which is the one-way door. **Stop** on the server card stops the server a phone reaches through the tunnel, so on that phone it stays down until somebody starts it again; Dev Updater reaches it over its own port and is unaffected, which is what makes the button safe to press and easy to regret. ### Icons **Nerd Fonts glyphs from a committed subset**, not vector assets and not ordinary Unicode. `NerdIcons.kt` declares each codepoint and `app/build-icon-font.sh` subsets the font; the two lists have to agree, because a codepoint in the Kotlin that the script did not subset is a glyph that silently isn't there. Rerun the script and commit its output when adding one — it needs network access. `md-cog` and `md-refresh` are deliberately the same codepoints dev-updater uses and must not drift from it. The subset is the **Mono** face, where every glyph is one em square, which is what makes two icon buttons the same width without either being given one — and why `GLYPH_SIZE` is smaller than it looks like it should be. **The Rust app does the same, from its own subset**: `iris/core/build-icon-font.sh` -> `iris/core/assets/fonts/nerd_icons.ttf`, with the codepoints named in `iris/core/src/icon.rs` and drawn as text with `Family::Icons`. Same rule about the two lists agreeing (there is a test, `every_icon_is_in_the_bundled_font`), same Mono face, same Material Design family so an icon means the same thing in both apps. Its subset is separate rather than shared because subsetting only what one app draws is the point. This is the **only** font iris bundles — body and monospace text come from the platform (decided 2026-09-07), and an icon is the opposite case: a small closed set of codepoints no system font is guaranteed to have. ## Checking your work - **Rust**: `./scripts/run-tests.sh` from the repo root runs `event-model`, `server/` and `app-rust/`; `cd iris && cargo test` runs the framework's own suite, which is slower and not about this product. Each workspace also gets `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-rust/` and `iris/` are pinned to the same dated nightly (`rust-toolchain.toml`, one copy each, because a pin applies per directory); `server/` and `event-model/` are stable. - **App**: from `app/`, `. ./android-env.sh && ./gradlew :androidApp:ktfmtFormat :androidApp:compileDebugKotlin :androidApp:lintDebug :androidApp:testDebugUnitTest`. The unit tests are JVM-only and cover the syntax highlighter, the ANSI parser and the transcript cache — the app's pure logic with no Android in it. Touching anything under `BenchFixture.kt`, `BenchNetwork.kt`, `BenchRun.kt` or the `bench` build type also needs `:androidApp:compileBenchKotlin :androidApp:lintBench` — a second build type compiles separately and lint has caught real bugs debug alone never would (see "Android Lint" below). - **Android Lint is not optional and is not run by a build.** It found a crash that had been shipping (`java.time` on a minSdk-24 app with desugaring off) and later a permission check that silently dropped every notification on Android 12 and below. Fully clean as of 2026-08-31; keep it that way, and suppress with `tools:ignore` plus a written reason rather than by lowering the bar. - Then `./build-apk.sh` for the APK to install on a phone through Dev Updater, or `./run-android.sh` to build, install and launch on the emulator. **The phone gets the release build**, signed with a key the script generates once under `~/.config/ai-app/release.jks` (never in the repo); `./build-apk.sh debug` builds the other variant, and Dev Updater's build modes call the script with exactly that word. Dev Updater lists every variant under `build/outputs/apk`, so pick `release` there; a phone still holding the debug build has to uninstall it first, since the two are signed differently. - The emulator scripts stay on the debug build. **Never read a frame time from one as the app's** — a debuggable build runs Compose at a fraction of release speed; the render report says which build it came from. ## Running it here - Run the server for development with `--bind 127.0.0.1`. Without it the server binds wg0, which exists here but is unreachable from the emulator (it dials 10.0.2.2). First run prints the enrollment QR/URI with the token. `ai-server --enroll-link` mints one more device's link while the server keeps running; the server adopts that token on its first use. It is what Dev Updater's Enroll button runs. - Point development at a scratch state directory rather than the real one: `--config /tmp/…/config.ron --data-dir /tmp/…/sessions --port 8444`. - **The APK pins the CA of the machine that builds it**, read at build time from `$XDG_CONFIG_HOME/ai-app/certs/ca.pem` (`AI_APP_CA` overrides). So the server must have started once on that machine first — the build stops with that instruction otherwise — and an APK built in this VM only works against a server in this VM. - Prefer exercising the server directly over going through the UI: `curl --cacert ~/.config/ai-app/certs/ca.pem -H "Authorization: Bearer …" https://127.0.0.1:8443/sessions`. The CA is wherever `--certs` put it — by default under `$XDG_CONFIG_HOME`, never in the checkout, so a relative `certs/ca.pem` finds nothing. The emulator app reaches it at `https://10.0.2.2:8443`; enroll with `adb shell "am start -a android.intent.action.VIEW -d 'aiapp://enroll?host=10.0.2.2&port=8443&token=…'"`. - **`ai-server --delay MS` holds every response back.** Over the tunnel a phone's requests take tens to hundreds of milliseconds, and several faults live entirely in what the app does *while* one is outstanding. On a loopback server those windows close before anything can be observed, so the bug looks like it is not there. - **`RUST_LOG=ai_server=debug`** logs every transcript page with its `before`, `after` and what came back, and logs each SSE subscriber's cursor and whether it was continued or reset (`stream backlog:`). That is the only place "how far had this phone fallen behind" is answerable — the app sees a window arrive and cannot tell. - **`./scripts/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. - **The `bench` build type and `app/bench-fixture/`** exist for P0 (RUST.md and the 2026-09-05 decisions), the phone benchmark gate Iris asked for before porting continues: a deterministic, checked-in synthetic transcript (`app/bench-fixture/generate.py`, never a real one) that both this app and iris open with no server, so a frame-time comparison measures the renderer rather than the data. `./build-apk.sh bench` builds it — own application id (`com.example.aiapp.bench`) and label ("AI Sessions bench") so it installs beside a real enrollment rather than replacing it. Opening it goes straight to a session screen holding the fixture (no enrollment, no permission prompts) with a "Run benchmark" control beside "Copy" in session settings: it drives the same scroll loop and streaming phase `transcript-bench.sh`/`stream-bench.sh` drive over `ui-trace`, but in-process, since a real phone has no usable system tracing and no agent can drive one (this-machine-android's skill). `BenchFixture.kt`/`BenchNetwork.kt` fake the backend by installing a `URLStreamHandlerFactory` that answers `TranscriptSource`/`EventStream`'s requests from an in-memory copy of the fixture instead of opening a socket — so the fold, the paging and `uniqueItems` under test are the screen's real ones, never a shortcut built just for this. The report gains a `bench:` section (process CPU time, peak RSS, battery current) on every build, empty except when `BenchRun.kt` filled it in. - **`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. - **A fake CLI exercises the process lifecycle without a token.** Point a `claude_cli` provider's `command` at a two-line script — `#!/bin/sh` and `cat > /dev/null` — and it behaves the way the lifecycle code cares about: it holds the fifo open, records a real pid, writes nothing, and dies on a signal. So adopt, stop, restart and start are all drivable without a real `--resume` and without spending a turn on somebody's account. Reach for this when what is under test is *whether a process is running*, and for `debug-transcript.sh` when it is *what the transcript draws*. - **`app/transcript-bench.sh`** is the standard scroll measurement: it opens the first session (or `-k` keeps the current screen), scrolls a fixed gesture loop, and prints the app's render report — the same one the in-app copy button produces, whose `on screen:` line names what the viewport was holding. Compare two runs with the same gestures; the emulator's absolute frame times transfer nothing, the report's accounting does. Run it either side of any change under `Markdown*.kt`, `Transcript*.kt` or `SessionScreen.kt`'s list, and put the report in the commit. The numbers that move first are the worst `record: one block`, the reparse mean while streaming, and the draw phase's accounting line. - **`app/stream-bench.sh [-k] FILE`** is that measurement for a reply still arriving. It taps "Jump to latest" so the list is pinned to the newest end, resets the report, sends FILE, waits for the transcript to stop growing, and prints. Both of those are corrections to a first version that measured nothing: a transcript parked further back never redraws while a reply streams into it, and a session is idle at *both* ends of a turn, so polling for idle answers before the turn has started. - **`app/trace-draw.sh`** names what a scrolling frame spends inside the framework, from `atrace` text output with no trace processor needed. It is how the cost of a layout node per link was attributed to the framework rather than guessed at. - **`app-rust/build-apk.sh [debug|release] [--abi ...] [--features ...]`** builds the Rust app's cdylib (`cargo ndk` from `app-rust/`, straight into `android-project/app/src/main/jniLibs/`) and its APK (Gradle, from `android-project/`) in one step and verifies the result (`aapt2`/`apksigner`), and **`app-rust/run-bench.sh [--apk PATH]`** installs it on this checkout's own emulator, taps "Run benchmark" by label, and prints the report -- written so the P0 build/install/tap/read-report cycle stops being retyped by hand each time (docs/RUST.md's P0 box). It passes `--no-default-features`, so `--features` alone decides what is in the `.so`; that is what keeps the 1.9 MB bench fixture out of a build that did not ask for `bench`. A phone build is published only by replacing `~/repos/ai-app-bench/iris/build/outputs/apk/release/iris-bench-arm64.apk` and pushing the **bench repository**, not this checkout. - **iris's three test layers** (docs/RUST.md's "Three test layers" has the commands and what each cannot answer): test at the cheapest one that can answer the question. `cd app-rust && cargo test` runs the real transcript screen over the bench fixture with **no window, no compositor and no GPU** (`iris::harness`), on a clock the test owns and a gesture replayed from a `t_ms action x y` file under `app-rust/touch/` -- which is how the batched 120Hz flick a finger actually makes is testable at all, since a `ui-trace` swipe is many evenly-spaced events. `iris/run-headless.sh phone --phone --dir ../app-rust --shot …` opens the same screen in a window at the phone's own size and density for looking at, and `--replay FILE` drives the same recording into it (`--dir` names the workspace to build in, since the rig lives in iris and the app's examples do not). The emulator is for JNI, the IME, insets, the surface lifecycle and one verification run before a build goes to the phone -- not for iterating on layout. - **`scripts/rigs/ui-profile/`** holds the two layer-1 profiling rigs, in a crate of their own so a rig's dependencies stay out of the app's (Iris, 2026-09-09: *"Rigs should probably all be in their own crate so dependencies and such don't get mixed"*). Run either from that directory; both are `#[ignore]`d and assertion-free, so `run-tests.sh` neither runs them nor can fail on them, and both need **release or the numbers mean nothing**. - **`tests/frame_profile.rs`** is what a frame costs on the CPU, at layer 1 -- `cargo test --release --test frame_profile -- --ignored --nocapture`. Two runs: a fling over the bench fixture eight times out and back, and a reply streaming into it one event at a time. Text shaping dominates, which is why the profile is meaningless unoptimised. It cannot answer anything about the GPU, the swapchain or the phone's own clock. What it established on 2026-09-09, worth not re-deriving. A **fling** is not CPU-bound: only about one frame in six lays anything out (the rest are moved on the GPU through `move_offsets`), and the multi-millisecond spikes are all in the *first* pass over a stretch of transcript -- every later pass over the same rows is p99 0.26ms. A **streamed event** is, and it is not where it looks: folding the event is 0.35ms and applying the diff to the widget tree is 0.41ms, while the *frame* is 3.86ms here and 9.5ms on Iris's phone. (The fold was the hypothesis, from `foldEvent`'s Compose lesson under "Things that have bitten"; measuring it is what ruled it out.) That frame is one `TextBuffer::shape` of the block a delta landed in, and **the fixture's is 14,888 characters in a single block** -- against a largest-ever 1,580 across 7,706 blocks of real replies. So the stream phase's number is a property of the fixture, not of streaming; docs/RUST.md's "Incremental text" has the measurements and why parley cannot help. The last two runs (`where_a_streamed_deltas_cost_is`, `what_the_fixture_streams`) exist to keep that answerable: what a delta costs to re-split and re-compare, and what the fixture actually streams. - **`tests/arena_churn.rs`** is what a frame costs to *upload* -- the half of a frame layer 1 builds and never performs, and so the half `frame_profile.rs` cannot see at all. It prints three numbers per GPU array per frame, and the point of the rig is that no two of them alone are honest: **floor** (entries whose bytes actually differ, found by diffing), **uploaded** (what iris really writes, read from the same `Dirty` sets `UiRenderNode::update` consumes), and **whole** (what the old code wrote whenever anything changed). A gap between the first two is over-marking; one was 122x and invisible until both were printed side by side. What it established on 2026-09-09, and what the three optimisations it drove were. Uploading the whole arena on any change cost **758 MB over a fling and 1.2 GB over 401 streamed deltas**, p50 3.0 MB per streamed frame. Three things were wrong and each is now guarded by this rig: `ArrBuf` reallocated on every length change, so adding one glyph made the buffer's contents undefined and forced a full rewrite; a redraw freed its primitives and pushed new ones, which -- since freed slots are only reusable next frame and provisional layout nested -- grew the arena to **127,443 slots for 11,569 live primitives**; and nothing tracked *which* entries changed. Now: the stream arena is 11,569 slots for 11,569 live, and every array uploads within a hair of its floor. The CPU half improved with it, since the freeing and renumbering went away: a streamed frame is p50 1.39ms, from 2.20ms. The remaining layout cost was then removed at the framework boundary: `Painter::set_child_offset` gives a container one retained coordinate slot for its child subtree, and `LazySpan` keeps row boxes stable behind it. Pinned growth now uploads instances at **1.1% against a 1.1% floor**, from 71.9% against 71.8%; p50 instance upload is **1,488 bytes**, from 176,496. `Primitives` also cancels dirty marks for provisional writes restored before upload, so CPU-only layout states never become GPU work. - **The emulator is a GLES rig, deliberately** (Iris, 2026-09-08; docs/RUST.md). Its guest has no hardware Vulkan -- only SwiftShader in software -- while its GLES *is* the host's real GPU through virgl at ES 3.1, so an ordinary build's runtime fallback lands there by itself and nothing should pass `force-gles` to arrange it. The Vulkan path is verified on the desktop build and on Iris's phone. Do not boot the emulator with SwiftShader Vulkan to "test the Vulkan path": that measures a software rasteriser and steers iris away from the one hardware-accelerated backend it has there. Every run says which adapter drew it (`iris renderer:` in logcat, printed by `run-bench.sh`); read that line before reading a number. ### 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 The prebuilt CPU llama.cpp lives outside the repo at `~/.local/opt/llama.cpp` (the 15 MB `ubuntu-x64` release asset). It needs its own directory on `LD_LIBRARY_PATH`, so start the server as `LD_LIBRARY_PATH=~/.local/opt/llama.cpp ai-server …` and point a provider's `command` at `~/.local/opt/llama.cpp/llama-server`. A 0.6B Q8_0 answers at usable speed on this VM's 8 cores. **Do not test with a 2-bit quant**: the 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**: generate a throwaway key, append the public half to `~/.ssh/authorized_keys`, and configure a host of `bob@127.0.0.1` with `identityFile` pointing at it plus `options: ["StrictHostKeyChecking=no", "UserKnownHostsFile=…"]` so it touches nothing real. Point a provider's `command` at something harmless like `/bin/echo` rather than at `claude`: the transport is what is under test, the process exiting immediately is the signal, and it costs no tokens. **Take the key back out afterwards.** The remote login shell here is **fish**; the remote script and `ssh.rs`'s POSIX quoting happen to mean the same thing in both, but that is luck rather than design, and a shell that is neither is the thing to suspect first if a remote spawn ever mangles an argument. ## Where things run (host vs this VM) This checkout runs in a VM while production runs on its host: - **`ai-server` belongs on the host in production.** That is where the LAN address the phone can reach is, and where WireGuard terminates. `scripts/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 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. - 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. docs/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 (`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. **Never import a Claude Code session that is open in a terminal.** The app refuses it — see docs/PLAN.md for the incident that made that a refusal rather than a warning. **One Claude Code session id can name two files, and the listing offers it once.** Resuming from a different working directory makes the CLI write a second transcript with the same id under that directory's project folder — an ordinary state of a machine, not corruption. Everything downstream addresses a session by id, and the phone keyed its list on it, so two rows sharing one **closed the app** on a Compose duplicate-key throw. `parse_listing` keeps the copy with the most lines, because the other is usually a few-hundred-byte stub and is often the *newer* of the two, so recency is the wrong key. Deleting removes every copy rather than the first, or the row came back after a delete that reported success. The phone's half is `uniqueItems`, which every list keyed on a server-chosen id goes through: a repeat there must never be able to close the app, whatever produced it. **Deleting a session offers to take the machine's own transcript with it** — `DELETE /sessions/{id}?deleteForeign=true`, behind a switch in the confirmation, and only where the driver keeps a record of its own (`keepsOwnTranscript`, which today means Claude Code). Off by default, because leaving that copy is what makes an ordinary delete recoverable — and the dialog's paragraph is rewritten when it is on rather than appended to, since the sentence promising the conversation "should still be there to import again" is exactly the one the switch makes false. The server deletes the machine's copy *first*, so a machine it cannot reach leaves the session where it was instead of half-deleted. ## Shared appearance - **A row something is happening to is dimmed, drained of colour, and says which operation in a word** — `BusyItem`, used by both the session list and the import list so the appearance is learned once. The word rather than a bare spinner because "deleting" and "importing" differ in kind. It does **not** make the row inert: the caller disables its own click handler while it passes a label. An overlay consuming pointer events was tried and swallowed the drag along with the tap, so a list could not be scrolled while anything in it was busy. ## Things that have bitten Project-specific only; keep cross-project machine notes out of this file. - **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 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. 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) …)`, 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 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. - **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 the 867 MB in this VM, paid once per session in a batch. `import::find` takes the same script with one glob narrower: 78ms. Ids are checked (`is_session_id`) before they reach that glob, since a `/` or `..` walks it out of the projects directory. - **A transcript page used to cost the whole transcript.** `read_window` read and parsed every line and then kept the last `limit` of them, so the work was the size of the conversation rather than the size of the answer: one page of a 21 MB, 24,000-event transcript took ~500ms to return 620 KB, and took the same 500ms whichever page was asked for. It is a bisection now (`Indexed` in `transcript.rs`) — sequence numbers only increase, so the edge of a range is found by parsing one line per halving. Same page, ~110ms, of which ~20ms is the file scan. The file is still read whole; that is where the remaining cost is, and going further means a chunked backwards reader. - **Paging back has two failures that look like "there is simply no more history", and neither says anything on screen.** Both invisible on a loopback server and reproducible at `--delay 150`. The pager fires on the *first layout*, before any event has arrived — `moreHistory` starts true, so the spinner is in the list and `visibleItemsInfo` is not empty — and `before = 0` asks for the events before the first one, which is none, which is exactly how this code is told it has reached the start. `loadOlderPage` refuses `oldestSeq == 0` now. And `joinPages` only ran `adoptRun` on the path where a *split* call had been found, so a boundary landing cleanly between two calls — most of them — left one run of tool calls drawn as two groups with the seam wherever the reader happened to have paged. Reproducing either takes a boundary placed on purpose: the opening page is 80 events, so arrange the transcript so that event counts back from the newest. - **A page is 800 events and a screen is a handful of rows, and the two have no fixed ratio.** A run of thirty-five tool calls is one row; a reply is hundreds of text deltas folded into one. So anything that budgets in rows has to measure a screen rather than name a number: the history cushion was eight rows, which on a tool-heavy transcript is less than one screenful, so the reader hit the end of what was loaded on every swipe and stood there for a round trip. It is `HISTORY_SCREENS` viewports now, counted from what is actually on screen. - **Only `fetchTranscript` was off the main thread; the fold was not.** `foldEvent` returns a new list per event, so a page is that many copies of a growing list — fine at 80 events and about 300,000 element copies at 800, run in the middle of the scroll that asked for it. `warm` had the same shape: the `markdownIn` scan that decides *what* to parse ran before the hop to `Dispatchers.Default`. The shape to watch for is a `withContext` that wraps the *fetch* and leaves the work done with the result outside it. ## Measurements worth not re-taking - **What the transcript screen costs to scroll.** Taken 2026-08-30 on the GPU emulator against a real imported transcript with the server at `--delay 120`. Settled and flinging fast, both into fresh history and back through rows already drawn: **5.2–5.9% janky frames, 99th percentile 29–32ms, 0–2 slow UI-thread frames.** The stock Settings app on the same device is 3.3% and 38ms, so this is at the platform floor. The number that is *not* at the floor is the first few seconds after opening a session, where every row on the way is being composed for the first time; that is inherent to a lazy list and it is why a measurement taken before the screen settles reads three times worse. **Settle first, then reset `gfxinfo`.** - **The reset path is not reachable by reopening a session.** Measured 2026-09-04 against a session streaming at 20 events a second: reopening one with an anchor 1,800 events back connects **87–119 events behind**, well under `CATCH_UP_LIMIT`'s 200, because the restore is two requests — the opening page, then one span covering the whole distance. To exercise the reset at all you have to lower `CATCH_UP_LIMIT` in a throwaway build; at 5 the app takes the reset on a live connection, clears, refills and carries on without reconnecting. - **The session screen's stream survives backgrounding here** — 20 seconds at the launcher while 415 events were produced brought no reconnect at all, which is not what the comment above that loop expects, and is most likely this emulator being headless rather than the phone's behaviour. - **Reopening a cached session costs one request for one event** (the probe), and scrolling the whole conversation back costs nothing more; a cold open of the same 500-event session is two pages, 100 events. Measured 2026-09-04 on the emulator against the sandbox. - **Reading is cheap and editing is not.** The viewer handles a 1 MiB, 28,000-line file because it draws one row per line; the editor is one `BasicTextField`, which costs two seconds a frame at 128 kB and stops the app at 1 MiB, so `EDIT_LIMIT` caps it at 32 kB with the reason said on screen. If you make the editor faster, that number is what to move. docs/EXPLORER.md's "What the measurements said" has the rest.