Iris: "remove both decisions and iris.md. I've decided to instead make decisions when planning with agents rather than after they do things, and they're both too long for me to wanna read, + don't cover all the decisions I'll wanna make about the code anyways. I'll just naturally run into things for now. Todo is important though." So docs/DECISIONS.md (850 lines) and docs/IRIS.md (1,986) are gone, and AGENTS.md now says not to start another: raise a choice while planning it with her, otherwise decide it and put the reasoning at the code it governs. The TODO lists stay. docs/SUBAGENTS_DECISIONS.md went with them -- same artefact, same reasoning, and she did not name it, so its six decisions were folded into docs/SUBAGENTS.md rather than deleted. Deleting the logs left ~30 citations dangling in code comments and docs. Each states its reason inline and cited the file only for provenance, so they now read "decided 2026-09-07" or name the module doc that carries the reasoning. The root had six things that were not a program or a document. Moved, per "I only meant top level sh files": run-tests.sh, test-wg-tunnel.sh, wg-setup-host.sh -> scripts/ rigs/ -> scripts/rigs/ xtask/ -> scripts/xtask/ A project's own scripts stayed with the project: app/*.sh, app-rust/*.sh, iris/*.sh and server/enroll-link.sh did not move. `target/` at the root is deleted and cannot come back: there was never a workspace there, and the 29 MB was only xtask's scratch space, now in scripts/xtask/target/. `cargo xtask apk` still runs from the repo root and now publishes to scripts/build/outputs/apk/<mode>/ -- one directory deep, because that is what Dev Updater's `*/build/outputs/apk/*/*.apk` discovery pattern needs, and scripts/xtask/build would have been two. Verified: ./scripts/run-tests.sh and `cd iris && cargo test` green, clippy and fmt clean everywhere, `cargo xtask apk debug --abi x86_64` builds and signs an APK carrying lib/x86_64/libai_app.so at the new publish path, and the repo root is now eleven entries with no build output among them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
9.5 KiB
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 choices behind it are at the bottom of this file.
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
- 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, thenUserMessage { text: <the Task's prompt> }when the prompt is known -- it genuinely is the subagent's first user turn. - 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). - The parent's
tool_resultnever finishes a subagent. The Task tool runs in the background by default: thetool_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'smessage_deltaon its stream carryingstop_reason: "end_turn"(astop_reasonoftool_useis the model about to call one, not an end), or aresultline for its own turn if a future CLI version ever sends one. Either maps toStatus Exited; the subagent's vocabulary has noIdle, so the equivalent eventdispatchproduces for an ordinary session is dropped rather than written. A shipped version of this finished on thetool_resultinstead, which read a running background agent as "finished" with its transcript truncated at the moment it launched. - 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 childTranslator, just picking back up. - When the parent session's process exits (
Status Exitedon the session), every subagent stillRunninggetsStatus Exitedtoo: 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, inShared),Subagent(itsTranscriptbehind a mutex plus abroadcast::Sender<SeqEvent>),record(id, event),start(id, title, prompt),finish(id),reopen(id),finish_all(),list()from disk. Drivers get anArc<Subagents>beside theirEventSink; llama ignores it.session/claude/translate.rs-- routes child lines by parent id, holds one childTranslatorper 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 oneBashtool 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-widthChevron(Pointing.Down)row that flips toPointing.Up-- collapsed by default. Expanding fetches/sessions/{id}/subagentsand draws oneOutlinedCardper 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 isSessionScreenin 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,TranscriptSourceand the cache take a transcript address rather than a session id --sessions/{id}orsessions/{id}/subagents/{sub}-- so the cache nests a subagent's copy under its session's and the same code serves both.
The choices behind the shape, and what they rejected
Made without asking, each with limited blast radius:
- 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. - 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
readOnlymode threaded throughSessionScreen. - 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 /sessionsfrom reading every subagent transcript. Consequence: an expanded card's statuses refresh with the list, not live. - 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.
- Subagents of imported sessions are not shown. The import path still
skips
isSidechainrecords; the CLI's ownsubagents/agent-*.jsonlfiles are not read. Only subagents run while this backend was watching exist. - 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.