Claude Code keeps every session as JSONL under `~/.claude/projects/`, and the CLI continues one with `--resume <id>`. `claude.rs` already resumes whenever it finds a resume token in the session directory, for crash recovery -- so importing is that same path with the token written before the driver starts, and there is deliberately no second way to begin a session. The seed goes through `launch` with the ordinary spawn, so the driver never learns which kind it got. Two things the machine answers and the phone does not. **Which sessions exist.** One command per setup rather than one per file, for the reason discovery already gives: over ssh each would be its own connection. Titles come from the first few user records rather than the first, because a session opens with records the CLI injected -- slash commands, caveats around local command output -- which are stored as ordinary user records without the meta flag, so titling by "first user record" produced a list where most rows read `<command-name>/clear`. **Which file an id names.** The phone sends an id and never a path; the server looks it up again among the sessions it enumerated. An enrolled token must not be able to turn a spawn into "read me this file", which is the same rule that keeps a provider's command out of `POST /setups`. Only the tail is replayed. The imported conversation is for reading -- continuing it is the CLI's job, and it reads the whole file itself -- so this is a display budget, and it has to be one: the session this was written in is 39 MB, and all of it would otherwise cross a tunnel to a phone. A recorded working directory can outlive itself, which this found immediately: every session from before the checkouts moved to `~/repos` still records `~/host/repos/...`. Resuming into one fails at `cd` before the CLI starts -- a confusing way to meet a feature whose promise is "carry on where you left off" -- so the directory is checked, and a missing one is dropped with a log line naming it rather than being passed on to fail. Verified against this very session: 905 events replayed from the tail (351 tool calls, 350 results, 185 assistant messages, 19 mine), the resume token pointing at its id, and the stale directory reported and dropped. The list was read on the emulator, where the top row is that session under its opening sentence.
16 KiB
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>— whichclaude.rsalready 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 ofPOST /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.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.rsbootstraps (TLS, the auth layer, token/QR enrollment, wg0 binding),routes.rshas the HTTP table in its module doc comment,auth.rsthe bearer-token middleware,config.rsthe persisted schema (written in the shared RON house rules),session/the manager (registry pattern),Drivertrait + event model,EchoDriver, and transcripts.app/— Compose Android app, single:androidAppmodule, packagecom.example.aiapp, label "AI Sessions".AppRoot.ktis the navigationwhen;Api.kt/EventStream.ktthe REST + SSE clients;Events.ktthe event model mirror;ServerConfig.ktsettings + Keystore-sealed token; screens inSessionListScreen/SessionScreen/SpawnScreen/SettingsScreen..dev-updater.ron— what Dev Updater is asked to do with this checkout: the backend (built inserver/, run asservice: Managed(...)) and then the APK (built inapp/), 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 inapp/.Managedmeans Dev Updater supervisesai-serverwith 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'sREADME.mdfor the diff that decided each one and the permissions bug the extraction found. Clone withgit clone --recurse-submodules, orgit submodule update --initin 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/certsand 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.shfrom the repo root (orcargo testfromserver/) +cargo clippy --all-targets+cargo fmt. The build stays warning-clean and rustfmt-clean at the defaults — there is norustfmt.tomland 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.shto produce the APK to install on a phone (through Dev Updater), or./run-android.shto 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.timeon 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 withtools:ignoreplus 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_CAoverrides) 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--certsput it — by default under$XDG_CONFIG_HOME(~/.configwhen that is unset), never in the checkout, so a relativecerts/ca.pemfinds nothing. The emulator app reaches it athttps://10.0.2.2:8443; enroll it withadb 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-serverbelongs on the host in production. That is where the LAN address the phone can reach is, and where WireGuard terminates.wg-setup-host.shsets that up (keys,wg0.conf, the phone's QR); run it there withsudo 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-serverwith 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.1for app work../test-wg-tunnel.sh up|test|downbuilds 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
claudeCLI 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.ronandcerts/,$XDG_DATA_HOME/ai-app/sessions/, owner-only. - Certificates are generated by the server, on first start, into
$XDG_CONFIG_HOME/ai-app/certs(--certsoverrides). 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, orXDG_CONFIG_HOME=… XDG_DATA_HOME=….
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'sCertificateFactoryits preamble sniff, so it tries DER instead and fails at runtime withASN.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.EnrollmentScanActivityalso turns off the library's 10% framing-rect inset (it decodes only what is inside it) and its laser/result-point decorations.