Two corrections to the previous commit. A subagent's closing report belongs in the subagent's transcript, which is where it already is; drawing it as a card in the parent's put the same paragraph in two places for a reader who did not ask for it. The row is a divider now -- a boundary, which is what the transcript actually needed there -- closed, saying only what reported and how it went. Opening it shows the report anyway, since leaving the conversation to read one line has its own cost, and a backgrounded command has no transcript of its own so this is the only place its report exists at all: that one names itself from its summary and has nothing left to open. `TranscriptDivider` grew a `trailing` slot for the chevron rather than the row growing its own copy of the rules. And the status was wrong for a session that was already running before the update, which is every session when the backend is replaced under it. Adoption picks a session's stdout back up from a recorded offset, so the `task_started` lines for subagents launched earlier are behind it and the translator never saw them -- it started with an empty set and reported `idle` with a subagent plainly still working. `Subagents::any_open` reads the directory instead, which is a measurement rather than bookkeeping and is right for a session this process did not start. Both sources are kept and neither subsumes the other: the translator's own set is the only thing that knows about a backgrounded *command*, which has no subagent to be found. The same pair decides whether an ending has already been reported, so a task that began before the restart still gets its divider. Echo's helpers now record their report as their own subagent's closing text, the way the real driver does, so the fixture has the shape being tested. Verified on the emulator: three dividers closed, one opened to its report, and each reply drawn as its own message. 170 server tests, ktfmt, clippy, rustfmt, Android lint and the JVM unit tests all clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
213 lines
12 KiB
Markdown
213 lines
12 KiB
Markdown
# 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 marked in the parent** (2026-09-06), as
|
|
`Event::TaskNote { about, title, status, summary }`: the turn the session
|
|
wakes up and runs would otherwise begin with nothing in front of it, which
|
|
drew two replies as one paragraph. It is a **divider**, closed, and does
|
|
not repeat the summary -- that is this subagent's own closing text, and
|
|
here is not where somebody reads it. Reported once however many of the two
|
|
lifecycle shapes arrive: whichever gets there first is the one that finds
|
|
the task still open, and `finish` below closes it. See PLAN.md's "A task
|
|
reporting back".
|
|
|
|
**While any task is outstanding the session's turn ends in
|
|
`Status Waiting` rather than `Idle`.** `Idle` means "waiting for a person",
|
|
and a session with a backgrounded subagent is not doing that. Two sources:
|
|
the translator's `open_tasks`, and `Subagents::any_open` -- which is what
|
|
covers a subagent launched before a backend restart adopted the session,
|
|
whose `task_started` is behind the offset its stdout is read from.
|
|
|
|
**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.
|