Three numbers, taken on the emulator through the app's own render report and written into EXPLORER.md; the fixture tree the sandbox now builds is what they were taken against. The viewer's scan was on the main thread. Decision 8 said off it, and the first version did it in a `remember` inside the composition, which is not that -- 460ms of frozen screen on a 1 MiB file, long enough that the accessibility tree cannot be read, which is exactly what "the app has stopped" looks like from outside. It runs on Dispatchers.Default now, with a spinner where the file will be. Reading a megabyte is otherwise fine: the viewer is a row per line, and it opens and scrolls 28,660 of them. Edit mode needed a cap, and not the one the plan expected. The cost that matters is not the highlighting -- 40ms a keystroke at 128 kB, which is survivable -- it is Compose laying out one enormous text in the field: 2,027ms per frame at 128 kB, with typed characters dropped, and no response at all at 1 MiB. Switching highlighting off would have saved nothing, since every arrangement of a single text field pays it. So EDIT_LIMIT is 32 kB, the largest size actually measured as usable, and above it the pencil is disabled with the reason in words beside it: a disabled control teaches what the thing can do but cannot say why it is off, and a reader who cannot edit a file they can plainly read would otherwise conclude the app is broken. `FileLines.of` is timed like everything else here, so the figure lands in the render report rather than needing a harness to ask for it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
885 lines
57 KiB
Markdown
885 lines
57 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.
|
||
|
||
**`TRANSCRIPT_RENDERING.md` is the record of the transcript work** --
|
||
measurements, techniques, the harness, and the ordered list of what is
|
||
next. Read it before touching anything under `Markdown*.kt`,
|
||
`Transcript*.kt` or `SessionScreen.kt`'s list.
|
||
|
||
**`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, and a source file
|
||
per language. 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` and TRANSCRIPT_RENDERING.md.
|
||
- **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.
|
||
- **`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.
|