Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/422-claude-spawn-confirmation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@agent-bundle/runtime": patch
"agent-bundle": patch
---

Take Claude Code's own word for a subagent's parent: the parent's `Agent` `PostToolUse` carries the spawn `tool_use_id` and `tool_response.agentId`, the child's `agent_id`, so the lineage registry behind `request.lineage` now confirms the edge it matched from spawn-call ordering, fills in `subagent.toolCallId` for siblings it had claimed blind, places a `SubagentStart` no spawn window could (none open, or two parents with one — the start's id, type, time, stop, and any confirmations it issued for its own children are kept meanwhile, so a missed spawn hook at one level does not lose the subtree beneath it), moves a child it had filed under the wrong parent and re-bases that child's descendants, and holds a child the host names before its start arrives. Add `'confirmed'` to `AgentLineageResolution` (`@agent-bundle/runtime`, `RequestLineageProvenance` in `agent-bundle`): a subagent's `parent`/`root`/`depth` resolve `confirmed` once every edge up to the root is host-named — right after `SubagentStart` for a background spawn, after `SubagentStop` for a foreground one — and stay `registry` otherwise. The Claude capability table's `lineage.parent`/`lineage.depth` rows record the confirmation and its timing, and the generated hosts page gains a "Conversation lineage" section rendered from every host's `lineage` rows. (#422)
4 changes: 2 additions & 2 deletions docs/audits/2026-09-03-host-lineage-matrix.md
Original file line number Diff line number Diff line change
Expand Up @@ -321,7 +321,7 @@ gives shell commands the agent runs `CURSOR_CONVERSATION_ID`/`CURSOR_REQUEST_ID`

| Host | Root | Parent |
| --- | --- | --- |
| Claude | Yes — `session_id` on every event is the root session (re-verified live: all 42 + 46 + 127 hook payloads across the three runs carry the root id — through resumed turns and a compaction too — and `CLAUDE_CODE_SESSION_ID` in every plugin process is the root id) | Only through the runtime's registry (inferred at `SubagentStart`); nothing in the child's payload |
| Claude | Yes — `session_id` on every event is the root session (re-verified live: all 42 + 46 + 127 hook payloads across the three runs carry the root id — through resumed turns and a compaction too — and `CLAUDE_CODE_SESSION_ID` in every plugin process is the root id) | Only through the runtime's registry (placed at `SubagentStart`, confirmed by the parent's `Agent` PostToolUse); nothing in the child's payload |
| Codex | Yes — `session_id` is the root thread on every event, and `_meta.x-codex-turn-metadata.session_id` on MCP calls | Yes on MCP calls (`parent_thread_id`); on hooks only through the registry (or the parent rollout at `SubagentStop`) |
| Cursor | Only through the registry — a child's payload carries neither root nor parent | Only through the registry (ordering-bound) |

Expand Down Expand Up @@ -354,7 +354,7 @@ root, and the parent-of-subagent chain — is the only identity-adjacent surface
| Roots first seen on a tool hook (Cursor restart or plugin load mid-conversation) | Cursor | §9 | Ours: workspace-scoped child binding plus correction (subtree re-rooted) when a bound conversation later carries a root-only event (`beforeSubmitPrompt`, `stop`, `sessionEnd`, `preCompact`) |
| Cursor CLI not exercised | Cursor | table above | Needs a signed-in `cursor-agent`; not attempted on the operator's account |
| ~~Claude session used a scripted model~~ | Claude | §8 | Closed 2026-09-03: two live-model sessions replace the stand-in fixture; every stand-in claim held, see §8 |
| Claude `PostToolUse(Agent).tool_response.agentId` not consumed by the registry | Claude | §1, §2 | The registry claims the newest unclaimed spawn under the root at `SubagentStart` and marks same-parent sibling cohorts `siblingsUncertain`; the parent's `Agent` PostToolUse could later firm those up. Not needed for any live run: even the orchestration run's two parallel spawns arrived serialised (`SubagentStart` before the next `Agent` `PreToolUse`), so every claim was certain; left as an improvement |
| ~~Claude `PostToolUse(Agent).tool_response.agentId` not consumed by the registry~~ | Claude | §1, §2 | Closed 2026-09-03 (#422 follow-up PR): the registry now treats the parent's `Agent` PostToolUse as the host's word on the edge — it confirms the spawn-window match (`resolution: confirmed` once every edge to the root is host-named), fills in sibling `toolCallId`s claimed blind, places a `SubagentStart` no window could (an unplaced start keeps id/type/time/stop, and any confirmations it issued for its own children, until its edge is known), moves a child filed under the wrong parent and re-bases its descendants, and holds a child named before its start (orchestration row 13 → 14). Replays of the orchestration capture with row 64 or row 81 withheld recover the sequential agent and the depth-2 child from rows 99/101 alone |
| ~~Claude interactive OAuth session expired; refresh fails~~ | Claude | header table | Closed 2026-09-03: after the operator signed in again, the unmodified harness (`probe:install claude` byte-for-byte copy of `~/.claude/.credentials.json`) produced the signed-in 2.1.259 orchestration session. The two 2.1.257 runs had used a one-off re-seed of the isolated `.credentials.json` outside the harness (`docs/audits/2026-09-03-claude-live-session-proofs.md`); no credential-handling code was ever added |
| `stream-json` shows depth-1 subagent traffic only | Claude | §1 (orchestration run) | The nested agent's 7 tool calls appear in hooks (with its `agent_id`) but in no stream envelope; `task_started` still announces it with `spawn_depth: 2`. Hooks remain the only complete lineage source; recorded, no framework impact |
| Auto-compaction (`PreCompact trigger: "auto"`) not induced | Claude | §1 (orchestration run) | Manual `/compact` as a resumed `-p` prompt fires `PreCompact` → `SessionStart source: compact` → `PostCompact`; the automatic path would need a session near the context limit and was not attempted |
Expand Down
57 changes: 54 additions & 3 deletions docs/entry-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -409,10 +409,21 @@ interface AgentLineage {
depth: number; // 0 at the root, +1 per subagent level
generation?: string; // Cursor generation_id, Codex turn_id, Claude prompt_id
subagent?: { id: string; type?: string; toolCallId?: string; isParallelWorker?: boolean };
resolution: 'native' | 'registry' | 'inferred';
resolution: 'native' | 'registry' | 'confirmed' | 'inferred';
}
```

`resolution` is the trust level of `parent`/`root`/`depth`: `native` — the
host named them on this payload (a Claude/Codex root, a Codex tool call's
`_meta`); `registry` — the warm runtime's registry placed the conversation
when its subagent started, matching the start to the newest unclaimed spawn
call; `confirmed` — that registry edge, and every edge above it up to the
root, was afterwards named by the host itself (Claude's `Agent` PostToolUse
carries the spawn `tool_use_id`, the caller's identity and
`tool_response.agentId`, the child); `inferred` — ordering inference the host
forced (Cursor binds a child conversation to the single pending
`subagentStart`).

Hooks are thin clients to the warm runtime, so lineage is runtime-held state:
the generated MCP process owns an **agent lineage registry**
(`@agent-bundle/runtime/lineage`), journaled through the state kernel beside
Expand All @@ -426,14 +437,54 @@ for every event by the id the payload carries. The observed host vocabulary

| Host | `conversation` | `root` | Parent of a new subagent | MCP call correlation |
| --- | --- | --- | --- | --- |
| Claude | `agent_id`, else `session_id` | `session_id` | the agent whose `Agent`/`Task` `PreToolUse` is the newest unclaimed spawn | `_meta["claudecode/toolUseId"]` = the open `PreToolUse` `tool_use_id` |
| Claude | `agent_id`, else `session_id` | `session_id` | the agent whose `Agent`/`Task` `PreToolUse` is the newest unclaimed spawn; confirmed by that agent's `Agent` `PostToolUse` (`tool_response.agentId` = the child) | `_meta["claudecode/toolUseId"]` = the open `PreToolUse` `tool_use_id` |
| Codex | `agent_id`, else `session_id` | `session_id` | the thread whose `spawn_agent` call is the newest unclaimed spawn | `_meta["x-codex-turn-metadata"]` carries `thread_id`, `parent_thread_id`, `session_id`, `turn_id` natively |
| Cursor | `conversation_id` | the bound root | `parent_conversation_id` on `subagentStart`; the child's fresh `conversation_id` is bound to the single pending start in the same workspace when it first speaks | the newest open `preToolUse` whose `tool_name` is `MCP:<tool>` |

A Claude or Codex subagent is placed only when its spawning pre-tool hook
(`Agent`/`Task`, `collaborationspawn_agent`) was observed, so projects that
want `parent`/`depth` for subagents route `tool/before` alongside
`agent/start`; a start with no claimable spawn stays `id-not-resolvable`.
`agent/start`; a start with no claimable spawn — none open, or several
parents with one — stays `id-not-resolvable`, and the registry keeps what the
start said (id, type, time, and a stop that follows) as an unplaced start.

Claude Code names no parent on any hook a subagent emits (#422; hooks
reference "Common input fields": `agent_id` and `agent_type` are the only
subagent fields), but the *parent's* `Agent` `PostToolUse` names the edge:
`tool_use_id` is the spawn call, the carrier is the parent, and
`tool_response.agentId` is the child's `agent_id` (observed on Claude Code
2.1.257 and 2.1.259, `status: "async_launched"` for a background spawn,
`status: "completed"` for a foreground one). The registry treats that hook
as the host's word on the edge, whatever it believed before:

- it confirms an edge the spawn window matched (`resolution: 'confirmed'`
once every edge from the conversation to the root is host-named);
- it fills in which sibling came from which spawn call when several from one
parent were claimed blind, so `subagent.toolCallId` appears after the fact;
- it places an unplaced start under the parent, as it started (time, type,
and already stopped when its stop came first), and consumes the spawn call
so no later start can claim it; a confirmation an unplaced start issued
for its own children waits with it and is applied when it is placed, so a
missed spawn hook at one level does not lose the subtree beneath it
(replaying the 2.1.259 capture without the root's spawn `PreToolUse`
recovers both the sequential agent and its depth-2 child from the two
`PostToolUse` payloads alone);
- it moves a child the window filed under the wrong parent (a missed spawn
`PreToolUse` leaves another parent's open call as the only candidate) and
re-bases everything the child spawned meanwhile;
- it holds a child it names before that child's `SubagentStart` arrives
(Claude fires a background spawn's `PostToolUse` first); the start then
adds its `agent_type`.

Timing bounds what this buys a subagent's own events: a background spawn is
confirmed right after `SubagentStart`, so the child's hooks and MCP calls
resolve `confirmed`; a foreground spawn's `PostToolUse` fires only after the
child's `SubagentStop`, so that child's events resolve `registry` for their
whole life and only the tree (`snapshot()`, the Workbench) shows the
confirmation. A spawn whose response carries no `agentId` (the sub-agents
reference says the one-shot built-in Explore and Plan agents return no agent
ID to Claude) keeps the registry's own match. Nothing here derives an actor
or a user (#391); `request.lineage` stays the only identity surface (#444).
Cursor names a child only on the parent's `subagentStart` (`subagent_id` =
the parent's `Task` call id); the child's own hooks carry a fresh
`conversation_id` and nothing that points back, so the registry binds by
Expand Down
3 changes: 2 additions & 1 deletion examples/worktree-proximity/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,8 @@ the runtime's `outside-invocation` error outside a request.

Every identity claim records where it came from. `native` is read from the
host envelope (or a `request.lineage` the runtime resolved natively),
`registry` and `inferred` are the runtime lineage registry's own resolutions,
`registry`, `inferred` and `confirmed` are the runtime lineage registry's own
resolutions (`confirmed` once the host has named every edge up to the root),
and `derived` is this application's fallback:

- `session/start` observes `session:<root>` as the root actor, where the root
Expand Down
9 changes: 5 additions & 4 deletions examples/worktree-proximity/src/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,12 @@ import { z } from 'zod';
const nonEmpty = z.string().trim().min(1);
/**
* Where an identity claim came from: `native` is read straight from the host
* envelope (or a `request.lineage` the runtime resolved natively), `registry`
* and `inferred` are the runtime lineage registry's own resolutions, and
* `derived` is this application's fallback (`worktree:<root>`).
* envelope (or a `request.lineage` the runtime resolved natively), `registry`,
* `inferred` and `confirmed` are the runtime lineage registry's own
* resolutions (`confirmed` once the host has named every edge to the root),
* and `derived` is this application's fallback (`worktree:<root>`).
*/
const provenance = z.enum(['native', 'registry', 'inferred', 'derived']);
const provenance = z.enum(['native', 'registry', 'inferred', 'confirmed', 'derived']);

export type IdentityProvenance = z.output<typeof provenance>;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -174,11 +174,16 @@
},
"parent": {
"state": "degraded",
"reason": "live model 2026-09-03, Claude Code 2.1.257 and 2.1.259: no hook payload names a subagent's parent (host gap tracked in #422: SubagentStart carries only the child agent_id, and a subagent's own hooks carry session_id + agent_id); the runtime registry infers it from the Agent/Task PreToolUse that is the newest unclaimed spawn when SubagentStart fires (resolution: registry) — verified at depth 1 and 2 in background, foreground and parallel spawn paths, exact for sequential spawns and for the 2.1.259 parallel pair (the host serialises SubagentStart before the next Agent PreToolUse), refused as id-not-resolvable when two parents have unclaimed spawns, and toolCallId is withheld when siblings from one parent were claimed blind; the parent's Agent PostToolUse tool_response.agentId confirms the inference after the fact, and the host's stream-json task_started envelope (task_id, tool_use_id) agrees, but that stream is not delivered to plugins."
"reason": "live model 2026-09-03, Claude Code 2.1.257 and 2.1.259: no hook payload a subagent itself emits names its parent (#422: SubagentStart carries only the child agent_id and agent_type, and a subagent's own hooks carry session_id + agent_id; the hooks reference's common input fields list session_id, transcript_path, cwd, permission_mode, agent_id and agent_type, no parent). The runtime registry places the child under the Agent/Task PreToolUse that is the newest unclaimed spawn when SubagentStart fires (resolution: registry) — exact for sequential spawns and for the 2.1.259 parallel pair (the host serialises SubagentStart before the next Agent PreToolUse), verified at depth 1 and 2 in background, foreground and parallel spawn paths, refused as id-not-resolvable when two parents have unclaimed spawns, toolCallId withheld when siblings from one parent were claimed blind — and then takes the host's own word for the edge: the parent's Agent PostToolUse carries the spawn tool_use_id, the parent's identity and tool_response.agentId, the child. That hook confirms the edge (resolution: confirmed once every edge up to the root is host-named), fills in the sibling→tool_use_id assignment, places a start the window could not, and moves a child the window filed under the wrong parent, re-basing its descendants. It arrives right after SubagentStart for a background spawn (status async_launched; in 2.1.259 even before it) but only after the child's SubagentStop for a foreground one (status completed), so a foreground child's own events resolve as registry for their whole life. The host's stream-json task_started envelope (task_id, tool_use_id, spawn_depth) agrees with every placement but is not delivered to plugins.",
"evidence": [
"live model 2026-09-03, Claude Code 2.1.257 (fixtures/host-lineage/claude-2.1.257.ndjson rows 17 and 41; claude-2.1.257-foreground.ndjson rows 47 and 49): the parent's Agent PostToolUse tool_response is {isAsync: true, status: \"async_launched\", agentId} for a background spawn and {status: \"completed\", agentId, agentType, content[]} for a foreground one; agentId equals the agent_id on the child's SubagentStart, SubagentStop and every hook the child fired.",
"live model 2026-09-03, Claude Code 2.1.259 (fixtures/host-lineage/claude-2.1.259-orchestration.ndjson): all four Agent PostToolUse payloads name their child — rows 13 and 17 (background Explore and general-purpose, async_launched; row 13 fires before that child's SubagentStart at row 14, so the registry holds the node by the host's word and the start adds its agent_type), row 99 (the depth-1 agent's own PostToolUse, carrying its agent_id, names the depth-2 child, completed) and row 101 (the root's, naming the depth-1 agent, completed); the depth-2 child's SubagentStart (row 82) and every hook it fires carry only its own agent_id and the root session_id, nothing that names the depth-1 parent. Replays of that capture with the root's spawn PreToolUse (row 64) or the depth-1 agent's (row 81) withheld leave the affected starts unplaced until rows 99/101 place them — the sequential agent under the root and the depth-2 child under it, with the start and stop times the hooks carried (packages/rsc-runtime/tests/lineage-registry.test.ts). The child's SubagentStop agent_transcript_path is <session>/subagents/agent-<agent_id>.jsonl for depth-1 and depth-2 children alike (rows 57, 61, 98, 100), and Stop/SubagentStop background_tasks[] entries carry id, type, status, description and agent_type but no tool_use_id or parent, so neither path nor list can bind a nested child to its parent.",
"uploaded 2026-09-03, Claude Code docs hooks reference (hooks-2.md, \"Common input fields\": agent_id and agent_type are the only subagent fields hooks add, no parent; \"SubagentStart\"/\"SubagentStop\": agent_id, agent_type, agent_transcript_path under the parent's transcript folder, background_tasks scoped to the parent session; \"PostToolUse\": tool_name, tool_input, tool_response, tool_use_id) and sub-agents reference (sub-agents-3.md, \"Resume subagents\": when a subagent completes Claude receives its agent ID — the id the registry matches to agent_id — and the one-shot built-in Explore and Plan agents return no agent ID to Claude, so a spawn whose tool_response omits agentId keeps the registry's own match; observed 2.1.259 background Explore responses did carry agentId)."
]
},
"depth": {
"state": "degraded",
"reason": "live model 2026-09-03, Claude Code 2.1.257 and 2.1.259: no depth counter is delivered to hooks (#422; the result document's subagent_stats.max_depth and the stream-json task_started.spawn_depth are not hook fields); the registry derives it from the inferred parent chain, so it is exactly as certain as parent — it matched spawn_depth 1 and 2 in the 2.1.259 orchestration run."
"reason": "live model 2026-09-03, Claude Code 2.1.257 and 2.1.259: no depth counter is delivered to hooks (#422; the result document's subagent_stats.max_depth and the stream-json task_started.spawn_depth are not hook fields); the registry derives it from the parent chain — the placed parent's depth plus one, re-based when a host confirmation moves the parent — so it is exactly as certain as parent, resolves as confirmed only when every edge up to the root is, and matched spawn_depth 1 and 2 in the 2.1.259 orchestration run."
},
"mcp-correlation": {
"state": "supported",
Expand Down
2 changes: 1 addition & 1 deletion packages/agent-bundle/src/contracts/request-provenance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export interface RequestLineageProvenance {
readonly depth: number;
readonly generation?: string;
readonly parent?: string;
readonly resolution: 'native' | 'registry' | 'inferred';
readonly resolution: 'native' | 'registry' | 'confirmed' | 'inferred';
readonly root: string;
readonly subagent?: Readonly<{
readonly id: string;
Expand Down
Loading
Loading