Repository navigation
design client subagent sessions
Issue: #71. Ships: feat/71-native-subagent-sessions (shared/cli/web).
When a claude-code client agent used its Task/Agent tool (a sub-agent), the child's entire tool activity (Bash/Edit/Read cards) streamed into the MAIN agent transcript as if the main agent performed it. The main agent's steps, the sub-agent's tool rows, and the Task tool card interleaved in one flat list (rig-reproduced live: a hello-world dispatch rendered six child tool cards inline under a "Create about.html intro page" Task card).
claude-agent-acp has a native-sub-agent runtime that only turns on when the
CLIENT advertises clientCapabilities.subagents; without it the adapter pours
child frames into the root session. Two further facts from driving the pinned
0.79.0 by hand (initialize + session/load over stdio):
- The bundled
@agentclientprotocol/sdk1.4.0 zod schema strips an unknownclientCapabilities.subagentsduring request parsing — the advertisement never reached the runtime. (_metasurvives the parse, which is the AIR-draft escape hatch the adapter also accepts — that path speaks the older draft protocol.) - The bundled
@anthropic-ai/claude-agent-sdk0.3.274getSessionMessagesreturns no sidechain entries (sub-agent transcripts live in a separatesubagents/file), so a resumed session could not reconstruct children at all — and with the capability forced on, the adapter then SKIPS the Task tool card, making the child vanish entirely from replays.
Both are fixed upstream: the pin moved to claude-agent-acp 0.93.0 (ACP SDK
1.9.0 keeps subagents; agent-sdk 0.3.300 replays sidechain). The #2
setModel-skip dist patch is retired with the bump — upstream landed its own
skip; the patch mechanism stays (test-injectable since #71) for future use.
- The launch stays an ordinary parent
tool_call(Agent/Task) — NOT suppressed (that was the 0.79 AIR-draft behavior). - The parent frame carries the child lifecycle as
subagent_update:{sessionUpdate:'subagent_update', sessionId:<child>, title, description, state:{state:'running'}}announces; a later frame withstate:{state:'idle', stopReason:'end_turn'|'cancelled'|'error', error?}(or{state:'unknown'}) ends it. - The child's incoming prompt rides
session_messageon the CHILD's sessionId; its work (message/thought/tool frames) also streams on the child's own sessionId. - The 0.79 draft shapes (
subagent_spawned/subagent_state_update) are kept in our mapper for back-compat.
-
shared — three additive
ChatStreamEventkinds:subagent_spawned{subagentId, name, task},subagent_update{subagentId, event} (the child event wrapped; the child union is message_delta/thought_delta/tool_call/raw — config/usage/permissions are root-session concerns),subagent_done{subagentId, state: completed|failed|cancelled|disconnected}. RFD states map: idle+end_turn→completed, cancelled→cancelled, error→failed, unknown→disconnected. -
cli — initialize advertises
subagents: {}(agent-connection.ts);routeSubagentFrameroutes everysession/updateBY FRAME SESSION ID on both the live path (wireSession) and the session/load capture path (wireCapture): RFD/draft lifecycle frames map and REGISTER the child id; frames on a registered child id map through the ordinarymapAcpUpdateand wrap intosubagent_update(childusagedrops child-side). Unknown non-root session ids fall through to root handling (defensive; nothing emits them today). -
web — the fold gains a
subagentrow (upsert by subagentId; a wrapped update for an unannounced child still renders via a placeholder row). The child's activity lives in its OWNFoldStateinside the row, so it renders inside a collapsible card (subagent-card.tsx: 24px disclosure row — state dot, name,·, truncated task, duration, running label — expanding into adata-surface="well"body that reuses the ordinary row components, max-h scrollable). Turn sweeps settle still-running children like tool rows;subagent_donealso settles the child fold in place (no synthetic tail). - The dispatch poll's text accumulator skips the new kinds — a driven channel's report stays the MAIN agent's text (the child's narration no longer pollutes it; previously it did).
- cli fixture scripts the RFD sequence (announce → session_message + text + Write + dropped usage on the child id → idle/end_turn → launch card completes); tests assert the wrapped kinds, zero child leakage into root events, the capability advertisement, and the same routing on the capture path. 332 cli / 388 server / 131 shared / 114 web green; ui-snap 52/52.
- Rig E2E (agent-browser): resuming the session that reported the defect now renders the sub-agent card (completed, name/task, full child transcript inside); a fresh live run streams running→completed with the child's tools only inside the card; probe artifacts cleaned.
-
session_message(the child's full incoming prompt) currently maps torawand renders nothing inside the card — the card shows the announce's shortdescription. Surfacing the full prompt is a small web-only follow-up. - Other targets (opencode/codex/dsh/pi) do not speak sub-agent sessions; the capability advertisement is inert for them.
-
Deployment note: the pin bump does not propagate to EXISTING adapter
stores —
provisionAdaptersonly checks bin presence, so a machine with a 0.79.0~/.hnx/acp-adapterskeeps it until the store is wiped (rm + daemon restart re-provisions 0.93.0). Old stores simply keep the pre-#71 behavior (capability stripped → flat mixing); nothing breaks.