ai-server prints the QR as block characters, which take the terminal's foreground colour. On a dark-themed terminal that comes out as a photographic negative, and ZXing looks only for a dark code on a light ground, so the in-app scanner silently never matched it -- while the phone's own camera app, which tries both, did. Fixing it in the scanner rather than by having the server force its colours means however the code gets displayed stops mattering: a light terminal, a dark one, a screenshot someone inverted, a printout. MIXED_SCAN alternates normal and inverted frames, so the cost is half the frame rate at each polarity. Checked against the library's own MixedDecoder off-device: the plain decoder reads the normal image and returns nothing for the inverted one, and the mixed decoder reads both within two frames. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QQz6R4kBQcWSHBNZMgBnjL
183 lines
11 KiB
Markdown
183 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, `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`.
|
||
- `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.json`
|
||
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.json --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.
|
||
- **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.
|