Six things Bryan asked for, which turned out to be one change: the app had no icon set, so every one of them was blocked on having somewhere for icons to come from. That somewhere is dev-updater's arrangement, ported: a Nerd Fonts subset committed as an asset, drawn as text. `Gear.kt`'s hand-drawn canvas gear argued against icon fonts because a system font may not have the glyph and whoever gets the empty box is never the person who wrote it. The objection is right about *relying* on a system font and the answer is to ship the glyph, so the file is gone and its reasoning is restated in `NerdIcons.kt` rather than deleted -- otherwise the next reader re-derives it. `md-cog` and `md-refresh` are dev-updater's own codepoints, because a cog means the same thing in both apps. The root screen's four words under the title are now four tabs, and the two that act on the whole screen -- settings and refresh -- moved up onto the title row as glyphs. That row's old comment recorded that a fifth word would have had nowhere to go; tabs also say something the words did not, which is that sessions, import, models and setups are four views of one backend rather than four errands. Refresh feeds whichever tab is showing. Import, models and setups lose their headings and their Back buttons, since the tab row is now both. Usage is a dialog. It is checked *against* what you were reading -- "can I start this" is asked with the transcript still on screen -- and it had no navigation of its own, so the only thing its Back could mean was "put this away". The button that opens it is a chart glyph coloured by the worst of the machine's windows, so the row says whether the limits are worth opening before anybody opens them. One `quotaColor` now colours every bar that measures a quota: blue, yellow at 75%, red at 95%. The session bar escalates where it used to sit blue at every level, and the dialog's thresholds moved out of it. A download keeps plain blue at every value -- it has no limit to approach, and colouring it like one would say the opposite of what is happening. States that are not measurements take the ordinary control colour, since blue is the low end of this scale and would read as "checked, and fine" about a machine nobody could reach. Send and stop are the filled paper plane and the filled square. Send keeps the word "Queue" while a turn is in flight, because that is what pressing it then does, and an icon that does two things while looking identical would promise something immediate and do something that waits. Looked at on the emulator: all six glyphs render, the tabs and the system back gesture between them, the dialog over a live session, and the bar bands at 82% and 97% forced through a scratch build, since this account is at 72/31/5 and would only ever have shown blue.
342 lines
20 KiB
Markdown
342 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.
|
||
Images in the replayed tail are written into the session's `files/` by
|
||
the same function the live translator uses, so a screenshot looks the
|
||
same whether it was watched happening or replayed afterwards, and the
|
||
phone fetches the bytes only when it draws one.
|
||
An imported session then **keeps itself level with that file**, so work
|
||
done at a terminal appears without anyone pressing anything. Which new
|
||
lines came from *here* is answered by counting the events this session
|
||
has recorded, **not** by looking at its status — 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`; `MainScreen.kt` the root's four tabs (sessions, import, models,
|
||
setups) with settings and refresh on the title row; `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`.
|
||
**Icons are Nerd Fonts glyphs from a committed subset**, not vector assets
|
||
and not ordinary Unicode — `NerdIcons.kt` declares each codepoint and
|
||
`app/build-icon-font.sh` subsets the font. The two lists have to agree: a
|
||
codepoint in the Kotlin that the script did not subset is a glyph that
|
||
silently isn't there. Rerun the script and commit its output when adding
|
||
one; it needs network access. `md-cog` and `md-refresh` are deliberately
|
||
the same codepoints dev-updater uses and must not drift from it.
|
||
- `.dev-updater.ron` — what Dev Updater is asked to do with this checkout:
|
||
the server (built in `server/`, run as `service: Managed(...)`) and the
|
||
APK (built in `app/`), built in parallel. The project it serves is the
|
||
repository, not either half of it, which is why this sits at the root
|
||
rather than in `app/`.
|
||
It points at `resources.ron` beside it, which says this project keeps its
|
||
state as `ai-app` — so the Uninstall dialog offers `~/.local/share/ai-app`
|
||
and `~/.config/ai-app` instead of saying it cannot tell. That file is
|
||
*ours*, not Dev Updater's: it ignores keys it doesn't know, so anything
|
||
else worth keeping in one place belongs there too. Note what deleting the
|
||
config directory takes with it — the CA under `certs`, which is the
|
||
one-way door described below.
|
||
`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 server 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. 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 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.
|
||
## Status
|
||
|
||
Phases 1–3 done 2026-08-24 (PLAN.md's phase list says 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 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` over
|
||
its OpenAI-compatible streaming endpoint. Two things 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.
|
||
|
||
Setups — machines, each carrying what it can run — are added, renamed,
|
||
re-probed and removed from the app; 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, 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). 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. **Take the key back out afterwards.** Note the remote login shell
|
||
here is **fish**; the remote script (`cd '…' && exec '…'`) and `ssh.rs`'s
|
||
POSIX quoting happen to mean the same thing in both, but that is luck
|
||
rather than design, and a shell that isn't either is 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. The machine itself — the two boxes, the shared
|
||
`~/repos` mount, and why the VM is untrusted — is described once in
|
||
`~/.claude/MACHINE.md`; what follows is only what that means here.
|
||
|
||
- **`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) 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. 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 how 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 as it would any other machine: a configured host,
|
||
and a session that names it.
|
||
- **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). 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 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 — 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`.
|
||
|
||
## 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.
|