Files
ai-app/AGENTS.md
T
irisandClaude Fable 5.1 9fa09b0af1 Show a session's subagents as subcards, each with a read-only transcript
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>
2026-09-05 13:41:15 -04:00

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.