The count beside the status said how much work was going and never what,
so "3 bg tasks" was a number with no way to find out what it was about.
Drivers now report the tasks themselves rather than a size:
`Driver::background_tasks` returns `Vec<BackgroundTask>` -- id, the
provider's own description, and a kind -- served by
`GET /sessions/{id}/background`. It is runtime state, never persisted,
and `null` is "nobody has said", which is what a session with no process
answers and what the panel says in words rather than drawing as an empty
list. `description` is optional because Codex names a background terminal
by a process id, and a number drawn as a name is worse than admitting
there is none.
Claude's `background_tasks_changed` entries turn out to be objects
carrying `task_id`, `task_type` and `description`, so each is read rather
than counted -- and an `ambient` one is now dropped from the list and the
count alike, on the CLI's own instruction: a live-update watcher is not
activity, and counting one left a session reading `waiting` with nothing
to wait for.
The phone draws them in the right-hand panel above the subagents,
collapsed to "2 bg tasks running" and pushing the subagents down when
opened. Both lists are items of one lazy column, so neither can run off
the panel, and the section is refetched whenever the live count moves --
a card for work that has finished is exactly the stale measurement the
count exists not to be.
Verified against the real Claude CLI (2.1.261): a backgrounded `sleep 120`
came back as `{"id":"br16327wr","description":"Sleep for 120 seconds",
"kind":"command"}`, and on the emulator against the echo rig the section
appeared, expanded, and dropped a card as its task finished.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
290 lines
17 KiB
Markdown
290 lines
17 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 in a panel
|
|
over the open session 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 when the parent Task/Agent call is seen. A current Claude CLI's
|
|
`task_started` with `task_type: local_agent` is a recovery source when an
|
|
adopted stream begins after that call. A bare `parent_tool_use_id` is not
|
|
enough: other operations can also parent nested lines, and treating one as
|
|
proof created false subagents named after their first subcommand.
|
|
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`, `task_type`, `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.
|
|
|
|
Since Claude Code 2.1.261, `background_tasks_changed { tasks: [...] }` is
|
|
the authoritative level beside those edges: its set replaces the previous
|
|
set, so a missed terminal edge cannot leave a subagent running forever. Its
|
|
ids are deliberately not correlated with the edge stream; what is read off
|
|
each entry is its own description and kind, and what is read off the set is
|
|
whether it is empty and how large. The session API and stream expose that
|
|
size as `backgroundTasks`, which the phone draws beside the status, and
|
|
`GET /sessions/{id}/background` serves the entries themselves -- listed in
|
|
the session's panel *above* the subagents and never as subagent cards. An
|
|
`ambient` entry is excluded from both, on the CLI's own instruction: a
|
|
live-update watcher is not activity. A backgrounded subagent is legitimately
|
|
in both lists, since it is both running and a transcript. The edges still
|
|
carry mapping, outcome and closing summary. On adoption the driver sends a repeated `initialize`,
|
|
which makes a current CLI send the full set; an older CLI accepts it and sends no level,
|
|
leaving the edge-based path unchanged. A snapshot is reconciled immediately
|
|
when the persisted parent status proves it is between turns, and otherwise
|
|
at the next `result` boundary -- while a turn is open, a foreground agent is
|
|
legitimately absent from the background set. Reconciliation writes
|
|
`Status Exited`, which is also what makes a formerly stale row deletable;
|
|
a task notification ordered after the level can still add its summary.
|
|
|
|
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. The edge
|
|
fallback has two sources: the translator's `open_tasks`, and
|
|
`Subagents::any_open` -- which covers a subagent launched before a backend
|
|
restart adopted the session, whose `task_started` is behind the durable
|
|
stdout offset. On current Claude versions the replace-semantics level above
|
|
reconciles both at a safe turn boundary.
|
|
|
|
**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. The registry's open count is also
|
|
Codex's `backgroundTasks` measurement: lifecycle changes send it through the
|
|
same event and session-summary fields as Claude's provider snapshot. The other
|
|
part of that measurement is app-server's runtime
|
|
`thread/backgroundTerminals/list` set. Its process ids are held only in memory
|
|
and added to the open-child count; the driver refreshes the set at terminal
|
|
boundaries and while it remains nonempty, rather than decrementing for an
|
|
unmatched ending edge.
|
|
|
|
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 `Task` when the
|
|
description is absent. An adopted current CLI can recover the same fields from
|
|
its `local_agent` lifecycle record. 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 panel rather than
|
|
at the bottom of the session: this selection belongs to the subagent list,
|
|
and a bar under the composer would read as acting on the conversation.
|
|
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 the panel without refetching
|
|
anything else. The phone's cached copy of
|
|
a deleted subagent's transcript is purged with it.
|
|
- The main session list does not expand or count subagents. Swiping left over
|
|
an open session pulls an 88%-wide panel in from the right and fetches
|
|
`/sessions/{id}/subagents`; it draws one `OutlinedCard` per subagent: title,
|
|
then the status word and a relative time. The transcript remains composed
|
|
under the panel, so its event stream, draft and scroll position stay live.
|
|
Horizontal scrollers inside the transcript win the gesture. Collapsing one,
|
|
or starting over any ordinary part of the session, gives the gesture back to
|
|
the panel; Android keeps its own edge Back gesture. Swiping right on the
|
|
panel, tapping outside it, or Back closes it.
|
|
- Tapping a subcard opens a `SessionScreen` layer 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.
|
|
It is another layer over the still-composed session and its panel; Back
|
|
returns to the panel.
|
|
- 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.
|