# ai-app A phone interface to AI coding sessions (Claude Code and llama.cpp via pi), replacing the Claude app for daily use. Rust/Axum backend on the desktop, Kotlin/Compose Android app, WireGuard + pinned self-signed TLS + bearer token between them. **`PLAN.md` is the design source of truth.** Read it before building or changing anything structural. It records every decision with its date, its rationale, and the alternatives that were rejected and why — keep that habit when a decision changes: update the plan in place, don't let this file and the plan drift into two versions of the truth. This file is the working notes layer: conventions, commands, and things that have bitten. The central design point, worth not undoing by accident: **a session is a child process speaking JSONL over stdio, translated into one common event model.** Claude Code (stream-json) and pi (RPC mode) are two translators behind one `Driver` trait; the transcript, the SSE stream, the phone UI, and SSH spawning (the same command wrapped in `ssh host …`) all work purely in the common model. A new session type is a new driver — never a session-type branch in shared code (routes, transcript, app screens). ## Layout Mirrors `../dev-updater` deliberately — same stack (axum 0.8 + axum-server/rustls, tokio, clap; Kotlin 2.4.x + Compose Multiplatform, single `:androidApp` module), same cert scheme, same registry pattern (every session mutation funnels through the manager so in-memory and on-disk state can't come apart). Read dev-updater's `README.md` and `AGENTS.md` for the conventions before diverging from them; module-by-module intent for this repo is in PLAN.md's "Backend layout" section. - `server/src/models.rs` — downloaded GGUF models and the HuggingFace browsing behind them. Downloads are keyed by the model rather than by who asked, so any device can watch one; they resume through HTTP Range, refuse to resume onto a partial from a different revision, and are checked against HuggingFace's published sha256 before the file gets its real name. - `server/` — Rust backend (`ai-server`). `main.rs` bootstraps (TLS, the auth layer, token/QR enrollment, wg0 binding), `routes.rs` has the HTTP table in its module doc comment, `auth.rs` the bearer-token middleware, `config.rs` the persisted schema and the RON its file is written in, `session/` the manager (registry pattern), `Driver` trait + event model, `EchoDriver`, and transcripts. - `app/` — Compose Android app, single `:androidApp` module, package `com.example.aiapp`, label "AI Sessions". `AppRoot.kt` is the navigation `when`; `Api.kt`/`EventStream.kt` the REST + SSE clients; `Events.kt` the event model mirror; `ServerConfig.kt` settings + Keystore-sealed token; screens in `SessionListScreen/SessionScreen/SpawnScreen/SettingsScreen`. - `.dev-updater.ron` — what Dev Updater is asked to do with this checkout: the backend (built in `server/`, installed and controlled through `server/service`) and then the APK (built in `app/`), in that order. The project it serves is the repository, not either half of it, which is why this sits at the root rather than in `app/`. - `server/src/certs.rs` — the TLS certificates, generated in process on first start into `$XDG_CONFIG_HOME/ai-app/certs`: idempotent CA, leaf reissued every start covering every local IPv4 plus 127.0.0.1 and 10.0.2.2 (emulator → host). Regenerating the CA strands the installed app — the one-way door. ## Status Phases 1–3 done 2026-08-24 (see PLAN.md's phase list for what each verified): the skeleton pipe, the full Claude driver (streaming, tools, permission + AskUserQuestion cards, steering, interrupt, `--resume` crash recovery, images both ways), and the usage screen. **Phase 5 (SSH) is written and now exercised** (2026-08-28). A session names a host, `session::transport` turns that into an `ssh host …` invocation, and the driver never learns which it got. **Phase 4 (llama.cpp) works on the server side** (2026-08-28). Models are browsed and downloaded from HuggingFace (`models.rs`, resumable and verified), and `session::llama` runs one through `llama-server`, talking to its OpenAI-compatible streaming endpoint. Two things about it are deliberate and easy to undo by accident: the conversation is rebuilt from the **transcript** rather than kept in the driver, because driver memory is invisible to a second device; and a llama session is refused on an ssh host, because the model is reached over HTTP and forwarding that port is not built. No app screen yet — models are driven through the routes. What is left is the models UI, and real-phone/WireGuard bring-up, which is operational rather than code. **Testing llama.cpp here:** the prebuilt CPU build lives outside the repo at `~/.local/opt/llama.cpp` (the 15 MB `ubuntu-x64` release asset — no compiling, and it runs fine on Arch). 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. **How to test SSH here, since there is no second machine:** ssh this VM to itself. Generate a throwaway key, append the public half to `~/.ssh/authorized_keys`, and configure a host of `bob@127.0.0.1` with `identityFile` pointing at it plus `options: ["StrictHostKeyChecking=no", "UserKnownHostsFile=…"]` so it touches nothing real. Point a provider's `command` at something harmless like `/bin/echo` rather than at `claude`: the transport is what is under test, the process exiting immediately is the signal, and it costs no tokens. A session spawned on that host logs `running /bin/echo on loopback (bob@127.0.0.1)` and lands `status: exited` in its transcript, which is the whole path — connection, remote exec, process death reported. **Take the key back out afterwards**; this VM's `authorized_keys` is not scratch space. Note the remote login shell here is **fish**, not a POSIX shell. The remote script (`cd '…' && exec '…'`) happens to be valid in both, and the POSIX single-quote escaping `ssh.rs` does happens to mean the same thing in fish — but that is luck rather than design, and a shell that isn't either would be the thing to suspect first if a remote spawn ever mangles an argument. ## Checking your work - Server: from `server/`, `./run-tests.sh` (or `cargo test`) + `cargo clippy --all-targets` + `cargo fmt`. The build stays warning-clean and rustfmt-clean at the defaults — there is no `rustfmt.toml` and there should not be one. - App: from `app/`, `. ./android-env.sh && ./gradlew :androidApp:ktfmtFormat :androidApp:compileDebugKotlin :androidApp:lintDebug` — format, typecheck and lint, the app-side equivalent of the line above. Then `./build-apk.sh` to produce the APK to install on a phone (through Dev Updater), or `./run-android.sh` to build, install, and launch on the emulator. - **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. It is clean now apart from Compose 1.11.1 having a 1.12.0 available; keep it that way, and suppress with `tools:ignore` plus a written reason rather than by lowering the bar. - **The APK pins the CA of the machine that builds it**, read at build time from `$XDG_CONFIG_HOME/ai-app/certs/ca.pem` (`AI_APP_CA` overrides) and generated into a constant. So the server must have started once on that machine first — the build stops with that instruction otherwise — and an APK built in this VM only works against a server in this VM. - Run the server for development with `--bind 127.0.0.1`. Without it the server binds wg0, which exists here but is unreachable from the emulator (it dials 10.0.2.2). First run prints the enrollment QR/URI with the token — capture it from the log. - Prefer exercising the server directly over going through the UI: `curl --cacert ~/.config/ai-app/certs/ca.pem -H "Authorization: Bearer …" https://127.0.0.1:8443/sessions`. The CA is wherever `--certs` put it — by default under `$XDG_CONFIG_HOME` (`~/.config` when that is unset), never in the checkout, so a relative `certs/ca.pem` finds nothing. The emulator app reaches it at `https://10.0.2.2:8443`; enroll it with `adb shell "am start -a android.intent.action.VIEW -d 'aiapp://enroll?host=10.0.2.2&port=8443&token=…'"` (quote so the device shell doesn't eat the `&`s). ## Where things run (host vs this VM) Established 2026-08-25, and it decides more than it looks like. The machine itself — the two boxes, the shared `~/repos` mount, gitea, and why the VM is treated as untrusted — is described once in `~/.claude/MACHINE.md`; what follows is only what that means for **this** project. - **`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) now exists in this VM too, so the production path — `ai-server` with no `--bind` — is exercisable during development. It has no reachable peer and doesn't need one; the interface existing is what the server requires. Consequence: **with no `--bind`, the emulator can't reach the server** (it dials 10.0.2.2), so keep using `--bind 127.0.0.1` for app work. - `./test-wg-tunnel.sh up|test|down` builds a real tunnel between two network namespaces inside one machine and drives the server through it — a genuine handshake against 10.66.0.1 with pinned TLS, no router or phone involved. That's the way to verify the wg0-only posture. - **The `claude` CLI is only in the VM, so from the host it is a remote.** The backend reaches it the way it would any other machine: a configured host, and a session that names it. For the host to ssh in, the VM needs an inbound port forward it does not have by default. - **Nothing secret goes in the repo**, which is shared with the host and attacker-writable under this project's threat model (PLAN.md's security section). So state lives outside it: `$XDG_CONFIG_HOME/ai-app/config.ron` and `certs/`, `$XDG_DATA_HOME/ai-app/sessions/`, owner-only. - Certificates are generated **by the server, on first start**, into `$XDG_CONFIG_HOME/ai-app/certs` (`--certs` overrides). The CA is created once and then left alone; the leaf is reissued every start, so covering a new address is a restart. Starting the server in the VM therefore makes a separate throwaway dev CA for emulator work — never install a build pinning that on the real phone. - Point development at a scratch state directory rather than the real one: `--config /tmp/…/config.ron --data-dir /tmp/…/sessions --port 8444`, or `XDG_CONFIG_HOME=… XDG_DATA_HOME=…`. ## Things that have bitten Project-specific only — a lesson that would bite any project on this machine belongs in `~/.claude/TOOLCHAIN.md` (toolchain versions) or `~/.claude/MACHINE.md` (the machine itself) instead. - **tracing caches callsite interest process-wide.** A test that hits a `tracing::warn!` with no subscriber installed can poison the interest cache for a concurrent test that captures logs (flaky "nothing was logged" failures). Keep every exercise of a logging code path under the one capturing subscriber — that's why the auth middleware has a single combined gating+logging test. - **The keyboard pans the window unless the activity opts into resize.** Without `android:windowSoftInputMode="adjustResize"`, opening the IME slides the whole window up (top bar off screen) instead of resizing — `imePadding()` alone doesn't fix it and the transcript looks empty. - **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. `EnrollmentScanActivity` also turns off the library's 10% framing-rect inset (it decodes only what is inside it) and its laser/result-point decorations.