Merge branch 'worktree-agent-a27094a7db775552a' into tmp-merge

This commit is contained in:
iris committed 2026-09-05 21:37:12 -04:00
commit 50fe4828a2
52 files changed
+6281 -324

No files matched your search

+244
View File
@@ -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.25.9% janky frames, 99th percentile
2932ms, 02 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 **87119 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.
+44
View File
@@ -0,0 +1,44 @@
# Decisions awaiting review
Choices made while working autonomously, for Bryan to keep or change. Each
says what was picked and why; the detail is in the design doc it names.
Delete an entry once it has been looked at.
## Subagent views (2026-09-05, `SUBAGENTS.md`)
Made on my own judgement, limited blast radius:
1. **A subagent is a transcript, not a session.** It has no process,
controls or settings; it is addressed as `/sessions/{id}/subagents/{sub}`
and stored under the session's directory, so deleting the session takes
it. Alternative rejected: registering it as a session of its own, which
would give it a card in the main list and a driver that can do nothing.
2. **Read-only view is the session screen minus its controls**, rather than
a second, simpler transcript screen. Keeps paging, caching, selection
and rendering in one place. Cost: a `readOnly` mode threaded through
`SessionScreen`.
3. **The list only carries a count.** Each session row says how many
subagents it has; their titles and statuses are fetched when the card is
expanded. Keeps `GET /sessions` from reading every subagent transcript.
Consequence: an expanded card's statuses refresh with the list, not live.
4. **Expanded/collapsed is remembered per session on the phone**, not on
the server. Collapsed by default, per the transcript convention that new
things arrive collapsed.
5. **Subagents of imported sessions are not shown.** The import path still
skips `isSidechain` records; the CLI's own `subagents/agent-*.jsonl` files
are not read. Only subagents run while this backend was watching exist.
6. **Echo grows `/subagent [n]`** as the test rig, so nothing here needs a
paid turn to exercise.
Deferred, because they reach further than this feature:
- **Live status on the list.** Whether the session list should follow a
stream at all (it refreshes on demand today) decides whether subagent
status can ever be live there. Not changed.
- **Nested subagents.** A subagent's own Task calls are shown as tool calls
in its transcript and are not given transcripts of their own. Supporting
that is the same mechanism one level down, but the UI would need nested
expanders.
- **The subagent status row says "context unknown".** Nothing measures a
subagent's context; the row could leave it out rather than admit it.
+141
View File
@@ -0,0 +1,141 @@
# Subagents
A session's subagents -- the helpers a Claude Code session starts through its
Task tool -- each get a transcript of their own, listed under the session's
card and readable in the same transcript view the session has. Designed
2026-09-05; the decisions Bryan has not yet reviewed are in `DECISIONS.md`.
## What a subagent is here
**A subagent is a second transcript owned by a session, in the same event
model, with no process and no controls.** It is not a session: it cannot be
messaged, stopped or started, and it has no setup, model or usage of its
own. Everything it shares with a session -- the transcript file format, the
paging routes, the SSE stream, the phone's cache and rendering -- is reused
by addressing, not by copying.
The CLI reports a subagent's messages on the parent's own stream-json
output, each carrying `parent_tool_use_id` = the id of the Task `tool_use`
that started it. Before this the translator dropped those lines
(`subagent_events_are_not_duplicated_into_the_transcript`); now it routes
them to that subagent's own translator and transcript. The parent's
transcript still shows only the Task call itself.
## Storage
Under the session directory:
```
<session>/subagents/<tool_use_id>/meta.json {title, created}
<session>/subagents/<tool_use_id>/transcript.jsonl same SeqEvent lines as the session's
```
The id is the Task tool_use id (`toolu_…`), which is unique, stable across a
backend restart, and already the key everything on the parent side uses.
Only ids matching `[A-Za-z0-9_-]+` are ever created or looked up, since the
id becomes a path.
The transcript's sequence numbers are its own, starting at 1. `Transcript`,
`read_window`, `catch_up` and `read_after` work on it unchanged.
Its path out: deleting the session deletes its directory, subagents included.
There is no separate delete.
## Lifecycle, as events in the subagent's transcript
1. Created on the first child line for an unseen parent id (or, when the
parent Task call was seen, at that call). First lines written:
`Status Running`, then `UserMessage { text: <the Task's prompt> }` when
the prompt is known -- it genuinely is the subagent's first user turn.
2. Every child line is translated by that subagent's own `Translator`
(one per subagent: tool ids are unique but streaming deltas are by
content-block index, and parallel subagents interleave).
3. **The parent's `tool_result` never finishes a subagent.** The Task tool
runs in the background by default: the `tool_result` -- "Async agent
launched..." -- arrives the moment it *starts*, while the subagent goes
on working for however long its own turn takes, sometimes minutes. What
ends it is its own turn ending: the raw API's `message_delta` on its
stream carrying `stop_reason: "end_turn"` (a `stop_reason` of `tool_use`
is the model about to call one, not an end), or a `result` line for its
own turn if a future CLI version ever sends one. Either maps to
`Status Exited`; the subagent's vocabulary has no `Idle`, so the
equivalent event `dispatch` produces for an ordinary session is dropped
rather than written. A shipped version of this finished on the
`tool_result` instead, which read a running background agent as
"finished" with its transcript truncated at the moment it launched.
4. **A child line for a subagent that already finished reopens it**
(`Status Running`) rather than being dropped: a background Task can be
sent another message long after its first turn ended, and that is
exactly what a further line for it means. Same transcript, same child
`Translator`, just picking back up.
5. When the parent session's process exits (`Status Exited` on the
session), every subagent still `Running` gets `Status Exited` too: its
process was the parent's.
A subagent that was mid-flight when the backend restarted keeps working:
the registry reopens the existing transcript on the next child line, and
the file continues its sequence -- the same reopening #4 describes, whether
what closed it was a restart or its own `end_turn`. If its turn ended while
the backend was down nothing recorded that until the next line arrives, so
its last status stays `Running`, which the list reports as **unknown**
rather than as running (see the wire shape) until then.
Title: the Task call's `description` input, then ` (<subagent_type>)` when
one is given; falling back to the tool's name when the child arrives before
(or without) the parent call being seen.
## Server layout
- `session/subagent.rs` -- the registry: `Subagents` (per session, in
`Shared`), `Subagent` (its `Transcript` behind a mutex plus a
`broadcast::Sender<SeqEvent>`), `record(id, event)`, `start(id, title,
prompt)`, `finish(id)`, `reopen(id)`, `finish_all()`, `list()` from disk. Drivers get an
`Arc<Subagents>` beside their `EventSink`; llama ignores it.
- `session/claude/translate.rs` -- routes child lines by parent id, holds
one child `Translator` per subagent, remembers pending Task calls'
description/prompt/subagent_type.
- `session/echo.rs` -- `/subagent [n]`: the test rig. Starts *n* (default 1)
subagents at once, each named "helper k". Each writes the prompt as its
user message, streams a few words of text, runs one `Bash` tool call, then
finishes about three seconds after starting, and the parent's Task calls
end when their subagent does. Three seconds so the running state can be
seen on the phone.
- `routes.rs` -- three routes, in the doc table.
## Wire shape
```
GET /sessions/{id} SessionInfo gains `subagents: N` (count, 0 when none)
GET /sessions same field on each row
GET /sessions/{id}/subagents [{id, title, status, created, lastActivity}], oldest first
GET /sessions/{id}/subagents/{sub}/transcript exactly the session transcript's query and answer
GET /sessions/{id}/subagents/{sub}/events?after=N exactly the session events stream
```
`status` is the transcript's last `Status` event, serialised like a session's
(`running`, `exited`), except that a subagent whose session is not itself
running cannot be running: the list answers `unknown` for that one. The
phone words these as *running*, *finished* and *unknown* on the subcard.
The count on `SessionInfo` is a directory listing, so the list stays cheap.
The per-subagent status is only read when the list route is asked for.
## Phone
- `SessionSummary.subagents: Int`. A card with a non-zero count ends in an
expander row -- a full-width `Chevron(Pointing.Down)` row that flips to
`Pointing.Up` -- collapsed by default. Expanding fetches
`/sessions/{id}/subagents` and draws one `OutlinedCard` per subagent,
indented inside the session card, the way dev-updater draws a project's
components: title, then the status word and a relative time. The
expansion state is per session id and survives a refresh of the list.
- Tapping a subcard opens `Screen.Subagent`, which is `SessionScreen` in
**read-only** form: the same transcript, paging, cache, selection,
images and status row, with the composer, the process button, the model
picker, the files button, the settings cog and the usage bar left out.
The header shows the subagent's title with the session's title beneath
it. Back returns to the list.
- Addressing: `fetchTranscript`, `EventStream`, `TranscriptSource` and the
cache take a transcript address rather than a session id --
`sessions/{id}` or `sessions/{id}/subagents/{sub}` -- so the cache nests a
subagent's copy under its session's and the same code serves both.
@@ -142,6 +142,20 @@ data class SessionSummary(
val keepsOwnTranscript: Boolean, val keepsOwnTranscript: Boolean,
/** How much the session asks before acting; null when it was never set. */ /** How much the session asks before acting; null when it was never set. */
val permissionMode: String?, val permissionMode: String?,
/**
* How hard the model thinks, or null for the CLI's own default.
*
* Null is a level somebody can choose, not only one to start in -- see [EFFORT_LEVELS]. It is
* reported rather than assumed for the same reason [permissionMode] is.
*/
val effort: String?,
/**
* Whether a thinking level does anything here -- a Claude CLI session, not a llama or echo one.
*
* Asked of the server rather than worked out from the provider's name, because this is a
* property of the driver's *kind* and the phone only has the name.
*/
val takesEffort: Boolean,
/** /**
* Whether this continues a session the machine already had, which changes what deleting means. * Whether this continues a session the machine already had, which changes what deleting means.
*/ */
@@ -153,6 +167,24 @@ data class SessionSummary(
* itself from a default is one you can turn off while believing you are reading it. * itself from a default is one you can turn off while believing you are reading it.
*/ */
val notify: Boolean, val notify: Boolean,
/**
* Whether this session sends itself a message once its account's usage limit lifts, and what
* that message says.
*
* The message is what the server would actually send, with its own default already filled in,
* so the field shows the words rather than an empty box standing for them.
*/
val autoResume: Boolean,
val autoResumeMessage: String,
/**
* When the server next intends to check whether the limit has lifted, in epoch seconds, or null
* when nothing is waiting.
*
* A time to *ask*, not a time to resume: the server checks the meter at that moment and waits
* again if the limit is still on. Worded that way wherever it is shown, because a promise this
* app cannot keep is worse than no time at all.
*/
val resumeAt: Double?,
/** /**
* The directory the session works in, or null where it was never given one. * The directory the session works in, or null where it was never given one.
* *
@@ -178,8 +210,27 @@ data class SessionSummary(
* server because that is where a provider's kind is known. * server because that is where a provider's kind is known.
*/ */
val maxImageEdge: Int?, val maxImageEdge: Int?,
/**
* Which of `GET /usage`'s snapshots is about this session, and null where nothing meters it.
*
* The rate-limit bar answers a question about an *account*, and what decides which account --
* if any -- is the provider this session runs, not the machine it runs on. Pairing by machine
* alone drew the Claude CLI's five-hour window under every echo session on a machine that also
* has the CLI: a quota that session cannot spend and could never run down. Decided by the
* server for the same reason [maxImageEdge] is -- it is a fact about the provider's kind, and
* this app has only its name.
*/
val usageProvider: String?,
val status: String, val status: String,
val lastActivity: Double, val lastActivity: Double,
/**
* How many subagents this session has, however their own status now reads.
*
* A directory listing on the server rather than a status read per subagent, so the list stays
* cheap; the per-subagent state is only fetched when the card is expanded. Zero on a server
* that predates subagents, so this app still opens against one.
*/
val subagents: Int,
) )
private fun parseSession(session: JSONObject) = private fun parseSession(session: JSONObject) =
@@ -192,14 +243,24 @@ private fun parseSession(session: JSONObject) =
title = session.getString("title"), title = session.getString("title"),
model = session.optString("model").ifEmpty { null }, model = session.optString("model").ifEmpty { null },
permissionMode = session.optString("permissionMode").ifEmpty { null }, permissionMode = session.optString("permissionMode").ifEmpty { null },
effort = session.optString("effort").ifEmpty { null },
takesEffort = session.optBoolean("takesEffort", false),
imported = session.optBoolean("imported", false), imported = session.optBoolean("imported", false),
notify = session.optBoolean("notify", true), notify = session.optBoolean("notify", true),
autoResume = session.optBoolean("autoResume", false),
// The server sends its own default rather than nothing, so an empty answer means an older
// server -- and this app's word for it is the same word.
autoResumeMessage =
session.optString("autoResumeMessage").ifEmpty { DEFAULT_RESUME_MESSAGE },
resumeAt = if (session.has("resumeAt")) session.getDouble("resumeAt") else null,
cwd = session.optString("cwd").ifEmpty { null }, cwd = session.optString("cwd").ifEmpty { null },
contextTokens = contextTokens =
if (session.has("contextTokens")) session.getLong("contextTokens") else null, if (session.has("contextTokens")) session.getLong("contextTokens") else null,
maxImageEdge = session.optInt("maxImageEdge", 0).takeIf { it > 0 }, maxImageEdge = session.optInt("maxImageEdge", 0).takeIf { it > 0 },
usageProvider = session.optString("usageProvider").ifEmpty { null },
status = session.getString("status"), status = session.getString("status"),
lastActivity = session.getDouble("lastActivity"), lastActivity = session.getDouble("lastActivity"),
subagents = session.optInt("subagents", 0),
) )
fun fetchSessions(settings: ServerSettings): List<SessionSummary> = fun fetchSessions(settings: ServerSettings): List<SessionSummary> =
@@ -215,6 +276,35 @@ fun fetchSessions(settings: ServerSettings): List<SessionSummary> =
fun fetchSession(settings: ServerSettings, sessionId: String): SessionSummary = fun fetchSession(settings: ServerSettings, sessionId: String): SessionSummary =
requestFromServer(settings, "/sessions/$sessionId") { parseSession(it.jsonObject()) } requestFromServer(settings, "/sessions/$sessionId") { parseSession(it.jsonObject()) }
/**
* One row of `GET /sessions/{id}/subagents`, oldest first.
*
* A subagent is a second transcript owned by a session -- no process, no controls of its own -- so
* this carries only what a card needs to draw and to open it; see SUBAGENTS.md. [status] is
* "running", "exited" or "unknown": a subagent whose session is not itself running cannot be
* running, and the list says so rather than reporting a state that cannot hold.
*/
data class SubagentSummary(
val id: String,
val title: String,
val status: String,
val created: Double,
val lastActivity: Double,
)
fun fetchSubagents(settings: ServerSettings, sessionId: String): List<SubagentSummary> =
requestFromServer(settings, "/sessions/$sessionId/subagents") {
it.jsonObjects { row ->
SubagentSummary(
id = row.getString("id"),
title = row.getString("title"),
status = row.getString("status"),
created = row.getDouble("created"),
lastActivity = row.getDouble("lastActivity"),
)
}
}
// What the server offers, so the spawn screen has no hardcoded lists: a setup added to the server's // What the server offers, so the spawn screen has no hardcoded lists: a setup added to the server's
// config.ron appears here with no app rebuild. // config.ron appears here with no app rebuild.
// //
@@ -375,6 +465,12 @@ data class SshDetails(
* Where files attached from here land on that machine; null for the session's own directory. * Where files attached from here land on that machine; null for the session's own directory.
*/ */
val attachmentsDir: String? = null, val attachmentsDir: String? = null,
/**
* Where that machine keeps its GGUF models; null for the same place the backend keeps its own
* (`~/.local/share/ai-app/models`, read on that machine). A llama.cpp session serves the file
* from the machine it runs on, so this is where its models are looked for and listed.
*/
val modelsDir: String? = null,
) )
private fun SshDetails.toJson() = private fun SshDetails.toJson() =
@@ -382,6 +478,7 @@ private fun SshDetails.toJson() =
if (port != null) put("port", port) if (port != null) put("port", port)
if (!identityFile.isNullOrBlank()) put("identityFile", identityFile) if (!identityFile.isNullOrBlank()) put("identityFile", identityFile)
if (!attachmentsDir.isNullOrBlank()) put("attachmentsDir", attachmentsDir) if (!attachmentsDir.isNullOrBlank()) put("attachmentsDir", attachmentsDir)
if (!modelsDir.isNullOrBlank()) put("modelsDir", modelsDir)
} }
/** What a machine turns out to have, without saving anything. */ /** What a machine turns out to have, without saving anything. */
@@ -450,6 +547,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,
@@ -467,6 +566,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>()))
@@ -906,7 +1006,7 @@ fun startImport(
*/ */
fun fetchTranscript( fun fetchTranscript(
settings: ServerSettings, settings: ServerSettings,
sessionId: String, address: TranscriptAddress,
before: Long? = null, before: Long? = null,
limit: Int = 80, limit: Int = 80,
// Count [limit] in rows, not events, joining a reply's streamed deltas into one -- so a page of // Count [limit] in rows, not events, joining a reply's streamed deltas into one -- so a page of
@@ -925,7 +1025,7 @@ fun fetchTranscript(
if (coalesce) append("&coalesce=true") if (coalesce) append("&coalesce=true")
if (after != null) append("&after=").append(after) if (after != null) append("&after=").append(after)
} }
return requestFromServer(settings, "/sessions/$sessionId/transcript$query") { connection -> return requestFromServer(settings, "/${address.urlPath}/transcript$query") { connection ->
val body = JSONArray(connection.inputStream.bufferedReader().readText()) val body = JSONArray(connection.inputStream.bufferedReader().readText())
// The text as well as the event: the transcript cache stores the one and the fold needs the // The text as well as the event: the transcript cache stores the one and the fold needs the
// other, and they have to be the same line. // other, and they have to be the same line.
@@ -973,6 +1073,56 @@ 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.
*
* Not offered alongside the model and the permission mode on the session's own bar, because it does
* not behave like them: the CLI has a control request for those two and none for this (checked
* against 2.1.258), so a level is settled when the process is launched. Changing it therefore stops
* the process, which is what the working directory beside it in this dialog does, and why it is
* here rather than on a bar whose other controls take effect mid-turn.
*/
val EFFORT_LEVELS = listOf("low", "medium", "high", "xhigh", "max")
/** What the picker shows, and sends as null, for a session that has chosen no level. */
const val DEFAULT_EFFORT = "default"
/**
* Records how hard a session thinks and **stops its process**, since the level is read when the
* process is launched. The next message, or Start, runs one that has it.
*
* [level] is null for the CLI's own default.
*/
fun setSessionEffort(settings: ServerSettings, sessionId: String, level: String?) {
requestFromServer(
settings,
"/sessions/$sessionId/effort",
method = "POST",
jsonBody = JSONObject().put("effort", level ?: JSONObject.NULL).toString(),
) {}
}
/** Switches how much a running session asks before acting, also in place. */ /** Switches how much a running session asks before acting, also in place. */
fun setSessionPermissionMode(settings: ServerSettings, sessionId: String, mode: String) { fun setSessionPermissionMode(settings: ServerSettings, sessionId: String, mode: String) {
requestFromServer( requestFromServer(
@@ -984,6 +1134,36 @@ fun setSessionPermissionMode(settings: ServerSettings, sessionId: String, mode:
} }
/** Turns this session's notifications on or off. Stored on the backend -- see `SessionConfig`. */ /** Turns this session's notifications on or off. Stored on the backend -- see `SessionConfig`. */
/**
* What an auto-resume says when nothing else was typed. Mirrors the server's own default, so a
* cleared field shows the word that would actually be sent instead of going blank.
*/
const val DEFAULT_RESUME_MESSAGE = "continue"
/**
* Turns auto-resume on or off and sets what it would say, in one request because they are one
* decision -- see the server's `/sessions/{id}/auto-resume`.
*/
fun setSessionAutoResume(
settings: ServerSettings,
sessionId: String,
autoResume: Boolean,
message: String?,
) {
requestFromServer(
settings,
"/sessions/$sessionId/auto-resume",
method = "POST",
jsonBody =
JSONObject()
.put("autoResume", autoResume)
// Empty means the server's default rather than a session poked with nothing to
// read, which is the same rule the server applies to the field.
.put("message", message?.trim()?.ifEmpty { null } ?: JSONObject.NULL)
.toString(),
) {}
}
fun setSessionNotify(settings: ServerSettings, sessionId: String, notify: Boolean) { fun setSessionNotify(settings: ServerSettings, sessionId: String, notify: Boolean) {
requestFromServer( requestFromServer(
settings, settings,
@@ -1074,6 +1254,26 @@ private fun parseDownload(o: JSONObject) =
error = if (o.has("error")) o.getString("error") else null, error = if (o.has("error")) o.getString("error") else null,
) )
/**
* The models on one machine, which is the list a llama.cpp session there can choose from.
*
* Not [fetchModels], which is what the *backend* has downloaded. A session serves its model from
* the machine it runs on, so for a machine reached over ssh those are two different lists -- and
* offering the backend's would name files that are not there, turning a choice that cannot work
* into a session that fails when it tries to load one.
*/
fun fetchSetupModels(settings: ServerSettings, setupId: String): List<LocalModel> =
requestFromServer(settings, "/setups/${setupId.urlEncoded()}/models") { connection ->
JSONArray(connection.inputStream.bufferedReader().readText()).mapObjects { m ->
LocalModel(
key = m.getString("key"),
repo = m.getString("repo"),
file = m.getString("file"),
bytes = m.getLong("bytes"),
)
}
}
fun fetchModels(settings: ServerSettings): Models = fun fetchModels(settings: ServerSettings): Models =
requestFromServer(settings, "/models") { connection -> requestFromServer(settings, "/models") { connection ->
val body = JSONObject(connection.inputStream.bufferedReader().readText()) val body = JSONObject(connection.inputStream.bufferedReader().readText())
@@ -1,7 +1,9 @@
package com.example.aiapp package com.example.aiapp
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
@@ -34,7 +36,23 @@ import kotlinx.coroutines.withContext
* session, spawning one, and settings. * session, spawning one, and settings.
*/ */
private sealed class Screen { private sealed class Screen {
data object Main : Screen() /**
* The session list, with a subagent's own transcript over it when [subagent] is set.
*
* A layer on this screen rather than a screen of its own, for the same reason [Session.files]
* is: [SessionListScreen] owns which cards are expanded and what each expansion fetched, kept
* in `remember`, and a subagent is opened from a card's expander. As a sibling `Screen` it was
* disposed and recreated on every return, which lost that state -- an expanded card collapsed
* itself the moment its own subagent's view was closed.
*/
data class Main(val subagent: SubagentTarget? = null) : Screen()
/**
* One subagent's own transcript, read-only. See [SessionScreen]'s `subagent` parameter and
* SUBAGENTS.md's "Phone". Closing it returns to [Main] under it, not to [Session]: a subagent
* is opened from the session list's card rather than from inside the session it belongs to.
*/
data class SubagentTarget(val summary: SessionSummary, val subagent: SubagentSummary)
/** /**
* One session, with the file explorer over it when [files] is set. * One session, with the file explorer over it when [files] is set.
@@ -81,7 +99,7 @@ fun AppRoot(
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var settings by remember(settingsVersion) { mutableStateOf(loadServerSettings(context)) } var settings by remember(settingsVersion) { mutableStateOf(loadServerSettings(context)) }
var screen by remember { mutableStateOf<Screen>(Screen.Main) } var screen by remember { mutableStateOf<Screen>(Screen.Main()) }
// A notification tap this could not follow, and why. Null both before one is asked for and // A notification tap this could not follow, and why. Null both before one is asked for and
// after one succeeds, since success is a screen rather than a message. // after one succeeds, since success is a screen rather than a message.
var failedOpen by remember { mutableStateOf<FailedOpen?>(null) } var failedOpen by remember { mutableStateOf<FailedOpen?>(null) }
@@ -96,7 +114,7 @@ fun AppRoot(
share = shareRequest share = shareRequest
// A session already open takes it. Otherwise the list is where the choice is made, // A session already open takes it. Otherwise the list is where the choice is made,
// whatever screen was showing: Spawn and Settings have nowhere to put a file. // whatever screen was showing: Spawn and Settings have nowhere to put a file.
if (screen !is Screen.Session) screen = Screen.Main if (screen !is Screen.Session) screen = Screen.Main()
} }
} }
@@ -123,7 +141,7 @@ fun AppRoot(
existing = null, existing = null,
onSaved = { saved -> onSaved = { saved ->
settings = saved settings = saved
screen = Screen.Main screen = Screen.Main()
}, },
onBack = null, onBack = null,
) )
@@ -136,7 +154,7 @@ fun AppRoot(
// shows, so it always refetches. // shows, so it always refetches.
val goToMain = { val goToMain = {
reloadToken++ reloadToken++
screen = Screen.Main screen = Screen.Main()
} }
if (screen !is Screen.Main) { if (screen !is Screen.Main) {
BackHandler(onBack = goToMain) BackHandler(onBack = goToMain)
@@ -185,6 +203,9 @@ fun AppRoot(
reloadToken = reloadToken, reloadToken = reloadToken,
share = share, share = share,
onOpen = { screen = Screen.Session(it) }, onOpen = { screen = Screen.Session(it) },
onOpenSubagent = { summary, subagent ->
screen = here.copy(subagent = Screen.SubagentTarget(summary, subagent))
},
onSpawn = { screen = Screen.Spawn }, onSpawn = { screen = Screen.Spawn },
onImported = { imported -> onImported = { imported ->
reloadToken++ reloadToken++
@@ -192,6 +213,27 @@ fun AppRoot(
}, },
onSettings = { screen = Screen.Settings }, onSettings = { screen = Screen.Settings },
) )
// Its own back handler is registered after MainScreen's, so it is the one the
// platform asks first while a subagent is open -- the same rule the files
// explorer's handler follows over its session, below.
here.subagent?.let { target ->
BackHandler { screen = here.copy(subagent = null) }
// Its own opaque background: this screen was always the sole content under
// the theme's own Surface before, so it never had to paint one -- stacked over
// the list here, the space between its own cards let the list underneath show
// through without this. The same fix FilesScreen needed over its session.
Box(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
key(target.summary.id, target.subagent.id) {
SessionScreen(
settings = current,
summary = target.summary,
onBack = { screen = here.copy(subagent = null) },
onFiles = {},
subagent = target.subagent,
)
}
}
}
} }
is Screen.Session -> is Screen.Session ->
// Keyed on the id, because a different session is a different screen rather than this // Keyed on the id, because a different session is a different screen rather than this
@@ -12,6 +12,10 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
/** /**
* A line across the transcript saying what left the session's context. * A line across the transcript saying what left the session's context.
@@ -47,3 +51,40 @@ fun TranscriptDivider(text: String, color: Color, modifier: Modifier = Modifier)
fun ClearedRow(modifier: Modifier = Modifier) { fun ClearedRow(modifier: Modifier = Modifier) {
TranscriptDivider("Context cleared", clearedColor, modifier) TranscriptDivider("Context cleared", clearedColor, modifier)
} }
/**
* The mark running out of quota leaves.
*
* The same red the usage bar takes when a window is spent, because it is the same fact in a second
* place: colour by consequence, so "there is nothing left to spend" is learned once.
*
* A time rather than a countdown. The row is folded once and never re-measured, so a span would go
* stale on screen the moment it was drawn; and this is when the *account* said it would reset,
* which is not a promise about when the session picks back up. A limit the session was told no
* reset time for says nothing about one -- that state has its own words rather than a plausible
* number.
*/
@Composable
fun LimitRow(item: TranscriptItem.LimitNote, modifier: Modifier = Modifier) {
TranscriptDivider(limitSummary(item.resetsAt, ZoneId.systemDefault()), overLimitColor, modifier)
}
/**
* What the row says. Split out so the wording is testable without a screen, since the two states it
* has to keep apart -- a reset time that arrived and one that never did -- are exactly the pair
* that reads the same when it goes wrong.
*
* [zone] is a parameter rather than read here so a test says the same thing wherever it runs.
*/
fun limitSummary(resetsAt: Double?, zone: ZoneId): String {
val at = resetsAt?.let {
try {
DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
.withZone(zone)
.format(Instant.ofEpochSecond(it.toLong()))
} catch (_: Exception) {
null
}
}
return if (at == null) "Usage limit reached" else "Usage limit reached • resets $at"
}
@@ -14,7 +14,7 @@ private const val RESET_EVENT = "reset"
* mean. [close] from any thread ends it, and the caller owns reconnecting -- with the last seq it * mean. [close] from any thread ends it, and the caller owns reconnecting -- with the last seq it
* saw as the new cursor. * saw as the new cursor.
*/ */
class EventStream(settings: ServerSettings, private val sessionId: String) { class EventStream(settings: ServerSettings, private val address: TranscriptAddress) {
private val stream = Sse(settings) private val stream = Sse(settings)
fun close() = stream.close() fun close() = stream.close()
@@ -35,7 +35,7 @@ class EventStream(settings: ServerSettings, private val sessionId: String) {
// one and the screen folds the other, and they have to be the same line. // one and the screen folds the other, and they have to be the same line.
onEvent: (raw: String, event: SeqEvent) -> Unit, onEvent: (raw: String, event: SeqEvent) -> Unit,
) { ) {
stream.run("/sessions/$sessionId/events?after=$after", onOpen) { name, data -> stream.run("/${address.urlPath}/events?after=$after", onOpen) { name, data ->
// A named frame carries no payload and a data frame has no name. // A named frame carries no payload and a data frame has no name.
if (name == RESET_EVENT) onReset() if (name == RESET_EVENT) onReset()
else if (data.isNotEmpty()) onEvent(data, parseSeqEvent(data)) else if (data.isNotEmpty()) onEvent(data, parseSeqEvent(data))
@@ -157,6 +157,18 @@ sealed class SessionEvent {
*/ */
data object Cleared : SessionEvent() data object Cleared : SessionEvent()
/**
* The session stopped because its account's usage limit was reached.
*
* Its own event rather than an [Error] carrying the CLI's sentence, because it is a state
* rather than something that went wrong -- and because the raw sentence is `Claude AI usage
* limit reached|1788546972`, which is not readable by the person it is shown to.
*
* [resetsAt] is epoch seconds and null where the session was told nothing. Only the server acts
* on it; what this draws it as is a time, not a countdown, because nothing here re-measures it.
*/
data class LimitReached(val resetsAt: Double?) : SessionEvent()
data class Error(val message: String) : SessionEvent() data class Error(val message: String) : SessionEvent()
/** /**
@@ -261,6 +273,10 @@ fun parseSeqEvent(json: String): SeqEvent {
trigger = body.optString("trigger").ifEmpty { null }, trigger = body.optString("trigger").ifEmpty { null },
) )
"cleared" -> SessionEvent.Cleared "cleared" -> SessionEvent.Cleared
"limitReached" ->
SessionEvent.LimitReached(
if (body.has("resetsAt")) body.getDouble("resetsAt") else null
)
"error" -> SessionEvent.Error(body.getString("message")) "error" -> SessionEvent.Error(body.getString("message"))
else -> SessionEvent.Unknown(type) else -> SessionEvent.Unknown(type)
} }
@@ -48,6 +48,8 @@ fun MainScreen(
/** What another app shared in and no session has taken yet; see [ShareRequest]. */ /** What another app shared in and no session has taken yet; see [ShareRequest]. */
share: ShareRequest? = null, share: ShareRequest? = null,
onOpen: (SessionSummary) -> Unit, onOpen: (SessionSummary) -> Unit,
/** Opens one session's subagent, from the expander under its card. */
onOpenSubagent: (SessionSummary, SubagentSummary) -> Unit,
onSpawn: () -> Unit, onSpawn: () -> Unit,
onImported: (SessionSummary) -> Unit, onImported: (SessionSummary) -> Unit,
onSettings: () -> Unit, onSettings: () -> Unit,
@@ -139,6 +141,7 @@ fun MainScreen(
settings = settings, settings = settings,
reloadToken = token, reloadToken = token,
onOpen = onOpen, onOpen = onOpen,
onOpenSubagent = onOpenSubagent,
onSpawn = onSpawn, onSpawn = onSpawn,
) )
MainTab.Import -> MainTab.Import ->
@@ -1,7 +1,9 @@
package com.example.aiapp package com.example.aiapp
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@@ -9,6 +11,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
@@ -17,6 +20,7 @@ import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
@@ -30,6 +34,8 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -47,12 +53,40 @@ fun SessionListScreen(
settings: ServerSettings, settings: ServerSettings,
reloadToken: Int, reloadToken: Int,
onOpen: (SessionSummary) -> Unit, onOpen: (SessionSummary) -> Unit,
/** Opens one session's subagent, from the expander under its card. */
onOpenSubagent: (SessionSummary, SubagentSummary) -> Unit,
onSpawn: () -> Unit, onSpawn: () -> Unit,
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var listState by remember { mutableStateOf<LoadState<List<SessionSummary>>>(LoadState.Loading) } var listState by remember { mutableStateOf<LoadState<List<SessionSummary>>>(LoadState.Loading) }
var confirmingDelete by remember { mutableStateOf<SessionSummary?>(null) } var confirmingDelete by remember { mutableStateOf<SessionSummary?>(null) }
// Which session cards are expanded to show their subagents, and what each expansion fetched.
// Ids rather than a flag on the row for the same reason `deleting` is: the rows are rebuilt
// from
// whatever the server last said, and this belongs to the reader's own choice, which survives a
// refresh.
var expandedSessions by remember { mutableStateOf(setOf<String>()) }
var subagentLoads by remember {
mutableStateOf(mapOf<String, LoadState<List<SubagentSummary>>>())
}
fun loadSubagents(sessionId: String) {
subagentLoads = subagentLoads + (sessionId to LoadState.Loading)
scope.launch {
subagentLoads =
subagentLoads +
(sessionId to
try {
LoadState.Loaded(
withContext(Dispatchers.IO) { fetchSubagents(settings, sessionId) }
)
} catch (e: ApiException) {
LoadState.failed(e)
})
}
}
// Failures that belong to one session rather than to the list, keyed by its id and shown on its // Failures that belong to one session rather than to the list, keyed by its id and shown on its
// own card. The two scopes are decided by whether the server answered: it answered and refused, // own card. The two scopes are decided by whether the server answered: it answered and refused,
// so this says nothing about the other rows. // so this says nothing about the other rows.
@@ -84,6 +118,14 @@ fun SessionListScreen(
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
transcriptCache.retainOnly(loaded.value.map { it.id }.toSet()) transcriptCache.retainOnly(loaded.value.map { it.id }.toSet())
} }
// A session gone from this answer cannot still be expanded, and an expanded one
// that is still here asks again -- its subagents may have changed since the
// last
// fetch.
val ids = loaded.value.map { it.id }.toSet()
expandedSessions = expandedSessions intersect ids
subagentLoads = subagentLoads.filterKeys { it in ids }
expandedSessions.forEach(::loadSubagents)
loaded loaded
} catch (e: ApiException) { } catch (e: ApiException) {
LoadState.failed(e) LoadState.failed(e)
@@ -127,6 +169,17 @@ fun SessionListScreen(
deleting = session.id in deleting, deleting = session.id in deleting,
onOpen = { onOpen(session) }, onOpen = { onOpen(session) },
onLongPress = { confirmingDelete = session }, onLongPress = { confirmingDelete = session },
expanded = session.id in expandedSessions,
subagents = subagentLoads[session.id],
onToggleSubagents = {
if (session.id in expandedSessions) {
expandedSessions = expandedSessions - session.id
} else {
expandedSessions = expandedSessions + session.id
loadSubagents(session.id)
}
},
onOpenSubagent = { subagent -> onOpenSubagent(session, subagent) },
) )
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
} }
@@ -225,7 +278,7 @@ fun SessionListScreen(
deleteSession(settings, session.id, alsoDeleteForeign) deleteSession(settings, session.id, alsoDeleteForeign)
// After it succeeded, not before: a refused delete leaves the // After it succeeded, not before: a refused delete leaves the
// session exactly as it was, and its transcript with it. // session exactly as it was, and its transcript with it.
transcriptCache.session(session.id).purge() transcriptCache.session(TranscriptAddress(session.id)).purge()
} }
// Only this row, and only what changed. Refetching the list instead // Only this row, and only what changed. Refetching the list instead
// put every other session back through loading and handed the // put every other session back through loading and handed the
@@ -276,6 +329,12 @@ private fun SessionCard(
deleting: Boolean, deleting: Boolean,
onOpen: () -> Unit, onOpen: () -> Unit,
onLongPress: () -> Unit, onLongPress: () -> Unit,
/** Whether the expander below is open. Collapsed by default; see [SessionListScreen]. */
expanded: Boolean,
/** What the expander's own fetch answered, or null before it has been asked. */
subagents: LoadState<List<SubagentSummary>>?,
onToggleSubagents: () -> Unit,
onOpenSubagent: (SubagentSummary) -> Unit,
) { ) {
BusyItem(label = if (deleting) "deleting" else null) { BusyItem(label = if (deleting) "deleting" else null) {
Card( Card(
@@ -332,11 +391,105 @@ private fun SessionCard(
color = MaterialTheme.colorScheme.error, color = MaterialTheme.colorScheme.error,
) )
} }
// Nothing at all for a card with no subagents: a disabled expander here would be
// noise on every ordinary session's card. Its own row at the bottom rather than
// beside the title or the machine line, so opening it never displaces text that was
// already on screen -- see UI_RULES on a control not displacing the text beside it.
if (session.subagents > 0) {
Spacer(Modifier.height(8.dp))
// The platform's minimum touch height, not the chevron's own ten or so dp:
// at the chevron's height a tap meant for it landed on the first subcard
// beneath and opened a subagent instead.
Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier.fillMaxWidth()
.heightIn(min = 48.dp)
.clickable(enabled = !deleting, onClick = onToggleSubagents)
.semantics {
contentDescription =
if (expanded) "Collapse subagents" else "Expand subagents"
},
) {
Chevron(if (expanded) Pointing.Up else Pointing.Down)
}
if (expanded) {
Spacer(Modifier.height(4.dp))
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
when (subagents) {
null,
is LoadState.Loading ->
CircularProgressIndicator(
modifier = Modifier.width(20.dp).height(20.dp),
strokeWidth = 2.dp,
)
is LoadState.Error ->
// Said here rather than left silent: a fetch that failed and an
// expander that simply found nothing must not look the same --
// see UI_RULES on designing the unknown state first.
Text(
subagents.message,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
is LoadState.Loaded ->
subagents.value.forEach { subagent ->
SubagentCard(
subagent,
onClick = { onOpenSubagent(subagent) },
)
}
}
}
}
}
} }
} }
} }
} }
/**
* One subagent, indented inside its session's card -- the way dev-updater draws a project's
* components (`ComponentCard`, `UpdaterScreen.kt`): an outlined card, not the session card's own
* filled one, so the nesting reads as one step rather than as another session.
*/
@Composable
private fun SubagentCard(subagent: SubagentSummary, onClick: () -> Unit) {
OutlinedCard(Modifier.fillMaxWidth().clickable(onClick = onClick)) {
Column(Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) {
Text(subagent.title, style = MaterialTheme.typography.titleSmall)
Spacer(Modifier.height(2.dp))
Row(modifier = Modifier.fillMaxWidth()) {
Text(
subagentStatusLabel(subagent.status),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
Text(
relativeTime(subagent.lastActivity),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
/**
* The subcard's word for a subagent's status -- see SUBAGENTS.md's "Wire shape". Its own function
* rather than a branch inside [StatusText], because a subagent's three states are not that
* composable's five: "exited" reads as "finished" here, since its process was always its parent's
* and never something of its own to have merely stopped.
*/
private fun subagentStatusLabel(status: String) =
when (status) {
"running" -> "running"
"exited" -> "finished"
else -> "unknown"
}
@Composable @Composable
fun StatusText(status: String) { fun StatusText(status: String) {
val (label, color) = val (label, color) =
@@ -216,16 +216,33 @@ fun SessionScreen(
share: ShareRequest? = null, share: ShareRequest? = null,
/** Said once [share] has been attached here, so it is not attached again. */ /** Said once [share] has been attached here, so it is not attached again. */
onShareTaken: () -> Unit = {}, onShareTaken: () -> Unit = {},
/**
* Draws this screen read-only, on a subagent's own transcript instead of the session's.
*
* A subagent has no process and no controls of its own -- see SUBAGENTS.md's "Phone" -- so
* every gate below keyed on this switches off the composer, the files button, the settings cog,
* the usage bar and notifications, while everything that draws a transcript (paging, cache,
* selection, images, the status row, stream reconnects) is reused unchanged, pointed at
* [address] instead of the session's own.
*/
subagent: SubagentSummary? = null,
) { ) {
DebugStats.count("session screen recomposed") DebugStats.count("session screen recomposed")
val isSubagent = subagent != null
val address = TranscriptAddress(summary.id, subagent?.id)
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val topEdgeHeld = remember { TopEdgeHold() } val topEdgeHeld = remember { TopEdgeHold() }
var items by remember { mutableStateOf(listOf<TranscriptItem>()) } var items by remember { mutableStateOf(listOf<TranscriptItem>()) }
var status by remember { mutableStateOf(summary.status) } var status by remember { mutableStateOf(subagent?.status ?: summary.status) }
// Seeded from the row this screen was opened from, so a conversation already under way says how // Seeded from the row this screen was opened from, so a conversation already under way says how
// much it is holding before any turn happens here. Null is "nobody has measured it", which is a // much it is holding before any turn happens here. Null is "nobody has measured it", which is a
// different answer from an empty context and is drawn differently. // different answer from an empty context and is drawn differently.
var contextTokens by remember(summary.id) { mutableStateOf(summary.contextTokens) } //
// A subagent has no context measurement of its own, so it always starts unmeasured rather than
// borrowing the parent session's figure -- see UI_RULES on not showing an inferred value as one
// that was measured.
var contextTokens by
remember(address) { mutableStateOf(if (isSubagent) null else summary.contextTokens) }
// When the current compaction started. The moment comes off the `compacting` status event // When the current compaction started. The moment comes off the `compacting` status event
// itself -- the server timestamps every transcript line -- rather than off this device noticing // itself -- the server timestamps every transcript line -- rather than off this device noticing
// one, which is what makes it survive leaving the session and reopening it. // one, which is what makes it survive leaving the session and reopening it.
@@ -241,7 +258,13 @@ fun SessionScreen(
val context = LocalContext.current val context = LocalContext.current
// Seeded from what was left in the box last time and written back on every keystroke, so // Seeded from what was left in the box last time and written back on every keystroke, so
// leaving the screen does not throw away a half-typed message. See `Drafts.kt`. // leaving the screen does not throw away a half-typed message. See `Drafts.kt`.
var input by remember(summary.id) { mutableStateOf(atEnd(loadDraft(context, summary.id))) } //
// A subagent has no box to type into, so it never touches a draft at all -- not this session's,
// which is what reading one keyed only by `summary.id` would do here.
var input by
remember(summary.id) {
mutableStateOf(if (isSubagent) atEnd("") else atEnd(loadDraft(context, summary.id)))
}
// A model the reader has chosen and not yet confirmed. See [ModelSwitchWarning]: switching // A model the reader has chosen and not yet confirmed. See [ModelSwitchWarning]: switching
// makes the session re-read the whole conversation. // makes the session re-read the whole conversation.
var pendingModel by remember { mutableStateOf<String?>(null) } var pendingModel by remember { mutableStateOf<String?>(null) }
@@ -294,27 +317,26 @@ fun SessionScreen(
// Reload throws away what it was reading from. // Reload throws away what it was reading from.
val cache = remember(settings) { TranscriptCache(cacheRoot(context, settings)) } val cache = remember(settings) { TranscriptCache(cacheRoot(context, settings)) }
val source = val source =
remember(summary.id, epoch) { remember(address, epoch) { TranscriptSource(settings, address, cache.session(address)) }
TranscriptSource(settings, summary.id, cache.session(summary.id))
}
// Whether the cached tail has been shown to still be the server's own line. Nothing is resumed // Whether the cached tail has been shown to still be the server's own line. Nothing is resumed
// from a cached cursor until it has, and a probe that could not be made leaves this false for // from a cached cursor until it has, and a probe that could not be made leaves this false for
// the stream loop to try again. // the stream loop to try again.
var probePassed by remember(summary.id, epoch) { mutableStateOf(false) } var probePassed by remember(address, epoch) { mutableStateOf(false) }
// Whether the opening effect is still settling that question. It draws the cached rows and // Whether the opening effect is still settling that question. It draws the cached rows and
// lifts [ready] before the answer arrives, which is the point of the cache -- so the stream // lifts [ready] before the answer arrives, which is the point of the cache -- so the stream
// below waits for this rather than for `ready`, or it asks the same question twice. // below waits for this rather than for `ready`, or it asks the same question twice.
var probing by remember(summary.id, epoch) { mutableStateOf(true) } var probing by remember(address, epoch) { mutableStateOf(true) }
// The oldest sequence number loaded, and whether there is more behind it. Paging backwards is // The oldest sequence number loaded, and whether there is more behind it. Paging backwards is
// what keeps opening a long session cheap. // what keeps opening a long session cheap.
var oldestSeq by remember { mutableLongStateOf(0L) } var oldestSeq by remember { mutableLongStateOf(0L) }
// Where this session was last being read, from this device's own store. Read once, because the // Where this transcript was last being read, from this device's own store, keyed by the address
// answer stops being interesting the moment the list is on screen. // rather than the session id so a subagent's saved position cannot collide with its session's.
val savedAnchor = remember(summary.id, epoch) { loadScrollAnchor(context, summary.id) } // Read once, because the answer stops being interesting the moment the list is on screen.
val savedAnchor = remember(address, epoch) { loadScrollAnchor(context, address.cachePath) }
// Whether the saved position is still being put back. Nothing is drawn while it is: opening at // Whether the saved position is still being put back. Nothing is drawn while it is: opening at
// the newest end and then travelling to the anchor is exactly the journey a reader must never // the newest end and then travelling to the anchor is exactly the journey a reader must never
// see. // see.
var restoring by remember(summary.id, epoch) { mutableStateOf(savedAnchor != null) } var restoring by remember(address, epoch) { mutableStateOf(savedAnchor != null) }
// Messages the server has taken and the session has not read yet, by the id that will resolve // Messages the server has taken and the session has not read yet, by the id that will resolve
// them. From the event stream rather than from what this screen sent, so they survive leaving // them. From the event stream rather than from what this screen sent, so they survive leaving
// the session -- and a message sent from another device is drawn waiting on this one too. // the session -- and a message sent from another device is drawn waiting on this one too.
@@ -327,11 +349,11 @@ fun SessionScreen(
var loadingHistory by remember { mutableStateOf(false) } var loadingHistory by remember { mutableStateOf(false) }
var ready by remember { mutableStateOf(false) } var ready by remember { mutableStateOf(false) }
// Replies parsed ahead of the rows that draw them; see [ParsedReplies]. // Replies parsed ahead of the rows that draw them; see [ParsedReplies].
val replies = remember(summary.id) { ParsedReplies() } val replies = remember(address) { ParsedReplies() }
// Keyed like everything else describing one session's transcript. `rememberLazyListState` saves // Keyed like everything else describing one transcript. `rememberLazyListState` saves through
// through `rememberSaveable`, and this screen restores by its own anchor instead -- two // `rememberSaveable`, and this screen restores by its own anchor instead -- two restores would
// restores would fight over the first frame. // fight over the first frame.
val listState = remember(summary.id) { LazyListState() } val listState = remember(address) { LazyListState() }
// Whether the newest message is on screen right now. The list is reversed, so the newest end is // Whether the newest message is on screen right now. The list is reversed, so the newest end is
// the scrolling start: nothing behind you is exactly being at the bottom. Asked of the scroll // the scrolling start: nothing behind you is exactly being at the bottom. Asked of the scroll
// state rather than of item indices, because a zero-height first item makes an index ambiguous. // state rather than of item indices, because a zero-height first item makes an index ambiguous.
@@ -637,7 +659,7 @@ fun SessionScreen(
// ended and carries live events only. The window comes from this phone's own copy when there is // ended and carries live events only. The window comes from this phone's own copy when there is
// one, and then costs a single request to check that the server's transcript is still the one // one, and then costs a single request to check that the server's transcript is still the one
// it came from. See TRANSCRIPT_CACHE.md. // it came from. See TRANSCRIPT_CACHE.md.
LaunchedEffect(summary.id, epoch) { LaunchedEffect(address, epoch) {
/** /**
* One opening window onto the screen, whichever side it came from. * One opening window onto the screen, whichever side it came from.
* *
@@ -667,11 +689,16 @@ fun SessionScreen(
// A replay is as old as the last visit; the row this screen was opened from was // A replay is as old as the last visit; the row this screen was opened from was
// fetched moments ago. So the transcript comes from the cache and everything that // fetched moments ago. So the transcript comes from the cache and everything that
// is not the transcript comes from the summary -- otherwise a session that finished // is not the transcript comes from the summary -- otherwise a session that finished
// an hour ago opens saying "working" until the stream connects. // an hour ago opens saying "working" until the stream connects. A subagent's status
status = summary.status // comes from its own summary, never the parent session's: they are two different
// things running or not, and the parent's model and permission mode do not apply to
// it at all.
status = subagent?.status ?: summary.status
if (!isSubagent) {
model = summary.model model = summary.model
permissionMode = summary.permissionMode ?: "auto" permissionMode = summary.permissionMode ?: "auto"
if (summary.status != "compacting") compactingSince = null }
if (status != "compacting") compactingSince = null
// Nothing to put back, so these rows are the screen and the probe can return under // Nothing to put back, so these rows are the screen and the probe can return under
// them. A restore still has history to fetch and is gated below. // them. A restore still has history to fetch and is gated below.
if (savedAnchor == null) ready = true if (savedAnchor == null) ready = true
@@ -798,7 +825,7 @@ fun SessionScreen(
// at the top on their return. Switching apps is a choice somebody made, not a fault to report. // at the top on their return. Switching apps is a choice somebody made, not a fault to report.
// Stopping the stream deliberately makes the drop a close rather than an error, and resuming // Stopping the stream deliberately makes the drop a close rather than an error, and resuming
// reconnects from the same cursor. // reconnects from the same cursor.
LaunchedEffect(summary.id, ready, epoch, lifecycleOwner) { LaunchedEffect(address, ready, epoch, lifecycleOwner) {
if (!ready) return@LaunchedEffect if (!ready) return@LaunchedEffect
// The opening effect draws cached rows and lifts `ready` *before* it has checked that the // The opening effect draws cached rows and lifts `ready` *before* it has checked that the
// cursor under them is still the server's, so `ready` is no longer the whole gate. Without // cursor under them is still the server's, so `ready` is no longer the whole gate. Without
@@ -868,10 +895,14 @@ fun SessionScreen(
// The screen going away entirely, which the lifecycle scope above does not cover: a composable // The screen going away entirely, which the lifecycle scope above does not cover: a composable
// can leave the composition while the activity stays started. Keyed on the epoch as well, so // can leave the composition while the activity stays started. Keyed on the epoch as well, so
// Reload's replacement source is the one a later disposal closes. // Reload's replacement source is the one a later disposal closes.
DisposableEffect(summary.id, epoch) { onDispose { source.close() } } DisposableEffect(address, epoch) { onDispose { source.close() } }
// Nothing gets announced about the session somebody is reading; see NotificationService. // Nothing gets announced about the session somebody is reading; see NotificationService.
// RESUMED rather than STARTED because "looking at it" means the foreground. // RESUMED rather than STARTED because "looking at it" means the foreground.
//
// Not for a subagent: it has no notifications of its own, and it is not the session this would
// otherwise mark as being read.
if (!isSubagent) {
LaunchedEffect(summary.id, lifecycleOwner) { LaunchedEffect(summary.id, lifecycleOwner) {
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) { lifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) {
NotificationService.showing(context, summary.id) NotificationService.showing(context, summary.id)
@@ -882,6 +913,7 @@ fun SessionScreen(
} }
} }
} }
}
// Back at the newest end, so the backlog [apply] held can land. Everything at once rather than // Back at the newest end, so the backlog [apply] held can land. Everything at once rather than
// paced out: they are at the bottom, which is the one place the list is allowed to follow new // paced out: they are at the bottom, which is the one place the list is allowed to follow new
@@ -924,7 +956,7 @@ fun SessionScreen(
val (index, offset, awayFromNewest) = settled val (index, offset, awayFromNewest) = settled
saveScrollAnchor( saveScrollAnchor(
context, context,
summary.id, address.cachePath,
// Nothing to restore at the newest end, which is where a session with no anchor // Nothing to restore at the newest end, which is where a session with no anchor
// opens anyway. One *before* the index, because item zero is the "below" slot. // opens anyway. One *before* the index, because item zero is the "below" slot.
if (!awayFromNewest) null if (!awayFromNewest) null
@@ -947,7 +979,7 @@ fun SessionScreen(
// //
// There is no correction beside this one. Following the newest message is not an effect: the // There is no correction beside this one. Following the newest message is not an effect: the
// list is reversed, so an arriving message extends the end the viewport is pinned to. // list is reversed, so an arriving message extends the end the viewport is pinned to.
val unitSizes = remember(summary.id) { HashMap<Any, Int>() } val unitSizes = remember(address) { HashMap<Any, Int>() }
LaunchedEffect(listState, moreHistory) { LaunchedEffect(listState, moreHistory) {
snapshotFlow { listState.layoutInfo } snapshotFlow { listState.layoutInfo }
.collect { info -> .collect { info ->
@@ -983,6 +1015,8 @@ fun SessionScreen(
} }
} }
// Only for the model picker, which a subagent does not have.
if (!isSubagent) {
LaunchedEffect(summary.setupName, summary.provider) { LaunchedEffect(summary.setupName, summary.provider) {
offeredModels = offeredModels =
try { try {
@@ -995,10 +1029,12 @@ fun SessionScreen(
.orEmpty() .orEmpty()
} }
} catch (_: Exception) { } catch (_: Exception) {
// Not worth reporting: the picker simply has nothing to offer, which is visible. // Not worth reporting: the picker simply has nothing to offer, which is
// visible.
emptyList() emptyList()
} }
} }
}
/** /**
* Asks the server to take back a message the session has not read yet. * Asks the server to take back a message the session has not read yet.
@@ -1148,8 +1184,9 @@ fun SessionScreen(
} }
// One poll for the machines' limits, read by everything on this screen that reports them. // One poll for the machines' limits, read by everything on this screen that reports them.
val usageFeed = rememberUsageFeed(settings) // Nothing meters a subagent -- it has no account of its own -- so it never starts this poll.
val usage = usageFeed.forSetup(summary.setup) val usageFeed = if (isSubagent) null else rememberUsageFeed(settings)
val usage = usageFeed?.forSession(summary) ?: SessionUsage.NotMetered
RecordFrames() RecordFrames()
var usageOpen by remember { mutableStateOf(false) } var usageOpen by remember { mutableStateOf(false) }
var settingsOpen by remember { mutableStateOf(false) } var settingsOpen by remember { mutableStateOf(false) }
@@ -1256,20 +1293,35 @@ fun SessionScreen(
// A ring's worth, which is what the arrow already keeps on its other three sides. // A ring's worth, which is what the arrow already keeps on its other three sides.
Spacer(Modifier.width(GLYPH_BUTTON_MARGIN)) Spacer(Modifier.width(GLYPH_BUTTON_MARGIN))
Column(Modifier.weight(1f)) { Column(Modifier.weight(1f)) {
// A subagent's own title, with the session's beneath it in a smaller style --
// the header says whose conversation this is as well as what it is. Otherwise
// just the session's title, as before.
if (subagent != null) {
Text(subagent.title, style = MaterialTheme.typography.titleMedium)
Text(
title,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
Text(title, style = MaterialTheme.typography.titleMedium) Text(title, style = MaterialTheme.typography.titleMedium)
// Machine first, then what runs on it -- the same order and the same wording // Machine first, then what runs on it -- the same order and the same
// everywhere this pair appears, so it reads as one fact rather than two // wording everywhere this pair appears, so it reads as one fact rather than
// sentences with different grammar. // two sentences with different grammar.
// //
// No model. The picker in the footer already shows what this session is set to, // No model. The picker in the footer already shows what this session is set
// and showing it twice means two things to keep in step -- they disagreed for a // to, and showing it twice means two things to keep in step -- they
// moment on every model change. // disagreed for a moment on every model change.
Text( Text(
"${summary.setupName} · ${summary.provider}", "${summary.setupName} · ${summary.provider}",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} }
}
// None of this is a subagent's: it has no files of its own to browse, no settings,
// and nothing meters it -- see SUBAGENTS.md's "Phone".
//
// Beside the provider it reports on, which is the line directly to its left. Its // Beside the provider it reports on, which is the line directly to its left. Its
// real home is this provider's settings, which do not exist yet. A session on a // real home is this provider's settings, which do not exist yet. A session on a
// provider with no such service gets an honest "unavailable" rather than a hidden // provider with no such service gets an honest "unavailable" rather than a hidden
@@ -1284,6 +1336,7 @@ fun SessionScreen(
// Usage, files, settings -- widest scope first, narrowing to the right, so the cog // Usage, files, settings -- widest scope first, narrowing to the right, so the cog
// stays at the end where every other screen keeps it. Asked for in this order by // stays at the end where every other screen keeps it. Asked for in this order by
// Iris on 2026-09-03. // Iris on 2026-09-03.
if (!isSubagent) {
Row { Row {
GlyphButton( GlyphButton(
USAGE_GLYPH, USAGE_GLYPH,
@@ -1301,25 +1354,29 @@ fun SessionScreen(
FilesTarget( FilesTarget(
setup = summary.setup, setup = summary.setup,
setupName = summary.setupName, setupName = summary.setupName,
// Where this session works, and the machine's own home when it // Where this session works, and the machine's own home when
// was never given a directory -- resolved there rather than // it was never given a directory -- resolved there rather
// guessed at here, since this app does not know that home. // than guessed at here, since this app does not know that
// home.
start = summary.cwd?.takeIf { it.isNotBlank() } ?: "~", start = summary.cwd?.takeIf { it.isNotBlank() } ?: "~",
) )
) )
}, },
) )
// What it opens is about this session, so it sits at the end of the session's // What it opens is about this session, so it sits at the end of the
// own row. A cog and not a word because there will be more, and a bar of words // session's own row. A cog and not a word because there will be more, and a
// has nowhere to put it. // bar of words has nowhere to put it.
GlyphButton(SETTINGS_GLYPH, "Session settings", { settingsOpen = true }) GlyphButton(SETTINGS_GLYPH, "Session settings", { settingsOpen = true })
} }
} }
}
// Under the header, above everything the session itself says: it is a fact about the // Under the header, above everything the session itself says: it is a fact about the
// machine rather than a turn in the conversation, and it is the number that decides // machine rather than a turn in the conversation, and it is the number that decides
// whether to keep going. // whether to keep going. Nothing meters a subagent.
if (!isSubagent) {
SessionUsageBar(usage) SessionUsageBar(usage)
}
(streamError ?: actionError)?.let { message -> (streamError ?: actionError)?.let { message ->
Text( Text(
@@ -1565,6 +1622,7 @@ fun SessionScreen(
is TranscriptItem.ClearedNote -> ClearedRow() is TranscriptItem.ClearedNote -> ClearedRow()
is TranscriptItem.CompactedNote -> is TranscriptItem.CompactedNote ->
CompactedRow(item) CompactedRow(item)
is TranscriptItem.LimitNote -> LimitRow(item)
// Never reached: a peer message is flattened into // Never reached: a peer message is flattened into
// its own units. Here because a `when` over the // its own units. Here because a `when` over the
// item kinds has to stay exhaustive. // item kinds has to stay exhaustive.
@@ -1663,24 +1721,34 @@ fun SessionScreen(
) )
} }
// Kept for a subagent -- see SUBAGENTS.md's "Phone" -- with the wording that turns
// "exited" into "finished" for one, since it has no process to leave running or stop.
SessionStatusRow( SessionStatusRow(
status = status, status = status,
compactingFor = compactingFor, compactingFor = compactingFor,
contextTokens = contextTokens, contextTokens = contextTokens,
subagent = isSubagent,
) )
// Between the transcript and the box: above what is being typed, so the list does not // Everything from here down is the composer: a subagent cannot be messaged, so none of
// cover the thing the command is about, and below everything that explains it. // it applies -- see SUBAGENTS.md's "Phone".
if (!isSubagent) {
// Between the transcript and the box: above what is being typed, so the list does
// not cover the thing the command is about, and below everything that explains it.
CommandSuggestions( CommandSuggestions(
// Nothing to suggest about a suggestion that was just taken. `/compact` is a whole // Nothing to suggest about a suggestion that was just taken. `/compact` is a
// command *and* a prefix of itself, so picking it left the list standing there with // whole command *and* a prefix of itself, so picking it left the list standing
// the one row already chosen. Held by what was picked rather than by a flag, so // there with the one row already chosen. Held by what was picked rather than by
// typing anything else brings the list back without a second thing to reset. // a flag, so typing anything else brings the list back without a second thing
commands = if (input.text == picked) emptyList() else suggestedCommands(input.text), // to
// reset.
commands =
if (input.text == picked) emptyList() else suggestedCommands(input.text),
onPick = { command -> onPick = { command ->
// At the end of what was inserted, which is where the reader carries on typing: // At the end of what was inserted, which is where the reader carries on
// a command with an argument is put in the box half-written, and a cursor left // typing: a command with an argument is put in the box half-written, and a
// at the front makes the next keystroke the first character of "/rename". // cursor left at the front makes the next keystroke the first character of
// "/rename".
input = atEnd(command.typed()) input = atEnd(command.typed())
picked = command.typed() picked = command.typed()
}, },
@@ -1689,11 +1757,14 @@ fun SessionScreen(
// Always enabled -- a send while the session is running becomes a steering message // Always enabled -- a send while the session is running becomes a steering message
// injected at the next tool boundary, which is the point of the whole app. // injected at the next tool boundary, which is the point of the whole app.
// //
// The field gets a row of its own, above the buttons: sharing one put the full width // The field gets a row of its own, above the buttons: sharing one put the full
// behind three controls, so the thing being typed into was the narrowest on the row. // width
// behind three controls, so the thing being typed into was the narrowest on the
// row.
Column(Modifier.fillMaxWidth().padding(8.dp)) { Column(Modifier.fillMaxWidth().padding(8.dp)) {
// Directly above the box they will be sent from, so what is attached is visible // Directly above the box they will be sent from, so what is attached is visible
// rather than counted: the "+2" on the button below said how many and never which. // rather than counted: the "+2" on the button below said how many and never
// which.
PendingAttachments( PendingAttachments(
settings = settings, settings = settings,
sessionId = summary.id, sessionId = summary.id,
@@ -1707,8 +1778,8 @@ fun SessionScreen(
saveDraft(context, summary.id, it.text) saveDraft(context, summary.id, it.text)
}, },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
// No longer "(+image)": the images are on screen above this, and a placeholder // No longer "(+image)": the images are on screen above this, and a
// saying so said it in words beside the thing itself. // placeholder saying so said it in words beside the thing itself.
placeholder = { Text("Message") }, placeholder = { Text("Message") },
maxLines = 4, maxLines = 4,
) )
@@ -1716,17 +1787,19 @@ fun SessionScreen(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { ) {
// Photo or file, asked here rather than by two buttons: the row is full, and // Photo or file, asked here rather than by two buttons: the row is full,
// and
// attaching is one action whichever picker answers it. // attaching is one action whichever picker answers it.
var attaching by remember { mutableStateOf(false) } var attaching by remember { mutableStateOf(false) }
Box { Box {
// Just "+". The count it used to carry was standing in for showing them. // Just "+". The count it used to carry was standing in for showing
// them.
BubbleButton(onClick = { attaching = true }) { Text("+") } BubbleButton(onClick = { attaching = true }) { Text("+") }
DropdownMenu( DropdownMenu(
expanded = attaching, expanded = attaching,
onDismissRequest = { attaching = false }, onDismissRequest = { attaching = false },
// See PickerButton: without this the menu opens a status bar's height // See PickerButton: without this the menu opens a status bar's
// away from the button in an edge-to-edge activity. // height away from the button in an edge-to-edge activity.
properties = PopupProperties(clippingEnabled = false), properties = PopupProperties(clippingEnabled = false),
shape = BubbleMenuShape, shape = BubbleMenuShape,
) { ) {
@@ -1750,11 +1823,11 @@ fun SessionScreen(
) )
} }
} }
// The settings share what is left after the actions have taken what they need. // The settings share what is left after the actions have taken what they
// A Row hands out intrinsic widths in order and clips whatever runs past the // need. A Row hands out intrinsic widths in order and clips whatever runs
// edge, so with these laid out first the arrival of Stop pushed Send off the // past the edge, so with these laid out first the arrival of Stop pushed
// screen entirely -- the app's central control, gone at the moment it is most // Send off the screen entirely -- the app's central control, gone at the
// in use. // moment it is most in use.
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
@@ -1762,16 +1835,18 @@ fun SessionScreen(
if (offeredModels.isNotEmpty()) { if (offeredModels.isNotEmpty()) {
PickerButton( PickerButton(
current = modelLabel(model), current = modelLabel(model),
// What the machine offers, plus the state a session is in when it // What the machine offers, plus the state a session is in when
// has chosen none of them. The button has always been able to say // it has chosen none of them. The button has always been able
// "default"; until this the list could not, so leaving it was a // to
// one-way trip. // say "default"; until this the list could not, so leaving it
// was a one-way trip.
options = listOf(DEFAULT_MODEL) + offeredModels, options = listOf(DEFAULT_MODEL) + offeredModels,
// Not set here. The button follows what the session reports it is // Not set here. The button follows what the session reports it
// set to, which arrives a moment later and is sometimes a different // is set to, which arrives a moment later and is sometimes a
// answer -- a name the CLI resolved, or no change at all on a // different answer -- a name the CLI resolved, or no change at
// provider whose model is fixed. Asked about first, unless there is // all on a provider whose model is fixed. Asked about first,
// nothing to lose by it -- see [ModelSwitchWarning]. // unless there is nothing to lose by it -- see
// [ModelSwitchWarning].
onPick = { chosen -> onPick = { chosen ->
if ( if (
modelLabel(chosen) == modelLabel(model) || modelLabel(chosen) == modelLabel(model) ||
@@ -1792,14 +1867,16 @@ fun SessionScreen(
}, },
) )
} }
// The same filled shape as the button beside it, not an outlined one: these are // The same filled shape as the button beside it, not an outlined one: these
// two things you can do about the session, and weighting one as secondary said // are two things you can do about the session, and weighting one as
// they were a primary action and its qualifier. What separates them is the // secondary said they were a primary action and its qualifier. What
// colour and the mark, which is what they mean. // separates them is the colour and the mark, which is what they mean.
// //
// Always here, rather than arriving with the turn as it used to. A control that // Always here, rather than arriving with the turn as it used to. A control
// comes and goes makes its own presence the signal, and a button always in the // that comes and goes makes its own presence the signal, and a button
// same place also cannot push Send off the end of the row by turning up. // always
// in the same place also cannot push Send off the end of the row by turning
// up.
val process = val process =
when { when {
running -> ProcessAction.Pause running -> ProcessAction.Pause
@@ -1819,18 +1896,21 @@ fun SessionScreen(
Glyph( Glyph(
process.glyph, process.glyph,
colour = LocalContentColor.current, colour = LocalContentColor.current,
modifier = Modifier.semantics { contentDescription = process.label }, modifier =
Modifier.semantics { contentDescription = process.label },
) )
} }
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
// The paper plane, with a clock on it while a turn is in flight: sending then // The paper plane, with a clock on it while a turn is in flight: sending
// queues the message for the next tool boundary rather than starting a turn of // then queues the message for the next tool boundary rather than starting a
// its own, and the two have to be told apart at a glance. The label says the // turn of its own, and the two have to be told apart at a glance. The label
// same thing to a screen reader. // says the same thing to a screen reader.
// //
// Disabled while there is nothing to send, rather than pressable and silent: // Disabled while there is nothing to send, rather than pressable and
// `send` has always returned early on an empty composer, so the button promised // silent:
// something it would not do. Disabled and not hidden, for the reason above. // `send` has always returned early on an empty composer, so the button
// promised something it would not do. Disabled and not hidden, for the
// reason above.
Button( Button(
onClick = { send() }, onClick = { send() },
enabled = input.text.isNotBlank() || pendingAttachments.isNotEmpty(), enabled = input.text.isNotBlank() || pendingAttachments.isNotEmpty(),
@@ -1847,12 +1927,13 @@ fun SessionScreen(
} }
} }
} }
}
// Beside the other two dialogs, and outside the list for the same reason as them: what is open // Beside the other two dialogs, and outside the list for the same reason as them: what is open
// is the screen's business rather than any row's. See [SessionImageViewer]. // is the screen's business rather than any row's. See [SessionImageViewer].
fullImage?.let { ref -> SessionImageViewer(settings, summary.id, ref) { fullImage = null } } fullImage?.let { ref -> SessionImageViewer(settings, summary.id, ref) { fullImage = null } }
if (usageOpen) { if (usageOpen) {
UsageDialog(feed = usageFeed, onDismiss = { usageOpen = false }) usageFeed?.let { UsageDialog(feed = it, onDismiss = { usageOpen = false }) }
} }
if (settingsOpen) { if (settingsOpen) {
// Measured when the dialog opens rather than kept up to date: what the reader is being told // Measured when the dialog opens rather than kept up to date: what the reader is being told
@@ -1866,6 +1947,8 @@ fun SessionScreen(
settings = settings, settings = settings,
sessionId = summary.id, sessionId = summary.id,
title = title, title = title,
effort = summary.effort.takeIf { summary.takesEffort },
takesEffort = summary.takesEffort,
cachedBytes = cachedBytes, cachedBytes = cachedBytes,
// The purge finishes before the epoch moves, because the relaunched opening effect // The purge finishes before the epoch moves, because the relaunched opening effect
// reads the same directory and would otherwise draw what is about to be deleted. The // reads the same directory and would otherwise draw what is about to be deleted. The
@@ -2143,6 +2226,13 @@ private fun SessionStatusRow(
/** Context the session is holding, or null where nothing has measured it. */ /** Context the session is holding, or null where nothing has measured it. */
contextTokens: Long?, contextTokens: Long?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
/**
* Whether this row is for a subagent rather than a session, which changes only one word:
* "exited" reads as "finished" there too, the same as the subagent list's own card -- a
* subagent's process was always its parent's, so "exited" would read as a fault rather than the
* ordinary way one of these ends.
*/
subagent: Boolean = false,
) { ) {
DebugStats.count("status row recomposed") DebugStats.count("status row recomposed")
Row( Row(
@@ -2199,7 +2289,7 @@ private fun SessionStatusRow(
Text( Text(
when (status) { when (status) {
"idle" -> "idle" "idle" -> "idle"
"exited" -> "exited" "exited" -> if (subagent) "finished" else "exited"
"awaitingInput" -> "your turn" "awaitingInput" -> "your turn"
"unknown" -> "can't tell" "unknown" -> "can't tell"
else -> status else -> status
@@ -2270,7 +2360,7 @@ private const val ONE_TAP_MS = 250L
* session is set to without spending a second line on saying it. * session is set to without spending a second line on saying it.
*/ */
@Composable @Composable
private fun PickerButton(current: String, options: List<String>, onPick: (String) -> Unit) { fun PickerButton(current: String, options: List<String>, onPick: (String) -> Unit) {
var open by remember { mutableStateOf(false) } var open by remember { mutableStateOf(false) }
// When an outside touch last closed the menu. // When an outside touch last closed the menu.
// //
@@ -6,8 +6,10 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -26,6 +28,10 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -54,6 +60,16 @@ fun SessionSettingsDialog(
*/ */
title: String, title: String,
onRenamed: (String) -> Unit, onRenamed: (String) -> Unit,
/**
* How hard the model thinks, as the session reports it, or null for the CLI's own default.
*
* Taken from the row this dialog was opened over rather than fetched, because unlike the
* notification switch there is nothing else that changes it: the level is this app's to set and
* the server does not resolve it into something else.
*/
effort: String?,
/** Whether a level does anything here; the row is left out entirely where it does not. */
takesEffort: Boolean,
/** /**
* What this phone is holding of the conversation, or null while that is being measured -- see * What this phone is holding of the conversation, or null while that is being measured -- see
* the Reload row below, which is what would discard it. * the Reload row below, which is what would discard it.
@@ -76,6 +92,8 @@ fun SessionSettingsDialog(
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var name by remember(sessionId) { mutableStateOf(title) } var name by remember(sessionId) { mutableStateOf(title) }
var level by remember(sessionId) { mutableStateOf(effort) }
var effortError by remember { mutableStateOf<String?>(null) }
var saving by remember { mutableStateOf(false) } var saving by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) } var error by remember { mutableStateOf<String?>(null) }
// Null until the server has been asked. The row this dialog was opened over is a snapshot of // Null until the server has been asked. The row this dialog was opened over is a snapshot of
@@ -84,6 +102,16 @@ fun SessionSettingsDialog(
// and a spinner sits beside it, which is what not knowing looks like. // and a spinner sits beside it, which is what not knowing looks like.
var notify by remember(sessionId) { mutableStateOf<Boolean?>(null) } var notify by remember(sessionId) { mutableStateOf<Boolean?>(null) }
var notifyError by remember { mutableStateOf<String?>(null) } var notifyError by remember { mutableStateOf<String?>(null) }
// The same three-state shape the notification switch has, for the same reason: until the
// server has answered, the switch is disabled rather than showing a position nothing confirmed.
var autoResume by remember(sessionId) { mutableStateOf<Boolean?>(null) }
var resumeMessage by remember(sessionId) { mutableStateOf(DEFAULT_RESUME_MESSAGE) }
// When the server next intends to ask whether the limit has lifted, or null when nothing is
// waiting. Read once with everything else: it moves on the server's schedule, not this
// screen's, and a figure that redrew itself here would be this app re-measuring what it was
// told.
var resumeAt by remember(sessionId) { mutableStateOf<Double?>(null) }
var resumeError by remember { mutableStateOf<String?>(null) }
// Where the session works. Null until the server has been asked, for the same reason the switch // Where the session works. Null until the server has been asked, for the same reason the switch
// above is. An empty answer is a session that was never given a directory, which is not the // above is. An empty answer is a session that was never given a directory, which is not the
// same as one whose directory is unknown -- the field is only enabled once one of those is // same as one whose directory is unknown -- the field is only enabled once one of those is
@@ -97,6 +125,9 @@ fun SessionSettingsDialog(
try { try {
val fresh = withContext(Dispatchers.IO) { fetchSession(settings, sessionId) } val fresh = withContext(Dispatchers.IO) { fetchSession(settings, sessionId) }
notify = fresh.notify notify = fresh.notify
autoResume = fresh.autoResume
resumeMessage = fresh.autoResumeMessage
resumeAt = fresh.resumeAt
cwd = fresh.cwd.orEmpty() cwd = fresh.cwd.orEmpty()
typedCwd = fresh.cwd.orEmpty() typedCwd = fresh.cwd.orEmpty()
} catch (e: ApiException) { } catch (e: ApiException) {
@@ -104,6 +135,8 @@ fun SessionSettingsDialog(
// instead of offering a position nothing confirmed. // instead of offering a position nothing confirmed.
notifyError = e.message notifyError = e.message
notify = null notify = null
resumeError = e.message
autoResume = null
} }
} }
@@ -132,6 +165,26 @@ fun SessionSettingsDialog(
} }
} }
/**
* Chooses a thinking level, which ends the process the old level was launched with.
*
* Put back if the request is refused, for the reason the notification switch below gives: a
* control that stays where it was put after a refusal is stating something untrue.
*/
fun setEffort(chosen: String?) {
val was = level
level = chosen
effortError = null
scope.launch {
try {
withContext(Dispatchers.IO) { setSessionEffort(settings, sessionId, chosen) }
} catch (e: ApiException) {
level = was
effortError = e.message
}
}
}
// Moved optimistically so the switch answers the finger that moved it, and put back if the // Moved optimistically so the switch answers the finger that moved it, and put back if the
// request is refused -- a switch that waits for a round trip reads as broken on a slow tunnel, // request is refused -- a switch that waits for a round trip reads as broken on a slow tunnel,
// and one that stays moved after a refusal lies. // and one that stays moved after a refusal lies.
@@ -149,6 +202,39 @@ fun SessionSettingsDialog(
} }
} }
/**
* Turns auto-resume on or off, or changes what it would say.
*
* One request for both, because the server takes one: switching it on and typing the message
* are two halves of the same decision, and sending them separately would leave a moment where
* the session is armed with the old words.
*
* Put back if refused, like the notification switch. Turning it off also clears what was
* scheduled -- said here rather than only on the server, or the row would go on naming a time
* that no longer exists.
*/
fun setAutoResume(on: Boolean, message: String) {
val wasOn = autoResume
val wasMessage = resumeMessage
val wasAt = resumeAt
autoResume = on
resumeMessage = message
if (!on) resumeAt = null
resumeError = null
scope.launch {
try {
withContext(Dispatchers.IO) {
setSessionAutoResume(settings, sessionId, on, message)
}
} catch (e: ApiException) {
autoResume = wasOn
resumeMessage = wasMessage
resumeAt = wasAt
resumeError = e.message
}
}
}
// Nothing to do when the name has not changed, so the button says so rather than sending a // Nothing to do when the name has not changed, so the button says so rather than sending a
// request whose success would look exactly like the failure of having typed nothing. // request whose success would look exactly like the failure of having typed nothing.
val changed = name.trim().isNotEmpty() && name.trim() != title val changed = name.trim().isNotEmpty() && name.trim() != title
@@ -175,7 +261,10 @@ fun SessionSettingsDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
title = { Text("Session settings") }, title = { Text("Session settings") },
text = { text = {
Column { // Scrollable, because this dialog grew past a screenful: a Material dialog constrains
// its own height and clips what does not fit, so the last control on the list is one
// large system font away from being unreachable with nothing on screen to say so.
Column(Modifier.verticalScroll(rememberScrollState())) {
OutlinedTextField( OutlinedTextField(
value = name, value = name,
onValueChange = { name = it }, onValueChange = { name = it },
@@ -219,6 +308,70 @@ fun SessionSettingsDialog(
) )
} }
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Text("Resume after a usage limit", modifier = Modifier.weight(1f))
if (autoResume == null && resumeError == null) {
CircularProgressIndicator(
modifier = Modifier.width(16.dp).height(16.dp),
strokeWidth = 2.dp,
)
Spacer(Modifier.width(8.dp))
}
Switch(
checked = autoResume == true,
onCheckedChange = { setAutoResume(it, resumeMessage) },
enabled = autoResume != null,
)
}
// Disabled rather than hidden while the switch is off: a field that comes and goes
// makes its own presence the signal, and a visible one teaches what the switch will
// do. Committed on the keyboard's Done rather than on every keystroke, so typing a
// sentence is one request instead of one per letter.
OutlinedTextField(
value = resumeMessage,
onValueChange = { resumeMessage = it },
label = { Text("Message to send") },
// What an empty field means, in the field: the server's own word rather than a
// session poked with nothing to read.
placeholder = { Text(DEFAULT_RESUME_MESSAGE) },
singleLine = true,
enabled = autoResume == true,
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions =
KeyboardActions(onDone = { setAutoResume(true, resumeMessage) }),
)
// What it does and what it costs, in the order it happens. The last sentence is the
// one that matters: the time below is when the server will *ask*, not a promise
// about when the session speaks.
Text(
"When this session stops because the account is out of quota, the server " +
"checks the limit and sends this message once it has lifted. It checks " +
"again if the limit is still on.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
// Only where something is actually waiting. Absent is not a state worth a row: a
// session that has not hit a limit has nothing scheduled, which the reader can see
// from the switch.
resumeAt?.let { at ->
Text(
"Waiting now -- next check ${formatCheckTime(at)}.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
resumeError?.let {
Text(
it,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
}
Spacer(Modifier.height(8.dp))
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -264,6 +417,44 @@ fun SessionSettingsDialog(
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
) )
} }
// Left out rather than disabled, the one place this dialog does that: a disabled
// control teaches what the thing can do, and a llama session cannot do this at all
// -- the row would be teaching something false about it.
if (takesEffort) {
Spacer(Modifier.height(8.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Text("Thinking", modifier = Modifier.weight(1f))
PickerButton(
current = level ?: DEFAULT_EFFORT,
// The level the CLI picks for itself is in the list as well as in the
// button, so leaving a level is not a one-way trip -- the same
// correction the model picker carries.
options = listOf(DEFAULT_EFFORT) + EFFORT_LEVELS,
onPick = { chosen ->
setEffort(chosen.takeIf { it != DEFAULT_EFFORT })
},
)
}
// What it costs, said where it is about to be pressed, like Move above: the
// CLI reads the level when it launches and has no control request for
// changing one.
Text(
"Changing this stops the session's process. It starts again with the " +
"next message, or with Start.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
effortError?.let {
Text(
it,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
}
}
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@@ -351,3 +542,21 @@ fun SessionSettingsDialog(
dismissButton = { TextButton(onClick = onDismiss) { Text("Close") } }, dismissButton = { TextButton(onClick = onDismiss) { Text("Close") } },
) )
} }
/**
* When the server will next look, as a local time.
*
* A time rather than a countdown, for the reason the transcript's own limit row gives: this screen
* reads the figure once, and a span drawn from a value nothing refreshes goes stale while somebody
* is looking at it.
*/
private fun formatCheckTime(epochSeconds: Double): String =
try {
DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
.withZone(ZoneId.systemDefault())
.format(Instant.ofEpochSecond(epochSeconds.toLong()))
} catch (_: Exception) {
// A time that cannot be read is not a time to show: the sentence above still says a check
// is coming, which is the part the reader can act on.
"soon"
}
@@ -70,12 +70,21 @@ class UsageFeed(
/** Ask the backend again now. The dialog's refresh button; the poll does it on its own. */ /** Ask the backend again now. The dialog's refresh button; the poll does it on its own. */
val refresh: () -> Unit, val refresh: () -> Unit,
) { ) {
/** What [setup]'s own limits came back as. See [usageFor] for why the states are these. */ /**
fun forSetup(setup: String): SessionUsage = * What meters [session], and what that meter came back as. See [usageFor] for the states.
when (val state = snapshots) { *
* A session rather than a machine, because a machine is not what is metered: one machine runs
* the Claude CLI and an echo session side by side, and only the first of them spends anything.
*/
fun forSession(session: SessionSummary): SessionUsage {
// Settled without asking anybody: a session nothing meters has nothing to check, and
// "checking" is what the fetch's own states would say about it for as long as one is out.
val provider = session.usageProvider ?: return SessionUsage.NotMetered
return when (val state = snapshots) {
is LoadState.Loading -> SessionUsage.Waiting is LoadState.Loading -> SessionUsage.Waiting
is LoadState.Error -> SessionUsage.Unavailable(state.message) is LoadState.Error -> SessionUsage.Unavailable(state.message)
is LoadState.Loaded -> usageFor(state.value, setup) is LoadState.Loaded -> usageFor(state.value, session.setup, provider)
}
} }
} }
@@ -158,9 +167,15 @@ fun SessionUsageBar(usage: SessionUsage, modifier: Modifier = Modifier) {
} }
} }
// Nothing at all for a machine that meters nothing: a row saying "unknown" there would report a // Nothing at all for a session that meters nothing: a row saying "unknown" there would report
// problem about a setup somebody chose, on every screen, forever. // a problem about a setup somebody chose, on every screen, forever.
if (usage is SessionUsage.NotMetered) { //
// And nothing while the first fetch is out, which is a different silence. A request in flight
// is not a state to report -- and the session that meters nothing is exactly the one this
// cannot yet tell apart, so "5-hour usage: checking" appeared under an echo session for half a
// second and was then taken away. A row that has to be withdrawn is worse than one that
// arrives late.
if (usage is SessionUsage.NotMetered || usage is SessionUsage.Waiting) {
return return
} }
@@ -171,9 +186,10 @@ fun SessionUsageBar(usage: SessionUsage, modifier: Modifier = Modifier) {
// Words, not a colour and not an empty bar: every one of these is a different kind of // Words, not a colour and not an empty bar: every one of these is a different kind of
// answer from "this much is used", and only words carry a difference in kind. // answer from "this much is used", and only words carry a difference in kind.
when (val state = usage) { when (val state = usage) {
SessionUsage.NotMetered -> Unit // Both handled above, before the row exists at all.
SessionUsage.NotMetered,
SessionUsage.Waiting -> Unit
is SessionUsage.Unavailable -> UsageNote("5-hour usage unknown -- ${state.why}") is SessionUsage.Unavailable -> UsageNote("5-hour usage unknown -- ${state.why}")
SessionUsage.Waiting -> UsageNote("5-hour usage: checking")
is SessionUsage.Known -> { is SessionUsage.Known -> {
val window = state.windows.firstOrNull { it.kind == "session" } val window = state.windows.firstOrNull { it.kind == "session" }
if (window == null) { if (window == null) {
@@ -234,16 +250,22 @@ private fun fiveHourLabel(window: UsageWindow, now: OffsetDateTime): String {
} }
/** /**
* One machine's snapshot, out of every machine's. * One meter's snapshot, out of every machine's: [setup]'s row for [provider].
*
* Both halves are needed to pick it. A machine can hold more than one meter -- the Claude CLI's
* account and, while a test has one set, an echo session's invented one -- and a snapshot is one
* service on one machine.
* *
* Every way of having *failed* to get numbers is [SessionUsage.Unavailable] with the reason in it. * Every way of having *failed* to get numbers is [SessionUsage.Unavailable] with the reason in it.
* None of them may look like zero, and none may look like [SessionUsage.NotMetered], which is the * None of them may look like zero, and none may look like [SessionUsage.NotMetered], which is the
* machine having no quota rather than the question going unanswered. * machine having no quota rather than the question going unanswered.
*/ */
fun usageFor(snapshots: List<UsageSnapshot>, setup: String): SessionUsage { fun usageFor(snapshots: List<UsageSnapshot>, setup: String, provider: String): SessionUsage {
// No snapshot at all means the backend never asked, which it only does for a machine with // No snapshot at all means the backend never asked, which it only does where there is nothing
// nothing metered on it. That is a different answer from having asked and failed. // to ask about. That is a different answer from having asked and failed.
val mine = snapshots.firstOrNull { it.setup == setup } ?: return SessionUsage.NotMetered val mine =
snapshots.firstOrNull { it.setup == setup && it.provider == provider }
?: return SessionUsage.NotMetered
if (mine.state != "ok") { if (mine.state != "ok") {
return SessionUsage.Unavailable(mine.detail ?: mine.state) return SessionUsage.Unavailable(mine.detail ?: mine.state)
} }
@@ -236,6 +236,7 @@ private fun AddSetupDialog(
var address by remember { mutableStateOf("") } var address by remember { mutableStateOf("") }
var identity by remember { mutableStateOf("") } var identity by remember { mutableStateOf("") }
var attachmentsDir by remember { mutableStateOf("") } var attachmentsDir by remember { mutableStateOf("") }
var modelsDir by remember { mutableStateOf("") }
var tested by remember { mutableStateOf<String?>(null) } var tested by remember { mutableStateOf<String?>(null) }
var testing by remember { mutableStateOf(false) } var testing by remember { mutableStateOf(false) }
@@ -250,6 +251,7 @@ private fun AddSetupDialog(
port = typedPort, port = typedPort,
identityFile = identity.trim().ifEmpty { null }, identityFile = identity.trim().ifEmpty { null },
attachmentsDir = attachmentsDir.trim().ifEmpty { null }, attachmentsDir = attachmentsDir.trim().ifEmpty { null },
modelsDir = modelsDir.trim().ifEmpty { null },
) )
} }
@@ -293,6 +295,14 @@ private fun AddSetupDialog(
label = { Text("Folder for attached files (optional)") }, label = { Text("Folder for attached files (optional)") },
singleLine = true, singleLine = true,
) )
// Where that machine's GGUFs are, for a llama.cpp session on it. Blank means
// the same place this backend keeps its own downloads, read on that machine.
OutlinedTextField(
value = modelsDir,
onValueChange = { modelsDir = it },
label = { Text("Folder for models (optional)") },
singleLine = true,
)
tested?.let { tested?.let {
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
Text(it, style = MaterialTheme.typography.bodySmall) Text(it, style = MaterialTheme.typography.bodySmall)
@@ -61,18 +61,31 @@ 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.
var spawnError by remember { mutableStateOf<String?>(null) } var spawnError by remember { mutableStateOf<String?>(null) }
// Downloaded models, for a llama provider to choose between. Kept separate from the setups: a // The models on the *chosen machine*, for a llama provider to choose between. Kept separate
// Claude session needs none, so failing to list them must not stop the screen rendering. // from the setups: a Claude session needs none, so failing to list them must not stop the
// screen rendering. Refetched when the machine changes, because a model is a file on one
// machine -- see [fetchSetupModels].
var models by remember { mutableStateOf<List<LocalModel>>(emptyList()) } var models by remember { mutableStateOf<List<LocalModel>>(emptyList()) }
var modelKey by remember { mutableStateOf<String?>(null) } var modelKey by remember { mutableStateOf<String?>(null) }
var contextSize by remember { mutableStateOf("") } var contextSize by remember { mutableStateOf("") }
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) }
@@ -83,9 +96,6 @@ fun SpawnScreen(
} catch (e: ApiException) { } catch (e: ApiException) {
LoadState.failed(e) LoadState.failed(e)
} }
models =
runCatching { withContext(Dispatchers.IO) { fetchModels(settings).local } }
.getOrDefault(emptyList())
} }
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp)) { Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp)) {
@@ -115,6 +125,17 @@ fun SpawnScreen(
is LoadState.Loaded -> state.value is LoadState.Loaded -> state.value
} }
val setup = setups.firstOrNull { it.name == setupName } val setup = setups.firstOrNull { it.name == setupName }
// Whichever machine is chosen now, asked again when that changes. The old machine's list
// is dropped first rather than left on screen: a file name from another machine looks
// exactly like one from this one.
LaunchedEffect(setup?.id) {
models = emptyList()
modelKey = null
val id = setup?.id ?: return@LaunchedEffect
models =
runCatching { withContext(Dispatchers.IO) { fetchSetupModels(settings, id) } }
.getOrDefault(emptyList())
}
val current = setup?.providers?.firstOrNull { it.name == providerName } val current = setup?.providers?.firstOrNull { it.name == providerName }
// Only the Claude CLI has models, a working directory and permission modes; keying the // Only the Claude CLI has models, a working directory and permission modes; keying the
// extra fields on the kind rather than the provider name keeps a second Claude provider // extra fields on the kind rather than the provider name keeps a second Claude provider
@@ -175,12 +196,13 @@ fun SpawnScreen(
) )
if (isLlama) { if (isLlama) {
// A llama session names one of the models this backend has downloaded, so the choice is // A llama session names one of the models on the machine it will run on, so the
// that list rather than free text -- a name that is not on disk is a session that // choice is that list rather than free text -- a name that is not on that machine's
// cannot start. // disk is a session that cannot start.
if (models.isEmpty()) { if (models.isEmpty()) {
Text( Text(
"No models downloaded yet. Get one from the Models screen first.", "No models on ${setup?.name ?: "this machine"}. The Models screen downloads " +
"to the backend; another machine needs the file put there itself.",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
@@ -251,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))
@@ -268,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
@@ -280,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 =
@@ -0,0 +1,27 @@
package com.example.aiapp
/**
* Where one transcript lives: a session's own, or one of its subagents'.
*
* The single mechanism [fetchTranscript], [EventStream], [TranscriptSource] and
* [TranscriptCache.session] all take, rather than each growing its own branch between a session and
* a subagent -- see SUBAGENTS.md's "Phone" and "Wire shape". A caller that has only a session id
* builds one with the one-argument constructor; a subagent's screen supplies both ids.
*/
data class TranscriptAddress(val sessionId: String, val subagentId: String? = null) {
/** The URL segment naming this transcript, before `/transcript` or `/events`. */
val urlPath: String
get() =
if (subagentId == null) "sessions/$sessionId"
else "sessions/$sessionId/subagents/$subagentId"
/**
* Where this transcript's cache lives on the phone, relative to the cache root.
*
* A subagent's nests under its session's directory rather than sitting beside it, so deleting a
* session's cache directory takes its subagents' with it -- the same one-way door the server's
* own storage describes.
*/
val cachePath: String
get() = if (subagentId == null) sessionId else "$sessionId/subagents/$subagentId"
}
@@ -35,8 +35,15 @@ class TranscriptCache(
private val root: File, private val root: File,
private val warn: (String) -> Unit = { Log.w("ai-app", it) }, private val warn: (String) -> Unit = { Log.w("ai-app", it) },
) { ) {
/** The cache for one session, whether or not anything has been stored for it yet. */ /**
fun session(id: String): SessionCache = SessionCache(File(root, id), warn) * The cache for one transcript, whether or not anything has been stored for it yet.
*
* A subagent's [TranscriptAddress.cachePath] nests it under its session's directory, so
* deleting the session (below) takes its subagents' caches with it -- there is no separate
* purge for one.
*/
fun session(address: TranscriptAddress): SessionCache =
SessionCache(File(root, address.cachePath), warn)
/** /**
* Deletes every session directory not in [ids], called after a successful list fetch. The path * Deletes every session directory not in [ids], called after a successful list fetch. The path
@@ -175,6 +175,18 @@ sealed class TranscriptItem {
val preTokens: Long?, val preTokens: Long?,
val postTokens: Long?, val postTokens: Long?,
) : TranscriptItem() ) : TranscriptItem()
/**
* The account ran out of quota, so the turn stopped here.
*
* A divider rather than an error: nothing failed, and what a reader scrolling back needs from
* it is the same thing a clear or a compaction gives them -- why the conversation stops at this
* line.
*
* [resetsAt] is epoch seconds and null where the session was told nothing, which is a state the
* row has words for rather than a time it invents.
*/
data class LimitNote(override val seq: Long, val resetsAt: Double?) : TranscriptItem()
} }
/** /**
@@ -458,6 +470,7 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
} else { } else {
items + TranscriptItem.ImageItem(entry.seq, event.ref) items + TranscriptItem.ImageItem(entry.seq, event.ref)
} }
is SessionEvent.LimitReached -> items + TranscriptItem.LimitNote(entry.seq, event.resetsAt)
is SessionEvent.Cleared -> items + TranscriptItem.ClearedNote(entry.seq) is SessionEvent.Cleared -> items + TranscriptItem.ClearedNote(entry.seq)
is SessionEvent.Compacted -> is SessionEvent.Compacted ->
items + TranscriptItem.CompactedNote(entry.seq, event.preTokens, event.postTokens) items + TranscriptItem.CompactedNote(entry.seq, event.preTokens, event.postTokens)
@@ -18,7 +18,7 @@ import java.util.concurrent.atomic.AtomicReference
*/ */
class TranscriptSource( class TranscriptSource(
private val settings: ServerSettings, private val settings: ServerSettings,
private val sessionId: String, private val address: TranscriptAddress,
val cache: SessionCache, val cache: SessionCache,
) { ) {
private val stream = AtomicReference<EventStream?>(null) private val stream = AtomicReference<EventStream?>(null)
@@ -65,7 +65,7 @@ class TranscriptSource(
val tail = cache.tail() ?: return false val tail = cache.tail() ?: return false
// `before = seq + 1` is the newest event with seq <= the cursor, which is the event *at* // `before = seq + 1` is the newest event with seq <= the cursor, which is the event *at*
// the cursor when the server still has one there. // the cursor when the server still has one there.
val answer = fetchTranscript(settings, sessionId, before = tail.seq + 1, limit = 1) val answer = fetchTranscript(settings, address, before = tail.seq + 1, limit = 1)
val matches = val matches =
answer.size == 1 && answer.size == 1 &&
try { try {
@@ -83,7 +83,7 @@ class TranscriptSource(
*/ */
suspend fun fetchOpening(): List<SeqEvent> { suspend fun fetchOpening(): List<SeqEvent> {
DebugStats.count("transcript page from server") DebugStats.count("transcript page from server")
val page = fetchTranscript(settings, sessionId, limit = OPENING_WINDOW) val page = fetchTranscript(settings, address, limit = OPENING_WINDOW)
page.forEach { (line, entry) -> cache.append(line, entry.seq) } page.forEach { (line, entry) -> cache.append(line, entry.seq) }
cache.flush() cache.flush()
return page.map { it.second } return page.map { it.second }
@@ -108,7 +108,7 @@ class TranscriptSource(
val page = val page =
fetchTranscript( fetchTranscript(
settings, settings,
sessionId, address,
before = before, before = before,
limit = limit, limit = limit,
coalesce = coalesce, coalesce = coalesce,
@@ -131,7 +131,7 @@ class TranscriptSource(
* well lose. * well lose.
*/ */
fun follow(after: Long, onOpen: () -> Unit, onReset: () -> Unit, onEvent: (SeqEvent) -> Unit) { fun follow(after: Long, onOpen: () -> Unit, onReset: () -> Unit, onEvent: (SeqEvent) -> Unit) {
val opened = EventStream(settings, sessionId) val opened = EventStream(settings, address)
stream.getAndSet(opened)?.close() stream.getAndSet(opened)?.close()
try { try {
opened.run(after, onOpen, onReset) { raw, entry -> opened.run(after, onOpen, onReset) { raw, entry ->
@@ -0,0 +1,32 @@
package com.example.aiapp
import java.time.ZoneId
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* What the transcript says where a session ran out of quota.
*
* The pair worth a test is the one that reads the same when it goes wrong: a reset time that
* arrived and one that never did. The second must not turn into a plausible-looking time, because a
* reader has no way of telling an invented one from a reported one.
*/
class LimitRowTest {
private val utc = ZoneId.of("UTC")
@Test
fun `a reported reset time is shown as a time`() {
// 2026-09-05T12:00:00Z. Asserted as a prefix and the clock reading rather than as the
// whole string: the platform's own short-time format is what this asks for, and it
// differs by JDK and locale down to which space character separates the meridiem.
val summary = limitSummary(1_788_609_600.0, utc)
assertTrue(summary.startsWith("Usage limit reached • resets "), summary)
assertTrue(summary.contains("12:00"), summary)
}
@Test
fun `a limit with no reset time says only what is known`() {
assertEquals("Usage limit reached", limitSummary(null, utc))
}
}
@@ -23,7 +23,7 @@ class TranscriptCacheTest {
private fun cache() = TranscriptCache(File(temp, "v1/host_8443")) { said += it } private fun cache() = TranscriptCache(File(temp, "v1/host_8443")) { said += it }
private fun session(id: String = "s") = cache().session(id) private fun session(id: String = "s") = cache().session(TranscriptAddress(id))
private fun line(seq: Long, type: String = "toolStart") = private fun line(seq: Long, type: String = "toolStart") =
"""{"seq":$seq,"ts":1.5,"type":"$type","id":"x"}""" """{"seq":$seq,"ts":1.5,"type":"$type","id":"x"}"""
+19
View File
@@ -60,6 +60,25 @@ marked **DEFERRED** are ones the agent chose not to decide alone.
flagged here because it is the first half of something Iris explicitly flagged here because it is the first half of something Iris explicitly
asked to see before P1. asked to see before P1.
- **P0's iris half is also built and smoke-tested on the emulator,
2026-09-05.** A new `bench` Cargo feature on `iris-android-app`, on top
of `transcript-screen`: the same checked-in fixture (`include_str!`, no
asset pipeline needed), the same 24-swipe scroll loop animated through
`List::scroll` and the same 400-event/20s streaming phase through
`fold_event`, "Run benchmark"/"Copy report" as named accessible
controls, and the same three added report fields (process CPU time,
peak RSS, battery current) via direct JNI calls
(`bench_jni.rs::PlatformHandle`) since `android_view` has no
`BatteryManager`/`ClipboardManager` wrapper of its own. One small public
API addition to get there: `AndroidAppState::platform_ready` (`IRIS.md`),
a default-no-op lifecycle hook handing an implementor a `JavaVM` +
`GlobalRef` it can call Java through from any thread. Packaged with a
new `release` build type on `iris-android-app`'s own Gradle project
(there was previously only `debug`), signed with the same key
`app/build-apk.sh` generates. Smoke run and the full report are in
RUST.md's P0 box; not attempted this pass: the real on-phone runs and
Iris's pass/fail call, which is the actual gate.
- **The intermittent touch-scroll dropout is root-caused and fixed: a - **The intermittent touch-scroll dropout is root-caused and fixed: a
missed `ACTION_DOWN` hit-test, not the previously-suspected coalesced missed `ACTION_DOWN` hit-test, not the previously-suspected coalesced
first `ACTION_MOVE`.** Diagnosed by temporary logcat tracing of every first `ACTION_MOVE`.** Diagnosed by temporary logcat tracing of every
+23
View File
@@ -8,6 +8,29 @@ capability that moved. Small and trivial changes do not go here.
An entry gives the date, what changed, why, and a short before/after where An entry gives the date, what changed, why, and a short before/after where
it helps judge the change without the session that made it. Newest first. it helps judge the change without the session that made it. Newest first.
## 2026-09-05: `AndroidAppState::platform_ready` (RUST.md's P0 box, iris half)
Added a second, optional lifecycle method to `iris::android::AndroidAppState`
(`iris/src/android/view.rs`), called once from `new_peer` right after `new`:
```rust
fn platform_ready(&mut self, rsc: &mut AndroidRsc<Self>, vm: JavaVM, view: GlobalRef) {}
```
Default does nothing, so every existing implementor (`Client`,
`TranscriptClient`) is unaffected. It exists for a caller that needs to call
into Java itself beyond what a `RequestRedraw` handle already covers --
P0's bench build (`iris-android-app`'s new `bench` feature,
`bench_client.rs`/`bench_jni.rs`) uses it to hold a `JavaVM` + `GlobalRef`
to the view so its "Copy report" control and once-a-second battery sampler
can call `BatteryManager`/`ClipboardManager` through the view's own
`Context`, from a background tokio task as well as the UI thread. `new`
itself was not extended with these two parameters: most implementors need
nothing here, and `new`'s job is building the widget tree, not holding a
platform handle. `vm`/`view` are independent handles from the ones
`new_peer` keeps for its own `RequestRedraw` (a fresh `get_java_vm`/
`new_global_ref` each), so storing them has no effect on that mechanism.
## 2026-09-05 (later still): `iris_core::device_limits()`, and iris no longer requests compute-shader limits ## 2026-09-05 (later still): `iris_core::device_limits()`, and iris no longer requests compute-shader limits
New public function, `iris_core::device_limits() -> wgpu::Limits`. Why: New public function, `iris_core::device_limits() -> wgpu::Limits`. Why:
+12
View File
@@ -105,6 +105,18 @@ order and what "done" looks like. Tick and date them in place.
were exactly the same root cause measured two different ways. Frame 2 were exactly the same root cause measured two different ways. Frame 2
now reports 0 (see the numbers above); not a separate fix. now reports 0 (see the numbers above); not a separate fix.
- [ ] **A read-only text display has no widget of its own — P0's bench
report area is a `TextEdit` standing in for one (2026-09-05).** The only
way to get selectable text on screen today is `.editable(...)` plus
`.attr::<Selectable>(())` (`Selectable` is only implemented for
`TextEdit`, `iris/src/attr.rs`), which also makes the field focusable —
tapping the bench report opens the soft keyboard over text nothing lets
you type into. Harmless for a bench-only debug screen (not fixed this
pass), but a real "selectable, not editable" text primitive would
remove the keyboard side effect and is worth having before another
screen wants the same thing (P1's own transcript rows already read
their content from a `TextEdit` for the same reason).
## Build ## Build
- [x] **Benchmarks**, not unit tests, run on demand (2026-09-05; a - [x] **Benchmarks**, not unit tests, run on demand (2026-09-05; a
+147 -10
View File
@@ -147,12 +147,37 @@ turn.
Spawn: `claude -p --verbose --input-format stream-json --output-format Spawn: `claude -p --verbose --input-format stream-json --output-format
stream-json --permission-mode <mode>` in the chosen working directory, plus stream-json --permission-mode <mode>` in the chosen working directory, plus
`--model`. Wire-format notes are pinned against CLI 2.1.237 in `--model` and, where one has been chosen, `--effort`. Wire-format notes are
`session/claude.rs`'s module doc: permissions need the hidden pinned against CLI 2.1.237 in `session/claude.rs`'s module doc: permissions
`--permission-prompt-tool stdio` flag, AskUserQuestion answers ride need the hidden `--permission-prompt-tool stdio` flag, AskUserQuestion answers
`updatedInput.answers` keyed by question text, and `set_model`/`interrupt` ride `updatedInput.answers` keyed by question text, and `set_model`/`interrupt`
are control requests. are control requests.
**The thinking level is settled at launch** (added 2026-09-04, because it is
the largest saving available on a long session: output is about an eighth of
what a session costs and thinking is the bulk of output, against the ~1.5% that
is prose). The CLI's only two setting control requests are `set_model` and
`set_permission_mode` -- checked against the 2.1.258 binary -- so there is no
way to ask a running process to think differently. `set_session_effort` is
therefore shaped like `set_session_cwd` rather than like `set_session_model`:
it records the level and **stops the process**, and the next message or Start
launches one that has it. It lives in the session settings dialog beside the
working directory for that reason, not on the session bar beside the model and
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 **`--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
@@ -169,10 +194,33 @@ deliberate and easy to undo by accident:
when the process restarts. That leaves the Claude driver as the odd one when the process restarts. That leaves the Claude driver as the odd one
out rather than this one — the CLI's memory is a cache in front of the same out rather than this one — the CLI's memory is a cache in front of the same
transcript. Resolve any inconsistency in this direction. transcript. Resolve any inconsistency in this direction.
- **A llama session on an ssh host is refused.** The model is reached over - **A llama session runs on whatever machine its setup names** (2026-09-04,
HTTP and forwarding that port is not built, so refusing beats silently the last of phase 5). A transport is "run this" plus "reach this port", and
talking to the wrong machine. A transport is "run this" plus "reach this the second half is `Transport::reserve_port` — the port the server binds
port", and only the first half exists. *there* and the port that reaches it *here*, the same number locally —
carried by `Launch::reaching` onto the connection that already runs the
command. `llama-server` binds loopback on the far machine, so nothing is
served to its network. The far port is a guess from a range below the
ephemeral one, because no portable way to ask a machine for a free port
avoids racing the bind anyway; a collision is not silent, since the server
fails to bind and the readiness poll reports what its log said.
- **The model file lives on the machine that serves it** (2026-09-04). Each
setup names its own models directory (`SshConfig::models_dir`, default
`~/.local/share/ai-app/models` expanded *there*), and a spawn resolves the
key on that machine — one round trip answering "at /abs/path" or "missing",
so a model that is not there is refused at the spawn rather than becoming a
server that never becomes ready. The spawn screen offers
`GET /setups/{id}/models`, that machine's list, rather than `GET /models`,
which is this backend's downloads. Downloading *to* another machine is
deliberately not built: a multi-gigabyte transfer with no progress
anywhere, and the file gets there however anything else on that machine
did.
- **The readiness poll watches the process, not only the port.** A model that
will not load, a port already taken, a flag an older build does not know:
all exit within a second and none will ever answer `/health`, so waiting
out the 300s timeout turned the server's own account of the problem into
"gave up". The failure carries the tail of `llama-server.log`, which on a
remote session is the only copy anybody reading the phone can see.
### Models (2026-08-28) ### Models (2026-08-28)
@@ -203,6 +251,13 @@ deliberate and easy to undo by accident:
A driver says what to run; something above it turns that into a process. A driver says what to run; something above it turns that into a process.
Otherwise transport knowledge sits inside a translator whose job is a wire Otherwise transport knowledge sits inside a translator whose job is a wire
format, and every future driver has to remember to do the same. format, and every future driver has to remember to do the same.
- **A forwarded launch gets a pty and every other one does not** (measured
2026-09-04). Killing the ssh client ends a CLI because it closes the stdin
that CLI is reading; `llama-server` never reads its stdin, so the same kill
left it running on the far machine with the model loaded — one orphan per
stopped session. With `-tt` the far side takes SIGHUP when the connection
goes. Its log then arrives through a line discipline, which nothing parses.
`-T` stays everywhere else, where a pty would rewrite the JSONL.
- **`command -v` follows ssh's non-login PATH**, which is narrower than an - **`command -v` follows ssh's non-login PATH**, which is narrower than an
interactive shell's, so a binary somewhere unusual is invisible to interactive shell's, so a binary somewhere unusual is invisible to
discovery. Point `command` at an absolute path. discovery. Point `command` at an absolute path.
@@ -499,6 +554,25 @@ rate-limited bucket). Poll at ≥180 s, only while a Claude session exists or
the usage screen is open, and cache the last answer. It is undocumented, so the usage screen is open, and cache the last answer. It is undocumented, so
`usage.rs` treats every field as optional and degrades rather than erroring. `usage.rs` treats every field as optional and degrades rather than erroring.
**Per provider, not per machine (2026-09-04).** A machine is not what is
metered; the provider a session runs is. One machine offers echo, the Claude
CLI and a local model side by side, and only the second spends anything — so
pairing a session with a snapshot by machine alone drew the CLI's five-hour
window under every echo session on it, a quota that session cannot spend. A
session now names its meter (`usageProvider`, from
`DriverKind::usage_provider`, which `usage::providers_for` reads too, so the
two lists cannot disagree) and `GET /usage` is matched on machine *and*
provider. `None` is a session that meters nothing, and the phone draws
nothing at all for it — not a zero, and not "unknown".
`DriverKind::Echo` names a meter of its own that exists only when a test has
asked for one: `/usage` in an echo session sets an invented answer
(`usage::Fixture`), and with none set there is no snapshot and no bar. That
is what makes those screens' states reachable — a number near the top, a
window between blocks with no reset time, a machine nobody logged into, one
that could not be reached — without spending real quota to arrange them,
which is why none of them had ever been looked at.
**Per machine, not per backend (2026-08-29).** The credential store that **Per machine, not per backend (2026-08-29).** The credential store that
matters is the one on the machine the session runs on, because that is the matters is the one on the machine the session runs on, because that is the
account being billed — and in the layout this aims at, `ai-server` is on the account being billed — and in the layout this aims at, `ai-server` is on the
@@ -522,6 +596,68 @@ always running. So absent means **not running**, and only a timestamp that
arrives and cannot be parsed is unknown. `WindowEnd` in `ResetCountdown.kt` arrives and cannot be parsed is unknown. `WindowEnd` in `ResetCountdown.kt`
is the one rule both readers go through. is the one rule both readers go through.
### Auto-resume (2026-09-05)
**A session may pick itself back up when the account's usage limit lifts.**
Off unless somebody switched that session to it, because it spends quota the
moment quota exists and does so with nobody looking — that is not a thing a
default may decide. It sends one message, `continue` unless another was
typed, and then it is done; there is no retry loop around the conversation
itself.
**Running out of quota is a state, not an error.** `Event::LimitReached`
carries the dialect's reset time where it gave one, and recognising it
belongs to the driver — the Claude CLI ends the turn with `is_error` and
`Claude AI usage limit reached|1788546972`, and nothing above the driver
matches on a string. The transcript draws it as a divider, like a clear or a
compaction: what a reader scrolling back wants from it is why the
conversation stops at that line.
**The schedule is a plan to ask, never a plan to send.** Every reset time
available here is untrustworthy in the direction that matters: the dialect's
is written when the turn fails, and the endpoint's moves when the window
does. So the wait ends in a question to `usage.rs`, and only `ok` with no
window at 100% sends anything. A window still spent reschedules to *its own*
reset time — which is what makes a limit that lifts later than promised wait
longer, and one that lifts sooner resume sooner. A meter that cannot be
asked at all is a longer wait too, never a send: "we could not find out"
must not be able to produce the same action as "there is room".
Bounded, because something has to be: a day after the limit was hit the wait
stops and says so in the session's own transcript. A machine that can never
be asked would otherwise be retried for ever with nothing on screen saying
so.
The schedule is persisted on the session (`resume: Some(ScheduledResume)`),
not held in memory: a five-hour window routinely outlasts a backend restart,
and a wait forgotten across one is a session that silently never comes back.
`resume.rs` is the top layer — it holds the manager and the monitor and
neither holds it — which is what lets the decision be a pure function of a
snapshot and a clock. The pump reports limits downward on a broadcast, for
the reason `Shared` exists: the pump runs underneath the manager.
**Exercised with echo, never with a real account.** `/limit [minutes]` in an
echo session reports the same event a real driver does, and `/usage` sets
what the meter answers — deliberately two commands, because the two
disagreeing is the state the whole design is about. The loop was driven end
to end that way on 2026-09-05: the wait moved from the dialect's two minutes
to the meter's seven when the meter changed its mind, and the message went
out on the first check after the meter came back under the limit.
### Subagents (2026-09-05)
**A subagent is a second transcript owned by a session, in the same event
model, with no process and no controls of its own.** Full design and wire
shape in `SUBAGENTS.md`, kept separate because the app half is being built
against it in parallel and it is the shared contract between the two. The
one-paragraph reason: a session's Task-tool helpers already speak the common
event model on the parent's own stdout (each line carrying
`parent_tool_use_id`), so giving each one its own small transcript — same
file format, same paging routes, same SSE stream, reused by addressing rather
than by copying — costs a routing step in the translator and a registry
(`session/subagent.rs`) rather than a second session type with a driver, a
process and a config entry it does not need.
### HTTP surface ### HTTP surface
**`routes.rs`'s module doc comment is the table.** REST for actions, one SSE **`routes.rs`'s module doc comment is the table.** REST for actions, one SSE
@@ -800,8 +936,9 @@ Noticed and deliberately not fixed, so they are not re-found from scratch.
Phases 13 (the skeleton pipe, the full Claude driver, the usage screen) done Phases 13 (the skeleton pipe, the full Claude driver, the usage screen) done
2026-08-24. Phase 4 (llama.cpp: model browsing, downloads, and `llama-server` 2026-08-24. Phase 4 (llama.cpp: model browsing, downloads, and `llama-server`
through its OpenAI-compatible endpoint) and phase 5 (ssh) done 2026-08-28. through its OpenAI-compatible endpoint) and phase 5 (ssh) done 2026-08-28,
The file explorer and the transcript cache followed in September. What is except for the remote `llama-server` and its port forward, which landed
2026-09-04. The file explorer and the transcript cache followed in September. What is
left is real-phone/WireGuard bring-up, which is operational rather than code. left is real-phone/WireGuard bring-up, which is operational rather than code.
Each phase ended runnable and verified against the real thing. The backend Each phase ended runnable and verified against the real thing. The backend
+174
View File
@@ -3541,6 +3541,180 @@ device.
this session was told not to touch `iris/`), and anything past the this session was told not to touch `iris/`), and anything past the
emulator — the actual on-phone runs and Iris's pass/fail call. emulator — the actual on-phone runs and Iris's pass/fail call.
**iris half: done, 2026-09-05.** A `bench` Cargo feature on
`iris-android-app`, built on top of `transcript-screen`
(`bench = ["transcript-screen", "dep:libc", "dep:tokio"]`,
`iris/android-app/Cargo.toml`), gives `lib.rs`'s `ActiveClient`
priority a third `AndroidAppState` (`bench_client::BenchClient`)
over `TranscriptClient` when both features are listed together --
matching the exact build command below, which lists both.
**Fixture.** `include_str!("../../../app/bench-fixture/assets/
transcript.jsonl")` (1,915,760 bytes) at compile time -- no asset
pipeline needed the way the Compose half's Gradle source set does.
`bench_client::parse_fixture` splits the same way `BenchFixture.kt`
does: the first 3,200 non-blank lines parsed as `serde_json::Value`s
and folded once through `client_core::transcript_fold::fold_page`
(the real fold a `/transcript` page goes through), the rest parsed
as `event_model::SeqEvent`s and held back as the streaming tail.
`build.rs` (transcript-screen's own) now exits early under `bench`
before requiring a live server's host/port/token/CA -- `BenchClient`
never calls `build_transport()`, so that requirement made no sense
for a build that talks to nothing.
**"Run benchmark" (`.label("Run benchmark")`) and "Copy report"
(`.label("Copy report")`)** sit in a fixed bar above the transcript;
a selectable `TextEdit` (`.attr::<Selectable>(())`, the same
attribute the composer field uses) below it shows the report text.
Pressing "Run benchmark" resets `FrameReport`, then drives
`List::scroll` in ~60Hz steps (`ANIM_STEP_MS = 16`) to animate each
900px/200ms swipe rather than jumping it -- iris's `List` has no
built-in tween the way `animateScrollBy(tween(...))` gives Compose,
so this is the one place the two backends' bench code has to differ
in shape rather than only in numbers -- through the same
`rsc.tasks.redraw_handle()` + manual `request_redraw()` per step
`transcript_client.rs` already established (a `Tasks::spawn`d
future's *automatic* redraw fires once, after the whole future
completes, which would show nothing moving until the run ends).
After the scroll loop, `List::jump_to_end()` pins to the newest
content (matching `stream-bench.sh`'s "Jump to latest" tap), then
400 fixture events replay at 20/s through `fold_event` -- the same
fold path a live SSE frame takes in `transcript_client.rs`'s own
`apply_event` -- each one triggering `rebuild_transcript`'s full
`transcript_ui::build_tree` rebuild, same tradeoff as
`TranscriptClient`/`desktop-app`. A battery sampler runs
concurrently on its own `tokio::spawn`d task (not through
`ctx.update`, since a JNI battery read needs no widget-tree access),
attaching whichever thread it runs on via a stored `JavaVM` --
`AndroidAppState::platform_ready` (new, `IRIS.md`) is what hands
`bench_client.rs` that `JavaVM` + a `GlobalRef` to the view, since
neither was reachable from `AndroidAppState::new` before this box.
**Report fields.** `FrameStats`'s existing `Display` (frames, janky
%, p50/p90/p99, worst, and I5's own `cpu_p50`/`gpu_wait_p50` CPU/GPU
split) plus a `bench:`-shaped tail this box added: process CPU time
via `libc::getrusage(RUSAGE_SELF)` (user+system time; chosen over
parsing `/proc/self/stat` by hand to avoid assuming `USER_HZ`), peak
RSS from `/proc/self/status`'s `VmHWM` (same source `BenchRun.kt`
reads), and battery current sampled once a second via
`BatteryManager.getIntProperty(BATTERY_PROPERTY_CURRENT_NOW)`
through direct JNI calls (`bench_jni.rs`'s `PlatformHandle` --
`android_view::context`'s own `Context`/`Resources` wrappers have no
`getSystemService`, so this calls it directly rather than growing
that crate's wrapper for two one-off calls). `0`/`Integer.MIN_VALUE`
read as "unavailable" rather than folded into the average, matching
`BatterySampler`'s own rule and UI_RULES.md's "never present an
inferred value as a measured one." The report is logged under the
existing `iris-android-app` logcat tag on a line starting `iris
bench report:` (grep-able the same way `transcript_client.rs`'s
"Frame report" control already is), shown in the on-screen
`TextEdit`, and copied to the system clipboard by "Copy report"
through `ClipboardManager.setPrimaryClip` (`bench_jni.rs`, same
`PlatformHandle`).
**Build commands, all clean this pass:**
- `cargo fmt --all -- --check` (iris workspace) and
`cd iris/android-app && cargo fmt --all -- --check`: clean.
- `cargo clippy --workspace --all-targets` (iris workspace): clean
(only the pre-existing `wgpu`/`winit`/`naga` future-incompat
notice).
- `cargo test --workspace` (iris workspace): 39 + 8 + 10 = the same
pre-existing counts, all passing, unaffected by this box (it
touched no logic under test there beyond `AndroidAppState`'s new
default no-op method).
- `cargo ndk -t x86_64 -P 26 clippy --features "transcript-screen
force-gles bench" --lib -- -D warnings` (`iris/android-app`):
clean.
- `cargo ndk -t arm64-v8a -P 26 -o app/src/main/jniLibs/ build
--release --features "transcript-screen force-gles bench"`:
clean, `arm64-v8a/libmain.so` produced. The pre-existing "unused
dependency `tabs-ui`" Cargo advisory also appears on a plain
`--features transcript-screen` build with no `bench` (confirmed
by building that combination alone with fake env vars) -- not
something this box introduced, and not a clippy/rustc warning
(AGENTS.md's "keep the build clean" gate is `cargo clippy`, which
stays silent on it).
**Packaging.** No `cargo xtask apk` exists for `iris/android-app`
yet (I2's own Gradle project is the only pipeline), so this reused
that split rather than inventing one: `cargo ndk --release` above
builds the cdylib straight into `app/src/main/jniLibs/`, then a new
`release` build type in `app/build.gradle` (there was previously
only `debug`) packages and signs it --
`AI_APP_KEYSTORE=~/.config/ai-app/release.jks` +
`AI_APP_KEYSTORE_PASSWORD` (the same key `app/build-apk.sh`
generates for the Compose app) via `gradle :app:assembleRelease`,
with `applicationIdSuffix ".bench"` so it installs beside the plain
tabs demo rather than replacing it. `aapt2 dump badging` on the
result: `package: name='dev.iris.android.demo.bench'`, one native
library, `lib/arm64-v8a/libmain.so`. `apksigner verify
--print-certs` shows the same `CN=ai-app` certificate
`compose-bench-arm64.apk` is signed with.
**Emulator smoke run, 2026-09-05.** This checkout's own AVD
(`ai-app-2`) was in use by the session recording I5's clean-scroll
comparison in this same file (its Compose app was in the
foreground, confirmed via `dumpsys window`/`dumpsys activity
processes` before touching anything) -- rather than contend for it
(AGENTS.md's "coordinate with peer agents"), a second,
differently-named AVD was created (`AVD_NAME=ai-app-2-bench emu
up`, `pixel_10`/`android-36`/`google_apis`/`x86_64`, cold boot, host
GPU, no `EMU_GPU=software`), with 12GB of the VM's memory still
available after both were up (this-machine-android's "two are
comfortable" guidance). Installed via `adb -s emulator-5556 install
-r`, launched, driven by `ui-trace record -s emulator-5556 --do
"tap 'Run benchmark'"` (the control resolved by its accessibility
label, per AGENTS.md's "no coordinate" rule), then read back over
`adb logcat`:
iris bench report
frames=372 janky%=56.99 p50=19.5ms p90=219.5ms p99=284.5ms worst=369.3ms (measures redraw-start to after present() is called, not GPU/compositor completion) cpu_p50=0.4ms gpu_wait_p50=13.9ms (redraw-start-to-submit vs. submit-to-after-present)
scroll: 6 cycles (24 swipes), streamed 400/400 fixture events
process CPU time over this run: 24665ms
peak RSS: 224600kB
battery current: mean 900000µA over 21 samples (min 900000, max 900000)
"Copy report" was pressed immediately after and logged `iris bench
report: copied to clipboard` (`ClipboardManager.setPrimaryClip`
succeeded). No crash (`adb logcat`'s `FATAL`/`AndroidRuntime` lines
checked -- only `ui-trace`'s own runtime, unrelated), process alive
throughout (`dumpsys activity processes`), 400/400 stream events
confirmed sent.
Read this the same way the Compose half's own box already asks to
read its number: this is software-rasterised (well, GLES-over-virgl
under `force-gles`, per I5's "Where iris's frame time goes")
emulator output, "the harness runs end to end and produces every
field P0 asked for," not a phone number -- and the battery current
is again the emulator's fixed 900000µA mocked charger reporting a
constant, exactly what the Compose box's own run found, not a real
battery answering. `cpu_p50=0.4ms` (iris's own per-frame CPU work)
against a much larger `gpu_wait_p50`/`p50` again matches I5's "Where
iris's frame time goes" finding under real GPU rendering (`-gpu
host`, `force-gles`) -- the frame-time budget here is dominated by
the driver/compositor wait, not by iris's layout or primitive
building, though this run's `janky%`/`p90`/`p99` are considerably
worse than that earlier isolated pass, most likely the cost of this
AVD's very first cold boot plus running two emulators on this VM at
once (a fair comparison against Compose would need both apps run
back-to-back on the same freshly-booted device, not attempted this
pass since the second AVD was torn down immediately after per
AGENTS.md's "stop yours when you are done with it").
Copied to `~/host/bench/iris-bench-arm64.apk` (15,445,468 bytes) and
`~/host/bench/README.md`'s "iris" section filled in (install, open,
tap "Run benchmark", read the report from the on-screen text or
logcat, tap "Copy report", paste back).
**Not done this pass**: the actual on-phone runs and Iris's
pass/fail call between the two reports (P0's own pass condition) --
that needs Iris's phone, which this session has no access to.
`iris/src/android/view.rs` was touched (`AndroidAppState::
platform_ready`, `new_peer`'s wiring) -- confirmed to not be one of
the three files the concurrent `device_limits()` work on this
branch was using (`iris/core/src/render/mod.rs`,
`iris/src/android/render.rs`, `iris/src/default/render.rs`).
- [ ] **P1 — session screen parity.** History paging backward (with the - [ ] **P1 — session screen parity.** History paging backward (with the
page-boundary healing `client-core` does not have yet, below), page-boundary healing `client-core` does not have yet, below),
`TranscriptSource`-backed cache/server stitching, jump-to-latest, `TranscriptSource`-backed cache/server stitching, jump-to-latest,
+2
View File
@@ -1768,9 +1768,11 @@ dependencies = [
"client-core", "client-core",
"event-model", "event-model",
"iris", "iris",
"libc",
"log", "log",
"serde_json", "serde_json",
"tabs-ui", "tabs-ui",
"tokio",
"transcript-ui", "transcript-ui",
] ]
+25
View File
@@ -32,6 +32,23 @@ transcript-ui = { path = "../transcript-ui", optional = true }
client-core = { path = "../../client-core", optional = true } client-core = { path = "../../client-core", optional = true }
event-model = { path = "../../event-model", optional = true } event-model = { path = "../../event-model", optional = true }
serde_json = { version = "1", features = ["float_roundtrip"], optional = true } serde_json = { version = "1", features = ["float_roundtrip"], optional = true }
# P0's bench build only (docs/RUST.md): `getrusage(RUSAGE_SELF)` for
# process CPU time, matching `libc::getrusage`'s mention in that box over
# parsing `/proc/self/stat` by hand and assuming `USER_HZ`. Already in the
# workspace's own dependency tree transitively (`iris/Cargo.lock`, pinned
# at 0.2.179) -- this makes it a direct dependency at the same version
# rather than a second, possibly-drifting resolution.
libc = { version = "0.2.179", optional = true }
# P0's bench build only: the scroll animation and the streaming phase are
# both a sequence of `sleep`s inside the async task `rsc.spawn_task` already
# runs on iris's own tokio runtime (`iris/src/task.rs`'s `Tasks::init`), and
# the battery sampler is a second, concurrent task on that same runtime
# (`tokio::spawn`) -- so this crate needs `tokio` directly rather than only
# through `iris`. `rt`+`time` only: no I/O, no macros, nothing this crate
# doesn't call. Version matches the one `iris`'s own dependency tree already
# resolves to (`iris/Cargo.lock`), so there is one copy of the runtime, not
# two.
tokio = { version = "1.53.1", features = ["rt", "time"], optional = true }
[features] [features]
default = ["tabs-screen"] default = ["tabs-screen"]
@@ -41,6 +58,14 @@ transcript-screen = ["dep:transcript-ui", "dep:client-core", "dep:event-model",
# instead of SwiftShader's software Vulkan. See `iris/Cargo.toml`'s own doc # instead of SwiftShader's software Vulkan. See `iris/Cargo.toml`'s own doc
# on the feature this forwards to. # on the feature this forwards to.
force-gles = ["iris/force-gles"] force-gles = ["iris/force-gles"]
# P0's iris half (docs/RUST.md, docs/AGENTS.md's "The rigs"): the same
# checked-in fixture, scroll loop and streaming phase the Compose `bench`
# build type drives, run here against `transcript-ui`'s real screen with no
# server. Depends on `transcript-screen` for `transcript-ui`/`client-core`/
# `event-model` -- `lib.rs`'s `ActiveClient` selection gives this feature
# priority over `transcript-screen`'s own `TranscriptClient` when both are
# listed, which is how this crate's build command names both explicitly.
bench = ["transcript-screen", "dep:libc", "dep:tokio"]
[profile.release] [profile.release]
panic = "abort" panic = "abort"
+30
View File
@@ -19,9 +19,39 @@ android {
versionName = "1.0" versionName = "1.0"
} }
// A release build must be signed, and the key is per machine rather than per repo -- same
// reasoning and the same key as `app/build-apk.sh` (the Compose app): it is what a phone
// recognises the app by, and a secret never lives in a checkout (the mount is shared with an
// untrusted VM). `build-apk.sh` generates this key once and points at it through the
// environment; without it a release build here is unsigned, which is fine for everything
// except installing.
def keystore = System.getenv("AI_APP_KEYSTORE")
signingConfigs {
if (keystore != null) {
release {
storeFile = file(keystore)
storePassword = System.getenv("AI_APP_KEYSTORE_PASSWORD")
keyAlias = "ai-app"
keyPassword = storePassword
}
}
}
buildTypes { buildTypes {
debug { debug {
} }
// P0's iris half (docs/RUST.md's P0 box): the build a phone actually runs. The `.so`
// itself is built separately with `cargo ndk --release --features "transcript-screen
// force-gles bench"` straight into src/main/jniLibs/ (this crate's own Cargo.toml) --
// Gradle here only packages and signs whatever is already there, the same division as the
// debug/tabs-screen build this project started with. `applicationIdSuffix` keeps it
// installable beside a debug build of the tabs demo rather than replacing it.
release {
applicationIdSuffix ".bench"
if (keystore != null) {
signingConfig = signingConfigs.release
}
}
} }
compileOptions { compileOptions {
+8
View File
@@ -22,6 +22,14 @@ fn main() {
if std::env::var_os("CARGO_FEATURE_TRANSCRIPT_SCREEN").is_none() { if std::env::var_os("CARGO_FEATURE_TRANSCRIPT_SCREEN").is_none() {
return; return;
} }
// P0's bench build (docs/RUST.md) opens the checked-in fixture with no
// server at all -- `bench_client.rs` never references the `pinned`
// module this generates, so requiring a live server's host/port/token/
// CA to build it (as plain `transcript-screen` does, below) would be a
// pointless requirement for a build that talks to nothing.
if std::env::var_os("CARGO_FEATURE_BENCH").is_some() {
return;
}
println!("cargo:rerun-if-env-changed=AI_APP_TRANSCRIPT_HOST"); println!("cargo:rerun-if-env-changed=AI_APP_TRANSCRIPT_HOST");
println!("cargo:rerun-if-env-changed=AI_APP_TRANSCRIPT_PORT"); println!("cargo:rerun-if-env-changed=AI_APP_TRANSCRIPT_PORT");
println!("cargo:rerun-if-env-changed=AI_APP_TRANSCRIPT_TOKEN"); println!("cargo:rerun-if-env-changed=AI_APP_TRANSCRIPT_TOKEN");
+418
View File
@@ -0,0 +1,418 @@
//! P0's iris half (docs/RUST.md's P0 box, docs/AGENTS.md's "The rigs"):
//! the same fixture, scroll loop and streaming phase the Compose `bench`
//! build type's `BenchRun.kt`/`BenchFixture.kt` drive, run here against
//! `transcript-ui`'s real screen with no server -- a frame-time comparison
//! that measures the renderer rather than the data or the network.
//!
//! **Reuses `transcript_client.rs`'s shape** (folded items, a full
//! `transcript_ui::build_tree` rebuild per event) with the network half
//! replaced by the checked-in fixture, embedded with `include_str!` --
//! `app/bench-fixture/assets/transcript.jsonl`, 1,915,760 bytes, generated
//! by `app/bench-fixture/generate.py` and never a real transcript (that
//! file's own README). The first 3,200 lines are the opening backlog,
//! folded once through `client_core::transcript_fold::fold_page` exactly
//! as a real `/transcript` page would be; the remaining ~400 are the
//! streaming tail, replayed one at a time through `fold_event` -- the same
//! fold path a live SSE reply arrives on -- by the "Run benchmark"
//! control below.
use crate::bench_jni::PlatformHandle;
use android_view::jni::{JavaVM, objects::GlobalRef};
use client_core::transcript_fold::{TranscriptItem, fold_event, fold_page, group_tool_runs};
use event_model::SeqEvent;
use iris::android::{AndroidAppState, AndroidRsc, AndroidUiState, HasAndroidUiState};
use iris::prelude::*;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
/// bench-fixture/README.md: the first `BACKLOG_COUNT` non-blank lines are
/// the opening window; the rest are the streaming tail. Kept in sync with
/// `BenchFixture.kt`'s identical constant by hand -- both read the same
/// checked-in file, so a mismatch would only mean the two apps' bench
/// builds open a different split of it, not a wrong-vs-right answer.
const BACKLOG_COUNT: usize = 3200;
/// `BenchRun.kt`'s own constants -- kept identical so the two apps' bench
/// runs are the same gesture and the same load, which is the entire point
/// of a shared fixture and a shared scripted loop (P0's pass condition).
const CYCLES: usize = 6;
const SWIPE_PX: f32 = 900.0;
const SWIPE_MS: u64 = 200;
const SWIPE_PAUSE_MS: u64 = 500;
const STREAM_EVENTS_PER_SEC: u64 = 20;
const STREAM_SECONDS: u64 = 20;
/// One animation step's target cadence -- close enough to 60Hz that a
/// `List::scroll` swipe is many small moves rather than one jump, so
/// frames are actually rendered along the way (the point of animating it
/// at all rather than calling `scroll` once per swipe).
const ANIM_STEP_MS: u64 = 16;
const FIXTURE_JSONL: &str = include_str!("../../../app/bench-fixture/assets/transcript.jsonl");
pub struct BenchClient {
ui_state: AndroidUiState,
content: WeakWidget<WidgetPtr>,
report_display: WeakWidget<TextEdit>,
screen: Option<transcript_ui::TranscriptScreen>,
items: Vec<TranscriptItem>,
/// The events not yet streamed -- consumed by `start_benchmark`'s own
/// clone, kept here only as the source a second run would need (the
/// button can be pressed more than once; `running` just stops overlap,
/// not repeat).
stream_tail: Vec<SeqEvent>,
platform: Option<Arc<PlatformHandle>>,
last_report: Option<String>,
running: bool,
}
impl HasAndroidUiState for BenchClient {
fn android_state(&self) -> &AndroidUiState {
&self.ui_state
}
fn android_state_mut(&mut self) -> &mut AndroidUiState {
&mut self.ui_state
}
}
/// Parses the fixture once: `serde_json::Value`s for the backlog
/// (`fold_page` takes a page of raw wire JSON, same as a real
/// `/transcript` response) and folded `SeqEvent`s for the tail (`fold_event`
/// takes one live wire event at a time, same as a real SSE frame).
fn parse_fixture() -> (Vec<serde_json::Value>, Vec<SeqEvent>) {
let lines: Vec<&str> = FIXTURE_JSONL
.lines()
.filter(|line| !line.trim().is_empty())
.collect();
let mut backlog = Vec::with_capacity(BACKLOG_COUNT.min(lines.len()));
let mut stream_tail = Vec::new();
for (i, line) in lines.iter().enumerate() {
let value: serde_json::Value =
serde_json::from_str(line).expect("bench fixture is generated JSON, always valid");
if i < BACKLOG_COUNT {
backlog.push(value);
} else {
let event: SeqEvent = serde_json::from_value(value)
.expect("bench fixture event matches event-model's SeqEvent");
stream_tail.push(event);
}
}
(backlog, stream_tail)
}
fn placeholder<Rsc: HasEvents>(rsc: &mut Rsc, message: &str) -> StrongWidget {
wtext(message.to_string())
.color(Color::WHITE)
.wrap(true)
.pad(16)
.add_strong(rsc)
.any()
}
/// `getrusage(RUSAGE_SELF)`'s user+system time, in ms -- `None` only if
/// the syscall itself fails, which UI_RULES.md's "never present an
/// inferred value as a measured one" says to keep apart from a real (and
/// here, impossible) zero.
fn process_cpu_ms() -> Option<u64> {
// SAFETY: `rusage` is a plain-old-data struct `getrusage` fully
// initialises on success; on failure it is never read.
unsafe {
let mut usage: libc::rusage = std::mem::zeroed();
if libc::getrusage(libc::RUSAGE_SELF, &mut usage) != 0 {
return None;
}
let user_ms = usage.ru_utime.tv_sec as u64 * 1000 + usage.ru_utime.tv_usec as u64 / 1000;
let sys_ms = usage.ru_stime.tv_sec as u64 * 1000 + usage.ru_stime.tv_usec as u64 / 1000;
Some(user_ms + sys_ms)
}
}
/// `VmHWM` from `/proc/self/status` -- the process's peak RSS since it
/// started, in kB. Same source `BenchRun.kt`'s `peakRssLine` reads, so the
/// two reports' numbers mean the same thing.
fn peak_rss_kb() -> Option<u64> {
std::fs::read_to_string("/proc/self/status")
.ok()?
.lines()
.find_map(|line| line.strip_prefix("VmHWM:"))
.and_then(|rest| rest.trim().strip_suffix("kB"))
.and_then(|n| n.trim().parse().ok())
}
fn battery_line(samples: &[i32]) -> String {
if samples.is_empty() {
return " battery current: unavailable on this device".to_string();
}
let mean = samples.iter().map(|&v| v as i64).sum::<i64>() / samples.len() as i64;
let min = samples.iter().min().unwrap();
let max = samples.iter().max().unwrap();
format!(
" battery current: mean {mean}\u{b5}A over {} samples (min {min}, max {max})",
samples.len()
)
}
impl AndroidAppState for BenchClient {
fn new(mut ui_state: AndroidUiState, rsc: &mut AndroidRsc<Self>) -> Self {
let content = WidgetPtr::new().add(rsc);
let loading = placeholder(rsc, "Loading fixture...");
content(rsc).set(loading);
let report_display = wtext("")
.editable(EditMode::MultiLine)
.text_align(Align::LEFT)
.wrap(true)
.size(14)
.color(Color::WHITE)
.attr::<Selectable>(())
.label("Benchmark report")
.add(rsc);
let controls = bench_controls(rsc);
let tree = (
controls,
content.height(rest(2)),
report_display.height(rest(1)).pad(8),
)
.span(Dir::DOWN)
.add_strong(rsc)
.any();
ui_state.set_root(tree);
let mut client = Self {
ui_state,
content,
report_display,
screen: None,
items: Vec::new(),
stream_tail: Vec::new(),
platform: None,
last_report: None,
running: false,
};
let (backlog, stream_tail) = parse_fixture();
client.stream_tail = stream_tail;
match fold_page(&backlog) {
Ok(items) => {
client.items = items;
client.rebuild_transcript(rsc);
}
Err(message) => {
client.show_message(rsc, &format!("Couldn't fold the bench fixture: {message}"))
}
}
client
}
fn platform_ready(&mut self, _rsc: &mut AndroidRsc<Self>, vm: JavaVM, view: GlobalRef) {
self.platform = Some(Arc::new(PlatformHandle::new(vm, view)));
}
fn back_pressed(&mut self, _rsc: &mut AndroidRsc<Self>, _render: &mut UiRenderState) -> bool {
false
}
}
type Rsc = AndroidRsc<BenchClient>;
fn bench_controls(rsc: &mut Rsc) -> WeakWidget {
let run_rect = rect(Color::rgb(40, 70, 40))
.on(
CursorSense::click(),
|ctx: EventIdCtx<'_, Rsc, _, _>, rsc: &mut Rsc| {
ctx.state.start_benchmark(rsc);
},
)
.label("Run benchmark");
let run = (
run_rect,
wtext("Run benchmark").size(18).text_align(Align::CENTER),
)
.stack()
.pad(8)
.add(rsc);
let copy_rect = rect(Color::rgb(50, 50, 60))
.on(
CursorSense::click(),
|ctx: EventIdCtx<'_, Rsc, _, _>, _rsc: &mut Rsc| {
ctx.state.copy_report();
},
)
.label("Copy report");
let copy = (
copy_rect,
wtext("Copy report").size(18).text_align(Align::CENTER),
)
.stack()
.pad(8)
.add(rsc);
(run, copy).span(Dir::RIGHT).height(56).add(rsc)
}
impl BenchClient {
fn show_message(&mut self, rsc: &mut Rsc, message: &str) {
let widget = placeholder(rsc, message);
(self.content)(rsc).set(widget);
self.screen = None;
}
fn rebuild_transcript(&mut self, rsc: &mut Rsc) {
let rows = group_tool_runs(&self.items);
let (screen, tree) = transcript_ui::build_tree(rsc, rows);
(self.content)(rsc).set(tree);
self.screen = Some(screen);
}
fn copy_report(&mut self) {
let Some(report) = &self.last_report else {
log::info!("iris bench report: nothing to copy -- run the benchmark first");
return;
};
let Some(platform) = &self.platform else {
log::info!("iris bench report: no platform handle, can't reach the clipboard");
return;
};
if platform.copy_to_clipboard("iris bench report", report) {
log::info!("iris bench report: copied to clipboard");
} else {
log::info!("iris bench report: clipboard copy failed");
}
}
/// P0's scripted run: `BenchRun.kt`'s scroll loop, then its streaming
/// phase, then the report -- run in-process for the same reason that
/// file's own doc gives (no usable system tracing on a real phone, no
/// agent that can drive one).
fn start_benchmark(&mut self, rsc: &mut Rsc) {
if self.running {
log::info!("iris bench report: already running");
return;
}
self.running = true;
self.android_state_mut().frame_report.reset();
self.report_display.edit(rsc).set("Running benchmark...");
let redraw = rsc.tasks.redraw_handle();
let platform = self.platform.clone();
let stream_tail = self.stream_tail.clone();
let cpu_start = process_cpu_ms();
rsc.spawn_task(async move |mut ctx| {
// The swipe loop: two drags toward newer content, two back --
// a cycle returns to where it started, so the whole loop
// measures steady-state scrolling. `BenchRun.kt`'s own
// comment on this shape.
for _ in 0..CYCLES {
for delta in [SWIPE_PX, SWIPE_PX, -SWIPE_PX, -SWIPE_PX] {
animate_scroll(&mut ctx, &redraw, delta, SWIPE_MS).await;
tokio::time::sleep(Duration::from_millis(SWIPE_PAUSE_MS)).await;
}
}
// Pinned to the newest end before streaming starts, matching
// `stream-bench.sh`'s "Jump to latest" tap.
ctx.update(|state: &mut BenchClient, rsc| {
if let Some(screen) = &state.screen {
(screen.list)(rsc).jump_to_end();
}
});
redraw.request_redraw();
// The battery sampler runs concurrently with the streaming
// phase, once a second, the same cadence `BatterySampler` uses
// on the Compose side -- via its own JNI-attached thread, not
// `ctx.update`, since a sample needs no widget-tree access.
let sampler_done = Arc::new(AtomicBool::new(false));
let samples = Arc::new(std::sync::Mutex::new(Vec::<i32>::new()));
let sampler = platform.clone().map(|platform| {
let done = sampler_done.clone();
let samples = samples.clone();
tokio::spawn(async move {
while !done.load(Ordering::Relaxed) {
if let Some(value) = platform.battery_current_ua() {
samples.lock().unwrap().push(value);
}
tokio::time::sleep(Duration::from_secs(1)).await;
}
})
});
let total = (STREAM_EVENTS_PER_SEC * STREAM_SECONDS) as usize;
let mut sent = 0usize;
for event in stream_tail.into_iter().take(total) {
ctx.update(move |state: &mut BenchClient, rsc| {
state.items = fold_event(&state.items, &event);
state.rebuild_transcript(rsc);
});
redraw.request_redraw();
sent += 1;
tokio::time::sleep(Duration::from_millis(1000 / STREAM_EVENTS_PER_SEC)).await;
}
// Lets the last few deltas land and draw before the report is
// read -- `BenchRun.kt`'s own closing delay.
tokio::time::sleep(Duration::from_millis(300)).await;
sampler_done.store(true, Ordering::Relaxed);
if let Some(sampler) = sampler {
let _ = sampler.await;
}
let battery = battery_line(&samples.lock().unwrap());
let cpu_line = match (cpu_start, process_cpu_ms()) {
(Some(start), Some(end)) => {
format!(" process CPU time over this run: {}ms", end.saturating_sub(start))
}
_ => " process CPU time over this run: unavailable".to_string(),
};
let rss_line = match peak_rss_kb() {
Some(kb) => format!(" peak RSS: {kb}kB"),
None => " peak RSS: unavailable (/proc/self/status unreadable)".to_string(),
};
ctx.update(move |state: &mut BenchClient, rsc| {
state.running = false;
let scroll_line = format!(
" scroll: {CYCLES} cycles ({} swipes), streamed {sent}/{total} fixture events",
CYCLES * 4
);
let frames_line = match state.android_state().frame_report.report() {
Some(stats) => format!("{stats}"),
None => "no frames recorded".to_string(),
};
let report = format!(
"iris bench report\n{frames_line}\n{scroll_line}\n{cpu_line}\n{rss_line}\n{battery}"
);
log::info!("iris bench report: {report}");
state.report_display.edit(rsc).set(&report);
state.last_report = Some(report);
});
redraw.request_redraw();
});
}
}
/// Moves `List::scroll` by `total_px` over `duration_ms`, in ~60Hz steps,
/// so the swipe is many rendered frames rather than one jump -- the same
/// shape `animateScrollBy(SWIPE_PX, tween(SWIPE_MS))` gives on the Compose
/// side, in the one place the two backends have to differ (iris's `List`
/// has no built-in tween, so this drives it by hand).
async fn animate_scroll(
ctx: &mut iris::task::TaskCtx<Rsc>,
redraw: &Arc<dyn iris::task::RequestRedraw>,
total_px: f32,
duration_ms: u64,
) {
let steps = (duration_ms / ANIM_STEP_MS).max(1);
let step_px = total_px / steps as f32;
for _ in 0..steps {
ctx.update(move |state: &mut BenchClient, rsc| {
if let Some(screen) = &state.screen {
(screen.list)(rsc).scroll(step_px);
}
});
redraw.request_redraw();
tokio::time::sleep(Duration::from_millis(ANIM_STEP_MS)).await;
}
}
+134
View File
@@ -0,0 +1,134 @@
//! JNI calls the `bench` feature needs that go through the shell's own
//! Java side rather than anything `iris`/`android-view` already wraps:
//! `BatteryManager.getIntProperty(BATTERY_PROPERTY_CURRENT_NOW)` for the
//! per-second battery sample, and `ClipboardManager.setPrimaryClip` for
//! the "Copy report" control (P0's iris half, docs/RUST.md). Neither is
//! part of `android_view::context`'s own `Context`/`Resources` wrappers
//! (that file's own `// TODO: more methods?`), so this calls them
//! directly rather than growing that crate's wrapper for two one-off
//! calls this crate alone needs.
//!
//! Holds its own `JavaVM` + `GlobalRef` to the view (handed in through
//! [`iris::android::AndroidAppState::platform_ready`]) so it can attach
//! whichever thread calls it -- the battery sampler runs on a background
//! tokio task, not the UI thread the rest of `IrisViewPeer`'s JNI calls
//! run on. `JavaVM::attach_current_thread` is safe to call from a thread
//! already attached (the `jni` crate detects it and does not double
//! attach), so no caller here needs to know or care which thread it is.
use android_view::jni::{
JNIEnv, JavaVM,
objects::{GlobalRef, JObject, JValue},
};
/// `android.os.BatteryManager.BATTERY_PROPERTY_CURRENT_NOW` -- not exposed
/// as a constant anywhere reachable without the Android SDK jar, so named
/// here with its source rather than left as a bare `2`.
const BATTERY_PROPERTY_CURRENT_NOW: i32 = 2;
pub struct PlatformHandle {
vm: JavaVM,
view: GlobalRef,
}
impl PlatformHandle {
pub fn new(vm: JavaVM, view: GlobalRef) -> Self {
Self { vm, view }
}
fn context<'e>(&self, env: &mut JNIEnv<'e>) -> Option<JObject<'e>> {
env.call_method(
self.view.as_obj(),
"getContext",
"()Landroid/content/Context;",
&[],
)
.ok()?
.l()
.ok()
}
fn system_service<'e>(
&self,
env: &mut JNIEnv<'e>,
context: &JObject<'e>,
name: &str,
) -> Option<JObject<'e>> {
let jname = env.new_string(name).ok()?;
env.call_method(
context,
"getSystemService",
"(Ljava/lang/String;)Ljava/lang/Object;",
&[JValue::Object(jname.as_ref())],
)
.ok()?
.l()
.ok()
}
/// One sample of `BATTERY_PROPERTY_CURRENT_NOW`, in microamps. `None`
/// on any JNI failure, on a device with no `BatteryManager` service,
/// or when the platform itself answers "not supported" -- `0` or
/// `Integer.MIN_VALUE` are both documented SDK answers for that, and
/// both would read as a real (and wrong) measurement if folded into an
/// average rather than named apart. UI_RULES.md: never present an
/// inferred value as a measured one.
pub fn battery_current_ua(&self) -> Option<i32> {
let mut guard = self.vm.attach_current_thread().ok()?;
let env: &mut JNIEnv = &mut guard;
let context = self.context(env)?;
let battery_manager = self.system_service(env, &context, "batterymanager")?;
let value = env
.call_method(
&battery_manager,
"getIntProperty",
"(I)I",
&[JValue::Int(BATTERY_PROPERTY_CURRENT_NOW)],
)
.ok()?
.i()
.ok()?;
if value == 0 || value == i32::MIN {
None
} else {
Some(value)
}
}
/// Puts `text` on the system clipboard through `ClipboardManager` --
/// `true` only if the whole JNI chain (service lookup, `ClipData`,
/// `setPrimaryClip`) succeeded.
pub fn copy_to_clipboard(&self, label: &str, text: &str) -> bool {
self.try_copy_to_clipboard(label, text).is_some()
}
fn try_copy_to_clipboard(&self, label: &str, text: &str) -> Option<()> {
let mut guard = self.vm.attach_current_thread().ok()?;
let env: &mut JNIEnv = &mut guard;
let context = self.context(env)?;
let clipboard = self.system_service(env, &context, "clipboard")?;
let jlabel = env.new_string(label).ok()?;
let jtext = env.new_string(text).ok()?;
let clip = env
.call_static_method(
"android/content/ClipData",
"newPlainText",
"(Ljava/lang/CharSequence;Ljava/lang/CharSequence;)Landroid/content/ClipData;",
&[
JValue::Object(jlabel.as_ref()),
JValue::Object(jtext.as_ref()),
],
)
.ok()?
.l()
.ok()?;
env.call_method(
&clipboard,
"setPrimaryClip",
"(Landroid/content/ClipData;)V",
&[JValue::Object(&clip)],
)
.ok()?;
Some(())
}
}
+20 -2
View File
@@ -23,6 +23,18 @@
//! A build picks one screen or the other, never both, so `Client` and //! A build picks one screen or the other, never both, so `Client` and
//! `TranscriptClient` are cfg-gated apart rather than switched at runtime -- //! `TranscriptClient` are cfg-gated apart rather than switched at runtime --
//! there is no in-app navigation to switch *to* on either side yet. //! there is no in-app navigation to switch *to* on either side yet.
//!
//! **`bench` feature (P0's iris half, docs/RUST.md):** a third
//! `AndroidAppState`, `bench_client::BenchClient`, on the same axis --
//! `transcript_ui::build_tree` again, this time against the checked-in
//! fixture (`app/bench-fixture/assets/transcript.jsonl`) instead of a real
//! server, with a "Run benchmark" control that drives the same scroll loop
//! and streaming phase the Compose `bench` build type's `BenchRun.kt`
//! does. `bench` depends on `transcript-screen` (Cargo.toml) for
//! `transcript-ui`/`client-core`/`event-model`, so both features end up
//! enabled together -- `ActiveClient` below gives `bench` priority in that
//! case, the same way `transcript-screen` already takes priority over the
//! default `tabs-screen`.
use android_view::{ use android_view::{
Context, View, Context, View,
@@ -39,7 +51,11 @@ use iris::prelude::*;
use log::LevelFilter; use log::LevelFilter;
use std::ffi::c_void; use std::ffi::c_void;
#[cfg(feature = "transcript-screen")] #[cfg(feature = "bench")]
mod bench_client;
#[cfg(feature = "bench")]
mod bench_jni;
#[cfg(all(feature = "transcript-screen", not(feature = "bench")))]
mod transcript_client; mod transcript_client;
/// The app's `View` subclass, matching the Java side's package -- /// The app's `View` subclass, matching the Java side's package --
@@ -85,8 +101,10 @@ impl AndroidAppState for Client {
#[cfg(not(feature = "transcript-screen"))] #[cfg(not(feature = "transcript-screen"))]
type ActiveClient = Client; type ActiveClient = Client;
#[cfg(feature = "transcript-screen")] #[cfg(all(feature = "transcript-screen", not(feature = "bench")))]
type ActiveClient = transcript_client::TranscriptClient; type ActiveClient = transcript_client::TranscriptClient;
#[cfg(feature = "bench")]
type ActiveClient = bench_client::BenchClient;
extern "system" fn new_view_peer<'local>( extern "system" fn new_view_peer<'local>(
env: JNIEnv<'local>, env: JNIEnv<'local>,
+18 -2
View File
@@ -4,7 +4,7 @@ use accesskit_android::Adapter as AccessAdapter;
use android_view::{ use android_view::{
AccessibilityNodeInfo, AccessibilityNodeProvider, Bundle, CallbackCtx, Context, AccessibilityNodeInfo, AccessibilityNodeProvider, Bundle, CallbackCtx, Context,
InputConnection, KeyEvent, MotionEvent, Rect, View, ViewPeer, InputConnection, KeyEvent, MotionEvent, Rect, View, ViewPeer,
jni::{JNIEnv, sys::jint}, jni::{JNIEnv, JavaVM, objects::GlobalRef, sys::jint},
ndk::event::{Keycode, MotionAction}, ndk::event::{Keycode, MotionAction},
}; };
// `marker::Sized` explicitly: `crate::prelude::*` below also brings in the // `marker::Sized` explicitly: `crate::prelude::*` below also brings in the
@@ -107,6 +107,19 @@ pub trait AndroidAppState: HasAndroidUiState {
fn back_pressed(&mut self, rsc: &mut AndroidRsc<Self>, render: &mut UiRenderState) -> bool { fn back_pressed(&mut self, rsc: &mut AndroidRsc<Self>, render: &mut UiRenderState) -> bool {
false false
} }
/// Called once, right after `new`, with a fresh `JavaVM` handle and a
/// global reference to this app's own `View` -- for a caller that
/// needs to call into Java itself beyond what a [`RequestRedraw`]
/// handle already covers (P0's bench build calling
/// `BatteryManager`/`ClipboardManager` through the view's `Context`,
/// docs/RUST.md). Not folded into `new` itself: most implementors need
/// nothing here, and `new`'s job is building the widget tree, not
/// holding a platform handle -- the default does nothing. `vm`/`view`
/// are independent handles from the ones `new_peer` keeps for its own
/// `RequestRedraw` (a fresh `get_java_vm`/`new_global_ref` each), so
/// storing them has no effect on that mechanism.
#[allow(unused_variables)]
fn platform_ready(&mut self, rsc: &mut AndroidRsc<Self>, vm: JavaVM, view: GlobalRef) {}
} }
/// The android-view analogue of `default::DefaultRsc` -- identical in /// The android-view analogue of `default::DefaultRsc` -- identical in
@@ -561,7 +574,10 @@ pub fn new_peer<'local, State: AndroidAppState>(
}; };
let shared = Rc::new(RefCell::new(Shared::default())); let shared = Rc::new(RefCell::new(Shared::default()));
let ui_state = AndroidUiState::new(shared.clone()); let ui_state = AndroidUiState::new(shared.clone());
let state = State::new(ui_state, &mut rsc); let mut state = State::new(ui_state, &mut rsc);
let platform_vm = env.get_java_vm().unwrap();
let platform_view = env.new_global_ref(&view.0).unwrap();
state.platform_ready(&mut rsc, platform_vm, platform_view);
let peer = IrisViewPeer { let peer = IrisViewPeer {
rsc, rsc,
render: UiRenderState::new(), render: UiRenderState::new(),
+1
View File
@@ -36,6 +36,7 @@ dependencies = [
"sha2", "sha2",
"tempfile", "tempfile",
"thiserror", "thiserror",
"time",
"tokio", "tokio",
"tokio-stream", "tokio-stream",
"tower", "tower",
+6
View File
@@ -57,6 +57,12 @@ ureq = { version = "3", features = ["json"] }
# both in the graph rustls refuses to auto-select one. # both in the graph rustls refuses to auto-select one.
rustls = "0.23" rustls = "0.23"
libc = "0.2.189" libc = "0.2.189"
# One ISO-8601 timestamp: the reset time on the invented rate-limit window
# an echo session's `/usage` puts up. Already in the tree behind the
# certificate machinery, so this is a direct name for what is compiled
# anyway rather than a new crate -- and the alternative was hand-rolling a
# civil-from-days conversion to print one line.
time = { version = "0.3", features = ["formatting"] }
[dev-dependencies] [dev-dependencies]
tempfile = "3" tempfile = "3"
+14
View File
@@ -207,6 +207,20 @@ mod tests {
/// like any other. /// like any other.
#[tokio::test] #[tokio::test]
async fn a_spooled_enrollment_is_adopted_on_first_use() { async fn a_spooled_enrollment_is_adopted_on_first_use() {
// Under a subscriber, like every other exercise of this middleware.
// `tracing` caches a callsite's interest process-wide the first time it
// is reached, so the refusal at the end of this test -- reached with no
// subscriber on this thread -- could cache the rejection warning as
// never-enabled and make the tripwire above see an empty log. That
// failed about one full-suite run in ten, in the test that exists to
// notice a credential leak, which is the worst place for a flake.
let _guard = tracing::subscriber::set_default(
tracing_subscriber::fmt()
.with_max_level(tracing::Level::TRACE)
.with_writer(std::io::sink)
.finish(),
);
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
let manager = manager_with_token(dir.path(), "first"); let manager = manager_with_token(dir.path(), "first");
let spooled = generate_token(); let spooled = generate_token();
+155
View File
@@ -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.
@@ -78,6 +90,16 @@ pub struct ProviderConfig {
pub models: Vec<String>, pub models: Vec<String>,
} }
impl ProviderConfig {
/// The executable to run for this provider: its override, or its kind's
/// default.
pub fn program(&self) -> &str {
self.command
.as_deref()
.unwrap_or(self.kind.default_program())
}
}
/// How to reach a setup that isn't this machine, with the system `ssh` client /// How to reach a setup that isn't this machine, with the system `ssh` client
/// -- so `~/.ssh/config`, agents and jump hosts all keep working, and there is /// -- so `~/.ssh/config`, agents and jump hosts all keep working, and there is
/// one place to configure connections. A remote session is the identical /// one place to configure connections. A remote session is the identical
@@ -94,6 +116,19 @@ pub struct SshConfig {
/// Extra `-o` settings, each written as `Key=value`. /// Extra `-o` settings, each written as `Key=value`.
#[serde(default, skip_serializing_if = "Vec::is_empty")] #[serde(default, skip_serializing_if = "Vec::is_empty")]
pub options: Vec<String>, pub options: Vec<String>,
/// Where this machine keeps the GGUF models it can serve, absent for
/// the same default this backend uses (`~/.local/share/ai-app/models`
/// -- `$XDG_DATA_HOME` is not read on the far side, since it is this
/// machine's environment that would answer). A `~` prefix is the
/// remote home.
///
/// Here rather than on the provider because it is a fact about the
/// machine, and because a machine reached over ssh is where the model
/// has to be: a llama.cpp session serves the file from the machine
/// that runs `llama-server`, and this backend's own downloads are on
/// whichever machine that is only when they are the same one.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub models_dir: Option<PathBuf>,
/// Where a file attached from the phone is put on this machine so the /// Where a file attached from the phone is put on this machine so the
/// session can read it. Absent means the session's own working directory, /// session can read it. Absent means the session's own working directory,
/// or the login home for a session that has none. A `~` prefix is the /// or the login home for a session that has none. A `~` prefix is the
@@ -145,6 +180,45 @@ impl DriverKind {
} }
} }
/// Which paid service meters a session of this kind, and `None` for one
/// that costs nothing.
///
/// What decides which account -- if any -- a rate-limit bar is about is the
/// provider a session runs, not the machine it runs on: an echo session on
/// a machine that also has the Claude CLI was drawn with that CLI's
/// five-hour window, a quota it cannot spend.
///
/// Echo names a meter of its own that exists only when a test has asked for
/// one (`/usage` in `session::echo`), which is how the bar's states are
/// reached without an account. With none set there is no snapshot, and the
/// phone draws nothing.
///
/// The string is a [`crate::usage::UsageProvider::name`], and it is what
/// pairs a session with one of `GET /usage`'s snapshots -- so
/// `usage::providers_for` reads this rather than matching on kinds again.
pub fn usage_provider(self) -> Option<&'static str> {
match self {
Self::ClaudeCli => Some(crate::usage::CLAUDE),
Self::Echo => Some(crate::usage::ECHO),
Self::LlamaCpp => None,
}
}
/// The executable a provider of this kind runs when it names none.
///
/// Here rather than at each spawn site because it is not only the spawn
/// that runs it: `usage` runs the Claude CLI too, to have it refresh its
/// own OAuth token, and a default that disagreed with the driver's would
/// ask the wrong binary on a machine with two installs.
pub fn default_program(self) -> &'static str {
match self {
Self::ClaudeCli => "claude",
Self::LlamaCpp => "llama-server",
// Echo is translated in-process; nothing is spawned for it.
Self::Echo => "echo",
}
}
/// Whether the conversation exists outside this app, so that deleting the /// Whether the conversation exists outside this app, so that deleting the
/// session here does not end it. /// session here does not end it.
/// ///
@@ -162,6 +236,24 @@ impl DriverKind {
Self::Echo | Self::LlamaCpp => false, Self::Echo | Self::LlamaCpp => false,
} }
} }
/// Whether a thinking level means anything to this kind, so the phone can
/// offer the control only where it does something.
///
/// Reported from here rather than decided on the phone, and asked of the
/// *kind* rather than branched on: the alternative is the session-type
/// `if` this app does not have anywhere else. `--effort` is the Claude
/// CLI's; a llama session's sampling is `params`, and echo does not think.
///
/// It matters more than a control that would simply do nothing, because
/// choosing a level stops the process -- so on a session that cannot use
/// one it is a button whose only effect is the cost.
pub fn takes_effort(self) -> bool {
match self {
Self::ClaudeCli => true,
Self::Echo | Self::LlamaCpp => false,
}
}
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -196,6 +288,17 @@ pub struct SessionConfig {
/// the CLI stays the one authority on which modes exist. /// the CLI stays the one authority on which modes exist.
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub permission_mode: Option<String>, pub permission_mode: Option<String>,
/// How hard the model thinks, passed straight to `--effort`. A string for
/// the same reason `permission_mode` is: the CLI owns which levels exist.
///
/// Unlike the model and the mode, there is no control request that changes
/// one -- checked against 2.1.258, whose only two are `set_model` and
/// `set_permission_mode` -- so this is settled at launch and `None` means
/// whatever the CLI's own default is. That is a state the phone has to be
/// able to *choose*, not just start in, which is why it is an option
/// rather than a level with a default written here.
#[serde(skip_serializing_if = "Option::is_none")]
pub effort: Option<String>,
/// Settings the driver interprets, chosen at spawn. /// Settings the driver interprets, chosen at spawn.
/// ///
/// Deliberately untyped: what a temperature or a context size means is the /// Deliberately untyped: what a temperature or a context size means is the
@@ -215,6 +318,30 @@ pub struct SessionConfig {
/// turned off in one tap where one that never arrived is not diagnosable. /// turned off in one tap where one that never arrived is not diagnosable.
#[serde(default = "notify_default")] #[serde(default = "notify_default")]
pub notify: bool, pub notify: bool,
/// Whether a session stopped by the account's usage limit sends itself a
/// message once the limit lifts, instead of waiting for a person.
///
/// Off unless somebody asked for it. It spends quota the moment it becomes
/// available and it does so while nobody is looking, which is exactly the
/// kind of thing that must not happen because a default said so.
#[serde(default, skip_serializing_if = "not_set")]
pub auto_resume: bool,
/// What that message says. `None` is [`DEFAULT_RESUME_MESSAGE`], and stays
/// reachable: it is this app's word, not one somebody chose, so clearing
/// the field goes back to it rather than sending an empty message.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auto_resume_message: Option<String>,
/// The message this session owes itself once the limit lifts, and when to
/// try. Written when a limit is hit, moved when the wait turns out to be
/// wrong, and cleared when the message goes out or auto-resume is turned
/// off -- see [`ScheduledResume`].
///
/// Persisted rather than held in memory because the wait outlives the
/// process doing it: a five-hour window and a weekly one both routinely
/// outlast a backend restart, and a resume forgotten across one is a
/// session that silently never comes back.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resume: Option<ScheduledResume>,
/// Whether this session's process is stopped when the server exits, instead /// Whether this session's process is stopped when the server exits, instead
/// of being left running for the next start to adopt. /// of being left running for the next start to adopt.
/// ///
@@ -231,6 +358,28 @@ pub struct SessionConfig {
pub created: f64, pub created: f64,
} }
/// A message owed to a session whose account ran out, and when to try sending
/// it.
///
/// `since` is the whole reason this is a struct: the wait is rescheduled every
/// time the meter is asked and still says no, so `at` alone cannot say how long
/// this has been going on -- and something has to, or a machine that can never
/// be asked is retried until somebody notices. See `crate::resume`.
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ScheduledResume {
/// Epoch seconds: when the limit is next worth checking. Never a promise
/// that the message goes out then -- the meter is asked first.
pub at: f64,
/// Epoch seconds the limit was hit.
pub since: f64,
}
/// What an auto-resume says when nothing else was chosen. One word, because
/// the session already knows what it was doing and this is only the nudge that
/// lets it carry on.
pub const DEFAULT_RESUME_MESSAGE: &str = "continue";
fn notify_default() -> bool { fn notify_default() -> bool {
true true
} }
@@ -385,6 +534,7 @@ mod tests {
port: Some(2222), port: Some(2222),
identity_file: None, identity_file: None,
options: Vec::new(), options: Vec::new(),
models_dir: None,
attachments_dir: None, attachments_dir: None,
}), }),
providers: vec![ProviderConfig { providers: vec![ProviderConfig {
@@ -395,6 +545,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(),
@@ -403,8 +554,12 @@ mod tests {
model: None, model: None,
cwd: None, cwd: None,
permission_mode: None, permission_mode: None,
effort: None,
params: BTreeMap::new(), params: BTreeMap::new(),
notify: true, notify: true,
auto_resume: false,
auto_resume_message: None,
resume: None,
throwaway: false, throwaway: false,
created: 1234.5, created: 1234.5,
}], }],
+11 -1
View File
@@ -16,6 +16,7 @@ mod config;
mod files; mod files;
mod media; mod media;
mod models; mod models;
mod resume;
mod routes; mod routes;
mod session; mod session;
mod setups; mod setups;
@@ -264,7 +265,16 @@ async fn main() -> Result<()> {
// No providers listed here any more: which machines can be asked, and about // No providers listed here any more: which machines can be asked, and about
// what, comes from the setups at the moment the screen is opened -- so a // what, comes from the setups at the moment the screen is opened -- so a
// machine added from the phone reports its limits without a restart. // machine added from the phone reports its limits without a restart.
let monitor = Arc::new(usage::UsageMonitor::new()); // The fixture is the manager's, because that is where the `/usage` command
// that sets it is typed; the monitor is what serves it.
let monitor = Arc::new(usage::UsageMonitor::new(manager.usage_fixture()));
// The one thing in here that acts without a request behind it: a session
// switched to auto-resume waits out its account's usage limit and picks
// itself back up. Started whether or not any session has it on, because
// the setting is per session and changes from the phone -- see
// `resume::run`.
tokio::spawn(resume::run(Arc::clone(&manager), Arc::clone(&monitor)));
// The bearer-token middleware wraps the entire router -- routes and fallback // The bearer-token middleware wraps the entire router -- routes and fallback
// alike -- here and only here, so a new route can't forget auth. // alike -- here and only here, so a new route can't forget auth.
+79
View File
@@ -29,6 +29,8 @@ use serde::Serialize;
use wg_app_link::private; use wg_app_link::private;
use crate::session::transport::{Launch, Transport};
/// Identifies this client to HuggingFace. They ask for one, and a request /// Identifies this client to HuggingFace. They ask for one, and a request
/// without it is more likely to be rate-limited. /// without it is more likely to be rate-limited.
const USER_AGENT: &str = concat!("ai-server/", env!("CARGO_PKG_VERSION")); const USER_AGENT: &str = concat!("ai-server/", env!("CARGO_PKG_VERSION"));
@@ -521,6 +523,83 @@ fn collect(root: &Path, dir: &Path, found: &mut Vec<LocalModel>) {
} }
} }
/// Where a machine reached over ssh keeps its models, when its setup does
/// not say.
///
/// The same place this backend puts its own downloads, written out rather
/// than derived: `$XDG_DATA_HOME` here describes *this* machine's
/// environment, and the far machine's is the far machine's business. A
/// setup whose models are elsewhere says so (`SshConfig::models_dir`).
const FAR_MODELS_DIR: &str = "~/.local/share/ai-app/models";
/// Which directory holds the models on the machine `transport` reaches.
///
/// One answer, because two things ask: the list a spawn screen offers,
/// and the path a session hands `llama-server`. A machine that listed one
/// directory and served from another would offer models that then failed
/// to load, which reads as the model being broken.
pub fn dir_on(transport: &Transport, local: &Path) -> String {
match transport {
Transport::Here => local.to_string_lossy().into_owned(),
Transport::Ssh { ssh, .. } => ssh
.models_dir
.as_ref()
.map_or(FAR_MODELS_DIR.to_string(), |dir| {
dir.to_string_lossy().into_owned()
}),
}
}
/// Every GGUF on the machine a setup names, which is the machine that
/// would have to serve it.
///
/// The local half of this is [`ModelStore::list`], reading the same shape
/// off this machine's disk; a caller picks by transport, since a setup
/// with no ssh *is* this machine and asking a shell about it would be a
/// slower way to the same answer. What must not happen is offering this
/// backend's downloads for a session on another machine: the file has to
/// be where `llama-server` runs, and a list that says otherwise is a
/// claim about the wrong filesystem.
///
/// `dir` is that machine's models directory, `~` included -- expanded on
/// the far side, which is the only place that knows what it is. A
/// directory that is not there is an empty list rather than a failure: a
/// machine that has never had a model put on it is an ordinary state, and
/// the same one as a machine whose directory exists and is empty.
pub async fn on_machine(transport: &Transport, dir: &str) -> Result<Vec<LocalModel>> {
let script = "p=$1; case $p in \"~\") p=$HOME;; \"~/\"*) p=$HOME/${p#\"~/\"};; esac; \
[ -d \"$p\" ] || exit 0; \
find \"$p\" -type f -name '*.gguf' -printf '%s\\t%P\\0'";
let launch = Launch::new(
"sh",
vec![
"-c".to_string(),
script.to_string(),
"sh".to_string(),
dir.to_string(),
],
None,
);
let out = transport.capture(&launch).await?;
let mut found: Vec<LocalModel> = out
.split('\0')
.filter(|record| !record.is_empty())
// Two fields, and the name last, so a `\t` in a filename survives.
.filter_map(|record| record.split_once('\t'))
.filter_map(|(bytes, key)| {
let (repo, file) = key.rsplit_once('/')?;
Some(LocalModel {
key: key.to_string(),
repo: repo.to_string(),
file: file.to_string(),
bytes: bytes.trim().parse().unwrap_or(0),
})
})
.collect();
found.sort_by(|a, b| a.key.cmp(&b.key));
Ok(found)
}
/// A model repository on HuggingFace, as the browse screen shows it. /// A model repository on HuggingFace, as the browse screen shows it.
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
+382
View File
@@ -0,0 +1,382 @@
//! Auto-resume: picking a session back up when its account's usage limit
//! lifts.
//!
//! Off unless a session was switched to it, because this spends quota the
//! moment quota exists and does it while nobody is watching. What it does is
//! narrow on purpose: it sends one message -- "continue" unless something else
//! was typed -- to a session that stopped because the account ran out, and
//! then it is done. There is no retry loop around the conversation itself.
//!
//! **The schedule is a plan to ask, never a plan to send.** A reset time is
//! the one thing here that cannot be trusted: the dialect's is a hint written
//! when the turn failed, the endpoint's moves when the window moves, and both
//! are wrong across the case this exists for -- a limit that lifts later than
//! it said. So the wait ends in a *question* to [`crate::usage`], and only an
//! answer that says the limits no longer apply sends anything. Every other
//! answer, including one that cannot be got at all, becomes a new wait.
//!
//! This is the top layer: it holds the session manager and the usage monitor
//! and neither holds it. That is what lets the decision below be a pure
//! function of a snapshot and a clock, which is the whole of what is worth
//! testing here.
use std::sync::Arc;
use std::time::Duration;
use crate::session::{LimitHit, OwedResume, SessionManager, now};
use crate::usage::{UsageMonitor, UsageSnapshot, UsageState};
/// How often to look at the schedule. Coarse deliberately: a wait measured in
/// hours does not deserve a fine-grained clock, and the meter behind it is
/// cached for three minutes anyway.
const TICK: Duration = Duration::from_secs(60);
/// How close to a scheduled check is close enough to ask the meter. Anything
/// further out is left alone, so a session waiting five hours costs nothing
/// until the last few minutes of it.
const NEARLY: f64 = 300.0;
/// How long to wait after an answer that decided nothing -- the machine could
/// not be asked, or it says the limit is still on with no reset time.
const BACKOFF: f64 = 300.0;
/// The least time to wait before asking again, whatever a reset time says. A
/// window that claims to reset in the past would otherwise be asked about on
/// every tick.
const AT_LEAST: f64 = 60.0;
/// How long after the limit was hit to stop waiting.
///
/// Something has to bound it, or a machine that can never be asked -- an
/// unplugged laptop, a setup somebody edited away -- is retried for ever with
/// nothing on screen saying so. A day is past the longest window Claude
/// reports, so reaching this means the wait was never going to end on its own.
const GIVE_UP: f64 = 24.0 * 60.0 * 60.0;
/// The percentage at which a window is spent. The API counts up to 100, so
/// this is an equality in all but name; written as a threshold because a
/// figure arriving slightly over is a full window, not a corrupt one.
const SPENT: f64 = 100.0;
/// What to do about one owed resume, having asked the meter.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Step {
/// The limits no longer apply: send the message.
Send,
/// Ask again at this epoch second.
WaitUntil(f64),
/// This has been waiting longer than anything real would take.
GiveUp,
}
/// Runs the schedule until the server stops.
///
/// Two things wake it: the tick, and a session reporting that it has just run
/// out. The second is not an optimisation -- a limit hit is what *creates* a
/// schedule, and a tick that happened a moment before it would otherwise leave
/// the session unrecorded until the next one.
pub async fn run(manager: Arc<SessionManager>, monitor: Arc<UsageMonitor>) {
let mut limits = manager.subscribe_limits();
loop {
tokio::select! {
_ = tokio::time::sleep(TICK) => {}
hit = limits.recv() => match hit {
Ok(LimitHit { session_id, resets_at }) => note(&manager, &session_id, resets_at),
// Lagged: some reports were dropped, and a session that hit a
// limit while this was busy has no schedule. Nothing is lost
// for good -- the sweep below reads the config, and the
// session will report again the next time it is poked -- but
// it is worth saying, because until then that session waits
// for a person.
Err(tokio::sync::broadcast::error::RecvError::Lagged(missed)) => {
tracing::warn!("auto-resume missed {missed} limit reports");
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => return,
},
}
sweep(&manager, &monitor).await;
}
}
/// Records a limit against the session that hit it, if it is one that resumes.
pub(crate) fn note(manager: &SessionManager, session_id: &str, resets_at: Option<f64>) {
match manager.note_limit(session_id, resets_at) {
Ok(true) => tracing::info!("session {session_id} hit its usage limit; auto-resume is on"),
Ok(false) => {}
Err(err) => tracing::error!("couldn't schedule a resume for {session_id}: {err:#}"),
}
}
/// One pass over everything owed a message.
async fn sweep(manager: &SessionManager, monitor: &Arc<UsageMonitor>) {
let at = now();
for owed in manager.owed_resumes() {
if owed.scheduled.at - at > NEARLY {
continue;
}
// Asked per session rather than once for the whole sweep: the answer
// is cached per machine and per meter, so several sessions on one
// account share one fetch, and a machine nobody is waiting on is not
// dialled at all.
let snapshot = snapshot_for(Arc::clone(monitor), manager, &owed).await;
match decide(snapshot.as_ref(), &owed, now()) {
Step::Send => match manager.resume_now(&owed.session_id) {
Ok(message) => tracing::info!(
"the limit on {} has lifted; sent \"{message}\" to {}",
owed.setup,
owed.session_id
),
Err(err) => {
tracing::error!("couldn't resume {}: {err:#}", owed.session_id)
}
},
Step::WaitUntil(next) => {
if let Err(err) = manager.reschedule_resume(&owed.session_id, next) {
tracing::error!(
"couldn't move {}'s resume to {next}: {err:#}",
owed.session_id
);
}
}
Step::GiveUp => {
// About the machine rather than in the state's own words: the
// detail is in the log, and what lands in the transcript has
// to read on a phone.
let why = match snapshot.as_ref().map(|snapshot| &snapshot.state) {
Some(UsageState::Ok) => "the limit has not lifted in a day".to_string(),
_ => format!("{} could not be asked for a day", owed.setup),
};
if let Err(err) = manager.abandon_resume(&owed.session_id, &why) {
tracing::error!("couldn't clear {}'s resume: {err:#}", owed.session_id);
}
}
}
}
}
/// The numbers for the machine and the meter this session is billed against,
/// and `None` when nothing reports on it.
///
/// Blocking work, so it goes to a blocking thread: the fetch behind it reads a
/// credential file over ssh and then makes an HTTP call.
async fn snapshot_for(
monitor: Arc<UsageMonitor>,
manager: &SessionManager,
owed: &OwedResume,
) -> Option<UsageSnapshot> {
let setups: Vec<_> = manager
.setups()
.into_iter()
.filter(|setup| setup.id == owed.setup)
.collect();
if setups.is_empty() {
return None;
}
let provider = owed.provider;
tokio::task::spawn_blocking(move || {
monitor
.snapshots(&setups)
.into_iter()
.find(|snapshot| snapshot.provider == provider)
})
.await
.unwrap_or_default()
}
/// What one owed resume should do, given what the meter said and the time.
///
/// A pure function of the two, which is what makes the rule inspectable: every
/// answer that is not "the limits no longer apply" is a longer wait, and the
/// only thing that ends the waiting other than success is the clock.
///
/// The reset time comes from the *snapshot* rather than from the schedule, so
/// a window that turns out to reset later than the dialect said pushes the
/// check back, and one that resets sooner pulls it forward. That is the case
/// the whole design is about: the first answer was a guess, this one is a
/// measurement.
pub fn decide(snapshot: Option<&UsageSnapshot>, owed: &OwedResume, at: f64) -> Step {
let step = match snapshot {
// The meter answered with numbers, which is the only answer that can
// send anything.
Some(snapshot) if snapshot.state == UsageState::Ok => {
let spent: Vec<&crate::usage::UsageWindow> = snapshot
.windows
.iter()
.filter(|window| window.percent >= SPENT)
.collect();
if spent.is_empty() {
Step::Send
} else {
// The earliest of the spent windows: it is the first moment
// the situation can have changed, and if the others are still
// full this comes straight back here.
match spent
.iter()
.filter_map(|window| epoch_of(window.resets_at.as_deref()))
.min_by(f64::total_cmp)
{
Some(resets) => Step::WaitUntil(resets),
// Spent with no reset time anybody could read. Not a
// reason to send: what is known is that the limit is on.
None => Step::WaitUntil(at + BACKOFF),
}
}
}
// Logged out, unreachable, or the endpoint refused us -- and nothing
// at all, which is a session whose machine or provider has gone. None
// of them says the limit has lifted, and sending on any of them is
// exactly the "inferred value presented as a measured one" this is
// built to avoid.
_ => Step::WaitUntil(at + BACKOFF),
};
match step {
// Waiting past the point where a real window would have reset means
// whatever is wrong is not going to fix itself.
Step::WaitUntil(_) if at - owed.scheduled.since > GIVE_UP => Step::GiveUp,
Step::WaitUntil(next) => Step::WaitUntil(next.max(at + AT_LEAST)),
other => other,
}
}
/// An RFC-3339 timestamp as epoch seconds, and `None` for one that is absent
/// or unreadable -- the same two answers the phone's countdown makes, kept
/// apart from each other nowhere here because both mean "this cannot decide
/// when to ask".
fn epoch_of(resets_at: Option<&str>) -> Option<f64> {
let text = resets_at?;
time::OffsetDateTime::parse(text, &time::format_description::well_known::Rfc3339)
.ok()
.map(|at| at.unix_timestamp() as f64)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::ScheduledResume;
use crate::usage::UsageWindow;
fn owed(since: f64) -> OwedResume {
OwedResume {
session_id: "s1".to_string(),
setup: "local".to_string(),
provider: crate::usage::CLAUDE,
scheduled: ScheduledResume { at: since, since },
}
}
fn snapshot(state: UsageState, windows: Vec<UsageWindow>) -> UsageSnapshot {
UsageSnapshot {
provider: crate::usage::CLAUDE.to_string(),
setup: "local".to_string(),
setup_name: "this machine".to_string(),
state,
windows,
fetched_at: 0.0,
}
}
fn window(percent: f64, resets_at: Option<&str>) -> UsageWindow {
UsageWindow {
kind: "session".to_string(),
label: "5-hour window".to_string(),
percent,
resets_at: resets_at.map(str::to_string),
active: true,
}
}
#[test]
fn a_meter_with_room_in_it_is_the_only_thing_that_sends() {
let clear = snapshot(UsageState::Ok, vec![window(41.0, None)]);
assert_eq!(decide(Some(&clear), &owed(0.0), 100.0), Step::Send);
}
#[test]
fn a_window_still_spent_moves_the_check_to_its_own_reset_time() {
// The case the feature exists for: the wait was scheduled for one
// time, the limit is still on, and the endpoint now names another.
let at = 1_788_546_972.0;
let later = "2026-09-05T12:00:00+00:00";
let spent = snapshot(UsageState::Ok, vec![window(100.0, Some(later))]);
assert_eq!(
decide(Some(&spent), &owed(at - 60.0), at),
Step::WaitUntil(epoch_of(Some(later)).expect("parses"))
);
}
#[test]
fn a_reset_time_already_past_still_waits_a_little() {
let at = 1_788_546_972.0;
let spent = snapshot(
UsageState::Ok,
vec![window(100.0, Some("2020-01-01T00:00:00+00:00"))],
);
assert_eq!(
decide(Some(&spent), &owed(at - 60.0), at),
Step::WaitUntil(at + AT_LEAST)
);
}
#[test]
fn the_earliest_spent_window_is_the_one_worth_waiting_on() {
let at = 1_788_546_972.0;
let soon = "2026-09-05T12:00:00+00:00";
let far = "2026-09-09T12:00:00+00:00";
let mut weekly = window(100.0, Some(far));
weekly.kind = "weekly_all".to_string();
let spent = snapshot(UsageState::Ok, vec![window(100.0, Some(soon)), weekly]);
assert_eq!(
decide(Some(&spent), &owed(at - 60.0), at),
Step::WaitUntil(epoch_of(Some(soon)).expect("parses"))
);
}
#[test]
fn a_meter_that_could_not_be_asked_never_sends() {
let at = 1_788_546_972.0;
for state in [
UsageState::NotLoggedIn,
UsageState::Unreachable {
detail: "no route".to_string(),
},
UsageState::Failed {
detail: "429".to_string(),
},
] {
let broken = snapshot(state.clone(), Vec::new());
assert_eq!(
decide(Some(&broken), &owed(at - 60.0), at),
Step::WaitUntil(at + BACKOFF),
"{state:?}"
);
}
// And no snapshot at all -- a machine or provider edited away under a
// session that was waiting on it.
assert_eq!(
decide(None, &owed(at - 60.0), at),
Step::WaitUntil(at + BACKOFF)
);
}
#[test]
fn waiting_longer_than_any_real_window_gives_up_rather_than_retrying_for_ever() {
let at = 1_788_546_972.0;
let broken = snapshot(
UsageState::Unreachable {
detail: "no route".to_string(),
},
Vec::new(),
);
assert_eq!(
decide(Some(&broken), &owed(at - GIVE_UP - 1.0), at),
Step::GiveUp
);
// A meter that answers is still allowed to send on the same tick: the
// ceiling bounds waiting, not resuming.
let clear = snapshot(UsageState::Ok, vec![window(3.0, None)]);
assert_eq!(
decide(Some(&clear), &owed(at - GIVE_UP - 1.0), at),
Step::Send
);
}
}
+251 -13
View File
@@ -7,6 +7,7 @@
//! POST /setups add {name, ssh?} -- providers are discovered //! POST /setups add {name, ssh?} -- providers are discovered
//! POST /setups/probe dry run {ssh?}: what would be found there //! POST /setups/probe dry run {ssh?}: what would be found there
//! GET /setups/{id} one machine, for refetching after a change //! GET /setups/{id} one machine, for refetching after a change
//! GET /setups/{id}/models GGUFs on that machine, for a llama session
//! GET /setups/{id}/dir?path=P entries of directory P, and P resolved //! GET /setups/{id}/dir?path=P entries of directory P, and P resolved
//! GET /setups/{id}/file?path=P content of file P, or why not //! GET /setups/{id}/file?path=P content of file P, or why not
//! PUT /setups/{id}/file {path, content, ifSha256} -> new size/mtime/sha256 //! PUT /setups/{id}/file {path, content, ifSha256} -> new size/mtime/sha256
@@ -28,6 +29,12 @@
//! GET /sessions/{id}/transcript a page of history: ?before=N (newest when absent), //! GET /sessions/{id}/transcript a page of history: ?before=N (newest when absent),
//! ?limit=N, ?coalesce=true to count rows not deltas, //! ?limit=N, ?coalesce=true to count rows not deltas,
//! ?after=N to floor it at what the caller already holds //! ?after=N to floor it at what the caller already holds
//! GET /sessions/{id}/subagents [{id, title, status, created, lastActivity}], oldest
//! first -- see SUBAGENTS.md
//! GET /sessions/{id}/subagents/{sub}/transcript exactly the transcript route above,
//! against that subagent's own transcript
//! GET /sessions/{id}/subagents/{sub}/events?after=N exactly the events route above,
//! against that subagent's own stream
//! POST /sessions/{id}/message {text, attachmentIds?} //! POST /sessions/{id}/message {text, attachmentIds?}
//! (starts the process first if it has exited) //! (starts the process first if it has exited)
//! POST /sessions/{id}/unqueue {messageId} -- take back one not read yet //! POST /sessions/{id}/unqueue {messageId} -- take back one not read yet
@@ -41,6 +48,8 @@
//! which starts again in the new one //! which starts again in the new one
//! POST /sessions/{id}/model {model} //! POST /sessions/{id}/model {model}
//! POST /sessions/{id}/permission-mode {permissionMode} //! POST /sessions/{id}/permission-mode {permissionMode}
//! POST /sessions/{id}/effort {effort} -- null for the CLI's default;
//! settled at launch, so this stops the process
//! POST /sessions/{id}/command {text} -- /compact, /clear, /rename x, or the dialect's own //! POST /sessions/{id}/command {text} -- /compact, /clear, /rename x, or the dialect's own
//! (starts the process first if it has exited) //! (starts the process first if it has exited)
//! POST /sessions/{id}/compact //! POST /sessions/{id}/compact
@@ -49,8 +58,12 @@
//! DELETE /sessions/{id} kill process, delete transcript + files //! DELETE /sessions/{id} kill process, delete transcript + files
//! (?deleteForeign=true removes the machine's own copy too) //! (?deleteForeign=true removes the machine's own copy too)
//! POST /sessions/{id}/notify {notify} -- announce this one or not //! POST /sessions/{id}/notify {notify} -- announce this one or not
//! POST /sessions/{id}/auto-resume {autoResume, message?} -- carry on by itself
//! once the account's usage limit lifts
//! 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
@@ -92,6 +105,7 @@ use tokio_stream::wrappers::{BroadcastStream, ReceiverStream};
use crate::session::driver::{SessionCommand, Unqueued}; use crate::session::driver::{SessionCommand, Unqueued};
use crate::session::pending::Operation; use crate::session::pending::Operation;
use crate::session::subagent::{Subagent, SubagentInfo};
use crate::session::transcript::{CATCH_UP_LIMIT, CatchUp, SeqEvent, catch_up}; use crate::session::transcript::{CATCH_UP_LIMIT, CatchUp, SeqEvent, catch_up};
use crate::session::{LiveSession, SessionInfo, SessionManager, SpawnSpec}; use crate::session::{LiveSession, SessionInfo, SessionManager, SpawnSpec};
@@ -109,6 +123,8 @@ pub fn router(manager: Arc<SessionManager>) -> Router {
"/setups/{id}", "/setups/{id}",
get(read_setup).put(update_setup).delete(delete_setup), get(read_setup).put(update_setup).delete(delete_setup),
) )
// The models on the machine a setup names, for a llama session there.
.route("/setups/{id}/models", get(setup_models))
// The filesystem of the machine a setup names. Under the setup // The filesystem of the machine a setup names. Under the setup
// rather than under a session because a filesystem is a property of // rather than under a session because a filesystem is a property of
// a machine; a session only says where to start looking. // a machine; a session only says where to start looking.
@@ -121,6 +137,15 @@ pub fn router(manager: Arc<SessionManager>) -> Router {
.route("/sessions/{id}", get(read_session).delete(delete_session)) .route("/sessions/{id}", get(read_session).delete(delete_session))
.route("/sessions/{id}/events", get(events)) .route("/sessions/{id}/events", get(events))
.route("/sessions/{id}/transcript", get(transcript)) .route("/sessions/{id}/transcript", get(transcript))
.route("/sessions/{id}/subagents", get(list_subagents))
.route(
"/sessions/{id}/subagents/{sub}/transcript",
get(subagent_transcript),
)
.route(
"/sessions/{id}/subagents/{sub}/events",
get(subagent_events),
)
.route("/sessions/{id}/message", post(message)) .route("/sessions/{id}/message", post(message))
.route("/sessions/{id}/unqueue", post(unqueue)) .route("/sessions/{id}/unqueue", post(unqueue))
.route("/sessions/{id}/answer", post(answer)) .route("/sessions/{id}/answer", post(answer))
@@ -131,7 +156,10 @@ pub fn router(manager: Arc<SessionManager>) -> Router {
.route("/sessions/{id}/cwd", post(set_cwd)) .route("/sessions/{id}/cwd", post(set_cwd))
.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("/defaults", get(defaults).post(set_defaults))
.route("/sessions/{id}/notify", post(set_notify)) .route("/sessions/{id}/notify", post(set_notify))
.route("/sessions/{id}/auto-resume", post(set_auto_resume))
.route("/notifications", get(notifications)) .route("/notifications", get(notifications))
.route("/sessions/{id}/compact", post(compact)) .route("/sessions/{id}/compact", post(compact))
.route("/sessions/{id}/command", post(command)) .route("/sessions/{id}/command", post(command))
@@ -197,6 +225,18 @@ fn lookup(manager: &SessionManager, id: &str) -> Result<Arc<LiveSession>, ApiErr
.ok_or_else(|| ApiError::NotFound(format!("no session {id}"))) .ok_or_else(|| ApiError::NotFound(format!("no session {id}")))
} }
/// A session's subagent by id -- the second half of the lookup every
/// `/sessions/{id}/subagents/{sub}/...` route needs. `Arc` because reopening
/// one from disk (a subagent this process has not touched yet) inserts it
/// into the registry, and a route holding a borrow across that would be
/// holding the registry's lock the whole request.
fn lookup_subagent(session: &LiveSession, sub: &str) -> Result<Arc<Subagent>, ApiError> {
session
.subagents()
.get(sub)
.ok_or_else(|| ApiError::NotFound(format!("no subagent {sub}")))
}
async fn list_sessions(State(manager): State<Arc<SessionManager>>) -> axum::Json<Vec<SessionInfo>> { async fn list_sessions(State(manager): State<Arc<SessionManager>>) -> axum::Json<Vec<SessionInfo>> {
axum::Json(manager.sessions()) axum::Json(manager.sessions())
} }
@@ -291,6 +331,9 @@ struct SshRequest {
/// Where attached files land on that machine; see `SshConfig`. /// Where attached files land on that machine; see `SshConfig`.
#[serde(default)] #[serde(default)]
attachments_dir: Option<String>, attachments_dir: Option<String>,
/// Where that machine keeps its GGUF models; see `SshConfig`.
#[serde(default)]
models_dir: Option<String>,
} }
impl SshRequest { impl SshRequest {
@@ -320,6 +363,14 @@ impl SshRequest {
.map(str::trim) .map(str::trim)
.filter(|dir| !dir.is_empty()) .filter(|dir| !dir.is_empty())
.map(std::path::PathBuf::from), .map(std::path::PathBuf::from),
// The same rule, and for the same reason: this directory is
// on the other machine, so a `~` in it is that machine's home.
models_dir: self
.models_dir
.as_deref()
.map(str::trim)
.filter(|dir| !dir.is_empty())
.map(std::path::PathBuf::from),
}) })
} }
} }
@@ -495,6 +546,27 @@ struct PathQuery {
path: String, path: String,
} }
/// The models **that machine** has, which is the list a llama.cpp session
/// on it can choose from.
///
/// Not `GET /models`, which is this backend's own downloads: those are on
/// the machine a session runs on only when they are the same machine. A
/// spawn screen offering this backend's list for a remote setup would be
/// naming files that are not there, and the session would fail at the
/// point of loading rather than at the point of choosing.
async fn setup_models(
State(manager): State<Arc<SessionManager>>,
UrlPath(id): UrlPath<String>,
) -> Result<axum::Json<Vec<crate::models::LocalModel>>, ApiError> {
let setup = setup_by_id(&manager, &id)?;
let transport = crate::session::transport::Transport::for_setup(&setup);
let dir = crate::models::dir_on(&transport, manager.models_dir());
crate::models::on_machine(&transport, &dir)
.await
.map(axum::Json)
.map_err(from_machine)
}
/// What is in a directory, and what that directory resolved to. /// What is in a directory, and what that directory resolved to.
async fn list_dir( async fn list_dir(
State(manager): State<Arc<SessionManager>>, State(manager): State<Arc<SessionManager>>,
@@ -609,6 +681,8 @@ struct SpawnRequest {
cwd: Option<PathBuf>, cwd: Option<PathBuf>,
#[serde(default)] #[serde(default)]
permission_mode: Option<String>, permission_mode: Option<String>,
#[serde(default)]
effort: Option<String>,
/// Whatever the chosen driver understands -- llama.cpp's context size and /// Whatever the chosen driver understands -- llama.cpp's context size and
/// sampling. Opaque here on purpose: see `SessionConfig::params`. /// sampling. Opaque here on purpose: see `SessionConfig::params`.
#[serde(default)] #[serde(default)]
@@ -796,6 +870,7 @@ async fn start_import(
model: body.model.clone(), model: body.model.clone(),
cwd: None, cwd: None,
permission_mode: body.permission_mode.clone(), permission_mode: body.permission_mode.clone(),
effort: body.effort.clone(),
params: std::collections::BTreeMap::new(), params: std::collections::BTreeMap::new(),
import: Some(session.clone()), import: Some(session.clone()),
}; };
@@ -828,6 +903,8 @@ struct ImportRequest {
model: Option<String>, model: Option<String>,
#[serde(default)] #[serde(default)]
permission_mode: Option<String>, permission_mode: Option<String>,
#[serde(default)]
effort: Option<String>,
} }
/// Runs `work` on the server, marked as in flight for as long as it takes. /// Runs `work` on the server, marked as in flight for as long as it takes.
@@ -978,6 +1055,7 @@ async fn spawn(manager: &Arc<SessionManager>, body: SpawnRequest) -> Result<Sess
.filter(|cwd| cwd.as_os_str() != "") .filter(|cwd| cwd.as_os_str() != "")
}), }),
permission_mode: body.permission_mode, permission_mode: body.permission_mode,
effort: body.effort,
params: body.params, params: body.params,
}; };
@@ -1311,6 +1389,60 @@ 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)]
#[serde(rename_all = "camelCase")]
#[serde(deny_unknown_fields)]
struct EffortRequest {
/// Absent or null is the CLI's own default, which is a choice somebody can
/// make rather than only a state to start in.
#[serde(default)]
effort: Option<String>,
}
/// Records how hard this session thinks, and stops the process so the next one
/// is launched with it -- `--effort` has no control request behind it. See
/// [`SessionManager::set_session_effort`].
async fn set_effort(
State(manager): State<Arc<SessionManager>>,
UrlPath(id): UrlPath<String>,
axum::Json(body): axum::Json<EffortRequest>,
) -> Result<StatusCode, ApiError> {
manager
.set_session_effort(&id, body.effort.as_deref())
.map_err(bad_request)?;
Ok(StatusCode::NO_CONTENT)
}
async fn set_permission_mode( async fn set_permission_mode(
State(manager): State<Arc<SessionManager>>, State(manager): State<Arc<SessionManager>>,
UrlPath(id): UrlPath<String>, UrlPath(id): UrlPath<String>,
@@ -1339,6 +1471,33 @@ async fn set_notify(
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct AutoResumeRequest {
auto_resume: bool,
/// What to send when the limit lifts. Absent -- and empty, which is what a
/// cleared field sends -- means this app's own default word, which is a
/// choice a caller has to be able to make rather than only start in.
#[serde(default)]
message: Option<String>,
}
/// Turns auto-resume on or off, and sets what it would say.
///
/// One request for both, because they are one decision: switching it on
/// without saying what to send is the ordinary case, and changing the words
/// while it is off is how somebody sets it up before it is needed.
async fn set_auto_resume(
State(manager): State<Arc<SessionManager>>,
UrlPath(id): UrlPath<String>,
axum::Json(body): axum::Json<AutoResumeRequest>,
) -> Result<StatusCode, ApiError> {
manager
.set_session_auto_resume(&id, body.auto_resume, body.message.as_deref())
.map_err(bad_request)?;
Ok(StatusCode::NO_CONTENT)
}
#[derive(Deserialize)] #[derive(Deserialize)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
struct CommandRequest { struct CommandRequest {
@@ -1616,8 +1775,36 @@ async fn transcript(
Query(query): Query<TranscriptQuery>, Query(query): Query<TranscriptQuery>,
) -> Result<axum::Json<Vec<crate::session::transcript::SeqEvent>>, ApiError> { ) -> Result<axum::Json<Vec<crate::session::transcript::SeqEvent>>, ApiError> {
let session = lookup(&manager, &id)?; let session = lookup(&manager, &id)?;
transcript_page(session.transcript_path(), &id, query)
}
/// Exactly [`transcript`]'s route and answer, against one subagent's own
/// transcript instead of its session's -- see `SUBAGENTS.md`'s wire shape.
async fn subagent_transcript(
State(manager): State<Arc<SessionManager>>,
UrlPath((id, sub)): UrlPath<(String, String)>,
Query(query): Query<TranscriptQuery>,
) -> Result<axum::Json<Vec<crate::session::transcript::SeqEvent>>, ApiError> {
let session = lookup(&manager, &id)?;
let subagent = lookup_subagent(&session, &sub)?;
transcript_page(
&subagent.transcript_path(),
&format!("{id}/subagents/{sub}"),
query,
)
}
/// A page of history at `path`, newest first to open with -- the one
/// implementation [`transcript`] and [`subagent_transcript`] share, since a
/// subagent's transcript is read exactly the way a session's is. `label` is
/// only for the debug line below.
fn transcript_page(
path: &Path,
label: &str,
query: TranscriptQuery,
) -> Result<axum::Json<Vec<crate::session::transcript::SeqEvent>>, ApiError> {
let events = crate::session::transcript::read_window( let events = crate::session::transcript::read_window(
session.transcript_path(), path,
query.before, query.before,
query.after, query.after,
query.limit, query.limit,
@@ -1629,7 +1816,7 @@ async fn transcript(
// for events and draws rows, and the ratio between them is a property of // for events and draws rows, and the ratio between them is a property of
// the conversation. `RUST_LOG=ai_server=debug`. // the conversation. `RUST_LOG=ai_server=debug`.
tracing::debug!( tracing::debug!(
session = %id, session = %label,
before = ?query.before, before = ?query.before,
after = ?query.after, after = ?query.after,
limit = query.limit, limit = query.limit,
@@ -1647,23 +1834,74 @@ async fn events(
headers: HeaderMap, headers: HeaderMap,
) -> Result<Sse<impl tokio_stream::Stream<Item = Result<SseEvent, Infallible>>>, ApiError> { ) -> Result<Sse<impl tokio_stream::Stream<Item = Result<SseEvent, Infallible>>>, ApiError> {
let session = lookup(&manager, &id)?; let session = lookup(&manager, &id)?;
let cursor = headers let cursor = cursor_of(&headers, query.after);
.get("last-event-id")
.and_then(|value| value.to_str().ok())
.and_then(|value| value.parse().ok())
.unwrap_or(query.after);
// Subscribe before reading the file so nothing can land in the gap // Subscribe before reading the file so nothing can land in the gap
// between replay and live; overlap is deduplicated by seq. // between replay and live; overlap is deduplicated by seq.
let live = session.subscribe(); let live = session.subscribe();
let (tx, stream) = mpsc::channel(64); Ok(sse_stream(
tokio::spawn(stream_session(
session.transcript_path().to_path_buf(), session.transcript_path().to_path_buf(),
cursor, cursor,
live, live,
tx, ))
)); }
Ok(Sse::new(ReceiverStream::new(stream).map(Ok)).keep_alive(KeepAlive::default()))
/// Exactly [`events`]'s route and answer, against one subagent's own stream
/// instead of its session's -- see `SUBAGENTS.md`'s wire shape.
async fn subagent_events(
State(manager): State<Arc<SessionManager>>,
UrlPath((id, sub)): UrlPath<(String, String)>,
Query(query): Query<EventsQuery>,
headers: HeaderMap,
) -> Result<Sse<impl tokio_stream::Stream<Item = Result<SseEvent, Infallible>>>, ApiError> {
let session = lookup(&manager, &id)?;
let subagent = lookup_subagent(&session, &sub)?;
let cursor = cursor_of(&headers, query.after);
let live = subagent.subscribe();
Ok(sse_stream(subagent.transcript_path(), cursor, live))
}
/// The cursor an SSE reconnect resumes from: the native `Last-Event-ID`
/// takes precedence over the query parameter, same cursor either way.
fn cursor_of(headers: &HeaderMap, query_after: u64) -> u64 {
headers
.get("last-event-id")
.and_then(|value| value.to_str().ok())
.and_then(|value| value.parse().ok())
.unwrap_or(query_after)
}
/// Spawns the backlog-then-live task and wraps it as the response, the one
/// piece [`events`] and [`subagent_events`] share.
fn sse_stream(
transcript: PathBuf,
cursor: u64,
live: broadcast::Receiver<SeqEvent>,
) -> Sse<impl tokio_stream::Stream<Item = Result<SseEvent, Infallible>>> {
let (tx, stream) = mpsc::channel(64);
tokio::spawn(stream_session(transcript, cursor, live, tx));
Sse::new(ReceiverStream::new(stream).map(Ok)).keep_alive(KeepAlive::default())
}
/// `GET /sessions/{id}/subagents`: every subagent this session has started,
/// oldest first, with a status read from its own transcript -- see
/// `SUBAGENTS.md`'s wire shape. A subagent whose last status is `Running` is
/// reported `Unknown` instead when the session itself is not running: its
/// process was the session's, and a session with none has nothing left to
/// ask.
async fn list_subagents(
State(manager): State<Arc<SessionManager>>,
UrlPath(id): UrlPath<String>,
) -> Result<axum::Json<Vec<SubagentInfo>>, ApiError> {
let session = lookup(&manager, &id)?;
// Anything but `Exited` or `Unknown` has a process behind it, which is
// what decides whether a subagent still reading `Running` from its own
// transcript can be believed -- see `SUBAGENTS.md`'s wire shape.
let running = !matches!(
session.status(),
crate::session::driver::SessionStatus::Exited
| crate::session::driver::SessionStatus::Unknown
);
Ok(axum::Json(session.subagents().list(running)))
} }
/// Every session's attention-wanting moments, on one stream. /// Every session's attention-wanting moments, on one stream.
+29 -6
View File
@@ -59,6 +59,7 @@ use tokio::sync::mpsc;
use super::driver::{AttachmentRef, Driver, Event, EventSink, SessionStatus, Unqueued}; use super::driver::{AttachmentRef, Driver, Event, EventSink, SessionStatus, Unqueued};
use super::process; use super::process;
use super::subagent::Subagents;
use super::transport::{Launch, Streams, Transport}; use super::transport::{Launch, Streams, Transport};
use crate::config::{ProviderConfig, SessionConfig}; use crate::config::{ProviderConfig, SessionConfig};
use translate::{AnswerOutcome, Setting, Translator, starts_a_model_call}; use translate::{AnswerOutcome, Setting, Translator, starts_a_model_call};
@@ -213,8 +214,12 @@ impl ClaudeDriver {
transport: &Transport, transport: &Transport,
session_dir: &Path, session_dir: &Path,
sink: EventSink, sink: EventSink,
subagents: Arc<Subagents>,
) -> Result<Self> { ) -> Result<Self> {
let state = Arc::new(Mutex::new(Translator::new(session_dir.to_path_buf()))); let state = Arc::new(Mutex::new(Translator::new(
session_dir.to_path_buf(),
subagents,
)));
let queue = Arc::new(Mutex::new(Queue::default())); let queue = Arc::new(Mutex::new(Queue::default()));
let reading = Arc::new(AtomicBool::new(true)); let reading = Arc::new(AtomicBool::new(true));
@@ -351,6 +356,12 @@ impl ClaudeDriver {
if let Some(mode) = &meta.permission_mode { if let Some(mode) = &meta.permission_mode {
push("--permission-mode", mode); push("--permission-mode", mode);
} }
// Launch-only: see `SessionConfig::effort`. Omitted entirely when
// unset, so the CLI's own default is what an unchosen session gets
// rather than a level this app decided to call the default.
if let Some(effort) = &meta.effort {
push("--effort", effort);
}
// Named at birth, so this session is the same session in the CLI's own // Named at birth, so this session is the same session in the CLI's own
// picker and in what other agents see. // picker and in what other agents see.
// //
@@ -386,7 +397,7 @@ impl ClaudeDriver {
let stdout = create_log(&session_dir.join(STDOUT_LOG))?; let stdout = create_log(&session_dir.join(STDOUT_LOG))?;
let stderr = create_log(&session_dir.join(STDERR_LOG))?; let stderr = create_log(&session_dir.join(STDERR_LOG))?;
let program = provider.command.as_deref().unwrap_or("claude"); let program = provider.program();
let launch = Launch::new(program, args, meta.cwd.as_deref()); let launch = Launch::new(program, args, meta.cwd.as_deref());
let child = transport.spawn( let child = transport.spawn(
&launch, &launch,
@@ -1163,7 +1174,10 @@ mod tests {
/// this" and "the transcript records that". /// this" and "the transcript records that".
fn events_from_lines(lines: &[&str]) -> Vec<Event> { fn events_from_lines(lines: &[&str]) -> Vec<Event> {
let dir = tempfile::tempdir().expect("temp dir"); let dir = tempfile::tempdir().expect("temp dir");
let state = Arc::new(Mutex::new(Translator::new(dir.path().to_path_buf()))); let state = Arc::new(Mutex::new(Translator::new(
dir.path().to_path_buf(),
Arc::new(Subagents::new(dir.path().to_path_buf())),
)));
let queue = Arc::new(Mutex::new(Queue::default())); let queue = Arc::new(Mutex::new(Queue::default()));
let (sink, mut out) = mpsc::unbounded_channel::<Event>(); let (sink, mut out) = mpsc::unbounded_channel::<Event>();
for line in lines { for line in lines {
@@ -1187,7 +1201,10 @@ mod tests {
interject: impl FnOnce(&Arc<Mutex<Queue>>), interject: impl FnOnce(&Arc<Mutex<Queue>>),
) -> Vec<Event> { ) -> Vec<Event> {
let dir = tempfile::tempdir().expect("temp dir"); let dir = tempfile::tempdir().expect("temp dir");
let state = Arc::new(Mutex::new(Translator::new(dir.path().to_path_buf()))); let state = Arc::new(Mutex::new(Translator::new(
dir.path().to_path_buf(),
Arc::new(Subagents::new(dir.path().to_path_buf())),
)));
let queue = Arc::new(Mutex::new(Queue::default())); let queue = Arc::new(Mutex::new(Queue::default()));
let (sink, mut out) = mpsc::unbounded_channel::<Event>(); let (sink, mut out) = mpsc::unbounded_channel::<Event>();
let mut interject = Some(interject); let mut interject = Some(interject);
@@ -1481,7 +1498,10 @@ mod tests {
// doing. // doing.
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
let (sink, mut received) = mpsc::unbounded_channel(); let (sink, mut received) = mpsc::unbounded_channel();
let state = Arc::new(Mutex::new(Translator::new(dir.path().to_path_buf()))); let state = Arc::new(Mutex::new(Translator::new(
dir.path().to_path_buf(),
Arc::new(Subagents::new(dir.path().to_path_buf())),
)));
let queue = Arc::new(Mutex::new(Queue::default())); let queue = Arc::new(Mutex::new(Queue::default()));
let text = r#"{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"working"}},"parent_tool_use_id":null}"#; let text = r#"{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"working"}},"parent_tool_use_id":null}"#;
@@ -1526,7 +1546,10 @@ mod tests {
// session back to work. // session back to work.
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
let (sink, mut received) = mpsc::unbounded_channel(); let (sink, mut received) = mpsc::unbounded_channel();
let state = Arc::new(Mutex::new(Translator::new(dir.path().to_path_buf()))); let state = Arc::new(Mutex::new(Translator::new(
dir.path().to_path_buf(),
Arc::new(Subagents::new(dir.path().to_path_buf())),
)));
let queue = Arc::new(Mutex::new(Queue::default())); let queue = Arc::new(Mutex::new(Queue::default()));
queue.lock().unwrap().close(&sink, "the session ended"); queue.lock().unwrap().close(&sink, "the session ended");
+512 -40
View File
@@ -11,10 +11,12 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use serde_json::{Value, json}; use serde_json::{Value, json};
use super::super::driver::{Event, QuestionOption, SessionStatus, context_tokens}; use super::super::driver::{Event, QuestionOption, SessionStatus, context_tokens};
use super::super::subagent::Subagents;
/// Whether this line is the CLI opening a fresh model call. /// Whether this line is the CLI opening a fresh model call.
/// ///
@@ -92,10 +94,20 @@ pub(super) struct Translator {
/// reports none rather than repeating the previous turn's. /// reports none rather than repeating the previous turn's.
context: Option<u64>, context: Option<u64>,
session_dir: PathBuf, session_dir: PathBuf,
/// This session's subagents, shared with every child translator below --
/// see `SUBAGENTS.md`. One registry per session, so a subagent started
/// through this translator or any of its children lands in the same
/// place a route reads it back from.
subagents: Arc<Subagents>,
/// One translator per subagent id, holding *its* streaming and
/// tool-tracking state -- separate from the parent's because tool ids
/// are unique but a `stream_event`'s content-block index is not, and
/// parallel subagents interleave their deltas on one stdout.
children: HashMap<String, Arc<Mutex<Translator>>>,
} }
impl Translator { impl Translator {
pub(super) fn new(session_dir: PathBuf) -> Self { pub(super) fn new(session_dir: PathBuf, subagents: Arc<Subagents>) -> Self {
Self { Self {
session_id: None, session_id: None,
pending: HashMap::new(), pending: HashMap::new(),
@@ -103,6 +115,8 @@ impl Translator {
interrupting: false, interrupting: false,
context: None, context: None,
session_dir, session_dir,
subagents,
children: HashMap::new(),
} }
} }
@@ -122,13 +136,77 @@ impl Translator {
pub(super) fn translate(&mut self, message: &Value) -> Vec<Event> { pub(super) fn translate(&mut self, message: &Value) -> Vec<Event> {
// Events from subagents (Task tool internals) carry a // Events from subagents (Task tool internals) carry a
// parent_tool_use_id; the transcript shows the Task tool's own // parent_tool_use_id; the transcript shows the Task tool's own
// start/end instead of every nested step. // start/end instead of every nested step. Routed into that
if message // subagent's own transcript rather than dropped -- see
.get("parent_tool_use_id") // `SUBAGENTS.md`.
.is_some_and(|id| !id.is_null()) if let Some(parent_id) = message.get("parent_tool_use_id").and_then(Value::as_str) {
{ return self.translate_child(parent_id, message);
return Vec::new();
} }
self.dispatch(message)
}
/// A line belonging to a subagent rather than to this translator's own
/// session. Always returns nothing to the *caller*: everything it
/// produces goes into the subagent's own transcript instead.
fn translate_child(&mut self, id: &str, message: &Value) -> Vec<Event> {
match self.subagents.get(id) {
Some(subagent) if !subagent.is_open() => {
// Not stale: the Task tool runs in the background by
// default, so a finished subagent can still be sent another
// message later (SendMessage) and start working again. A
// line arriving after `finish` means exactly that, not a
// conversation that is over -- see `SUBAGENTS.md`.
self.subagents.reopen(id);
}
Some(_) => {}
None => {
// Nobody has heard of this id yet: the Task call itself
// either has not been seen or never will be. Started here
// with the best title available -- the tool name of this
// first line -- since SUBAGENTS.md's real title only
// arrives with the Task call.
self.subagents.start(id, &fallback_title(message), None);
}
}
let child = self
.children
.entry(id.to_string())
.or_insert_with(|| {
Arc::new(Mutex::new(Translator::new(
self.session_dir.clone(),
Arc::clone(&self.subagents),
)))
})
.clone();
let events = child.lock().unwrap().dispatch(message);
for event in events {
// The subagent's own vocabulary is Running/Exited/Unknown, never
// Idle -- a background Task is either working or it has ended,
// never merely "between turns" the way a session is. Dropped
// here rather than never produced, so a `result` line's own
// `Idle` (dispatch's ordinary end-of-turn event, for a subagent
// dialect that ever sends one) is caught the same way a
// `message_delta` would be.
if !matches!(
event,
Event::Status {
state: SessionStatus::Idle
}
) {
self.subagents.record(id, event);
}
}
// What actually ends a subagent's turn: not the parent's
// `tool_result`, which for a background Task arrives at launch
// ("Async agent launched...") long before the work is done -- see
// `SUBAGENTS.md`.
if ends_a_turn(message) {
self.subagents.finish(id);
}
Vec::new()
}
fn dispatch(&mut self, message: &Value) -> Vec<Event> {
match message.get("type").and_then(Value::as_str) { match message.get("type").and_then(Value::as_str) {
Some("system") => self.translate_system(message), Some("system") => self.translate_system(message),
// The CLI's own announcement that `/clear` took effect, sent just // The CLI's own announcement that `/clear` took effect, sent just
@@ -224,12 +302,12 @@ impl Translator {
.and_then(Value::as_bool) .and_then(Value::as_bool)
.unwrap_or(false) .unwrap_or(false)
{ {
events.push(Event::Error { let said = message.get("result").and_then(Value::as_str);
message: message events.push(match said.and_then(usage_limit) {
.get("result") Some(resets_at) => Event::LimitReached { resets_at },
.and_then(Value::as_str) None => Event::Error {
.unwrap_or("the turn ended with an error") message: said.unwrap_or("the turn ended with an error").to_string(),
.to_string(), },
}); });
} }
let context = self.context.take(); let context = self.context.take();
@@ -379,22 +457,47 @@ impl Translator {
content content
.iter() .iter()
.filter(|block| block.get("type").and_then(Value::as_str) == Some("tool_use")) .filter(|block| block.get("type").and_then(Value::as_str) == Some("tool_use"))
.map(|block| Event::ToolStart { .map(|block| {
id: block let id = block
.get("id") .get("id")
.and_then(Value::as_str) .and_then(Value::as_str)
.unwrap_or_default() .unwrap_or_default()
.to_string(), .to_string();
tool: block let tool = block
.get("name") .get("name")
.and_then(Value::as_str) .and_then(Value::as_str)
.unwrap_or_default() .unwrap_or_default()
.to_string(), .to_string();
input: block.get("input").cloned().unwrap_or(Value::Null), let input = block.get("input").cloned().unwrap_or(Value::Null);
// A subagent this call is about to start -- see
// `SUBAGENTS.md`'s lifecycle #1. The parent's own transcript
// still shows only the Task call itself, below.
if tool == "Task" || tool == "Agent" {
self.start_subagent_from_task(&id, &input);
}
Event::ToolStart { id, tool, input }
}) })
.collect() .collect()
} }
/// Starts the subagent a Task call names, with the title and prompt
/// SUBAGENTS.md describes: the call's `description`, then
/// `(<subagent_type>)` when one is given, falling back to the tool's own
/// name when there is no description to build one from.
fn start_subagent_from_task(&self, id: &str, input: &Value) {
let description = text_field(input, "description");
let subagent_type = text_field(input, "subagent_type");
let prompt = input.get("prompt").and_then(Value::as_str);
let title = match (description, subagent_type) {
(Some(description), Some(subagent_type)) => {
format!("{description} ({subagent_type})")
}
(Some(description), None) => description,
(None, _) => "Task".to_string(),
};
self.subagents.start(id, &title, prompt);
}
fn translate_control_request(&mut self, message: &Value) -> Vec<Event> { fn translate_control_request(&mut self, message: &Value) -> Vec<Event> {
let request = &message["request"]; let request = &message["request"];
if request.get("subtype").and_then(Value::as_str) != Some("can_use_tool") { if request.get("subtype").and_then(Value::as_str) != Some("can_use_tool") {
@@ -598,11 +701,90 @@ impl Translator {
id: about.clone(), id: about.clone(),
output: texts.join("\n"), output: texts.join("\n"),
}); });
// Deliberately does *not* finish a subagent `about` might name:
// the Task tool runs in the background by default, so this
// `tool_result` -- "Async agent launched..." -- arrives at
// launch, long before the subagent's own work is done. What
// ends it is its own turn ending, handled in `translate_child`.
} }
events events
} }
} }
/// The title to start a subagent under when its own first line arrives
/// before (or without) its Task call ever being seen: the tool name of that
/// first line, which is the only thing known about it yet. `"subagent"` for
/// a line this cannot even find a tool name in, such as one that opens with
/// something other than a tool call.
fn fallback_title(message: &Value) -> String {
message["message"]["content"]
.as_array()
.into_iter()
.flatten()
.find(|block| block.get("type").and_then(Value::as_str) == Some("tool_use"))
.and_then(|block| block.get("name"))
.and_then(Value::as_str)
.unwrap_or("subagent")
.to_string()
}
/// Whether this line is a subagent's *own* turn ending -- the only thing
/// that does, per `SUBAGENTS.md`: not the parent's `tool_result`, which for
/// a background Task arrives at launch rather than at completion.
///
/// Checked on the raw line rather than on what `dispatch` returns, so this
/// never has to touch the shared `translate_stream_event`/`dispatch` code a
/// top-level session's own turn-ending also goes through -- a subagent's
/// idea of "ended" must not change when a real session's does.
///
/// `message_delta` is the raw API's own signal, carrying the stop reason:
/// `end_turn` is genuinely done, `tool_use` means the model is about to call
/// one and there is more coming. A `result` line is the CLI's own shape for
/// a top-level turn; a subagent has not been observed to send one, but
/// SUBAGENTS.md counts it too in case a future CLI version does.
fn ends_a_turn(message: &Value) -> bool {
match message.get("type").and_then(Value::as_str) {
Some("stream_event") => {
let event = &message["event"];
event.get("type").and_then(Value::as_str) == Some("message_delta")
&& event["delta"].get("stop_reason").and_then(Value::as_str) == Some("end_turn")
}
Some("result") => true,
_ => false,
}
}
/// Whether a failed turn failed because the account is out of quota, and when
/// the CLI said the limit lifts.
///
/// The wording is the CLI's: a turn stopped by the limit ends with `is_error`
/// and a result of `Claude AI usage limit reached|1788546972`, the reset being
/// epoch seconds after a pipe. Matched on the sentence rather than on a code
/// because the CLI sends none, so this is deliberately loose about everything
/// but the four words.
///
/// The two `None`s mean different things and both are real. The outer one is
/// "some other failure". The inner one is "the limit is reached and the CLI did
/// not say until when" -- which is not a reason to invent a time: `crate::resume`
/// asks the usage endpoint before sending anything, and that answer is the one
/// that decides.
///
/// Milliseconds are accepted as well as seconds and told apart by magnitude,
/// since a wrong guess would schedule a resume tens of thousands of years out
/// and look exactly like auto-resume being broken.
fn usage_limit(result: &str) -> Option<Option<f64>> {
if !result.to_ascii_lowercase().contains("usage limit reached") {
return None;
}
let stamp = result
.rsplit('|')
.next()
.and_then(|tail| tail.trim().parse::<f64>().ok())
.filter(|stamp| *stamp > 0.0)
.map(|stamp| if stamp > 1e11 { stamp / 1000.0 } else { stamp });
Some(stamp)
}
/// A string field that is there and not empty, or `None`. The CLI omits these /// A string field that is there and not empty, or `None`. The CLI omits these
/// rather than sending them empty, but a caller that sends `""` means the same /// rather than sending them empty, but a caller that sends `""` means the same
/// thing and should not produce a description that draws as a blank line. /// thing and should not produce a description that draws as a blank line.
@@ -665,10 +847,18 @@ mod tests {
.collect() .collect()
} }
/// A fresh, empty subagent registry over the same temp dir a test's
/// translator writes into -- every test here is about the parent's own
/// events, so what a registry does with a subagent is `subagent.rs`'s
/// tests to make, not these.
fn test_subagents(dir: &tempfile::TempDir) -> Arc<Subagents> {
Arc::new(Subagents::new(dir.path().to_path_buf()))
}
#[test] #[test]
fn captures_the_resume_token_and_the_settings_from_init() { fn captures_the_resume_token_and_the_settings_from_init() {
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
let mut translator = Translator::new(dir.path().to_path_buf()); let mut translator = Translator::new(dir.path().to_path_buf(), test_subagents(&dir));
let events = translate_lines( let events = translate_lines(
&mut translator, &mut translator,
&[ &[
@@ -690,7 +880,7 @@ mod tests {
#[test] #[test]
fn a_setting_is_reported_when_the_cli_accepts_it_and_not_before() { fn a_setting_is_reported_when_the_cli_accepts_it_and_not_before() {
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
let mut translator = Translator::new(dir.path().to_path_buf()); let mut translator = Translator::new(dir.path().to_path_buf(), test_subagents(&dir));
// What `set_model` does: remember, send, and say nothing yet. // What `set_model` does: remember, send, and say nothing yet.
translator.expect_setting("req-a".to_string(), Setting::Model("sonnet".to_string())); translator.expect_setting("req-a".to_string(), Setting::Model("sonnet".to_string()));
@@ -767,7 +957,7 @@ mod tests {
// The line it sends just after answering `set_permission_mode`, which is // The line it sends just after answering `set_permission_mode`, which is
// also how a mode changed from the terminal arrives. // also how a mode changed from the terminal arrives.
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
let mut translator = Translator::new(dir.path().to_path_buf()); let mut translator = Translator::new(dir.path().to_path_buf(), test_subagents(&dir));
let events = translate_lines( let events = translate_lines(
&mut translator, &mut translator,
&[ &[
@@ -787,7 +977,7 @@ mod tests {
fn streams_text_deltas_and_skips_the_consolidated_copy() { fn streams_text_deltas_and_skips_the_consolidated_copy() {
// Real lines (trimmed) from the 2.1.237 probe. // Real lines (trimmed) from the 2.1.237 probe.
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
let mut translator = Translator::new(dir.path().to_path_buf()); let mut translator = Translator::new(dir.path().to_path_buf(), test_subagents(&dir));
let events = translate_lines( let events = translate_lines(
&mut translator, &mut translator,
&[ &[
@@ -807,7 +997,7 @@ mod tests {
#[test] #[test]
fn tool_use_and_result_become_tool_events() { fn tool_use_and_result_become_tool_events() {
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
let mut translator = Translator::new(dir.path().to_path_buf()); let mut translator = Translator::new(dir.path().to_path_buf(), test_subagents(&dir));
let events = translate_lines( let events = translate_lines(
&mut translator, &mut translator,
&[ &[
@@ -834,7 +1024,7 @@ mod tests {
#[test] #[test]
fn subagent_events_are_not_duplicated_into_the_transcript() { fn subagent_events_are_not_duplicated_into_the_transcript() {
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
let mut translator = Translator::new(dir.path().to_path_buf()); let mut translator = Translator::new(dir.path().to_path_buf(), test_subagents(&dir));
let events = translate_lines( let events = translate_lines(
&mut translator, &mut translator,
&[ &[
@@ -844,10 +1034,243 @@ mod tests {
assert!(events.is_empty()); assert!(events.is_empty());
} }
/// A child line does not just vanish from the parent -- it lands in its
/// own subagent's transcript, with that transcript's own sequence
/// numbers, starting at 1 like any other.
#[test]
fn a_child_line_lands_in_its_own_subagents_transcript() {
let dir = tempfile::tempdir().expect("tempdir");
let subagents = test_subagents(&dir);
let mut translator = Translator::new(dir.path().to_path_buf(), Arc::clone(&subagents));
translate_lines(
&mut translator,
&[
r#"{"type":"assistant","message":{"content":[{"type":"tool_use","id":"toolu_c1","name":"Bash","input":{"command":"echo hi"}}]},"parent_tool_use_id":"toolu_parent"}"#,
],
);
let subagent = subagents.get("toolu_parent").expect("subagent started");
let lines = crate::session::transcript::read_after(&subagent.transcript_path(), 0)
.expect("read subagent transcript");
assert_eq!(lines[0].seq, 1);
assert_eq!(
lines[0].event,
Event::Status {
state: SessionStatus::Running
}
);
assert!(
lines.iter().any(
|entry| matches!(&entry.event, Event::ToolStart { tool, .. } if tool == "Bash")
)
);
}
/// The title and prompt shown for a subagent come from the Task call
/// that started it, not from anything guessed at its first line.
#[test]
fn the_subagent_takes_its_title_and_prompt_from_the_task_call() {
let dir = tempfile::tempdir().expect("tempdir");
let subagents = test_subagents(&dir);
let mut translator = Translator::new(dir.path().to_path_buf(), Arc::clone(&subagents));
translate_lines(
&mut translator,
&[
r#"{"type":"assistant","message":{"content":[{"type":"tool_use","id":"toolu_task","name":"Task","input":{"description":"Investigate the bug","prompt":"Find why X fails","subagent_type":"general-purpose"}}]},"parent_tool_use_id":null}"#,
],
);
let rows = subagents.list(true);
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].title, "Investigate the bug (general-purpose)");
let subagent = subagents.get(&rows[0].id).expect("subagent");
let lines = crate::session::transcript::read_after(&subagent.transcript_path(), 0)
.expect("read subagent transcript");
assert!(lines.iter().any(
|entry| matches!(&entry.event, Event::UserMessage { text, .. } if text == "Find why X fails")
));
}
/// The parent's `tool_result` for the Task id is what ends the
/// subagent -- SUBAGENTS.md's lifecycle #3 -- and nothing else does.
#[test]
fn the_parents_tool_result_does_not_finish_the_subagent() {
// The Task tool runs in the background by default: this
// `tool_result` is "Async agent launched...", arriving the moment
// the subagent *starts*, while it goes on working for however long
// its own turn takes. Finishing it here was the bug -- a running
// background agent read as "finished" with its transcript truncated
// at launch.
let dir = tempfile::tempdir().expect("tempdir");
let subagents = test_subagents(&dir);
let mut translator = Translator::new(dir.path().to_path_buf(), Arc::clone(&subagents));
translate_lines(
&mut translator,
&[
r#"{"type":"assistant","message":{"content":[{"type":"tool_use","id":"toolu_task2","name":"Task","input":{"description":"helper"}}]},"parent_tool_use_id":null}"#,
],
);
let subagent = subagents.get("toolu_task2").expect("subagent started");
assert!(subagent.is_open());
translate_lines(
&mut translator,
&[
r#"{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_task2","content":"Async agent launched","is_error":false}]},"parent_tool_use_id":null}"#,
],
);
assert!(subagent.is_open());
}
/// What actually ends a subagent: the raw API's own `message_delta`
/// saying its turn stopped with `end_turn`. Never written into the
/// subagent's own transcript as `Idle` -- its vocabulary has no such
/// state.
#[test]
fn the_subagents_own_end_turn_finishes_it() {
let dir = tempfile::tempdir().expect("tempdir");
let subagents = test_subagents(&dir);
let mut translator = Translator::new(dir.path().to_path_buf(), Arc::clone(&subagents));
translate_lines(
&mut translator,
&[
r#"{"type":"assistant","message":{"content":[{"type":"tool_use","id":"toolu_task3","name":"Task","input":{"description":"helper"}}]},"parent_tool_use_id":null}"#,
r#"{"type":"stream_event","event":{"type":"message_delta","delta":{"stop_reason":"end_turn"}},"parent_tool_use_id":"toolu_task3"}"#,
],
);
let subagent = subagents.get("toolu_task3").expect("subagent started");
assert!(!subagent.is_open());
let lines = crate::session::transcript::read_after(&subagent.transcript_path(), 0)
.expect("read subagent transcript");
assert!(
!lines
.iter()
.any(|entry| matches!(&entry.event, Event::Status { state } if *state == SessionStatus::Idle)),
"a subagent's transcript must never carry Idle: {lines:?}"
);
assert_eq!(
lines.last().unwrap().event,
Event::Status {
state: SessionStatus::Exited
}
);
}
/// `stop_reason: "tool_use"` is the model about to call a tool, with
/// more of the turn still coming -- not an end.
#[test]
fn a_stop_reason_of_tool_use_does_not_finish_the_subagent() {
let dir = tempfile::tempdir().expect("tempdir");
let subagents = test_subagents(&dir);
let mut translator = Translator::new(dir.path().to_path_buf(), Arc::clone(&subagents));
translate_lines(
&mut translator,
&[
r#"{"type":"assistant","message":{"content":[{"type":"tool_use","id":"toolu_task4","name":"Task","input":{"description":"helper"}}]},"parent_tool_use_id":null}"#,
r#"{"type":"stream_event","event":{"type":"message_delta","delta":{"stop_reason":"tool_use"}},"parent_tool_use_id":"toolu_task4"}"#,
],
);
assert!(
subagents
.get("toolu_task4")
.expect("subagent started")
.is_open()
);
}
/// A background Task can be sent another message long after its first
/// turn ended -- a further child line for it reopens rather than being
/// dropped, and the same transcript and child translator carry on.
#[test]
fn a_line_after_finish_reopens_the_subagent_rather_than_being_dropped() {
let dir = tempfile::tempdir().expect("tempdir");
let subagents = test_subagents(&dir);
let mut translator = Translator::new(dir.path().to_path_buf(), Arc::clone(&subagents));
translate_lines(
&mut translator,
&[
r#"{"type":"assistant","message":{"content":[{"type":"tool_use","id":"toolu_task5","name":"Task","input":{"description":"helper"}}]},"parent_tool_use_id":null}"#,
r#"{"type":"stream_event","event":{"type":"message_delta","delta":{"stop_reason":"end_turn"}},"parent_tool_use_id":"toolu_task5"}"#,
],
);
let subagent = subagents.get("toolu_task5").expect("subagent started");
assert!(!subagent.is_open());
translate_lines(
&mut translator,
&[
r#"{"type":"assistant","message":{"content":[{"type":"tool_use","id":"toolu_more","name":"Bash","input":{}}]},"parent_tool_use_id":"toolu_task5"}"#,
],
);
assert!(subagent.is_open());
let lines = crate::session::transcript::read_after(&subagent.transcript_path(), 0)
.expect("read subagent transcript");
// Running, [prompt], Exited, Running (reopened), then the new line's
// own ToolStart -- the same transcript throughout, not a new one.
assert!(
lines.iter().any(
|entry| matches!(&entry.event, Event::ToolStart { tool, .. } if tool == "Bash")
)
);
assert_eq!(
lines
.iter()
.filter(|entry| matches!(
&entry.event,
Event::Status {
state: SessionStatus::Running
}
))
.count(),
2,
"expected one Running at creation and one at the reopen: {lines:?}"
);
}
/// Two subagents running at once keep two separate transcripts: tool ids
/// are unique but a `stream_event`'s content-block index is not, so
/// sharing translation state between them would cross their streams.
#[test]
fn two_parallel_subagents_keep_separate_transcripts() {
let dir = tempfile::tempdir().expect("tempdir");
let subagents = test_subagents(&dir);
let mut translator = Translator::new(dir.path().to_path_buf(), Arc::clone(&subagents));
translate_lines(
&mut translator,
&[
r#"{"type":"assistant","message":{"content":[{"type":"tool_use","id":"toolu_a","name":"Bash","input":{}}]},"parent_tool_use_id":"toolu_task_a"}"#,
r#"{"type":"assistant","message":{"content":[{"type":"tool_use","id":"toolu_b","name":"Read","input":{}}]},"parent_tool_use_id":"toolu_task_b"}"#,
],
);
let a = subagents.get("toolu_task_a").expect("subagent a");
let b = subagents.get("toolu_task_b").expect("subagent b");
let a_events = crate::session::transcript::read_after(&a.transcript_path(), 0)
.expect("read a's transcript");
let b_events = crate::session::transcript::read_after(&b.transcript_path(), 0)
.expect("read b's transcript");
assert!(
a_events.iter().any(
|entry| matches!(&entry.event, Event::ToolStart { tool, .. } if tool == "Bash")
)
);
assert!(
b_events.iter().any(
|entry| matches!(&entry.event, Event::ToolStart { tool, .. } if tool == "Read")
)
);
assert!(
!a_events.iter().any(
|entry| matches!(&entry.event, Event::ToolStart { tool, .. } if tool == "Read")
)
);
assert!(
!b_events.iter().any(
|entry| matches!(&entry.event, Event::ToolStart { tool, .. } if tool == "Bash")
)
);
}
#[test] #[test]
fn a_permission_request_becomes_an_allow_deny_question() { fn a_permission_request_becomes_an_allow_deny_question() {
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
let mut translator = Translator::new(dir.path().to_path_buf()); let mut translator = Translator::new(dir.path().to_path_buf(), test_subagents(&dir));
let events = translate_lines( let events = translate_lines(
&mut translator, &mut translator,
&[ &[
@@ -896,7 +1319,7 @@ mod tests {
#[test] #[test]
fn denying_a_permission_sends_deny() { fn denying_a_permission_sends_deny() {
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
let mut translator = Translator::new(dir.path().to_path_buf()); let mut translator = Translator::new(dir.path().to_path_buf(), test_subagents(&dir));
translate_lines( translate_lines(
&mut translator, &mut translator,
&[ &[
@@ -914,7 +1337,7 @@ mod tests {
// The real 2.1.237 shape, verified live: answers go back inside // The real 2.1.237 shape, verified live: answers go back inside
// updatedInput, keyed by the question text. // updatedInput, keyed by the question text.
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
let mut translator = Translator::new(dir.path().to_path_buf()); let mut translator = Translator::new(dir.path().to_path_buf(), test_subagents(&dir));
let events = translate_lines( let events = translate_lines(
&mut translator, &mut translator,
&[ &[
@@ -966,7 +1389,7 @@ mod tests {
// in the event: a phone that had to read this dialect's tool input to // in the event: a phone that had to read this dialect's tool input to
// find them would be the only place that knew how. // find them would be the only place that knew how.
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
let mut translator = Translator::new(dir.path().to_path_buf()); let mut translator = Translator::new(dir.path().to_path_buf(), test_subagents(&dir));
let events = translate_lines( let events = translate_lines(
&mut translator, &mut translator,
&[ &[
@@ -1014,7 +1437,7 @@ mod tests {
#[test] #[test]
fn images_in_tool_results_are_saved_and_referenced() { fn images_in_tool_results_are_saved_and_referenced() {
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
let mut translator = Translator::new(dir.path().to_path_buf()); let mut translator = Translator::new(dir.path().to_path_buf(), test_subagents(&dir));
// A 1x1 PNG, the smallest real payload worth round-tripping. // A 1x1 PNG, the smallest real payload worth round-tripping.
let png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="; let png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
let line = format!( let line = format!(
@@ -1043,7 +1466,7 @@ mod tests {
#[test] #[test]
fn a_turn_result_reports_usage_and_returns_to_idle() { fn a_turn_result_reports_usage_and_returns_to_idle() {
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
let mut translator = Translator::new(dir.path().to_path_buf()); let mut translator = Translator::new(dir.path().to_path_buf(), test_subagents(&dir));
let events = translate_lines( let events = translate_lines(
&mut translator, &mut translator,
&[ &[
@@ -1078,7 +1501,7 @@ mod tests {
#[test] #[test]
fn a_turn_started_by_another_agent_records_who_and_what() { fn a_turn_started_by_another_agent_records_who_and_what() {
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
let mut translator = Translator::new(dir.path().to_path_buf()); let mut translator = Translator::new(dir.path().to_path_buf(), test_subagents(&dir));
let events = translate_lines( let events = translate_lines(
&mut translator, &mut translator,
&[ &[
@@ -1112,7 +1535,7 @@ mod tests {
#[test] #[test]
fn an_ordinary_turn_carries_no_peer_note() { fn an_ordinary_turn_carries_no_peer_note() {
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
let mut translator = Translator::new(dir.path().to_path_buf()); let mut translator = Translator::new(dir.path().to_path_buf(), test_subagents(&dir));
let events = translate_lines( let events = translate_lines(
&mut translator, &mut translator,
&[ &[
@@ -1137,7 +1560,7 @@ mod tests {
#[test] #[test]
fn the_context_is_what_the_last_message_held_not_the_turn_added_up() { fn the_context_is_what_the_last_message_held_not_the_turn_added_up() {
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
let mut translator = Translator::new(dir.path().to_path_buf()); let mut translator = Translator::new(dir.path().to_path_buf(), test_subagents(&dir));
let events = translate_lines( let events = translate_lines(
&mut translator, &mut translator,
&[ &[
@@ -1177,7 +1600,7 @@ mod tests {
// Note the snake_case keys -- the CLI's transcript file writes the same // Note the snake_case keys -- the CLI's transcript file writes the same
// records in camelCase. // records in camelCase.
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
let mut translator = Translator::new(dir.path().to_path_buf()); let mut translator = Translator::new(dir.path().to_path_buf(), test_subagents(&dir));
let events = translate_lines( let events = translate_lines(
&mut translator, &mut translator,
&[ &[
@@ -1207,7 +1630,7 @@ mod tests {
#[test] #[test]
fn a_failed_compaction_says_why_and_leaves_the_turn_running() { fn a_failed_compaction_says_why_and_leaves_the_turn_running() {
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
let mut translator = Translator::new(dir.path().to_path_buf()); let mut translator = Translator::new(dir.path().to_path_buf(), test_subagents(&dir));
let events = translate_lines( let events = translate_lines(
&mut translator, &mut translator,
&[ &[
@@ -1234,7 +1657,7 @@ mod tests {
#[test] #[test]
fn a_boundary_without_counts_says_so_rather_than_inventing_them() { fn a_boundary_without_counts_says_so_rather_than_inventing_them() {
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
let mut translator = Translator::new(dir.path().to_path_buf()); let mut translator = Translator::new(dir.path().to_path_buf(), test_subagents(&dir));
let events = translate_lines( let events = translate_lines(
&mut translator, &mut translator,
&[ &[
@@ -1254,7 +1677,7 @@ mod tests {
#[test] #[test]
fn an_error_result_surfaces_the_message() { fn an_error_result_surfaces_the_message() {
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
let mut translator = Translator::new(dir.path().to_path_buf()); let mut translator = Translator::new(dir.path().to_path_buf(), test_subagents(&dir));
let events = translate_lines( let events = translate_lines(
&mut translator, &mut translator,
&[ &[
@@ -1275,6 +1698,55 @@ mod tests {
); );
} }
/// Running out of quota is a state, not a failure of the work.
///
/// The naive reading -- an error result like any other -- is what shipped
/// before this: the transcript said "Claude AI usage limit reached|…" in
/// red, which is neither readable nor actionable, and nothing above the
/// driver could tell it apart from a broken tool call.
#[test]
fn a_turn_stopped_by_the_usage_limit_says_so_and_carries_the_reset() {
let dir = tempfile::tempdir().expect("tempdir");
let mut translator = Translator::new(dir.path().to_path_buf(), test_subagents(&dir));
let events = translate_lines(
&mut translator,
&[
r#"{"type":"result","subtype":"error_during_execution","is_error":true,"result":"Claude AI usage limit reached|1788546972","usage":{}}"#,
],
);
assert_eq!(
events[0],
Event::LimitReached {
resets_at: Some(1_788_546_972.0)
}
);
}
#[test]
fn a_limit_the_cli_gave_no_reset_for_is_reported_without_one() {
let dir = tempfile::tempdir().expect("tempdir");
let mut translator = Translator::new(dir.path().to_path_buf(), test_subagents(&dir));
let events = translate_lines(
&mut translator,
&[
r#"{"type":"result","subtype":"error_during_execution","is_error":true,"result":"Claude AI usage limit reached","usage":{}}"#,
],
);
// Not a time this side invented: the meter is asked before anything is
// sent, and a made-up reset would only decide when to ask.
assert_eq!(events[0], Event::LimitReached { resets_at: None });
}
#[test]
fn a_reset_in_milliseconds_is_not_read_as_the_year_58000() {
assert_eq!(
usage_limit("Claude AI usage limit reached|1788546972000"),
Some(Some(1_788_546_972.0))
);
// And anything that is not the limit stays an ordinary failure.
assert_eq!(usage_limit("something broke"), None);
}
/// Pressing Stop is not a failure, and the CLI cannot tell you which it was. /// Pressing Stop is not a failure, and the CLI cannot tell you which it was.
/// ///
/// An interrupted turn arrives as exactly the same shape a broken one does, /// An interrupted turn arrives as exactly the same shape a broken one does,
@@ -1286,7 +1758,7 @@ mod tests {
#[test] #[test]
fn a_turn_stopped_on_purpose_is_not_an_error() { fn a_turn_stopped_on_purpose_is_not_an_error() {
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
let mut translator = Translator::new(dir.path().to_path_buf()); let mut translator = Translator::new(dir.path().to_path_buf(), test_subagents(&dir));
let stopped_result = r#"{"type":"result","subtype":"error_during_execution","is_error":true,"result":"Interrupted by user","usage":{}}"#; let stopped_result = r#"{"type":"result","subtype":"error_during_execution","is_error":true,"result":"Interrupted by user","usage":{}}"#;
translator.expect_interrupt(); translator.expect_interrupt();
@@ -1318,7 +1790,7 @@ mod tests {
#[test] #[test]
fn replayed_and_synthetic_user_text_is_skipped() { fn replayed_and_synthetic_user_text_is_skipped() {
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
let mut translator = Translator::new(dir.path().to_path_buf()); let mut translator = Translator::new(dir.path().to_path_buf(), test_subagents(&dir));
let events = translate_lines( let events = translate_lines(
&mut translator, &mut translator,
&[ &[
+184 -1
View File
@@ -26,6 +26,20 @@
//! - `/error [text]` -- a failure, which is otherwise awkward to cause. //! - `/error [text]` -- a failure, which is otherwise awkward to cause.
//! - `/peer [text]`, `/peer-turn` -- a message from another agent, in the //! - `/peer [text]`, `/peer-turn` -- a message from another agent, in the
//! in-place and the live shapes. //! in-place and the live shapes.
//! - `/usage [what]` -- an invented rate-limit answer, or `/usage off` to take
//! it away. An echo session meters nothing, so it draws no usage bar until
//! this is set; what it exists for is the states that bar can be in, which
//! otherwise cost real quota to reach. `/usage 42`, `/usage 95 20`,
//! `/usage 42 never`, `/usage notloggedin`, `/usage unreachable`,
//! `/usage failed`. The vocabulary is `usage::Fixture`'s, where the states
//! live.
//! - `/limit [minutes]` -- a turn that stops because the account is out of
//! quota, saying the limit lifts in `minutes` (default 5, and `never` for a
//! limit with no stated reset). What it exists for is auto-resume, which is
//! otherwise reachable only by actually exhausting somebody's account: pair
//! it with `/usage 100 5` for a meter that agrees, and then `/usage 20` for
//! the moment the limit lifts. The wait itself is decided by the meter, so
//! those two commands are the whole rig.
//! - `/compact` -- a compaction, start to finish. //! - `/compact` -- a compaction, start to finish.
//! - `/stream N` -- one long answer in N small pieces, 50ms apart: the shape a //! - `/stream N` -- one long answer in N small pieces, 50ms apart: the shape a
//! real model's reply arrives in, and the one where the row a reader is //! real model's reply arrives in, and the one where the row a reader is
@@ -34,6 +48,10 @@
//! and height the app draws, in one session, which is what a scrolling //! and height the app draws, in one session, which is what a scrolling
//! problem needs in order to be reproduced twice the same way. //! problem needs in order to be reproduced twice the same way.
//! - `/table [columns]` -- a markdown table with cells too long for one line. //! - `/table [columns]` -- a markdown table with cells too long for one line.
//! - `/subagent [n]` -- n subagents at once (default 1), each named
//! "helper k", its prompt recorded as its own first user message: a
//! streamed reply, one Bash call, then it finishes about three seconds
//! later, the same lifecycle a real Task call has -- see `SUBAGENTS.md`.
//! //!
//! `/slow` earns its place: a queued message, a Stop button and a spinner are //! `/slow` earns its place: a queued message, a Stop button and a spinner are
//! states that only exist mid-turn, and the obvious way to get one -- ask a //! states that only exist mid-turn, and the obvious way to get one -- ask a
@@ -48,6 +66,7 @@ use std::time::Duration;
use super::driver::{ use super::driver::{
AttachmentRef, Driver, Event, EventSink, QuestionOption, SessionStatus, Unqueued, AttachmentRef, Driver, Event, EventSink, QuestionOption, SessionStatus, Unqueued,
}; };
use super::subagent::Subagents;
/// Delay between streamed deltas -- long enough that streaming is visibly /// Delay between streamed deltas -- long enough that streaming is visibly
/// streaming, short enough that tests waiting on a full turn stay fast. /// streaming, short enough that tests waiting on a full turn stay fast.
@@ -85,11 +104,19 @@ pub struct EchoDriver {
/// because `/ask` puts up to four on one tool call, and the turn resumes /// because `/ask` puts up to four on one tool call, and the turn resumes
/// when the last is answered rather than the first. /// when the last is answered rather than the first.
pending_questions: Mutex<Vec<PendingQuestion>>, pending_questions: Mutex<Vec<PendingQuestion>>,
/// The invented rate-limit answer `/usage` sets, shared with the usage
/// monitor that serves it. An echo session meters nothing, so this is unset
/// until a test asks for something -- see [`crate::usage::Fixture`].
usage: crate::usage::Fixture,
/// A pretend context, so the status row has something that behaves the way /// A pretend context, so the status row has something that behaves the way
/// a real one does: it grows with each turn, drops to what the compaction /// a real one does: it grows with each turn, drops to what the compaction
/// says it recovered, and a clear leaves it unmeasured. What is real is /// says it recovered, and a clear leaves it unmeasured. What is real is
/// which way the numbers move. /// which way the numbers move.
context: Arc<AtomicU64>, context: Arc<AtomicU64>,
/// This session's subagents -- see `SUBAGENTS.md`. `/subagent` is the
/// test rig for the same registry the claude driver routes real Task
/// calls into.
subagents: Arc<Subagents>,
} }
impl EchoDriver { impl EchoDriver {
@@ -311,6 +338,110 @@ impl EchoDriver {
return; return;
} }
// Answered here rather than in the turn below, because it is not a
// turn: nothing is generated, and what is being exercised is the
// *other* screens -- the bar under the header, the button beside it and
// the dialog it opens, which read the usage route, not this transcript.
if let Some(rest) = text.strip_prefix("/usage") {
if announce {
self.emit(Event::MessageTaken {
id: None,
text: text.clone(),
attachments,
});
}
let said = self.usage.command(rest);
self.emit(Event::AssistantText {
delta: format!("{said}\n"),
});
self.emit(Event::Status {
state: SessionStatus::Idle,
});
return;
}
// A turn that ends the way a real one does when the account runs out:
// the same event a real driver reports, so what acts on it -- the
// transcript row and `crate::resume` -- is exercised rather than
// imitated. The meter it should agree with is `/usage`'s fixture,
// deliberately separate: the two disagreeing is a state worth being
// able to produce, since it is what a stale reset time looks like.
if let Some(rest) = text.strip_prefix("/limit") {
if announce {
self.emit(Event::MessageTaken {
id: None,
text: text.clone(),
attachments,
});
}
let rest = rest.trim();
let resets_at = match rest {
"never" | "none" => None,
"" => Some(super::now() + 5.0 * 60.0),
minutes => Some(super::now() + minutes.parse::<f64>().unwrap_or(5.0) * 60.0),
};
self.emit(Event::Status {
state: SessionStatus::Running,
});
self.emit(Event::AssistantText {
delta: "Working on it".to_string(),
});
self.emit(Event::LimitReached { resets_at });
self.emit(Event::Status {
state: SessionStatus::Idle,
});
return;
}
// `n` subagents at once, each with its own transcript in the
// registry a real Task call routes into -- see `SUBAGENTS.md`. The
// parent's own Task calls end when their subagent does, three
// seconds later, which is long enough to see the running state on
// the phone before it finishes.
if let Some(rest) = text.strip_prefix("/subagent") {
let n = rest.trim().parse::<usize>().unwrap_or(1).clamp(1, 8);
if announce {
self.emit(Event::MessageTaken {
id: None,
text: text.clone(),
attachments,
});
}
self.emit(Event::Status {
state: SessionStatus::Running,
});
let sink = self.sink.clone();
let subagents = Arc::clone(&self.subagents);
tokio::spawn(async move {
let mut helpers = Vec::new();
for k in 1..=n {
let id = format!("echo-subagent-{k}-{}", super::random_hex());
let title = format!("helper {k}");
let prompt = format!(
"You are helper {k} of {n}. Say a few words, run a command, then stop."
);
let _ = sink.send(Event::ToolStart {
id: id.clone(),
tool: "Task".to_string(),
input: serde_json::json!({
"description": title,
"prompt": prompt,
"subagent_type": "general-purpose",
}),
});
subagents.start(&id, &title, Some(&prompt));
helpers.push((id, sink.clone(), Arc::clone(&subagents)));
}
for (id, sink, subagents) in helpers {
tokio::spawn(run_helper(id, sink, subagents));
}
let _ = sink.send(Event::Status {
state: SessionStatus::Idle,
});
});
return;
}
// The same word the real CLI takes, so a phone drives both the same way. // The same word the real CLI takes, so a phone drives both the same way.
// `Driver::compact` is what the manager's route calls; this is the typed // `Driver::compact` is what the manager's route calls; this is the typed
// path onto it. // path onto it.
@@ -606,7 +737,12 @@ impl EchoDriver {
}); });
} }
pub fn new(sink: EventSink, session_dir: PathBuf) -> Self { pub fn new(
sink: EventSink,
session_dir: PathBuf,
usage: crate::usage::Fixture,
subagents: Arc<Subagents>,
) -> Self {
let driver = Self { let driver = Self {
sink, sink,
pending_questions: Mutex::new(Vec::new()), pending_questions: Mutex::new(Vec::new()),
@@ -614,6 +750,8 @@ impl EchoDriver {
busy: Arc::new(AtomicBool::new(false)), busy: Arc::new(AtomicBool::new(false)),
queued: Arc::new(Mutex::new(Vec::new())), queued: Arc::new(Mutex::new(Vec::new())),
session_dir, session_dir,
usage,
subagents,
}; };
driver.emit(Event::Status { driver.emit(Event::Status {
state: SessionStatus::Idle, state: SessionStatus::Idle,
@@ -735,6 +873,51 @@ async fn write_beat(sink: &EventSink, session_dir: &Path, beat: usize) {
tokio::time::sleep(Duration::from_millis(120)).await; tokio::time::sleep(Duration::from_millis(120)).await;
} }
/// One `/subagent` helper: a few streamed words, one Bash call, then
/// `Status::Exited` about three seconds after it started -- long enough that
/// its `Running` state can be seen on the phone before it finishes. The
/// parent's own Task call for it ends at the same moment, the same way a
/// real Task's `tool_result` ends it.
async fn run_helper(id: String, sink: EventSink, subagents: Arc<Subagents>) {
let start = tokio::time::Instant::now();
for word in "Working on it now.".split_inclusive(' ') {
subagents.record(
&id,
Event::AssistantText {
delta: word.to_string(),
},
);
tokio::time::sleep(DELTA_DELAY).await;
}
let tool_id = format!("{id}-bash");
subagents.record(
&id,
Event::ToolStart {
id: tool_id.clone(),
tool: "Bash".to_string(),
input: serde_json::json!({ "command": "echo helper done" }),
},
);
tokio::time::sleep(DELTA_DELAY).await;
subagents.record(
&id,
Event::ToolEnd {
id: tool_id,
output: "helper done".to_string(),
},
);
let target = Duration::from_secs(3);
let elapsed = start.elapsed();
if elapsed < target {
tokio::time::sleep(target - elapsed).await;
}
subagents.finish(&id);
let _ = sink.send(Event::ToolEnd {
id,
output: "subagent finished".to_string(),
});
}
/// A message written during a turn and waiting for it to end: the id of the /// A message written during a turn and waiting for it to end: the id of the
/// `MessageQueued` that announced it, what it said, and what was attached. All /// `MessageQueued` that announced it, what it said, and what was attached. All
/// three, because all three are what the `MessageTaken` at the other end owes. /// three, because all three are what the `MessageTaken` at the other end owes.
+143 -29
View File
@@ -5,9 +5,19 @@
//! //!
//! **It is spawned but not spoken to over stdio.** The process is started //! **It is spawned but not spoken to over stdio.** The process is started
//! through the same [`Transport`] as any other and then reached over HTTP on a //! through the same [`Transport`] as any other and then reached over HTTP on a
//! loopback port. A remote llama-server would need its port forwarded as well //! loopback port. That is the second half of what a transport is -- "run this"
//! as its command wrapped, which is not built, so a session on an ssh host is //! plus "reach this port" -- and it is what lets a session run on another
//! refused rather than silently talking to the wrong machine. //! machine: [`Transport::reserve_port`] hands back a port the server binds
//! *there* and one that reaches it *here*, and the ssh connection carrying the
//! command carries the tunnel between them. The far `llama-server` binds
//! loopback only, so a model is never served to that machine's network.
//!
//! **The model file is the far machine's, not this one's.** A remote setup
//! names its own models directory (`SshConfig::models_dir`, defaulting to where
//! this backend keeps its downloads), and the file is looked for *there* -- so
//! a session naming a model that machine does not have says so, instead of
//! starting a server that will never load one. Downloading to another machine
//! is not built; the model gets there however anything else does.
//! //!
//! **The server is stateless between requests**, so the whole conversation goes //! **The server is stateless between requests**, so the whole conversation goes
//! with every one. It is rebuilt from the session's transcript rather than kept //! with every one. It is rebuilt from the session's transcript rather than kept
@@ -67,6 +77,7 @@ impl LlamaDriver {
/// in a different currency: two servers holding the same model is twice the /// in a different currency: two servers holding the same model is twice the
/// memory, and the second would bind a different port while the phone kept /// memory, and the second would bind a different port while the phone kept
/// talking to the first. /// talking to the first.
#[allow(clippy::too_many_arguments)]
pub fn launch( pub fn launch(
meta: &SessionConfig, meta: &SessionConfig,
provider: &ProviderConfig, provider: &ProviderConfig,
@@ -75,17 +86,15 @@ impl LlamaDriver {
transcript: &Path, transcript: &Path,
session_dir: &Path, session_dir: &Path,
sink: EventSink, sink: EventSink,
// llama.cpp has no notion of a Task call, so this is accepted only
// to keep one shape across every driver's launch -- see
// `SUBAGENTS.md`'s "Server layout".
_subagents: Arc<super::subagent::Subagents>,
) -> Result<Self> { ) -> Result<Self> {
if !matches!(transport, Transport::Here) {
bail!(
"llama.cpp sessions can only run on this machine for now: the model is served \
over HTTP, and forwarding that port to another host isn't built yet."
);
}
let model = meta.model.as_deref().context( let model = meta.model.as_deref().context(
"a llama.cpp session needs a model -- one of the downloaded ones, by its key", "a llama.cpp session needs a model -- one of the downloaded ones, by its key",
)?; )?;
let path = model_path(models_dir, model)?; let path = model_on(transport, models_dir, model)?;
// Already loaded and still running: keep talking to it. The health poll // Already loaded and still running: keep talking to it. The health poll
// below confirms it is really answering, so adopting a pid whose server // below confirms it is really answering, so adopting a pid whose server
@@ -111,14 +120,21 @@ impl LlamaDriver {
)); ));
} }
let port = free_port().context("finding a port for llama-server")?; // Where it listens on its own machine, and where that is reached
// from here -- the same number when that machine is this one.
let forward = transport
.reserve_port()
.context("finding a port for llama-server")?;
let mut args: Vec<String> = vec![ let mut args: Vec<String> = vec![
"-m".into(), "-m".into(),
path.to_string_lossy().into_owned(), path.clone(),
// Loopback there, whichever machine there is: what reaches it
// from outside that machine is the ssh tunnel and nothing
// else.
"--host".into(), "--host".into(),
"127.0.0.1".into(), "127.0.0.1".into(),
"--port".into(), "--port".into(),
port.to_string(), forward.there.to_string(),
]; ];
// Settings that belong to the server because they decide how the model // Settings that belong to the server because they decide how the model
// is loaded; the sampling ones ride on each request instead, so changing // is loaded; the sampling ones ride on each request instead, so changing
@@ -134,8 +150,8 @@ impl LlamaDriver {
} }
} }
let program = provider.command.as_deref().unwrap_or("llama-server"); let program = provider.program();
let launch = Launch::new(program, args, meta.cwd.as_deref()); let launch = Launch::new(program, args, meta.cwd.as_deref()).reaching(forward);
// Its output goes to files, not pipes. Not only so the process can // Its output goes to files, not pipes. Not only so the process can
// outlive this server: nothing ever read those pipes, so a chatty // outlive this server: nothing ever read those pipes, so a chatty
// llama-server filled the 64 KB buffer and blocked mid-load with no sign // llama-server filled the 64 KB buffer and blocked mid-load with no sign
@@ -152,8 +168,12 @@ impl LlamaDriver {
.id() .id()
.context("llama-server exited before it could be recorded")?; .context("llama-server exited before it could be recorded")?;
tracing::info!( tracing::info!(
"session {} running {program} for {model} on 127.0.0.1:{port} as pid {pid}", "session {} running {program} for {model} {} on 127.0.0.1:{} there, \
meta.id reached at 127.0.0.1:{} here, as pid {pid}",
meta.id,
transport.describe(),
forward.there,
forward.here,
); );
// Reaped so it does not become a zombie while this server is still its // Reaped so it does not become a zombie while this server is still its
// parent; the health poll and the record are what say whether the // parent; the health poll and the record are what say whether the
@@ -163,12 +183,18 @@ impl LlamaDriver {
let _ = child.wait().await; let _ = child.wait().await;
}); });
let record = process::Record::of(pid, process::Detail::Http { port }) // The *near* port, because that is the one anything reaching this
// server has to dial -- including a later run of this backend,
// which adopts the record without knowing which machine the server
// is on. For a remote session the recorded pid is the ssh
// client's, which is the process this machine owns and which holds
// the tunnel open for exactly as long as the far server lives.
let record = process::Record::of(pid, process::Detail::Http { port: forward.here })
.context("llama-server was gone before its start time could be read")?; .context("llama-server was gone before its start time could be read")?;
process::write(session_dir, &record); process::write(session_dir, &record);
Ok(Self::attached( Ok(Self::attached(
format!("http://127.0.0.1:{port}"), format!("http://127.0.0.1:{}", forward.here),
meta, meta,
model, model,
transcript, transcript,
@@ -202,7 +228,7 @@ impl LlamaDriver {
let endpoint = endpoint.clone(); let endpoint = endpoint.clone();
let model = model.to_string(); let model = model.to_string();
let session_dir = session_dir.to_path_buf(); let session_dir = session_dir.to_path_buf();
std::thread::spawn(move || match wait_until_ready(&endpoint) { std::thread::spawn(move || match wait_until_ready(&endpoint, &session_dir) {
Ok(()) => { Ok(()) => {
tracing::info!("{model} loaded and answering at {endpoint}"); tracing::info!("{model} loaded and answering at {endpoint}");
let _ = sink.send(Event::Status { let _ = sink.send(Event::Status {
@@ -499,17 +525,70 @@ fn model_path(models_dir: &Path, key: &str) -> Result<PathBuf> {
Ok(path) Ok(path)
} }
/// An unused loopback port, by asking the OS for one and letting it go. Racy in /// The model file's path **on the machine that will serve it**, confirmed to be
/// principle, but nothing on this machine is hunting for ports, and the /// there.
/// alternative -- parsing the port back out of the server's log -- couples us to ///
/// its output format for no real gain. /// One function rather than a local check and hope for the other case: the same
fn free_port() -> Result<u16> { /// question has to be asked of two filesystems. The remote answer is measured
let listener = std::net::TcpListener::bind("127.0.0.1:0")?; /// for the reason the local one is -- a missing file otherwise becomes a
Ok(listener.local_addr()?.port()) /// `llama-server` that starts, fails to load, and reports as a session that
/// never became ready, which reads as the machine being slow.
///
/// One blocking round trip on a remote spawn, which is what the spawn is
/// already paying to start ssh. The alternative is a path built here from a `~`
/// this machine cannot expand.
fn model_on(transport: &Transport, models_dir: &Path, key: &str) -> Result<String> {
let Transport::Ssh { name, .. } = transport else {
return Ok(model_path(models_dir, key)?.to_string_lossy().into_owned());
};
// The same directory the spawn screen listed for this machine, and one
// function for the same reason: a list from one place and a load from
// another is a model that appears and then fails.
let dir = crate::models::dir_on(transport, models_dir);
// Checked here rather than in the script: `..` in a key would walk out of
// the models directory on a machine this server can start processes on,
// and the phone is where the key comes from.
for part in key.split('/') {
if part.is_empty() || part == "." || part == ".." {
bail!("\"{key}\" is not a model key this can resolve");
}
}
let path = format!("{}/{key}", dir.trim_end_matches('/'));
// `$HOME` on the far side, which is the only machine that knows what it is,
// and the resolved path printed back so the launch hands `llama-server`
// something absolute. "Not there" is answered rather than failed, because a
// machine that could not be asked at all has to say so in its own words --
// it would otherwise arrive as this same sentence about a missing model.
let script = "p=$1; case $p in \"~\") p=$HOME;; \"~/\"*) p=$HOME/${p#\"~/\"};; esac; \
[ -f \"$p\" ] && printf 'at\\t%s\\n' \"$p\" || printf 'missing\\n'"
.to_string();
let launch = Launch::new(
"sh",
vec!["-c".to_string(), script, "sh".to_string(), path.clone()],
None,
);
let answer = transport
.capture_blocking(&launch)
.with_context(|| format!("couldn't ask {name} where its models are"))?;
match answer.trim().split_once('\t') {
Some(("at", resolved)) => Ok(resolved.to_string()),
_ => bail!(
"{name} has no model at {path}. A llama.cpp session serves the file from the \
machine it runs on, so the model has to be on {name} -- what this backend has \
downloaded is somewhere else."
),
}
} }
/// Polls until the server says it is ready, or gives up. /// Polls until the server says it is ready, or gives up.
fn wait_until_ready(endpoint: &str) -> Result<()> { ///
/// Watches the process as well as the port, because the two failures need
/// different words and one of them is common: a model that will not load,
/// a port already taken on the far machine, a `llama-server` too old for
/// a flag. All of those exit within a second and none of them will ever
/// answer `/health`, so waiting out the timeout turns a server that said
/// exactly what was wrong into "gave up after 300s".
fn wait_until_ready(endpoint: &str, session_dir: &Path) -> Result<()> {
let deadline = std::time::Instant::now() + READY_TIMEOUT; let deadline = std::time::Instant::now() + READY_TIMEOUT;
let url = format!("{endpoint}/health"); let url = format!("{endpoint}/health");
loop { loop {
@@ -518,13 +597,48 @@ fn wait_until_ready(endpoint: &str) -> Result<()> {
{ {
return Ok(()); return Ok(());
} }
// `None` is the session having been stopped or deleted while this
// waited, which is nobody's fault and still not worth waiting on.
match process::recorded(session_dir) {
Some((_, process::Liveness::Alive | process::Liveness::Unknown)) => {}
Some((_, process::Liveness::Dead)) | None => {
bail!("it exited before it answered.{}", log_tail(session_dir));
}
}
if std::time::Instant::now() > deadline { if std::time::Instant::now() > deadline {
bail!("gave up after {}s", READY_TIMEOUT.as_secs()); bail!(
"gave up after {}s.{}",
READY_TIMEOUT.as_secs(),
log_tail(session_dir)
);
} }
std::thread::sleep(std::time::Duration::from_millis(250)); std::thread::sleep(std::time::Duration::from_millis(250));
} }
} }
/// The end of `llama-server`'s own log, for a failure message.
///
/// Its account of what went wrong is the useful half -- "failed to load
/// model", "bind: Address already in use" -- and on a remote session it
/// is the only half, since nobody reading the phone can open a file on
/// that machine. Bounded, because this ends up in an event a phone draws.
fn log_tail(session_dir: &Path) -> String {
let Ok(text) = std::fs::read_to_string(session_dir.join(SERVER_LOG)) else {
return String::new();
};
let tail: Vec<&str> = text.lines().rev().take(LOG_TAIL_LINES).collect();
if tail.is_empty() {
return String::new();
}
format!(
" It last said: {}",
tail.into_iter().rev().collect::<Vec<_>>().join(" / ")
)
}
/// How much of that log to carry into a message somebody reads on a phone.
const LOG_TAIL_LINES: usize = 6;
/// One streamed completion: posts the conversation, emits each delta as it /// One streamed completion: posts the conversation, emits each delta as it
/// arrives. Emits rather than returns, because the transcript those events land /// arrives. Emits rather than returns, because the transcript those events land
/// in is what the next turn reads back. /// in is what the next turn reads back.
+853 -28
View File
File diff suppressed because it is too large. Load diff
+507
View File
@@ -0,0 +1,507 @@
//! A session's subagents -- see `SUBAGENTS.md`.
//!
//! **A subagent is a second transcript owned by a session, in the same event
//! model, with no process and no controls.** It shares the transcript file
//! format, the paging routes, and the SSE stream with a session by
//! addressing, not by copying: `Transcript`, `read_window` and `catch_up`
//! work on a subagent's file unchanged.
//!
//! Storage is `<session dir>/subagents/<id>/{meta.json,transcript.jsonl}`,
//! where `<id>` is the Task tool_use id that started it -- unique, stable
//! across a backend restart, and already the key the parent side uses. Only
//! ids matching [`is_subagent_id`] are ever turned into a path.
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use tokio::sync::broadcast;
use super::driver::{Event, SessionStatus};
use super::transcript::{SeqEvent, Transcript};
/// Fan-out buffer for one subagent's SSE subscribers. Smaller than a
/// session's: a subagent's whole conversation is usually a handful of tool
/// calls, not an hours-long session.
const EVENT_BUFFER: usize = 64;
/// Whether `id` is safe to become a path segment under a session's
/// `subagents/` directory. Mirrors `import::is_session_id`'s reasoning: the
/// id arrives as a value inside JSON the CLI sent, and it becomes a
/// directory name, so a `/` or `..` in it must never be trusted.
fn is_subagent_id(id: &str) -> bool {
!id.is_empty()
&& id.len() <= 200
&& id
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
}
/// What a subagent's directory holds beside its transcript. Small and
/// separate from `Subagent` itself because this is exactly what survives a
/// backend restart on disk, and nothing else does.
#[derive(Debug, Clone, Serialize, Deserialize)]
struct Meta {
title: String,
/// Epoch seconds. Absent from `SubagentInfo`'s sort key deliberately:
/// `list` sorts by this rather than by directory order, which a
/// filesystem does not promise.
created: f64,
}
/// One row of `GET /sessions/{id}/subagents`.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SubagentInfo {
pub id: String,
pub title: String,
pub status: SessionStatus,
pub created: f64,
pub last_activity: f64,
}
/// One subagent: its own transcript and broadcast, same shape as a
/// session's but with no driver behind it.
pub struct Subagent {
dir: PathBuf,
transcript: Mutex<Transcript>,
events: broadcast::Sender<SeqEvent>,
/// Mirrors the transcript's last `Status` event, kept live rather than
/// read back from `Transcript::last_status` -- that answers "as of
/// opening" (see its own doc comment) and never moves for an append made
/// through *this* object, which is every append a live subagent ever
/// makes. Without this, `finish` immediately after `start` in the same
/// process read the file's stale opening status and reported itself
/// still open.
status: Mutex<SessionStatus>,
}
impl Subagent {
pub fn transcript_path(&self) -> PathBuf {
self.dir.join("transcript.jsonl")
}
pub fn subscribe(&self) -> broadcast::Receiver<SeqEvent> {
self.events.subscribe()
}
/// Whether this subagent's last recorded status is not `Exited` --
/// what decides whether a further child line reopens it (see
/// `Subagents::reopen`) rather than continuing straight through. See
/// `SUBAGENTS.md`'s lifecycle.
pub fn is_open(&self) -> bool {
*self.status.lock().unwrap() != SessionStatus::Exited
}
fn append(&self, event: Event) {
let mut transcript = self.transcript.lock().unwrap();
match transcript.append(event, super::now()) {
Ok(entry) => {
if let Event::Status { state } = &entry.event {
*self.status.lock().unwrap() = *state;
}
// No subscribers is fine; the transcript already has it,
// same as a session's pump.
let _ = self.events.send(entry);
}
Err(err) => tracing::error!("subagent transcript append failed: {err:#}"),
}
}
}
/// Every subagent one session has started, keyed by the Task tool_use id
/// that names it.
///
/// Lives beside a session's driver rather than inside it: a claude driver
/// holds an `Arc` to this and routes child lines into it; echo uses it for
/// its `/subagent` rig; llama ignores it, since it has no notion of a Task
/// call. One instance per live session, built at launch and handed to
/// whichever driver replaces it across a stop/start.
pub struct Subagents {
/// The session's own directory; subagents live under `<dir>/subagents`.
dir: PathBuf,
live: Mutex<HashMap<String, Arc<Subagent>>>,
}
impl Subagents {
pub fn new(session_dir: PathBuf) -> Self {
Self {
dir: session_dir,
live: Mutex::new(HashMap::new()),
}
}
fn subagents_dir(&self) -> PathBuf {
self.dir.join("subagents")
}
/// Opens the subagent named `id`, creating it if this is the first
/// anyone has heard of it -- on disk as well as in memory, so a
/// subagent from before a backend restart is reopened rather than
/// recreated. `title`/`prompt` are used only at creation: reopening an
/// existing one keeps its original title and never repeats the prompt
/// into its transcript a second time.
fn open_or_create(&self, id: &str, title: &str, prompt: Option<&str>) -> Result<Arc<Subagent>> {
let dir = self.subagents_dir().join(id);
let meta_path = dir.join("meta.json");
let existed = meta_path.is_file();
let meta = if existed {
let text = fs::read_to_string(&meta_path)
.with_context(|| format!("read {}", meta_path.display()))?;
serde_json::from_str::<Meta>(&text).context("parse subagent meta")?
} else {
wg_app_link::private::create_dir(&dir)?;
let meta = Meta {
title: title.to_string(),
created: super::now(),
};
wg_app_link::private::write_file(
&meta_path,
serde_json::to_string(&meta)
.context("serialize subagent meta")?
.as_bytes(),
)?;
meta
};
let mut transcript = Transcript::open(&dir.join("transcript.jsonl"))?;
if !existed {
// First lines, in order: the subagent is running the moment it
// exists, and its prompt -- when known -- is genuinely its first
// user turn. Written once, here, so a reopen never repeats them.
transcript.append(
Event::Status {
state: SessionStatus::Running,
},
meta.created,
)?;
if let Some(prompt) = prompt {
transcript.append(
Event::UserMessage {
id: None,
text: prompt.to_string(),
attachments: Vec::new(),
},
meta.created,
)?;
}
}
// A freshly created subagent is running by construction (its only
// lines so far are `Status::Running` and maybe its prompt); a
// reopened one takes whatever the file last said, since this
// `Transcript` has not been appended to yet in this process.
let status = if existed {
transcript.last_status().unwrap_or(SessionStatus::Running)
} else {
SessionStatus::Running
};
let (events, _) = broadcast::channel(EVENT_BUFFER);
Ok(Arc::new(Subagent {
dir,
transcript: Mutex::new(transcript),
events,
status: Mutex::new(status),
}))
}
/// Starts a subagent unless one is already known by this id -- see
/// `SUBAGENTS.md`'s lifecycle: created at the Task call or at the first
/// child line, whichever comes first, and never twice. A bad id is
/// refused rather than turned into a path.
pub fn start(&self, id: &str, title: &str, prompt: Option<&str>) {
if !is_subagent_id(id) {
tracing::debug!("refusing to start a subagent with a bad id {id:?}");
return;
}
let mut live = self.live.lock().unwrap();
if live.contains_key(id) {
return;
}
match self.open_or_create(id, title, prompt) {
Ok(subagent) => {
live.insert(id.to_string(), subagent);
}
Err(err) => tracing::error!("couldn't start subagent {id}: {err:#}"),
}
}
/// The subagent named `id`, reopening it from disk on first use in this
/// process if one is there. `None` for an id nothing has ever started --
/// deliberately not created here, since a route or a routing decision is
/// not the Task call that is supposed to be the only way one begins.
pub fn get(&self, id: &str) -> Option<Arc<Subagent>> {
if !is_subagent_id(id) {
return None;
}
if let Some(existing) = self.live.lock().unwrap().get(id).cloned() {
return Some(existing);
}
if !self.subagents_dir().join(id).join("meta.json").is_file() {
return None;
}
// Title and prompt are ignored: the directory already exists, so
// `open_or_create` reads its own meta rather than using either.
match self.open_or_create(id, "", None) {
Ok(subagent) => {
self.live
.lock()
.unwrap()
.insert(id.to_string(), Arc::clone(&subagent));
Some(subagent)
}
Err(err) => {
tracing::error!("couldn't reopen subagent {id}: {err:#}");
None
}
}
}
/// Appends one event to a subagent's own transcript. A no-op, with a
/// debug log, for an id nothing was started under -- a child line for a
/// subagent this registry never opened is dropped rather than guessed
/// at.
pub fn record(&self, id: &str, event: Event) {
match self.live.lock().unwrap().get(id).cloned() {
Some(subagent) => subagent.append(event),
None => tracing::debug!("dropping an event for unknown subagent {id}"),
}
}
/// The subagent's own turn ended: its `Status::Exited`. Called from
/// `translate_child` on the subagent's own `end_turn`, never on the
/// parent's `tool_result` -- a background Task's `tool_result` arrives
/// at launch, not at completion, so it says nothing about whether this
/// is over. A no-op for an id that is not a subagent's or is already
/// closed.
pub fn finish(&self, id: &str) {
if let Some(subagent) = self.live.lock().unwrap().get(id).cloned()
&& subagent.is_open()
{
subagent.append(Event::Status {
state: SessionStatus::Exited,
});
}
}
/// A line arrived for a subagent that had already finished: it is
/// working again, not stale -- a background Task can be sent another
/// message long after its first turn ended. Appends `Status::Running`
/// so the list stops reporting it as finished; a no-op if it was not
/// actually closed, so a caller need not check first.
pub fn reopen(&self, id: &str) {
if let Some(subagent) = self.live.lock().unwrap().get(id).cloned()
&& !subagent.is_open()
{
subagent.append(Event::Status {
state: SessionStatus::Running,
});
}
}
/// The parent session's process is gone, so nothing still open here has
/// a process behind it either -- see `SUBAGENTS.md`'s lifecycle #4.
pub fn finish_all(&self) {
let subagents: Vec<Arc<Subagent>> = self.live.lock().unwrap().values().cloned().collect();
for subagent in subagents {
if subagent.is_open() {
subagent.append(Event::Status {
state: SessionStatus::Exited,
});
}
}
}
/// Every subagent under this session's directory, oldest first --
/// `GET /sessions/{id}/subagents`. Read straight from disk rather than
/// from `live`, so a subagent from before this process started (or one
/// this run has not yet touched) still shows up; one file read per
/// subagent, which is fine at the handful a session usually has.
///
/// `session_running` is what turns a subagent whose last status is
/// `Running` into `Unknown`: its process was the session's, and the
/// session has none.
pub fn list(&self, session_running: bool) -> Vec<SubagentInfo> {
let mut rows: Vec<SubagentInfo> = match fs::read_dir(self.subagents_dir()) {
Ok(entries) => entries
.filter_map(Result::ok)
.filter_map(|entry| info_of(&entry.path(), session_running))
.collect(),
// No directory is no subagents, not a fault worth reporting.
Err(_) => Vec::new(),
};
rows.sort_by(|a, b| {
a.created
.partial_cmp(&b.created)
.unwrap_or(std::cmp::Ordering::Equal)
});
rows
}
}
fn info_of(subagent_dir: &Path, session_running: bool) -> Option<SubagentInfo> {
let id = subagent_dir.file_name()?.to_str()?.to_string();
let meta_path = subagent_dir.join("meta.json");
let text = fs::read_to_string(&meta_path).ok()?;
let meta: Meta = serde_json::from_str(&text).ok()?;
let transcript = Transcript::open(&subagent_dir.join("transcript.jsonl")).ok()?;
// The subagent's first line is always `Status::Running`, written before
// this directory is discoverable at all, so `None` here is not a state a
// reader can actually observe -- but it is not this function's place to
// invent one, so a status this build does not expect to see falls back
// to the word the lifecycle promises it started in.
let last_status = transcript.last_status().unwrap_or(SessionStatus::Running);
let status = if last_status == SessionStatus::Running && !session_running {
SessionStatus::Unknown
} else {
last_status
};
Some(SubagentInfo {
id,
title: meta.title,
status,
created: meta.created,
last_activity: transcript.last_activity().unwrap_or(meta.created),
})
}
/// How many subagents a session has, for `SessionInfo::subagents`: a
/// directory listing, so the session list stays cheap and only the
/// dedicated route pays for reading a status out of each one.
pub fn count(session_dir: &Path) -> usize {
fs::read_dir(session_dir.join("subagents"))
.map(|entries| entries.filter_map(Result::ok).count())
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_bad_id_is_refused_rather_than_turned_into_a_path() {
let dir = tempfile::tempdir().expect("tempdir");
let subagents = Subagents::new(dir.path().to_path_buf());
subagents.start("../../etc", "escape", None);
assert!(subagents.get("../../etc").is_none());
assert!(!dir.path().join("subagents").exists());
}
#[test]
fn starting_twice_keeps_the_first_title_and_does_not_repeat_the_prompt() {
let dir = tempfile::tempdir().expect("tempdir");
let subagents = Subagents::new(dir.path().to_path_buf());
subagents.start("toolu_1", "first title", Some("do the thing"));
subagents.start("toolu_1", "second title", Some("do the thing"));
let rows = subagents.list(true);
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].title, "first title");
let events = crate::session::transcript::read_after(
&subagents.get("toolu_1").unwrap().transcript_path(),
0,
)
.expect("read");
assert_eq!(
events
.iter()
.filter(|e| matches!(e.event, Event::UserMessage { .. }))
.count(),
1
);
}
#[test]
fn a_reopened_subagent_continues_its_own_transcript() {
let dir = tempfile::tempdir().expect("tempdir");
{
let subagents = Subagents::new(dir.path().to_path_buf());
subagents.start("toolu_2", "helper", Some("go"));
subagents.record(
"toolu_2",
Event::AssistantText {
delta: "working".to_string(),
},
);
}
// A fresh registry, the way a backend restart builds one.
let subagents = Subagents::new(dir.path().to_path_buf());
let subagent = subagents.get("toolu_2").expect("reopened");
assert!(subagent.is_open());
subagents.record(
"toolu_2",
Event::AssistantText {
delta: " more".to_string(),
},
);
let events =
crate::session::transcript::read_after(&subagent.transcript_path(), 0).expect("read");
// Status, UserMessage, two AssistantText deltas, seq continuing.
assert_eq!(events.len(), 4);
assert_eq!(events.last().unwrap().seq, 4);
}
#[test]
fn finishing_appends_exited_and_further_lines_are_droppable() {
let dir = tempfile::tempdir().expect("tempdir");
let subagents = Subagents::new(dir.path().to_path_buf());
subagents.start("toolu_3", "helper", None);
subagents.finish("toolu_3");
let subagent = subagents.get("toolu_3").unwrap();
assert!(!subagent.is_open());
// On disk too, not only in the live cache `is_open` reads.
assert_eq!(
Transcript::open(&subagent.transcript_path())
.expect("reopen")
.last_status(),
Some(SessionStatus::Exited)
);
// Finishing an id that was never a subagent is a no-op, not a panic.
subagents.finish("never-started");
}
#[test]
fn finish_all_closes_only_what_is_still_open() {
let dir = tempfile::tempdir().expect("tempdir");
let subagents = Subagents::new(dir.path().to_path_buf());
subagents.start("toolu_4", "one", None);
subagents.start("toolu_5", "two", None);
subagents.finish("toolu_4");
subagents.finish_all();
let rows = subagents.list(false);
assert_eq!(rows.len(), 2);
for row in rows {
assert_eq!(row.status, SessionStatus::Exited);
}
}
#[test]
fn a_subagent_still_running_when_the_session_is_not_reports_unknown() {
let dir = tempfile::tempdir().expect("tempdir");
let subagents = Subagents::new(dir.path().to_path_buf());
subagents.start("toolu_6", "helper", None);
assert_eq!(subagents.list(true)[0].status, SessionStatus::Running);
assert_eq!(subagents.list(false)[0].status, SessionStatus::Unknown);
}
#[test]
fn list_is_oldest_first_and_the_count_matches_the_directory() {
let dir = tempfile::tempdir().expect("tempdir");
let subagents = Subagents::new(dir.path().to_path_buf());
assert_eq!(count(dir.path()), 0);
subagents.start("toolu_a", "a", None);
std::thread::sleep(std::time::Duration::from_millis(2));
subagents.start("toolu_b", "b", None);
let rows = subagents.list(true);
assert_eq!(
rows.iter().map(|r| r.id.as_str()).collect::<Vec<_>>(),
["toolu_a", "toolu_b"]
);
assert_eq!(count(dir.path()), 2);
}
}
+71 -10
View File
@@ -11,9 +11,13 @@
//! `crate::ssh`'s: this module decides *which* transport, that one knows what a //! `crate::ssh`'s: this module decides *which* transport, that one knows what a
//! correct ssh invocation is. //! correct ssh invocation is.
//! //!
//! Known second operation, not built because nothing needs it yet: a managed //! A transport is therefore two operations rather than one: **run this** and
//! `llama-server` is spawned as a process but then spoken to over HTTP, so a //! **reach this port**. The second is what a managed `llama-server` needs -- it
//! remote one needs a forwarded port (`ssh -L`) as well. //! is spawned as a process and then spoken to over HTTP -- and it is a no-op
//! locally, where the port a program binds is already one this machine can
//! dial. Over ssh it is an `-L` tunnel on the same connection that runs the
//! command, so the model server binds loopback on the far machine and is never
//! exposed to its network. See [`Transport::reserve_port`].
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::Stdio; use std::process::Stdio;
@@ -22,14 +26,26 @@ use anyhow::{Context, Result};
use tokio::process::Child; use tokio::process::Child;
use crate::config::SshConfig; use crate::config::SshConfig;
pub use crate::ssh::Forward;
/// What a driver needs run in order to exist as a process. Deliberately just /// What a driver needs run in order to exist as a process. Deliberately just
/// the three things every transport can carry; anything a particular machine /// what every transport can carry -- the command, where it runs, and a port the
/// needs is the transport's own configuration, not something a driver states. /// caller needs to reach; anything a particular machine needs is the
/// transport's own configuration, not something a driver states.
pub struct Launch { pub struct Launch {
pub program: String, pub program: String,
pub args: Vec<String>, pub args: Vec<String>,
pub cwd: Option<PathBuf>, pub cwd: Option<PathBuf>,
/// A port this program will listen on, and the port that reaches it
/// from here -- see [`Transport::reserve_port`], which is the only
/// thing that should produce one.
///
/// On the launch rather than in [`Transport::spawn`]'s signature
/// because it is part of what is being run: a caller that needs to
/// reach the process it is starting says so once, where it says
/// everything else about it, and every transport reads it the same
/// way.
pub forward: Option<Forward>,
} }
impl Launch { impl Launch {
@@ -38,8 +54,16 @@ impl Launch {
program: program.into(), program: program.into(),
args, args,
cwd: cwd.map(Path::to_path_buf), cwd: cwd.map(Path::to_path_buf),
forward: None,
} }
} }
/// Says that this program serves `forward.there`, and that the caller
/// will reach it at `forward.here`.
pub fn reaching(mut self, forward: Forward) -> Self {
self.forward = Some(forward);
self
}
} }
/// How a launched process's standard streams are connected. /// How a launched process's standard streams are connected.
@@ -102,6 +126,7 @@ impl Transport {
&launch.program, &launch.program,
&launch.args, &launch.args,
launch.cwd.as_deref(), launch.cwd.as_deref(),
launch.forward,
)); ));
match streams { match streams {
Streams::Piped => { Streams::Piped => {
@@ -153,12 +178,15 @@ impl Transport {
Self::Here => None, Self::Here => None,
Self::Ssh { ssh, .. } => Some(ssh), Self::Ssh { ssh, .. } => Some(ssh),
}; };
let output = let output = crate::ssh::command(
crate::ssh::command(host, &launch.program, &launch.args, launch.cwd.as_deref()) host,
&launch.program,
&launch.args,
launch.cwd.as_deref(),
launch.forward,
)
.output() .output()
.with_context(|| { .with_context(|| format!("couldn't run \"{}\" {}", launch.program, self.describe()))?;
format!("couldn't run \"{}\" {}", launch.program, self.describe())
})?;
if !output.status.success() { if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
anyhow::bail!(if stderr.is_empty() { anyhow::bail!(if stderr.is_empty() {
@@ -217,6 +245,34 @@ impl Transport {
}) })
} }
/// Picks a port for a launched program to serve on, and the port that
/// reaches it from here.
///
/// The "reach this port" half of what a transport is. Locally there is
/// one port and the OS chooses it, by binding and letting go -- racy
/// in principle, and nothing on this machine is hunting for ports.
///
/// Over ssh the near end is chosen the same way and the far end is a
/// guess, because there is no portable way to ask a machine for a free
/// port that does not race with binding it anyway. It is taken from
/// [`FAR_PORTS`], below the range Linux hands out to outgoing
/// connections, so a collision means something else deliberately
/// listening there. That is not silent: the program fails to bind and
/// exits, and `session::llama` reports what its log said rather than
/// waiting out its readiness timeout.
pub fn reserve_port(&self) -> Result<Forward> {
let listener = std::net::TcpListener::bind("127.0.0.1:0")
.context("asking this machine for a free port")?;
let here = listener.local_addr()?.port();
Ok(match self {
Self::Here => Forward { there: here, here },
Self::Ssh { .. } => Forward {
there: rand::random_range(FAR_PORTS),
here,
},
})
}
/// How to say where this runs, for a log line a person reads. /// How to say where this runs, for a log line a person reads.
pub fn describe(&self) -> String { pub fn describe(&self) -> String {
match self { match self {
@@ -226,6 +282,11 @@ impl Transport {
} }
} }
/// Where a port on another machine is guessed from: high enough to be out
/// of the way of services, and below the 32768-60999 Linux hands out to
/// outgoing connections, which is where a guess would most often collide.
const FAR_PORTS: std::ops::Range<u16> = 20000..30000;
/// What a command is given on its standard input. /// What a command is given on its standard input.
/// ///
/// Three cases rather than an `Option<Stdio>` because they are three genuinely /// Three cases rather than an `Option<Stdio>` because they are three genuinely
+4 -1
View File
@@ -25,7 +25,10 @@ use crate::session::transport::{Launch, Transport};
/// session stores. /// session stores.
const PROBES: &[(&str, &str, DriverKind)] = &[ const PROBES: &[(&str, &str, DriverKind)] = &[
("claude-cli", "claude", DriverKind::ClaudeCli), ("claude-cli", "claude", DriverKind::ClaudeCli),
("local-llama", "llama-server", DriverKind::LlamaCpp), // Named for the program rather than for where it runs: it runs
// wherever the setup is, and "local" was true only while a llama
// session could not be spawned on another machine.
("llama-cpp", "llama-server", DriverKind::LlamaCpp),
]; ];
/// Models offered for a discovered Claude CLI. A shortcut list for the spawn /// Models offered for a discovered Claude CLI. A shortcut list for the spawn
+101 -5
View File
@@ -14,6 +14,22 @@ use std::process::Command;
use crate::config::SshConfig; use crate::config::SshConfig;
/// A port on the machine a command runs on, and the port that reaches it from
/// the backend.
///
/// The second half of what a transport is (PLAN.md's SSH section): "run this"
/// plus "reach this port". Locally the two numbers are one and nothing is
/// forwarded; over ssh the connection carries an `-L` tunnel, so a model server
/// binds loopback on the far machine and is never exposed to its network.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Forward {
/// What the launched program should listen on, on its own machine.
pub there: u16,
/// What this machine connects to. The same number as `there` when the
/// program runs here.
pub here: u16,
}
/// Options forced onto every connection. `BatchMode` makes a missing key fail /// Options forced onto every connection. `BatchMode` makes a missing key fail
/// immediately with a readable message instead of hanging on a password prompt /// immediately with a readable message instead of hanging on a password prompt
/// nothing can answer; the keepalives turn a silently dropped link into a /// nothing can answer; the keepalives turn a silently dropped link into a
@@ -39,6 +55,7 @@ pub fn command(
program: &str, program: &str,
args: &[String], args: &[String],
cwd: Option<&Path>, cwd: Option<&Path>,
forward: Option<Forward>,
) -> Command { ) -> Command {
let Some(ssh) = remote else { let Some(ssh) = remote else {
let mut command = Command::new(program); let mut command = Command::new(program);
@@ -55,9 +72,31 @@ pub fn command(
}; };
let mut command = Command::new("ssh"); let mut command = Command::new("ssh");
// -T: no pty. This carries JSONL, and a pty would rewrite it (echo, CRLF if let Some(forward) = forward {
// translation, ^C handling) into something the parser can't read. // A forwarded process is not spoken to over stdio, and that changes how
// it is shut down. Everything else here is a CLI reading its stdin, so
// killing the ssh client ends it; a `llama-server` never reads its own,
// so the same kill left it running on the far machine with the model
// loaded -- measured 2026-09-04, an orphan per stopped session. A pty
// is what makes sshd hang the far side up. `-tt` because this client
// has no terminal to inherit one from. The cost is a log that arrives
// through a line discipline, which nothing parses.
command.arg("-tt");
// Loopback at both ends: the far side binds 127.0.0.1, so what it
// serves is reachable only through this connection.
command.args([
"-L",
&format!("127.0.0.1:{}:127.0.0.1:{}", forward.here, forward.there),
]);
// Without this a forward that cannot be set up is a warning on stderr
// and a session that runs anyway, answering nothing -- which would
// arrive as "the model never became ready".
command.args(["-o", "ExitOnForwardFailure=yes"]);
} else {
// -T: no pty. This carries JSONL, and a pty would rewrite it (echo,
// CRLF translation, ^C handling) into something the parser can't read.
command.arg("-T"); command.arg("-T");
}
for option in SSH_OPTIONS { for option in SSH_OPTIONS {
command.args(["-o", option]); command.args(["-o", option]);
} }
@@ -181,6 +220,7 @@ mod tests {
port: None, port: None,
identity_file: None, identity_file: None,
options: vec![], options: vec![],
models_dir: None,
attachments_dir: None, attachments_dir: None,
} }
} }
@@ -192,6 +232,7 @@ mod tests {
"claude", "claude",
&args(["-p", "--verbose"]), &args(["-p", "--verbose"]),
Some(Path::new("/tmp/x")), Some(Path::new("/tmp/x")),
None,
); );
assert_eq!(argv(&command), ["claude", "-p", "--verbose"]); assert_eq!(argv(&command), ["claude", "-p", "--verbose"]);
assert_eq!(command.get_current_dir(), Some(Path::new("/tmp/x"))); assert_eq!(command.get_current_dir(), Some(Path::new("/tmp/x")));
@@ -204,6 +245,7 @@ mod tests {
port: Some(2222), port: Some(2222),
identity_file: Some("/home/me/.ssh/id_ai".into()), identity_file: Some("/home/me/.ssh/id_ai".into()),
options: vec!["StrictHostKeyChecking=accept-new".to_string()], options: vec!["StrictHostKeyChecking=accept-new".to_string()],
models_dir: None,
attachments_dir: None, attachments_dir: None,
}; };
let rendered = argv(&command( let rendered = argv(&command(
@@ -211,6 +253,7 @@ mod tests {
"claude", "claude",
&args(["-p", "--model", "haiku"]), &args(["-p", "--model", "haiku"]),
Some(Path::new("/home/bob/work")), Some(Path::new("/home/bob/work")),
None,
)); ));
assert_eq!(rendered[0], "ssh"); assert_eq!(rendered[0], "ssh");
@@ -231,12 +274,59 @@ mod tests {
#[test] #[test]
fn a_remote_command_without_a_cwd_just_execs() { fn a_remote_command_without_a_cwd_just_execs() {
let ssh = bare_host(); let ssh = bare_host();
let rendered = argv(&command(Some(&ssh), "claude", &args(["-p"]), None)); let rendered = argv(&command(Some(&ssh), "claude", &args(["-p"]), None, None));
assert_eq!(rendered.last().unwrap(), "exec 'claude' '-p'"); assert_eq!(rendered.last().unwrap(), "exec 'claude' '-p'");
// No -i means no IdentitiesOnly: ~/.ssh/config decides instead. // No -i means no IdentitiesOnly: ~/.ssh/config decides instead.
assert!(!rendered.contains(&"IdentitiesOnly=yes".to_string())); assert!(!rendered.contains(&"IdentitiesOnly=yes".to_string()));
} }
/// The second half of a transport: the connection that runs the command also
/// carries the port that reaches it.
///
/// Both ends are pinned to loopback, which is what keeps a model server off
/// the far machine's network -- asserted rather than trusted, because
/// dropping the addresses is a one-word edit that still works on a machine
/// nobody else can reach.
#[test]
fn a_forwarded_port_rides_the_same_connection_as_the_command() {
let ssh = bare_host();
let rendered = argv(&command(
Some(&ssh),
"llama-server",
&args(["--port", "24242"]),
None,
Some(Forward {
there: 24242,
here: 41000,
}),
));
let forward = rendered
.iter()
.position(|arg| arg == "-L")
.expect("a forward");
assert_eq!(rendered[forward + 1], "127.0.0.1:41000:127.0.0.1:24242");
assert!(rendered.contains(&"ExitOnForwardFailure=yes".to_string()));
// The half that is easy to lose: without a pty the far process outlives
// the connection, because nothing closes a stdin it never reads.
assert!(rendered.contains(&"-tt".to_string()));
assert!(!rendered.contains(&"-T".to_string()));
// Options come before the host, or ssh reads them as part of the
// remote command.
assert!(forward < rendered.len() - 2);
assert_eq!(
rendered.last().unwrap(),
"exec 'llama-server' '--port' '24242'"
);
// Nothing forwarded is nothing added: every other session is one of
// these, and an -L on it would bind a port for no reason.
let plain = argv(&command(Some(&ssh), "claude", &args(["-p"]), None, None));
assert!(!plain.contains(&"-L".to_string()));
// And a session that *is* spoken to over stdio keeps its raw pipe.
assert!(plain.contains(&"-T".to_string()));
assert!(!plain.contains(&"-tt".to_string()));
}
/// The one character quoting must not swallow. A working directory typed as /// The one character quoting must not swallow. A working directory typed as
/// `~/repos/ai-app` was arriving as the literal directory `~`, and the /// `~/repos/ai-app` was arriving as the literal directory `~`, and the
/// remote shell reported it missing -- which reads as the path being wrong /// remote shell reported it missing -- which reads as the path being wrong
@@ -277,7 +367,13 @@ mod tests {
assert_eq!(expand_home(Path::new("/tmp/~/x")), Path::new("/tmp/~/x")); assert_eq!(expand_home(Path::new("/tmp/~/x")), Path::new("/tmp/~/x"));
assert_eq!(expand_home(Path::new("~user/x")), Path::new("~user/x")); assert_eq!(expand_home(Path::new("~user/x")), Path::new("~user/x"));
let local = command(None, "claude", &args(["-p"]), Some(Path::new("~/work"))); let local = command(
None,
"claude",
&args(["-p"]),
Some(Path::new("~/work")),
None,
);
assert_eq!(local.get_current_dir(), Some(home.join("work").as_path())); assert_eq!(local.get_current_dir(), Some(home.join("work").as_path()));
} }
@@ -301,7 +397,7 @@ mod tests {
// tries to close the quote and start a new command. // tries to close the quote and start a new command.
let ssh = bare_host(); let ssh = bare_host();
let evil = Path::new("/tmp/'; touch /tmp/pwned; '"); let evil = Path::new("/tmp/'; touch /tmp/pwned; '");
let rendered = argv(&command(Some(&ssh), "claude", &[], Some(evil))); let rendered = argv(&command(Some(&ssh), "claude", &[], Some(evil), None));
let script = rendered.last().unwrap(); let script = rendered.last().unwrap();
assert_eq!( assert_eq!(
script, script,
+477 -42
View File
@@ -30,13 +30,13 @@
//! already has over that machine. //! already has over that machine.
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Mutex; use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use serde::Serialize; use serde::Serialize;
use serde_json::Value; use serde_json::Value;
use crate::config::{DriverKind, SetupConfig}; use crate::config::SetupConfig;
use crate::session::transport::{Launch, Transport}; use crate::session::transport::{Launch, Transport};
const USAGE_URL: &str = "https://api.anthropic.com/api/oauth/usage"; const USAGE_URL: &str = "https://api.anthropic.com/api/oauth/usage";
@@ -107,10 +107,30 @@ pub struct UsageSnapshot {
pub fetched_at: f64, pub fetched_at: f64,
} }
/// The name of each meter, said in one place because two lists have to
/// agree on it: [`UsageSnapshot::provider`], which is what `GET /usage`
/// labels a row with, and [`crate::config::DriverKind::usage_provider`],
/// which is how a session says which of those rows is about it.
pub const CLAUDE: &str = "claude";
/// The invented one, for testing the screens that draw these -- see
/// [`Fixture`].
pub const ECHO: &str = "echo";
pub trait UsageProvider: Send + Sync { pub trait UsageProvider: Send + Sync {
fn name(&self) -> &'static str; fn name(&self) -> &'static str;
/// Blocking -- call off the async workers. /// Blocking -- call off the async workers.
fn fetch(&self) -> UsageSnapshot; fn fetch(&self) -> UsageSnapshot;
/// How long an answer from this one may be reused.
///
/// A property of the provider rather than of the cache, because what
/// sets it is what asking costs: [`ClaudeUsage`] makes a network call
/// against an endpoint that rate-limits impatient callers, and the
/// fixture below reads a mutex. Caching the fixture for three minutes
/// would mean a test setting a number and watching the old one for
/// most of that, which reads exactly like the command not working.
fn poll_interval(&self) -> Duration {
MIN_POLL_INTERVAL
}
} }
/// Reads the numbers behind Claude Code's `/usage` from one machine, using the /// Reads the numbers behind Claude Code's `/usage` from one machine, using the
@@ -121,6 +141,10 @@ pub struct ClaudeUsage {
pub setup_name: String, pub setup_name: String,
/// How to reach that machine. `Here` for the backend's own. /// How to reach that machine. `Here` for the backend's own.
pub transport: Transport, pub transport: Transport,
/// The CLI to run there, for the one thing this asks of it: refreshing its
/// own expired token. The provider's, so a machine with the CLI somewhere
/// odd is asked at the same path its sessions run.
pub program: String,
} }
/// Where Claude Code keeps its credentials, as a shell word rather than a path: /// Where Claude Code keeps its credentials, as a shell word rather than a path:
@@ -170,7 +194,7 @@ impl ClaudeUsage {
impl UsageProvider for ClaudeUsage { impl UsageProvider for ClaudeUsage {
fn name(&self) -> &'static str { fn name(&self) -> &'static str {
"claude" CLAUDE
} }
fn fetch(&self) -> UsageSnapshot { fn fetch(&self) -> UsageSnapshot {
@@ -178,36 +202,111 @@ impl UsageProvider for ClaudeUsage {
Ok(token) => token, Ok(token) => token,
Err(state) => return self.snapshot(state, Vec::new()), Err(state) => return self.snapshot(state, Vec::new()),
}; };
let text = match ureq::get(USAGE_URL) let body = match self.call(&token) {
Ok(body) => body,
Err(Refused::Other(detail)) => {
return self.snapshot(UsageState::Failed { detail }, Vec::new());
}
Err(Refused::Unauthorized) => match self.after_cli_refresh(&token) {
Ok(body) => body,
Err(state) => return self.snapshot(state, Vec::new()),
},
};
self.snapshot(UsageState::Ok, parse_windows(&body))
}
}
/// Why one call to the usage endpoint did not produce numbers.
///
/// 401 is apart from the rest because it is the only one with a way out: the
/// endpoint answered, and it means the access token has expired rather than
/// that anything is broken.
enum Refused {
Unauthorized,
Other(String),
}
impl ClaudeUsage {
/// One call to the endpoint with one token.
fn call(&self, token: &str) -> Result<Value, Refused> {
let text = ureq::get(USAGE_URL)
.header("Authorization", &format!("Bearer {token}")) .header("Authorization", &format!("Bearer {token}"))
.header("anthropic-beta", "oauth-2025-04-20") .header("anthropic-beta", "oauth-2025-04-20")
.header("User-Agent", USER_AGENT) .header("User-Agent", USER_AGENT)
.call() .call()
.and_then(|mut response| response.body_mut().read_to_string()) .and_then(|mut response| response.body_mut().read_to_string())
{
Ok(text) => text,
Err(err) => {
// The error string can embed the URL but never the token. // The error string can embed the URL but never the token.
return self.snapshot( .map_err(|err| match err {
UsageState::Failed { ureq::Error::StatusCode(401) => Refused::Unauthorized,
detail: format!("usage endpoint unreachable: {err}"), other => Refused::Other(why(&other)),
}, })?;
Vec::new(), serde_json::from_str(&text)
); .map_err(|err| Refused::Other(format!("usage endpoint sent non-JSON: {err}")))
} }
};
let body: Value = match serde_json::from_str(&text) { /// Have the machine's own CLI refresh its token, then ask once more.
Ok(body) => body, ///
Err(err) => { /// **The CLI does the refresh, never this.** Anthropic's OAuth rotates the
return self.snapshot( /// refresh token, so whoever refreshes second presents a dead one and the
UsageState::Failed { /// machine is logged out until somebody runs `/login` on it -- and the
detail: format!("usage endpoint sent non-JSON: {err}"), /// machine we would be refreshing on is usually one with a live session of
}, /// its own. Running the CLI keeps it the only writer of
Vec::new(), /// `.credentials.json`.
); ///
/// `doctor` rather than the `auth status` it reads like, measured against
/// this CLI (2.1.258) on 2026-09-05 with a deliberately invalid token:
/// `auth status` reports `loggedIn: true` off the file alone and never
/// touches the network, so it would have refreshed nothing while looking
/// like it had. `doctor` resolves the account, which is what makes it
/// refresh, and it spends no quota. The same probe showed what a *failed*
/// refresh does -- the CLI blanks both tokens -- so this must stay on the
/// 401 path, where the access token is already dead, and never be used to
/// refresh speculatively.
///
/// Only a token that actually changed is retried, so a CLI that refreshed
/// nothing costs one call rather than two, and this cannot become a loop.
fn after_cli_refresh(&self, stale: &str) -> Result<Value, UsageState> {
let launch = Launch::new(&self.program, vec!["doctor".to_string()], None);
if let Err(err) = self.transport.capture_blocking(&launch) {
return Err(UsageState::Failed {
detail: format!(
"the Claude login on {} has expired, and `{} doctor` couldn't be run there to refresh it: {err:#}",
self.setup_name, self.program
),
});
} }
}; let fresh = self.access_token()?;
self.snapshot(UsageState::Ok, parse_windows(&body)) if fresh == stale {
return Err(self.still_expired());
}
self.call(&fresh).map_err(|err| match err {
Refused::Unauthorized => self.still_expired(),
Refused::Other(detail) => UsageState::Failed { detail },
})
}
/// A login the CLI could not renew: the one state here somebody has to act
/// on, so it says where and what to run.
fn still_expired(&self) -> UsageState {
UsageState::Failed {
detail: format!(
"the Claude login on {} has expired and could not be refreshed; run `{} /login` there",
self.setup_name, self.program
),
}
}
}
/// What a failed call to the usage endpoint should say.
///
/// A status is not a network fault and must not be reported as one: the
/// endpoint answered. 401 never reaches here -- it has its own way out in
/// [`ClaudeUsage::after_cli_refresh`] -- so what is left is a refusal nobody
/// on this side can fix.
fn why(err: &ureq::Error) -> String {
match err {
ureq::Error::StatusCode(code) => format!("usage endpoint refused the request: HTTP {code}"),
other => format!("usage endpoint unreachable: {other}"),
} }
} }
@@ -278,24 +377,260 @@ fn parse_windows(body: &Value) -> Vec<UsageWindow> {
.collect() .collect()
} }
/// An invented answer, so the screens that draw these can be exercised
/// without an account.
///
/// Every state the usage bar and the usage dialog can be in is otherwise
/// reachable only by spending somebody's quota or by breaking a machine:
/// a number near the top, a machine nobody has logged into, one that
/// cannot be reached, a window between blocks with no reset time. Those
/// are exactly the states worth looking at, and the ones nobody looks at
/// because arranging them costs real turns. An echo session sets this
/// with `/usage` (see `session::echo`), which is the same bargain the
/// rest of that driver makes: the fixture is invented, what is real is
/// the path it travels.
///
/// Shared by the session layer, which writes it, and [`UsageMonitor`],
/// which reads it. Empty until something sets it, and an empty fixture
/// produces no snapshot at all -- an echo session meters nothing, and
/// nothing is what the phone should draw.
#[derive(Clone, Default)]
pub struct Fixture {
said: Arc<Mutex<Option<Reported>>>,
}
/// What a meter answered: which of the four states it is in, and whatever
/// windows go with it. Empty for every state but [`UsageState::Ok`].
type Reported = (UsageState, Vec<UsageWindow>);
/// How long the invented five-hour window has left, when nothing says.
const FIXTURE_MINUTES: i64 = 125;
impl Fixture {
pub fn new() -> Self {
Self::default()
}
fn is_set(&self) -> bool {
self.said.lock().unwrap().is_some()
}
fn read(&self) -> Option<Reported> {
self.said.lock().unwrap().clone()
}
/// Acts on the words typed after `/usage`, and says what it did.
///
/// The vocabulary lives here rather than in the echo driver because
/// these are this module's states: a driver spelling them out would
/// be a second place that has to learn about a fifth one.
pub fn command(&self, words: &str) -> String {
let mut words = words.split_whitespace();
let Some(first) = words.next() else {
return match self.read() {
Some((state, windows)) => format!("usage fixture: {}", describe(&state, &windows)),
None => "usage fixture: unset, so this session meters nothing. \
`/usage 42` puts up a five-hour window at 42%."
.to_string(),
};
};
let rest: Vec<&str> = words.collect();
let detail = || {
if rest.is_empty() {
"set by /usage".to_string()
} else {
rest.join(" ")
}
};
let (state, windows) = match first {
"off" | "none" | "clear" => {
*self.said.lock().unwrap() = None;
return "usage fixture cleared: this session meters nothing again".to_string();
}
"notloggedin" | "logged-out" => (UsageState::NotLoggedIn, Vec::new()),
"unreachable" => (UsageState::Unreachable { detail: detail() }, Vec::new()),
"failed" => (UsageState::Failed { detail: detail() }, Vec::new()),
percent => match percent.parse::<f64>() {
Ok(percent) => (
UsageState::Ok,
fixture_windows(percent.clamp(0.0, 100.0), rest.first().copied()),
),
Err(_) => {
return format!(
"\"{percent}\" is not one of this fixture's answers. Say a percentage \
(`/usage 42`, optionally with `90` minutes left, `never` for a window \
between blocks, or `unreadable` for a reset time that cannot be read), \
or one of `notloggedin`, `unreachable`, `failed`, `off`."
);
}
},
};
let said = describe(&state, &windows);
*self.said.lock().unwrap() = Some((state, windows));
format!("usage fixture set: {said}")
}
}
/// The three windows Claude reports today, invented around one number.
///
/// Three rather than one because the bar under a session header reads the
/// five-hour window and the dialog behind the button draws all of them,
/// and a fixture with one window leaves half the screen untested. The
/// weekly ones are derived from the same figure so that the worst of them
/// -- which is what colours the button -- is still the one asked for.
fn fixture_windows(percent: f64, reset: Option<&str>) -> Vec<UsageWindow> {
let resets_at = match reset {
// The state a real response is in between blocks: there is no
// window running, so there is nothing to reset. It is not a
// missing value, and the phone words it differently.
Some("never") | Some("none") => None,
// A timestamp that arrives and cannot be read, which is the one
// case that really is "we could not find out".
Some("unreadable") | Some("bad") => Some("whenever it feels like it".to_string()),
other => Some(reset_in(
other
.and_then(|word| word.parse().ok())
.unwrap_or(FIXTURE_MINUTES),
)),
};
vec![
UsageWindow {
kind: "session".to_string(),
label: "5-hour window".to_string(),
percent,
resets_at: resets_at.clone(),
active: true,
},
UsageWindow {
kind: "weekly_all".to_string(),
label: "Weekly (all models)".to_string(),
percent: percent / 2.0,
resets_at: resets_at.as_ref().map(|_| reset_in(FIXTURE_MINUTES * 40)),
active: false,
},
UsageWindow {
kind: "weekly_scoped".to_string(),
label: "Weekly (Echo)".to_string(),
percent: percent / 4.0,
resets_at: resets_at.as_ref().map(|_| reset_in(FIXTURE_MINUTES * 40)),
active: false,
},
]
}
/// `minutes` from now, in the format the real endpoint sends.
fn reset_in(minutes: i64) -> String {
let at = time::OffsetDateTime::now_utc() + time::Duration::minutes(minutes);
at.format(&time::format_description::well_known::Rfc3339)
// Formatting a timestamp cannot fail for any input this builds;
// saying so beats a fixture that silently has no reset time.
.unwrap_or_else(|_| "unformattable".to_string())
}
/// One line naming what a fixture is currently claiming, for the reply
/// the echo session writes back.
fn describe(state: &UsageState, windows: &[UsageWindow]) -> String {
match state {
UsageState::Ok => match windows.first() {
Some(window) => format!(
"{}% of the five-hour window, {}",
window.percent,
match &window.resets_at {
Some(at) => format!("resetting at {at}"),
None => "with no reset time (the between-blocks state)".to_string(),
}
),
None => "no windows at all".to_string(),
},
UsageState::NotLoggedIn => "nobody is logged in on this machine".to_string(),
UsageState::Unreachable { detail } => format!("machine unreachable ({detail})"),
UsageState::Failed { detail } => format!("the meter failed ({detail})"),
}
}
/// The fixture, as a provider, so it travels the same route and the same
/// cache as a real meter rather than being spliced in at the screen.
struct EchoUsage {
setup: String,
setup_name: String,
fixture: Fixture,
}
impl UsageProvider for EchoUsage {
fn name(&self) -> &'static str {
ECHO
}
fn fetch(&self) -> UsageSnapshot {
let (state, windows) = self
.fixture
.read()
// Only ever built for a fixture that is set; a race with
// `/usage off` between the two reads lands here, and "the
// machine could not be asked" is the honest word for it.
.unwrap_or((
UsageState::Unreachable {
detail: "the usage fixture was cleared".to_string(),
},
Vec::new(),
));
UsageSnapshot {
provider: self.name().to_string(),
setup: self.setup.clone(),
setup_name: self.setup_name.clone(),
state,
windows,
fetched_at: crate::session::now(),
}
}
/// Read from memory, and set by somebody who is about to look at the
/// screen it changes.
fn poll_interval(&self) -> Duration {
Duration::ZERO
}
}
/// Which paid services a machine can be asked about. /// Which paid services a machine can be asked about.
/// ///
/// Derived from what the setup says it can run, so a machine with no Claude /// Derived from what the setup says it can run, so a machine with no Claude
/// provider is not asked about Claude limits -- it has none, and a row saying /// provider is not asked about Claude limits -- it has none, and a row saying
/// so would be a fact about nothing. A second service later adds a branch here /// so would be a fact about nothing.
/// and an impl beside [`ClaudeUsage`], not a screen. ///
fn providers_for(setup: &SetupConfig) -> Vec<Box<dyn UsageProvider>> { /// Which meter a provider has is [`DriverKind::usage_provider`]'s answer rather
/// than a second match on kinds here, because the phone pairs a session with
/// one of these rows by that same name: two lists that disagreed would leave a
/// session looking for a snapshot nothing produces. A second service later is a
/// name there and an impl beside [`ClaudeUsage`], not a screen.
fn providers_for(setup: &SetupConfig, fixture: &Fixture) -> Vec<Box<dyn UsageProvider>> {
let mut found: Vec<Box<dyn UsageProvider>> = Vec::new(); let mut found: Vec<Box<dyn UsageProvider>> = Vec::new();
if setup for provider in &setup.providers {
.providers let Some(name) = provider.kind.usage_provider() else {
.iter() continue;
.any(|provider| provider.kind == DriverKind::ClaudeCli) };
{ // A machine offering two Claude providers has one account, not
found.push(Box::new(ClaudeUsage { // two: the meter belongs to the machine and the service, which is
// exactly what the cache is keyed by.
if found.iter().any(|already| already.name() == name) {
continue;
}
match name {
CLAUDE => found.push(Box::new(ClaudeUsage {
setup: setup.id.clone(), setup: setup.id.clone(),
setup_name: setup.name.clone(), setup_name: setup.name.clone(),
transport: Transport::for_setup(setup), transport: Transport::for_setup(setup),
})); program: provider.program().to_string(),
})),
// Nothing at all until a test has asked for something: an
// echo session costs nothing, so the honest answer is no row
// rather than a row saying zero.
ECHO if fixture.is_set() => found.push(Box::new(EchoUsage {
setup: setup.id.clone(),
setup_name: setup.name.clone(),
fixture: fixture.clone(),
})),
_ => {}
}
} }
found found
} }
@@ -313,11 +648,18 @@ type Cached = HashMap<(String, &'static str), (Instant, UsageSnapshot)>;
/// machine per service per [`MIN_POLL_INTERVAL`], however often the phone asks. /// machine per service per [`MIN_POLL_INTERVAL`], however often the phone asks.
pub struct UsageMonitor { pub struct UsageMonitor {
cache: Mutex<Cached>, cache: Mutex<Cached>,
/// The invented meter an echo session can put up; empty unless one
/// has. Shared with the session layer, which is where the command
/// that sets it is typed -- see [`Fixture`].
fixture: Fixture,
} }
impl UsageMonitor { impl UsageMonitor {
pub fn new() -> Self { pub fn new(fixture: Fixture) -> Self {
Self::default() Self {
cache: Mutex::new(Cached::new()),
fixture,
}
} }
/// One snapshot per machine that offers a paid service, in the order the /// One snapshot per machine that offers a paid service, in the order the
@@ -329,10 +671,10 @@ impl UsageMonitor {
pub fn snapshots(&self, setups: &[SetupConfig]) -> Vec<UsageSnapshot> { pub fn snapshots(&self, setups: &[SetupConfig]) -> Vec<UsageSnapshot> {
let mut fresh = Vec::new(); let mut fresh = Vec::new();
for setup in setups { for setup in setups {
for provider in providers_for(setup) { for provider in providers_for(setup, &self.fixture) {
let key = (setup.id.clone(), provider.name()); let key = (setup.id.clone(), provider.name());
if let Some((fetched, snapshot)) = self.cache.lock().unwrap().get(&key) if let Some((fetched, snapshot)) = self.cache.lock().unwrap().get(&key)
&& fetched.elapsed() < MIN_POLL_INTERVAL && fetched.elapsed() < provider.poll_interval()
{ {
// Cached numbers, but the machine's *name* is read fresh: a // Cached numbers, but the machine's *name* is read fresh: a
// rename should show immediately rather than waiting out a // rename should show immediately rather than waiting out a
@@ -367,6 +709,44 @@ impl UsageMonitor {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::config::DriverKind;
#[test]
fn a_refusal_the_endpoint_answered_is_not_reported_as_an_unreachable_one() {
assert!(why(&ureq::Error::StatusCode(500)).contains("HTTP 500"));
assert!(why(&ureq::Error::HostNotFound).contains("unreachable"));
}
#[test]
fn an_expired_login_says_where_to_log_in_rather_than_naming_the_network() {
let provider = ClaudeUsage {
setup: "far".to_string(),
setup_name: "somewhere else".to_string(),
transport: Transport::for_setup(&unreachable_setup()),
program: "/opt/claude".to_string(),
};
// The machine cannot be reached, so the refresh attempt fails there
// rather than at the endpoint -- and the message still has to name the
// machine and the command, since that is all anybody gets to act on.
let UsageState::Failed { detail } = provider
.after_cli_refresh("stale")
.expect_err("an unreachable machine cannot refresh anything")
else {
panic!("an expired login is a fault to report, not a logged-out machine");
};
assert!(detail.contains("somewhere else"), "{detail}");
assert!(detail.contains("/opt/claude doctor"), "{detail}");
assert!(
!detail.contains("stale"),
"the token must never be quoted back"
);
let UsageState::Failed { detail } = provider.still_expired() else {
panic!("still expired is a fault");
};
assert!(detail.contains("/opt/claude /login"), "{detail}");
assert!(!detail.contains("unreachable"), "{detail}");
}
#[test] #[test]
fn parses_the_limits_array_defensively() { fn parses_the_limits_array_defensively() {
@@ -404,6 +784,7 @@ mod tests {
port: None, port: None,
identity_file: None, identity_file: None,
options: vec!["ConnectTimeout=1".to_string()], options: vec!["ConnectTimeout=1".to_string()],
models_dir: None,
attachments_dir: None, attachments_dir: None,
}), }),
providers: vec![crate::config::ProviderConfig { providers: vec![crate::config::ProviderConfig {
@@ -421,6 +802,7 @@ mod tests {
setup: "far".to_string(), setup: "far".to_string(),
setup_name: "somewhere else".to_string(), setup_name: "somewhere else".to_string(),
transport: Transport::for_setup(&unreachable_setup()), transport: Transport::for_setup(&unreachable_setup()),
program: "claude".to_string(),
}; };
let snapshot = provider.fetch(); let snapshot = provider.fetch();
// The distinction the old single `error` string could not make: this // The distinction the old single `error` string could not make: this
@@ -475,9 +857,62 @@ mod tests {
models: vec![], models: vec![],
}]; }];
// A machine with no Claude on it has no Claude limits, and a row // A machine with no Claude on it has no Claude limits, and a row
// reporting on it would be a fact about nothing. // reporting on it would be a fact about nothing. Echo included:
assert!(providers_for(&echo_only).is_empty()); // an echo session spends nothing, so until a fixture says
assert_eq!(providers_for(&unreachable_setup()).len(), 1); // otherwise there is no meter to report.
let unset = Fixture::new();
assert!(providers_for(&echo_only, &unset).is_empty());
assert_eq!(providers_for(&unreachable_setup(), &unset).len(), 1);
// And with one set, that machine has exactly the invented meter
// -- under the name the session's `usageProvider` will name.
let fixture = Fixture::new();
fixture.command("42");
let found = providers_for(&echo_only, &fixture);
assert_eq!(found.len(), 1);
assert_eq!(found[0].name(), ECHO);
assert_eq!(DriverKind::Echo.usage_provider(), Some(ECHO));
assert_eq!(DriverKind::ClaudeCli.usage_provider(), Some(CLAUDE));
// A local model costs nothing to run, so it meters nothing.
assert_eq!(DriverKind::LlamaCpp.usage_provider(), None);
}
/// The states the fixture exists to make reachable, and the one thing
/// it must not do: invent a reset time for a window that has none.
#[test]
fn the_fixture_says_each_state_the_screens_have_to_draw() {
let fixture = Fixture::new();
assert!(fixture.read().is_none(), "unset until somebody sets it");
fixture.command("42 90");
let (state, windows) = fixture.read().expect("set");
assert_eq!(state, UsageState::Ok);
assert_eq!(windows[0].kind, "session");
assert_eq!(windows[0].percent, 42.0);
assert!(windows[0].resets_at.is_some());
// Between blocks: no reset time, which the phone words as the
// window not running rather than as a time it could not read.
fixture.command("42 never");
assert_eq!(fixture.read().expect("set").1[0].resets_at, None);
fixture.command("unreachable no route to host");
assert!(matches!(
fixture.read().expect("set").0,
UsageState::Unreachable { detail } if detail == "no route to host",
));
fixture.command("off");
assert!(fixture.read().is_none());
// A word it does not know changes nothing and says what it takes.
fixture.command("42");
let refused = fixture.command("sideways");
assert!(
refused.contains("not one of this fixture's answers"),
"{refused}"
);
assert_eq!(fixture.read().expect("still set").1[0].percent, 42.0);
} }
#[test] #[test]