AGENTS.md had grown a description of the machine these sessions run on -- the two boxes, the qemu networking, the shared ~/repos mount, gitea and the push permission, the Android SDK path, the shared emulator, how to detach a server. None of that is about ai-app, and all of it is equally true in the sibling repos, so each copy was a place for the truth to drift: the push note in this one had been wrong since the bot key was authorized, and the checkout path it named had moved. It now lives once in ~/.claude/MACHINE.md, which every session already reads. What stays here is only what the machine means for this project: that ai-server belongs on the host because WireGuard terminates there, that the tunnel can never terminate in the VM, that the claude CLI being VM-only makes it a remote to the backend, and where this project's secrets live instead of the repo. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
9.4 KiB
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/— Rust backend (ai-server).main.rsbootstraps (TLS, the auth layer, token/QR enrollment, wg0 binding),routes.rshas the HTTP table in its module doc comment,auth.rsthe bearer-token middleware,config.rsthe persisted schema and the RON its file is written in,session/the manager (registry pattern),Drivertrait + event model,EchoDriver, and transcripts.app/— Compose Android app, single:androidAppmodule, packagecom.example.aiapp, label "AI Sessions".AppRoot.ktis the navigationwhen;Api.kt/EventStream.ktthe REST + SSE clients;Events.ktthe event model mirror;ServerConfig.ktsettings + Keystore-sealed token; screens inSessionListScreen/SessionScreen/SpawnScreen/SettingsScreen..dev-updater.ron— what Dev Updater is asked to do with this checkout: the backend (built inserver/, installed and controlled throughserver/service) and then the APK (built inapp/), 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 inapp/.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 4
(pi/llama.cpp) is deferred — not testable in this VM. Next: phase 5
(SSH; needs a decision on how to test — no keys in ~/.ssh here) and
real-phone/WireGuard bring-up, which is operational rather than code.
Checking your work
- Server:
./run-tests.sh(orcargo test) +cargo clippy --all-targetsfromserver/— the build stays warning-clean, keep it that way. - App: from
app/,. ./android-env.sh && ./gradlew :androidApp:compileDebugKotlinto typecheck;./build-apk.shto produce the APK to install on a phone (through Dev Updater);./run-android.shto build, install, and launch on the emulator. - 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_CAoverrides) 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(wg0 doesn't exist on this machine yet; the default fails closed). 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 certs/ca.pem -H "Authorization: Bearer …" https://127.0.0.1:8443/sessions. The emulator app reaches it athttps://10.0.2.2:8443; enroll it withadb 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-serverbelongs on the host in production. That is where the LAN address the phone can reach is, and where WireGuard terminates.wg-setup-host.shsets that up (keys,wg0.conf, the phone's QR); run it there withsudo 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) now exists in this VM too, so the production path —ai-serverwith 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.1for app work../test-wg-tunnel.sh up|test|downbuilds 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
claudeCLI 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.ronandcerts/,$XDG_DATA_HOME/ai-app/sessions/, owner-only. - Certificates are generated by the server, on first start, into
$XDG_CONFIG_HOME/ai-app/certs(--certsoverrides). 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, orXDG_CONFIG_HOME=… XDG_DATA_HOME=….
Things that have bitten
- 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. - CMP 1.11 deprecates the
compose.*dependency accessors — declareorg.jetbrains.compose.<x>:<x>directly (material3 has its own release train, separate from the CMP version). - A PEM constant must start at the opening quotes. A generated
"""\n-----BEGIN CERTIFICATE-----costs Android'sCertificateFactoryits preamble sniff, so it tries DER instead and fails at runtime withASN.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.EnrollmentScanActivityalso turns off the library's 10% framing-rect inset (it decodes only what is inside it) and its laser/result-point decorations. - AGP 9 refuses
Providers in the source-set API: generated sources go throughandroidComponents.onVariants { it.sources.java?.addGenerated SourceDirectory(task, Task::outputDir) }, which also carries the task dependency.