Files
ai-app/AGENTS.md
T

773 lines
48 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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/<mode>/`,
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/<id>/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 **2.9% against a 2.9% floor**, from
71.9% against 71.8%; p50 instance upload is **1,728 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=<ddns name>`.
- **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.25.9% janky frames, 99th percentile
2932ms, 02 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 **87119 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.