Files
ai-app/AGENTS.md
T

28 KiB

ai-app

A phone interface to AI coding sessions (Codex, 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 its configured machine (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 /machines/{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". Codex is one persistent codex app-server --stdio process per session; its driver uses native turn steering and interruption, persists the protocol state and thread id, and reads subscription limits through the same CLI protocol.
  • 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, machines); 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 machines 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-machine-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.

A session waiting on its own work

Since 2026-09-06 a session whose turn ended with a backgrounded subagent or command still running reports waiting rather than idle — its own status, drawn as the word "waiting" in waitingColor on both screens. idle means "waiting for a person" and this means the opposite, so it also suppresses the "finished" notification, which used to arrive at the one moment it was untrue. Two things fall out of it and are easy to get wrong again: the queue and the held-command boundary release on either end-of-turn status, so a message sent while a subagent runs is not held until the subagent finishes; and sessionWorking("waiting") is deliberately false — nothing is being written, and the fold uses that same predicate to decide a reply is settled.

  • Nothing subagent-specific goes in the main agent's transcript unless a subagent sends it a real message that wakes it — which is the peer path, and already has a row. A row per finished background task was tried and was a screenful of dividers about work nobody was asking after, one of them a whole shell command. A subagent's report is its own transcript's closing text and is read in the subcard.
  • A backgrounded command has no subagent, so its report lands in the tool card that launched it — a ToolUpdate against the call's own id, replacing the launch result that says it is still running. /background [seconds] in an echo session is that shape end to end.
  • Two replies that meet are separated by a TurnBreak — a hairline, no words. The reply that follows a turn boundary is a new message: the fold refuses to grow a settled reply, and without that the two ran together mid-sentence. ./ui-sandbox.sh plus /subagent 3 or /background 5 in an echo session is the whole rig; the helpers stagger a second apart so each reply is its own.
  • Claude's background-task level is authority; two edge sources are the fallback. Since Claude Code 2.1.261, background_tasks_changed { tasks: [...] } replaces the live set and repairs a missed ending edge. Its array size is also the measured backgroundTasks count exposed on the session row and event stream; the phone draws a nonzero count beside the status rather than deriving one from waiting or from the subagent directory. An adopted CLI is sent a repeated initialize to ask for the current set. Reconcile only between turns or at a result boundary: a foreground agent is legitimately absent from a background-only snapshot. Older CLIs still need both edge sources: open_tasks knows about a backgrounded command, while Subagents::any_open finds a subagent whose task_started is behind an adopted stdout offset.
  • A usage limit a subagent hits reaches the session, not just the subagent's own transcript; auto-resume can only schedule against a session. That is the case where the main agent is idle and a background Task is still burning quota.
  • Codex's count is two id sets added together. Open child thread ids come from the subagent registry; live background command process ids come from app-server's experimental thread/backgroundTerminals/list. The command set is runtime state, refreshed at lifecycle edges and once a second while nonempty. Never decrement it from an unmatched completion.
  • The status word and its colour are sessionStatusWord / sessionStatusColour, shared by the list and the session screen. They were two whens, and the second one silently missed waiting.

Shared appearance

  • A row something is happening to is dimmed, drained of colour, and says which operation in a wordBusyItem, 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

  • A transcript outlives the enum. Removing Event::TaskNote hours after adding it made every transcript that had recorded one unreadable, so launch failed for those sessions and SessionManager::new skipped them — no status, nothing sendable, no new messages, for every live session that had run a background task. The set of kinds a transcript can hold only ever grows: a line may come from a newer server or from an older one that wrote a kind since dropped, and one unfamiliar word must never be able to end the file. Indexed::parse_at degrades a line it cannot read to Event::Unreadable { kind }, keeping its seq — which is what everything downstream is addressed by — and the phone draws it as a placeholder saying which kind. Never delete a variant instead of retiring it; Event::TaskNote is what retiring looks like, and the phone folds it to no row.

Project-specific only. A lesson that would bite any project on this machine belongs in ~/.claude/MACHINE.md or the this-machine-* skill for its subject; one that would bite any project anywhere belongs in the code-lessons skill, under the admission test at its end.

  • 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.
  • A page landing while the history observer was fetching it must trigger its own successor. The observer once collected only LazyListState.layoutInfo; while its collector was suspended in loadOlderPage, a compact page could be composed and laid out without leaving another change to observe afterward. Keying the effect on oldestSeq still missed the opening prefetch: that key changed while loadingHistory was true, so the restarted effect declined to overlap it and never noticed the flag returning to false. Codex exposes both failures because a page full of calls collapses into one tool group: loading stopped until expanding that group forced a layout. The observer now collects the cursor, loading, restoring and failure state with the layout, so returning to not-loading always rechecks the settled height. A failed page turns the history boundary into a Try again control rather than retrying in a loop or requiring another scroll.
  • 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.
  • A transcript snapshot cannot survive a suspension and then be assigned. loadOlderPage joined its page to items, suspended while warm parsed markdown, and then assigned the joined snapshot. An SSE event arriving in that gap appeared and vanished; reopening brought it back because the transcript and cache had it all along. Warm against a candidate if needed, then join against the current items and assign without another suspension. Also keep the page's original oldestSeq: a stream reset while the fetch or warm is suspended makes the page stale, and it must be discarded rather than joined into the reset window.