Files
ai-app/AGENTS.md
T
irisandClaude Opus 5 362d436d4f Let sessions outlive the backend, and never resume one twice
Three `claude` processes ended up running against this checkout on
2026-08-29, and the account hit its session limit. One cause, several
ways in.

An agent imported the Claude Code session it was *itself* running in.
That is an ordinary import, and importing runs `--resume` -- so a second
CLI attached to a file the first was still writing. The whole 65 MB
conversation, 154 embedded screenshots included, was re-appended to the
transcript under a new prompt id; both copies then read each other's
writes as work done elsewhere, and the adopted one was billed for
re-reading all of it. Meanwhile `shutdown_all` asked each session to stop
and the process exited immediately, so the SIGKILL timer died with the
runtime, the stop was unreliable, and whatever survived was orphaned with
nothing written down to find it by.

The processes leaked either way. So leak them on purpose, and be able to
pick them back up.

A session's process now outlives the backend and is adopted again on the
way up, which is worth having for its own sake: restarting the server no
longer ends a turn somebody is waiting on. Its stdio lives in the session
directory -- a fifo opened read-write so the process is its own last
writer and never reads EOF, plus stdout/stderr logs read from a byte
offset. `session::process` records the pid *and* the kernel's start time
for it, because a pid alone is reused and adopting a stranger's would mean
never resuming the real conversation.

That makes the fix structural rather than a check: everything goes through
`ClaudeDriver::launch`, which adopts if it can and starts if it cannot,
and `--resume` is reachable only on the second path. `Driver` gains two
ways out where it had one -- `detach` (coming back) and `stop` (the
session is being deleted, so the process must not survive).

Importing a session that is open is now refused outright. Claude Code
keeps `~/.claude/sessions/<pid>.json` for every live session, so this is a
measurement rather than a guess; it reports no/yes/unknown, because a
machine that keeps no such record cannot answer and "could not check" is
not "nobody is using it". `SessionStatus` gains `Unknown` for the same
reason.

Also here, found on the way:

- A reconnecting phone was sent the entire backlog. Opening a session was
  bounded to a page but reconnecting was not, so a long disconnect
  delivered thousands of events one frame at a time. Past `CATCH_UP_LIMIT`
  the stream sends a `reset` frame and the newest window, and the client
  rebuilds from it as it does on open -- without the reset the window is
  spliced onto rows no longer adjacent to it.
- A session's status was assumed idle at launch. Read from the transcript
  instead, so a restart stops claiming an exited session is waiting for
  you.
- `llama-server`'s stdout was piped and never drained, so a chatty one
  blocked on a full pipe buffer mid-load. It goes to a log now.
- A turn that exited or errored never emitted `Idle`, so the queue stayed
  "running" for good: every later message was held forever and, since a
  message is only recorded when taken, vanished with nothing on screen.
- Two doc comments had drifted onto the wrong functions.

Verified by killing the server mid-turn: the process survived, finished
its turn unattended (12.8 KB of output nothing was reading), and the
restarted server adopted it -- one process, all 700 lines in the
transcript, no hole, and it still took a new message afterwards. Deleting
a session stops its process; a 266-event backlog resets while a 16-event
one streams. 46 tests, clippy and rustfmt clean, app compiles and lints.

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

19 KiB
Raw Blame History

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 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.
  • Local only: an ssh session's child dies with its connection, so it takes the ordinary --resume path.

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.