Measured against a session streaming 20 events a second. Reopening one with an anchor 1,800 events back connects 87-119 events behind, under CATCH_UP_LIMIT's 200 -- the restore is two requests now, the opening page and one span covering the whole distance to the anchor, where the report was written against a restore that took thirteen. Three provocations, no repeat connection between them: that reopen, twenty seconds backgrounded while 415 events were produced, and a reset forced by dropping the limit to 5, where the app cleared, refilled and carried on without reconnecting. So the TODO entry goes. What stays is the instrument that answered it: every SSE subscriber now logs at debug the cursor it arrived with and whether it was continued or reset, since nothing else could say. The app sees a window arrive and cannot tell how far it had fallen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
923 lines
60 KiB
Markdown
923 lines
60 KiB
Markdown
# 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.
|
||
Images in the replayed tail are written into the session's `files/` by
|
||
the same function the live translator uses, so a screenshot looks the
|
||
same whether it was watched happening or replayed afterwards, and the
|
||
phone fetches the bytes only when it draws one.
|
||
An imported session then **keeps itself level with that file**, so work
|
||
done at a terminal appears without anyone pressing anything. Which new
|
||
lines came from *here* is answered by counting the events this session
|
||
has recorded, **not** by looking at its status — 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/files.rs` — the file explorer's half of the backend:
|
||
listing a directory, reading a file, writing one, creating a file or a
|
||
directory, on whichever machine a setup names. Each is one small POSIX
|
||
script run through `Transport`, so the local and the ssh case are the
|
||
same code and a machine the backend cannot reach fails with ssh's own
|
||
message. The path is a **positional argument**, never text spliced into
|
||
the script; `PATH_PRELUDE` is the one line that gives a leading `~` its
|
||
meaning, since a shell expands a tilde in text and not in an argument.
|
||
A read has four answers — `text`, `binary`, `tooBig`, or the machine's
|
||
own error — because a binary file drawn as text and a big one cut off
|
||
silently are both wrong in ways the reader cannot see. A write carries
|
||
the sha256 the read reported and is refused (409) when the file has
|
||
moved on, which is the ordinary case when an agent is editing the same
|
||
file. `EXPLORER.md` is the design.
|
||
- `server/src/usage.rs` — rate-limit windows, asked **of each machine that
|
||
can run Claude**, not of the backend. Credentials are read through the
|
||
session `Transport`, so a remote setup is an ssh round trip and the local
|
||
one is unchanged; the HTTP call stays here. A machine with no Claude
|
||
provider is never asked. The four states (`ok`, `notLoggedIn`,
|
||
`unreachable`, `failed`) exist because a machine nobody logged in on is a
|
||
choice rather than a fault, and one `error` string made it look like one.
|
||
- `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.
|
||
- **Attachments** are one list on a user message (`attachments`, the
|
||
ref the files route serves), in two shapes. An image is `<hex>.<ext>`
|
||
and goes to the model as an image block. Anything else is
|
||
`<hex>-<name>` -- the name it was shared or picked under, cleaned by
|
||
`safe_file_name` -- and the Claude driver appends `Attached file:
|
||
/abs/path` to the message text, since the CLI reads files by path and
|
||
a model cannot be shown a trace. `media::media_type_for` on the server
|
||
and `isImageRef` on the phone tell the two apart; keep those lists
|
||
level. The phone attaches from the photo picker, the file chooser and
|
||
Android's share sheet (`Share.kt`; the manifest's SEND filter), all
|
||
through one `attach` path in `SessionScreen`, streamed both from the
|
||
phone and onto disk. A file for a session on another machine is also
|
||
copied there during the upload (setup's `attachmentsDir`, else the
|
||
session's cwd, else home) and the driver names that path, read from
|
||
the `<name>.remote` marker beside the file -- PLAN.md's "Transport" has
|
||
the reasoning.
|
||
- `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`; `MainScreen.kt` the root's four tabs (sessions, import, models,
|
||
setups) with settings and refresh on the title row; `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`.
|
||
`Notifications.kt` is the foreground service holding the notification
|
||
stream and the one place that decides where a notification is said --
|
||
nothing for the session on screen, a `SessionAlerts` banner while the app
|
||
is up, Android's drawer otherwise, never two of them. See PLAN.md's
|
||
"Notifications: two places, never both".
|
||
**Icons are 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: 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 — that is
|
||
what makes two icon buttons the same width without either being given
|
||
one, and it is why `GLYPH_SIZE` is smaller than it looks like it should
|
||
be.
|
||
- **The file explorer** — `FilesScreen.kt` (the navigation stack, the
|
||
per-directory cache, the create dialog), `FileViewer.kt` (a `LazyColumn`
|
||
of lines, each with its own colours from `FileLines.kt`, sharing one
|
||
horizontal scroll so nothing wraps), `FileEditor.kt` (a
|
||
`BasicTextField` with a `VisualTransformation` carrying the scanner's
|
||
spans, which is the one Compose API that colours a field's own text).
|
||
It draws **over** the session in `AppRoot`'s `Screen.Session`, so the
|
||
session under it stays composed and coming back from a file costs
|
||
nothing; back steps editor → viewer → directory → parent and only closes
|
||
from where it opened. `EXPLORER.md` is the design and `server/src/files.rs`
|
||
is the other half.
|
||
To exercise it, `./ui-sandbox.sh` builds a fixture tree at the sandbox
|
||
home's `~/files` holding the states that are otherwise only reachable by
|
||
finding a real machine in one: an empty directory, a name with a tab in
|
||
it and one with an apostrophe, a binary file, one over `FILE_LIMIT`, one
|
||
`chmod 000`, a symlink to a directory and a broken one, a source file per
|
||
language, and the three sizes the limits were measured against
|
||
(`edit-32k.rs`, `edit-128k.rs`, `big-source.rs`), so those figures can be
|
||
taken again rather than re-derived. Point a session at it with
|
||
`./ui-sandbox.sh api /sessions/<id>/cwd -X POST -H 'content-type: application/json' -d '{"cwd":"~/files"}'`.
|
||
The 409 is produced by editing the file on the machine (`printf … > file`)
|
||
between pressing the pencil and pressing save.
|
||
**Reading is cheap and editing is not**, and the sizes are measured
|
||
rather than guessed -- see EXPLORER.md's "What the measurements said".
|
||
The viewer handles a 1 MiB, 28,000-line file because it draws one row per
|
||
line; the editor is one `BasicTextField`, which costs two seconds a frame
|
||
at 128 kB and stops the app at 1 MiB, so `EDIT_LIMIT` caps it at 32 kB
|
||
with the reason said on screen. If you make the editor faster, that
|
||
number is what to move.
|
||
- `.dev-updater.ron` — what Dev Updater is asked to do with this checkout:
|
||
the server (built in `server/`, run as `service: Managed(...)`) and the
|
||
APK (built in `app/`), built in parallel. The project it serves is the
|
||
repository, not either half of it, which is why this sits at the root
|
||
rather than in `app/`.
|
||
It points at `resources.ron` beside it, which says this project keeps its
|
||
state as `ai-app` — so the Uninstall dialog offers `~/.local/share/ai-app`
|
||
and `~/.config/ai-app` instead of saying it cannot tell. That file is
|
||
*ours*, not Dev Updater's: it ignores keys it doesn't know, so anything
|
||
else worth keeping in one place belongs there too. Note what deleting the
|
||
config directory takes with it — the CA under `certs`, which is the
|
||
one-way door described below.
|
||
`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 server 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. 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 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.
|
||
## Status
|
||
|
||
Phases 1–3 done 2026-08-24 (PLAN.md's phase list says 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 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` over
|
||
its OpenAI-compatible streaming endpoint. Two things 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.
|
||
|
||
Setups — machines, each carrying what it can run — are added, renamed,
|
||
re-probed and removed from the app; 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, 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). 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.
|
||
|
||
**No script that drives this app's UI presses a coordinate.** Every control
|
||
is found by the name it already carries for assistive technology --
|
||
`ui-trace record --do "tap 'Session settings'"`, which resolves the label
|
||
against the screen at the moment of the gesture and fails the whole run
|
||
when it is not there. `app/bench-lib.sh` is what `transcript-bench.sh` and
|
||
`stream-bench.sh` share for it. A coordinate is a position measured once by
|
||
hand, and anything that moves the control makes the tap land on whatever
|
||
now sits there -- the bench then reports a number that was never measured,
|
||
which reads exactly like a result. Both scripts pressed the render report
|
||
at `tap 723 205` until that button moved into the session settings dialog
|
||
on 2026-09-03. The check that none has crept back:
|
||
|
||
grep -n "tap [0-9]" app/*.sh
|
||
|
||
Swipes are still coordinates, deliberately: a gesture across a scrolling
|
||
area is a distance rather than a control.
|
||
|
||
**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. **Take the key back out afterwards.** Note the remote login shell
|
||
here is **fish**; the remote script (`cd '…' && exec '…'`) and `ssh.rs`'s
|
||
POSIX quoting happen to mean the same thing in both, but that is luck
|
||
rather than design, and a shell that isn't either is 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
|
||
:androidApp:testDebugUnitTest` — format, typecheck, lint and test, the
|
||
app-side equivalent of the line above. The unit tests are JVM-only and
|
||
cover the syntax highlighter's scanner, which is the app's one piece of
|
||
pure logic with no Android in it. 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.
|
||
**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.
|
||
The emulator scripts stay on the debug build; a debuggable build runs
|
||
Compose at a fraction of release speed, so never read a frame time from
|
||
one as the app's -- the render report now says which build it came from.
|
||
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.
|
||
- **A row something is happening to is dimmed, drained of colour, inert,
|
||
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 dims and desaturates but 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.
|
||
- **Importing and deleting run on the server, not in the request, and a
|
||
batch is handed over in one call.** `POST
|
||
/setups/{id}/importable/delete` and `POST /setups/{id}/importable/import`
|
||
each take a list of session ids, answer 202, and do the work in spawned
|
||
tasks -- because the phone that asked is free to leave and used to cancel
|
||
its own batch by doing so. A list rather than a route per session because
|
||
one request per row made a handover only as atomic as the network: some
|
||
rows started and the rest were never asked for, and a row nobody asked
|
||
for looks exactly like a row nobody picked. Every id is registered as in
|
||
flight before the 202 goes back. Only the *registering* is atomic; the
|
||
work itself settles per row, since six deletes that all roll back
|
||
together is not something a filesystem offers. What replaces the reply is
|
||
`session::pending`: every row of the listing carries `pending` and
|
||
`error`, and `GET /setups/{id}/importable/events` streams the changes.
|
||
**Both, not either.** The stream is a broadcast with no memory, so an
|
||
operation that starts and finishes while it is still connecting is one
|
||
nothing will ever be said about -- that left a row marked "waiting" for
|
||
ever, and the listing is what repairs it. So the screen fetches again
|
||
after a handover when anything still looks outstanding, and takes the row
|
||
states from the answer rather than from what it remembers.
|
||
- **A single tap still waits.** "Continue this and take me to it" needs the
|
||
session it made, and 202 does not carry one. The batch and the tap share
|
||
`spawn` on the server so the two cannot drift about what importing means.
|
||
- **The import screen selects in batches: hold to enter, tap to add.** The
|
||
options that act on a selection appear along the bottom, and are Delete
|
||
and Import only. Submitting clears the selection immediately and marks
|
||
every chosen row -- the one in flight as "importing" or "deleting", the
|
||
rest as "waiting" -- so the bar goes away and the affected set is what
|
||
says the work is happening. Rows are taken out as each one lands rather
|
||
than all at the end: a finished row still sitting there looks exactly
|
||
like one that has not been imported, and tapping it starts a second CLI
|
||
on the same transcript. What that costs is that the rows below slide up
|
||
under the reader's finger, so a row that has just moved ignores taps for
|
||
half a second (`SETTLE_MS`).
|
||
- **An answered question keeps its options and marks the one that was
|
||
taken**, in the same purple that says "picked" while it is still open --
|
||
it does not collapse into a line repeating the answer. The options are
|
||
what the question *was*, and "Deny" alone does not say that Allow was the
|
||
alternative. One rule in two places (`AskedQuestion` and `PermissionAsk`),
|
||
since a permission is a question with two bare options rather than a
|
||
different kind of thing. An answer typed into **Other** matches no option,
|
||
so that one is still written out -- the state the marking cannot say.
|
||
- **Anything that is a note *about* the conversation rather than a turn in
|
||
it is closed by default**: a tool call, a peer message, and now a memory
|
||
note (`<cc-memory>`). Open-ness is the screen's, never the card's -- a
|
||
card that remembered for itself forgets the moment the lazy list stops
|
||
composing it, so a note opened and scrolled past would shut behind the
|
||
reader.
|
||
- **The full-screen image lives on the screen, not in the row that drew the
|
||
thumbnail** (`SessionImageViewer`). A `Read` whose result is an image is a
|
||
row of one call until the next call arrives and makes it a group -- a
|
||
different composable in a different part of the tree, so the old subtree
|
||
and everything it remembered goes, the open dialog included. Somebody
|
||
looking at a screenshot was thrown back to the transcript because the
|
||
session made another tool call. `/tools n gap` puts an image on its first
|
||
call so this is reproducible: open it, wait a gap, watch the row regroup.
|
||
- **All transcript text is selectable, from one `SelectionContainer` around
|
||
the whole list** (`TranscriptList.kt`). Not per row: a transcript is one
|
||
body of text to a reader, so a selection has to be able to run from a
|
||
reply into the tool output under it -- and a container per row leaves
|
||
whatever was drawn without one silently unselectable, which nothing on
|
||
screen reports. Rows keep their tap handlers; selection is a long press.
|
||
**An inline code chip is drawn behind the text** rather than as the
|
||
renderer's span background, because a span background is part of the
|
||
text's own drawing and hid the selection under it -- see
|
||
`appendCodeChip` in `MarkdownLinks.kt`.
|
||
- **A session can be moved to another directory** from the settings dialog
|
||
(`POST /sessions/{id}/cwd`). It stops the process, because a working
|
||
directory is settled at spawn; the next message starts it in the new one.
|
||
**`claude --resume <id>` finds a session from any directory** -- measured
|
||
on 2.1.237 -- so nothing of Claude Code's is relocated, and should you ever
|
||
be tempted, its project directory is the path with every non-alphanumeric
|
||
character replaced by `-`, cut at 200 characters with a hash appended, and
|
||
overridable besides.
|
||
- **A message from another agent reaches a live session on the turn's
|
||
`result`, not before.** Measured on CLI 2.1.237 by sending a real
|
||
cross-session message to a real stream-json session: no `user` record, and
|
||
nothing in the partial-message stream -- the whole of it is an `origin`
|
||
object on the `result`, the same shape the session file records, which is
|
||
why `import::peer_message` reads both. So it is *recorded* after the reply
|
||
it caused, and cannot be recorded anywhere else in an append-only log --
|
||
which is why the event carries `turnStart`, the seq of the status that
|
||
opened its turn, and the phone draws the note at that seq instead of where
|
||
it arrived. Exercise it with the echo driver's `/peer-turn`; plain `/peer`
|
||
is the in-place shape an import replays. See PLAN.md.
|
||
- **A queued message can be tapped to take it back**, which is
|
||
`POST /sessions/{id}/unqueue` and a `messageDropped` event -- see PLAN.md's
|
||
"Taking a queued message back". On a **Claude** session it always refuses,
|
||
and that is correct rather than broken: the driver writes a steer into the
|
||
CLI the moment it arrives, so what the bubble is waiting for is the CLI
|
||
*reading* it, not this server sending it. The refusal is drawn on the
|
||
bubble. The echo driver really does hold its queue, so that is the rig for
|
||
the case where the drop succeeds.
|
||
- **Deleting a session offers to take the machine's own transcript with
|
||
it.** `DELETE /sessions/{id}?deleteForeign=true`, behind a switch in the
|
||
confirmation, and only where the driver keeps a record of its own
|
||
(`keepsOwnTranscript`, which today means Claude Code). Off by default,
|
||
because leaving that copy is what makes an ordinary delete recoverable --
|
||
and the dialog's paragraph is rewritten when it is on rather than
|
||
appended to, since the sentence promising the conversation "should still
|
||
be there to import again" is exactly the one the switch makes false. The
|
||
server deletes the machine's copy *first*, so a machine it cannot reach
|
||
leaves the session where it was instead of half-deleted.
|
||
- **One Claude Code session id can name two files, and the listing offers
|
||
it once.** Resuming a session from a different working directory makes
|
||
the CLI write a second transcript with the same id under that
|
||
directory's project folder -- an ordinary state of a machine, not
|
||
corruption. Everything downstream addresses a session by id (`--resume`,
|
||
the delete glob, the in-flight registry) and the phone keyed its list on
|
||
it, so two rows sharing one *closed the app* on a Compose duplicate-key
|
||
throw. `parse_listing` keeps the copy with the most lines, because the
|
||
other is usually a few-hundred-byte stub and is often the *newer* of the
|
||
two -- so recency is the wrong key. Deleting removes every copy rather
|
||
than the first, or the row came back after a delete that reported
|
||
success. The phone's half is `uniqueItems`, which every list keyed on a
|
||
server-chosen id goes through: a repeat there must never be able to
|
||
close the app, whatever produced it.
|
||
- **A reply is drawn as pieces of one parse, never as re-parsed
|
||
substrings.** `MarkdownPieces.kt`: a `Piece` addresses a top-level block
|
||
of the message's tree, or one item of a top-level list, and every piece
|
||
is drawn from the same `State.Success` that `ParsedReplies` cached and
|
||
`warm` made. That is what bounds a lazy-list item (one paragraph, one
|
||
bullet) without parsing a message more than once, and it is why a
|
||
forty-item list of sources is forty units rather than one. The renderer
|
||
is still the parser and the environment: `MarkdownRoot` provides its
|
||
locals and `MarkdownElement` dispatches a whole block through our
|
||
component table, so paragraphs, headings and table cells are span-linked
|
||
`LinkedText` (links as spans with one tap detector per text, not a layout
|
||
node per link -- the cost that made a list of sources bumpy) and lists
|
||
are ours wherever the dispatch meets one. A heading's words are its
|
||
`ATX_CONTENT`/`SETEXT_CONTENT` child; the inline builder draws nothing
|
||
for a node type it does not know, so hand it the child.
|
||
- **A markdown table wraps its cells and never cuts one off.** The
|
||
renderer's own defaults draw every cell at one line with an ellipsis,
|
||
which on a phone loses most of a table -- and an elided cell looks
|
||
exactly like a short one, so nothing on screen says anything was cut.
|
||
`Markdown.kt` supplies its own rows (`LinkedTableRow`): as many lines as
|
||
a cell needs, cells aligned to the top of the row so a two-line cell
|
||
does not re-centre its neighbours, and each cell a `LinkedText`. Width is
|
||
the other half: a column narrows to 136dp and no further, and past that
|
||
the whole table scrolls sideways rather than squeezing -- 136 because it
|
||
is the widest floor that still fits three columns across a phone, which
|
||
is the commonest table there is. Exercise it with the echo driver's
|
||
`/table N` (default six columns), which writes long cells on purpose:
|
||
a fixture of tidy one-word values renders fine whether or not the
|
||
truncation is fixed.
|
||
- **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. It is 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.
|
||
- **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. `ai-server --enroll-link` (same
|
||
`--config`/`--bind`/`--port`) mints one more device's link while the
|
||
server keeps running and prints only the URI; the server adopts that
|
||
token on its first use. It is what Dev Updater's Enroll button runs.
|
||
- **`app/debug-transcript.sh` puts a real conversation on the emulator.**
|
||
The echo driver stays the right rig for most things and is the wrong one
|
||
for anything whose cost scales with what was actually written: a real
|
||
reply is longer, is real markdown, and carries tool calls whose input and
|
||
output are kilobytes rather than a word. Two faults were invisible until
|
||
a real transcript was loaded — a page of history landing mid-fling threw
|
||
the reader back to the newest end, and parsing one real reply took 51ms
|
||
against 4.6ms for a synthetic one. `-b` takes the biggest conversation on
|
||
the machine rather than the newest, which is what a scrolling test wants;
|
||
`--stop` takes it all down again.
|
||
It copies the transcript into `/tmp` and gives the server a `HOME` of its
|
||
own, so the import can only see the copy — importing spawns `claude
|
||
--resume`, and against the real file that is a second CLI writing to a
|
||
conversation somebody may still be in. **A transcript never goes in this
|
||
repository**: they hold whatever was said, read and written in that
|
||
session, and `~/repos` is shared with the host besides.
|
||
- **`app/ui-sandbox.sh` is the rig for driving the UI against invented
|
||
sessions.** It starts a second `ai-server` with its own `$HOME`, config
|
||
and data directory, holding eight invented Claude Code transcripts and a
|
||
`claude` that is two lines of shell. That isolation is the point: the
|
||
import screen lists whatever is in `~/.claude/projects`, which in this VM
|
||
is real agent transcripts, so exercising *delete* against the ordinary
|
||
server deletes somebody's conversation and exercising *import* starts a
|
||
real `--resume` on the owner's account. Neither is a price worth paying to
|
||
look at a list. It shares the real TLS certificates, because the
|
||
installed APK pins that CA.
|
||
Its port and root are derived from the checkout's name, so two checkouts'
|
||
sandboxes (and the emulators enrolled against them) cannot reach each
|
||
other, and its token is generated once into
|
||
`~/.config/ai-app/sandbox-token` and carried across restarts along with
|
||
any tokens the server's own enrolment flow appended -- so the emulator app
|
||
is enrolled **once** (the start banner prints the command) and stays
|
||
enrolled. It also carries the driving verbs every UI investigation needs,
|
||
so none of this is re-derived per session:
|
||
`./ui-sandbox.sh spawn [title]` (an echo session, prints its id),
|
||
`./ui-sandbox.sh send SID text|@file`, and
|
||
`./ui-sandbox.sh api /path [curl args]` for everything else.
|
||
`./ui-sandbox.sh keep` restarts the server without wiping the sessions and
|
||
enrolment already there -- for when the fixture under test was expensive to
|
||
build (a long delta-heavy transcript, say) and should survive a rebuild of
|
||
the server binary; plain `start` wipes them, which is right for the
|
||
list-screen fixtures and wrong for that.
|
||
It passes `--delay` by default for the reason the next entry gives, and
|
||
`AI_SANDBOX_BIG_MB` puts one large transcript among the small ones --
|
||
`AI_SANDBOX_SPAWN_DELAY` makes the fake CLI slow to start. Both exist
|
||
because operations that finish in milliseconds have states on the way that
|
||
nothing can observe, and an unobservable state is one where broken and
|
||
working look identical.
|
||
- **`app/transcript-bench.sh` is the standard scroll measurement.** It
|
||
opens the first session (or `-k` keeps the current screen), scrolls a
|
||
fixed gesture loop, and prints the app's render report -- the same one
|
||
the in-app copy button produces, whose `on screen:` line names what the
|
||
viewport was actually holding. Compare two runs of it with the same
|
||
gestures; the emulator's absolute frame times transfer nothing, the
|
||
report's accounting does. Run it either side of any change under
|
||
`Markdown*.kt`, `Transcript*.kt` or `SessionScreen.kt`'s list, and put the
|
||
report in the commit; the numbers that move first are the worst
|
||
`record: one block`, the reparse mean while streaming, and the draw
|
||
phase's accounting line.
|
||
- **`app/stream-bench.sh [-k] FILE` is that measurement for a reply still
|
||
arriving.** It opens the first session, taps "Jump to latest" so the list
|
||
is pinned to the newest end, resets the report, sends FILE, waits for the
|
||
transcript to stop growing, and prints. Both of those are corrections to a
|
||
first version that measured nothing: a transcript parked further back never
|
||
redraws while a reply streams into it, and a session is idle at *both* ends
|
||
of a turn, so polling for idle answers before the turn has started.
|
||
- **`app/trace-draw.sh` names what a scrolling frame spends inside the
|
||
framework**, from `atrace` text output with no trace processor needed. It
|
||
is how the cost of a layout node per link was attributed to the framework
|
||
rather than guessed at.
|
||
- **Two traps in the emulator bench loop**, each of which cost a run.
|
||
`adb shell pm clear` removes the enrolment and the notification permission
|
||
along with the saved anchors, so the next run measures a permission dialog
|
||
-- re-enrol with the command `ui-sandbox.sh` prints, and
|
||
`pm grant ... POST_NOTIFICATIONS`. And a saved scroll anchor is per session
|
||
id, so the only way two builds start a scroll from the same place is a
|
||
*fresh session for each*.
|
||
- **A phone that falls behind the stream is answered with `reset`, and
|
||
`RUST_LOG=ai_server=debug` says when.** Every SSE subscriber logs the
|
||
cursor it arrived with and whether it was continued or reset
|
||
(`stream backlog:` in `send_backlog`), which is the only place that
|
||
question is answerable: the app sees a window arrive and cannot tell how
|
||
far it had fallen, and a reset is the one thing that makes its screen jump
|
||
to the newest end. Measured 2026-09-04 against a session streaming at 20
|
||
events a second: reopening one with an anchor 1,800 events back connects
|
||
**87-119 events behind**, well under `CATCH_UP_LIMIT`'s 200, because the
|
||
restore is two requests -- the opening page, then one span covering the
|
||
whole distance to the anchor. So the reset path is not reachable by
|
||
reopening a session, and **to exercise it at all you have to lower
|
||
`CATCH_UP_LIMIT`** in a throwaway server build; at 5 the app takes the
|
||
reset on a live connection, clears, refills and carries on without
|
||
reconnecting. Worth knowing alongside it: **the session screen's stream
|
||
survives backgrounding here** -- 20 seconds at the launcher while 415
|
||
events were produced brought no reconnect at all -- which is not what the
|
||
comment above that loop expects, and is most likely this emulator being
|
||
headless rather than the phone's behaviour.
|
||
- **`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.
|
||
- **A fake CLI exercises the process lifecycle without a token.** Point a
|
||
`claude_cli` provider's `command` at a two-line script — `#!/bin/sh` and
|
||
`cat > /dev/null` — and it behaves the way the lifecycle code cares
|
||
about: it holds the fifo open, records a real pid, writes nothing, and
|
||
dies on a signal. So adopt, stop, restart and start are all drivable
|
||
without a real `--resume` and without spending a turn on somebody's
|
||
account. Sibling to `debug-transcript.sh`, and the two cover different
|
||
halves: reach for this when what is under test is *whether a process is
|
||
running*, and for the script when it is *what the transcript draws*.
|
||
(From the ai-app-2 session, 2026-08-30, which found a clock bug with it
|
||
that the tests did not have.)
|
||
- 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 -s "$SERIAL" 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).
|
||
- **The emulator is `~/repos/emulator-tools`' business, not this repo's.**
|
||
`emu up` creates and boots the AVD named after this checkout — whatever
|
||
`emu name` prints, never a name typed out here, since this file is the same
|
||
in every clone — refusing when the machine has no room for one; `emu list`
|
||
says what is attached and what it costs; `emu down` stops it.
|
||
`run-android.sh` is that plus a build and an install. Run that repo's
|
||
`install.sh` once if `emu` is missing.
|
||
The `adb` on `PATH` after sourcing `android-env.sh` is that repo's wrapper,
|
||
which fills in `-s` from the same rule — so a bare `adb shell` reaches this
|
||
checkout's emulator and refuses to reach another one's. That defaulting is
|
||
what makes the old advice unnecessary rather than wrong: with two attached
|
||
and no `-s`, a bare `adb shell pm list packages` comes back **empty**,
|
||
which reads as the app having been uninstalled rather than as the question
|
||
being ambiguous.
|
||
**Gradle does not go through that wrapper**, so it had the same hole until
|
||
2026-08-31: `installDebug`, `uninstallDebug` and `connectedAndroidTest` ask
|
||
the adb server for every attached device and act on all of them, which is
|
||
how one session's debug build landed on another's emulator. A Gradle init
|
||
script from `emulator-tools` now runs `emu check` before those tasks and
|
||
fails the build rather than fanning out. When it refuses, say which device
|
||
you mean at the moment you use it — `ANDROID_SERIAL=$(emu serial)
|
||
./gradlew …` — rather than exporting a serial into the shell, which goes
|
||
stale the next time an emulator restarts and another checkout's takes the
|
||
port.
|
||
|
||
## Where things run (host vs this VM)
|
||
|
||
Established 2026-08-25. The machine itself — the two boxes, the shared
|
||
`~/repos` mount, and why the VM is untrusted — is described once in
|
||
`~/.claude/MACHINE.md`; what follows is only 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 —
|
||
`ai-server` with no `--bind` — is exercisable during development. It has
|
||
no reachable peer and doesn't need one. 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 how 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 as it would any other machine: a configured host,
|
||
and a session that names it.
|
||
- **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). 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 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 — 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`.
|
||
|
||
## 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 end one, either `POST /sessions/{id}/stop` —
|
||
which keeps the session and its transcript, and `POST .../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.** `POST
|
||
.../message`, `.../command` and `.../compact` go through
|
||
`SessionManager::send_message` and `::run_command`, which start a process
|
||
first when the session is known to have exited and then hand the thing to
|
||
the driver that has one behind it. Only on `exited`: `unknown` has a
|
||
process that may well be reading its fifo. `/rename` starts one too, and
|
||
for a sharper reason than the rest: the CLI keeps its own copy of the
|
||
name, that copy is what its session picker and other agents' session
|
||
lists show, and a session is only ever *given* a name at birth — every
|
||
later start is a `--resume` — so a rename that reached no process would
|
||
leave the two lists disagreeing for good. Its save happens before the
|
||
telling, so a failure there says the telling failed rather than the
|
||
rename. 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** (2026-08-30). It picks up
|
||
the processes still running and leaves every other session as it found
|
||
it: listed, with its transcript and its stream, reporting `exited`, with
|
||
no process and no driver until somebody asks for one. Restarting the
|
||
server used to relaunch a driver for every session, which started a CLI
|
||
for each one that had none — so a session stopped on purpose came back at
|
||
the next rebuild, and the `Idle` the new driver announced stamped every
|
||
row as active just now. If you are looking for a stopped session's
|
||
process after a restart, there is deliberately none; press Start, or send
|
||
it anything.
|
||
- **A launch never moves a session's clock.** A status it has to correct is
|
||
written at the time of the last thing the session actually did, not at
|
||
`now()`, and a session that has never done anything reports
|
||
`SessionConfig::created` rather than the clock — its transcript is empty,
|
||
since a driver announcing the state it starts in is not news, so there is
|
||
no line to read a time off. Both are the same rule as
|
||
`Transcript::last_activity`: a restart has been told nothing, so it must
|
||
not claim anything happened.
|
||
- **A session spawned while testing cleans itself up: `--throwaway-sessions`**
|
||
(2026-08-30), which a **debug build defaults to on**. Every session
|
||
spawned by such a server is marked `throwaway: true` in `config.ron`, and
|
||
its process is stopped — SIGTERM, then SIGKILL after
|
||
`process::STOP_GRACE` — when the server exits or is sent SIGTERM/SIGINT.
|
||
Sessions outliving the backend is right for the ones somebody is using
|
||
and wrong for the ones a test made: those leave a `claude` behind that
|
||
every later server adopts, and they pile up unnoticed (twelve on this
|
||
machine in a day, each holding a conversation open).
|
||
Two things worth knowing. The flag decides only what **new** sessions are
|
||
marked as; what happens on the way out is decided by the **mark**, which
|
||
is the session's own — so a session you spawned deliberately keeps
|
||
running whichever server is up when one exits, and a throwaway one is
|
||
cleaned away even by a server started without the flag. And the waiting
|
||
is not optional: `process::stop` leaves its SIGKILL on a tokio timer,
|
||
which a runtime that is shutting down never runs, so
|
||
`process::wait_gone` does the waiting on the way out. Pass
|
||
`--throwaway-sessions=false` to keep what a development server spawns.
|
||
- **A process that has exited but not been reaped reads as dead**, not
|
||
alive. `/proc/<pid>/stat` keeps the entry — same pid, same start time —
|
||
until the status is collected, so a zombie used to answer "still there",
|
||
which made `exited` unsayable: the session showed `unknown`, its Start
|
||
button never appeared, and stopping it said there was nothing to stop.
|
||
`process::stat_of` reads the state field alongside the start time.
|
||
- **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. The status a
|
||
launch reports obeys the same rule: a session recorded as `exited` whose
|
||
launch has just started a process reports `idle`, because `exited` is the
|
||
word that refuses every command and offers a phone the chance to start a
|
||
second CLI on a live conversation.
|
||
- **`exited` is never taken on trust; it is checked against the process
|
||
record** (`corrected` in `session/mod.rs`). It is the one status that draws
|
||
the phone's Start button and lets `start_session` build a driver, so a
|
||
record that is not known to be dead makes it false and the session reports
|
||
`unknown` instead. Without that, a session adopted at a backend start kept
|
||
the transcript's `exited` while its CLI was running, Start was accepted
|
||
every press, and each press left another reader on the same process —
|
||
which reads on screen as one reply written several times, interleaved
|
||
(`GotGotGot it — it — it —`), not as anything to do with a button.
|
||
A driver that `start_session` replaces gets `Driver::detach` for the same
|
||
reason: swapping the `Arc` does not end the tasks the old one is running.
|
||
- Remote sessions are adopted too. The pid recorded for one is the **`ssh`
|
||
client's**, on this machine — that is the process the backend owns, and it
|
||
lives as long as the remote command does. (This said "local only" until
|
||
2026-08-29; the code never had that branch.) Note the far `claude` always
|
||
has an sshd pipe on stdin whichever version started it, since the fifo is
|
||
on the backend's side — so you cannot tell a backend's version by looking
|
||
at a remote session's stdin.
|
||
|
||
The import list reports each session's **size as well as its line count**,
|
||
because the two disagree in the way that matters: these transcripts embed
|
||
screenshots as base64, so one line can be a megabyte. On this machine a
|
||
69 MB session has 3,427 lines and a 44 MB one has 6,792 — nothing about a
|
||
line count tells you what continuing a session will cost. Shown, not warned
|
||
about; importing a large session is a choice somebody is entitled to make.
|
||
|
||
**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 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 (see
|
||
the layout note above it). 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, since no further keyboard movement will fire it
|
||
again. A streaming reply invalidates the view every frame, which is
|
||
exactly the condition known to starve that callback of its `onEnd`.
|
||
`WindowInsets.isImeVisible` (`ExperimentalLayoutApi`) 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 moment the
|
||
platform says the keyboard is gone, whatever the animated value still
|
||
claims.
|
||
**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) ...)` computed in `SessionScreen`, which
|
||
subscribes the whole screen to a value that changes every frame of the
|
||
animation: measured on the emulator at **16 full recompositions of
|
||
`SessionScreen` per keyboard open, against 1**, and it put the
|
||
transcript's position behind a recomposition while the composer's stayed
|
||
a draw-phase read of the same frame, so the two were only together while
|
||
that recomposition kept landing inside the frame. It is `.then(if (imeVisible) Modifier.imePadding() else
|
||
Modifier)` instead -- `imePadding` reads the inset in the layout phase,
|
||
which is what the comment above the transcript box means by "the whole of
|
||
what the keyboard re-measures", and dropping the modifier is the same
|
||
coercion to zero that the boolean was added for. The counter to check is
|
||
`session screen recomposed` in the debug button's 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 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.
|
||
- **The five-hour window has no reset time between blocks, and that is not
|
||
a missing value.** The usage API anchors it to the block it started in --
|
||
measured 2026-08-31, the reset came back as exactly five hours after work
|
||
resumed, and the weekly windows in the same response carried the identical
|
||
microsecond, so both are computed from one `now()` at request time. When
|
||
no block is running there is nothing to reset and `resets_at` is `null`;
|
||
the same response shows other idle windows with the same shape. The weekly
|
||
ones always have a reset because a week is always running, which is why
|
||
"the others seem fine".
|
||
So `resets_at` absent means **not running**, and only a timestamp that
|
||
arrives and cannot be parsed is unknown. The app collapsed both into one
|
||
null and the session bar said "reset time unknown" for a machine behaving
|
||
perfectly -- while the usage dialog, reading the same field, quietly drew
|
||
nothing. `WindowEnd` in `ResetCountdown.kt` is now the one rule both go
|
||
through.
|
||
- **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, and `delete` resolves the
|
||
path itself: 78ms. Ids are checked (`is_session_id`) before they reach
|
||
that glob, since a `/` or `..` in one walks it out of the projects
|
||
directory and `delete` removes what it lands on.
|
||
- **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:
|
||
on a 21 MB, 24,000-event transcript one page took ~500ms of server time to
|
||
return 620 KB, and took the same 500ms whichever page was asked for. A
|
||
phone scrolling back paid it per page and every stream reconnect paid it
|
||
again to find out nothing had happened. 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 and only the
|
||
window is built. 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.
|
||
`RUST_LOG=ai_server=debug` logs each page with what was asked and what
|
||
came back, which is how to see a phone paging back in real time.
|
||
- **Paging back has two failures that look like "there is simply no more
|
||
history", and neither says anything on screen.** Both fixed 2026-08-31,
|
||
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 history 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, and 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. Measured at the server, which is
|
||
the one number here that does not depend on how the emulator renders:
|
||
against a 24,000-event transcript, ten swipes asked for ten pages before
|
||
and three after.
|
||
- **What the transcript screen costs to scroll, for whoever measures it
|
||
next.** Taken 2026-08-30 on the GPU emulator (`emu up` provides one; a
|
||
frame number from the software rasteriser means nothing -- see
|
||
`~/.claude/MACHINE.md`), against a real imported transcript with the debug
|
||
server at `--delay 120`. Settled and flinging fast, both into fresh
|
||
history and back through rows already drawn: **5.2-5.9% janky frames, 99th
|
||
percentile 29-32ms, 0-2 slow UI-thread frames.** The stock Settings app on
|
||
the same device is 3.3% and 38ms, so this is at the platform floor and
|
||
what is left is the emulator rather than the app. The number that is *not*
|
||
at the floor is the first few seconds after opening a session, where every
|
||
row on the way is being composed for the first time; that is inherent to a
|
||
lazy list and it is why a measurement taken before the screen settles
|
||
reads three times worse. **Settle first, then reset `gfxinfo`.**
|
||
- **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`, over every assistant message loaded, on
|
||
every page. Both are off it now. The shape to watch for is a
|
||
`withContext` that wraps the *fetch* and leaves the work done with the
|
||
result outside 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. `EnrollmentScanActivity` also
|
||
turns off the library's 10% framing-rect inset (it decodes only what is
|
||
inside it) and its laser/result-point decorations.
|