diff --git a/.claude/skills/ai-app-rigs/SKILL.md b/.claude/skills/ai-app-rigs/SKILL.md new file mode 100644 index 0000000..0c7ecbd --- /dev/null +++ b/.claude/skills/ai-app-rigs/SKILL.md @@ -0,0 +1,244 @@ +--- +name: ai-app-rigs +description: ai-app's test rigs, harness scripts and reference measurements - ui-sandbox.sh, debug-transcript.sh, transcript-bench.sh, stream-bench.sh, trace-draw.sh, the /usage fixture vocabulary, the fake CLI, the rule that no UI-driving script may tap a coordinate, how to test llama.cpp and ssh on this machine, how importing behaves, and the scroll/stream/explorer numbers not worth re-measuring. Read before running or writing a benchmark, driving the app's UI from a script, exercising the session lifecycle, testing a llama or remote session, or touching the import screen. +--- + +# ai-app: rigs, harnesses and measurements + +Moved out of `AGENTS.md` on 2026-09-04 so it is read when it is relevant +rather than sent with every request in this repo -- it was 12 KB of the 35 KB +that file cost on every one. Unchanged in the move, and still the only copy. + +## The rigs + +Each exists because something was invisible without it. + +- **`app/ui-sandbox.sh`** — 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. + Its port and root derive from the checkout's name, so two checkouts' + sandboxes cannot reach each other, and its token is generated once into + `~/.config/ai-app/sandbox-token` and carried across restarts along with any + the enrolment flow appended — so the emulator app is enrolled **once** (the + start banner prints the command) and stays enrolled. It shares the real TLS + certificates, because the installed APK pins that CA. + Driving verbs, 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]`. + `./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; plain `start` wipes them, which is right for the list-screen + fixtures and wrong for that. + It passes `--delay` by default, and `AI_SANDBOX_BIG_MB` puts one large + transcript among the small ones while `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. + It also builds a fixture tree at the sandbox home's `~/files` for the + explorer, holding the states otherwise only reachable by finding a real + machine in one: an empty directory, a name with a tab and one with an + apostrophe, a binary file, one over `FILE_LIMIT`, one `chmod 000`, a + symlink to a directory and a broken one, a source file per language, and + the three sizes the limits were measured against (`edit-32k.rs`, + `edit-128k.rs`, `big-source.rs`). Point a session at it with + `./ui-sandbox.sh api /sessions//cwd -X POST -H 'content-type: application/json' -d '{"cwd":"~/files"}'`. + The explorer's 409 is produced by editing the file on the machine + (`printf … > file`) between pressing the pencil and pressing save. +- **`app/debug-transcript.sh`** — a real conversation on the emulator. The + echo driver is the right rig for most things and 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. 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 down. + 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. +- **`/usage` in an echo session puts up an invented meter**, which is how the + rate-limit screens' states are reached without spending quota: `/usage 42`, + `/usage 95 20` (minutes left), `/usage 42 never` (the between-blocks window + with no reset time), `/usage 42 unreadable`, `/usage notloggedin`, + `/usage unreachable`, `/usage failed`, `/usage off`. The vocabulary is + `usage::Fixture`'s, since those are its states. With none set an echo + session meters nothing, which is the ordinary case and draws no bar. +- **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. Reach for + this when what is under test is *whether a process is running*, and for + `debug-transcript.sh` when it is *what the transcript draws*. +- **`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 + holding. Compare two runs with the same gestures; the emulator's absolute + frame times transfer nothing, the report's accounting does. Run it either + side of any change under `Markdown*.kt`, `Transcript*.kt` or + `SessionScreen.kt`'s list, and put the report in the commit. The numbers + that move first are the worst `record: one block`, the reparse mean while + streaming, and the draw phase's accounting line. +- **`app/stream-bench.sh [-k] FILE`** is that measurement for a reply still + arriving. It taps "Jump to latest" so the list is pinned to the newest end, + resets the report, sends FILE, waits for the transcript to stop growing, + and prints. Both of those are corrections to a first version that measured + nothing: a transcript parked further back never redraws while a reply + streams into it, and a session is idle at *both* ends of a turn, so polling + for idle answers before the turn has started. +- **`app/trace-draw.sh`** names what a scrolling frame spends inside the + framework, from `atrace` text output with no trace processor needed. It is + how the cost of a layout node per link was attributed to the framework + rather than guessed at. + +### Driving the UI + +**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 the bench scripts 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 bench 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. + +**Two traps in the emulator bench loop**, each of which cost a run. +`adb shell pm clear` removes the enrolment and the notification permission +along with the saved anchors, so the next run measures a permission dialog — +re-enrol with the command `ui-sandbox.sh` prints, and +`pm grant … POST_NOTIFICATIONS`. And a saved scroll anchor is per session id, +so the only way two builds start a scroll from the same place is a *fresh +session for each*. + +**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. `run-android.sh` is that plus a build and an install. The `adb` +on `PATH` after sourcing `android-env.sh` is that repo's wrapper, which fills +in `-s` from the same rule. Gradle does not go through it, so a Gradle init +script from `emulator-tools` runs `emu check` before `installDebug`, +`uninstallDebug` and `connectedAndroidTest` and fails rather than fanning out +to every attached device; when it refuses, say which device you mean at the +moment you use it — `ANDROID_SERIAL=$(emu serial) ./gradlew …`. + +### Testing llama.cpp and ssh here + +**Both are set up here as of 2026-09-04** and need nothing typed. The +prebuilt CPU llama.cpp lives outside the repo at `~/.local/opt/llama.cpp` +(the 15 MB `ubuntu-x64` release asset) and is symlinked as +`/usr/local/bin/llama-server`, which is what makes **discovery find it over +ssh**: `~/.local/bin` is not on the PATH a non-interactive ssh session gets. +It resolves its own libraries through `$ORIGIN`, so no `LD_LIBRARY_PATH` is +needed. One model is downloaded — `unsloth/Qwen3-0.6B-GGUF/Qwen3-0.6B-Q8_0.gguf`, +639 MB under `~/.local/share/ai-app/models` — and 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. + +There is no second machine, so **ssh this VM to itself**. That is set up +too: the key is `~/.config/ai-app/ssh-self` (its public half is in +`~/.ssh/authorized_keys`, labelled removable), and the real config carries a +setup called **"this vm over ssh"** — `bob@127.0.0.1` with that +`identityFile` plus +`options: ["StrictHostKeyChecking=no", "UserKnownHostsFile=/tmp/ai-app-known-hosts"]` +so it touches nothing real — offering `claude-cli` and `llama-cpp`. It is the +whole rig for "does a remote llama session work", since the far machine is +this one and the model file is the same file. For a throwaway setup of your +own, 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. The remote login +shell here is **fish**; the +remote script 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 is neither is the +thing to suspect first if a remote spawn ever mangles an argument. + +## Importing + +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 — see PLAN.md for the incident that made that a refusal rather +than a warning. + +**One Claude Code session id can name two files, and the listing offers it +once.** Resuming 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, 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. + +**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. + +## Measurements worth not re-taking + +- **What the transcript screen costs to scroll.** Taken 2026-08-30 on the GPU + emulator against a real imported transcript with the 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. 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`.** +- **The reset path is not reachable by reopening a session.** Measured + 2026-09-04 against a session streaming at 20 events a second: reopening one + with an anchor 1,800 events back connects **87–119 events behind**, well + under `CATCH_UP_LIMIT`'s 200, because the restore is two requests — the + opening page, then one span covering the whole distance. To exercise the + reset at all you have to lower `CATCH_UP_LIMIT` in a throwaway build; at 5 + the app takes the reset on a live connection, clears, refills and carries + on without reconnecting. +- **The session screen's stream survives backgrounding here** — 20 seconds at + the launcher while 415 events were produced brought no reconnect at all, + which is not what the comment above that loop expects, and is most likely + this emulator being headless rather than the phone's behaviour. +- **Reopening a cached session costs one request for one event** (the probe), + and scrolling the whole conversation back costs nothing more; a cold open + of the same 500-event session is two pages, 100 events. Measured + 2026-09-04 on the emulator against the sandbox. +- **Reading is cheap and editing is not.** 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. + EXPLORER.md's "What the measurements said" has the rest. diff --git a/AGENTS.md b/AGENTS.md index cd01ade..f94676b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,7 +9,15 @@ between them. 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, rigs, and things that have bitten. +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 @@ -151,168 +159,6 @@ two icon buttons the same width without either being given one — and why genuine handshake against 10.66.0.1 with pinned TLS, no router or phone involved. That is how to verify the wg0-only posture. -## The rigs - -Each exists because something was invisible without it. - -- **`app/ui-sandbox.sh`** — 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. - Its port and root derive from the checkout's name, so two checkouts' - sandboxes cannot reach each other, and its token is generated once into - `~/.config/ai-app/sandbox-token` and carried across restarts along with any - the enrolment flow appended — so the emulator app is enrolled **once** (the - start banner prints the command) and stays enrolled. It shares the real TLS - certificates, because the installed APK pins that CA. - Driving verbs, 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]`. - `./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; plain `start` wipes them, which is right for the list-screen - fixtures and wrong for that. - It passes `--delay` by default, and `AI_SANDBOX_BIG_MB` puts one large - transcript among the small ones while `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. - It also builds a fixture tree at the sandbox home's `~/files` for the - explorer, holding the states otherwise only reachable by finding a real - machine in one: an empty directory, a name with a tab and one with an - apostrophe, a binary file, one over `FILE_LIMIT`, one `chmod 000`, a - symlink to a directory and a broken one, a source file per language, and - the three sizes the limits were measured against (`edit-32k.rs`, - `edit-128k.rs`, `big-source.rs`). Point a session at it with - `./ui-sandbox.sh api /sessions//cwd -X POST -H 'content-type: application/json' -d '{"cwd":"~/files"}'`. - The explorer's 409 is produced by editing the file on the machine - (`printf … > file`) between pressing the pencil and pressing save. -- **`app/debug-transcript.sh`** — a real conversation on the emulator. The - echo driver is the right rig for most things and 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. 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 down. - 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. -- **`/usage` in an echo session puts up an invented meter**, which is how the - rate-limit screens' states are reached without spending quota: `/usage 42`, - `/usage 95 20` (minutes left), `/usage 42 never` (the between-blocks window - with no reset time), `/usage 42 unreadable`, `/usage notloggedin`, - `/usage unreachable`, `/usage failed`, `/usage off`. The vocabulary is - `usage::Fixture`'s, since those are its states. With none set an echo - session meters nothing, which is the ordinary case and draws no bar. -- **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. Reach for - this when what is under test is *whether a process is running*, and for - `debug-transcript.sh` when it is *what the transcript draws*. -- **`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 - holding. Compare two runs with the same gestures; the emulator's absolute - frame times transfer nothing, the report's accounting does. Run it either - side of any change under `Markdown*.kt`, `Transcript*.kt` or - `SessionScreen.kt`'s list, and put the report in the commit. The numbers - that move first are the worst `record: one block`, the reparse mean while - streaming, and the draw phase's accounting line. -- **`app/stream-bench.sh [-k] FILE`** is that measurement for a reply still - arriving. It taps "Jump to latest" so the list is pinned to the newest end, - resets the report, sends FILE, waits for the transcript to stop growing, - and prints. Both of those are corrections to a first version that measured - nothing: a transcript parked further back never redraws while a reply - streams into it, and a session is idle at *both* ends of a turn, so polling - for idle answers before the turn has started. -- **`app/trace-draw.sh`** names what a scrolling frame spends inside the - framework, from `atrace` text output with no trace processor needed. It is - how the cost of a layout node per link was attributed to the framework - rather than guessed at. - -### Driving the UI - -**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 the bench scripts 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 bench 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. - -**Two traps in the emulator bench loop**, each of which cost a run. -`adb shell pm clear` removes the enrolment and the notification permission -along with the saved anchors, so the next run measures a permission dialog — -re-enrol with the command `ui-sandbox.sh` prints, and -`pm grant … POST_NOTIFICATIONS`. And a saved scroll anchor is per session id, -so the only way two builds start a scroll from the same place is a *fresh -session for each*. - -**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. `run-android.sh` is that plus a build and an install. The `adb` -on `PATH` after sourcing `android-env.sh` is that repo's wrapper, which fills -in `-s` from the same rule. Gradle does not go through it, so a Gradle init -script from `emulator-tools` runs `emu check` before `installDebug`, -`uninstallDebug` and `connectedAndroidTest` and fails rather than fanning out -to every attached device; when it refuses, say which device you mean at the -moment you use it — `ANDROID_SERIAL=$(emu serial) ./gradlew …`. - -### Testing llama.cpp and ssh here - -**Both are set up here as of 2026-09-04** and need nothing typed. The -prebuilt CPU llama.cpp lives outside the repo at `~/.local/opt/llama.cpp` -(the 15 MB `ubuntu-x64` release asset) and is symlinked as -`/usr/local/bin/llama-server`, which is what makes **discovery find it over -ssh**: `~/.local/bin` is not on the PATH a non-interactive ssh session gets. -It resolves its own libraries through `$ORIGIN`, so no `LD_LIBRARY_PATH` is -needed. One model is downloaded — `unsloth/Qwen3-0.6B-GGUF/Qwen3-0.6B-Q8_0.gguf`, -639 MB under `~/.local/share/ai-app/models` — and 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. - -There is no second machine, so **ssh this VM to itself**. That is set up -too: the key is `~/.config/ai-app/ssh-self` (its public half is in -`~/.ssh/authorized_keys`, labelled removable), and the real config carries a -setup called **"this vm over ssh"** — `bob@127.0.0.1` with that -`identityFile` plus -`options: ["StrictHostKeyChecking=no", "UserKnownHostsFile=/tmp/ai-app-known-hosts"]` -so it touches nothing real — offering `claude-cli` and `llama-cpp`. It is the -whole rig for "does a remote llama session work", since the far machine is -this one and the model file is the same file. For a throwaway setup of your -own, 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. The remote login -shell here is **fish**; the -remote script 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 is neither is the -thing to suspect first if a remote spawn ever mangles an argument. - ## Where things run (host vs this VM) The machine itself — the two boxes, the shared `~/repos` mount, and why the @@ -359,43 +205,6 @@ day to day: in `process.json`; removing either by hand while the session is live loses output or replays it. -## Importing - -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 — see PLAN.md for the incident that made that a refusal rather -than a warning. - -**One Claude Code session id can name two files, and the listing offers it -once.** Resuming 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, 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. - -**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. - ## Shared appearance - **A row something is happening to is dimmed, drained of colour, and says @@ -523,37 +332,3 @@ belongs in `~/.claude/TOOLCHAIN.md` or `~/.claude/MACHINE.md` instead. 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. -## Measurements worth not re-taking - -- **What the transcript screen costs to scroll.** Taken 2026-08-30 on the GPU - emulator against a real imported transcript with the 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. 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`.** -- **The reset path is not reachable by reopening a session.** Measured - 2026-09-04 against a session streaming at 20 events a second: reopening one - with an anchor 1,800 events back connects **87–119 events behind**, well - under `CATCH_UP_LIMIT`'s 200, because the restore is two requests — the - opening page, then one span covering the whole distance. To exercise the - reset at all you have to lower `CATCH_UP_LIMIT` in a throwaway build; at 5 - the app takes the reset on a live connection, clears, refills and carries - on without reconnecting. -- **The session screen's stream survives backgrounding here** — 20 seconds at - the launcher while 415 events were produced brought no reconnect at all, - which is not what the comment above that loop expects, and is most likely - this emulator being headless rather than the phone's behaviour. -- **Reopening a cached session costs one request for one event** (the probe), - and scrolling the whole conversation back costs nothing more; a cold open - of the same 500-event session is two pages, 100 events. Measured - 2026-09-04 on the emulator against the sandbox. -- **Reading is cheap and editing is not.** 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. - EXPLORER.md's "What the measurements said" has the rest. diff --git a/PLAN.md b/PLAN.md index a0c24a3..2beee6d 100644 --- a/PLAN.md +++ b/PLAN.md @@ -167,6 +167,17 @@ the mode, which do take effect mid-turn. `None` is a level in its own right -- the CLI's own default -- so the picker can return to it; a level this app named as the default instead would be this app choosing one. +**What a new session starts at is `Config::default_effort`**, applied in +`spawn_session` rather than filled in by the spawn screen, so it holds for an +import and a bare API call as well. It is set by the spawn screen's own +picker, whose label says so: one control, where new sessions are made, rather +than a settings page for a single value. It is not on a provider, because +providers are discovered and the next rediscovery would erase it, and not on +the phone, because a second device would then spawn at a level nobody there +chose. `GET`/`POST /defaults` carry it, as a struct rather than a bare value +so the permission mode -- still hardcoded to `auto` on the spawn screen -- can +move there without a second route. + **`--resume` only ever runs when nothing else has that session open.** That is the rule behind the import refusal, the single `ClaudeDriver::launch` entry point, and the `Exited` correction below; two CLIs on one session file diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt index 8dafacd..c7fb46f 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt @@ -485,6 +485,8 @@ fun spawnSession( model: String? = null, cwd: String? = null, permissionMode: String? = null, + /** Null for whatever the server's default is; see [fetchDefaultEffort]. */ + effort: String? = null, params: Map = emptyMap(), /** Continue this Claude Code session instead of starting an empty one. */ import: String? = null, @@ -502,6 +504,7 @@ fun spawnSession( if (!model.isNullOrBlank()) put("model", model) if (!cwd.isNullOrBlank()) put("cwd", cwd) if (!permissionMode.isNullOrBlank()) put("permissionMode", permissionMode) + if (!effort.isNullOrBlank()) put("effort", effort) if (!import.isNullOrBlank()) put("import", import) if (params.isNotEmpty()) { put("params", JSONObject(params.toMap())) @@ -1008,6 +1011,27 @@ fun setSessionModel(settings: ServerSettings, sessionId: String, model: String) */ val PERMISSION_MODES = listOf("manual", "acceptEdits", "auto", "bypassPermissions", "plan") +/** + * What a new session's thinking level is when nothing chose one, or null for the CLI's own. + * + * Held by the server rather than by this phone, because a second device would otherwise spawn + * sessions at a level the first one's owner never picked. + */ +fun fetchDefaultEffort(settings: ServerSettings): String? = + requestFromServer(settings, "/defaults") { + it.jsonObject().optString("effort").ifEmpty { null } + } + +/** Sets what new sessions start at. Nothing already running changes. */ +fun setDefaultEffort(settings: ServerSettings, level: String?) { + requestFromServer( + settings, + "/defaults", + method = "POST", + jsonBody = JSONObject().put("effort", level ?: JSONObject.NULL).toString(), + ) {} +} + /** * How hard the model thinks, as `claude --effort` takes them, cheapest first. * diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SpawnScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SpawnScreen.kt index 9807836..4077cfa 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SpawnScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SpawnScreen.kt @@ -61,6 +61,11 @@ fun SpawnScreen( // "auto" rather than "manual": on a phone every ask is a round trip to a question card, and // answering "allow Bash?" dozens of times per task is what this app exists to avoid. var permissionMode by remember { mutableStateOf("auto") } + // Null until the server has been asked, and null again if it answers "no level chosen" -- the + // two are told apart by [defaultsAsked], because a picker that shows a level before the answer + // arrives is one you can spawn at without having chosen it. + var effort by remember { mutableStateOf(null) } + var defaultsAsked by remember { mutableStateOf(false) } var busy by remember { mutableStateOf(false) } // Only the spawn's own failure. The fetch's lives in `options`: this one leaves a filled-in // form worth keeping, and that one leaves nothing to fill in. @@ -75,6 +80,12 @@ fun SpawnScreen( var temperature by remember { mutableStateOf("") } LaunchedEffect(Unit) { + // Separate from the setups fetch below and deliberately not fatal: failing to learn the + // default must leave a screen you can still spawn from, so the picker stays on "default" + // and says so rather than the whole form refusing to draw. + runCatching { withContext(Dispatchers.IO) { fetchDefaultEffort(settings) } } + .onSuccess { effort = it } + defaultsAsked = true options = try { val fetched = withContext(Dispatchers.IO) { fetchSetups(settings) } @@ -262,6 +273,19 @@ fun SpawnScreen( selected = permissionMode, onSelect = { permissionMode = it }, ) + Spacer(Modifier.height(16.dp)) + + // Says what it does to *later* spawns as well, because it does: the level chosen here + // is stored as the default, which is the whole way that default is set. A picker that + // quietly changed a global would be the same control with the fact left out. + ChipGroup( + label = "Thinking (kept as the default for new sessions)", + options = listOf(DEFAULT_EFFORT) + EFFORT_LEVELS, + // The CLI's own default is a level in the list, so this cannot be a one-way trip. + // Disabled-looking until the server has answered, for the reason above. + selected = if (defaultsAsked) effort ?: DEFAULT_EFFORT else null, + onSelect = { chosen -> effort = chosen.takeIf { it != DEFAULT_EFFORT } }, + ) } Spacer(Modifier.height(24.dp)) @@ -279,6 +303,13 @@ fun SpawnScreen( try { val spawned = withContext(Dispatchers.IO) { + // Stored before the spawn and not after it: choosing a level is + // an intent about new sessions in general, so a spawn that then + // fails must not also lose the choice. Non-fatal for the same + // reason the fetch above is -- the session is what was asked for. + if (isClaude) { + runCatching { setDefaultEffort(settings, effort) } + } spawnSession( settings, // The id, not the label: labels are editable and the server @@ -291,6 +322,7 @@ fun SpawnScreen( if (isLlama) modelKey else model.trim().takeIf { isClaude }, cwd = cwd.trim().takeIf { isClaude }, permissionMode = permissionMode.takeIf { isClaude }, + effort = effort.takeIf { isClaude }, // Sent only when set, so blank means "whatever llama.cpp does // by default" rather than a zero. params = diff --git a/server/src/config.rs b/server/src/config.rs index 02d7bc4..d699cc8 100644 --- a/server/src/config.rs +++ b/server/src/config.rs @@ -30,6 +30,18 @@ pub struct Config { pub tokens: Vec, pub setups: Vec, pub sessions: Vec, + /// What a new session's thinking level is when nothing chose one. + /// + /// Here rather than on a provider because providers are *discovered*: a + /// default written onto one would be erased by the next rediscovery, which + /// is the kind of setting that looks like it stuck until the day it did + /// not. Here rather than on the phone because a second device would then + /// spawn sessions the first one's owner did not expect. + /// + /// `None` is the CLI's own default, and stays reachable: this is a level + /// somebody chose, not a level this app picked for them. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default_effort: Option, } /// A machine, and the things it can run. @@ -462,6 +474,7 @@ mod tests { }], }, ], + default_effort: Some("low".to_string()), sessions: vec![SessionConfig { id: "abc123".to_string(), setup: "vm".to_string(), diff --git a/server/src/routes.rs b/server/src/routes.rs index 7ce44bd..b369c92 100644 --- a/server/src/routes.rs +++ b/server/src/routes.rs @@ -54,6 +54,8 @@ //! POST /sessions/{id}/notify {notify} -- announce this one or not //! GET /notifications SSE: every session's attention-wanting //! moments, live only (see `notifications`) +//! GET /defaults {effort} -- what a new session starts at +//! POST /defaults {effort} -- null for the CLI's own default //! GET /usage cached usage windows per provider //! GET /models downloaded GGUFs, and what is being fetched //! GET /models/search?q=Q HuggingFace repositories matching Q @@ -137,6 +139,7 @@ pub fn router(manager: Arc) -> Router { .route("/sessions/{id}/model", post(set_model)) .route("/sessions/{id}/permission-mode", post(set_permission_mode)) .route("/sessions/{id}/effort", post(set_effort)) + .route("/defaults", get(defaults).post(set_defaults)) .route("/sessions/{id}/notify", post(set_notify)) .route("/notifications", get(notifications)) .route("/sessions/{id}/compact", post(compact)) @@ -1355,6 +1358,36 @@ struct PermissionModeRequest { mode: String, } +/// What new sessions start at. One field today; a struct rather than a bare +/// value because "the defaults" is the thing a phone asks for, and the next +/// one to move here -- the permission mode, which the spawn screen still +/// hardcodes -- must not need a second route. +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[serde(deny_unknown_fields)] +struct Defaults { + #[serde(default, skip_serializing_if = "Option::is_none")] + effort: Option, +} + +async fn defaults(State(manager): State>) -> axum::Json { + axum::Json(Defaults { + effort: manager.default_effort(), + }) +} + +/// Sets what a new session's thinking level is. Applied when a session is +/// spawned, so nothing already running changes underneath anybody. +async fn set_defaults( + State(manager): State>, + axum::Json(body): axum::Json, +) -> Result { + manager + .set_default_effort(body.effort.as_deref()) + .map_err(bad_request)?; + Ok(StatusCode::NO_CONTENT) +} + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] #[serde(deny_unknown_fields)] diff --git a/server/src/session/mod.rs b/server/src/session/mod.rs index 4c6cf81..a13601e 100644 --- a/server/src/session/mod.rs +++ b/server/src/session/mod.rs @@ -1038,7 +1038,19 @@ impl SessionManager { model: spec.model, cwd: spec.cwd, permission_mode: spec.permission_mode, - effort: spec.effort, + // Applied here rather than on the spawn screen, so it holds + // however a session was made -- the phone, an import, or a bare + // API call -- instead of only where somebody remembered to fill it + // in. And only where the driver reads one: a llama session storing + // a level it never passes to anything is a config file that + // answers a question about itself wrongly. + effort: spec.effort.or_else(|| { + provider + .kind + .takes_effort() + .then(|| inner.config.default_effort.clone()) + .flatten() + }), params: spec.params, // On by default. Not offered at spawn: a session's first turn // is exactly the one somebody is waiting for. @@ -1283,6 +1295,27 @@ impl SessionManager { /// /// `None` clears it, which is a level in its own right -- the CLI's own /// default -- and the reason this takes an option rather than a string. + /// What a new session's thinking level is when nothing chose one, and the + /// setting of it. See `Config::default_effort`; `None` is the CLI's own. + /// + /// Only the default: a session already spawned keeps the level it was + /// given, because changing what running conversations do from a screen + /// about *new* ones is not something anybody asked for by setting a + /// default. + pub fn default_effort(&self) -> Option { + self.inner.read().unwrap().config.default_effort.clone() + } + + pub fn set_default_effort(&self, effort: Option<&str>) -> Result<()> { + let effort = effort.map(str::trim).filter(|level| !level.is_empty()); + let mut inner = self.inner.write().unwrap(); + let mut candidate = inner.config.clone(); + candidate.default_effort = effort.map(str::to_string); + candidate.save(&self.config_path)?; + inner.config = candidate; + Ok(()) + } + pub fn set_session_effort(&self, id: &str, effort: Option<&str>) -> Result<()> { let effort = effort.map(str::trim).filter(|level| !level.is_empty()); { @@ -3480,6 +3513,74 @@ mod tests { std::fs::write(path, rewritten).expect("write transcript"); } + /// A new session takes the stored default, and an explicit choice still + /// wins over it. + /// + /// Applied where the session is made rather than on the spawn screen, so + /// it holds for an import and a bare API call too -- a default that only + /// worked from one screen would be a default somebody had already set and + /// would reasonably believe was in force. + #[tokio::test] + async fn a_new_session_starts_at_the_stored_default_thinking_level() { + let dir = tempfile::tempdir().expect("tempdir"); + let config_path = dir.path().join("config.ron"); + let data_dir = dir.path().join("sessions"); + // Seeded with both kinds, because half of what this asks is that a + // driver which does not read a level is not given one. + let cli = seed_stand_in_cli(&config_path, dir.path()); + let manager = SessionManager::new( + config_path.clone(), + data_dir.clone(), + data_dir.join("models"), + ) + .expect("manager"); + assert_eq!( + manager.default_effort(), + None, + "nothing is set to begin with" + ); + + manager + .set_default_effort(Some("low")) + .expect("store the default"); + let took = manager.spawn_session(stand_in_spec(&cli)).expect("spawn"); + assert_eq!( + took.effort.as_deref(), + Some("low"), + "a new session takes it" + ); + + let chosen = manager + .spawn_session(SpawnSpec { + effort: Some("max".to_string()), + ..stand_in_spec(&cli) + }) + .expect("spawn"); + assert_eq!( + chosen.effort.as_deref(), + Some("max"), + "an explicit choice is not overwritten by the default" + ); + + // The case this change had no reason to touch: echo does not read a + // level, so storing one on it would be a config file describing a + // session in terms of something that never reaches it. + let echo = manager.spawn_session(echo_spec()).expect("spawn echo"); + assert_eq!( + echo.effort, None, + "a driver that does not take a level is not given the default" + ); + + // Clearing it is reachable, so the CLI's own default can be restored. + manager.set_default_effort(None).expect("clear the default"); + let cleared = manager.spawn_session(stand_in_spec(&cli)).expect("spawn"); + assert_eq!(cleared.effort, None, "and then new sessions choose nothing"); + + for id in [took.id, chosen.id, echo.id, cleared.id] { + manager.delete_session(&id).expect("delete"); + } + } + /// A thinking level is stored and the process **ended**, because `--effort` /// is read when the CLI launches and has no control request behind it. A /// session left running would go on thinking at the old level underneath a