The same move, for the same reason: this file is written and read by hand, and JSON has no comments to say why a host is configured the way it is. Both house rules come across with it, in config.rs's `format` module and nowhere else -- a file is the *body* of the config, so no outer parentheses and nothing indented for them, and `Some` is implicit, which is what makes `skip_serializing_if` on every optional field load-bearing rather than tidiness. The switch is outright: there is no reader for the old format. That is invisible everywhere except here, because this file holds the enrolled token hashes -- starting empty leaves the phone unable to talk to the server and looks, from the phone, like the config having been lost. So a config.json left beside the new file is named in the log and left alone, rather than read or deleted. One wart, documented at DriverKind: the kebab-case spelling is the string the phone compares against, so it stays, and the file pays for it with `kind: r#claude-cli` -- a hyphen is not a RON identifier. Renaming the variant would change what an already-installed build is talking to. Verified: cargo test, cargo clippy --all-targets, and a real start against a scratch state directory -- a hand-typed config with comments and a bare `port: 2222` loads, and what the server writes back sits at column 0 with no Some(...) in it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
191 lines
11 KiB
Markdown
191 lines
11 KiB
Markdown
# 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.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 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` (or `cargo test`) + `cargo clippy --all-targets`
|
||
from `server/` — the build stays warning-clean, keep it that way.
|
||
- App: from `app/`, `. ./android-env.sh && ./gradlew :androidApp:compileDebugKotlin`
|
||
to typecheck; `./build-apk.sh` to produce the APK to install on a phone
|
||
(through Dev Updater); `./run-android.sh` to 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_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` (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 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 host (192.168.1.168) is the backend machine.** It runs
|
||
dev-updater's server today and is where `ai-server` belongs in
|
||
production: it has the LAN address the phone can reach, and it's where
|
||
WireGuard terminates. `wg-setup-host.sh` sets that up (keys, `wg0.conf`,
|
||
the phone's QR); run it there with `sudo WG_ENDPOINT=<ddns name>`.
|
||
- **This VM is a dev sandbox behind qemu user-mode networking**
|
||
(10.0.2.15, gateway 10.0.2.2) — outbound only. The host is reachable at
|
||
10.0.2.2, but **nothing outside can initiate a connection into the VM**,
|
||
so the tunnel and the real phone can never terminate here.
|
||
- **Code reaches the host through gitea, not the shared mount.** This VM's
|
||
checkout (`~/host/repos/ai-app`, a virtiofs mount the host also sees at
|
||
`~/stuff/vm/ai/repos/ai-app`) is a working copy only: its `origin` is
|
||
`git@git.arirex.me:iris/ai-app`, and the VM's key is **not** authorized
|
||
for it — pushing from here fails with `Permission denied (publickey)`.
|
||
The host pushes, and its own separate clone — outside the shared mount —
|
||
is what gets built and run. So the review at push time, not a filesystem
|
||
permission, is what keeps VM-authored code off the host.
|
||
- Consequence for building here: `server/target/` is shared with the host's
|
||
view of *this* checkout, so if anything on the host ever builds from the
|
||
shared path, the two `cargo build`s replace each other's binary and each
|
||
rebuilds from scratch. Building on the host from its own clone avoids it
|
||
entirely.
|
||
- `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 installed in this VM only, not on the host.** So
|
||
the backend reaches it the same 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 in its launch configuration
|
||
(qemu `hostfwd`) — usermode networking has none by default.
|
||
- **Nothing secret goes in the repo.** The VM is treated as untrusted (see
|
||
PLAN.md's security section), and the repo is shared read-write with the
|
||
host, 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
|
||
|
||
- **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** — declare
|
||
`org.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'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.
|
||
- **AGP 9 refuses `Provider`s in the source-set API**: generated sources go
|
||
through `androidComponents.onVariants { it.sources.java?.addGenerated
|
||
SourceDirectory(task, Task::outputDir) }`, which also carries the task
|
||
dependency.
|
||
|
||
## Environment notes (this machine, learned in dev-updater)
|
||
|
||
- Android SDK is at `~/Android/Sdk`, not the root-owned `/opt/android-sdk`
|
||
the ambient `$ANDROID_HOME` may point at; copy dev-updater's
|
||
`android-env.sh` override pattern.
|
||
- Each agent command runs in a fresh shell — exported environment does not
|
||
carry over. Chain: `cd app && . ./android-env.sh && ./gradlew …`. Never
|
||
pipe `source` into `head`/`grep` (subshell discards the exports).
|
||
- The shared emulator AVD is named `tdep` — one emulator across the Android
|
||
projects on this machine, not one per repo.
|
||
- Long-running servers launched from an agent must be fully detached
|
||
(`setsid nohup … & disown -h`, verify `PPID 1`), and every
|
||
`pgrep -f`/`pkill -f` pattern needs its first character bracketed
|
||
(`[a]i-server`) in **every** occurrence in the command, or the pattern
|
||
matches the shell running it. Full explanation in dev-updater's
|
||
`AGENTS.md` — it bites exactly the same way here.
|