`UsageDelta` gains `prefillMs`, llama-server's own `timings.prompt_ms`, so the footer under a finished reply is "read 9.5s · 50.3 tok/s · 3:00 PM". Prefill is the half of a turn that was invisible and is often the larger: measured on the 0.6B here, 1m 4s for the first turn after a model loads against 22ms for the next, whose prompt the server still had cached. The clock moves to the end of the line. Everything in front of it is a provider's own measurement, so a session on another provider has fewer of them or none, and a reader who has learned where the time is should not have to find it again because the model changed. The costs grow leftwards into the space instead, and a test asserts every shape of the line ends with the same thing. Verified on the emulator against a real llama session: three replies reading "read 1m 4s · 193 tok/s · 3:54 PM", "read 25ms · 308 tok/s · 3:54 PM" and "read 22ms · 194 tok/s · 3:54 PM", with the clock in one column. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
508 lines
32 KiB
Markdown
508 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".
|
|
**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.
|