Files
ai-app/AGENTS.md
T
iris-aiandClaude Opus 5 74cda485e5 Give a llama session a thinking level, asked of the model
A `thinking` param on the llama driver: "auto", "off", or a level, applied as a
chat-template argument on the next request -- `reasoning_effort`, or
`enable_thinking: false` for off -- so unlike the server flags it costs no
reload. It lands in the session settings dialog beside the other model
settings, which is what declaring it in `DriverKind::params` buys.

Which levels exist is the model's answer rather than a constant, because the
vocabularies disagree: the 27B here takes low, medium and xhigh and **raises**
on high and max, so a fixed list is a turn that fails on send. The driver asks
the loaded server (`thinking_options`) -- `chat_template_caps.
supports_reasoning_effort` for whether levels mean anything at all, which is
the gate that stops the control silently doing nothing on a template that
ignores the argument, then `/apply-template` per level, one cheap render each
at load time. Off is a separate argument and a separate question: honoured when
turning it off renders a different prompt, and both renders have to have
worked, since a template that refuses it also renders differently.

A level the loaded model cannot take is dropped from the request and said in
the transcript, naming what it does take. What is *not* said is anything about
a model nobody has asked yet: the answer is `Option<Vec<String>>`, where None
is "no server has been up" and an empty list is the model that genuinely takes
none.

Verified against the 27B on the GPU: "low" thought for 697ms and 79 characters,
"off" produced no thinking block at all, and "high" answered `this model does
not take "high" -- it takes off, low, medium, xhigh.` The picker wraps to two
rows in the settings dialog and shows the session's current value.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 16:14:35 -04:00

515 lines
32 KiB
Markdown

# ai-app
A phone interface to AI coding sessions (Codex, Claude Code and llama.cpp),
replacing the Claude app for daily use. Rust/Axum backend on the desktop,
Kotlin/Compose Android app, WireGuard + pinned self-signed TLS + bearer token
between them.
**`PLAN.md` is the design source of truth** — every decision with its date,
its rationale, and what was rejected. Read it before changing anything
structural, and update it in place when a decision changes rather than
letting this file and the plan become two versions of the truth. This file is
the working notes layer: layout, commands, and things that have bitten.
**The rigs are the `ai-app-rigs` skill** — the sandbox and bench scripts, the
rule that no UI-driving script may tap a coordinate, how to test llama.cpp and
ssh here, how importing behaves, and the measurements not worth re-taking.
They moved there on 2026-09-04 because they are 12 KB that only matter once
you are actually running one, and this file is sent with every request. Read
it before writing or running a benchmark, driving the UI from a script, or
touching the import screen.
The central design point, worth not undoing by accident: **a session is a
child process, translated into one common event model.** A new session type
is a new driver — never a session-type branch in shared code (routes,
transcript, app screens).
## Layout
Mirrors `../dev-updater` deliberately: same stack (axum 0.8 +
axum-server/rustls, tokio, clap; Kotlin 2.4.x + Compose Multiplatform, single
`:androidApp` module), same cert scheme, same registry pattern. Read
dev-updater's `README.md` and `AGENTS.md` before diverging from them.
Module-by-module intent is in PLAN.md's "Backend layout".
- `server/` — the Rust backend (`ai-server`). `routes.rs`'s module doc
comment is the HTTP table and the surface's source of truth.
**A llama.cpp session runs on its configured machine** (built
2026-09-04, the last of phase 5): `Transport::reserve_port` returns the
port the server binds *there* and the port that reaches it *here*, and
`Launch::reaching` puts the `-L` tunnel on the connection already carrying
the command. Three things fell out of it and are easy to get wrong again —
a forwarded launch gets a pty (`-tt`) and every other one keeps `-T`,
because `llama-server` never reads the stdin whose closing ends a CLI and
the same kill left it loaded on the far machine; the model is looked for on
the machine that will serve it, so the spawn screen offers
`GET /machines/{id}/models` rather than the backend's own downloads; and the
readiness poll watches the process as well as the port, since a model that
will not load exits in a second and was being reported as "gave up after
300s". See PLAN.md's "Transport" and "llama-server management".
**A llama session has tools and runs the loop itself** (2026-09-19):
`--tools all` gives it `llama-server`'s built-in set, which that server also
*runs* (`GET /tools` for the definitions, `POST /tools` to call one), while
web search comes from an MCP server this backend connects to directly
(`session/llama/mcp.rs`, Exa preset in a discovered provider's
`mcpServers`). Driving the loop is what makes the permission gate ours:
`manual` asks before every call and remembers a tool you answer
"Always allow …" to, `bypassPermissions` never asks, and the allowances are
folded back out of the transcript. Three more things fall out of it and are
easy to get wrong again — a model change **reloads the server** rather than
being refused, since the conversation lives in the transcript rather than in
`llama-server`; `-np 1` is always passed, and it is what decides whether the
MTP draft head is a 50% speed-up or a 33% loss; and `--spec-type draft-mtp`
is conditional on the file actually having a head, because asking for one
that is not there makes `llama-server` **exit**.
**A llama session's thinking is drawn** (2026-09-19): `reasoning_content`
becomes `Event::Thinking` deltas closed by an `Event::ThinkingDone` carrying
the span the *driver* measured, and the phone draws a card that spins while
the block is open and says "Thought for 12.4s" once it is not. The reasoning
is deliberately not part of the next prompt (`conversation` ignores it), and
`timings.predicted_per_second` and `timings.prompt_ms` off the same stream
become `UsageDelta`'s `tokensPerSecond` and `prefillMs`, which is the
"read 9.5s · 50.3 tok/s · 3:00 PM" under a finished reply — nothing else here
measures either, so every other driver sends `None`, and the clock is last so
that it does not move when a provider reports fewer of them.
**A turn's wait has two halves and says which** (2026-09-19):
`SessionStatus::Loading` is the model coming off disk and
`SessionStatus::Reading` is `llama-server` processing the prompt -- emitted
when the request goes out and cleared by the first thing the model says, of
any kind. Prefill is the expensive half here (~10s at 6k tokens, ~22s at
14k), and as `running` it looked exactly like thinking. The phone draws
both with the working spinner and its own words, "loading model" and
"reading prompt".
**Thinking effort is a param, and which levels exist is the model's answer**
(2026-09-19): the `thinking` param rides on the request as a chat-template
argument (`reasoning_effort`, or `enable_thinking: false` for `off`), so it
needs no restart -- and the driver asks the loaded server which levels its
template actually takes rather than trusting the offered list, because the
27B raises on `high` and answers to `xhigh`. A level it cannot take is
dropped and said in the transcript, naming the ones it can.
**Every one of those is a default rather than a constant** (2026-09-19):
`DriverKind::params` declares what a provider takes — key, label, shape,
and whether a change waits for a restart — and the phone renders whatever
arrives, on the spawn form and in the session settings dialog. Adding a
setting to a driver is one entry in that table and no app change. `tools`
is in there too, because the seven built-in definitions are ~1,300 tokens
of every prompt (2,191 against 887 with none), which on a small window is
the difference between a usable session and one that overruns; `"none"`
omits the flag, since `--tools none` is a server that exits.
Codex is one persistent `codex app-server --stdio` process per session; its
driver uses native turn steering and interruption, persists the protocol
state and thread id, and reads subscription limits through the same CLI
protocol.
- `app/` — the Compose app, package `com.example.aiapp`, label "AI Sessions".
`AppRoot.kt` is the navigation `when`; `MainScreen.kt` the root's four tabs
(sessions, import, models, machines); `Api.kt`/`EventStream.kt` the REST + SSE
clients; `Events.kt` the event model mirror; `ServerConfig.kt` settings and
the Keystore-sealed token.
- `wg-app-link/` — a **git submodule** shared with dev-updater: the pinned CA
and leaf (`certs`), QR enrollment and the bearer token (`enroll`), wg0
binding and the certificate's SANs (`netif`), owner-only files (`private`),
and the RON house rules (`format`). Clone with `--recurse-submodules`, or
`git submodule update --init` in an existing checkout — `server/` will not
build without it, since it is a path dependency, which is what keeps the two
projects version-locked to the commit this repo pins. What deliberately did
**not** move is the API surface and the config *schema*: routes, drivers,
sessions and machines are what makes this project itself.
- `SUBAGENTS.md` — a session's subagents as transcripts of their own
(`server/src/session/subagent.rs`, the subcards in `SessionListScreen.kt`
and the read-only form of `SessionScreen.kt`); `DECISIONS.md` holds the
choices made there that are still awaiting review.
- `EXPLORER.md` — the file explorer's design (`server/src/files.rs` and
`FilesScreen.kt` / `FileViewer.kt` / `FileEditor.kt`).
- `TRANSCRIPT_CACHE.md` — the phone's copy of what it has been sent. Read it
before touching `TranscriptCache.kt`, `TranscriptSource.kt`, or the opening
and stream effects in `SessionScreen.kt`.
- `TODO.md` — the working list.
- `.dev-updater.ron` — what Dev Updater builds here: the server (run as
`service: Managed(…)`, supervised by Dev Updater's own implementation
rather than a script kept here) and the APK, in parallel. It points at
`resources.ron`, which is *ours* rather than Dev Updater's — it names
`~/.local/share/ai-app` and `~/.config/ai-app` so the Uninstall dialog can
offer them. Note what deleting the config directory takes with it: the CA
under `certs`, which is the one-way door. **Stop** on the server card stops
the server a phone reaches through the tunnel, so on that phone it stays
down until somebody starts it again; Dev Updater reaches it over its own
port and is unaffected, which is what makes the button safe to press and
easy to regret.
### Icons
**Nerd Fonts glyphs from a committed subset**, not vector assets and not
ordinary Unicode. `NerdIcons.kt` declares each codepoint and
`app/build-icon-font.sh` subsets the font; the two lists have to agree,
because a codepoint in the Kotlin that the script did not subset is a glyph
that silently isn't there. Rerun the script and commit its output when adding
one — it needs network access. `md-cog` and `md-refresh` are deliberately the
same codepoints dev-updater uses and must not drift from it. The subset is
the **Mono** face, where every glyph is one em square, which is what makes
two icon buttons the same width without either being given one — and why
`GLYPH_SIZE` is smaller than it looks like it should be.
## Checking your work
- **Server**: `./run-tests.sh` from the repo root (or `cargo test` from
`server/`), plus `cargo clippy --all-targets` and `cargo fmt`. The build
stays warning-clean and rustfmt-clean at the defaults — there is no
`rustfmt.toml` and there should not be one.
- **App**: from `app/`,
`. ./android-env.sh && ./gradlew :androidApp:ktfmtFormat
:androidApp:compileDebugKotlin :androidApp:lintDebug
:androidApp:testDebugUnitTest`. The unit tests are JVM-only and cover the
syntax highlighter, the ANSI parser and the transcript cache — the app's
pure logic with no Android in it.
- **Android Lint is not optional and is not run by a build.** It found a
crash that had been shipping (`java.time` on a minSdk-24 app with
desugaring off) and later a permission check that silently dropped every
notification on Android 12 and below. Fully clean as of 2026-08-31; keep it
that way, and suppress with `tools:ignore` plus a written reason rather
than by lowering the bar.
- Then `./build-apk.sh` for the APK to install on a phone through Dev
Updater, or `./run-android.sh` to build, install and launch on the
emulator. **The phone gets the release build**, signed with a key the
script generates once under `~/.config/ai-app/release.jks` (never in the
repo); `./build-apk.sh debug` builds the other variant, and Dev Updater's
build modes call the script with exactly that word. Dev Updater lists every
variant under `build/outputs/apk`, so pick `release` there; a phone still
holding the debug build has to uninstall it first, since the two are signed
differently.
- The emulator scripts stay on the debug build. **Never read a frame time
from one as the app's** — a debuggable build runs Compose at a fraction of
release speed; the render report says which build it came from.
## Running it here
- Run the server for development with `--bind 127.0.0.1`. Without it the
server binds wg0, which exists here but is unreachable from the emulator
(it dials 10.0.2.2). First run prints the enrollment QR/URI with the token.
`ai-server --enroll-link` mints one more device's link while the server
keeps running; the server adopts that token on its first use. It is what
Dev Updater's Enroll button runs.
- Point development at a scratch state directory rather than the real one:
`--config /tmp/…/config.ron --data-dir /tmp/…/sessions --port 8444`.
- **The APK pins the CA of the machine that builds it**, read at build time
from `$XDG_CONFIG_HOME/ai-app/certs/ca.pem` (`AI_APP_CA` overrides). So the
server must have started once on that machine first — the build stops with
that instruction otherwise — and an APK built in this VM only works against
a server in this VM.
- Prefer exercising the server directly over going through the UI:
`curl --cacert ~/.config/ai-app/certs/ca.pem -H "Authorization: Bearer …" https://127.0.0.1:8443/sessions`.
The CA is wherever `--certs` put it — by default under `$XDG_CONFIG_HOME`,
never in the checkout, so a relative `certs/ca.pem` finds nothing.
The emulator app reaches it at `https://10.0.2.2:8443`; enroll with
`adb shell "am start -a android.intent.action.VIEW -d 'aiapp://enroll?host=10.0.2.2&port=8443&token=…'"`.
- **`ai-server --delay MS` holds every response back.** Over the tunnel a
phone's requests take tens to hundreds of milliseconds, and several faults
live entirely in what the app does *while* one is outstanding. On a
loopback server those windows close before anything can be observed, so the
bug looks like it is not there.
- **`RUST_LOG=ai_server=debug`** logs every transcript page with its `before`,
`after` and what came back, and logs each SSE subscriber's cursor and
whether it was continued or reset (`stream backlog:`). That is the only
place "how far had this phone fallen behind" is answerable — the app sees a
window arrive and cannot tell.
- **`./test-wg-tunnel.sh up|test|down`** builds a real tunnel between two
network namespaces inside one machine and drives the server through it — a
genuine handshake against 10.66.0.1 with pinned TLS, no router or phone
involved. That is how to verify the wg0-only posture.
## Where things run (host vs this VM)
The machine itself — the two boxes, the shared `~/repos` mount, and why the
VM is untrusted — is described once in `~/.claude/MACHINE.md`. What that
means here:
- **`ai-server` belongs on the host in production.** That is where the LAN
address the phone can reach is, and where WireGuard terminates.
`wg-machine-host.sh` sets that up (keys, `wg0.conf`, the phone's QR); run it
there with `sudo WG_ENDPOINT=<ddns name>`.
- **The tunnel and the real phone can never terminate in the VM**, because
nothing outside can open a connection into it. Phone bring-up is host work.
- `wg0` (10.66.0.1) exists in this VM too, so the production path is
exercisable during development. It has no reachable peer and does not need
one — but with no `--bind` the emulator cannot reach the server.
- **The `claude` CLI is only in the VM, so from the host it is a remote.**
The backend reaches it as it would any other machine.
- Starting the server in the VM makes a separate throwaway dev CA. **Never
install a build pinning that on the real phone.**
## Sessions outlive the backend
Since 2026-08-29 a session's process is deliberately left running when
`ai-server` stops, and adopted again when it starts. PLAN.md has the design;
day to day:
- **Stopping the server no longer stops the sessions.** After `pkill
ai-server` the `claude` processes are still there, on purpose
(`reattaching to the claude-cli it left running` in the log). To end one,
`POST /sessions/{id}/stop` — which keeps the session and its transcript,
and `/start` brings the process back on the same conversation — or delete
the session, which ends the conversation too.
- **A message or a command sent to a stopped session starts it**, so the
Start button is for when you want a process and nothing to say to it yet.
- **A backend start adopts and starts nothing.** If you are looking for a
stopped session's process after a restart, there is deliberately none.
- **A session spawned while testing cleans itself up**: `--throwaway-sessions`,
which a debug build defaults to on. Pass `--throwaway-sessions=false` to
keep what a development server spawns. The flag decides only what **new**
sessions are marked as; what happens on the way out is decided by the
**mark**.
- Each session directory holds `process.json`, `stdin.fifo`, `stdout.log` and
`stderr.log`. `stdout.log` is the driver's input, read from the byte offset
in `process.json`; removing either by hand while the session is live loses
output or replays it.
## Auto-resume
**A session switched to it sends itself a message once the account's usage
limit lifts** — off by default, per session, in the session settings dialog.
PLAN.md's "Auto-resume" is the design; day to day:
- **The schedule is a plan to ask.** `resume.rs` wakes at the scheduled time,
asks `GET /usage`'s meter for that machine and provider, and only sends when
it answers `ok` with nothing at 100%. Anything else — still spent, logged
out, unreachable — is a longer wait, and a still-spent window reschedules to
the reset time the *meter* now gives.
- **Test it with echo, never with a real account.** `/limit [minutes]` reports
the same `limitReached` event a real driver does, and `/usage 100 5` sets
what the meter answers. They are deliberately separate: the two disagreeing
is the case the design exists for. `/usage 20` is the limit lifting.
- The wait is on the session in `config.ron` (`resume`), so it survives a
backend restart. A day after the limit was hit it gives up and says so in
the transcript.
## A session waiting on its own work
Since 2026-09-06 a session whose turn ended with a **backgrounded subagent or
command still running** reports `waiting` rather than `idle` — its own status,
drawn as the word "waiting" in `waitingColor` on both screens. `idle` means
"waiting for a person" and this means the opposite, so it also suppresses the
"finished" notification, which used to arrive at the one moment it was untrue.
Two things fall out of it and are easy to get wrong again: the queue and the
held-command boundary release on **either** end-of-turn status, so a message
sent while a subagent runs is not held until the subagent finishes; and
`sessionWorking("waiting")` is deliberately **false** — nothing is being
written, and the fold uses that same predicate to decide a reply is settled.
- **Nothing subagent-specific goes in the main agent's transcript** unless a
subagent sends it a real message that wakes it — which is the peer path, and
already has a row. A row per finished background task was tried and was a
screenful of dividers about work nobody was asking after, one of them a whole
shell command. A subagent's report is its own transcript's closing text and
is read in the subcard.
- **A backgrounded command has no subagent, so its report lands in the tool
card that launched it** — a `ToolUpdate` against the call's own id, replacing
the launch result that says it is still running. `/background [seconds]` in
an echo session is that shape end to end.
- **Two replies that meet are separated by a `TurnBreak`** — a hairline, no
words. The reply that follows a turn boundary is a **new** message: the fold
refuses to grow a settled reply, and without that the two ran together
mid-sentence. `./ui-sandbox.sh` plus `/subagent 3` or `/background 5` in an
echo session is the whole rig; the helpers stagger a second apart so each
reply is its own.
- **Claude's background-task level is authority; two edge sources are the
fallback.** Since Claude Code 2.1.261,
`background_tasks_changed { tasks: [...] }` replaces the live set and repairs
a missed ending edge. Its array size is also the measured `backgroundTasks`
count exposed on the session row and event stream; the phone draws a nonzero
count beside the status rather than deriving one from `waiting` or from the
subagent directory. An adopted CLI is sent a repeated `initialize` to ask
for the current set. Reconcile only between turns or at a result boundary:
a foreground agent is legitimately absent from a background-only snapshot.
Older CLIs still need both edge sources: `open_tasks` knows about a
backgrounded command, while `Subagents::any_open` finds a subagent whose
`task_started` is behind an adopted stdout offset.
- **A usage limit a subagent hits reaches the session**, not just the
subagent's own transcript; auto-resume can only schedule against a session.
That is the case where the main agent is idle and a background Task is
still burning quota.
- **Codex's count is two id sets added together.** Open child thread ids come
from the subagent registry; live background command process ids come from
app-server's experimental `thread/backgroundTerminals/list`. The command set
is runtime state, refreshed at lifecycle edges and once a second while
nonempty. Never decrement it from an unmatched completion.
- **The status word and its colour are `sessionStatusWord` /
`sessionStatusColour`**, shared by the list and the session screen. They
were two `when`s, and the second one silently missed `waiting`.
## Shared appearance
- **A row something is happening to is dimmed, drained of colour, and says
which operation in a word** — `BusyItem`, used by both the session list and
the import list so the appearance is learned once. The word rather than a
bare spinner because "deleting" and "importing" differ in kind. It does
**not** make the row inert: the caller disables its own click handler while
it passes a label. An overlay consuming pointer events was tried and
swallowed the drag along with the tap, so a list could not be scrolled
while anything in it was busy.
- **A rate-limit bar belongs to a session's provider, not to its machine.**
One machine offers echo, the Claude CLI and a local model at once and only
the CLI spends anything, so a session says which meter reports on it
(`usageProvider`, from `DriverKind::usage_provider`, which
`usage::providers_for` reads too so the two lists cannot disagree) and the
phone matches a snapshot on machine *and* provider. Nothing meters a llama
or echo session, and the phone draws **nothing** for one — not a zero, and
not "unknown". Nothing while the first fetch is out either: "checking"
under a session that turns out to meter nothing is a row the screen then
has to withdraw.
## Things that have bitten
- **A server started with no `--tools` answers 403 at `GET /tools`, not an
empty list.** The route is off rather than empty, so reading that as a
failure made "no tools" — the one setting whose entire purpose is to have
none — a session that never started.
- **A llama session reports `loading`, and a message sent into it waits.**
Before 2026-09-19 the session showed `running` from the moment the process
started, so a minute of reading a model off disk was indistinguishable from
a minute of thinking -- and anything sent in that window came back as an
error, because `llama-server` refuses everything until the model is in
memory. `SessionStatus::Loading` is the state and `Shared::await_ready` is
the waiting. A driver that reports `Loading` owes the holding as well as the
word.
- **A transcript outlives the enum.** Removing `Event::TaskNote` hours after
adding it made every transcript that had recorded one unreadable, so
`launch` failed for those sessions and `SessionManager::new` skipped them —
no status, nothing sendable, no new messages, for every live session that
had run a background task. **The set of kinds a transcript can hold only
ever grows**: a line may come from a newer server or from an older one that
wrote a kind since dropped, and one unfamiliar word must never be able to
end the file. `Indexed::parse_at` degrades a line it cannot read to
`Event::Unreadable { kind }`, keeping its seq — which is what everything
downstream is addressed by — and the phone draws it as a placeholder saying
which kind. Never delete a variant instead of retiring it; `Event::TaskNote`
is what retiring looks like, and the phone folds it to no row.
Project-specific only. A lesson that would bite any project on this machine
belongs in `~/.claude/MACHINE.md` or the `this-machine-*` skill for its
subject; one that would bite any project anywhere belongs in the
`code-lessons` skill, under the admission test at its end.
- **tracing caches callsite interest process-wide.** A test that hits a
`tracing::warn!` with no subscriber installed can poison the interest cache
for a concurrent test that captures logs (flaky "nothing was logged"
failures). Keep every exercise of a logging code path under the one
capturing subscriber — that is why the auth middleware has a single
combined gating+logging test.
- **The composer can get stuck floating above the bottom of the screen after
the keyboard closes, while a reply is streaming.** The composer's position
and the transcript's bottom padding are both driven by the raw, animated
`WindowInsets.ime` value read inside a `graphicsLayer` block, to avoid
recomposing the whole screen every frame of the keyboard's animation. That
animation is carried by a `WindowInsetsAnimationCallback`, and a callback
interrupted mid-flight leaves whatever it was carrying frozen at its last
value with nothing left to correct it. A streaming reply invalidates the
view every frame, which is exactly the condition known to starve that
callback of its `onEnd`. `WindowInsets.isImeVisible` does not share the
failure mode — it is set once, from the platform's own start/end of the
transition over a different path — so it is read once per keyboard toggle
and used to force both places back to zero.
**The guard is a boolean; the inset itself must never be read in the
composable body.** That correction first shipped as a `padding(bottom = …
imeInsets.getBottom(this) …)`, which subscribes the whole screen to a value
that changes every frame: measured at **16 full recompositions of
`SessionScreen` per keyboard open, against 1**. It is
`.then(if (imeVisible) Modifier.imePadding() else Modifier)` instead —
`imePadding` reads the inset in the layout phase, and dropping the modifier
is the same coercion to zero the boolean was added for. The counter to
check is `session screen recomposed` in the debug report, which should move
by one across a keyboard open, not by the number of frames it took.
- **The keyboard pans the window unless the activity opts into resize.**
Without `android:windowSoftInputMode="adjustResize"`, opening the IME
slides the whole window up (top bar off screen) instead of resizing —
`imePadding()` alone does not fix it and the transcript looks empty.
- **A PEM constant must start at the opening quotes.** A generated
`"""\n-----BEGIN CERTIFICATE-----` costs Android's `CertificateFactory` its
preamble sniff, so it tries DER instead and fails at runtime with
`ASN.1 … DECODE_ERROR` — nowhere near the code that produced it.
- **ZXing only looks for a dark code on a light ground.** The enrollment QR
is block characters in the terminal's foreground colour, so a dark-themed
terminal renders it as a negative and the in-app scanner silently never
matches — while the phone's own camera app, which tries both, does. The
scanner asks for `Intents.Scan.MIXED_SCAN`, which alternates normal and
inverted frames; keep it that way rather than making the server dictate the
colours.
- **`serde_json`'s default float parser is not correctly rounded**, so the
server handed out the same transcript line two different ways: a `ts` of
`1788546972.6030757` came back from `/transcript` as `…0755` while the SSE
stream sent the original. Nothing on screen could show it — a `ts` is drawn
as a relative time — and what found it was the phone's cache comparing a
line it held against the server's answer. The `float_roundtrip` feature in
`server/Cargo.toml` is the fix and
`a_line_read_back_is_the_line_that_was_written` is what keeps it; that test
fails within a second of the feature being dropped.
- **Resolving one importable session used to list every one of them.**
`import::delete` and the import seed both called `list`, which reads every
transcript Claude Code has ever written — measured at 3.7 seconds against
the 867 MB in this VM, paid once per session in a batch. `import::find`
takes the same script with one glob narrower: 78ms. Ids are checked
(`is_session_id`) before they reach that glob, since a `/` or `..` walks it
out of the projects directory.
- **A transcript page used to cost the whole transcript.** `read_window` read
and parsed every line and then kept the last `limit` of them, so the work
was the size of the conversation rather than the size of the answer: one
page of a 21 MB, 24,000-event transcript took ~500ms to return 620 KB, and
took the same 500ms whichever page was asked for. It is a bisection now
(`Indexed` in `transcript.rs`) — sequence numbers only increase, so the
edge of a range is found by parsing one line per halving. Same page,
~110ms, of which ~20ms is the file scan. The file is still read whole; that
is where the remaining cost is, and going further means a chunked backwards
reader.
- **Paging back has two failures that look like "there is simply no more
history", and neither says anything on screen.** Both invisible on a
loopback server and reproducible at `--delay 150`. The pager fires on the
*first layout*, before any event has arrived — `moreHistory` starts true,
so the spinner is in the list and `visibleItemsInfo` is not empty — and
`before = 0` asks for the events before the first one, which is none, which
is exactly how this code is told it has reached the start. `loadOlderPage`
refuses `oldestSeq == 0` now. And `joinPages` only ran `adoptRun` on the
path where a *split* call had been found, so a boundary landing cleanly
between two calls — most of them — left one run of tool calls drawn as two
groups with the seam wherever the reader happened to have paged.
Reproducing either takes a boundary placed on purpose: the opening page is
80 events, so arrange the transcript so that event counts back from the
newest.
- **A page is 800 events and a screen is a handful of rows, and the two have
no fixed ratio.** A run of thirty-five tool calls is one row; a reply is
hundreds of text deltas folded into one. So anything that budgets in rows
has to measure a screen rather than name a number: the history cushion was
eight rows, which on a tool-heavy transcript is less than one screenful, so
the reader hit the end of what was loaded on every swipe and stood there
for a round trip. It is `HISTORY_SCREENS` viewports now, counted from what
is actually on screen.
- **A page landing while the history observer was fetching it must trigger its
own successor.** The observer once collected only `LazyListState.layoutInfo`;
while its collector was suspended in `loadOlderPage`, a compact page could
be composed and laid out without leaving another change to observe afterward.
Keying the effect on `oldestSeq` still missed the opening prefetch: that key
changed while `loadingHistory` was true, so the restarted effect declined to
overlap it and never noticed the flag returning to false. Codex exposes both
failures because a page full of calls collapses into one tool group: loading
stopped until expanding that group forced a layout. The observer now collects
the cursor, loading, restoring and failure state with the layout, so returning
to not-loading always rechecks the settled height. A failed page turns the
history boundary into a Try again control rather than retrying in a loop or
requiring another scroll.
- **Only `fetchTranscript` was off the main thread; the fold was not.**
`foldEvent` returns a new list per event, so a page is that many copies of
a growing list — fine at 80 events and about 300,000 element copies at 800,
run in the middle of the scroll that asked for it. `warm` had the same
shape: the `markdownIn` scan that decides *what* to parse ran before the
hop to `Dispatchers.Default`. The shape to watch for is a `withContext`
that wraps the *fetch* and leaves the work done with the result outside it.
- **A transcript snapshot cannot survive a suspension and then be assigned.**
`loadOlderPage` joined its page to `items`, suspended while `warm` parsed
markdown, and then assigned the joined snapshot. An SSE event arriving in
that gap appeared and vanished; reopening brought it back because the
transcript and cache had it all along. Warm against a candidate if needed,
then join against the current `items` and assign without another suspension.
Also keep the page's original `oldestSeq`: a stream reset while the fetch or
warm is suspended makes the page stale, and it must be discarded rather
than joined into the reset window.