16 KiB
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
-
Created when the parent Task/Agent call is seen. A current Claude CLI's
task_startedwithtask_type: local_agentis a recovery source when an adopted stream begins after that call. A bareparent_tool_use_idis 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, thenUserMessage { text: <the Task's prompt> }when the prompt is known -- it genuinely is the subagent's first user turn. -
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). -
What ends a subagent is the CLI's own task lifecycle, on top-level
systemlines that carry noparent_tool_use_id:task_started(task_id,tool_use_id,task_type,is_backgrounded, the prompt),task_progressrepeatedly, thentask_updated(patch.status, naming the task only) andtask_notification(tool_use_id,status, andsummary-- the agent's own report).translate_taskkeeps thetask_id -> tool_use_idmapping from the first so the update can be attributed, records the summary as the subagent's closing text, and writesStatus Exited. Acompletedupdate 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; the useful claims here are whether the set is empty and its measured size. The session API and stream expose that size asbackgroundTasks, which the phone draws beside the status without pretending those tasks are subagents. The edges still carry mapping, outcome and closing summary. On adoption the driver sends a repeatedinitialize, 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 nextresultboundary -- while a turn is open, a foreground agent is legitimately absent from the background set. Reconciliation writesStatus 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_resultis 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 ownend_turn: measured against 2.1.237 on 2026-09-06, a subagent's lines carry nostream_eventat all -- they are wholeuser/assistantlines with a nullstop_reason, noresultline 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 stayedrunningfor ever.ends_a_turnis kept as a second detector for a dialect that does say either, and must never be the only one again.Status Exitedeither way; the subagent's vocabulary has noIdleorWaiting, so the end-of-turn statusdispatchproduces 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
finishbelow 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 Waitingrather thanIdle.Idlemeans "waiting for a person", and a session with a backgrounded subagent is not doing that. The edge fallback has two sources: the translator'sopen_tasks, andSubagents::any_open-- which covers a subagent launched before a backend restart adopted the session, whosetask_startedis 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.rscan 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". -
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 childTranslator, just picking back up. -
When the parent session's process exits (
Status Exitedon the session), every subagent stillRunninggetsStatus Exitedtoo: its process was the parent's. Read from the directory rather than from the live map, because one leftRunningby a previous run of the server is precisely the one nothing in this process has touched -- and it would otherwise readrunningagain 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 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, inShared),Subagent(itsTranscriptbehind a mutex plus abroadcast::Sender<SeqEvent>),record(id, event),start(id, title, prompt),finish(id),reopen(id),finish_all(),list()from disk, anddelete(ids)-- its path out. Drivers get anArc<Subagents>beside theirEventSink; llama ignores it.session/claude/translate.rs-- routes child lines by parent id, holds one childTranslatorper 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 oneBashtool 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-widthChevron(Pointing.Down)row that flips toPointing.Up-- collapsed by default. Expanding fetches/sessions/{id}/subagentsand draws oneOutlinedCardper 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 isSessionScreenin 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,TranscriptSourceand the cache take a transcript address rather than a session id --sessions/{id}orsessions/{id}/subagents/{sub}-- so the cache nests a subagent's copy under its session's and the same code serves both.