Files
ai-app/SUBAGENTS.md
T
irisandClaude Opus 5 5711c2568a Never run two turns into one, and say when a session waits on its own work
A turn started by something with no row of its own -- a subagent reporting
back, a peer message the CLI only owns up to at the end -- met the previous
reply with nothing between it, and the fold grew that reply rather than
starting a new one. Two answers were drawn as one paragraph, running together
mid-sentence with not even a space between them. The fold now refuses to grow
a settled reply, and `joinPages` carries the same rule across a page boundary.

The other half is the row. `Event::TaskNote` records a background task
reporting back -- a subagent that finished, or a backgrounded command -- with
its title, how it ended and what it said; `TaskNoteRow` draws it as a card,
since somebody said this, and its own row rather than an update to the Task
call's, which is above everything the session has said since. Reported once
however many of the CLI's two lifecycle shapes arrive.

`SessionStatus::Waiting` is a session whose own turn is over while work it
started is not. `Idle` means "waiting for a person" and this means the
opposite, so reporting it as idle sent a "finished" notification at the one
moment that was untrue. Drawn as "waiting" in `waitingColor`; the queue and
the held-command boundary release on either end-of-turn status, so a message
sent while a subagent runs is not held until it finishes.

And a usage limit the account hits inside a subagent now reaches the session
as well as the subagent's transcript. `resume.rs` can only schedule against a
session, and a background Task outliving its parent's turn is the ordinary
case, so auto-resume was doing nothing at all for it.

The status word and its colour were two `when`s on two screens, and the second
missed `waiting` silently; they are `sessionStatusWord`/`sessionStatusColour`
now. Echo's `/subagent n` reproduces the whole shape, staggered a second
apart. Verified on the emulator against the sandbox: 169 server tests, ktfmt,
clippy, rustfmt, Android lint and the JVM unit tests all clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 18:57:30 -04:00

12 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 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, and POST /sessions/{id}/subagents/delete removes finished ones on their own -- all or nothing, and refused while any named one is still running, since its transcript is still being written to and its process is the session's to stop.

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. What ends a subagent is the CLI's own task lifecycle, on top-level system lines that carry no parent_tool_use_id: task_started (task_id, tool_use_id, is_backgrounded, the prompt), task_progress repeatedly, then task_updated (patch.status, naming the task only) and task_notification (tool_use_id, status, and summary -- the agent's own report). translate_task keeps the task_id -> tool_use_id mapping from the first so the update can be attributed, records the summary as the subagent's closing text, and writes Status Exited. A completed update is deliberately not the end: its notification carries the summary and would otherwise land after the ending. Any other terminal status ends it from the update, since the failure to avoid is a subagent nothing ever finishes.

    The two rules this replaces were both wrong, in opposite directions. The parent's tool_result is not it: a backgrounded Task's arrives at launch ("Async agent launched..."), so ending there truncated a running agent's transcript at the moment it started. Nor is the subagent's own end_turn: measured against 2.1.237 on 2026-09-06, a subagent's lines carry no stream_event at all -- they are whole user/assistant lines with a null stop_reason, no result line is sent for one, and the sub's final report never appears as a child line -- so that rule could never fire and every subagent stayed running for ever. ends_a_turn is kept as a second detector for a dialect that does say either, and must never be the only one again.

    Status Exited either way; the subagent's vocabulary has no Idle or Waiting, so the end-of-turn status dispatch produces for an ordinary session is dropped rather than written.

    The ending is also reported to the parent (2026-09-06), as Event::TaskNote { about, title, status, summary }: the notification is a message the session received, and the turn it wakes up and runs would otherwise begin with nothing in front of it -- which drew two replies as one paragraph. Reported once however many of the two lifecycle shapes arrive; the task_id -> tool_use_id entry is removed as it is reported, which is what says the first one got there. See PLAN.md's "A task reporting back".

    While any task is outstanding the session's turn ends in Status Waiting rather than Idle -- the same tasks map, asked whether it is empty. Idle means "waiting for a person", and a session with a backgrounded subagent is not doing that.

    A limit the account hits inside a subagent is hoisted to the session as well as recorded here, because resume.rs can only schedule against a session, and a background subagent outliving its parent's turn is the ordinary case -- see PLAN.md's "A limit a subagent hits is the session's".

  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. Read from the directory rather than from the live map, because one left Running by a previous run of the server is precisely the one nothing in this process has touched -- and it would otherwise read running again every time its session was started.

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, and delete(ids) -- its path out. 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 -- four 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
POST /sessions/{id}/subagents/delete     {subagents} -> 204; refused whole if one is running

The delete is a batch rather than a DELETE per id for the reason the import list's is: the phone deletes what a reader selected, and one request per row means a batch can half-arrive, leaving the rows that were missed looking exactly like rows nobody picked. Unlike an import delete it is local file removal, so it is done by the time the reply is sent and there is no per-row state to follow afterwards. What decides "running" is Subagents::list's own rule, shared through routes::has_a_process so the list and the delete cannot disagree about it.

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. A subagent never reports waiting: that is a session's word for having outstanding work of its own, and a subagent has none. 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

  • The subcards are ordered still running first, then most recently active -- a display decision made on the phone (subagentOrder), over the server's stable oldest-first answer. Two keys rather than activity alone because a subagent that is thinking reports nothing meanwhile and would sink below one that just finished.
  • Holding a subcard selects it, and several at a time, exactly as the import list works, with the selection bar drawn inside the session's card rather than at the bottom of the screen: this selection belongs to one card, and a bar down there would read as acting on the whole list. One card at a time -- picking a row in another card moves the selection rather than adding to it, since one Delete is one request against one parent. Delete is disabled, with the reason in words, while anything selected is still running. Deleting confirms first, dims the rows it is acting on (BusyItem), and on success takes them out of that card and off the session's count without refetching anything else. The phone's cached copy of a deleted subagent's transcript is purged with it.
  • 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.