Summary
A client tool that first ran inside a subagent, and is later replayed by the SDK after VS Code restarts, is handled on the parent chat rather than the subagent chat that owns it. It executes and the turn completes, so this is a grouping defect rather than a hang, but the call appears in the wrong conversation and the parent gains a synthesized duplicate.
The in-process half of this is fixed in #330746. This issue is only the after-restart half, which needs coordinated changes and seemed worth raising as one design question rather than patching.
Why the parent cannot be resolved
The SDK gives the host nothing to resolve it with. The MCP invocation carries only the tool_use id: extractToolUseId reads extra._meta['claudecode/toolUseId'], and the CLI has the agent id in scope at that dispatch site and deliberately does not include it. The two channels that do carry subagent identity, canUseTool's agentID and parent_tool_use_id on streamed envelopes, are both unavailable here: a replayed call streams nothing, and inner subagent tools run under bypassPermissions so canUseTool is skipped.
The host's own inner-to-parent edge (SubagentRegistry._innerToParent) is written only by the stream mapper, so it does not exist for a replayed call. primeFromTranscript cannot supply it either: it only records (toolCallId, agentId) spawn pairs, and it is fed the main transcript, which excludes sidechain entries. Inner calls live in the per-subagent transcripts.
Why the obvious fix is harmful
Resolving the parent at invoke time, by keeping the registry edge alive past drainForegroundSpawns or priming it from elsewhere, is the smaller change and is worse. It sets parentToolCallId in cases where the host has no subagent chat, and the signal is then buffered in _pendingSubagentSignals awaiting a subagent_started that cannot arrive, since that is emitted only from a live inner stream. The parked MCP handler never settles.
The present coupling is safe by accident: the edge exists only when the same code path already emitted subagent_started. Any parent resolution from a non-stream source breaks that invariant, which is why #330746 fixes the in-process case with a host-side routing fallback instead.
What closing the restart case would take
Three coordinated changes, plus one structural wrinkle:
- Prime the edge from the SDK's store. The mapping is persisted as
subagents/agent-<id>.meta.json -> toolUseId, reachable through listSubagents and getSubagentMessages, whose envelopes carry parent_tool_use_id.
- Restore a subagent chat for a dangling
Task. _registerRestoredSubagentSummaries discovers subagents from ToolResultSubagentContent gated on Completed || Running. The replay case is exactly a Task that never completed, which replays as cancelled with no content, so it is skipped.
- Populate the routing map.
_subagentChats is written only from a live subagent_started; no restore path writes it, so routing has nowhere to go even once the chat exists.
The wrinkle: (1) is an async SDK read on a path that is synchronous today.
Shipping only part of this is worse than shipping none. (1) without (2) and (3) is precisely the harmful shape above.
Offer
I am happy to implement this if it is wanted, and equally happy for it to be closed or reshaped. I have no attachment to the approach above; it is the shape the code suggested to me, not a proposal I am invested in. The reason I am offering rather than just filing is that I want the in-process fix locally today regardless, so the analysis already exists and offering it publicly costs nothing.
If the answer is that client tool execution should be restructured around the SDK invocation instead, as discussed in #330899, this becomes moot and I would rather not build it.
Environment
Reproduced against a dev container (remote agent host) on 1.133.x, using the Claude provider.
Related: #330746 (in-process fix), #330899 (root cause of the wider client-tool execution seam).
AI disclosure: this issue was written with the assistance of AI.
Summary
A client tool that first ran inside a subagent, and is later replayed by the SDK after VS Code restarts, is handled on the parent chat rather than the subagent chat that owns it. It executes and the turn completes, so this is a grouping defect rather than a hang, but the call appears in the wrong conversation and the parent gains a synthesized duplicate.
The in-process half of this is fixed in #330746. This issue is only the after-restart half, which needs coordinated changes and seemed worth raising as one design question rather than patching.
Why the parent cannot be resolved
The SDK gives the host nothing to resolve it with. The MCP invocation carries only the tool_use id:
extractToolUseIdreadsextra._meta['claudecode/toolUseId'], and the CLI has the agent id in scope at that dispatch site and deliberately does not include it. The two channels that do carry subagent identity,canUseTool'sagentIDandparent_tool_use_idon streamed envelopes, are both unavailable here: a replayed call streams nothing, and inner subagent tools run underbypassPermissionssocanUseToolis skipped.The host's own inner-to-parent edge (
SubagentRegistry._innerToParent) is written only by the stream mapper, so it does not exist for a replayed call.primeFromTranscriptcannot supply it either: it only records(toolCallId, agentId)spawn pairs, and it is fed the main transcript, which excludes sidechain entries. Inner calls live in the per-subagent transcripts.Why the obvious fix is harmful
Resolving the parent at invoke time, by keeping the registry edge alive past
drainForegroundSpawnsor priming it from elsewhere, is the smaller change and is worse. It setsparentToolCallIdin cases where the host has no subagent chat, and the signal is then buffered in_pendingSubagentSignalsawaiting asubagent_startedthat cannot arrive, since that is emitted only from a live inner stream. The parked MCP handler never settles.The present coupling is safe by accident: the edge exists only when the same code path already emitted
subagent_started. Any parent resolution from a non-stream source breaks that invariant, which is why #330746 fixes the in-process case with a host-side routing fallback instead.What closing the restart case would take
Three coordinated changes, plus one structural wrinkle:
subagents/agent-<id>.meta.json->toolUseId, reachable throughlistSubagentsandgetSubagentMessages, whose envelopes carryparent_tool_use_id.Task._registerRestoredSubagentSummariesdiscovers subagents fromToolResultSubagentContentgated onCompleted || Running. The replay case is exactly aTaskthat never completed, which replays as cancelled with no content, so it is skipped._subagentChatsis written only from a livesubagent_started; no restore path writes it, so routing has nowhere to go even once the chat exists.The wrinkle: (1) is an async SDK read on a path that is synchronous today.
Shipping only part of this is worse than shipping none. (1) without (2) and (3) is precisely the harmful shape above.
Offer
I am happy to implement this if it is wanted, and equally happy for it to be closed or reshaped. I have no attachment to the approach above; it is the shape the code suggested to me, not a proposal I am invested in. The reason I am offering rather than just filing is that I want the in-process fix locally today regardless, so the analysis already exists and offering it publicly costs nothing.
If the answer is that client tool execution should be restructured around the SDK invocation instead, as discussed in #330899, this becomes moot and I would rather not build it.
Environment
Reproduced against a dev container (remote agent host) on
1.133.x, using the Claude provider.Related: #330746 (in-process fix), #330899 (root cause of the wider client-tool execution seam).
AI disclosure: this issue was written with the assistance of AI.