The import list reported a line count, which is the wrong axis: these transcripts embed screenshots as base64, so one line can be a megabyte. On this machine a 69 MB session has 3,427 lines while a 44 MB one has 6,792 — the number on the row said nothing about what continuing the session would cost, and size is the only thing there that predicts it. The session behind the 2026-08-29 incident was 65 MB across 13,000 lines, a line count that looks unremarkable. Shown beside the line count rather than instead of it, since a short file of long lines is exactly the expensive case. Not warned about and not marked: importing a large session is a choice somebody is entitled to make, and flagging it would be the interface nagging about a decision already taken. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VETa8afmpWaYezLCqJhDB8
332 lines
20 KiB
Markdown
332 lines
20 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/src/session/import.rs` — continuing a Claude Code session the
|
||
machine already has. Claude Code keeps each one as JSONL under
|
||
`~/.claude/projects/`, and the CLI resumes one with `--resume <id>` —
|
||
which `claude.rs` already does for crash recovery, so an import is that
|
||
same path with the token written up front rather than a second way to
|
||
start a session. The phone picks an **id**, never a path: the server
|
||
resolves which file that is, so an enrolled token cannot become "read me
|
||
an arbitrary file", the same rule that keeps a command out of
|
||
`POST /setups`. Only the tail is replayed (`REPLAY_LINES`) because these
|
||
files reach tens of megabytes and the CLI reads the real one itself; what
|
||
crosses the tunnel is what a person reads, not what the model is given.
|
||
An imported session then **keeps itself level with that file**, so work
|
||
done at a terminal appears without anyone pressing anything. `--resume`
|
||
appends to the same transcript rather than forking — measured, but
|
||
**against a session nothing else had open**; against a live one it
|
||
duplicates the whole conversation into the file, which is why an open
|
||
session cannot be imported at all (see below). So the
|
||
only hard question is which new lines came from *here* — answered by
|
||
counting the events this session has recorded, not by looking at its
|
||
status. Status is the obvious signal and is wrong: a turn that starts and
|
||
finishes between two polls reads as idle at both, and its own output gets
|
||
replayed on top of itself. That bug was visible on screen as `donedone`.
|
||
- `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 (written in the shared RON house rules),
|
||
`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/`, run as `service: Managed(...)`) 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/`.
|
||
`Managed` means Dev Updater supervises `ai-server` with its own built-in
|
||
service implementation rather than a script kept here. ai-app had such a
|
||
script until 2026-08-28 and it was the generic case exactly — no
|
||
arguments, no environment — so the two projects were maintaining one
|
||
behaviour twice, including the OpenRC branch neither can test from a
|
||
systemd machine.
|
||
Worth knowing before pressing it: **Stop** on the backend card stops the
|
||
server that a phone reaches through the tunnel, so on that phone it stays
|
||
down until someone starts it again from Dev Updater. Dev Updater reaches
|
||
it over its own port and is unaffected, which is what makes the button
|
||
safe to press and easy to regret.
|
||
- `wg-app-link/` — a **git submodule**, and the half of this backend that
|
||
dev-updater also needed: 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`). Both projects had written all five, and they had drifted;
|
||
see that repo's `README.md` for the diff that decided each one and the
|
||
permissions bug the extraction found. Clone with
|
||
`git clone --recurse-submodules`, or `git submodule update --init` in an
|
||
existing checkout — `server/` will not build without it, since it is a
|
||
path dependency rather than a registry one, which is what keeps the two
|
||
projects version-locked to the commit this repo pins.
|
||
The certificates are still the one-way door: the CA is generated once on
|
||
first start into `$XDG_CONFIG_HOME/ai-app/certs` and regenerating it
|
||
strands the installed app.
|
||
What deliberately did **not** move is the API surface and the config
|
||
*schema* — routes, drivers, sessions and setups are what makes this
|
||
project itself, and a shared vocabulary for them would be inventing one
|
||
neither project wants.
|
||
|
||
## 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 end to end, phone included** (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.
|
||
|
||
Models are browsed, downloaded and deleted from the app, and a llama
|
||
session picks one of them plus a context size and temperature. Setups —
|
||
machines, each carrying what it can run — are added, renamed, re-probed
|
||
and removed from the app too; providers are **discovered by asking the
|
||
machine**, never typed, so the enrolled token cannot introduce a command.
|
||
|
||
What is left is real-phone/WireGuard bring-up, which is operational rather
|
||
than code.
|
||
|
||
**`command -v` follows PATH under a non-interactive ssh session**, which is
|
||
not the PATH a login shell shows, so a binary somewhere unusual is
|
||
invisible to discovery — llama.cpp unpacked into `~/.local/opt` needs a
|
||
symlink into `~/.local/bin` before a setup finds it. The escape hatch for
|
||
anything odder is editing `config.ron` on the backend, which is
|
||
deliberately the one authority the phone does not have.
|
||
|
||
**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: `./run-tests.sh` from the repo root (or `cargo test` from
|
||
`server/`) +
|
||
`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=<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-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=…`.
|
||
|
||
## 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 — so restarting the
|
||
backend does not end a turn. PLAN.md has the design; what matters day to
|
||
day:
|
||
|
||
- **Stopping the server no longer stops the sessions.** After `pkill
|
||
ai-server` the `claude` processes are still there, on purpose, and the
|
||
next start picks them up (`reattaching to the claude-cli it left
|
||
running` in the log). To actually end one, delete the session — that is
|
||
the only path that stops a process.
|
||
- **Each session directory now 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.
|
||
- **`--resume` only ever runs when nothing is running.** That check is the
|
||
fix for the incident below, and the reason there is one entry point
|
||
(`ClaudeDriver::launch`) rather than a spawn and an attach.
|
||
- Local only: an ssh session's child dies with its connection, so it takes
|
||
the ordinary `--resume` path.
|
||
|
||
The import list reports each session's **size as well as its line count**,
|
||
because the two disagree in the way that matters: these transcripts embed
|
||
screenshots as base64, so one line can be a megabyte. On this machine a
|
||
69 MB session has 3,427 lines and a 44 MB one has 6,792 — nothing about a
|
||
line count tells you what continuing a session will cost. Shown, not warned
|
||
about; importing a large session is a choice somebody is entitled to make.
|
||
|
||
**Never import a Claude Code session that is open in a terminal.** The app
|
||
refuses it now — it reads `~/.claude/sessions/<pid>.json`, which Claude
|
||
Code keeps for every live session, and checks the pid's start time so a
|
||
descriptor left by a crashed CLI doesn't count. Refused rather than warned
|
||
about, because on 2026-08-29 an agent imported the session it was *itself*
|
||
running in. That put two `claude --resume` processes on one file: the whole
|
||
65 MB conversation, 154 embedded screenshots included, was re-appended to
|
||
the transcript under a new prompt id, both copies replayed each other's
|
||
writes as work done elsewhere, and the adopted one was billed for re-reading
|
||
all of it. It ended at the account's session limit, with three `claude`
|
||
processes running against one checkout.
|
||
|
||
## 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.
|
||
- **A reconnecting phone used to be sent the entire backlog.** The SSE
|
||
stream replayed everything after the client's cursor, unbounded, while
|
||
*opening* a session was bounded to a page — so a long disconnect
|
||
delivered thousands of events one frame at a time. Past
|
||
`CATCH_UP_LIMIT` the stream now sends a `reset` frame and the newest
|
||
window instead, and the client rebuilds from it exactly as it does when
|
||
the screen opens. The reset is not optional: without it the window is
|
||
spliced onto rows that are no longer adjacent to it, which reads as
|
||
ordinary output.
|
||
- **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.
|