iris is the framework alone; the app is one crate in app-rust/

Iris: "the organization of the rust rewrite is a mess right now... there
shouldn't be anything related to the app inside of iris. Iris is supposed
to be the UI framework alone." And, on the crate count: "I'm confused why
the app only code needs more than one crate though."

Nine cargo workspaces become three, and the port's project code -- which
sat in five places, four of them inside the framework -- becomes one crate,
`ai-app`, in `app-rust/`:

  client-core                -> app-rust/src/client
  iris/transcript-ui         -> app-rust/src/ui
  iris/transcript-fixture    -> app-rust/src/ui/fixture.rs + tests/ + touch/
  iris/desktop-app           -> app-rust/src/desktop + src/bin_desktop.rs
  iris/android-app           -> app-rust/src/android + android-project/
  android-shell              -> app-rust/src/shell

iris/ keeps core, macro, the iris crate, tabs-ui and rig-input, and now
mentions no session, transcript, setup or server anywhere.

Only two of the old splits had a reason that survived reading. event-model
stays a crate at the repo root because server/ depends on it too, so a
crate is what makes the backend and the app agree by construction. The two
Android .so names looked like a hard constraint -- a package produces one
library artifact -- until P2 turned out to already plan merging those two
Android apps into one; both faces now come out of libai_app.so, picked
apart by features so `--no-default-features --features shell` keeps wgpu,
parley and iris out of the Compose app's APK. docs/RUST.md's "One app
crate" has the rest, including what each remaining feature is for.

DECISIONS.md and SUBAGENTS.md move into docs/ with everything else.

Verified: ./run-tests.sh and `cd iris && cargo test` green, clippy and fmt
clean in all five workspaces, `cargo ndk -t x86_64` links libai_app.so,
build-apk.sh produces an APK that installs and launches on this checkout's
emulator (Gl ... virgl, as expected), and the phone-sized headless
screenshot renders the transcript unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Opus 5 committed 2026-09-08 23:36:38 -04:00
1 parent e9a6562dc6
commit 6d5a231f5c
100 files changed
+924 -3295

No files matched your search

+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 `SUBAGENTS_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.