Files
ai-app/AGENTS.md
T
irisandClaude Opus 5 184b6fc6a6 Correct the claim that reattach is local only
Written down as "an ssh session's child dies with its connection, so it
takes the ordinary --resume path". The code never had that branch: `start`
records a pid whatever the transport, and for a remote session the process
the backend owns is the ssh client. Adopting it is right -- the fifo feeds
it, its logs capture the far end, and ssh lives exactly as long as the
remote command, so its liveness is the session's.

The docs claimed less than the code does, which is the safe direction to be
wrong in but still wrong, and it was about to mislead someone: a remote
`claude` has an sshd pipe on stdin under every version of this server,
because the fifo is on the backend's side of the connection. Reading a
remote session's stdin therefore says nothing about which backend started
it, and we were an inch from concluding otherwise.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VETa8afmpWaYezLCqJhDB8
2026-08-29 13:57:12 -04:00

349 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.
Images in the replayed tail are written into the session's `files/` by
the same function the live translator uses, and referenced -- so a
screenshot looks the same whether it was watched happening or replayed
afterwards, the phone fetches the bytes only when it draws one, and none
of it goes anywhere near the CLI, which reads its own file.
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/usage.rs` — rate-limit windows, asked **of each machine that
can run Claude**, not of the backend. Credentials are read through the
session `Transport`, so a remote setup is an ssh round trip and the local
one is unchanged; the HTTP call stays here. A machine with no Claude
provider is never asked. The four states (`ok`, `notLoggedIn`,
`unreachable`, `failed`) exist because a machine nobody logged in on is a
choice rather than a fault, and one `error` string made it look like one.
- `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 13 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.
- Remote sessions are adopted too. The pid recorded for one is the **`ssh`
client's**, on this machine — that is the process the backend owns, and it
lives as long as the remote command does. (This said "local only" until
2026-08-29; the code never had that branch.) Note the far `claude` always
has an sshd pipe on stdin whichever version started it, since the fifo is
on the backend's side — so you cannot tell a backend's version by looking
at a remote session's stdin.
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.