255 lines
15 KiB
Markdown
255 lines
15 KiB
Markdown
# Subagents
|
|
|
|
A session's subagents -- helpers started by Claude Code's Task tool or Codex's
|
|
collaboration tools -- 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; extended to Codex's multiplexed app-server threads on
|
|
2026-09-13. 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 machine, 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.
|
|
|
|
Claude 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.
|
|
|
|
Codex app-server multiplexes every thread in the session tree onto the root
|
|
process's stdout. Its notifications carry `threadId`; `subAgentActivity`
|
|
items name the child thread and its lifecycle, and `collabAgentToolCall`
|
|
items carry the spawn prompt. The Codex translator routes a non-root
|
|
`threadId` exactly as Claude routes a `parent_tool_use_id`. The child thread
|
|
id is the subagent id on disk. An asynchronously delivered `agentMessage` is
|
|
a `PeerMessage`, not assistant text from the recipient. Its delta notification
|
|
does not repeat the completed item's `delivery` field, so the translator
|
|
remembers that field from `item/started` and suppresses those deltas. Letting
|
|
one into the recipient's provisional assistant row makes its next completed
|
|
message replace the combined row, visibly erasing text that Codex still has.
|
|
The parent draws the initial `spawnAgent` as its ordinary `Task` card and
|
|
closes it when the matching `subAgentActivity.started` arrives. The remaining
|
|
collaboration calls remain visible as coordination -- waiting, messaging,
|
|
listing and lifecycle controls -- rather than being mistaken for generic task
|
|
output. Null optional fields and a bare `completed` status carry no information
|
|
and are omitted; their useful result is the child transcript, status or peer
|
|
message beside them.
|
|
|
|
## Storage
|
|
|
|
Under the session directory:
|
|
|
|
```
|
|
<session>/subagents/<subagent_id>/meta.json {title, created}
|
|
<session>/subagents/<subagent_id>/transcript.jsonl same SeqEvent lines as the session's
|
|
```
|
|
|
|
The id is Claude's Task tool_use id (`toolu_…`) or Codex's child thread id.
|
|
Both are unique, stable across a backend restart, and already the key their
|
|
parent-side lifecycle 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 reaches the parent's transcript as nothing at all**
|
|
(2026-09-06). It was tried, and a row per finished subagent is a screenful
|
|
of dividers about work the reader was not asking after; the closing report
|
|
is *this* transcript's last line and here is where somebody reads it. What
|
|
the parent gets a row for is a message a subagent genuinely sends it, which
|
|
arrives by the peer path. A backgrounded *command* is the other half of
|
|
this and goes the other way: it has no transcript of its own, so its report
|
|
updates the tool card that launched it, which was still saying the command
|
|
was running. The two lifecycle shapes are still handled once:
|
|
whichever gets there first is the one that finds the task still open, and
|
|
`finish` below closes it. See PLAN.md's "Two turns must never be drawn as
|
|
one".
|
|
|
|
**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. That
|
|
second lookup is also what lets such a subagent's ending be recognised at
|
|
all.
|
|
|
|
**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.
|
|
|
|
For Codex the same lifecycle is expressed by app-server rather than Claude's
|
|
task notices: `subAgentActivity.started` creates the child,
|
|
`subAgentActivity.interacted` reopens it, and `completed` or `interrupted`
|
|
finishes it. A child's own `turn/completed` is not its end; it remains running
|
|
until that activity edge. The root's `turn/completed` reports `waiting` while
|
|
the registry contains an open child, and the last activity completion reports
|
|
`idle` if the root is between turns. Because the child thread id is also the
|
|
on-disk id, an adopted driver can route and finish a child whose spawn record
|
|
is already behind the durable stdout offset.
|
|
|
|
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: for Claude, 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. For Codex,
|
|
the first lifecycle record uses the spawned thread's name or the last segment
|
|
of `agentPath`, with underscores shown as spaces, then falls back to
|
|
`subagent`.
|
|
|
|
## 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/codex/translate.rs` -- routes multiplexed app-server notifications
|
|
by thread id, remembers collaboration prompts, and translates activity
|
|
edges into the same registry lifecycle.
|
|
- `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.
|