Default thinking level for new sessions, and move the rigs out of AGENTS.md
`Config::default_effort` is what a session starts at when nothing chose one, applied in `spawn_session` rather than filled in by the spawn screen so it holds for an import and a bare API call too. 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. Not on a provider, because providers are discovered and the next rediscovery would erase it; 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, so the permission mode -- still hardcoded to `auto` on the spawn screen -- can move there later without a second route. Only drivers that read a level are given one: an echo session was storing a `--effort` it never passes to anything, which is a config file answering a question about itself wrongly. Separately, `AGENTS.md` is 35 KB sent with every request in this repo, and 12 KB of it was rigs and reference measurements that only matter once you are running one. Those are the `ai-app-rigs` skill now -- the same text, still the only copy, read when the work touches it. 35,198 -> 20,813 chars. Verified on the emulator against the sandbox: the spawn screen pre-fills from the server, picking `low` spawned a session at `low` and left `/defaults` set to it, and an echo session spawned afterwards took no level at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1ff662c7c3
commit
4821a02bd3
8 files changed
+468
-235
No files matched your search
@@ -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/<id>/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.
|
||||||
@@ -9,7 +9,15 @@ between them.
|
|||||||
its rationale, and what was rejected. Read it before changing anything
|
its rationale, and what was rejected. Read it before changing anything
|
||||||
structural, and update it in place when a decision changes rather than
|
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
|
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
|
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
|
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
|
genuine handshake against 10.66.0.1 with pinned TLS, no router or phone
|
||||||
involved. That is how to verify the wg0-only posture.
|
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/<id>/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)
|
## Where things run (host vs this VM)
|
||||||
|
|
||||||
The machine itself — the two boxes, the shared `~/repos` mount, and why the
|
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
|
in `process.json`; removing either by hand while the session is live loses
|
||||||
output or replays it.
|
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
|
## Shared appearance
|
||||||
|
|
||||||
- **A row something is happening to is dimmed, drained of colour, and says
|
- **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`
|
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.
|
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.
|
|
||||||
@@ -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
|
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.
|
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
|
**`--resume` only ever runs when nothing else has that session open.** That
|
||||||
is the rule behind the import refusal, the single `ClaudeDriver::launch`
|
is the rule behind the import refusal, the single `ClaudeDriver::launch`
|
||||||
entry point, and the `Exited` correction below; two CLIs on one session file
|
entry point, and the `Exited` correction below; two CLIs on one session file
|
||||||
|
|||||||
@@ -485,6 +485,8 @@ fun spawnSession(
|
|||||||
model: String? = null,
|
model: String? = null,
|
||||||
cwd: String? = null,
|
cwd: String? = null,
|
||||||
permissionMode: String? = null,
|
permissionMode: String? = null,
|
||||||
|
/** Null for whatever the server's default is; see [fetchDefaultEffort]. */
|
||||||
|
effort: String? = null,
|
||||||
params: Map<String, String> = emptyMap(),
|
params: Map<String, String> = emptyMap(),
|
||||||
/** Continue this Claude Code session instead of starting an empty one. */
|
/** Continue this Claude Code session instead of starting an empty one. */
|
||||||
import: String? = null,
|
import: String? = null,
|
||||||
@@ -502,6 +504,7 @@ fun spawnSession(
|
|||||||
if (!model.isNullOrBlank()) put("model", model)
|
if (!model.isNullOrBlank()) put("model", model)
|
||||||
if (!cwd.isNullOrBlank()) put("cwd", cwd)
|
if (!cwd.isNullOrBlank()) put("cwd", cwd)
|
||||||
if (!permissionMode.isNullOrBlank()) put("permissionMode", permissionMode)
|
if (!permissionMode.isNullOrBlank()) put("permissionMode", permissionMode)
|
||||||
|
if (!effort.isNullOrBlank()) put("effort", effort)
|
||||||
if (!import.isNullOrBlank()) put("import", import)
|
if (!import.isNullOrBlank()) put("import", import)
|
||||||
if (params.isNotEmpty()) {
|
if (params.isNotEmpty()) {
|
||||||
put("params", JSONObject(params.toMap<String, Any>()))
|
put("params", JSONObject(params.toMap<String, Any>()))
|
||||||
@@ -1008,6 +1011,27 @@ fun setSessionModel(settings: ServerSettings, sessionId: String, model: String)
|
|||||||
*/
|
*/
|
||||||
val PERMISSION_MODES = listOf("manual", "acceptEdits", "auto", "bypassPermissions", "plan")
|
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.
|
* How hard the model thinks, as `claude --effort` takes them, cheapest first.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -61,6 +61,11 @@ fun SpawnScreen(
|
|||||||
// "auto" rather than "manual": on a phone every ask is a round trip to a question card, and
|
// "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.
|
// answering "allow Bash?" dozens of times per task is what this app exists to avoid.
|
||||||
var permissionMode by remember { mutableStateOf("auto") }
|
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<String?>(null) }
|
||||||
|
var defaultsAsked by remember { mutableStateOf(false) }
|
||||||
var busy 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
|
// 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.
|
// form worth keeping, and that one leaves nothing to fill in.
|
||||||
@@ -75,6 +80,12 @@ fun SpawnScreen(
|
|||||||
var temperature by remember { mutableStateOf("") }
|
var temperature by remember { mutableStateOf("") }
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
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 =
|
options =
|
||||||
try {
|
try {
|
||||||
val fetched = withContext(Dispatchers.IO) { fetchSetups(settings) }
|
val fetched = withContext(Dispatchers.IO) { fetchSetups(settings) }
|
||||||
@@ -262,6 +273,19 @@ fun SpawnScreen(
|
|||||||
selected = permissionMode,
|
selected = permissionMode,
|
||||||
onSelect = { permissionMode = it },
|
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))
|
Spacer(Modifier.height(24.dp))
|
||||||
|
|
||||||
@@ -279,6 +303,13 @@ fun SpawnScreen(
|
|||||||
try {
|
try {
|
||||||
val spawned =
|
val spawned =
|
||||||
withContext(Dispatchers.IO) {
|
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(
|
spawnSession(
|
||||||
settings,
|
settings,
|
||||||
// The id, not the label: labels are editable and the server
|
// 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 },
|
if (isLlama) modelKey else model.trim().takeIf { isClaude },
|
||||||
cwd = cwd.trim().takeIf { isClaude },
|
cwd = cwd.trim().takeIf { isClaude },
|
||||||
permissionMode = permissionMode.takeIf { isClaude },
|
permissionMode = permissionMode.takeIf { isClaude },
|
||||||
|
effort = effort.takeIf { isClaude },
|
||||||
// Sent only when set, so blank means "whatever llama.cpp does
|
// Sent only when set, so blank means "whatever llama.cpp does
|
||||||
// by default" rather than a zero.
|
// by default" rather than a zero.
|
||||||
params =
|
params =
|
||||||
|
|||||||
@@ -30,6 +30,18 @@ pub struct Config {
|
|||||||
pub tokens: Vec<TokenEntry>,
|
pub tokens: Vec<TokenEntry>,
|
||||||
pub setups: Vec<SetupConfig>,
|
pub setups: Vec<SetupConfig>,
|
||||||
pub sessions: Vec<SessionConfig>,
|
pub sessions: Vec<SessionConfig>,
|
||||||
|
/// 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<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A machine, and the things it can run.
|
/// A machine, and the things it can run.
|
||||||
@@ -462,6 +474,7 @@ mod tests {
|
|||||||
}],
|
}],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
default_effort: Some("low".to_string()),
|
||||||
sessions: vec![SessionConfig {
|
sessions: vec![SessionConfig {
|
||||||
id: "abc123".to_string(),
|
id: "abc123".to_string(),
|
||||||
setup: "vm".to_string(),
|
setup: "vm".to_string(),
|
||||||
|
|||||||
@@ -54,6 +54,8 @@
|
|||||||
//! POST /sessions/{id}/notify {notify} -- announce this one or not
|
//! POST /sessions/{id}/notify {notify} -- announce this one or not
|
||||||
//! GET /notifications SSE: every session's attention-wanting
|
//! GET /notifications SSE: every session's attention-wanting
|
||||||
//! moments, live only (see `notifications`)
|
//! 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 /usage cached usage windows per provider
|
||||||
//! GET /models downloaded GGUFs, and what is being fetched
|
//! GET /models downloaded GGUFs, and what is being fetched
|
||||||
//! GET /models/search?q=Q HuggingFace repositories matching Q
|
//! GET /models/search?q=Q HuggingFace repositories matching Q
|
||||||
@@ -137,6 +139,7 @@ pub fn router(manager: Arc<SessionManager>) -> Router {
|
|||||||
.route("/sessions/{id}/model", post(set_model))
|
.route("/sessions/{id}/model", post(set_model))
|
||||||
.route("/sessions/{id}/permission-mode", post(set_permission_mode))
|
.route("/sessions/{id}/permission-mode", post(set_permission_mode))
|
||||||
.route("/sessions/{id}/effort", post(set_effort))
|
.route("/sessions/{id}/effort", post(set_effort))
|
||||||
|
.route("/defaults", get(defaults).post(set_defaults))
|
||||||
.route("/sessions/{id}/notify", post(set_notify))
|
.route("/sessions/{id}/notify", post(set_notify))
|
||||||
.route("/notifications", get(notifications))
|
.route("/notifications", get(notifications))
|
||||||
.route("/sessions/{id}/compact", post(compact))
|
.route("/sessions/{id}/compact", post(compact))
|
||||||
@@ -1355,6 +1358,36 @@ struct PermissionModeRequest {
|
|||||||
mode: String,
|
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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn defaults(State(manager): State<Arc<SessionManager>>) -> axum::Json<Defaults> {
|
||||||
|
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<Arc<SessionManager>>,
|
||||||
|
axum::Json(body): axum::Json<Defaults>,
|
||||||
|
) -> Result<StatusCode, ApiError> {
|
||||||
|
manager
|
||||||
|
.set_default_effort(body.effort.as_deref())
|
||||||
|
.map_err(bad_request)?;
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
|
|||||||
+102
-1
@@ -1038,7 +1038,19 @@ impl SessionManager {
|
|||||||
model: spec.model,
|
model: spec.model,
|
||||||
cwd: spec.cwd,
|
cwd: spec.cwd,
|
||||||
permission_mode: spec.permission_mode,
|
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,
|
params: spec.params,
|
||||||
// On by default. Not offered at spawn: a session's first turn
|
// On by default. Not offered at spawn: a session's first turn
|
||||||
// is exactly the one somebody is waiting for.
|
// 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
|
/// `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.
|
/// 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<String> {
|
||||||
|
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<()> {
|
pub fn set_session_effort(&self, id: &str, effort: Option<&str>) -> Result<()> {
|
||||||
let effort = effort.map(str::trim).filter(|level| !level.is_empty());
|
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");
|
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`
|
/// 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
|
/// 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
|
/// session left running would go on thinking at the old level underneath a
|
||||||
|
|||||||
Reference in new issue
Block a user