A subagent is a second transcript owned by a session, in the same event model, with no process and no controls. The claude translator routes lines carrying parent_tool_use_id to a per-subagent translator and transcript under <session>/subagents/<tool_use_id>; three routes expose the list, a transcript page and the SSE stream. Echo grows /subagent [n] as the rig. On the phone a card with subagents ends in a chevron expander, collapsed by default, opening to outlined subcards styled like dev-updater's components; a subcard opens SessionScreen in read-only form, addressed through TranscriptAddress so paging, cache and stream are shared. Design in SUBAGENTS.md; choices awaiting review in DECISIONS.md. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
358 lines
22 KiB
Markdown
358 lines
22 KiB
Markdown
# ai-app
|
|
|
|
A phone interface to AI coding sessions (Claude Code and llama.cpp),
|
|
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** — every decision with its date,
|
|
its rationale, and what was rejected. Read it before changing anything
|
|
structural, and update it in place when a decision changes rather than
|
|
letting this file and the plan become two versions of the truth. This file is
|
|
the working notes layer: layout, commands, and things that have bitten.
|
|
|
|
**The rigs are the `ai-app-rigs` skill** — the sandbox and bench scripts, the
|
|
rule that no UI-driving script may tap a coordinate, how to test llama.cpp and
|
|
ssh here, how importing behaves, and the measurements not worth re-taking.
|
|
They moved there on 2026-09-04 because they are 12 KB that only matter once
|
|
you are actually running one, and this file is sent with every request. Read
|
|
it before writing or running a benchmark, driving the UI from a script, or
|
|
touching the import screen.
|
|
|
|
The central design point, worth not undoing by accident: **a session is a
|
|
child process, translated into one common event 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. Read
|
|
dev-updater's `README.md` and `AGENTS.md` before diverging from them.
|
|
Module-by-module intent is in PLAN.md's "Backend layout".
|
|
|
|
- `server/` — the Rust backend (`ai-server`). `routes.rs`'s module doc
|
|
comment is the HTTP table and the surface's source of truth.
|
|
**A llama.cpp session runs on whatever machine its setup names** (built
|
|
2026-09-04, the last of phase 5): `Transport::reserve_port` returns the
|
|
port the server binds *there* and the port that reaches it *here*, and
|
|
`Launch::reaching` puts the `-L` tunnel on the connection already carrying
|
|
the command. Three things fell out of it and are easy to get wrong again —
|
|
a forwarded launch gets a pty (`-tt`) and every other one keeps `-T`,
|
|
because `llama-server` never reads the stdin whose closing ends a CLI and
|
|
the same kill left it loaded on the far machine; the model is looked for on
|
|
the machine that will serve it, so the spawn screen offers
|
|
`GET /setups/{id}/models` rather than the backend's own downloads; and the
|
|
readiness poll watches the process as well as the port, since a model that
|
|
will not load exits in a second and was being reported as "gave up after
|
|
300s". See PLAN.md's "Transport" and "llama-server management".
|
|
- `app/` — the Compose app, package `com.example.aiapp`, label "AI Sessions".
|
|
`AppRoot.kt` is the navigation `when`; `MainScreen.kt` the root's four tabs
|
|
(sessions, import, models, setups); `Api.kt`/`EventStream.kt` the REST + SSE
|
|
clients; `Events.kt` the event model mirror; `ServerConfig.kt` settings and
|
|
the Keystore-sealed token.
|
|
- `wg-app-link/` — a **git submodule** shared with dev-updater: 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`). Clone with `--recurse-submodules`, or
|
|
`git submodule update --init` in an existing checkout — `server/` will not
|
|
build without it, since it is a path dependency, which is what keeps the two
|
|
projects version-locked to the commit this repo pins. What deliberately did
|
|
**not** move is the API surface and the config *schema*: routes, drivers,
|
|
sessions and setups are what makes this project itself.
|
|
- `SUBAGENTS.md` — a session's subagents as transcripts of their own
|
|
(`server/src/session/subagent.rs`, the subcards in `SessionListScreen.kt`
|
|
and the read-only form of `SessionScreen.kt`); `DECISIONS.md` holds the
|
|
choices made there that are still awaiting review.
|
|
- `EXPLORER.md` — the file explorer's design (`server/src/files.rs` and
|
|
`FilesScreen.kt` / `FileViewer.kt` / `FileEditor.kt`).
|
|
- `TRANSCRIPT_CACHE.md` — the phone's copy of what it has been sent. Read it
|
|
before touching `TranscriptCache.kt`, `TranscriptSource.kt`, or the opening
|
|
and stream effects in `SessionScreen.kt`.
|
|
- `TODO.md` — the working list.
|
|
- `.dev-updater.ron` — what Dev Updater builds here: the server (run as
|
|
`service: Managed(…)`, supervised by Dev Updater's own implementation
|
|
rather than a script kept here) and the APK, in parallel. It points at
|
|
`resources.ron`, which is *ours* rather than Dev Updater's — it names
|
|
`~/.local/share/ai-app` and `~/.config/ai-app` so the Uninstall dialog can
|
|
offer them. Note what deleting the config directory takes with it: the CA
|
|
under `certs`, which is the one-way door. **Stop** on the server card stops
|
|
the server a phone reaches through the tunnel, so on that phone it stays
|
|
down until somebody starts it again; Dev Updater reaches it over its own
|
|
port and is unaffected, which is what makes the button safe to press and
|
|
easy to regret.
|
|
|
|
### Icons
|
|
|
|
**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,
|
|
because 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. The subset is
|
|
the **Mono** face, where every glyph is one em square, which is what makes
|
|
two icon buttons the same width without either being given one — and why
|
|
`GLYPH_SIZE` is smaller than it looks like it should be.
|
|
|
|
## Checking your work
|
|
|
|
- **Server**: `./run-tests.sh` from the repo root (or `cargo test` from
|
|
`server/`), plus `cargo clippy --all-targets` and `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
|
|
:androidApp:testDebugUnitTest`. The unit tests are JVM-only and cover the
|
|
syntax highlighter, the ANSI parser and the transcript cache — the app's
|
|
pure logic with no Android in it.
|
|
- **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) and later a permission check that silently dropped every
|
|
notification on Android 12 and below. Fully clean as of 2026-08-31; keep it
|
|
that way, and suppress with `tools:ignore` plus a written reason rather
|
|
than by lowering the bar.
|
|
- Then `./build-apk.sh` for the APK to install on a phone through Dev
|
|
Updater, or `./run-android.sh` to build, install and launch on the
|
|
emulator. **The phone gets the release build**, signed with a key the
|
|
script generates once under `~/.config/ai-app/release.jks` (never in the
|
|
repo); `./build-apk.sh debug` builds the other variant, and Dev Updater's
|
|
build modes call the script with exactly that word. Dev Updater lists every
|
|
variant under `build/outputs/apk`, so pick `release` there; a phone still
|
|
holding the debug build has to uninstall it first, since the two are signed
|
|
differently.
|
|
- The emulator scripts stay on the debug build. **Never read a frame time
|
|
from one as the app's** — a debuggable build runs Compose at a fraction of
|
|
release speed; the render report says which build it came from.
|
|
|
|
## Running it here
|
|
|
|
- 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.
|
|
`ai-server --enroll-link` mints one more device's link while the server
|
|
keeps running; the server adopts that token on its first use. It is what
|
|
Dev Updater's Enroll button runs.
|
|
- Point development at a scratch state directory rather than the real one:
|
|
`--config /tmp/…/config.ron --data-dir /tmp/…/sessions --port 8444`.
|
|
- **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). 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.
|
|
- 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`,
|
|
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 with
|
|
`adb shell "am start -a android.intent.action.VIEW -d 'aiapp://enroll?host=10.0.2.2&port=8443&token=…'"`.
|
|
- **`ai-server --delay MS` holds every response back.** Over the tunnel a
|
|
phone's requests take tens to hundreds of milliseconds, and several faults
|
|
live entirely in what the app does *while* one is outstanding. On a
|
|
loopback server those windows close before anything can be observed, so the
|
|
bug looks like it is not there.
|
|
- **`RUST_LOG=ai_server=debug`** logs every transcript page with its `before`,
|
|
`after` and what came back, and logs each SSE subscriber's cursor and
|
|
whether it was continued or reset (`stream backlog:`). That is the only
|
|
place "how far had this phone fallen behind" is answerable — the app sees a
|
|
window arrive and cannot tell.
|
|
- **`./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 is how to verify the wg0-only posture.
|
|
|
|
## Where things run (host vs this VM)
|
|
|
|
The machine itself — the two boxes, the shared `~/repos` mount, and why the
|
|
VM is untrusted — is described once in `~/.claude/MACHINE.md`. 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 is
|
|
exercisable during development. It has no reachable peer and does not need
|
|
one — but with no `--bind` the emulator cannot reach the server.
|
|
- **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.
|
|
- Starting the server in the VM makes a separate throwaway dev CA. **Never
|
|
install a build pinning that on the real phone.**
|
|
|
|
## 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. PLAN.md has the design;
|
|
day to day:
|
|
|
|
- **Stopping the server no longer stops the sessions.** After `pkill
|
|
ai-server` the `claude` processes are still there, on purpose
|
|
(`reattaching to the claude-cli it left running` in the log). To end one,
|
|
`POST /sessions/{id}/stop` — which keeps the session and its transcript,
|
|
and `/start` brings the process back on the same conversation — or delete
|
|
the session, which ends the conversation too.
|
|
- **A message or a command sent to a stopped session starts it**, so the
|
|
Start button is for when you want a process and nothing to say to it yet.
|
|
- **A backend start adopts and starts nothing.** If you are looking for a
|
|
stopped session's process after a restart, there is deliberately none.
|
|
- **A session spawned while testing cleans itself up**: `--throwaway-sessions`,
|
|
which a debug build defaults to on. Pass `--throwaway-sessions=false` to
|
|
keep what a development server spawns. The flag decides only what **new**
|
|
sessions are marked as; what happens on the way out is decided by the
|
|
**mark**.
|
|
- Each session directory 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.
|
|
|
|
## Auto-resume
|
|
|
|
**A session switched to it sends itself a message once the account's usage
|
|
limit lifts** — off by default, per session, in the session settings dialog.
|
|
PLAN.md's "Auto-resume" is the design; day to day:
|
|
|
|
- **The schedule is a plan to ask.** `resume.rs` wakes at the scheduled time,
|
|
asks `GET /usage`'s meter for that machine and provider, and only sends when
|
|
it answers `ok` with nothing at 100%. Anything else — still spent, logged
|
|
out, unreachable — is a longer wait, and a still-spent window reschedules to
|
|
the reset time the *meter* now gives.
|
|
- **Test it with echo, never with a real account.** `/limit [minutes]` reports
|
|
the same `limitReached` event a real driver does, and `/usage 100 5` sets
|
|
what the meter answers. They are deliberately separate: the two disagreeing
|
|
is the case the design exists for. `/usage 20` is the limit lifting.
|
|
- The wait is on the session in `config.ron` (`resume`), so it survives a
|
|
backend restart. A day after the limit was hit it gives up and says so in
|
|
the transcript.
|
|
|
|
## Shared appearance
|
|
|
|
- **A row something is happening to is dimmed, drained of colour, and says
|
|
which operation in a word** — `BusyItem`, used by both the session list and
|
|
the import list so the appearance is learned once. The word rather than a
|
|
bare spinner because "deleting" and "importing" differ in kind. It does
|
|
**not** make the row inert: the caller disables its own click handler while
|
|
it passes a label. An overlay consuming pointer events was tried and
|
|
swallowed the drag along with the tap, so a list could not be scrolled
|
|
while anything in it was busy.
|
|
|
|
- **A rate-limit bar belongs to a session's provider, not to its machine.**
|
|
One machine offers echo, the Claude CLI and a local model at once and only
|
|
the CLI spends anything, so a session says which meter reports on it
|
|
(`usageProvider`, from `DriverKind::usage_provider`, which
|
|
`usage::providers_for` reads too so the two lists cannot disagree) and the
|
|
phone matches a snapshot on machine *and* provider. Nothing meters a llama
|
|
or echo session, and the phone draws **nothing** for one — not a zero, and
|
|
not "unknown". Nothing while the first fetch is out either: "checking"
|
|
under a session that turns out to meter nothing is a row the screen then
|
|
has to withdraw.
|
|
|
|
## Things that have bitten
|
|
|
|
Project-specific only — a lesson that would bite any project on this machine
|
|
belongs in `~/.claude/TOOLCHAIN.md` or `~/.claude/MACHINE.md` 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 is why the auth middleware has a single
|
|
combined gating+logging test.
|
|
- **The composer can get stuck floating above the bottom of the screen after
|
|
the keyboard closes, while a reply is streaming.** The composer's position
|
|
and the transcript's bottom padding are both driven by the raw, animated
|
|
`WindowInsets.ime` value read inside a `graphicsLayer` block, to avoid
|
|
recomposing the whole screen every frame of the keyboard's animation. That
|
|
animation is carried by a `WindowInsetsAnimationCallback`, and a callback
|
|
interrupted mid-flight leaves whatever it was carrying frozen at its last
|
|
value with nothing left to correct it. A streaming reply invalidates the
|
|
view every frame, which is exactly the condition known to starve that
|
|
callback of its `onEnd`. `WindowInsets.isImeVisible` does not share the
|
|
failure mode — it is set once, from the platform's own start/end of the
|
|
transition over a different path — so it is read once per keyboard toggle
|
|
and used to force both places back to zero.
|
|
**The guard is a boolean; the inset itself must never be read in the
|
|
composable body.** That correction first shipped as a `padding(bottom = …
|
|
imeInsets.getBottom(this) …)`, which subscribes the whole screen to a value
|
|
that changes every frame: measured at **16 full recompositions of
|
|
`SessionScreen` per keyboard open, against 1**. It is
|
|
`.then(if (imeVisible) Modifier.imePadding() else Modifier)` instead —
|
|
`imePadding` reads the inset in the layout phase, and dropping the modifier
|
|
is the same coercion to zero the boolean was added for. The counter to
|
|
check is `session screen recomposed` in the debug report, which should move
|
|
by one across a keyboard open, not by the number of frames it took.
|
|
- **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 does not 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.
|
|
- **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.
|
|
- **`serde_json`'s default float parser is not correctly rounded**, so the
|
|
server handed out the same transcript line two different ways: a `ts` of
|
|
`1788546972.6030757` came back from `/transcript` as `…0755` while the SSE
|
|
stream sent the original. Nothing on screen could show it — a `ts` is drawn
|
|
as a relative time — and what found it was the phone's cache comparing a
|
|
line it held against the server's answer. The `float_roundtrip` feature in
|
|
`server/Cargo.toml` is the fix and
|
|
`a_line_read_back_is_the_line_that_was_written` is what keeps it; that test
|
|
fails within a second of the feature being dropped.
|
|
- **Resolving one importable session used to list every one of them.**
|
|
`import::delete` and the import seed both called `list`, which reads every
|
|
transcript Claude Code has ever written — measured at 3.7 seconds against
|
|
the 867 MB in this VM, paid once per session in a batch. `import::find`
|
|
takes the same script with one glob narrower: 78ms. Ids are checked
|
|
(`is_session_id`) before they reach that glob, since a `/` or `..` walks it
|
|
out of the projects directory.
|
|
- **A transcript page used to cost the whole transcript.** `read_window` read
|
|
and parsed every line and then kept the last `limit` of them, so the work
|
|
was the size of the conversation rather than the size of the answer: one
|
|
page of a 21 MB, 24,000-event transcript took ~500ms to return 620 KB, and
|
|
took the same 500ms whichever page was asked for. It is a bisection now
|
|
(`Indexed` in `transcript.rs`) — sequence numbers only increase, so the
|
|
edge of a range is found by parsing one line per halving. Same page,
|
|
~110ms, of which ~20ms is the file scan. The file is still read whole; that
|
|
is where the remaining cost is, and going further means a chunked backwards
|
|
reader.
|
|
- **Paging back has two failures that look like "there is simply no more
|
|
history", and neither says anything on screen.** Both invisible on a
|
|
loopback server and reproducible at `--delay 150`. The pager fires on the
|
|
*first layout*, before any event has arrived — `moreHistory` starts true,
|
|
so the spinner is in the list and `visibleItemsInfo` is not empty — and
|
|
`before = 0` asks for the events before the first one, which is none, which
|
|
is exactly how this code is told it has reached the start. `loadOlderPage`
|
|
refuses `oldestSeq == 0` now. And `joinPages` only ran `adoptRun` on the
|
|
path where a *split* call had been found, so a boundary landing cleanly
|
|
between two calls — most of them — left one run of tool calls drawn as two
|
|
groups with the seam wherever the reader happened to have paged.
|
|
Reproducing either takes a boundary placed on purpose: the opening page is
|
|
80 events, so arrange the transcript so that event counts back from the
|
|
newest.
|
|
- **A page is 800 events and a screen is a handful of rows, and the two have
|
|
no fixed ratio.** A run of thirty-five tool calls is one row; a reply is
|
|
hundreds of text deltas folded into one. So anything that budgets in rows
|
|
has to measure a screen rather than name a number: the history cushion was
|
|
eight rows, which on a tool-heavy transcript is less than one screenful, so
|
|
the reader hit the end of what was loaded on every swipe and stood there
|
|
for a round trip. It is `HISTORY_SCREENS` viewports now, counted from what
|
|
is actually on screen.
|
|
- **Only `fetchTranscript` was off the main thread; the fold was not.**
|
|
`foldEvent` returns a new list per event, so a page is that many copies of
|
|
a growing list — fine at 80 events and about 300,000 element copies at 800,
|
|
run in the middle of the scroll that asked for it. `warm` had the same
|
|
shape: the `markdownIn` scan that decides *what* to parse ran before the
|
|
hop to `Dispatchers.Default`. The shape to watch for is a `withContext`
|
|
that wraps the *fetch* and leaves the work done with the result outside it.
|
|
|