A subagent is a second transcript owned by a session, in the same event model, with no process and no controls. The claude translator routes lines carrying parent_tool_use_id to a per-subagent translator and transcript under <session>/subagents/<tool_use_id>; three routes expose the list, a transcript page and the SSE stream. Echo grows /subagent [n] as the rig. On the phone a card with subagents ends in a chevron expander, collapsed by default, opening to outlined subcards styled like dev-updater's components; a subcard opens SessionScreen in read-only form, addressed through TranscriptAddress so paging, cache and stream are shared. Design in SUBAGENTS.md; choices awaiting review in DECISIONS.md. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
124 lines
6.2 KiB
Markdown
124 lines
6.2 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.
|
|
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. When the parent's `tool_result` for the Task id arrives, the parent gets
|
|
its `ToolEnd` as before, and the subagent gets `Status Exited`.
|
|
4. 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. If its Task call finished while the backend
|
|
was down nothing ever closes it -- its last status stays `Running`, which
|
|
the list reports as **unknown** rather than as running (see the wire shape).
|
|
|
|
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)`, `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.
|