diff --git a/.changeset/391-lineage-only-identity.md b/.changeset/391-lineage-only-identity.md new file mode 100644 index 000000000..c9b5666e0 --- /dev/null +++ b/.changeset/391-lineage-only-identity.md @@ -0,0 +1,6 @@ +--- +"@agent-bundle/runtime": patch +"agent-bundle": patch +--- + +Make `request.lineage` the only identity-adjacent surface — parent conversation, root, and the parent-of-subagent chain — and stop reading operator identity anywhere: the Cursor `workspaceOpen` event-route validator no longer inspects `user_email` (the field passes through inside `native` untouched), and `actor` is documented as the HTTP-authenticated MCP client only. Bind a never-seen Cursor conversation to a pending `subagentStart` only when exactly one is pending in the same `workspace_roots`, and undo a blind binding (re-rooting anything started beneath it) when that conversation later carries a root-only event such as `beforeSubmitPrompt`, so a chat tab whose prompt predates the registry resolves as a root instead of another conversation's subagent. (#444) diff --git a/docs/audits/2026-09-02-agent-plugins-cursor-ide-proof.md b/docs/audits/2026-09-02-agent-plugins-cursor-ide-proof.md index 9bfef918e..e247ab90f 100644 --- a/docs/audits/2026-09-02-agent-plugins-cursor-ide-proof.md +++ b/docs/audits/2026-09-02-agent-plugins-cursor-ide-proof.md @@ -88,6 +88,6 @@ are not provided (§9.1), and a plugin-relative `./` `command` is resolved against the workspace folder instead of the plugin root (§7.2.1). Full table, log excerpts, and 1440×900 captures: [`2026-09-03-agent-plugins-cursor-ide-proof.md`](./2026-09-03-agent-plugins-cursor-ide-proof.md); -ready-to-submit vendor report: -[`2026-09-03-cursor-agent-plugins-feedback.md`](./2026-09-03-cursor-agent-plugins-feedback.md). +internal observations record (not submitted to Cursor, by maintainer decision): +[`2026-09-03-cursor-agent-plugins-observations.md`](./2026-09-03-cursor-agent-plugins-observations.md). No capability row moved to `supported`. diff --git a/docs/audits/2026-09-03-agent-plugins-cursor-ide-proof.md b/docs/audits/2026-09-03-agent-plugins-cursor-ide-proof.md index 60eb216c7..e5d5f6cc4 100644 --- a/docs/audits/2026-09-03-agent-plugins-cursor-ide-proof.md +++ b/docs/audits/2026-09-03-agent-plugins-cursor-ide-proof.md @@ -146,8 +146,9 @@ correct; only the §7.2.1/§9 path contract is missing. `mcp.evidence`) gain a dated 2026-09-03 line for the re-verification and the three additional observations; no row moves to `supported` because nothing was fixed. -- The vendor feedback that #426 tracks is written up ready to submit in - [`2026-09-03-cursor-agent-plugins-feedback.md`](./2026-09-03-cursor-agent-plugins-feedback.md). +- The observations #426 tracks are recorded as internal reference in + [`2026-09-03-cursor-agent-plugins-observations.md`](./2026-09-03-cursor-agent-plugins-observations.md); + the maintainer chose not to submit them to Cursor. - The "re-run when a Cursor release notes placeholder expansion" item stays time-gated on a Cursor release; the harness above (`/tmp/w426/iso-setup.sh` shape: five probes + the emitted pack, reload, read the three log surfaces) diff --git a/docs/audits/2026-09-03-cursor-agent-plugins-feedback.md b/docs/audits/2026-09-03-cursor-agent-plugins-observations.md similarity index 89% rename from docs/audits/2026-09-03-cursor-agent-plugins-feedback.md rename to docs/audits/2026-09-03-cursor-agent-plugins-observations.md index b0a0976a7..26bc3d0c2 100644 --- a/docs/audits/2026-09-03-cursor-agent-plugins-feedback.md +++ b/docs/audits/2026-09-03-cursor-agent-plugins-observations.md @@ -1,33 +1,25 @@ -# Cursor product feedback — Agent Plugins 1.0.0 `${PLUGIN_ROOT}` expansion (ready to submit) - -Prepared 2026-09-03 for #426. Everything below was observed on the current -installed stable build and is reproducible with the plugin in "Minimal -repro" (one directory, four files — three for items 1–5 plus `mcp/launch.sh` -for item 6 — no dependencies beyond `node`). The maintainer submits -it; this repository cannot post on their behalf. - -**Where to submit (pick one, template-compatible):** - -- Bug report (preferred — every item below is a MUST violation with a - deterministic repro): → - "New Topic" (direct link - ). The - forum's bug template asks for product area, description, steps, expected - behaviour, screenshots, and the About-dialog version block; all of it is - filled in below. Reporting guidance: . -- Feature request, if triaged as "Agent Plugins §9 support" rather than a - defect: . -- Email fallback named by the reporting guide: `hi@cursor.com`. - -Attach the two screenshots from +# Cursor observations — Agent Plugins 1.0.0 `${PLUGIN_ROOT}` expansion + +Recorded 2026-09-03 for #426 as internal reference. Everything below was +observed on the current installed stable build and is reproducible with the +plugin in "Minimal repro" (one directory, four files — three for items 1–5 +plus `mcp/launch.sh` for item 6 — no dependencies beyond `node`). + +Maintainer decision (2026-09-03): this is **not** submitted to Cursor. The +record exists so the `portable` capability table and any future re-check of a +newer Cursor build have a precise baseline to compare against; the `portable` +rows stay `degraded` until a build is observed that expands the placeholders. + +Screenshots of the two states (spec shape failing, proprietary placeholder +connected) are in `docs/assets/agent-plugins-cursor-proof/2026-09-03-plugin-detail-spec-shape-error.png` and `…/2026-09-03-plugin-detail-cursor-placeholder-connected.png`. --- -## Bug report body +## Observed behaviour -**Where does the bug appear:** Cursor IDE → MCP & tools (plugin loader for +**Where it appears:** Cursor IDE → MCP & tools (plugin loader for Agent Plugins packages placed in `~/.cursor/plugins/local`). **Version block (Menu → About Cursor):** Cursor 3.18.25, Linux x64 (deb, @@ -163,7 +155,8 @@ starts, the marker shows `argv[1]` and `PROBE_ROOT` expanded to mapping of `${PLUGIN_ROOT}`/`${PLUGIN_DATA}` (and the §9.1 variables, the default `cwd`, and `./` command resolution) onto the pipeline that already serves `${CURSOR_PLUGIN_ROOT}` when the package is an Agent Plugins package -(`$schema` under `https://agent-plugins.org/schemas/`). +(`$schema` under `https://agent-plugins.org/schemas/`) — this is what a +future build would need to do for the `portable` rows to move. ### Why it matters diff --git a/docs/audits/2026-09-03-cursor-plugin-hooks-registration.md b/docs/audits/2026-09-03-cursor-plugin-hooks-registration.md index d404e80a6..aa67e0a10 100644 --- a/docs/audits/2026-09-03-cursor-plugin-hooks-registration.md +++ b/docs/audits/2026-09-03-cursor-plugin-hooks-registration.md @@ -220,17 +220,19 @@ Decision implemented in this change: and runs the emitted `install.mjs --mode marketplace` twice (`Staged`, then `Already staged` with the same commit) and Doctor's `AB7324` guidance. -## 7. Product feedback recorded for Cursor +## 7. Documentation discrepancies recorded on #407 (internal; not submitted to Cursor) -Text filed as the #407 discrepancy note (URL: https://cursor.com/docs/hooks): -"The hooks documentation lists working directories for project, user, +Recorded as the #407 discrepancy note against https://cursor.com/docs/hooks; +by maintainer decision (2026-09-03) no feedback is submitted to Cursor, so +this stays an internal observation: +the hooks documentation lists working directories for project, user, enterprise, and team hooks but not for plugin-delivered hooks; observed on 3.18.25 they run from the plugin root with `${CURSOR_PLUGIN_ROOT}` expanded. On 3.16.21 (remote server build) plugin-declared `preToolUse`/`postToolUse`/ `stop` hooks did not execute while the same commands in `~/.cursor/hooks.json` did; the CLI changelog of August 11, 2026 records a fix for plugin hooks in -the CLI. Please document the plugin hook working directory and the minimum -build that delivers plugin hooks for tool events, and expose a +the CLI. Undocumented as of this build: the plugin hook working directory, the +minimum build that delivers plugin hooks for tool events, and any non-interactive `cursor-agent plugin marketplace add` for local repositories -(`file://`) so packs can be marketplace-installed without the Customize -folder picker." +(`file://`), so packs cannot be marketplace-installed without the Customize +folder picker. diff --git a/docs/audits/2026-09-03-host-lineage-matrix.md b/docs/audits/2026-09-03-host-lineage-matrix.md index 89859096e..355ab6632 100644 --- a/docs/audits/2026-09-03-host-lineage-matrix.md +++ b/docs/audits/2026-09-03-host-lineage-matrix.md @@ -112,28 +112,30 @@ from the rollout filename in `transcript_path`): ### Cursor 3.18.25 (desktop) -| Event (native) | `conversation_id`* | `session_id`* | `generation_id` | `subagent_id` / `tool_call_id` | `parent_conversation_id` | `is_parallel_worker` | `user_email` | Other | -| --- | --- | --- | --- | --- | --- | --- | --- | --- | -| beforeSubmitPrompt | root | = conversation_id | per generation | — | — | — | yes | `prompt`, `attachments`, `model`, `model_id`, `composer_mode`, `cursor_version`, `workspace_roots`, `transcript_path: null` | -| preToolUse / postToolUse (root) | root | = conversation_id | per generation | `tool_use_id` (`call--\nfc__` for model tools, plain uuid for `Shell` and `MCP:*`) | — | — | yes | `tool_name` (`Read`, `Grep`, `Shell`, `Write`, `Task`, `MCP:dump`, `MCP:probe`), `tool_input`, `tool_output` (JSON string, Post), `duration` (Post), `cwd: ""` on `Shell`, `model: ""` on `Task` | -| preToolUse / postToolUse (inside a subagent) | **a new conversation id** (`bf617dfd-…`) | = that new id | new | tool_use_id | **absent** | **absent** | yes | **nothing in the payload names the parent** | -| preToolUse / postToolUse (inside the nested agent) | another new id (`46efda32-…`) | = id | new | | absent | absent | yes | | -| subagentStart | **the parent's** conversation id | = parent | **equals the conversation id** (not a generation) | `subagent_id` = `tool_call_id` = the parent's `Task` `tool_use_id` (a two-line composite) | = conversation_id | `false` | yes | `subagent_type`, `subagent_model`, `task` (full prompt), parent `transcript_path`; **the child's conversation id is not included** | -| subagentStop | parent's | = parent | = conversation id | `subagent_id` | = conversation_id | — | yes | `status`, `duration_ms`, `message_count`, `tool_call_count`, `loop_count`, `task`, `description`, `agent_transcript_path: null` | -| stop | root | = root | root generation | — | — | — | yes | `status`, `loop_count`, `input_tokens`, `output_tokens`, `cache_read_tokens`, `cache_write_tokens`, root `transcript_path` | +| Event (native) | `conversation_id`* | `session_id`* | `generation_id` | `subagent_id` / `tool_call_id` | `parent_conversation_id` | `is_parallel_worker` | Other | +| --- | --- | --- | --- | --- | --- | --- | --- | +| beforeSubmitPrompt | root | = conversation_id | per generation | — | — | — | `prompt`, `attachments`, `model`, `model_id`, `composer_mode`, `cursor_version`, `workspace_roots`, `transcript_path: null` | +| preToolUse / postToolUse (root) | root | = conversation_id | per generation | `tool_use_id` (`call--\nfc__` for model tools, plain uuid for `Shell` and `MCP:*`) | — | — | `tool_name` (`Read`, `Grep`, `Shell`, `Write`, `Task`, `MCP:dump`, `MCP:probe`), `tool_input`, `tool_output` (JSON string, Post), `duration` (Post), `cwd: ""` on `Shell`, `model: ""` on `Task` | +| preToolUse / postToolUse (inside a subagent) | **a new conversation id** (`bf617dfd-…`) | = that new id | new | tool_use_id | **absent** | **absent** | **nothing in the payload names the parent** | +| preToolUse / postToolUse (inside the nested agent) | another new id (`46efda32-…`) | = id | new | | absent | absent | | +| subagentStart | **the parent's** conversation id | = parent | **equals the conversation id** (not a generation) | `subagent_id` = `tool_call_id` = the parent's `Task` `tool_use_id` (a two-line composite) | = conversation_id | `false` | `subagent_type`, `subagent_model`, `task` (full prompt), parent `transcript_path`; **the child's conversation id is not included** | +| subagentStop | parent's | = parent | = conversation id | `subagent_id` | = conversation_id | — | `status`, `duration_ms`, `message_count`, `tool_call_count`, `loop_count`, `task`, `description`, `agent_transcript_path: null` | +| stop | root | = root | root generation | — | — | — | `status`, `loop_count`, `input_tokens`, `output_tokens`, `cache_read_tokens`, `cache_write_tokens`, root `transcript_path` | Not delivered to the plugin in this run: `sessionStart` (never fired for the plugin; the sibling raw-hooks probe on the same build saw none either), `workspaceOpen` (fired to user-level hooks only), `sessionEnd` (nothing arrived when the window was closed with the agent idle), `postToolUseFailure`, -`preCompact`. `preToolUse` was delivered twice for some `Read`/`Grep` calls -with the same `tool_use_id` (pairs 3/4, 9/10, … in the fixture). +`preCompact`. §9 below shows that on a real desktop `workspaceOpen` and +`sessionEnd` *do* reach plugin-scoped hooks and only `sessionStart` never +does. `preToolUse` was delivered twice for some `Read`/`Grep` calls with the +same `tool_use_id` (pairs 3/4, 9/10, … in the fixture). Payload excerpt (subagentStart — the only place the parent link exists, and it does not name the child conversation): ```json -{"conversation_id":"b60ae0c1-2f85-4c4d-b3e5-b512f9b06e4c","generation_id":"b60ae0c1-2f85-4c4d-b3e5-b512f9b06e4c","model":"default","subagent_id":"call-2ec9530d-b502-4c4f-8a6e-63f0bf7ebc9a-29\nfc_49466487-df47-9fb4-8b10-079ee845fb97_0","subagent_type":"general-purpose","task":"…","parent_conversation_id":"b60ae0c1-2f85-4c4d-b3e5-b512f9b06e4c","tool_call_id":"call-2ec9530d-…\nfc_49466487-…_0","subagent_model":"default","is_parallel_worker":false,"session_id":"b60ae0c1-2f85-4c4d-b3e5-b512f9b06e4c","hook_event_name":"subagentStart","cursor_version":"3.18.25","workspace_roots":["/tmp/host-test/cursor-workspace"],"user_email":"probe@example.invalid","transcript_path":"…/agent-transcripts/b60ae0c1-…/b60ae0c1-….jsonl"} +{"conversation_id":"b60ae0c1-2f85-4c4d-b3e5-b512f9b06e4c","generation_id":"b60ae0c1-2f85-4c4d-b3e5-b512f9b06e4c","model":"default","subagent_id":"call-2ec9530d-b502-4c4f-8a6e-63f0bf7ebc9a-29\nfc_49466487-df47-9fb4-8b10-079ee845fb97_0","subagent_type":"general-purpose","task":"…","parent_conversation_id":"b60ae0c1-2f85-4c4d-b3e5-b512f9b06e4c","tool_call_id":"call-2ec9530d-…\nfc_49466487-…_0","subagent_model":"default","is_parallel_worker":false,"session_id":"b60ae0c1-2f85-4c4d-b3e5-b512f9b06e4c","hook_event_name":"subagentStart","cursor_version":"3.18.25","workspace_roots":["/tmp/host-test/cursor-workspace"],"user_email":"…","transcript_path":"…/agent-transcripts/b60ae0c1-…/b60ae0c1-….jsonl"} ``` ## 2. Which id a subagent's own events carry @@ -208,9 +210,11 @@ gives shell commands the agent runs `CURSOR_CONVERSATION_ID`/`CURSOR_REQUEST_ID` | 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) | -**Actor principal facts** (for #391): Cursor delivers `user_email` on every hook -payload and `CURSOR_USER_EMAIL` in the hook environment; Claude and Codex -deliver no user identity to hooks or MCP servers. +**Operator identity is out of scope.** The maintainer decided on 2026-09-03 +(#391, closed as not planned) that agent-bundle will not derive or surface who +the human behind a host session is; the fields some hosts send for that are +passed through inside `native` unread, and `request.lineage` — parent session, +root, and the parent-of-subagent chain — is the only identity-adjacent surface. ## 6. Framework consequences landed with this audit @@ -228,10 +232,11 @@ deliver no user identity to hooks or MCP servers. | Gap | Host | Evidence | Status | | --- | --- | --- | --- | -| No parent id on `SubagentStart`; child events carry no parent | Claude, Codex | §1, §2 | Inferred from the open spawn tool call; filed as a host request | -| Child conversation id absent from `subagentStart`; child events carry no parent/root | Cursor | §1, §2 | Ordering-bound in the registry; ambiguous for parallel workers; filed | +| No parent id on `SubagentStart`; child events carry no parent | Claude, Codex | §1, §2 | Inferred from the newest unclaimed spawn call under the same root; refused when two parents have unclaimed spawns; filed as #422 / #423 | +| Child conversation id absent from `subagentStart`; child events carry no parent/root | Cursor | §1, §2 | Bound by elimination in the registry (single pending start per workspace); refused while ambiguous for parallel workers; filed as #424 | | `_meta` carries no conversation/tool-call id | Cursor | §3 | Hook-correlated only; filed | -| `sessionStart`, `workspaceOpen`, `sessionEnd` not delivered to plugin hooks | Cursor | §1 | Recorded; owned by the Cursor installer/emitter lane for follow-up | +| `sessionStart` never dispatched on the desktop (`workspaceOpen`/`sessionEnd` are) | Cursor | §1, §9 | Host-side (#424 gap 4); lineage never depends on it to establish a root | +| 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 either live run (spawns were sequential); left as an improvement | @@ -263,3 +268,48 @@ No lineage or projection code change was needed: the registry replay test (`packages/rsc-runtime/tests/lineage-registry.test.ts`) now runs against both live fixtures and passes unchanged apart from asserting the new `tool_response.agentId` host fact. + +## 9. Cursor desktop hooks-service evidence (added 2026-09-03) + +Cursor desktop keeps a per-window hooks log +(`~/.config/Cursor/logs///output_*/cursor.hooks.workspaceId-*.log`) +that prints `Hook step requested: ` for **every** step before it looks +up declared hooks — 58,717 `preToolUse` steps appear with no hook declared for +them — so an absent step is non-dispatch, not a registration problem. The +retained logs on the maintainer's machine (cursor_version 3.14.7 for +2026-08-14 → 2026-08-25, 3.18.25 on 2026-09-03; 89,219 steps; 35 +conversations; a local plugin declaring `sessionStart`, `sessionEnd`, +`workspaceOpen`, `stop`, `postToolUse`, `preCompact`, `afterFileEdit`, +`afterShellExecution`) show: + +| Step | Requested | Delivered to the plugin-scoped hook (`from claude-plugin config`) | +| --- | --- | --- | +| `preToolUse` | 58,717 | n/a (not declared) | +| `postToolUse` | 29,308 | yes | +| `afterShellExecution` | 887 | yes | +| `afterFileEdit` | 270 | yes | +| `workspaceOpen` | 12 | yes — sessionless envelope | +| `stop` | 8 | yes | +| `beforeSubmitPrompt` | 7 | yes — `prompt`, `attachments[]`, `composer_mode`, `generation_id` | +| `sessionEnd` | 6 | yes — `reason: window_close`, `final_status: none`, `duration_ms`, `is_background_agent: false`, `session_id` = `conversation_id`, `generation_id: ""`, `transcript_path: null` | +| `preCompact` | 4 | yes | +| **`sessionStart`** | **0** | — | + +Consequences for lineage: + +- `workspaceOpen` and `sessionEnd` are confirmed plugin-scoped deliveries on + the desktop; the §1 run missed them because the Xvfb CLI session never + closed a window. Only `sessionStart` remains a host gap (#424 gap 4). +- Only 7 of the 35 conversations ever produced `beforeSubmitPrompt`; 28 were + first seen on `postToolUse`/`preToolUse`/`afterShellExecution` (the log + starts mid-conversation after a Cursor restart, or the plugin loaded + mid-conversation). `transcript_path` is `null` on 98.5 % of desktop events, + so it cannot mark roots either. A registry that binds the next unseen + conversation to a lone pending `subagentStart` can therefore mis-bind a + second chat tab; the registry now scopes binding to the same + `workspace_roots` and undoes a binding when the bound conversation later + receives `beforeSubmitPrompt`, which a subagent never does. +- No `Task` tool call appears in these desktop logs, so desktop + `subagentStart`/`subagentStop` delivery is unobserved here; the §1 CLI + capture remains the evidence for the subagent families. + diff --git a/docs/audits/2026-09-03-host-lineage-observations.md b/docs/audits/2026-09-03-host-lineage-observations.md new file mode 100644 index 000000000..9d3c4f532 --- /dev/null +++ b/docs/audits/2026-09-03-host-lineage-observations.md @@ -0,0 +1,124 @@ +# Host lineage observations (2026-09-03) + +Internal reference, scoped to conversation lineage only: which conversation a +hook belongs to, who its parent is, what its root is, and — for subagents — +the parent-of-subagent chain. Nothing here concerns operator or user identity; +agent-bundle does not surface it (#391, closed as not planned by the +maintainer on 2026-09-03). + +Maintainer decision (2026-09-03): these observations are **not** submitted to +Cursor, Anthropic, or OpenAI. They are kept so the capability tables, the +lineage registry's inference rules, and any re-check against a newer host +build have a precise baseline. Each host section ends with what a future host +build would have to carry for the corresponding `lineage` rows to move from +`degraded` to `supported`; that is a description of the gap, not a request. + +Evidence for every claim is in-repo: `docs/audits/2026-09-03-host-lineage-matrix.md` +(§1–§3 live captures, §9 desktop hooks-service logs) and +`fixtures/host-lineage/{claude-2.1.257,codex-0.147.0,cursor-3.18.25}.ndjson`. +Trackers: #422 (Claude), #423 (Codex), #424 (Cursor). + +## Cursor (hooks, desktop 3.14.7 / 3.18.25 and cursor-agent CLI) — #424 + +Subagent hooks cannot be tied to their parent conversation by payload alone, +and `sessionStart` is never dispatched on the desktop. + +Observed (Cursor 3.18.25 desktop plugin hooks, one `Task` subagent that +spawned a nested `Task`): + +1. `subagentStart` / `subagentStop` are delivered to the **parent** + conversation with `subagent_id` (= the parent's `Task` `tool_call_id`), + `parent_conversation_id`, `subagent_type`, `is_parallel_worker` — but not + the child's `conversation_id`. +2. Every hook fired **inside** the subagent carries a fresh `conversation_id` + and nothing else: no `parent_conversation_id`, no `subagent_id`, no root, + `transcript_path` empty. The nested subagent is the same. +3. MCP `tools/call` `_meta` carries only `progressToken` (client + `cursor-vscode` 1.0.0), so an MCP server cannot tell which conversation is + calling it except by pairing with the `preToolUse` `MCP:` hook that + preceded it. +4. In 89,219 hook steps logged by the desktop hooks service (3.14.7 and + 3.18.25, `cursor.hooks.*.log`), `sessionStart` was requested 0 times while + `sessionEnd` (6), `workspaceOpen` (12), `beforeSubmitPrompt` (7), and + `stop` (8) were; the cursor-agent CLI with trusted project hooks does + dispatch it. The docs list `sessionStart` as an Agent hook with an + `additional_context` output, so this is a desktop dispatch gap rather than + a documentation one. +5. `preToolUse` is sometimes delivered twice for the same `tool_use_id` + (`Read`, `Grep`); the framework dedupes. + +Consequence: when two subagents are pending at once (parallel workers, or two +windows), nothing in the payloads says which fresh `conversation_id` belongs +to which `subagentStart`. The registry binds by elimination (a lone pending +start in the same `workspace_roots`), refuses otherwise, and undoes a binding +when the bound conversation later carries a root-only event. + +What would close the gap on the host side (not requested): + +- the child `conversation_id` on `subagentStart` and `subagentStop`; +- `parent_conversation_id` (and ideally `root_conversation_id`) on every hook + payload fired inside a subagent conversation; +- the calling `conversation_id` and the pre-tool `tool_use_id` in + `tools/call` `_meta`, as Claude Code (`claudecode/toolUseId`) and Codex + (`x-codex-turn-metadata`) carry; +- `sessionStart` dispatched on the desktop. + +## Claude Code (hooks, 2.1.257) — #422 + +`SubagentStart` and a subagent's own hooks name no parent agent. Every hook +payload names the root (`session_id`) and, inside a subagent, the subagent +(`agent_id`, `agent_type`). What is missing is the edge: which agent spawned +this one. + +Observed with `claude -p`, one `Agent` subagent that spawned a nested `Agent`: + +1. `SubagentStart` / `SubagentStop` carry the child's `agent_id` and + `agent_type` but no spawning `agent_id` and no spawning `tool_use_id`. +2. Hooks fired inside the subagent carry `session_id` (root) and `agent_id` + (self); the nested subagent's hooks look the same, so depth is unknown. +3. `agent_transcript_path` is flat (`/subagents/agent-.jsonl`) + and does not encode the parent. +4. `background_tasks[]` on `Stop`/`SubagentStop` lists running subagents as a + flat set with no edges. +5. The parent's `Agent` `PostToolUse` `tool_response.agentId` names the child + after the fact (live-model runs, matrix §8). +6. `PostToolUse.tool_response` for MCP tools is a plain string where the docs + describe an object; the framework accepts both. + +The registry infers the parent as the agent whose `Agent`/`Task` `PreToolUse` +is the newest unclaimed spawn when `SubagentStart` fires; that is exact for +sequential spawns and refused when two agents spawn concurrently. + +What would close the gap on the host side (not requested): `parent_agent_id` +(or the spawning `tool_use_id`) on `SubagentStart` and `SubagentStop`, and the +same field on every hook payload fired inside a subagent, so a nested agent's +hooks name their parent and depth follows. + +## Codex CLI (hooks, 0.147.0, `[features] multi_agent`) — #423 + +Hook payloads lag the MCP `_meta` lineage: `SubagentStart` names no parent +thread. The MCP side is complete — `tools/call` +`_meta["x-codex-turn-metadata"]` carries `session_id`, `thread_id`, +`parent_thread_id`, `forked_from_thread_id`, `thread_source`, +`subagent_kind`, `turn_id` — but the hook side does not carry the same edge. + +Observed with `codex exec --json`, one `collaborationspawn_agent` thread that +spawned a nested thread: + +1. `SubagentStart` carries `agent_id` (= thread id), `agent_type`, `turn_id`, + and the root `session_id`, but no `parent_thread_id`. +2. Hooks inside a subagent carry `session_id` (root) and `agent_id` (self); + only `SubagentStop` hints at the parent, through `transcript_path` being + the parent's rollout while `agent_transcript_path` is the child's. +3. The `spawn_agent` `PreToolUse` `tool_input.message` arrives encrypted, and + its `PostToolUse` (`{"task_name": …}`, no child id) fires *before* + `SubagentStart`, so the spawn call cannot be matched to the child by id. + +The registry infers the parent by claiming the newest unclaimed spawn call +under the same root and refuses when two parents have unclaimed spawns. + +What would close the gap on the host side (not requested): `parent_thread_id` +(already in `x-codex-turn-metadata`) on `SubagentStart`, `SubagentStop`, and +every hook payload fired inside a subagent thread, so hooks and MCP calls +agree without inference; the child thread id on the `spawn_agent` +`PostToolUse` would close it equally. diff --git a/docs/entry-conventions.md b/docs/entry-conventions.md index 2fda3e233..6929368a4 100644 --- a/docs/entry-conventions.md +++ b/docs/entry-conventions.md @@ -343,9 +343,14 @@ not. Generated MCP request scopes observe the negotiated client identity as a native host, derive workspace from the server process working directory, and use native transport session and HTTP authentication data when supplied. Bare stdio supplies neither a session id nor HTTP actor authentication, so those -axes remain honestly unavailable. Generated event scopes currently mount no -actor principal, so event routes observe actor as unavailable rather than -receiving a fabricated value. +axes remain honestly unavailable. `actor` is the HTTP-authenticated MCP +client and nothing else: hook-driven event scopes observe it as unavailable +rather than receiving a fabricated value, and the framework never derives an +operator identity (a signed-in user, an email) from any host payload or +environment — hosts that send one (Cursor's `user_email`) have it passed +through inside `native` untouched and unread. Who the *conversation* is — +its parent, its root, whether it is a subagent — is the +[`lineage` axis](#conversation-lineage-requestlineage). Handlers authored with `defineOperation` receive the same handle as optional `context.request` in the second `execute` argument: @@ -389,9 +394,12 @@ surface-specific `invocation.kind` the generated executable would use. #### Conversation lineage (`request.lineage`) -`(await agent()).lineage` is an `Observed` with one shape on -every surface — event routes, generated MCP tools, routed CLI commands, and -rendered scripts: +`request.lineage` is the one answer to "who is my parent, what is the root +conversation, and are we a subagent (of whom)?". It is the only place the +framework places a request in the host's conversation tree; there is no +separate operator or user identity axis, by design. `(await agent()).lineage` +is an `Observed` with one shape on every surface — event routes, +generated MCP tools, routed CLI commands, and rendered scripts: ```ts interface AgentLineage { @@ -420,17 +428,35 @@ for every event by the id the payload carries. The observed host vocabulary | --- | --- | --- | --- | --- | | 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` | | 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 newest pending start when it first speaks | the newest open `preToolUse` whose `tool_name` is `MCP:` | +| 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:` | 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`. -Only root-shaped Cursor events (`session/start`, `prompt/submit`, `stop`, -`session/end`, `compact/*`, `workspace/open`) may establish a root; a fresh -Cursor conversation seen on a tool event binds to the single pending -`subagentStart`, and stays unresolved while several are pending or after a -registry restart. `session/end` retires the root and every descendant still +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 +elimination and refuses when elimination is not possible: + +- Only root-shaped Cursor events (`session/start`, `prompt/submit`, `stop`, + `session/end`, `compact/*`, `workspace/open`) may establish a root. +- A never-seen Cursor conversation on a tool event binds to the pending + `subagentStart` only when exactly one is pending **in its workspace** + (`workspace_roots` digest, so two windows sharing one durable registry never + bind each other's children). With several pending in that workspace it + stays `id-not-resolvable` until all but one have stopped; after a registry + restart it stays unresolved, because nothing distinguishes it from a root. +- A blind binding is undone the moment the bound conversation carries any + root-shaped event (`prompt/submit`, `stop`, `session/end`, `compact/*`) — + subagents never do — so a second chat tab whose prompt predates the registry + (Cursor desktop restarts mid-conversation, and many conversations are first + seen on a tool hook) becomes the root it is, anything it started meanwhile is + re-rooted beneath it, and the pending child waits for its real conversation + again. The correction runs before the event acts, so a `session/end` on a + misbound chat retires that chat, never the parent it was filed under. + +`session/end` retires the root and every descendant still marked live; stopped nodes are pruned past the retention bound as they stop. Redelivered payloads replay their journal entries (keys derive from the canonical idempotency key and the payload minus receipt timestamps; a diff --git a/packages/agent-bundle/src/adapters/capabilities/claude-2.1.250.json b/packages/agent-bundle/src/adapters/capabilities/claude-2.1.250.json index 1f5ac1d88..bb673ee4c 100644 --- a/packages/agent-bundle/src/adapters/capabilities/claude-2.1.250.json +++ b/packages/agent-bundle/src/adapters/capabilities/claude-2.1.250.json @@ -172,11 +172,11 @@ }, "parent": { "state": "degraded", - "reason": "live model 2026-09-03, Claude Code 2.1.257: no hook payload names a subagent's parent; the runtime registry infers it from the Agent/Task PreToolUse that is open when SubagentStart fires (resolution: registry), verified at depth 1 and 2 in both background and foreground spawn paths; the parent's Agent PostToolUse tool_response.agentId confirms the inference after the fact." + "reason": "live model 2026-09-03, Claude Code 2.1.257: 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 both background and foreground spawn paths, exact for sequential spawns, 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." }, "depth": { "state": "degraded", - "reason": "live model 2026-09-03, Claude Code 2.1.257: no depth counter is delivered (the result document's subagent_stats.max_depth is not a hook field); the registry derives depth from the inferred parent chain." + "reason": "live model 2026-09-03, Claude Code 2.1.257: no depth counter is delivered (#422; the result document's subagent_stats.max_depth is not a hook field); the registry derives it from the inferred parent chain, so it is exactly as certain as parent." }, "mcp-correlation": { "state": "supported", diff --git a/packages/agent-bundle/src/adapters/capabilities/codex-0.147.0.json b/packages/agent-bundle/src/adapters/capabilities/codex-0.147.0.json index 366b6ee44..6d3595780 100644 --- a/packages/agent-bundle/src/adapters/capabilities/codex-0.147.0.json +++ b/packages/agent-bundle/src/adapters/capabilities/codex-0.147.0.json @@ -444,11 +444,11 @@ }, "parent": { "state": "degraded", - "reason": "2026-09-03: SubagentStart names no parent; SubagentStop carries the parent rollout in transcript_path and tools/call _meta carries parent_thread_id, so hooks resolve the parent through the registry (spawn_agent claim) while MCP calls resolve it natively." + "reason": "2026-09-03: SubagentStart names no parent (host gap tracked in #423; parent_thread_id exists only in tools/call _meta.x-codex-turn-metadata, and SubagentStop hints at it through the parent rollout in transcript_path). Hooks resolve the parent through the registry by claiming the newest unclaimed collaborationspawn_agent call under the same root (resolution: registry; refused when two parents have unclaimed spawns), while MCP calls resolve it natively." }, "depth": { "state": "degraded", - "reason": "2026-09-03: no depth counter is delivered; the registry derives it from the parent chain." + "reason": "2026-09-03: no depth counter is delivered (#423); the registry derives it from the parent chain for hooks and from parent_thread_id for MCP calls (a grandchild whose parent thread the registry never saw stays id-not-resolvable rather than guessed)." }, "mcp-correlation": { "state": "supported", diff --git a/packages/agent-bundle/src/adapters/capabilities/cursor-2026-08-28.json b/packages/agent-bundle/src/adapters/capabilities/cursor-2026-08-28.json index d9a8e4289..47be8c194 100644 --- a/packages/agent-bundle/src/adapters/capabilities/cursor-2026-08-28.json +++ b/packages/agent-bundle/src/adapters/capabilities/cursor-2026-08-28.json @@ -324,19 +324,19 @@ }, "root": { "state": "degraded", - "reason": "2026-09-03: a child's events carry only their own fresh conversation_id, so the root is known only through the registry that bound the child at start (resolution: inferred)." + "reason": "2026-09-03: a child's events carry only their own fresh conversation_id (host gap tracked in #424), so the root is known only through the registry that bound the child (resolution: inferred); a Cursor root itself is established only by a root-shaped event (beforeSubmitPrompt, stop, sessionEnd, preCompact, sessionStart when delivered), never by a tool hook." }, "parent": { "state": "degraded", - "reason": "2026-09-03: parent_conversation_id appears only on subagentStart/subagentStop; the child conversation is bound to the most recent pending start by ordering, which is ambiguous for parallel workers." + "reason": "2026-09-03: parent_conversation_id appears only on the parent's subagentStart/subagentStop, which also omit the child conversation id (#424). The registry binds a never-seen conversation to the pending subagentStart only when exactly one is pending in the same workspace_roots (so windows sharing one durable registry never bind each other's children); with several pending — parallel workers — it stays id-not-resolvable until all but one have stopped, and a blind binding is undone — with anything started beneath it re-rooted — the moment the conversation carries a root-only event (beforeSubmitPrompt, stop, sessionEnd, preCompact), which a subagent never does, so a sessionEnd on a misbound chat retires that chat and never the parent it was filed under." }, "depth": { "state": "degraded", - "reason": "2026-09-03: no depth counter is delivered; derived from the inferred parent chain." + "reason": "2026-09-03: no depth counter is delivered (#424); derived from the inferred parent chain, so exactly as certain as parent." }, "mcp-correlation": { "state": "degraded", - "reason": "2026-09-03: tools/call _meta carries only progressToken and the client name is cursor-vscode; the generated server resolves the caller through the open preToolUse whose tool_name is MCP:." + "reason": "2026-09-03: tools/call _meta carries only progressToken and the client name is cursor-vscode (#424); the generated server resolves the caller through the open preToolUse whose tool_name is MCP:, and refuses when open windows for that tool span several conversations." }, "cloud": { "state": "unavailable", @@ -583,6 +583,8 @@ "retrieved 2026-09-02: https://cursor.com/docs/hooks documents beforeSubmitPrompt input as prompt plus file/rule attachments, matcher evaluation against the literal UserPromptSubmit, and output as continue plus optional user_message. continue:false blocks submission; no additional-context or prompt-rewrite channel is documented.", "retrieved 2026-09-02: https://cursor.com/docs/hooks documents sessionEnd input as session_id, reason, duration_ms, is_background_agent, final_status, and optional error_message. It is fire-and-forget; output is logged but unused. The same source states cloud agents have no sessionEnd because they lack an editor-lifetime session boundary.", "2026-09-02: local cursor-agent 2026.08.31-4057e58 capture was attempted with trusted project hooks, but the binary required Cursor authentication unavailable in the capture environment; the pinned official contract is the evidence source for beforeSubmitPrompt and sessionEnd.", + "2026-09-03: Cursor desktop hooks-service logs on the maintainer's machine (~/.config/Cursor/logs///output_*/cursor.hooks.workspaceId-*.log; cursor_version 3.14.7 for 2026-08-14 → 2026-08-25 and 3.18.25 on 2026-09-03; 89,219 `Hook step requested` lines across 35 conversations, with a local plugin declaring sessionStart, sessionEnd, workspaceOpen, stop, postToolUse, preCompact, afterFileEdit and afterShellExecution) delivered plugin-scoped (`from claude-plugin config`) workspaceOpen 12×, sessionEnd 6× (reason window_close, final_status none, session_id = conversation_id, generation_id \"\", transcript_path null — accepted by the session/end validator), beforeSubmitPrompt 7×, stop 8×, preCompact 4×, postToolUse 29,308× — so workspaceOpen and sessionEnd do reach plugin-scoped hooks on the desktop even though the 2026-09-03 Xvfb CLI capture (fixtures/host-lineage/cursor-3.18.25.ndjson) saw neither.", + "2026-09-03: the same desktop hooks-service logs requested sessionStart 0× in 89,219 steps. The service logs every requested step before matching declared hooks (58,717 preToolUse steps were requested with no hook declared for them), so this is non-dispatch by Cursor desktop 3.14.7/3.18.25, not a registration gap; the cursor-agent CLI run with trusted project hooks (2026-09-02) did dispatch sessionStart with session and conversation ids. Lineage therefore never depends on session/start to establish a Cursor root — beforeSubmitPrompt, stop, sessionEnd and preCompact are the root-shaped events — and only 7 of those 35 desktop conversations ever produced beforeSubmitPrompt (28 were first seen on a tool hook after a Cursor restart or plugin load mid-conversation), which is why a blind child binding is corrected as soon as the bound conversation carries any root-only event (tracked in #424, gap 4).", "retrieved 2026-09-02: https://cursor.com/docs/hooks documents postToolUseFailure input as tool_name, tool_input, tool_use_id, cwd, error_message, failure_type, duration, and is_interrupt, with no supported output fields. Unlike Claude PostToolUseFailure, failure_type includes permission_denied.", "retrieved 2026-09-02: https://cursor.com/docs/hooks documents preCompact as observational and unable to block or modify compaction. Its optional user_message is user-facing rather than agent context, so the canonical Agent.Result and Agent.Context vocabulary exposes no output channel for it.", "retrieved 2026-09-02: the complete https://cursor.com/docs/hooks Agent and cloud event inventories document no postCompact event, so compact/after is unavailable rather than inferred from preCompact.", diff --git a/packages/agent-bundle/src/adapters/capabilities/portable-1.0.0.json b/packages/agent-bundle/src/adapters/capabilities/portable-1.0.0.json index 8a59930e5..9d47b85c8 100644 --- a/packages/agent-bundle/src/adapters/capabilities/portable-1.0.0.json +++ b/packages/agent-bundle/src/adapters/capabilities/portable-1.0.0.json @@ -76,7 +76,7 @@ "2026-09-02: Cursor 3.18.25 (stable, 280eca2911f1) discovers the emitted plugin, its skill, and its mcp.json from ~/.cursor/plugins/local and spawns the configured stdio server (docs/audits/2026-09-02-agent-plugins-cursor-ide-proof.md).", "2026-09-02: Cursor 3.18.25 does not expand ${PLUGIN_ROOT} in cwd or args and does not default an omitted cwd to the plugin root (spec 7.2.1/9.2 MUSTs), so spec-conformant stdio servers fail to launch there; its proprietary ${CURSOR_PLUGIN_ROOT} expands and connects.", "2026-09-02: With a launchable configuration the Cursor IDE completed the stdio handshake (connect_success with stable heartbeat), so the launch pipeline itself consumes this format.", - "2026-09-03: Re-verified on the same Cursor 3.18.25 build (realCommit 280eca2911f1…af3; no newer stable release): the cwd, args, and default-cwd gaps reproduce, and single-variable probes additionally show ${PLUGIN_ROOT}/${PLUGIN_DATA} unexpanded in env values (spec 9.2), the reserved PLUGIN_ROOT/PLUGIN_DATA subprocess variables absent (spec 9.1), and plugin-relative ./ commands resolved against the workspace folder (spec 7.2.1); vendor report prepared in docs/audits/2026-09-03-cursor-agent-plugins-feedback.md (docs/audits/2026-09-03-agent-plugins-cursor-ide-proof.md)." + "2026-09-03: Re-verified on the same Cursor 3.18.25 build (realCommit 280eca2911f1…af3; no newer stable release): the cwd, args, and default-cwd gaps reproduce, and single-variable probes additionally show ${PLUGIN_ROOT}/${PLUGIN_DATA} unexpanded in env values (spec 9.2), the reserved PLUGIN_ROOT/PLUGIN_DATA subprocess variables absent (spec 9.1), and plugin-relative ./ commands resolved against the workspace folder (spec 7.2.1); observations recorded as internal reference in docs/audits/2026-09-03-cursor-agent-plugins-observations.md (docs/audits/2026-09-03-agent-plugins-cursor-ide-proof.md); not submitted to Cursor by maintainer decision." ], "legacySse": { "reason": "2026-09-02: Agent Plugins 1.0.0 §7.2.1 defines the deprecated HTTP+SSE variant as OPTIONAL for clients; the compiler emits only the required modern transports and rejects a legacy transport with AB4339 (https://agent-plugins.org/specification).", diff --git a/packages/agent-bundle/src/adapters/hook-contract.ts b/packages/agent-bundle/src/adapters/hook-contract.ts index 0c6f1beaa..0839a4c54 100644 --- a/packages/agent-bundle/src/adapters/hook-contract.ts +++ b/packages/agent-bundle/src/adapters/hook-contract.ts @@ -312,7 +312,6 @@ export const createNativeEventStarter = ( ? { cursor_version: 'lifecycle-replay', hook_event_name: nativeEvent, - user_email: null, workspace_roots: ['/tmp'], } : base); diff --git a/packages/agent-bundle/src/events/projection.ts b/packages/agent-bundle/src/events/projection.ts index ce7f1d8e9..b1ba4207e 100644 --- a/packages/agent-bundle/src/events/projection.ts +++ b/packages/agent-bundle/src/events/projection.ts @@ -105,13 +105,9 @@ export const validateNativeEventEnvelope = ( return nativeEventError('native workspace_roots must be a nonempty array of nonempty strings'); } requireNativeString(native, 'cursor_version'); - if ( - Object.hasOwn(native, 'user_email') - && native.user_email !== null - && typeof native.user_email !== 'string' - ) { - return nativeEventError('native user_email must be a string or null'); - } + // Cursor also sends the signed-in user's email on this envelope. The + // framework does not read, validate, or surface operator identity; the + // field passes through untouched inside the native payload. return native; } if (typeof native.session_id !== 'string' && typeof native.conversation_id !== 'string') { diff --git a/packages/agent-bundle/src/routes/public.ts b/packages/agent-bundle/src/routes/public.ts index 58f8da9d2..85094b6be 100644 --- a/packages/agent-bundle/src/routes/public.ts +++ b/packages/agent-bundle/src/routes/public.ts @@ -55,11 +55,14 @@ export type AgentEventNativePayload = Readonly>; /** * Props received by an event route's async default Server Component. * - * Read transport-owned request identity with `await agent()` from - * `@agent-bundle/runtime`. The invocation, host, session, actor, and workspace - * axes are `Observed`, including typed unavailable reasons when the host - * cannot know an axis. Business payload fields cannot override them. - * Generated event scopes currently expose actor as unavailable. + * Read transport-owned request context with `await agent()` from + * `@agent-bundle/runtime`. The invocation, host, session, actor, workspace, + * and lineage axes are `Observed`, including typed unavailable reasons when + * the host cannot know an axis. Business payload fields cannot override them. + * `lineage` answers "who is my parent, what is the root, am I a subagent"; + * `actor` is the HTTP-authenticated client of a streamable MCP transport and + * is unavailable on hook-driven event scopes. The framework never derives or + * surfaces the operator's identity from a host payload. */ export interface AgentEventRouteProps { readonly canonical: AgentEventCanonicalIdentity; diff --git a/packages/agent-bundle/tests/event-project.test.ts b/packages/agent-bundle/tests/event-project.test.ts index 7bb13defb..69e0d1fc4 100644 --- a/packages/agent-bundle/tests/event-project.test.ts +++ b/packages/agent-bundle/tests/event-project.test.ts @@ -101,8 +101,12 @@ it('validates Cursor workspaceOpen without inventing an agent session', () => { } expect(() => validateNativeEventEnvelope({ ...documented, cursor_version: '' }, options)) .toThrow(/native cursor_version must be a nonempty string/u); - expect(() => validateNativeEventEnvelope({ ...documented, user_email: 7 }, options)) - .toThrow(/native user_email must be a string or null/u); + // Operator identity is not a framework concern: the envelope validator never + // inspects `user_email`, whatever Cursor puts there. + for (const userEmail of [undefined, null, 'someone@example.com', 7]) { + const envelope = { ...documented, user_email: userEmail }; + expect(validateNativeEventEnvelope(envelope, options)).toBe(envelope); + } expect(() => validateNativeEventEnvelope({ ...documented, diff --git a/packages/agent-bundle/tests/hooks.test.ts b/packages/agent-bundle/tests/hooks.test.ts index 95b718a76..caf88921e 100644 --- a/packages/agent-bundle/tests/hooks.test.ts +++ b/packages/agent-bundle/tests/hooks.test.ts @@ -1131,7 +1131,6 @@ it('runs the Cursor workspace/open lifecycle starter through a generated wrapper expect(starter).toEqual({ cursor_version: 'lifecycle-replay', hook_event_name: 'workspaceOpen', - user_email: null, workspace_roots: ['/tmp'], }); await expect(runNativeHook(wrapper, starter!)).resolves.toEqual({ diff --git a/packages/agent-bundle/tests/route-unit/lifecycle-replay.test.ts b/packages/agent-bundle/tests/route-unit/lifecycle-replay.test.ts index 7cef1bce1..b2010cef2 100644 --- a/packages/agent-bundle/tests/route-unit/lifecycle-replay.test.ts +++ b/packages/agent-bundle/tests/route-unit/lifecycle-replay.test.ts @@ -364,7 +364,6 @@ it('replays the Cursor workspaceOpen starter as an observation with no native re expect(target?.fixture?.native).toEqual({ cursor_version: 'lifecycle-replay', hook_event_name: 'workspaceOpen', - user_email: null, workspace_roots: ['/tmp'], }); const replay = await service.replay({ diff --git a/packages/rsc-runtime/src/lineage/registry.ts b/packages/rsc-runtime/src/lineage/registry.ts index c609a72f6..bff15bcc8 100644 --- a/packages/rsc-runtime/src/lineage/registry.ts +++ b/packages/rsc-runtime/src/lineage/registry.ts @@ -80,7 +80,11 @@ const SPAWN_TOOLS: Readonly boolean>> cursor: (toolName) => toolName === 'Task', }); -/** Cursor events only the user-facing conversation emits; a subagent's conversation never carries them. */ +/** + * Cursor events only the user-facing conversation emits; a subagent's + * conversation never carries them. Observed on a conversation the registry + * bound blind to a pending child, one proves the binding wrong. + */ const CURSOR_ROOT_EVENTS: ReadonlySet = new Set([ 'session/start', 'session/end', @@ -91,6 +95,24 @@ const CURSOR_ROOT_EVENTS: ReadonlySet = new Set([ 'workspace/open', ]); + +/** + * A digest of the Cursor `workspace_roots` on a payload. Every Cursor hook + * carries it (observed 2026-09-03 on CLI and desktop), and a subagent runs in + * its parent's workspace, so a pending child never binds across workspaces. + */ +const cursorWorkspace = (native: Readonly>): string | undefined => { + const roots = native['workspace_roots']; + if (!Array.isArray(roots)) return undefined; + const paths = roots.filter((root): root is string => typeof root === 'string' && root.trim() !== '').sort(); + if (paths.length === 0) return undefined; + return createHash('sha256').update(canonicalJson(paths), 'utf8').digest('hex').slice(0, 16); +}; + +/** An unknown workspace on either side matches: older Cursor builds that omit the roots keep the single-pending rule. */ +const sameWorkspace = (left: string | undefined, right: string | undefined): boolean => + left === undefined || right === undefined || left === right; + const lineageOf = (node: LineageNode, generation: string | undefined, resolution: AgentLineageResolution): AgentLineage => Object.freeze({ conversation: node.id, depth: node.depth, @@ -138,12 +160,18 @@ const journalKeys = (idempotencyKey: string): JournalKeys => ({ }, }); -const rootNode = (conversation: string, generation: string | undefined, startedAt: string): LineageNode => ({ +const rootNode = ( + conversation: string, + generation: string | undefined, + startedAt: string, + workspace: string | undefined, +): LineageNode => ({ depth: 0, ...(generation === undefined ? {} : { generation }), id: conversation, root: conversation, startedAt, + ...(workspace === undefined ? {} : { workspace }), }); export const createAgentLineageRegistry = ( @@ -259,9 +287,10 @@ export const createAgentLineageRegistry = ( /** * The node for a conversation that speaks for itself, creating it when it is * new. A never-seen Cursor conversation while exactly one `subagentStart` is - * pending is that child speaking for the first time; with several pending - * children the child payload carries nothing to tell them apart, so the - * conversation stays unresolved rather than being bound arbitrarily. + * pending *in its workspace* is that child speaking for the first time; with + * several pending children the child payload carries nothing to tell them + * apart, so the conversation stays unresolved rather than being bound + * arbitrarily. */ const ensureRoot = async ( host: LineageHost, @@ -270,26 +299,51 @@ export const createAgentLineageRegistry = ( observedAt: string, keys: JournalKeys, allowRoot: boolean, + workspace: string | undefined, ): Promise => { const existing = state.nodes[conversation]; if (existing !== undefined) return existing; // A root-shaped event is a root: it never binds to a pending child, so an // unrelated conversation starting beside a pending spawn stays its own root. if (allowRoot) { - const node = rootNode(conversation, generation, observedAt); + const node = rootNode(conversation, generation, observedAt, workspace); await dispatch('nodeStarted', node, keys); return node; } - if (host === 'cursor' && state.pendingChildren.length === 1) { - const subagentId = state.pendingChildren[0]!; - await dispatch('childBound', { conversation, subagentId }, keys); - return state.nodes[conversation]; + if (host === 'cursor') { + const pending = state.pendingChildren.filter((subagentId) => sameWorkspace(state.nodes[subagentId]?.workspace, workspace)); + if (pending.length === 1) { + await dispatch('childBound', { conversation, subagentId: pending[0]! }, keys); + return state.nodes[conversation]; + } } // No single pending start and not root-shaped: a Cursor child's tool event // after a registry restart carries nothing that distinguishes it from a root. return undefined; }; + /** + * A conversation bound blind to a pending Cursor child that now carries a + * root-only event was a root all along (its prompt predates the registry, or + * Cursor never delivered it). The child returns to pending, anything started + * beneath the conversation is re-rooted under it, and the conversation + * becomes the root it is. + */ + const correctMisboundChild = async ( + conversation: string, + observedAt: string, + keys: JournalKeys, + ): Promise => { + const node = state.nodes[conversation]; + if (node === undefined || node.depth === 0 || node.subagentId === undefined) return; + await dispatch('childUnbound', { conversation, subagentId: node.subagentId }, keys); + // Materialize the root at once — the bound node carried the conversation's + // generation, workspace and first-seen time — so the correcting event + // resolves against it like any known root's would, including a + // `session/end`, which then retires this node instead of finding none. + await dispatch('nodeStarted', rootNode(conversation, node.generation, node.startedAt, node.workspace), keys); + }; + const resolve = ( host: LineageHost, native: Readonly>, @@ -314,7 +368,8 @@ export const createAgentLineageRegistry = ( if (subagentId === undefined || parentId === undefined) return; // A replayed start already registered (or bound) this child, even if its node was pruned since. if (state.seenStarts.includes(subagentId) || state.nodes[subagentId] !== undefined || Object.values(state.nodes).some((node) => node.subagentId === subagentId)) return; - const parent = await ensureRoot(host, parentId, undefined, observedAt, keys, false); + const workspace = cursorWorkspace(native); + const parent = await ensureRoot(host, parentId, undefined, observedAt, keys, false, workspace); if (parent === undefined) return; await dispatch('nodeStarted', { depth: parent.depth + 1, @@ -326,6 +381,7 @@ export const createAgentLineageRegistry = ( subagentId, ...(nativeString(native, 'tool_call_id') === undefined ? {} : { toolCallId: nativeString(native, 'tool_call_id')! }), ...(nativeString(native, 'subagent_type') === undefined ? {} : { type: nativeString(native, 'subagent_type')! }), + ...(workspace === undefined ? {} : { workspace }), }, keys); return; } @@ -335,7 +391,7 @@ export const createAgentLineageRegistry = ( // A replayed start must not claim a second spawn or rewrite the node — even // after retention pruned the node, the start identity is remembered. if (state.seenStarts.includes(agentId) || state.nodes[agentId] !== undefined) return; - const rootNodeValue = await ensureRoot(host, root, undefined, observedAt, keys, true); + const rootNodeValue = await ensureRoot(host, root, undefined, observedAt, keys, true, undefined); if (rootNodeValue === undefined) return; const claim = await claimSpawn(host, rootNodeValue.root, keys); // No spawn to claim (the pre-tool hook was missed, or the registry @@ -401,6 +457,14 @@ export const createAgentLineageRegistry = ( const observedAt = observation.observedAt ?? new Date().toISOString(); const { event, host, native } = observation; const carrier = lineageCarrier(host, native); + // A Cursor conversation bound blind to a pending child cannot carry a + // root-only event; one that does was a root the registry had not seen, so + // the binding is undone before anything acts on the wrong tree — a + // `session/end` must retire this conversation, not the parent it was + // misfiled under. + if (host === 'cursor' && CURSOR_ROOT_EVENTS.has(event) && carrier.conversation !== undefined) { + await correctMisboundChild(carrier.conversation, observedAt, keys); + } switch (event) { case 'agent/start': await observeStart(observation, observedAt, keys); @@ -416,15 +480,15 @@ export const createAgentLineageRegistry = ( } // Claude and Codex name the root on every payload; Cursor never repeats // it, so only root-shaped Cursor events may establish a root, and a - // fresh child conversation binds to the single pending start. - // A session that ends before this registry saw it start leaves no node - // behind: establishing one after retirement would never be pruned. + // fresh child conversation binds to the single pending start in its + // workspace. A session that ends before this registry saw it start leaves + // no node behind: establishing one after retirement would never be pruned. if (event !== 'session/end' && carrier.conversation !== undefined && nodeFor(carrier.conversation) === undefined) { const rootLike = host === 'cursor' ? CURSOR_ROOT_EVENTS.has(event) : carrier.conversation === carrier.root; if (rootLike || host === 'cursor') { - await ensureRoot(host, carrier.conversation, carrier.generation, observedAt, keys, rootLike); + await ensureRoot(host, carrier.conversation, carrier.generation, observedAt, keys, rootLike, host === 'cursor' ? cursorWorkspace(native) : undefined); } } const toolCallId = nativeString(native, 'tool_use_id') ?? nativeString(native, 'tool_call_id'); diff --git a/packages/rsc-runtime/src/lineage/state.ts b/packages/rsc-runtime/src/lineage/state.ts index b1c1b1bf2..7dee45284 100644 --- a/packages/rsc-runtime/src/lineage/state.ts +++ b/packages/rsc-runtime/src/lineage/state.ts @@ -23,6 +23,12 @@ export const LineageNodeSchema = z.object({ subagentId: id.optional(), toolCallId: id.optional(), type: id.optional(), + /** + * Digest of the Cursor `workspace_roots` the node was seen in. A pending + * child only binds to a conversation from the same workspace, so two + * windows sharing one durable registry never bind each other's children. + */ + workspace: id.optional(), }).strict(); /** A pre-tool hook whose post-tool hook has not fired: the correlation window for MCP calls and spawns. */ @@ -66,6 +72,12 @@ export type LineageState = z.output; export const lineageEventSchemas = { /** A Cursor child conversation is now known for a pending subagent id: the node moves to its conversation id. */ childBound: z.object({ conversation: id, subagentId: id }).strict(), + /** + * A conversation bound blind to a pending Cursor child later carried a + * user-only event (`beforeSubmitPrompt`): it was a root whose prompt the + * registry never saw. The node returns to its subagent id and waits again. + */ + childUnbound: z.object({ conversation: id, subagentId: id }).strict(), nodeStarted: LineageNodeSchema, nodeStopped: z.object({ id, stoppedAt: timestamp }).strict(), /** A finished session releases its correlation windows and pending spawns. */ @@ -152,6 +164,40 @@ export const reduceLineage = ( seenStarts: remember(state.seenStarts, [conversation]), }; } + case 'childUnbound': { + const { conversation, subagentId } = event.payload as { conversation: string; subagentId: string }; + const bound = state.nodes[conversation]; + if (bound === undefined || bound.subagentId !== subagentId || state.nodes[subagentId] !== undefined) return state; + const { [conversation]: _moved, ...rest } = state.nodes; + // Everything started beneath the misbound conversation belongs to the + // root it is about to become: same shape, re-rooted, depth rebased. + const descendants = new Set(); + const descendsFrom = (node: LineageNode, hops = 0): boolean => { + if (node.parent === conversation) return true; + if (node.parent === undefined || hops > Object.keys(rest).length) return false; + const parent = rest[node.parent]; + return parent !== undefined && descendsFrom(parent, hops + 1); + }; + for (const node of Object.values(rest)) { + if (node.root === bound.root && descendsFrom(node)) descendants.add(node.id); + } + const rerooted = (node: LineageNode): LineageNode => + descendants.has(node.id) ? { ...node, depth: node.depth - bound.depth, root: conversation } : node; + const rerootedCall = (call: OpenToolCall): OpenToolCall => + call.conversation === conversation || descendants.has(call.conversation) ? { ...call, root: conversation } : call; + return { + ...state, + nodes: { ...Object.fromEntries(Object.entries(rest).map(([key, node]) => [key, rerooted(node)])), [subagentId]: { ...bound, id: subagentId } }, + openCalls: state.openCalls.map(rerootedCall), + // A child whose stop already arrived stays a finished, never-bound + // node; a live one waits for its real conversation again. + pendingChildren: bound.stoppedAt === undefined + ? [subagentId, ...state.pendingChildren.filter((candidate) => candidate !== subagentId)] + : state.pendingChildren, + pendingSpawns: state.pendingSpawns.map(rerootedCall), + seenStarts: state.seenStarts.filter((known) => known !== conversation), + }; + } case 'toolCallOpened': { const { spawn, ...call } = event.payload as OpenToolCall & { readonly spawn?: boolean }; const openCalls = [...state.openCalls.filter((open) => open.toolCallId !== call.toolCallId), call]; diff --git a/packages/rsc-runtime/tests/lineage-registry.test.ts b/packages/rsc-runtime/tests/lineage-registry.test.ts index 211a27ef5..297774770 100644 --- a/packages/rsc-runtime/tests/lineage-registry.test.ts +++ b/packages/rsc-runtime/tests/lineage-registry.test.ts @@ -722,3 +722,142 @@ describe('lineage registry replay ledger across degradation (review round 11)', await driver.close(); }); }); + +describe('lineage registry Cursor child binding precision (desktop hooks-service evidence, 2026-09-03)', () => { + const cursor = (registry: AgentLineageRegistry) => + (event: string, key: string, native: Record) => + registry.observe({ event, host: 'cursor', idempotencyKey: key, native, observedAt: '2026-09-03T00:00:00.000Z' }); + const start = (parent: string, id: string, workspace: string) => ({ + conversation_id: parent, hook_event_name: 'subagentStart', is_parallel_worker: false, parent_conversation_id: parent, + subagent_id: id, subagent_type: 'general-purpose', tool_call_id: id, workspace_roots: [workspace], + }); + const tool = (conversation: string, id: string, workspace: string) => ({ + conversation_id: conversation, hook_event_name: 'preToolUse', tool_input: {}, tool_name: 'Shell', tool_use_id: id, workspace_roots: [workspace], + }); + + it('binds an unseen conversation only to the pending child of its own workspace', async () => { + const registry = createAgentLineageRegistry(); + const observe = cursor(registry); + await observe('prompt/submit', 'pa', { conversation_id: 'root-a', hook_event_name: 'beforeSubmitPrompt', workspace_roots: ['/ws/a'] }); + await observe('prompt/submit', 'pb', { conversation_id: 'root-b', hook_event_name: 'beforeSubmitPrompt', workspace_roots: ['/ws/b'] }); + await observe('agent/start', 'sa', start('root-a', 'call-a', '/ws/a')); + await observe('agent/start', 'sb', start('root-b', 'call-b', '/ws/b')); + // Two starts are pending overall, but only one per workspace: each window's child binds to its own parent. + expect(value(await observe('tool/before', 'ta', tool('child-a', 'ta', '/ws/a')))).toMatchObject({ conversation: 'child-a', depth: 1, parent: 'root-a', root: 'root-a', subagent: { id: 'call-a' } }); + expect(value(await observe('tool/before', 'tb', tool('child-b', 'tb', '/ws/b')))).toMatchObject({ conversation: 'child-b', depth: 1, parent: 'root-b', root: 'root-b', subagent: { id: 'call-b' } }); + }); + + it('refuses to bind a conversation from a workspace where nothing is pending', async () => { + const registry = createAgentLineageRegistry(); + const observe = cursor(registry); + await observe('prompt/submit', 'pa', { conversation_id: 'root-a', hook_event_name: 'beforeSubmitPrompt', workspace_roots: ['/ws/a'] }); + await observe('agent/start', 'sa', start('root-a', 'call-a', '/ws/a')); + // A conversation in another window is not A's child, however alone the pending start is. + expect(await observe('tool/before', 'tc', tool('other-window', 'tc', '/ws/c'))).toEqual(unavailable('id-not-resolvable')); + expect(registry.snapshot().pendingChildren).toEqual(['call-a']); + // A payload without roots (older Cursor builds) keeps the single-pending rule. + const { workspace_roots: _roots, ...rootless } = tool('child-a', 'ta', '/ws/a'); + expect(value(await observe('tool/before', 'ta', rootless))).toMatchObject({ conversation: 'child-a', parent: 'root-a' }); + }); + + it('undoes a blind binding when the bound conversation receives a prompt, and rebinds the real child', async () => { + const registry = createAgentLineageRegistry(); + const observe = cursor(registry); + await observe('prompt/submit', 'p', { conversation_id: 'root', hook_event_name: 'beforeSubmitPrompt', workspace_roots: ['/ws'] }); + await observe('agent/start', 's', start('root', 'call-a', '/ws')); + // A second chat tab whose prompt predates the registry speaks first: it is bound blind. + expect(value(await observe('tool/before', 'x1', tool('other-root', 'x1', '/ws')))).toMatchObject({ conversation: 'other-root', depth: 1, parent: 'root' }); + // Its next prompt proves it a root: subagents never receive one. + const corrected = await observe('prompt/submit', 'x-prompt', { conversation_id: 'other-root', hook_event_name: 'beforeSubmitPrompt', workspace_roots: ['/ws'] }); + expect(value(corrected)).toMatchObject({ conversation: 'other-root', depth: 0, root: 'other-root' }); + expect(value(corrected).parent).toBeUndefined(); + const snapshot = registry.snapshot(); + expect(snapshot.pendingChildren).toEqual(['call-a']); + expect(snapshot.nodes['call-a']).toMatchObject({ depth: 1, id: 'call-a', parent: 'root', subagentId: 'call-a' }); + expect(snapshot.seenStarts).not.toContain('other-root'); + // The real child now binds to the restored pending start. + expect(value(await observe('tool/before', 'c1', tool('child', 'c1', '/ws')))).toMatchObject({ conversation: 'child', depth: 1, parent: 'root', subagent: { id: 'call-a' } }); + expect(value(await observe('tool/before', 'x2', tool('other-root', 'x2', '/ws')))).toMatchObject({ conversation: 'other-root', depth: 0, resolution: 'native' }); + }); + + it('keeps a mis-bound child that already stopped as a finished node instead of re-queuing it', async () => { + const registry = createAgentLineageRegistry(); + const observe = cursor(registry); + await observe('prompt/submit', 'p', { conversation_id: 'root', hook_event_name: 'beforeSubmitPrompt', workspace_roots: ['/ws'] }); + await observe('agent/start', 's', start('root', 'call-a', '/ws')); + await observe('tool/before', 'x1', tool('other-root', 'x1', '/ws')); + await observe('agent/stop', 'stop', { ...start('root', 'call-a', '/ws'), hook_event_name: 'subagentStop', status: 'completed' }); + expect(value(await observe('prompt/submit', 'x-prompt', { conversation_id: 'other-root', hook_event_name: 'beforeSubmitPrompt', workspace_roots: ['/ws'] }))).toMatchObject({ depth: 0 }); + const snapshot = registry.snapshot(); + expect(snapshot.pendingChildren).toEqual([]); + expect(snapshot.nodes['call-a']).toMatchObject({ id: 'call-a', stoppedAt: '2026-09-03T00:00:00.000Z' }); + expect(snapshot.nodes['other-root']).toMatchObject({ depth: 0, root: 'other-root' }); + }); + + it('corrects a blind binding before session/end so the misbound chat retires itself, not the parent it was filed under', async () => { + const registry = createAgentLineageRegistry(); + const observe = cursor(registry); + await observe('prompt/submit', 'p', { conversation_id: 'root', hook_event_name: 'beforeSubmitPrompt', workspace_roots: ['/ws'] }); + await observe('agent/start', 's', start('root', 'call-a', '/ws')); + await observe('tool/before', 'x1', tool('other-root', 'x1', '/ws')); + expect(registry.snapshot().nodes['other-root']).toMatchObject({ depth: 1, root: 'root' }); + // The other chat tab is closed: only a root receives sessionEnd, and the + // event resolves against the corrected root exactly as a known root's would. + const ended = await observe('session/end', 'x-end', { + conversation_id: 'other-root', duration_ms: 1, final_status: 'none', hook_event_name: 'sessionEnd', is_background_agent: false, reason: 'window_close', workspace_roots: ['/ws'], + }); + expect(value(ended)).toMatchObject({ conversation: 'other-root', depth: 0, root: 'other-root' }); + expect(value(ended).parent).toBeUndefined(); + const snapshot = registry.snapshot(); + expect(snapshot.nodes['root']?.stoppedAt).toBeUndefined(); + expect(snapshot.nodes['call-a']).toMatchObject({ id: 'call-a', parent: 'root' }); + expect(snapshot.nodes['call-a']?.stoppedAt).toBeUndefined(); + expect(snapshot.pendingChildren).toEqual(['call-a']); + // The proven root is materialized as stopped, like any retired root, and keeps its first-seen time. + expect(snapshot.nodes['other-root']).toMatchObject({ depth: 0, root: 'other-root', stoppedAt: '2026-09-03T00:00:00.000Z' }); + expect(snapshot.nodes['other-root']?.startedAt).toBe(snapshot.nodes['call-a']?.startedAt); + // The real child still binds to the restored pending start. + expect(value(await observe('tool/before', 'c1', tool('child', 'c1', '/ws')))).toMatchObject({ conversation: 'child', depth: 1, parent: 'root', root: 'root' }); + }); + + it('re-roots what the misbound conversation started beneath it when the binding is undone', async () => { + const registry = createAgentLineageRegistry(); + const observe = cursor(registry); + await observe('prompt/submit', 'p', { conversation_id: 'root', hook_event_name: 'beforeSubmitPrompt', workspace_roots: ['/ws'] }); + await observe('agent/start', 's', start('root', 'call-a', '/ws')); + // The other chat is bound blind, then spawns its own subagent, whose conversation binds beneath it. + await observe('tool/before', 'x1', tool('other-root', 'x1', '/ws')); + await observe('agent/start', 'sx', start('other-root', 'call-x', '/ws')); + const grandchild = value(await observe('tool/before', 'g1', tool('grandchild', 'g1', '/ws'))); + expect(grandchild).toMatchObject({ conversation: 'grandchild', depth: 2, parent: 'other-root', root: 'root' }); + await observe('tool/before', 'g-mcp', { ...tool('grandchild', 'g-mcp', '/ws'), tool_name: 'MCP:probe' }); + // The prompt proves the other chat a root; its subtree follows it. + expect(value(await observe('prompt/submit', 'x-prompt', { conversation_id: 'other-root', hook_event_name: 'beforeSubmitPrompt', workspace_roots: ['/ws'] }))).toMatchObject({ depth: 0, root: 'other-root' }); + const snapshot = registry.snapshot(); + expect(snapshot.nodes['grandchild']).toMatchObject({ depth: 1, parent: 'other-root', root: 'other-root', subagentId: 'call-x' }); + expect(snapshot.nodes['call-a']).toMatchObject({ depth: 1, parent: 'root', root: 'root' }); + expect(snapshot.pendingChildren).toEqual(['call-a']); + expect(snapshot.openCalls.find((call) => call.toolCallId === 'g-mcp')?.root).toBe('other-root'); + expect(value(await registry.resolveToolCall({ host: 'cursor', toolName: 'probe' }))).toMatchObject({ conversation: 'grandchild', depth: 1, parent: 'other-root', root: 'other-root' }); + // Retiring the corrected root takes its own subtree and nothing of the original root's. + await observe('session/end', 'x-end', { + conversation_id: 'other-root', duration_ms: 1, final_status: 'none', hook_event_name: 'sessionEnd', is_background_agent: false, reason: 'window_close', workspace_roots: ['/ws'], + }); + const retired = registry.snapshot(); + expect(retired.nodes['grandchild']?.stoppedAt).toBe('2026-09-03T00:00:00.000Z'); + expect(retired.nodes['root']?.stoppedAt).toBeUndefined(); + expect(retired.nodes['call-a']?.stoppedAt).toBeUndefined(); + expect(retired.openCalls.find((call) => call.toolCallId === 'g-mcp')).toBeUndefined(); + }); + + it('records the workspace digest on Cursor roots and children and never the raw roots', async () => { + const registry = createAgentLineageRegistry(); + const observe = cursor(registry); + await observe('prompt/submit', 'p', { conversation_id: 'root', hook_event_name: 'beforeSubmitPrompt', workspace_roots: ['/ws/b', '/ws/a'] }); + await observe('agent/start', 's', start('root', 'call-a', '/ws/a')); + const { nodes } = registry.snapshot(); + expect(nodes['root']?.workspace).toMatch(/^[0-9a-f]{16}$/u); + expect(nodes['call-a']?.workspace).toMatch(/^[0-9a-f]{16}$/u); + expect(JSON.stringify(nodes)).not.toContain('/ws/'); + }); +}); diff --git a/website/docs/en/guide/authoring/mcp.mdx b/website/docs/en/guide/authoring/mcp.mdx index b279b7954..b8c88d07b 100644 --- a/website/docs/en/guide/authoring/mcp.mdx +++ b/website/docs/en/guide/authoring/mcp.mdx @@ -76,6 +76,14 @@ transport publishes an `available` value and its source when it knows one, or `u a typed reason when it does not. Bare stdio supplies neither a session id nor HTTP actor authentication, so those axes stay honestly unavailable rather than being fabricated. +The handle also carries `lineage`, the one answer to "who is my parent, what is the root +conversation, and are we a subagent?" — `{ conversation, root, parent?, depth, generation?, +subagent?, resolution }`, resolved by the warm runtime's registry from the host's subagent +start/stop and pre-tool hooks, or `unavailable` with a typed reason (`id-not-resolvable`, +`no-subagent-events`, `no-shared-runtime`, `cloud-agent-no-user-hooks`, `unsupported-surface`, +`not-provided`). There is deliberately no operator or user identity axis: the framework never +reads or surfaces who the human behind a host session is. + ## Shared layouts `src/layout.tsx` is the composition point around every rendered route — the `layout.tsx` idea from diff --git a/website/docs/zh/guide/authoring/mcp.mdx b/website/docs/zh/guide/authoring/mcp.mdx index 473f32c7f..6cbf7ae98 100644 --- a/website/docs/zh/guide/authoring/mcp.mdx +++ b/website/docs/zh/guide/authoring/mcp.mdx @@ -71,6 +71,13 @@ export const config = { 其来源,不知道时则发布带有类型化原因的 `unavailable`。裸 stdio 既不提供 session id 也不提供 HTTP actor 认证,因此这两个轴保持诚实的不可用,而不是被伪造出来。 +句柄还带有 `lineage`,它是"我的父会话是谁、根会话是什么、我们是否是子代理"这一问题的唯一答案—— +`{ conversation, root, parent?, depth, generation?, subagent?, resolution }`,由常驻运行时的注册表根据 +宿主的子代理 start/stop 与 pre-tool 钩子解析得出;无法解析时则给出带类型化原因的 `unavailable` +(`id-not-resolvable`、`no-subagent-events`、`no-shared-runtime`、`cloud-agent-no-user-hooks`、 +`unsupported-surface`、`not-provided`)。框架刻意不提供操作者或用户身份轴:它永远不会读取或暴露宿主 +会话背后的人是谁。 + ## 共享布局 `src/layout.tsx` 是每个渲染式路由外层的组合点——页面框架中 `layout.tsx` 的思路应用到 Agent Document 上。