# 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. **`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, and things that have bitten. **The rigs are the `ai-app-rigs` skill** — the sandbox and bench scripts, the rule that no UI-driving script may tap a coordinate, how to test llama.cpp and ssh here, how importing behaves, and the measurements not worth re-taking. They moved there on 2026-09-04 because they are 12 KB that only matter once you are actually running one, and this file is sent with every request. Read it before writing or running a benchmark, driving the UI from a script, or touching the import screen. 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). ## 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 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. **A llama.cpp session runs on whatever machine its setup names** (built 2026-09-04, the last of phase 5): `Transport::reserve_port` returns the port the server binds *there* and the port that reaches it *here*, and `Launch::reaching` puts the `-L` tunnel on the connection already carrying the command. Three things fell out of it and are easy to get wrong again — a forwarded launch gets a pty (`-tt`) and every other one keeps `-T`, because `llama-server` never reads the stdin whose closing ends a CLI and the same kill left it loaded on the far machine; the model is looked for on the machine that will serve it, so the spawn screen offers `GET /setups/{id}/models` rather than the backend's own downloads; and the readiness poll watches the process as well as the port, since a model that will not load exits in a second and was being reported as "gave up after 300s". See PLAN.md's "Transport" and "llama-server management". - `app/` — the Compose app, package `com.example.aiapp`, label "AI Sessions". `AppRoot.kt` is the navigation `when`; `MainScreen.kt` the root's four tabs (sessions, import, models, setups); `Api.kt`/`EventStream.kt` the REST + SSE clients; `Events.kt` the event model mirror; `ServerConfig.kt` settings and the Keystore-sealed token. - `wg-app-link/` — a **git submodule** shared with dev-updater: the pinned CA and leaf (`certs`), QR enrollment and the bearer token (`enroll`), wg0 binding and the certificate's SANs (`netif`), owner-only files (`private`), and the RON house rules (`format`). Clone with `--recurse-submodules`, or `git submodule update --init` in an existing checkout — `server/` will not build without it, since it is a path dependency, which is what keeps the two projects version-locked to the commit this repo pins. What deliberately did **not** move is the API surface and the config *schema*: routes, drivers, sessions and setups are what makes this project itself. - `EXPLORER.md` — the file explorer's design (`server/src/files.rs` and `FilesScreen.kt` / `FileViewer.kt` / `FileEditor.kt`). - `TRANSCRIPT_CACHE.md` — the phone's copy of what it has been sent. Read it before touching `TranscriptCache.kt`, `TranscriptSource.kt`, or the opening and stream effects in `SessionScreen.kt`. - `TODO.md` — the working list. - `.dev-updater.ron` — what Dev Updater builds here: the server (run as `service: Managed(…)`, supervised by Dev Updater's own implementation rather than a script kept here) and the APK, in parallel. It points at `resources.ron`, which is *ours* rather than Dev Updater's — it names `~/.local/share/ai-app` and `~/.config/ai-app` so the Uninstall dialog can offer them. Note what deleting the config directory takes with it: the CA under `certs`, which is the one-way door. **Stop** on the server card stops the server a phone reaches through the tunnel, so on that phone it stays down until somebody starts it again; Dev Updater reaches it over its own port and is unaffected, which is what makes the button safe to press and easy to regret. ### Icons **Nerd Fonts glyphs from a committed subset**, not vector assets and not ordinary Unicode. `NerdIcons.kt` declares each codepoint and `app/build-icon-font.sh` subsets the font; the two lists have to agree, because a codepoint in the Kotlin that the script did not subset is a glyph that silently isn't there. Rerun the script and commit its output when adding one — it needs network access. `md-cog` and `md-refresh` are deliberately the same codepoints dev-updater uses and must not drift from it. The subset is the **Mono** face, where every glyph is one em square, which is what makes two icon buttons the same width without either being given one — and why `GLYPH_SIZE` is smaller than it looks like it should be. ## Checking your work - **Server**: `./run-tests.sh` from the repo root (or `cargo test` from `server/`), plus `cargo clippy --all-targets` and `cargo fmt`. The build stays warning-clean and rustfmt-clean at the defaults — there is no `rustfmt.toml` and there should not be one. - **App**: from `app/`, `. ./android-env.sh && ./gradlew :androidApp:ktfmtFormat :androidApp:compileDebugKotlin :androidApp:lintDebug :androidApp:testDebugUnitTest`. The unit tests are JVM-only and cover the syntax highlighter, the ANSI parser and the transcript cache — the app's pure logic with no Android in it. - **Android Lint is not optional and is not run by a build.** It found a crash that had been shipping (`java.time` on a minSdk-24 app with desugaring off) and later a permission check that silently dropped every notification on Android 12 and below. Fully clean as of 2026-08-31; keep it that way, and suppress with `tools:ignore` plus a written reason rather than by lowering the bar. - Then `./build-apk.sh` for the APK to install on a phone through Dev Updater, or `./run-android.sh` to build, install and launch on the emulator. **The phone gets the release build**, signed with a key the script generates once under `~/.config/ai-app/release.jks` (never in the repo); `./build-apk.sh debug` builds the other variant, and Dev Updater's build modes call the script with exactly that word. Dev Updater lists every variant under `build/outputs/apk`, so pick `release` there; a phone still holding the debug build has to uninstall it first, since the two are signed differently. - The emulator scripts stay on the debug build. **Never read a frame time from one as the app's** — a debuggable build runs Compose at a fraction of release speed; the render report says which build it came from. ## Running it here - Run the server for development with `--bind 127.0.0.1`. Without it the server binds wg0, which exists here but is unreachable from the emulator (it dials 10.0.2.2). First run prints the enrollment QR/URI with the token. `ai-server --enroll-link` mints one more device's link while the server keeps running; the server adopts that token on its first use. It is what Dev Updater's Enroll button runs. - Point development at a scratch state directory rather than the real one: `--config /tmp/…/config.ron --data-dir /tmp/…/sessions --port 8444`. - **The APK pins the CA of the machine that builds it**, read at build time from `$XDG_CONFIG_HOME/ai-app/certs/ca.pem` (`AI_APP_CA` overrides). So the server must have started once on that machine first — the build stops with that instruction otherwise — and an APK built in this VM only works against a server in this VM. - Prefer exercising the server directly over going through the UI: `curl --cacert ~/.config/ai-app/certs/ca.pem -H "Authorization: Bearer …" https://127.0.0.1:8443/sessions`. The CA is wherever `--certs` put it — by default under `$XDG_CONFIG_HOME`, never in the checkout, so a relative `certs/ca.pem` finds nothing. The emulator app reaches it at `https://10.0.2.2:8443`; enroll with `adb shell "am start -a android.intent.action.VIEW -d 'aiapp://enroll?host=10.0.2.2&port=8443&token=…'"`. - **`ai-server --delay MS` holds every response back.** Over the tunnel a phone's requests take tens to hundreds of milliseconds, and several faults live entirely in what the app does *while* one is outstanding. On a loopback server those windows close before anything can be observed, so the bug looks like it is not there. - **`RUST_LOG=ai_server=debug`** logs every transcript page with its `before`, `after` and what came back, and logs each SSE subscriber's cursor and whether it was continued or reset (`stream backlog:`). That is the only place "how far had this phone fallen behind" is answerable — the app sees a window arrive and cannot tell. - **`./test-wg-tunnel.sh up|test|down`** builds a real tunnel between two network namespaces inside one machine and drives the server through it — a genuine handshake against 10.66.0.1 with pinned TLS, no router or phone involved. That is how to verify the wg0-only posture. ## Where things run (host vs this VM) The machine itself — the two boxes, the shared `~/repos` mount, and why the VM is untrusted — is described once in `~/.claude/MACHINE.md`. What that means here: - **`ai-server` belongs on the host in production.** That is where the LAN address the phone can reach is, and where WireGuard terminates. `wg-setup-host.sh` sets that up (keys, `wg0.conf`, the phone's QR); run it there with `sudo WG_ENDPOINT=`. - **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. 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. ## Shared appearance - **A row something is happening to is dimmed, drained of colour, and says which operation in a word** — `BusyItem`, used by both the session list and the import list so the appearance is learned once. The word rather than a bare spinner because "deleting" and "importing" differ in kind. It does **not** make the row inert: the caller disables its own click handler while it passes a label. An overlay consuming pointer events was tried and swallowed the drag along with the tap, so a list could not be scrolled while anything in it was busy. - **A rate-limit bar belongs to a session's provider, not to its machine.** One machine offers echo, the Claude CLI and a local model at once and only the CLI spends anything, so a session says which meter reports on it (`usageProvider`, from `DriverKind::usage_provider`, which `usage::providers_for` reads too so the two lists cannot disagree) and the phone matches a snapshot on machine *and* provider. Nothing meters a llama or echo session, and the phone draws **nothing** for one — not a zero, and not "unknown". Nothing while the first fetch is out either: "checking" under a session that turns out to meter nothing is a row the screen then has to withdraw. ## Things that have bitten Project-specific only — a lesson that would bite any project on this machine belongs in `~/.claude/TOOLCHAIN.md` or `~/.claude/MACHINE.md` instead. - **tracing caches callsite interest process-wide.** A test that hits a `tracing::warn!` with no subscriber installed can poison the interest cache for a concurrent test that captures logs (flaky "nothing was logged" failures). Keep every exercise of a logging code path under the one capturing subscriber — that 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.