From c26ac276f73b78944b5a0b10e83e7cf5a4f51161 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 05:29:48 +0000 Subject: [PATCH 1/9] feat(cursor): refresh plugin contract coverage against the 2026-09-02 public docs (#189) Validate and project Cursor subagentStart/subagentStop through their documented subagent_* envelopes and output channels (permission deny + user_message; followup_message) in event routes, config-hook wrappers, playground codecs, and lifecycle starters. Add the cursor.* config extension for schema-admitted manifest metadata (author, homepage, repository, license, keywords, publisher, category, tags, minClientVersions) with cursor.manifest.* diagnostics. Pin the complete 21-event Cursor hook inventory with cloud availability, hook options, plugin formats, discovery, variables, marketplace limits, distribution and local-install surfaces, canvases, and the G5-gated agents component as dated capability rows mirrored through the unified bundle. Cursor adapterRevision 1.9.0, plugin 1.22.0. --- .changeset/cursor-contract-refresh.md | 19 + README.md | 2 + .../agent-bundle-walkthrough.canvas.tsx | 8 +- examples/rsc-agent-runtime/README.md | 14 +- packages/agent-bundle/README.md | 2 +- .../capabilities/cursor-2026-08-28.json | 355 ++++++++++++++++-- packages/agent-bundle/src/adapters/cursor.ts | 248 +++++++++++- .../src/adapters/hook-contract.ts | 102 ++++- packages/agent-bundle/src/adapters/plugin.ts | 54 ++- .../adapters/schemas/cursor/PROVENANCE.json | 9 + packages/agent-bundle/src/config/index.ts | 2 + .../agent-bundle/src/events/projection.ts | 99 +++-- packages/agent-bundle/src/index.ts | 2 + .../tests/adapter-capability-states.test.ts | 260 +++++++++++-- .../tests/adapter-metadata.test.ts | 4 +- .../agent-bundle/tests/cursor-adapter.test.ts | 95 +++++ .../agent-bundle/tests/event-project.test.ts | 58 +++ .../events/cursor-subagent-start.json | 18 + .../fixtures/events/cursor-subagent-stop.json | 21 ++ packages/agent-bundle/tests/hooks.test.ts | 103 +++++ .../tests/route-unit/event-project.test.ts | 83 ++++ 21 files changed, 1433 insertions(+), 125 deletions(-) create mode 100644 .changeset/cursor-contract-refresh.md create mode 100644 packages/agent-bundle/tests/fixtures/events/cursor-subagent-start.json create mode 100644 packages/agent-bundle/tests/fixtures/events/cursor-subagent-stop.json diff --git a/.changeset/cursor-contract-refresh.md b/.changeset/cursor-contract-refresh.md new file mode 100644 index 000000000..69130c6bc --- /dev/null +++ b/.changeset/cursor-contract-refresh.md @@ -0,0 +1,19 @@ +--- +"agent-bundle": minor +--- + +Refresh the Cursor plugin contract against the public docs retrieved 2026-09-02 +(#189). Cursor `subagentStart`/`subagentStop` now validate their documented +`subagent_*` envelopes and project through their real output channels +(`permission: "deny"` + `user_message`; `followup_message`), with wrapper, +playground, and lifecycle-replay fixtures. A new `cursor.*` config extension +emits schema-admitted manifest metadata (`author`, `homepage`, `repository`, +`license`, `keywords`, `publisher`, `category`, `tags`, `minClientVersions`) +into `.cursor-plugin/plugin.json` with `cursor.manifest.*` diagnostics. The +Cursor capability table records every documented hook event (21) with cloud +availability, hook options (`failClosed`, `loop_limit`, prompt hooks), plugin +formats, component discovery, variables, marketplace manifest limits, team +distribution and local-install surfaces, canvases, and the G5-gated agents +component as dated `supported`/`unavailable` rows; the unified bundle mirrors +each row and now intersects Cursor manifest metadata for real. Cursor +`adapterRevision` 1.8.0 → 1.9.0, unified plugin 1.22.0 → 1.23.0. diff --git a/README.md b/README.md index 707137197..b5199ab46 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,8 @@ Claude Code language servers are declared under `claude.lspServers`; the `claude Claude Code plugin defaults are declared under `claude.settings` and emitted as plugin-root `settings.json`, which Claude Code applies when the plugin is enabled. The pinned contract supports only `agent` and `subagentStatusLine`; Agent Bundle rejects any other key rather than shipping a default Claude Code would silently ignore, and it expands no path tokens here because `settings.json` is absent from the host's placeholder-substitution table. Because the plugin `agents/` component is still deferred, declaring `agent` also raises a warning: the referenced agent has to reach the plugin root some other way, such as a prebuilt payload. +Cursor Plugin manifest metadata is declared under `cursor.*` and emitted verbatim into `.cursor-plugin/plugin.json` by the `cursor` target and the Cursor half of `plugin`: `author` (`name`, optional `email`), `homepage`, `repository`, `license`, `keywords`, plus the schema-admitted `publisher`, `category`, `tags`, and `minClientVersions` (for example `{ cursor: '3.13.0' }`). Every field is validated against the pinned `cursor/plugins` manifest schema before emission — `author.url`, non-HTTP URLs, empty strings, and loose semver are rejected with `cursor.manifest.*` errors, and an invalid block emits no partial metadata. The Cursor artifact never mixes Agent Plugin (`plugin.json`, `${PLUGIN_ROOT}`) paths or tokens into the Cursor Plugin format; the portable target owns that format. The full documented-surface contract matrix (every Cursor hook event, cloud availability, hook options, marketplace and team-distribution surfaces, canvases, agents) lives as dated `supported` / `unavailable` rows in `packages/agent-bundle/src/adapters/capabilities/cursor-2026-08-28.json`. + The same config also owns the npm package build — no second bundler config, bin shims, or hand-rolled stdio lifecycles. `bin` and `lib` entries (or the conventions `src/cli.ts`, `src/index.ts`, and `src/mcp/.ts`) emit executable `dist/bin/.js` bundles and a library output alongside the host artifacts; an MCP entry that default-exports a server factory runs under a framework-owned stdio lifecycle; `tools.rsbuild` / `tools.rspack` is the one bundler escape hatch. [Entry conventions](docs/entry-conventions.md) is the full contract, and [Framework mode](docs/framework-mode.md) is the whole authoring model on one screen: structure in config and conventions (`src/skills//SKILL.md` ships with no declaration at all), JSX only where something is rendered. ## Commands diff --git a/docs/canvases/agent-bundle-walkthrough.canvas.tsx b/docs/canvases/agent-bundle-walkthrough.canvas.tsx index dbf934a78..f347046d3 100644 --- a/docs/canvases/agent-bundle-walkthrough.canvas.tsx +++ b/docs/canvases/agent-bundle-walkthrough.canvas.tsx @@ -742,8 +742,8 @@ else try { output = await requestEventRuntime({ ... }); } [tool/before, "PreToolUse", "PreToolUse", "preToolUse", "—"], [tool/after, "PostToolUse", "PostToolUse", "postToolUse", "—"], [stop, "Stop", "Stop", "stop", "—"], - [agent/start, "SubagentStart", "SubagentStart (adds turn_id, model, permission_mode)", "subagentStart", "—"], - [agent/stop, "SubagentStop", "SubagentStop", "subagentStop", "—"], + [agent/start, "SubagentStart", "SubagentStart (adds turn_id, model, permission_mode)", "subagentStart (subagent_id/subagent_type envelope; permission deny)", "—"], + [agent/stop, "SubagentStop", "SubagentStop", "subagentStop (status/loop_count envelope; followup_message)", "—"], [workspace/open, "unavailable: no such event", "unavailable: no such event", "workspaceOpen (observe-only; optional pluginPaths return not modeled)", "unavailable: spec defines no hooks"], ]} /> @@ -758,8 +758,8 @@ else try { output = await requestEventRuntime({ ... }); } ["tool/before", "yes, reason required", "yes (when not denying)", "Claude + Codex; dropped on Cursor"], ["tool/after", "—", "—", "all hosts"], ["stop", "yes — keeps the agent working (Cursor: followup_message)", "—", "—"], - ["agent/start", "no host can block subagent creation", "—", "all hosts"], - ["agent/stop", "Claude + Codex (keeps subagent running); Cursor cannot", "—", "Claude + Cursor; Codex schema rejects it"], + ["agent/start", "Cursor only (permission: deny + user_message); Claude + Codex cannot", "—", "Claude + Codex; Cursor documents no channel"], + ["agent/stop", "Claude + Codex (decision: block keeps subagent running); Cursor (followup_message auto-continue)", "—", "Claude only; Codex schema and Cursor docs reject it"], ["workspace/open", "—", "—", "no channel — observation only (Cursor)"], ]} /> diff --git a/examples/rsc-agent-runtime/README.md b/examples/rsc-agent-runtime/README.md index a0cd3dab7..b6316366c 100644 --- a/examples/rsc-agent-runtime/README.md +++ b/examples/rsc-agent-runtime/README.md @@ -246,8 +246,8 @@ Host/Origin allowlists mitigate DNS rebinding and cross-origin requests, but the | `task/create` | Unavailable | `TaskCreated` (deny) | Unavailable | | `task/complete` | Unavailable | `TaskCompleted` (observe-only; blocking is exit-code-only) | Unavailable | | `agent/idle` | Unavailable | `TeammateIdle` (deny via continue:false) | Unavailable | -| `agent/start` | `subagentStart` | `SubagentStart` | `SubagentStart` | -| `agent/stop` | `subagentStop` | `SubagentStop` | `SubagentStop` | +| `agent/start` | `subagentStart` (deny via `permission: "deny"`; no context channel) | `SubagentStart` (context) | `SubagentStart` (context) | +| `agent/stop` | `subagentStop` (deny via `followup_message`; no context channel) | `SubagentStop` (deny + context) | `SubagentStop` (deny) | | `workspace/open` | Supported (observe-only; native `pluginPaths` return not modeled) | Unavailable | Unavailable | Cursor's native `workspaceOpen` is sessionless and its optional `pluginPaths` @@ -260,7 +260,15 @@ block subagent creation. Their `agent/stop` routes can continue the subagent with the native `decision: "block"` plus `reason` contract. Codex `SubagentStop` exit-0 output is always JSON; its generated 0.147.0 output schema has no `additionalContext` field, so the route projection rejects that -unsupported effect rather than silently fabricating one. +unsupported effect rather than silently fabricating one. Cursor inverts the +pair: its `subagentStart` envelope (`subagent_id`, `subagent_type`, `task`, +`parent_conversation_id`, `tool_call_id`, `subagent_model`, +`is_parallel_worker`) accepts `permission: "deny"` plus `user_message`, and its +`subagentStop` envelope (`status`, `loop_count`, `summary`, `modified_files`, +`agent_transcript_path`) accepts only `followup_message`, which Cursor consumes +when `status` is `completed` and caps with `loop_limit` (default 5). Neither +Cursor event documents an additional-context channel, so `Agent.Context` fails +closed on Cursor for both. `session/end` and `prompt/submit` are event-route-only families and do not add `config.hooks.sessionEnd` or `config.hooks.promptSubmit` handler keys. `session/end` rejects every result effect because each native event is diff --git a/packages/agent-bundle/README.md b/packages/agent-bundle/README.md index b46f56646..753d88898 100644 --- a/packages/agent-bundle/README.md +++ b/packages/agent-bundle/README.md @@ -671,7 +671,7 @@ observed host version (`observedVersion`) and target, while adapters carry a mon `adapterRevision`. Git already versions repository-owned content; hashing it again inside the repository is self-referential and causes churn on every table edit. -`AgentBundleConfig` merges bundled portable, Codex, and Claude declarations +`AgentBundleConfig` merges bundled portable, Codex, Claude, and Cursor declarations through `AgentBundleConfigExtensions`. `TargetRegistry` owns the unique extension descriptor and adapter for each target. Ordinary projects need no extension key; extension values are strict finite JSON and host-specific values 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 f63fb40cc..812dd82a6 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 @@ -4,7 +4,10 @@ "cliInstall": false, "evidence": [ "Cursor CLI exposes plugin marketplace management but no non-interactive plugin install verb.", - "Cursor documents physical local plugin copies under ~/.cursor/plugins/local/." + "Cursor documents physical local plugin copies under ~/.cursor/plugins/local/.", + "retrieved 2026-09-02: https://cursor.com/docs/plugins \"Test plugins locally\" documents copying either plugin format into ~/.cursor/plugins/local/, restarting Cursor or running Developer: Reload Window, and confirming components in Customize; discovery depends on Allow Local Plugin Imports (off by default on Enterprise) and a same-named marketplace install takes precedence over the local copy.", + "retrieved 2026-09-02: https://cursor.com/docs/plugins also documents `ln -s /path/to/my-plugin ~/.cursor/plugins/local/my-plugin` for faster iteration, while the loader observed on 2026-08-31 (server build 9746bf0) realpath-checks local-plugin symlinks and rejects targets outside ~/.cursor/plugins/local; the generated installer copies, which satisfies both sources.", + "2026-09-03: Cursor IDE 3.18.25 `Cursor Plugins` output channel logged loadUserLocalPlugin for each physical directory under ~/.cursor/plugins/local (cargo-hauler, lcm-cursor, tracedecay) and `loadUserLocalPlugins completed (3 plugins loaded)`; the directory scan is the observed discovery mechanism and no settings entry references them." ], "localRoot": "~/.cursor/plugins/local/", "method": "copy", @@ -14,52 +17,68 @@ }, "deferredNativeEvents": { "afterAgentResponse": { - "reason": "retrieved 2026-09-02 from https://cursor.com/docs/hooks: native/deferred until a canonical consumer is accepted (#258 defer list).", + "reason": "retrieved 2026-09-02 from https://cursor.com/docs/hooks: native/deferred until a canonical consumer is accepted (#258 defer list). Input is { text } with no output fields; matcher value AgentResponse; runs in cloud agents.", "state": "unavailable" }, "afterAgentThought": { - "reason": "retrieved 2026-09-02 from https://cursor.com/docs/hooks: native/deferred until a canonical consumer is accepted (#258 defer list).", + "reason": "retrieved 2026-09-02 from https://cursor.com/docs/hooks: native/deferred until a canonical consumer is accepted (#258 defer list). Input is { text, duration_ms? } with no output fields; matcher value AgentThought; runs in cloud agents.", "state": "unavailable" }, "afterFileEdit": { - "reason": "retrieved 2026-09-02 from https://cursor.com/docs/hooks: agent-edit variant overlapping canonical tool/after per the #258 selector rule; a dedicated family requires fixture evidence that the generic tool route loses information.", + "reason": "retrieved 2026-09-02 from https://cursor.com/docs/hooks: agent-edit variant overlapping canonical tool/after per the #258 selector rule (postToolUse with the ^Write$ matcher fires for the same edit); a dedicated family requires a canonical-vocabulary decision because the native input { file_path, edits[{ old_string, new_string }] } carries per-edit diffs the generic tool_output string does not, and no output fields are documented. Matcher is the tool type (TabWrite, Write); runs in cloud agents.", "state": "unavailable" }, "afterMCPExecution": { - "reason": "retrieved 2026-09-02 from https://cursor.com/docs/hooks: native variant of canonical tool/after with mcp selector per the #258 selector rule.", + "reason": "retrieved 2026-09-02 from https://cursor.com/docs/hooks: native variant of canonical tool/after with mcp selector per the #258 selector rule (postToolUse with the ^MCP: matcher). Native input adds mcp_server_name, mcp_server_url, result_json, and duration with no output fields; deferred while cloud agents cannot run it.", "state": "unavailable" }, "afterShellExecution": { - "reason": "retrieved 2026-09-02 from https://cursor.com/docs/hooks: native variant of canonical tool/after with shell selector per the #258 selector rule.", + "reason": "retrieved 2026-09-02 from https://cursor.com/docs/hooks: native variant of canonical tool/after with shell selector per the #258 selector rule (postToolUse with the ^Shell$ matcher). Native input is { command, output, duration, sandbox } with raw terminal output rather than the JSON-stringified tool_output, and no output fields; matcher is the full command string; runs in cloud agents.", "state": "unavailable" }, "afterTabFileEdit": { - "reason": "retrieved 2026-09-02 from https://cursor.com/docs/hooks: Tab-surface callback, native/deferred until a canonical consumer is accepted (#258 defer list).", + "reason": "retrieved 2026-09-02 from https://cursor.com/docs/hooks: Tab-surface callback, native/deferred until a canonical consumer is accepted (#258 defer list). Only Tab triggers it, edits carry range/old_line/new_line, no output fields, and Tab hooks do not run in cloud agents.", "state": "unavailable" }, "beforeMCPExecution": { - "reason": "retrieved 2026-09-02 from https://cursor.com/docs/hooks: native variant of canonical tool/before with mcp selector per the #258 selector rule.", + "reason": "retrieved 2026-09-02 from https://cursor.com/docs/hooks: native variant of canonical tool/before with mcp selector per the #258 selector rule (preToolUse with the ^MCP: matcher). Native input adds mcp_server_name plus url/mcp_server_url (HTTP) or command (stdio) and its output admits permission ask, which the canonical deny/continue vocabulary does not express; deferred while cloud agents cannot run it.", "state": "unavailable" }, "beforeReadFile": { - "reason": "retrieved 2026-09-02 from https://cursor.com/docs/hooks: native variant of canonical tool/before with file.read selector per the #258 selector rule.", + "reason": "retrieved 2026-09-02 from https://cursor.com/docs/hooks: native variant of canonical tool/before with file.read selector per the #258 selector rule (preToolUse with the ^Read$ matcher). Native input adds content and attachments and its output is { permission allow|deny, user_message }; failClosed is documented for it; matcher is the tool type (TabRead, Read); runs in cloud agents.", "state": "unavailable" }, "beforeShellExecution": { - "reason": "retrieved 2026-09-02 from https://cursor.com/docs/hooks: native variant of canonical tool/before with shell selector per the #258 selector rule.", - "state": "unavailable" - }, - "beforeSubmitPrompt-cloud": { - "reason": "retrieved 2026-09-02 from https://cursor.com/docs/hooks: cloud/desktop availability differences are capability dimensions; cloud rows stay scoped per surface rather than universal-support claims (#258 defer list).", + "reason": "retrieved 2026-09-02 from https://cursor.com/docs/hooks: native variant of canonical tool/before with shell selector per the #258 selector rule (preToolUse with the ^Shell$ matcher). Native input is { command, cwd, sandbox } and its output admits permission ask, which the canonical deny/continue vocabulary does not express; matcher is the full command string; runs in cloud agents.", "state": "unavailable" }, "beforeTabFileRead": { - "reason": "retrieved 2026-09-02 from https://cursor.com/docs/hooks: Tab-surface callback, native/deferred until a canonical consumer is accepted (#258 defer list).", + "reason": "retrieved 2026-09-02 from https://cursor.com/docs/hooks: Tab-surface callback, native/deferred until a canonical consumer is accepted (#258 defer list). Only Tab triggers it, there is no attachments field, output is { permission allow|deny }, and Tab hooks do not run in cloud agents.", "state": "unavailable" } }, "hooks": { "config": "hooks/hooks.json", + "cloud": { + "configurationSources": ["project", "team", "enterprise"], + "evidence": [ + "retrieved 2026-09-02: https://cursor.com/docs/hooks documents cloud agents loading project .cursor/hooks.json, Enterprise team hooks, and Enterprise system-wide hooks; user-level ~/.cursor/hooks.json is not available because cloud VMs lack the local home directory.", + "retrieved 2026-09-02: https://cursor.com/docs/hooks documents that cloud agents run command-based hooks only, that hooks do not run during read-only exploratory turns, and the per-event cloud availability table mirrored in hooks.nativeEvents.", + "retrieved 2026-09-02: https://cursor.com/docs/hooks lists no plugin-distributed hooks/hooks.json among the cloud configuration sources, so every cloud row below describes the native event, not a delivery guarantee for this plugin's hooks." + ], + "executionTypes": ["command"], + "pluginHooks": { + "reason": "retrieved 2026-09-02: https://cursor.com/docs/hooks lists project, team, and enterprise hooks.json as the cloud configuration sources; a plugin's hooks/hooks.json is not a documented cloud source, so this compiler makes no cloud-delivery claim for generated plugin hooks.", + "state": "unavailable" + } + }, + "environment": { + "alwaysPresent": ["CURSOR_PROJECT_DIR", "CURSOR_VERSION", "CLAUDE_PROJECT_DIR"], + "conditional": ["CURSOR_USER_EMAIL", "CURSOR_TRANSCRIPT_PATH", "CURSOR_CODE_REMOTE"], + "evidence": [ + "retrieved 2026-09-02: https://cursor.com/docs/hooks documents CURSOR_PROJECT_DIR, CURSOR_VERSION, and the CLAUDE_PROJECT_DIR alias as always present, CURSOR_USER_EMAIL when logged in, CURSOR_TRANSCRIPT_PATH when transcripts are enabled, CURSOR_CODE_REMOTE=\"true\" in remote workspaces, and sessionStart env output propagated to later hooks in the session. CLAUDE_PLUGIN_ROOT and CLAUDE_PLUGIN_DATA are not documented, so the composite bundle keeps those compatibility variables unavailable on Cursor." + ] + }, "events": { "agentStart": "subagentStart", "agentStop": "subagentStop", @@ -73,13 +92,25 @@ "reason": "retrieved 2026-09-02: the complete https://cursor.com/docs/hooks event inventory documents no teammate-idle event.", "state": "unavailable" }, - "agent/start": { "nativeEvent": "subagentStart", "state": "supported" }, - "agent/stop": { "nativeEvent": "subagentStop", "state": "supported" }, + "agent/start": { + "availability": { "cloud": { "state": "supported" }, "desktop": { "state": "supported" } }, + "nativeEvent": "subagentStart", + "state": "supported" + }, + "agent/stop": { + "availability": { "cloud": { "state": "supported" }, "desktop": { "state": "supported" } }, + "nativeEvent": "subagentStop", + "state": "supported" + }, "compact/after": { "reason": "retrieved 2026-09-02: the complete https://cursor.com/docs/hooks event inventory documents no postCompact event.", "state": "unavailable" }, - "compact/before": { "nativeEvent": "preCompact", "state": "supported" }, + "compact/before": { + "availability": { "cloud": { "state": "supported" }, "desktop": { "state": "supported" } }, + "nativeEvent": "preCompact", + "state": "supported" + }, "config/change": { "reason": "retrieved 2026-09-02: the complete https://cursor.com/docs/hooks event inventory documents no configChange event.", "state": "unavailable" @@ -96,7 +127,11 @@ "reason": "retrieved 2026-09-02: https://cursor.com/docs/hooks documents no standalone permission-request event; pre-tool permission control is beforeShellExecution/beforeMCPExecution and is not inferred as this family per #258.", "state": "unavailable" }, - "prompt/submit": { "nativeEvent": "beforeSubmitPrompt", "state": "supported" }, + "prompt/submit": { + "availability": { "cloud": { "state": "supported" }, "desktop": { "state": "supported" } }, + "nativeEvent": "beforeSubmitPrompt", + "state": "supported" + }, "session/end": { "availability": { "cloud": { @@ -108,8 +143,22 @@ "nativeEvent": "sessionEnd", "state": "supported" }, - "session/start": { "nativeEvent": "sessionStart", "state": "supported" }, - "stop": { "nativeEvent": "stop", "state": "supported" }, + "session/start": { + "availability": { + "cloud": { + "reason": "retrieved 2026-09-02: https://cursor.com/docs/hooks defers cloud sessionStart while cloud agents can begin in a read-only environment where hooks do not load, so it would fire too late; Self-Hosted Machines workers fire it when a session claims the worker.", + "state": "unavailable" + }, + "desktop": { "state": "supported" } + }, + "nativeEvent": "sessionStart", + "state": "supported" + }, + "stop": { + "availability": { "cloud": { "state": "supported" }, "desktop": { "state": "supported" } }, + "nativeEvent": "stop", + "state": "supported" + }, "stop/failure": { "reason": "retrieved 2026-09-02: the complete https://cursor.com/docs/hooks event inventory documents no stop-failure event.", "state": "unavailable" @@ -122,10 +171,32 @@ "reason": "retrieved 2026-09-02: the complete https://cursor.com/docs/hooks event inventory documents no taskCreated event.", "state": "unavailable" }, - "tool/after": { "nativeEvent": "postToolUse", "state": "supported" }, - "tool/before": { "nativeEvent": "preToolUse", "state": "supported" }, - "tool/failure": { "nativeEvent": "postToolUseFailure", "state": "supported" }, - "workspace/open": { "nativeEvent": "workspaceOpen", "state": "supported" } + "tool/after": { + "availability": { "cloud": { "state": "supported" }, "desktop": { "state": "supported" } }, + "nativeEvent": "postToolUse", + "state": "supported" + }, + "tool/before": { + "availability": { "cloud": { "state": "supported" }, "desktop": { "state": "supported" } }, + "nativeEvent": "preToolUse", + "state": "supported" + }, + "tool/failure": { + "availability": { "cloud": { "state": "supported" }, "desktop": { "state": "supported" } }, + "nativeEvent": "postToolUseFailure", + "state": "supported" + }, + "workspace/open": { + "availability": { + "cloud": { + "reason": "retrieved 2026-09-02: https://cursor.com/docs/hooks states workspaceOpen is an IDE lifecycle hook that does not apply to cloud agents.", + "state": "unavailable" + }, + "desktop": { "state": "supported" } + }, + "nativeEvent": "workspaceOpen", + "state": "supported" + } }, "matchers": { "agent": "^Task$", @@ -133,6 +204,71 @@ "file.write": "^Write$", "mcp": "^MCP:", "shell": "^Shell$" + }, + "nativeEvents": { + "afterAgentResponse": { "canonical": null, "category": "agent", "cloud": "supported", "matcher": "literal AgentResponse", "outputFields": [], "row": "deferredNativeEvents.afterAgentResponse" }, + "afterAgentThought": { "canonical": null, "category": "agent", "cloud": "supported", "matcher": "literal AgentThought", "outputFields": [], "row": "deferredNativeEvents.afterAgentThought" }, + "afterFileEdit": { "canonical": null, "category": "agent", "cloud": "supported", "matcher": "tool type (TabWrite, Write)", "outputFields": [], "row": "deferredNativeEvents.afterFileEdit" }, + "afterMCPExecution": { "canonical": null, "category": "agent", "cloud": "unavailable", "matcher": null, "outputFields": [], "row": "deferredNativeEvents.afterMCPExecution" }, + "afterShellExecution": { "canonical": null, "category": "agent", "cloud": "supported", "matcher": "full command string", "outputFields": [], "row": "deferredNativeEvents.afterShellExecution" }, + "afterTabFileEdit": { "canonical": null, "category": "tab", "cloud": "unavailable", "matcher": null, "outputFields": [], "row": "deferredNativeEvents.afterTabFileEdit" }, + "beforeMCPExecution": { "canonical": null, "category": "agent", "cloud": "unavailable", "matcher": null, "outputFields": ["permission", "user_message", "agent_message"], "row": "deferredNativeEvents.beforeMCPExecution" }, + "beforeReadFile": { "canonical": null, "category": "agent", "cloud": "supported", "matcher": "tool type (TabRead, Read)", "outputFields": ["permission", "user_message"], "row": "deferredNativeEvents.beforeReadFile" }, + "beforeShellExecution": { "canonical": null, "category": "agent", "cloud": "supported", "matcher": "full command string", "outputFields": ["permission", "user_message", "agent_message"], "row": "deferredNativeEvents.beforeShellExecution" }, + "beforeSubmitPrompt": { "canonical": "prompt/submit", "category": "agent", "cloud": "supported", "matcher": "literal UserPromptSubmit", "outputFields": ["continue", "user_message"], "row": "eventRoutes.prompt/submit" }, + "beforeTabFileRead": { "canonical": null, "category": "tab", "cloud": "unavailable", "matcher": null, "outputFields": ["permission"], "row": "deferredNativeEvents.beforeTabFileRead" }, + "postToolUse": { "canonical": "tool/after", "category": "agent", "cloud": "supported", "matcher": "tool type (Shell, Read, Write, Grep, Delete, Task, MCP:)", "outputFields": ["updated_mcp_tool_output", "additional_context"], "row": "eventRoutes.tool/after" }, + "postToolUseFailure": { "canonical": "tool/failure", "category": "agent", "cloud": "supported", "matcher": "tool type (Shell, Read, Write, Grep, Delete, Task, MCP:)", "outputFields": [], "row": "eventRoutes.tool/failure" }, + "preCompact": { "canonical": "compact/before", "category": "agent", "cloud": "supported", "matcher": null, "outputFields": ["user_message"], "row": "eventRoutes.compact/before" }, + "preToolUse": { "canonical": "tool/before", "category": "agent", "cloud": "supported", "matcher": "tool type (Shell, Read, Write, Grep, Delete, Task, MCP:)", "outputFields": ["permission", "user_message", "agent_message", "updated_input"], "row": "eventRoutes.tool/before" }, + "sessionEnd": { "canonical": "session/end", "category": "agent", "cloud": "unavailable", "matcher": null, "outputFields": [], "row": "eventRoutes.session/end" }, + "sessionStart": { "canonical": "session/start", "category": "agent", "cloud": "unavailable", "matcher": null, "outputFields": ["env", "additional_context"], "row": "eventRoutes.session/start" }, + "stop": { "canonical": "stop", "category": "agent", "cloud": "supported", "matcher": "literal Stop", "outputFields": ["followup_message"], "row": "eventRoutes.stop" }, + "subagentStart": { "canonical": "agent/start", "category": "agent", "cloud": "supported", "matcher": "subagent type (generalPurpose, explore, shell, ...)", "outputFields": ["permission", "user_message"], "row": "eventRoutes.agent/start" }, + "subagentStop": { "canonical": "agent/stop", "category": "agent", "cloud": "supported", "matcher": "subagent type (generalPurpose, explore, shell, ...)", "outputFields": ["followup_message"], "row": "eventRoutes.agent/stop" }, + "workspaceOpen": { "canonical": "workspace/open", "category": "app", "cloud": "unavailable", "matcher": null, "outputFields": ["pluginPaths"], "row": "eventRoutes.workspace/open" } + }, + "options": { + "command": { + "evidence": [ + "retrieved 2026-09-02: https://cursor.com/docs/hooks documents command hooks as the default type, receiving JSON on stdin and returning JSON on stdout; exit 0 uses the JSON, exit 2 blocks the action, and other exit codes fail open. Generated Cursor wrappers signal deny through the documented JSON channels and never rely on exit 2; a wrapper validation failure exits 1, which Cursor treats as fail-open unless failClosed is set." + ], + "state": "supported" + }, + "failClosed": { + "evidence": [ + "retrieved 2026-09-02: https://cursor.com/docs/hooks documents failClosed (default false) so hook crashes, timeouts, and invalid JSON block the action instead of allowing it, recommended for security-critical beforeMCPExecution and beforeReadFile hooks; the pinned hooks.schema.json admits it." + ], + "reason": "Agent Bundle's canonical hook vocabulary has no fail-closed authoring field, so generated entries omit failClosed and inherit Cursor's fail-open default; a wrapper that exits 1 on a malformed envelope therefore lets the action proceed.", + "state": "unavailable" + }, + "loopLimit": { + "evidence": [ + "retrieved 2026-09-02: https://cursor.com/docs/hooks documents loop_limit (default 5, null for no limit) as the per-script cap on stop and subagentStop followup_message auto-continuations; the pinned hooks.schema.json admits it." + ], + "reason": "Agent Bundle's canonical hook vocabulary has no loop-limit authoring field, so generated stop and subagentStop entries omit loop_limit and inherit Cursor's default of 5 auto follow-ups per script.", + "state": "unavailable" + }, + "matcher": { + "evidence": [ + "retrieved 2026-09-02: https://cursor.com/docs/hooks documents matcher as a per-hook filter whose subject depends on the event: tool type for preToolUse/postToolUse/postToolUseFailure (Shell, Read, Write, Grep, Delete, Task, MCP:), subagent type for subagentStart/subagentStop, the full command string for beforeShellExecution/afterShellExecution, tool type for beforeReadFile (TabRead, Read) and afterFileEdit (TabWrite, Write), and literal values UserPromptSubmit, Stop, AgentResponse, and AgentThought for beforeSubmitPrompt, stop, afterAgentResponse, and afterAgentThought.", + "retrieved 2026-09-02: canonical tool selectors lower to the pinned tool-type matchers (^Shell$, ^Read$, ^Write$, ^MCP:, ^Task$) on preToolUse/postToolUse; the `cursor:` selector escape hatch admits Grep and Delete or a specific MCP:. Selectors on subagentStart/subagentStop would match subagent types, not tool names, so authors must not attach tool selectors to agentStart/agentStop hooks." + ], + "state": "supported" + }, + "prompt": { + "evidence": [ + "retrieved 2026-09-02: https://cursor.com/docs/hooks documents prompt hooks ({ type: \"prompt\", prompt, timeout?, model? }) evaluated by a fast LLM returning { ok, reason? } with $ARGUMENTS substitution; cloud agents run command hooks only. The pinned hooks.schema.json admits prompt entries." + ], + "reason": "Agent Bundle compiles handler modules into command hooks; it has no authoring surface for LLM-evaluated prompt hooks, and cloud agents cannot run them.", + "state": "unavailable" + }, + "timeout": { + "evidence": [ + "retrieved 2026-09-02: https://cursor.com/docs/hooks documents timeout in seconds with a platform default; canonical timeoutMs lowers to ceil(seconds) on every generated entry that declares one." + ], + "state": "supported" + } } }, "mcp": { @@ -181,16 +317,168 @@ }, "observedCliVersion": "2026-08-28", "plugin": { + "agents": { + "component": { + "evidence": [ + "retrieved 2026-09-02: https://cursor.com/docs/reference/plugins documents agents as Markdown (.md, .mdc, .markdown) files in the plugin-root agents/ directory with name and description frontmatter, discovered by folder or replaced by the manifest agents path field; https://cursor.com/docs/plugins lists Agents as a Cursor Plugins-only component.", + "retrieved 2026-09-02: https://github.com/ScriptedAlchemy/agent-bundle/pull/220 and https://github.com/ScriptedAlchemy/agent-bundle/issues/107 revision 3 record the #100 stage-2 G5 narrowing: re-admitting agents requires its own evidence-backed increment, so this compiler emits no agents/ directory or manifest agents pointer on Cursor either." + ], + "reason": "The #100 stage-2 G5 gate, merged in PR #220 and preserved by #107 revision 3, deliberately defers Agent Bundle's authored plugin agents/ component; Cursor documents the component, but this compiler emits no agents/ directory or manifest agents pointer until a later stage admits it.", + "state": "unavailable" + } + }, + "canvases": { + "evidence": [ + "retrieved 2026-09-02: https://cursor.com/docs/plugins \"Cursor Plugin canvases\" advertises prebuilt canvases (Hex Canvas, Atlassian Canvas) opened from an installed plugin in Customize, but neither that page nor https://cursor.com/docs/reference/plugins defines a canvas component field, file layout, or schema, and the pinned cursor/plugins@0701892 plugin.schema.json has no canvas property." + ], + "reason": "Cursor advertises plugin canvases without publishing a schema-backed authoring contract; no canvas component is inferred from marketplace examples, so this compiler emits none.", + "state": "unavailable" + }, "commands": true, - "manifest": ".cursor-plugin/plugin.json", - "rules": true, - "skills": true, + "componentDiscovery": { + "defaults": { + "agents": "agents/ (.md, .mdc, .markdown)", + "commands": "commands/ (.md, .mdc, .markdown, .txt)", + "hooks": "hooks/hooks.json", + "mcpServers": "mcp.json", + "rootSkill": "SKILL.md at the plugin root (only without skills/ and without a manifest skills field)", + "rules": "rules/ (.md, .mdc, .markdown)", + "skills": "skills//SKILL.md" + }, + "emitted": { + "commands": "./commands/", + "hooks": "./hooks/hooks.json", + "mcpServers": "./mcp.json", + "rules": "./rules/", + "skills": "./skills/" + }, + "evidence": [ + "retrieved 2026-09-02: https://cursor.com/docs/reference/plugins documents folder-based discovery defaults (skills/ subdirectories with SKILL.md; rules/ and agents/ .md/.mdc/.markdown; commands/ .md/.mdc/.markdown/.txt; hooks/hooks.json; mcp.json; root SKILL.md single-skill fallback) and that a manifest field replaces folder discovery for that component so the default folder is not also scanned.", + "retrieved 2026-09-02: the generated .cursor-plugin/plugin.json points every emitted component at its default location (./skills/, ./rules/, ./commands/, ./hooks/hooks.json, ./mcp.json), so replacement and discovery resolve to the same files; emitted rules are .mdc and commands are .md, subsets of the documented suffixes." + ], + "replacement": "manifest-path-replaces-default-scan", + "state": "supported" + }, + "contract": "cursor-plugin", + "distributionPolicy": { + "autoRefresh": { + "evidence": [ + "retrieved 2026-09-02: https://cursor.com/docs/plugins documents Enable Auto Refresh re-indexing a GitHub-imported team marketplace at most once every 10 minutes on pushes to the tracked branch (requires the Cursor GitHub App), full-manifest re-reads for Import from Repo marketplaces, existing-plugin-only refresh for individually added plugins, and a manual Refresh button." + ], + "reason": "Agent Bundle emits marketplace documents but does not configure dashboard Auto Refresh, install the Cursor GitHub App, or trigger manual re-indexing.", + "state": "unavailable" + }, + "installModes": { + "evidence": [ + "retrieved 2026-09-02: https://cursor.com/docs/plugins documents team-marketplace plugin installation modes Default Off (developers choose), Default On (installed by default, opt-out allowed), and Required (always installed, cannot be uninstalled), chosen per plugin by admins after setting marketplace access." + ], + "modes": ["Default Off", "Default On", "Required"], + "reason": "Installation modes are set per plugin by team admins in Dashboard -> Plugins; no plugin manifest or marketplace field declares them, so this compiler emits nothing for them.", + "state": "unavailable" + }, + "localPluginImports": { + "evidence": [ + "retrieved 2026-09-02: https://cursor.com/docs/plugins documents that ~/.cursor/plugins/local discovery depends on Allow Local Plugin Imports under Dashboard -> Settings -> Security & Identity -> Marketplace and Plugins on Teams and Enterprise, off by default on Enterprise." + ], + "reason": "Agent Bundle's installer copies into ~/.cursor/plugins/local but cannot grant the admin-controlled Allow Local Plugin Imports setting a Teams or Enterprise user needs before Cursor discovers the copy.", + "state": "unavailable" + }, + "localSymlinkInstall": { + "evidence": [ + "retrieved 2026-09-02: https://cursor.com/docs/plugins documents `ln -s /path/to/my-plugin ~/.cursor/plugins/local/my-plugin` for faster local iteration.", + "2026-08-31: the installed cursor-agent-exec loader (server build 9746bf0) realpath-checked local-plugin symlinks and rejected `symlink target ... is outside ~/.cursor/plugins/local` with only a warn-level log (#127); the #327 built-artifact validator mirrors that containment rule inside the bundle.", + "2026-09-03: Cursor IDE 3.18.25 loaded three physical local plugin directories; no symlinked entry was present to re-test the rejection." + ], + "reason": "The generated installer and doctor guidance copy the built bundle instead of symlinking it: Cursor's docs now show a symlink, but the observed loader rejected symlinks resolving outside ~/.cursor/plugins/local, and a copy loads under both readings.", + "state": "unavailable" + }, + "marketplaceAccess": { + "evidence": [ + "retrieved 2026-09-02: https://cursor.com/docs/plugins documents team marketplaces (1 on Teams, unlimited on Enterprise) available to the whole team by default, restrictable to selected Organization Groups (SCIM-synced) under Marketplace Settings -> Marketplace Access, with Enterprise admins alone adding marketplaces." + ], + "reason": "Marketplace scope and access are dashboard settings on the team's marketplace, not plugin-authored fields; this compiler emits nothing for them.", + "state": "unavailable" + }, + "marketplaceReview": { + "evidence": [ + "retrieved 2026-09-02: https://cursor.com/docs/plugins and https://cursor.com/docs/reference/plugins document that official marketplace plugins are Git repositories submitted at cursor.com/marketplace/publish, manually reviewed before listing and on each update, must be open source, and follow a submission checklist (valid manifest, unique kebab-case name, description, valid component frontmatter, committed relative-path logo, README.md, declared variables, relative manifest paths, local testing, unique names in .cursor-plugin/marketplace.json)." + ], + "reason": "Agent Bundle emits a checklist-conformant artifact (manifest, relative pointers, declared variables, copied logo) but does not host it in a Git repository, submit it, or complete Cursor's manual review.", + "state": "unavailable" + }, + "teamMarketplaces": { + "evidence": [ + "retrieved 2026-09-02: https://cursor.com/docs/plugins documents team marketplaces distributing Agent Plugins and Cursor Plugins through Dashboard -> Plugins (Add Marketplace, Import from Repo, Add to Marketplace), the Default team marketplace linking Team MCP servers, and the warning that removing a linked MCP plugin or the marketplace can delete the Team MCP server." + ], + "reason": "Team marketplaces are created and populated in the Cursor dashboard from a Git repository; this compiler emits the repository-side .cursor-plugin/marketplace.json only.", + "state": "unavailable" + } + }, + "formats": { + "agentPlugin": { + "evidence": [ + "retrieved 2026-09-02: https://cursor.com/docs/plugins and https://cursor.com/docs/reference/plugins document Agent Plugins as a root plugin.json with the agent-plugins.org 1.0.0 schema identifier packaging skills and mcp.json, loaded by Cursor without changes and distributable through the same marketplaces and ~/.cursor/plugins/local.", + "retrieved 2026-09-02: the portable target owns that format (portable-1.0.0.json); the cursor target rejects ${PLUGIN_ROOT}/${PLUGIN_DATA} tokens (cursor.mcp.token) so no artifact mixes Agent Plugin paths or tokens into a Cursor Plugin." + ], + "manifest": "plugin.json", + "reason": "The cursor target emits the full Cursor Plugin format only; the portable Agent Plugin format is emitted by the portable target and is never mixed into this artifact.", + "state": "unavailable" + }, + "cursorPlugin": { + "evidence": [ + "retrieved 2026-09-02: https://cursor.com/docs/reference/plugins documents Cursor Plugins by their .cursor-plugin/plugin.json manifest with skills, MCP servers, rules, agents, commands, hooks, and variables, developed in parallel with the Agent Plugins standard." + ], + "manifest": ".cursor-plugin/plugin.json", + "state": "supported" + } + }, "localInstall": { "method": "copy", "root": "~/.cursor/plugins/local/" }, - "contract": "cursor-plugin", - "schema": "cursor/plugins@070189284e702e8a4d2e3cc8913994b204c5337a:schemas/plugin.schema.json" + "manifest": ".cursor-plugin/plugin.json", + "manifestMetadata": { + "authoredFields": ["author.name", "author.email", "homepage", "repository", "license", "keywords", "publisher", "category", "tags", "minClientVersions"], + "evidence": [ + "retrieved 2026-09-02: https://cursor.com/docs/reference/plugins documents .cursor-plugin/plugin.json requiring only name (lowercase kebab-case alphanumerics, hyphens, periods; must start and end alphanumeric) with optional description, version, author { name, email? }, homepage, repository, license, keywords, logo, component paths, hooks, mcpServers, and variables; author.url is not admitted by the pinned schema and is rejected.", + "retrieved 2026-09-02: the pinned cursor/plugins@0701892 plugin.schema.json additionally admits displayName, publisher, category, tags, and minClientVersions ({ cursor: semver, ... }); the current public reference documents category and tags only as marketplace-entry fields and omits displayName, publisher, and minClientVersions, so those are emitted only when authored under cursor.* and are recorded here as schema-admitted rather than reference-documented.", + "retrieved 2026-09-02: generated fields are name, displayName (schema-admitted, falls back to name), description, version, logo, component pointers, and variables; author, homepage, repository, license, keywords, publisher, category, tags, and minClientVersions come from the cursor.* config extension and are emitted verbatim when valid." + ], + "generatedFields": ["name", "displayName", "description", "version", "logo", "commands", "hooks", "mcpServers", "rules", "skills", "variables"], + "schemaOnlyFields": ["displayName", "publisher", "category", "tags", "minClientVersions"], + "state": "supported" + }, + "marketplaceManifest": { + "entryFields": ["name", "source", "description", "version", "author", "homepage", "repository", "license", "keywords", "logo", "category", "tags", "skills", "rules", "agents", "commands", "hooks", "mcpServers", "variables"], + "evidence": [ + "retrieved 2026-09-02: https://cursor.com/docs/reference/plugins documents .cursor-plugin/marketplace.json at the repository root with required name (kebab-case), owner { name, email? }, and plugins (max 500 entries) plus optional metadata { description, version, pluginRoot }; entries carry name, source (path or object with path and options), description, version, author, homepage, repository, license, keywords, logo, category, tags, component paths, hooks, mcpServers, and variables (manifest values take precedence when both set).", + "retrieved 2026-09-02: https://cursor.com/docs/reference/plugins documents resolution: the parser looks for /.cursor-plugin/plugin.json, merges it with the marketplace entry with manifest values taking precedence, and runs component discovery inside the source directory.", + "retrieved 2026-09-02: the generated document is the pinned-schema-valid single-entry form { name: -marketplace, owner: { name }, plugins: [{ name, source: './', description }] }; it emits no entry component overrides because the per-plugin manifest is authoritative under the documented precedence, so the emitted entry count (1) is within the 500 limit by construction." + ], + "generatedEntryFields": ["name", "source", "description"], + "maxEntries": 500, + "mergePrecedence": "plugin-manifest-over-marketplace-entry", + "state": "supported" + }, + "rootSkill": { + "evidence": [ + "retrieved 2026-09-02: https://cursor.com/docs/reference/plugins documents a plugin-root SKILL.md as a single-skill plugin only when there is no skills/ directory and no manifest skills field." + ], + "reason": "Agent Bundle always emits skills under skills//SKILL.md with an explicit manifest skills pointer, so the root SKILL.md single-skill fallback is never produced.", + "state": "unavailable" + }, + "rules": true, + "schema": "cursor/plugins@070189284e702e8a4d2e3cc8913994b204c5337a:schemas/plugin.schema.json", + "skills": true, + "variables": { + "acceptedKeywords": ["type", "title", "description", "default", "enum", "const", "properties", "required", "items", "length and numeric constraints"], + "evidence": [ + "retrieved 2026-09-02: https://cursor.com/docs/reference/plugins documents variables as a JSON Schema whose top level must be { type: \"object\", properties: {...} } with a fixed accepted keyword set (type, title, description, default, enum, const, properties, required, items, common length/numeric constraints); values are set by admins in Dashboard -> Plugins -> Configure and substituted into ${VAR} placeholders; the plugin never stores secret values, and the submission checklist requires every ${VAR} used by mcp.json to be declared.", + "retrieved 2026-09-02: the generated manifest derives variables from every ${VAR} placeholder in the emitted mcp.json (excluding the built-in CURSOR_PLUGIN_ROOT/CLAUDE_PLUGIN_ROOT tokens) as { type: \"string\" } properties under { type: \"object\" }, emits no values, and omits the block when no placeholder is used; required, title, description, default, and enum are not authored." + ], + "secretValuesEmitted": false, + "state": "supported" + } }, "tokens": { "pluginRoot": "${CURSOR_PLUGIN_ROOT}", @@ -218,7 +506,12 @@ "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.", "2026-09-02: local cursor-agent 2026.08.31-4057e58 capture attempts for postToolUseFailure and preCompact could not dispatch because the binary required unavailable authentication; the pinned official contract is the evidence source.", - "2026-09-01: https://cursor.com/docs/plugins and https://prod.cursor.com/docs/reference/plugins document agents as a full Cursor Plugin component alongside rules and commands; #100 stage 2 defers the agents component per the G5 narrowing in #107, so no agents capability row is published until a later stage admits it." + "2026-09-01: https://cursor.com/docs/plugins and https://prod.cursor.com/docs/reference/plugins document agents as a full Cursor Plugin component alongside rules and commands; #100 stage 2 defers the agents component per the G5 narrowing in #107, so the plugin.agents.component row is unavailable with that gate as its reason.", + "retrieved 2026-09-02: https://cursor.com/docs/hooks documents subagentStart input as subagent_id, subagent_type, task, parent_conversation_id, tool_call_id, subagent_model, is_parallel_worker, and optional git_branch (plus the common envelope), with output { permission allow|deny, user_message }; ask is treated as deny. The canonical agent/start deny therefore lowers to permission deny with user_message on Cursor, unlike Claude and Codex where SubagentStart cannot block; Cursor documents no additional-context channel for subagentStart, so Agent.Context fails closed there.", + "retrieved 2026-09-02: https://cursor.com/docs/hooks documents subagentStop input as subagent_type, status (completed|error|aborted), task, description, summary, duration_ms, message_count, tool_call_count, loop_count, modified_files, and agent_transcript_path (string|null), with output { followup_message } consumed only when status is completed and capped by loop_limit (default 5). The canonical agent/stop deny lowers to followup_message with the reason, mirroring the stop family; no additional-context channel is documented, so Agent.Context fails closed.", + "retrieved 2026-09-02: https://cursor.com/docs/hooks documents the common input envelope (conversation_id, generation_id, model, model_id?, model_params?, hook_event_name, cursor_version, workspace_roots, user_email, transcript_path) for every agent hook; generated wrappers require session_id or conversation_id plus the per-event fields and reject anything else only when documented types are violated.", + "retrieved 2026-09-02: https://cursor.com/docs/hooks documents preToolUse output permission allow|deny (ask accepted by the schema but not enforced), user_message, agent_message, and updated_input; postToolUse output updated_mcp_tool_output (MCP tools only) and additional_context; sessionStart output env and additional_context (continue/user_message accepted but unenforced); stop output followup_message. The Cursor projections emit exactly those channels.", + "2026-09-02: local cursor-agent 2026.08.31-4057e58 capture attempts for subagentStart and subagentStop could not dispatch because the binary required unavailable authentication; the pinned official contract plus the wrapper fixture replay in tests are the evidence source for the subagent lifecycle envelopes." ] } } diff --git a/packages/agent-bundle/src/adapters/cursor.ts b/packages/agent-bundle/src/adapters/cursor.ts index cb6cec4ea..675d34358 100644 --- a/packages/agent-bundle/src/adapters/cursor.ts +++ b/packages/agent-bundle/src/adapters/cursor.ts @@ -1,4 +1,5 @@ import { createTargetDiagnostics } from './diagnostics.ts'; +import type { CapabilityEvidence, CapabilityState } from '../core/capabilities.ts'; import type { Diagnostic } from '../core/diagnostics.ts'; import { readMcpTransport, unsupportedMcpTransportDiagnostic } from '../core/mcp-transport.ts'; import { isPlainDataRecord, ownDataValue } from '../core/strict-json.ts'; @@ -19,6 +20,7 @@ import { eventRouteCapabilitiesFrom, supportedEventRouteNamesFrom, supportedCapability, + unavailableCapability, } from './capability-state.ts'; import capabilityTable from './capabilities/cursor-2026-08-28.json' with { type: 'json' }; import { @@ -56,6 +58,40 @@ import { withInstallSurface } from '../install/surface.ts'; const cursorName = 'cursor'; +export interface CursorAuthorConfig { + readonly email?: string; + readonly name: string; +} + +/** + * Cursor-only authored manifest metadata layered onto the generated + * `.cursor-plugin/plugin.json`. Every field is admitted by the pinned + * cursor/plugins@0701892 plugin schema; `author.url` is not (the schema's + * author object is closed), and Cursor documents no `nativeHooks` surface. + */ +export interface CursorHostConfig { + readonly author?: CursorAuthorConfig; + readonly category?: string; + readonly homepage?: string; + readonly keywords?: readonly string[]; + readonly license?: string; + /** Minimum client versions keyed by client identifier, e.g. `{ cursor: '3.13.0' }`. */ + readonly minClientVersions?: Readonly>; + readonly publisher?: string; + readonly repository?: string; + readonly tags?: readonly string[]; +} + +export interface CursorConfigExtension { + cursor?: CursorHostConfig; +} + +declare module '../core/types.ts' { + interface AgentBundleConfigExtensions { + cursor?: CursorHostConfig; + } +} + /** * Cursor's local-plugin document paths, shared with the unified bundle * adapter. A known-loading physical install uses `.cursor-plugin/plugin.json` @@ -254,11 +290,155 @@ export interface CursorManifestPointers { readonly variables?: Record; } +const isNonemptyString = (value: unknown): value is string => + typeof value === 'string' && value.trim().length > 0; + +const isAbsoluteUrl = (value: unknown): value is string => { + if (!isNonemptyString(value)) return false; + try { + const url = new URL(value); + return url.protocol === 'http:' || url.protocol === 'https:'; + } catch { + return false; + } +}; + +const isEmail = (value: unknown): value is string => + isNonemptyString(value) && /^[^\s@]+@[^\s@]+\.[^\s@]+$/u.test(value); + +const isNonemptyStringArray = (value: unknown): value is readonly string[] => + Array.isArray(value) && value.every(isNonemptyString); + +/** The pinned schema's strict `X.Y.Z[-prerelease]` semver for `minClientVersions` values. */ +const cursorSemverPattern = + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?$/u; + +const cursorManifestMetadataFields = Object.freeze([ + 'author', + 'category', + 'homepage', + 'keywords', + 'license', + 'minClientVersions', + 'publisher', + 'repository', + 'tags', +] as const); + +export interface CursorManifestMetadataPlan { + readonly diagnostics: readonly Diagnostic[]; + readonly document?: Readonly>; + readonly sourceInputs: readonly string[]; +} + +const noManifestMetadataPlan: CursorManifestMetadataPlan = Object.freeze({ + diagnostics: Object.freeze([]), + sourceInputs: Object.freeze([]), +}); + +export interface CursorManifestMetadataPlanContext { + /** Diagnostic code prefix, e.g. `cursor` or the bundle's `plugin.cursor`. */ + readonly codePrefix: string; + readonly errorDiagnostic: (code: string, message: string) => Diagnostic; +} + +/** + * Validates the authored `cursor.*` manifest metadata against the pinned + * plugin schema's field shapes. A diagnostic never yields a partial document: + * either every authored field is valid and emitted verbatim, or none is. + */ +export const planCursorManifestMetadata = ( + model: NormalizedPlugin, + { codePrefix, errorDiagnostic }: CursorManifestMetadataPlanContext, +): CursorManifestMetadataPlan => { + const extension = model.extensions[cursorName]; + if (extension === undefined || !isPlainDataRecord(extension.value)) return noManifestMetadataPlan; + const value = extension.value; + const diagnostics: Diagnostic[] = []; + const sourceInputs = Object.freeze([extension.provenance.sourcePath]); + const unknownFields = Object.keys(value).filter((field) => + !(cursorManifestMetadataFields as readonly string[]).includes(field)); + if (unknownFields.length > 0) { + diagnostics.push(errorDiagnostic( + `${codePrefix}.manifest.field.unknown`, + `Cursor config declares unsupported field${unknownFields.length === 1 ? '' : 's'} ${unknownFields.map((field) => JSON.stringify(field)).join(', ')}; the pinned Cursor plugin schema admits only ${cursorManifestMetadataFields.join(', ')} here.`, + )); + } + const document: Record = {}; + const author = value['author']; + if (author !== undefined) { + if (!isPlainDataRecord(author)) { + diagnostics.push(errorDiagnostic(`${codePrefix}.manifest.author.invalid`, 'Cursor author must be a plain object with name and optional email.')); + } else { + const extra = Object.keys(author).filter((field) => !['email', 'name'].includes(field)); + if (extra.length > 0) { + diagnostics.push(errorDiagnostic( + `${codePrefix}.manifest.author.invalid`, + `Cursor author contains unsupported field${extra.length === 1 ? '' : 's'} ${extra.map((field) => JSON.stringify(field)).join(', ')}; the pinned Cursor plugin schema admits only name and email.`, + )); + } + if (!isNonemptyString(author['name'])) { + diagnostics.push(errorDiagnostic(`${codePrefix}.manifest.author.name.invalid`, 'Cursor author.name must be a nonempty string.')); + } + if (author['email'] !== undefined && !isEmail(author['email'])) { + diagnostics.push(errorDiagnostic(`${codePrefix}.manifest.author.email.invalid`, 'Cursor author.email must be a valid email address.')); + } + if (extra.length === 0 && isNonemptyString(author['name']) && (author['email'] === undefined || isEmail(author['email']))) { + document['author'] = Object.freeze({ + ...(author['email'] === undefined ? {} : { email: author['email'] }), + name: author['name'], + }); + } + } + } + for (const field of ['homepage', 'repository'] as const) { + const url = value[field]; + if (url === undefined) continue; + if (isAbsoluteUrl(url)) document[field] = url; + else diagnostics.push(errorDiagnostic(`${codePrefix}.manifest.${field}.invalid`, `Cursor ${field} must be an absolute HTTP or HTTPS URL.`)); + } + for (const field of ['category', 'license', 'publisher'] as const) { + const text = value[field]; + if (text === undefined) continue; + if (isNonemptyString(text)) document[field] = text; + else diagnostics.push(errorDiagnostic(`${codePrefix}.manifest.${field}.invalid`, `Cursor ${field} must be a nonempty string.`)); + } + for (const field of ['keywords', 'tags'] as const) { + const list = value[field]; + if (list === undefined) continue; + if (isNonemptyStringArray(list)) document[field] = Object.freeze([...list]); + else diagnostics.push(errorDiagnostic(`${codePrefix}.manifest.${field}.invalid`, `Cursor ${field} must be an array of nonempty strings.`)); + } + const minClientVersions = value['minClientVersions']; + if (minClientVersions !== undefined) { + const entries = isPlainDataRecord(minClientVersions) ? Object.entries(minClientVersions) : undefined; + const invalid = entries?.filter(([client, version]) => !isNonemptyString(client) || !isNonemptyString(version) || !cursorSemverPattern.test(version)); + if (entries === undefined || entries.length === 0) { + diagnostics.push(errorDiagnostic( + `${codePrefix}.manifest.minClientVersions.invalid`, + 'Cursor minClientVersions must be a plain object with at least one client identifier, e.g. { cursor: "3.13.0" }.', + )); + } else if (invalid !== undefined && invalid.length > 0) { + diagnostics.push(errorDiagnostic( + `${codePrefix}.manifest.minClientVersions.invalid`, + `Cursor minClientVersions ${invalid.map(([client]) => JSON.stringify(client)).join(', ')} must be strict X.Y.Z semver strings with an optional prerelease suffix.`, + )); + } else { + document['minClientVersions'] = Object.freeze(Object.fromEntries(entries.slice().sort(([left], [right]) => left.localeCompare(right)))); + } + } + if (diagnostics.length > 0) return Object.freeze({ diagnostics: Object.freeze(diagnostics), sourceInputs }); + if (Object.keys(document).length === 0) return Object.freeze({ diagnostics: Object.freeze([]), sourceInputs }); + return Object.freeze({ diagnostics: Object.freeze([]), document: Object.freeze(document), sourceInputs }); +}; + /** Builds the `.cursor-plugin/plugin.json` manifest with explicit document pointers. */ export const cursorManifest = ( model: NormalizedPlugin, pointers: CursorManifestPointers, + manifestMetadata?: Readonly>, ): Record => ({ + ...(manifestMetadata ?? {}), ...(pointers.commands === undefined ? {} : { commands: pointers.commands }), description: model.metadata.description ?? model.metadata.name, displayName: model.metadata.name, @@ -272,8 +452,30 @@ export const cursorManifest = ( version: model.metadata.version, }); +interface CapabilityTableRow { + readonly reason?: string; + /** JSON imports widen literals; unsupported table states fail closed below. */ + readonly state: string; +} + +/** Converts one dated capability-table row into the shared four-state contract. */ +const rowCapability = (row: CapabilityTableRow, evidence: CapabilityEvidence): CapabilityState => { + switch (row.state) { + case 'supported': + return supportedCapability(evidence); + case 'degraded': + return Object.freeze({ evidence, reason: row.reason ?? 'The pinned Cursor contract degrades this surface.', state: 'degraded' }); + case 'unavailable': + return unavailableCapability(row.reason ?? 'The pinned Cursor contract does not support this surface.'); + case 'prohibited': + return Object.freeze({ reason: row.reason ?? 'The pinned Cursor contract prohibits this surface.', state: 'prohibited' }); + default: + throw new TypeError(`Unsupported Cursor capability table state ${JSON.stringify(row.state)}.`); + } +}; + const metadata = Object.freeze({ - adapterRevision: '1.8.0', + adapterRevision: '1.9.0', observedVersion: capabilityTable.observedCliVersion, schemas: schemaDescriptorsFrom(schemaProvenance, schemaProvenance.observedCliVersion), }); @@ -400,6 +602,8 @@ export const planCursorArtifacts = (model: NormalizedPlugin): TargetArtifactPlan const variables = cursorVariables(mcp); const marketplacePlan = planCursorMarketplace(model); diagnostics.push(...marketplacePlan.diagnostics); + const manifestMetadata = planCursorManifestMetadata(model, mcpPlanContext); + diagnostics.push(...manifestMetadata.diagnostics); const plugin = cursorManifest(model, { ...(selectedCommands.length === 0 ? {} : { commands: './commands/' }), ...(hookDocument !== undefined && hookDocumentValid ? { hooks: `./${cursorArtifactPaths.hooks}` } : {}), @@ -407,7 +611,7 @@ export const planCursorArtifacts = (model: NormalizedPlugin): TargetArtifactPlan ...(selectedRules.length === 0 ? {} : { rules: './rules/' }), ...(model.skills.some((skill) => isSelected(skill.targets)) ? { skills: './skills/' } : {}), ...(variables === undefined ? {} : { variables }), - }); + }, manifestMetadata.document); diagnostics.push(...schemaDiagnostics('plugin', validatePlugin(plugin), validatePlugin.errors)); const basePlan = standardPluginArtifactPlan({ @@ -415,6 +619,7 @@ export const planCursorArtifacts = (model: NormalizedPlugin): TargetArtifactPlan ...selectedCommands.map((command) => command.source), ...selectedRules.map((rule) => rule.source), ...(model.metadata.logo === undefined ? [] : [model.metadata.logo.source]), + ...manifestMetadata.sourceInputs, ], diagnostics, hookDocument, @@ -444,10 +649,48 @@ export const planCursorArtifacts = (model: NormalizedPlugin): TargetArtifactPlan }), model, 'cursor'); }; +const { distributionPolicy, formats } = capabilityTable.plugin; +const hookOptions = capabilityTable.hooks.options; + +/** + * Cursor-only capability rows (#189 contract matrix). Each key maps to one + * dated row in the pinned table so inspect and the composite bundle read the + * same judgment; the plugin adapter mirrors every key with an honest + * unavailable intersection for the hosts that lack the surface. + */ +export const cursorContractCapabilityRows = Object.freeze({ + agentPluginFormat: formats.agentPlugin, + agents: capabilityTable.plugin.agents.component, + canvases: capabilityTable.plugin.canvases, + componentDiscovery: capabilityTable.plugin.componentDiscovery, + cursorPluginFormat: formats.cursorPlugin, + hookFailClosed: hookOptions.failClosed, + hookLoopLimit: hookOptions.loopLimit, + hookMatchers: hookOptions.matcher, + hookTimeout: hookOptions.timeout, + installModes: distributionPolicy.installModes, + localPluginImports: distributionPolicy.localPluginImports, + localSymlinkInstall: distributionPolicy.localSymlinkInstall, + manifestMetadata: capabilityTable.plugin.manifestMetadata, + marketplaceAccess: distributionPolicy.marketplaceAccess, + marketplaceAutoRefresh: distributionPolicy.autoRefresh, + marketplaceManifest: capabilityTable.plugin.marketplaceManifest, + marketplaceReview: distributionPolicy.marketplaceReview, + promptHooks: hookOptions.prompt, + rootSkill: capabilityTable.plugin.rootSkill, + teamMarketplaces: distributionPolicy.teamMarketplaces, + variables: capabilityTable.plugin.variables, +} satisfies Readonly>); + +const contractCapabilities = Object.freeze(Object.fromEntries( + Object.entries(cursorContractCapabilityRows).map(([capability, row]) => [capability, rowCapability(row, evidence)]), +)); + export const cursorAdapter: TargetAdapter = Object.freeze({ artifactValidation, artifactLayout, capabilities: Object.freeze({ + ...contractCapabilities, ...eventRouteCapabilitiesFrom(capabilityTable.hooks.eventRoutes, evidence), commands: capabilityStateFromSupport( capabilityTable.plugin.commands, @@ -473,6 +716,7 @@ export const cursorAdapter: TargetAdapter = Object.freeze({ 'The pinned Cursor Plugin contract does not support skills.', ), }), + configExtension: Object.freeze({ key: cursorName }), hookContract, metadata, mcpRuntime, diff --git a/packages/agent-bundle/src/adapters/hook-contract.ts b/packages/agent-bundle/src/adapters/hook-contract.ts index 8dc59fe52..39b8aa898 100644 --- a/packages/agent-bundle/src/adapters/hook-contract.ts +++ b/packages/agent-bundle/src/adapters/hook-contract.ts @@ -208,7 +208,16 @@ export const createNativeEventStarter = ( : { ...base, ...codexTurn, last_assistant_message: 'Lifecycle replay stopped.', stop_hook_active: false }); case 'agent/start': return deepFreeze(target === 'cursor' - ? base + ? { + ...base, + is_parallel_worker: false, + parent_conversation_id: 'lifecycle-replay', + subagent_id: 'lifecycle-replay-agent', + subagent_model: 'default', + subagent_type: 'generalPurpose', + task: 'Lifecycle replay subagent task.', + tool_call_id: 'lifecycle-replay-tool', + } : { ...base, agent_id: 'lifecycle-replay-agent', @@ -219,7 +228,20 @@ export const createNativeEventStarter = ( }); case 'agent/stop': return deepFreeze(target === 'cursor' - ? base + ? { + ...base, + agent_transcript_path: null, + description: 'Lifecycle replay subagent.', + duration_ms: 0, + loop_count: 0, + message_count: 0, + modified_files: [], + status: 'completed', + subagent_type: 'generalPurpose', + summary: 'Lifecycle replay subagent summary.', + task: 'Lifecycle replay subagent task.', + tool_call_count: 0, + } : { ...base, agent_id: 'lifecycle-replay-agent', @@ -508,13 +530,34 @@ export const encodeCursorPlaygroundInput = ( ...(nativeEvent === 'stop' ? { loop_count: input.stopHookActive === true ? 1 : 0, status: 'completed' } : {}), + // https://cursor.com/docs/hooks#subagentstart / #subagentstop (retrieved + // 2026-09-02): Cursor names the subagent fields subagent_* and reports the + // completion summary and follow-up loop count instead of Claude's + // last_assistant_message / stop_hook_active pair. + ...(nativeEvent === 'subagentStart' + ? { + subagent_id: input.agentId, + subagent_type: input.agentType, + task: '', + tool_call_id: input.toolUseId, + } + : {}), + ...(nativeEvent === 'subagentStop' + ? { + agent_transcript_path: input.agentTranscriptPath ?? null, + loop_count: input.stopHookActive === true ? 1 : 0, + status: 'completed', + subagent_type: input.agentType, + summary: input.lastAssistantMessage, + } + : {}), session_id: input.sessionId, - tool_input: input.toolInput, - tool_name: input.toolName, + ...(nativeEvent === 'subagentStart' || nativeEvent === 'subagentStop' + ? {} + : { tool_input: input.toolInput, tool_name: input.toolName, tool_use_id: input.toolUseId }), ...(nativeEvent === 'postToolUse' && input.toolResponse !== undefined ? { tool_output: JSON.stringify(input.toolResponse) } : {}), - tool_use_id: input.toolUseId, transcript_path: input.transcriptPath, }); @@ -523,9 +566,12 @@ export const encodeCursorPlaygroundOutput = ( canonicalEvent: CanonicalHookEvent, ): Readonly> | undefined => { if (result === undefined) return undefined; - if (canonicalEvent === 'stop') { + if (canonicalEvent === 'stop' || canonicalEvent === 'agentStop') { return result.outcome === 'deny' ? defined({ followup_message: result.reason }) : undefined; } + if (canonicalEvent === 'agentStart') { + return result.outcome === 'deny' ? defined({ permission: 'deny', user_message: result.reason }) : undefined; + } if (canonicalEvent === 'beforeTool') { if (result.outcome === 'deny') { return defined({ agent_message: result.reason, permission: 'deny', user_message: result.reason }); @@ -704,15 +750,20 @@ export const cursorHookWrapperSource = (entry: TargetHookWrapper): string => [ ' if (!isRecord(parsed)) fail("native tool_output must encode an object");', ' return parsed;', '};', + 'const subagentEvent = canonicalEvent === "agentStart" || canonicalEvent === "agentStop";', 'const decodeCursorNative = (nativeInput) => defined({', + ' agentId: canonicalEvent === "agentStart" ? nativeInput.subagent_id : undefined,', + ' agentTranscriptPath: canonicalEvent === "agentStop" ? nativeInput.agent_transcript_path ?? undefined : undefined,', + ' agentType: subagentEvent ? nativeInput.subagent_type : undefined,', ' cwd: nativeInput.cwd,', ' hookEventName: nativeInput.hook_event_name,', - ' sessionId: nativeInput.session_id ?? nativeInput.conversation_id,', - ' stopHookActive: canonicalEvent === "stop" ? nativeInput.loop_count > 0 : undefined,', + ' lastAssistantMessage: canonicalEvent === "agentStop" ? nativeInput.summary : undefined,', + ' sessionId: nativeInput.session_id ?? nativeInput.conversation_id ?? nativeInput.parent_conversation_id,', + ' stopHookActive: canonicalEvent === "stop" || canonicalEvent === "agentStop" ? nativeInput.loop_count > 0 : undefined,', ' toolInput: nativeInput.tool_input,', ' toolName: nativeInput.tool_name,', ' toolResponse: parsedToolOutput(nativeInput),', - ' toolUseId: nativeInput.tool_use_id,', + ' toolUseId: canonicalEvent === "agentStart" ? nativeInput.tool_call_id : nativeInput.tool_use_id,', ' transcriptPath: nativeInput.transcript_path ?? undefined,', '});', 'const encodeCursorNative = (canonicalInput) => defined({', @@ -720,11 +771,11 @@ export const cursorHookWrapperSource = (entry: TargetHookWrapper): string => [ ' cwd: canonicalInput.cwd,', ' hook_event_name: nativeEvent,', ' ...(canonicalEvent === "stop" ? { loop_count: canonicalInput.stopHookActive === true ? 1 : 0, status: "completed" } : {}),', + ' ...(canonicalEvent === "agentStart" ? { subagent_id: canonicalInput.agentId, subagent_type: canonicalInput.agentType, task: "", tool_call_id: canonicalInput.toolUseId } : {}),', + ' ...(canonicalEvent === "agentStop" ? { agent_transcript_path: canonicalInput.agentTranscriptPath ?? null, loop_count: canonicalInput.stopHookActive === true ? 1 : 0, status: "completed", subagent_type: canonicalInput.agentType, summary: canonicalInput.lastAssistantMessage } : {}),', ' session_id: canonicalInput.sessionId,', - ' tool_input: canonicalInput.toolInput,', - ' tool_name: canonicalInput.toolName,', + ' ...(subagentEvent ? {} : { tool_input: canonicalInput.toolInput, tool_name: canonicalInput.toolName, tool_use_id: canonicalInput.toolUseId }),', ' ...(canonicalEvent === "afterTool" && canonicalInput.toolResponse !== undefined ? { tool_output: JSON.stringify(canonicalInput.toolResponse) } : {}),', - ' tool_use_id: canonicalInput.toolUseId,', ' transcript_path: canonicalInput.transcriptPath,', '});', 'const validateResult = (result) => {', @@ -736,16 +787,20 @@ export const cursorHookWrapperSource = (entry: TargetHookWrapper): string => [ ' if (result.reason !== undefined && typeof result.reason !== "string") fail("handler result reason must be a string");', ' if (result.additionalContext !== undefined && typeof result.additionalContext !== "string") fail("handler result additionalContext must be a string");', ' if (result.updatedInput !== undefined && !isRecord(result.updatedInput)) fail("handler result updatedInput must be an object");', - ' if (result.reason !== undefined && !(result.outcome === "deny" && (canonicalEvent === "beforeTool" || canonicalEvent === "stop"))) fail("reason is only valid for a denied beforeTool or stop hook");', - ' if (result.outcome === "deny" && (canonicalEvent === "beforeTool" || canonicalEvent === "stop") && (typeof result.reason !== "string" || result.reason.trim().length === 0)) fail(`denied ${canonicalEvent} hook requires a nonempty reason`);', + ' const supportsDeniedReason = canonicalEvent === "beforeTool" || canonicalEvent === "stop" || subagentEvent;', + ' if (result.reason !== undefined && !(result.outcome === "deny" && supportsDeniedReason)) fail("reason is only valid for a denied beforeTool, stop, agentStart, or agentStop hook");', + ' if (result.outcome === "deny" && supportsDeniedReason && (typeof result.reason !== "string" || result.reason.trim().length === 0)) fail(`denied ${canonicalEvent} hook requires a nonempty reason`);', ' if ((canonicalEvent === "sessionStart" || canonicalEvent === "afterTool") && (result.outcome === "deny" || result.outcome === "stop" || result.updatedInput !== undefined)) fail(`${canonicalEvent} cannot deny, stop, or replace input`);', ' if (canonicalEvent === "beforeTool" && (result.outcome === "stop" || (result.outcome === "deny" && result.updatedInput !== undefined))) fail("beforeTool cannot stop or replace input while denying");', ' if (canonicalEvent === "stop" && (result.outcome === "stop" || result.updatedInput !== undefined || result.additionalContext !== undefined)) fail("stop only accepts continue or deny with a reason");', + ' if (subagentEvent && (result.outcome === "stop" || result.updatedInput !== undefined)) fail(`${canonicalEvent} cannot stop the parent flow or replace input`);', + ' if (subagentEvent && result.additionalContext !== undefined) fail(`Cursor ${nativeEvent} has no additional-context channel`);', ' return result;', '};', 'const encodeOutput = (result) => {', ' if (result === undefined) return undefined;', - ' if (canonicalEvent === "stop") return result.outcome === "deny" ? defined({ followup_message: result.reason }) : undefined;', + ' if (canonicalEvent === "stop" || canonicalEvent === "agentStop") return result.outcome === "deny" ? defined({ followup_message: result.reason }) : undefined;', + ' if (canonicalEvent === "agentStart") return result.outcome === "deny" ? defined({ permission: "deny", user_message: result.reason }) : undefined;', ' if (canonicalEvent === "beforeTool") {', ' if (result.outcome === "deny") return defined({ agent_message: result.reason, permission: "deny", user_message: result.reason });', ' return result.updatedInput === undefined ? undefined : { permission: "allow", updated_input: result.updatedInput };', @@ -754,7 +809,8 @@ export const cursorHookWrapperSource = (entry: TargetHookWrapper): string => [ '};', 'const decodeOutput = (nativeOutput) => {', ' if (nativeOutput === undefined) return undefined;', - ' if (canonicalEvent === "stop") return typeof nativeOutput.followup_message === "string" ? { outcome: "deny", reason: nativeOutput.followup_message } : undefined;', + ' if (canonicalEvent === "stop" || canonicalEvent === "agentStop") return typeof nativeOutput.followup_message === "string" ? { outcome: "deny", reason: nativeOutput.followup_message } : undefined;', + ' if (canonicalEvent === "agentStart") return nativeOutput.permission === "deny" ? defined({ outcome: "deny", reason: nativeOutput.user_message }) : { outcome: "continue" };', ' if (canonicalEvent === "beforeTool") {', ' if (nativeOutput.permission === "deny") return defined({ outcome: "deny", reason: nativeOutput.agent_message });', ' return defined({ outcome: "continue", updatedInput: nativeOutput.updated_input });', @@ -766,7 +822,7 @@ export const cursorHookWrapperSource = (entry: TargetHookWrapper): string => [ '};', 'const validateNativeInput = (input) => {', ' if (input.hook_event_name !== nativeEvent) fail(`native hook_event_name must equal ${nativeEvent}`);', - ' if (typeof input.session_id !== "string" && typeof input.conversation_id !== "string") fail("native session_id or conversation_id must be a string");', + ' if (typeof input.session_id !== "string" && typeof input.conversation_id !== "string" && !(canonicalEvent === "agentStart" && typeof input.parent_conversation_id === "string")) fail("native session_id or conversation_id must be a string");', ' if (input.transcript_path !== undefined && input.transcript_path !== null && typeof input.transcript_path !== "string") fail("native transcript_path must be a string or null");', ' if (canonicalEvent === "sessionStart") return;', ' if (canonicalEvent === "beforeTool" || canonicalEvent === "afterTool") {', @@ -776,6 +832,18 @@ export const cursorHookWrapperSource = (entry: TargetHookWrapper): string => [ ' if (canonicalEvent === "afterTool") requireString(input, "tool_output");', ' return;', ' }', + ' if (canonicalEvent === "agentStart") {', + ' requireString(input, "subagent_id");', + ' requireString(input, "subagent_type");', + ' requireString(input, "task");', + ' return;', + ' }', + ' if (canonicalEvent === "agentStop") {', + ' requireString(input, "subagent_type");', + ' if (!["completed", "error", "aborted"].includes(input.status)) fail("native subagentStop status is invalid");', + ' if (typeof input.loop_count !== "number") fail("native subagentStop loop_count must be a number");', + ' return;', + ' }', ' if (typeof input.loop_count !== "number") fail("native stop loop_count must be a number");', ' requireString(input, "status");', '};', diff --git a/packages/agent-bundle/src/adapters/plugin.ts b/packages/agent-bundle/src/adapters/plugin.ts index 77068a3a7..019e2575f 100644 --- a/packages/agent-bundle/src/adapters/plugin.ts +++ b/packages/agent-bundle/src/adapters/plugin.ts @@ -22,6 +22,7 @@ import { codexAdapter, codexArtifactPaths, codexPluginDocumentValidator, planCod import { createCursorHookContract, cursorAdapter, + cursorContractCapabilityRows, cursorHooksValidator, cursorManifest, cursorMarketplaceValidator, @@ -31,6 +32,7 @@ import { cursorVariables, emptyCursorHooksDocument, isValidCursorPluginName, + planCursorManifestMetadata, planCursorMarketplace, planCursorMcpServer, } from './cursor.ts'; @@ -231,7 +233,7 @@ const artifactValidation = deepFreeze({ }); const metadata = Object.freeze({ - adapterRevision: '1.24.0', + adapterRevision: '1.25.0', observedVersion: `${claudeAdapter.metadata.observedVersion}+${codexAdapter.metadata.observedVersion}+${cursorAdapter.metadata.observedVersion}`, // Metadata schemas must exactly match the validation contract: each host's // documents, with one shared Claude-format hook schema (the pinned Codex @@ -504,6 +506,8 @@ const plan = (model: NormalizedPlugin): TargetArtifactPlan => { } } const cursorManifestVariables = cursorVariables(cursorMcp); + const cursorManifestMetadata = planCursorManifestMetadata(model, cursorMcpPlanContext); + diagnostics.push(...cursorManifestMetadata.diagnostics); // `commands/` contains Claude-generated frontmatter. The pinned Cursor // evidence establishes plain Markdown commands, but not tolerance for // Claude frontmatter, so this composite manifest deliberately omits it. @@ -513,7 +517,7 @@ const plan = (model: NormalizedPlugin): TargetArtifactPlan => { ...(selectedRules.length === 0 ? {} : { rules: './rules/' }), ...(model.skills.some((skill) => skill.targets.includes(pluginName)) ? { skills: './skills/' } : {}), ...(cursorManifestVariables === undefined ? {} : { variables: cursorManifestVariables }), - }); + }, cursorManifestMetadata.document); const cursorManifestValid = cursorPluginValidator(manifest); diagnostics.push(...schemaDiagnostics('cursor-plugin', cursorManifestValid, cursorPluginValidator.errors)); if (cursorManifestValid) { @@ -526,6 +530,7 @@ const plan = (model: NormalizedPlugin): TargetArtifactPlan => { ...targetSourceInputs, ...selectedRules.map((rule) => rule.source), model.metadata.logo?.source, + ...cursorManifestMetadata.sourceInputs, ), }); const logoEntry = pluginLogoCopyEntry(model); @@ -652,21 +657,47 @@ const agentCapabilities = Object.freeze(Object.fromEntries( Object.keys(claudeCapabilityTable.plugin.agents).map((rowName) => { const capability = rowName === 'component' ? 'agents' : `agents.${rowName}`; return [ + capability, + rowName === 'component' + ? intersectCapabilityStates( + intersectCapabilityStates(claudeAdapter.capabilities.agents!, cursorAdapter.capabilities.agents!), + unavailableCapability('The pinned Codex plugin contract publishes no plugin agents component.'), + ) + : intersectCapabilityStates( + claudeAdapter.capabilities[capability]!, + unavailableCapability( + 'The pinned Codex plugin contract publishes no plugin agents component, and the pinned Cursor agents component documents only name and description frontmatter, so no shared agent-frontmatter surface exists.', + ), + ), + ]; + }), +)); + +/** + * Cursor-only contract rows (#189) reach the Cursor half of the bundle only; + * each composite row is the honest intersection with the hosts that publish + * no matching surface. Rows shared with Claude or Codex intersect the real + * host judgments below instead. + */ +const cursorOnlyCapabilities = Object.freeze(Object.fromEntries( + Object.keys(cursorContractCapabilityRows) + .filter((capability) => !['agents', 'manifestMetadata', 'marketplaceManifest'].includes(capability)) + .map((capability) => [ capability, intersectCapabilityStates( - claudeAdapter.capabilities[capability]!, + cursorAdapter.capabilities[capability]!, unavailableCapability( - 'The pinned Codex and Cursor plugin contracts publish no shared plugin agents component or agent-frontmatter surface.', + `The pinned Claude Code and Codex plugin contracts publish no shared ${capability} surface; the Cursor row reaches the Cursor half of the bundle only.`, ), ), - ]; - }), + ]), )); export const pluginAdapter: TargetAdapter = Object.freeze({ artifactValidation, artifactLayout, capabilities: Object.freeze({ + ...cursorOnlyCapabilities, ...agentCapabilities, ...codexHookContractUnifiedCapabilities, ...compositeEventCapabilities, @@ -817,9 +848,12 @@ export const pluginAdapter: TargetAdapter = Object.freeze({ cursorAdapter.capabilities.marketplace!, ), marketplaceManifest: intersectCapabilityStates( - claudeAdapter.capabilities.marketplaceManifest!, + intersectCapabilityStates( + claudeAdapter.capabilities.marketplaceManifest!, + cursorAdapter.capabilities.marketplaceManifest!, + ), unavailableCapability( - 'The unified bundle emits the Claude marketplace overlay, but the pinned Codex and Cursor contracts do not share its completed marketplace manifest surface.', + 'The unified bundle emits the Claude marketplace overlay and the Cursor marketplace document, but the pinned Codex contract does not share a completed marketplace manifest surface.', ), ), allowCrossMarketplaceDependenciesOn: intersectCapabilityStates( @@ -841,9 +875,7 @@ export const pluginAdapter: TargetAdapter = Object.freeze({ claudeAdapter.capabilities.manifestMetadata!, codexAdapter.capabilities.manifestMetadata!, ), - unavailableCapability( - 'The pinned Cursor plugin contract does not share the authored Codex and Claude manifest metadata fields.', - ), + cursorAdapter.capabilities.manifestMetadata!, ), manifestPaths: intersectCapabilityStates( intersectCapabilityStates( diff --git a/packages/agent-bundle/src/adapters/schemas/cursor/PROVENANCE.json b/packages/agent-bundle/src/adapters/schemas/cursor/PROVENANCE.json index c72f29730..63f373782 100644 --- a/packages/agent-bundle/src/adapters/schemas/cursor/PROVENANCE.json +++ b/packages/agent-bundle/src/adapters/schemas/cursor/PROVENANCE.json @@ -36,6 +36,15 @@ "source": "Read-only inspection of the installed cursor-agent-exec loader and physical plugins under ~/.cursor/plugins/local.", "notes": "Known-loading tracedecay uses .cursor-plugin/plugin.json, root mcp.json, and hooks/hooks.json. The installed loader substitutes CURSOR_PLUGIN_ROOT for local MCP and hook commands. Symlinks escaping the local plugins root are rejected." }, + "publicDocsObservation": { + "retrievedAt": "2026-09-02", + "pages": [ + "https://cursor.com/docs/plugins", + "https://cursor.com/docs/reference/plugins", + "https://cursor.com/docs/hooks" + ], + "notes": "Contract refresh for #189. The reference documents two loadable formats (root plugin.json Agent Plugin; .cursor-plugin/plugin.json Cursor Plugin), manifest fields name/description/version/author{name,email}/homepage/repository/license/keywords/logo/component paths/hooks/mcpServers/variables, folder discovery defaults with manifest-path replacement, the .cursor-plugin/marketplace.json format (max 500 entries, manifest-over-entry merge), and 21 hook events (18 agent, 2 tab, workspaceOpen) with per-event cloud availability, matchers, failClosed, loop_limit, timeout, and prompt hooks. The pinned plugin.schema.json additionally admits displayName, publisher, category, tags, and minClientVersions, which the current reference does not list on plugin.json; hooks.schema.json enumerates exactly the 21 documented events. The docs now show `ln -s` for local iteration while the loader observed on 2026-08-31 rejected symlinks escaping ~/.cursor/plugins/local; Cursor IDE 3.18.25 loaded three physical local directories on 2026-09-03 (UTC)." + }, "observedCheckout": "/fast/projects/tracedecay", "contract": { "name": "Cursor Plugin", diff --git a/packages/agent-bundle/src/config/index.ts b/packages/agent-bundle/src/config/index.ts index 46f81a4b5..67d41d9e6 100644 --- a/packages/agent-bundle/src/config/index.ts +++ b/packages/agent-bundle/src/config/index.ts @@ -1,5 +1,6 @@ import type { ClaudeConfigExtension } from '../adapters/claude.ts'; import type { CodexConfigExtension } from '../adapters/codex.ts'; +import type { CursorConfigExtension } from '../adapters/cursor.ts'; import type { PortableConfigExtension } from '../adapters/portable.ts'; import type { AgentBundleConfig as CoreAgentBundleConfig } from '../core/types.ts'; @@ -36,6 +37,7 @@ export { validateModel, validateSource } from './validate.ts'; export type AgentBundleConfig = CoreAgentBundleConfig & ClaudeConfigExtension & CodexConfigExtension + & CursorConfigExtension & PortableConfigExtension; export type { AgentBundleConfigExtensions, diff --git a/packages/agent-bundle/src/events/projection.ts b/packages/agent-bundle/src/events/projection.ts index 92d4b212a..bb228ceba 100644 --- a/packages/agent-bundle/src/events/projection.ts +++ b/packages/agent-bundle/src/events/projection.ts @@ -183,6 +183,43 @@ export const validateNativeEventEnvelope = ( if (canonicalEvent === 'stop' && typeof native.loop_count !== 'number') { return nativeEventError('native loop_count must be a number'); } + if (canonicalEvent === 'agent/start') { + // https://cursor.com/docs/hooks#subagentstart (retrieved 2026-09-02). + requireNativeString(native, 'subagent_id'); + requireNativeString(native, 'subagent_type'); + requireNativeStringValue(native, 'task'); + for (const field of ['parent_conversation_id', 'tool_call_id', 'subagent_model', 'git_branch']) { + if (Object.hasOwn(native, field)) requireNativeStringValue(native, field); + } + if (Object.hasOwn(native, 'is_parallel_worker')) requireNativeBoolean(native, 'is_parallel_worker'); + } + if (canonicalEvent === 'agent/stop') { + // https://cursor.com/docs/hooks#subagentstop (retrieved 2026-09-02). + requireNativeString(native, 'subagent_type'); + if (!['completed', 'error', 'aborted'].includes(String(native.status))) { + return nativeEventError('native status is invalid'); + } + for (const field of ['task', 'description', 'summary']) { + if (Object.hasOwn(native, field)) requireNativeStringValue(native, field); + } + for (const field of ['duration_ms', 'message_count', 'tool_call_count']) { + if (Object.hasOwn(native, field)) requireNativeNumber(native, field); + } + requireNativeNumber(native, 'loop_count'); + if ( + Object.hasOwn(native, 'modified_files') + && (!Array.isArray(native.modified_files) || !native.modified_files.every((file) => typeof file === 'string')) + ) { + return nativeEventError('native modified_files must be an array of strings'); + } + if ( + Object.hasOwn(native, 'agent_transcript_path') + && native.agent_transcript_path !== null + && typeof native.agent_transcript_path !== 'string' + ) { + return nativeEventError('native agent_transcript_path must be a string or null'); + } + } return native; } requireNativeString(native, 'session_id'); @@ -429,44 +466,62 @@ export const projectEventDocument = ( : Object.freeze({ decision: 'block', reason: requireDenyReason() }); } if (event === 'agent/start') { - if (parsedValue?.outcome === 'deny') { - throw new TypeError('agent/start cannot block subagent creation on any supported host.'); - } if (parsedValue?.updatedInput !== undefined) { throw new TypeError('agent/start cannot replace native input.'); } + if (target === 'cursor') { + // https://cursor.com/docs/hooks#subagentstart (retrieved 2026-09-02): + // output is { permission: allow | deny, user_message? }; there is no + // additional-context channel, and `ask` is treated as deny. + if (additionalContext !== undefined) { + throw new TypeError('Cursor subagentStart has no additional-context channel.'); + } + if (parsedValue?.reason !== undefined && parsedValue.outcome !== 'deny') { + throw new TypeError('agent/start reason is only valid when outcome is deny.'); + } + return parsedValue?.outcome === 'deny' + ? Object.freeze({ permission: 'deny', user_message: requireDenyReason() }) + : undefined; + } + if (parsedValue?.outcome === 'deny') { + throw new TypeError('agent/start cannot block subagent creation on Claude Code or Codex.'); + } if (additionalContext === undefined) return undefined; - return target === 'cursor' - ? Object.freeze({ additional_context: additionalContext }) - : deepFreeze({ - hookSpecificOutput: { - additionalContext, - hookEventName: nativeEvent, - }, - }); + return deepFreeze({ + hookSpecificOutput: { + additionalContext, + hookEventName: nativeEvent, + }, + }); } if (event === 'agent/stop') { if (parsedValue?.updatedInput !== undefined) { throw new TypeError('agent/stop cannot replace native input.'); } - if (parsedValue?.outcome === 'deny') { - if (target === 'cursor') { - throw new TypeError('agent/stop cannot block subagent completion on cursor.'); + if (target === 'cursor') { + // https://cursor.com/docs/hooks#subagentstop (retrieved 2026-09-02): + // output is { followup_message? }, consumed only when status is + // completed and capped by loop_limit; there is no context channel. + if (additionalContext !== undefined) { + throw new TypeError('Cursor subagentStop has no additional-context channel; only followup_message is documented.'); } + return parsedValue?.outcome === 'deny' + ? Object.freeze({ followup_message: requireDenyReason() }) + : undefined; + } + if (parsedValue?.outcome === 'deny') { return Object.freeze({ decision: 'block', reason: requireDenyReason() }); } if (additionalContext === undefined) return undefined; if (target === 'codex') { throw new TypeError('agent/stop additional context is not supported by the Codex SubagentStop output schema.'); } - return target === 'cursor' - ? Object.freeze({ additional_context: additionalContext }) - : deepFreeze({ - hookSpecificOutput: { - additionalContext, - hookEventName: nativeEvent, - }, - }); + return deepFreeze({ + hookSpecificOutput: { + additionalContext, + hookEventName: nativeEvent, + }, + }); } if (event === 'session/end') { if ( diff --git a/packages/agent-bundle/src/index.ts b/packages/agent-bundle/src/index.ts index 49103a942..59f65544d 100644 --- a/packages/agent-bundle/src/index.ts +++ b/packages/agent-bundle/src/index.ts @@ -1,5 +1,6 @@ import type { ClaudeConfigExtension } from './adapters/claude.ts'; import type { CodexConfigExtension } from './adapters/codex.ts'; +import type { CursorConfigExtension } from './adapters/cursor.ts'; import type { PortableConfigExtension } from './adapters/portable.ts'; import type { AgentBundleConfig as CoreAgentBundleConfig } from './core/types.ts'; @@ -106,6 +107,7 @@ export type { export type AgentBundleConfig = CoreAgentBundleConfig & ClaudeConfigExtension & CodexConfigExtension + & CursorConfigExtension & PortableConfigExtension; export type { PortableAuthorConfig, PortableManifestConfig } from './adapters/portable.ts'; diff --git a/packages/agent-bundle/tests/adapter-capability-states.test.ts b/packages/agent-bundle/tests/adapter-capability-states.test.ts index dc725bb16..c6617b9ca 100644 --- a/packages/agent-bundle/tests/adapter-capability-states.test.ts +++ b/packages/agent-bundle/tests/adapter-capability-states.test.ts @@ -12,6 +12,8 @@ import { import claudeCapabilityTable from '../src/adapters/capabilities/claude-2.1.250.json' with { type: 'json' }; import codexCapabilityTable from '../src/adapters/capabilities/codex-0.147.0.json' with { type: 'json' }; import cursorCapabilityTable from '../src/adapters/capabilities/cursor-2026-08-28.json' with { type: 'json' }; +import cursorHooksSchema from '../src/adapters/schemas/cursor/hooks.schema.json' with { type: 'json' }; +import { cursorContractCapabilityRows } from '../src/adapters/cursor.ts'; import { TargetRegistry, createDefaultRegistry } from '../src/adapters/registry.ts'; import { CapabilityStateError, isCapabilityState } from '../src/core/capabilities.ts'; import type { CapabilityEvidence, CapabilityState } from '../src/core/capabilities.ts'; @@ -325,17 +327,38 @@ it('records dated unavailable Claude agent rows and mirrors them through the uni reason: row.reason, state: 'unavailable', }); - expect(registry.get('plugin').capabilities[capability]).toEqual(intersectCapabilityStates( - registry.get('claude').capabilities[capability]!, - unavailableCapability( - 'The pinned Codex and Cursor plugin contracts publish no shared plugin agents component or agent-frontmatter surface.', - ), - )); + expect(registry.get('plugin').capabilities[capability]).toEqual(rowName === 'component' + ? intersectCapabilityStates( + intersectCapabilityStates( + registry.get('claude').capabilities.agents!, + registry.get('cursor').capabilities.agents!, + ), + unavailableCapability('The pinned Codex plugin contract publishes no plugin agents component.'), + ) + : intersectCapabilityStates( + registry.get('claude').capabilities[capability]!, + unavailableCapability( + 'The pinned Codex plugin contract publishes no plugin agents component, and the pinned Cursor agents component documents only name and description frontmatter, so no shared agent-frontmatter surface exists.', + ), + )); expect(registry.supports('claude', capability)).toBe(false); expect(registry.supports('plugin', capability)).toBe(false); } }); +it('records the dated G5-gated Cursor agents component row beside the documented Cursor agents format', () => { + const registry = createDefaultRegistry(); + const row = cursorCapabilityTable.plugin.agents.component; + + expect(row.state).toBe('unavailable'); + expect(row.reason).toContain('PR #220'); + expect(row.reason).toContain('#107 revision 3'); + expect(row.evidence.every((line) => line.startsWith('retrieved 2026-09-02:'))).toBe(true); + expect(row.evidence.some((line) => line.includes('https://cursor.com/docs/reference/plugins') && line.includes('agents/'))).toBe(true); + expect(registry.get('cursor').capabilities.agents).toEqual({ reason: row.reason, state: 'unavailable' }); + expect(registry.supports('cursor', 'agents')).toBe(false); +}); + it('reports Claude userConfig support and honest unavailable composite coverage', () => { const registry = createDefaultRegistry(); @@ -610,10 +633,22 @@ it.each([ reason: expect.stringContaining(reason), state: 'unavailable', }); - for (const target of ['codex', 'cursor', 'portable'] as const) { + for (const target of ['codex', 'portable'] as const) { expect(registry.get(target).capabilities[capability]).toBeUndefined(); expect(registry.supports(target, capability)).toBe(false); } + // Cursor publishes its own dated marketplace manifest row (#189); the + // cross-marketplace dependency allowlist remains Claude-only. + if (capability === 'marketplaceManifest') { + expect(registry.get('cursor').capabilities[capability]).toMatchObject({ + evidence: { observedVersion: '2026-08-28', target: 'cursor' }, + state: 'supported', + }); + expect(registry.supports('cursor', capability)).toBe(true); + } else { + expect(registry.get('cursor').capabilities[capability]).toBeUndefined(); + expect(registry.supports('cursor', capability)).toBe(false); + } expect(registry.supports('claude', capability)).toBe(true); expect(registry.supports('plugin', capability)).toBe(false); }); @@ -633,39 +668,55 @@ it('pins the authored Claude marketplace source matrix and version gates', () => }); }); -it.each([ - ['manifestMetadata', 'manifest metadata fields'], - ['manifestPaths', 'custom manifest path rules'], -] as const)('reports Claude %s support without inventing shared composite coverage', (capability, reason) => { +it('reports Claude manifestPaths support without inventing shared composite coverage', () => { const registry = createDefaultRegistry(); - expect(registry.get('claude').capabilities[capability]).toMatchObject({ + expect(registry.get('claude').capabilities.manifestPaths).toMatchObject({ evidence: { observedVersion: '2.1.250', target: 'claude', }, state: 'supported', }); - expect(registry.get('plugin').capabilities[capability]).toMatchObject({ - reason: expect.stringContaining(reason), + expect(registry.get('plugin').capabilities.manifestPaths).toMatchObject({ + reason: expect.stringContaining('custom manifest path rules'), state: 'unavailable', }); - expect(registry.get('cursor').capabilities[capability]).toBeUndefined(); - expect(registry.supports('cursor', capability)).toBe(false); - // Agent Plugins 1.0.0 §5.4 defines manifest metadata for the portable manifest (#307); it - // has no custom manifest path rules, so only that capability is declared there. - if (capability === 'manifestMetadata') { - expect(registry.get('portable').capabilities[capability]).toMatchObject({ - evidence: { observedVersion: '1.0.0', target: 'portable' }, + // Neither the pinned Cursor contract nor Agent Plugins 1.0.0 (#307) defines + // custom manifest path rules. + for (const target of ['cursor', 'portable'] as const) { + expect(registry.get(target).capabilities.manifestPaths).toBeUndefined(); + expect(registry.supports(target, 'manifestPaths')).toBe(false); + } + expect(registry.supports('claude', 'manifestPaths')).toBe(true); + expect(registry.supports('plugin', 'manifestPaths')).toBe(false); +}); + +it('reports manifest metadata support on every native host and the three-host composite', () => { + const registry = createDefaultRegistry(); + + for (const target of ['claude', 'codex', 'cursor'] as const) { + expect(registry.get(target).capabilities.manifestMetadata).toMatchObject({ + evidence: { target }, state: 'supported', }); - expect(registry.supports('portable', capability)).toBe(true); - } else { - expect(registry.get('portable').capabilities[capability]).toBeUndefined(); - expect(registry.supports('portable', capability)).toBe(false); + expect(registry.supports(target, 'manifestMetadata')).toBe(true); } - expect(registry.supports('claude', capability)).toBe(true); - expect(registry.supports('plugin', capability)).toBe(false); + expect(registry.get('plugin').capabilities.manifestMetadata).toMatchObject({ + evidence: { target: 'claude+codex+cursor' }, + state: 'supported', + }); + // Agent Plugins 1.0.0 §5.4 defines manifest metadata for the portable manifest (#307). + expect(registry.get('portable').capabilities.manifestMetadata).toMatchObject({ + evidence: { observedVersion: '1.0.0', target: 'portable' }, + state: 'supported', + }); + expect(registry.supports('portable', 'manifestMetadata')).toBe(true); + expect(cursorCapabilityTable.plugin.manifestMetadata).toMatchObject({ + authoredFields: ['author.name', 'author.email', 'homepage', 'repository', 'license', 'keywords', 'publisher', 'category', 'tags', 'minClientVersions'], + schemaOnlyFields: ['displayName', 'publisher', 'category', 'tags', 'minClientVersions'], + state: 'supported', + }); }); const codexManifestPackageCapabilities = [ @@ -749,9 +800,7 @@ it('mirrors Codex manifest metadata and path states through the unified adapter' registry.get('claude').capabilities.manifestMetadata!, registry.get('codex').capabilities.manifestMetadata!, ), - unavailableCapability( - 'The pinned Cursor plugin contract does not share the authored Codex and Claude manifest metadata fields.', - ), + registry.get('cursor').capabilities.manifestMetadata!, )); expect(registry.get('plugin').capabilities.manifestPaths).toEqual(intersectCapabilityStates( intersectCapabilityStates( @@ -1049,8 +1098,7 @@ it('pins dated deferral rows for every explicitly deferred native callback from codex: ['Interrupt'], cursor: [ 'afterAgentResponse', 'afterAgentThought', 'afterFileEdit', 'afterMCPExecution', 'afterShellExecution', - 'afterTabFileEdit', 'beforeMCPExecution', 'beforeReadFile', 'beforeShellExecution', - 'beforeSubmitPrompt-cloud', 'beforeTabFileRead', + 'afterTabFileEdit', 'beforeMCPExecution', 'beforeReadFile', 'beforeShellExecution', 'beforeTabFileRead', ], } as const; for (const [host, names] of Object.entries(expected)) { @@ -1095,3 +1143,151 @@ it('advertises notice delivery routes per host with dated unavailability (#99 st } } }); + +/** + * The complete hook-event inventory published at https://cursor.com/docs/hooks + * and https://cursor.com/docs/reference/plugins (retrieved 2026-09-02): + * 18 Agent hooks, 2 Tab hooks, and the workspaceOpen app lifecycle hook. + */ +const documentedCursorHookEvents = { + agent: [ + 'sessionStart', 'sessionEnd', 'preToolUse', 'postToolUse', 'postToolUseFailure', 'subagentStart', 'subagentStop', + 'beforeShellExecution', 'afterShellExecution', 'beforeMCPExecution', 'afterMCPExecution', 'beforeReadFile', + 'afterFileEdit', 'beforeSubmitPrompt', 'preCompact', 'stop', 'afterAgentResponse', 'afterAgentThought', + ], + app: ['workspaceOpen'], + tab: ['beforeTabFileRead', 'afterTabFileEdit'], +} as const; + +/** The per-event cloud availability table published at https://cursor.com/docs/hooks (retrieved 2026-09-02). */ +const documentedCursorCloudUnavailable = [ + 'sessionStart', 'sessionEnd', 'beforeMCPExecution', 'afterMCPExecution', 'beforeTabFileRead', 'afterTabFileEdit', 'workspaceOpen', +] as const; + +it('pins every documented Cursor hook event exactly once across canonical routes and dated deferrals (#189)', () => { + const documented = Object.values(documentedCursorHookEvents).flat(); + const inventory = cursorCapabilityTable.hooks.nativeEvents as Readonly>; + const schemaEvents = Object.keys(cursorHooksSchema.properties.hooks.properties); + + expect(documented).toHaveLength(21); + expect(Object.keys(inventory).sort()).toEqual([...documented].sort()); + expect([...schemaEvents].sort()).toEqual([...documented].sort()); + + const routes = cursorCapabilityTable.hooks.eventRoutes as Readonly>; + const deferred = cursorCapabilityTable.deferredNativeEvents as Readonly>; + const routedNativeEvents = Object.values(routes).flatMap((route) => route.nativeEvent === undefined ? [] : [route.nativeEvent]); + + for (const [category, events] of Object.entries(documentedCursorHookEvents)) { + for (const event of events) { + const entry = inventory[event]!; + expect(entry.category).toBe(category); + expect(entry.cloud).toBe((documentedCursorCloudUnavailable as readonly string[]).includes(event) ? 'unavailable' : 'supported'); + if (entry.canonical === null) { + expect(entry.row).toBe(`deferredNativeEvents.${event}`); + expect(deferred[event]).toMatchObject({ reason: expect.stringContaining('retrieved 2026-09-02'), state: 'unavailable' }); + expect(routedNativeEvents).not.toContain(event); + } else { + expect(entry.row).toBe(`eventRoutes.${entry.canonical}`); + const route = routes[entry.canonical]!; + expect(route).toMatchObject({ nativeEvent: event, state: 'supported' }); + expect(route.availability?.desktop.state).toBe('supported'); + expect(route.availability?.cloud.state).toBe(entry.cloud); + expect(deferred[event]).toBeUndefined(); + } + } + } + // Each documented event maps to at most one canonical family. + expect(new Set(routedNativeEvents).size).toBe(routedNativeEvents.length); + // Cloud-side plugin delivery is recorded honestly rather than inferred from the per-event table. + expect(cursorCapabilityTable.hooks.cloud).toMatchObject({ + configurationSources: ['project', 'team', 'enterprise'], + executionTypes: ['command'], + pluginHooks: { reason: expect.stringContaining('retrieved 2026-09-02'), state: 'unavailable' }, + }); +}); + +it('records dated Cursor contract rows and mirrors every one through the unified adapter (#189)', () => { + const registry = createDefaultRegistry(); + const cursor = registry.get('cursor'); + const unified = registry.get('plugin'); + const expectedStates = { + agentPluginFormat: 'unavailable', + agents: 'unavailable', + canvases: 'unavailable', + componentDiscovery: 'supported', + cursorPluginFormat: 'supported', + hookFailClosed: 'unavailable', + hookLoopLimit: 'unavailable', + hookMatchers: 'supported', + hookTimeout: 'supported', + installModes: 'unavailable', + localPluginImports: 'unavailable', + localSymlinkInstall: 'unavailable', + manifestMetadata: 'supported', + marketplaceAccess: 'unavailable', + marketplaceAutoRefresh: 'unavailable', + marketplaceManifest: 'supported', + marketplaceReview: 'unavailable', + promptHooks: 'unavailable', + rootSkill: 'unavailable', + teamMarketplaces: 'unavailable', + variables: 'supported', + } as const; + + expect(Object.keys(cursorContractCapabilityRows).sort()).toEqual(Object.keys(expectedStates).sort()); + for (const [capability, expectedState] of Object.entries(expectedStates)) { + const row = cursorContractCapabilityRows[capability as keyof typeof cursorContractCapabilityRows] as { + readonly evidence: readonly string[]; + readonly reason?: string; + readonly state: string; + }; + expect(row.state).toBe(expectedState); + expect(row.evidence.length).toBeGreaterThan(0); + expect(row.evidence.some((line) => /2026-09-0[23]/u.test(line))).toBe(true); + if (expectedState === 'unavailable') { + expect(row.reason?.length ?? 0).toBeGreaterThan(0); + expect(cursor.capabilities[capability]).toEqual({ reason: row.reason, state: 'unavailable' }); + } else { + expect(cursor.capabilities[capability]).toEqual({ + evidence: { observedVersion: '2026-08-28', target: 'cursor' }, + state: 'supported', + }); + } + expect(registry.supports('cursor', capability)).toBe(expectedState === 'supported'); + expect(unified.capabilities[capability]).toBeDefined(); + if (capability === 'manifestMetadata') { + expect(unified.capabilities[capability]).toMatchObject({ state: 'supported' }); + } else { + expect(unified.capabilities[capability]).toMatchObject({ state: 'unavailable' }); + expect(registry.supports('plugin', capability)).toBe(false); + } + } + expect(cursorCapabilityTable.plugin.marketplaceManifest).toMatchObject({ + generatedEntryFields: ['name', 'source', 'description'], + maxEntries: 500, + mergePrecedence: 'plugin-manifest-over-marketplace-entry', + }); + expect(cursorCapabilityTable.plugin.distributionPolicy.installModes.modes).toEqual(['Default Off', 'Default On', 'Required']); + expect(cursorCapabilityTable.plugin.formats).toMatchObject({ + agentPlugin: { manifest: 'plugin.json', state: 'unavailable' }, + cursorPlugin: { manifest: '.cursor-plugin/plugin.json', state: 'supported' }, + }); + expect(cursorCapabilityTable.plugin.componentDiscovery.emitted).toEqual({ + commands: './commands/', + hooks: './hooks/hooks.json', + mcpServers: './mcp.json', + rules: './rules/', + skills: './skills/', + }); +}); diff --git a/packages/agent-bundle/tests/adapter-metadata.test.ts b/packages/agent-bundle/tests/adapter-metadata.test.ts index d681d4013..d9f6579c2 100644 --- a/packages/agent-bundle/tests/adapter-metadata.test.ts +++ b/packages/agent-bundle/tests/adapter-metadata.test.ts @@ -145,7 +145,7 @@ it('records exact immutable metadata for every built-in target', () => { ], }); expect(registryMetadata(registry, 'cursor')).toEqual({ - adapterRevision: '1.8.0', + adapterRevision: '1.9.0', observedVersion: '2026-08-28', schemas: [ { @@ -170,7 +170,7 @@ it('records exact immutable metadata for every built-in target', () => { }, ], }); - expect(registryMetadata(registry, 'plugin').adapterRevision).toBe('1.24.0'); + expect(registryMetadata(registry, 'plugin').adapterRevision).toBe('1.25.0'); }); it('records observed capability versions and rehashes schema snapshots against pinned provenance', async () => { diff --git a/packages/agent-bundle/tests/cursor-adapter.test.ts b/packages/agent-bundle/tests/cursor-adapter.test.ts index fb4439a02..446a4c6e3 100644 --- a/packages/agent-bundle/tests/cursor-adapter.test.ts +++ b/packages/agent-bundle/tests/cursor-adapter.test.ts @@ -165,6 +165,101 @@ it('validates Cursor documents against the vendored real-host schemas', () => { })).toBe(true); }); +const withCursorConfig = (value: unknown): NormalizedPlugin => ({ + ...plugin(), + extensions: { + cursor: { + id: 'extension:cursor', + key: 'cursor', + provenance: { kind: 'config', sourcePath: configPath }, + target: 'cursor', + value, + }, + }, + targets: [ + ...plugin().targets, + { id: 'target:plugin', name: 'plugin', provenance: { kind: 'config', sourcePath: configPath } }, + ], +}); + +it('registers the cursor config extension and emits schema-admitted manifest metadata on both Cursor manifests', () => { + const registry = createDefaultRegistry(); + expect(registry.configExtensions().map((extension) => extension.key)).toContain('cursor'); + + const model = withCursorConfig({ + author: { email: 'devtools@example.test', name: 'Example DevTools' }, + category: 'developer-tools', + homepage: 'https://example.test/cursor-review', + keywords: ['review', 'cursor'], + license: 'MIT', + minClientVersions: { cursor: '3.13.0' }, + publisher: 'Example', + repository: 'https://github.com/example/cursor-review', + tags: ['code-review'], + }); + const plan = cursorAdapter.plan(model); + expect(plan.diagnostics).toEqual([]); + const manifest = JSON.parse(writeContents(model)['.cursor-plugin/plugin.json']!) as Record; + expect(manifest).toMatchObject({ + author: { email: 'devtools@example.test', name: 'Example DevTools' }, + category: 'developer-tools', + homepage: 'https://example.test/cursor-review', + keywords: ['review', 'cursor'], + license: 'MIT', + minClientVersions: { cursor: '3.13.0' }, + name: 'cursor-review', + publisher: 'Example', + repository: 'https://github.com/example/cursor-review', + tags: ['code-review'], + }); + expect(cursorPluginValidator(manifest)).toBe(true); + const manifestEntry = plan.entries.find((entry) => entry.relativePath === '.cursor-plugin/plugin.json'); + expect(manifestEntry?.sourceInputs).toContain(configPath); + + const bundle = pluginAdapter.plan(model); + expect(bundle.diagnostics).toEqual([]); + const bundleManifest = JSON.parse( + (bundle.entries.find((entry) => entry.relativePath === '.cursor-plugin/plugin.json') as { readonly content: string }).content, + ) as Record; + expect(bundleManifest).toMatchObject({ author: { name: 'Example DevTools' }, minClientVersions: { cursor: '3.13.0' }, publisher: 'Example' }); + const claudeManifest = JSON.parse( + (bundle.entries.find((entry) => entry.relativePath === '.claude-plugin/plugin.json') as { readonly content: string }).content, + ) as Record; + expect(claudeManifest).not.toHaveProperty('publisher'); + expect(claudeManifest).not.toHaveProperty('minClientVersions'); +}); + +it('rejects cursor manifest metadata the pinned schema does not admit and emits no partial metadata', () => { + const model = withCursorConfig({ + author: { name: 'Example', url: 'https://example.test' }, + homepage: 'ftp://example.test', + keywords: ['ok', ''], + license: ' ', + minClientVersions: { cursor: '3.13' }, + nativeHooks: './hooks.json', + repository: 'https://github.com/example/cursor-review', + }); + const plan = cursorAdapter.plan(model); + expect(plan.diagnostics.map((diagnostic) => diagnostic.code).sort()).toEqual([ + 'cursor.manifest.author.invalid', + 'cursor.manifest.field.unknown', + 'cursor.manifest.homepage.invalid', + 'cursor.manifest.keywords.invalid', + 'cursor.manifest.license.invalid', + 'cursor.manifest.minClientVersions.invalid', + ]); + expect(plan.diagnostics.every((diagnostic) => diagnostic.severity === 'error' && diagnostic.target === 'cursor')).toBe(true); + const manifest = JSON.parse(writeContents(model)['.cursor-plugin/plugin.json']!) as Record; + for (const field of ['author', 'homepage', 'keywords', 'license', 'minClientVersions', 'repository']) { + expect(manifest).not.toHaveProperty(field); + } + expect(pluginAdapter.plan(model).diagnostics.map((diagnostic) => diagnostic.code)).toContain('plugin.cursor.manifest.author.invalid'); + + expect(cursorAdapter.plan(withCursorConfig({ minClientVersions: {} })).diagnostics.map((diagnostic) => diagnostic.code)) + .toEqual(['cursor.manifest.minClientVersions.invalid']); + expect(cursorAdapter.plan(withCursorConfig({})).diagnostics).toEqual([]); +}); + it('copies plugin.logo into the artifact and references it from plugin.json', () => { const model: NormalizedPlugin = { ...plugin(), diff --git a/packages/agent-bundle/tests/event-project.test.ts b/packages/agent-bundle/tests/event-project.test.ts index 94a6d6ffd..c19e6c796 100644 --- a/packages/agent-bundle/tests/event-project.test.ts +++ b/packages/agent-bundle/tests/event-project.test.ts @@ -110,6 +110,64 @@ it('validates Cursor workspaceOpen without inventing an agent session', () => { })).toThrow(/native session_id or conversation_id must be a string/u); }); +it('validates the documented Cursor subagentStart and subagentStop envelopes fail closed', () => { + // https://cursor.com/docs/hooks#subagentstart / #subagentstop (retrieved 2026-09-02). + const start = { + conversation_id: 'conv-456', + git_branch: 'feature/auth', + hook_event_name: 'subagentStart', + is_parallel_worker: false, + parent_conversation_id: 'conv-456', + subagent_id: 'abc-123', + subagent_model: 'claude-sonnet-4-20250514', + subagent_type: 'generalPurpose', + task: 'Explore the authentication flow', + tool_call_id: 'tc-789', + }; + const startOptions = { canonicalEvent: 'agent/start' as const, nativeEvent: 'subagentStart', target: 'cursor' }; + expect(validateNativeEventEnvelope(start, startOptions)).toBe(start); + expect(() => validateNativeEventEnvelope({ ...start, subagent_type: '' }, startOptions)) + .toThrow(/native subagent_type must be a nonempty string/u); + expect(() => validateNativeEventEnvelope({ ...start, subagent_id: undefined }, startOptions)) + .toThrow(/native subagent_id must be a nonempty string/u); + expect(() => validateNativeEventEnvelope({ ...start, is_parallel_worker: 'no' }, startOptions)) + .toThrow(/native is_parallel_worker must be a boolean/u); + // Claude's agent_id/agent_type spelling is not the Cursor envelope. + expect(() => validateNativeEventEnvelope({ + agent_id: 'abc-123', + agent_type: 'generalPurpose', + conversation_id: 'conv-456', + hook_event_name: 'subagentStart', + }, startOptions)).toThrow(/native subagent_id must be a nonempty string/u); + + const stop = { + agent_transcript_path: '/path/to/subagent/transcript.txt', + conversation_id: 'conv-456', + description: 'Exploring auth flow', + duration_ms: 45_000, + hook_event_name: 'subagentStop', + loop_count: 0, + message_count: 12, + modified_files: ['src/auth.ts'], + status: 'completed', + subagent_type: 'generalPurpose', + summary: 'Found the login handler.', + task: 'Explore the authentication flow', + tool_call_count: 8, + }; + const stopOptions = { canonicalEvent: 'agent/stop' as const, nativeEvent: 'subagentStop', target: 'cursor' }; + expect(validateNativeEventEnvelope(stop, stopOptions)).toBe(stop); + expect(validateNativeEventEnvelope({ ...stop, agent_transcript_path: null }, stopOptions)).toBeDefined(); + expect(() => validateNativeEventEnvelope({ ...stop, status: 'cancelled' }, stopOptions)) + .toThrow(/native status is invalid/u); + expect(() => validateNativeEventEnvelope({ ...stop, loop_count: '0' }, stopOptions)) + .toThrow(/native loop_count must be a number/u); + expect(() => validateNativeEventEnvelope({ ...stop, modified_files: 'src/auth.ts' }, stopOptions)) + .toThrow(/native modified_files must be an array of strings/u); + expect(() => validateNativeEventEnvelope({ ...stop, agent_transcript_path: 7 }, stopOptions)) + .toThrow(/native agent_transcript_path must be a string or null/u); +}); + it('validates prompt/submit and session/end host envelopes fail closed', () => { const promptEnvelopes = [ { diff --git a/packages/agent-bundle/tests/fixtures/events/cursor-subagent-start.json b/packages/agent-bundle/tests/fixtures/events/cursor-subagent-start.json new file mode 100644 index 000000000..95808fc37 --- /dev/null +++ b/packages/agent-bundle/tests/fixtures/events/cursor-subagent-start.json @@ -0,0 +1,18 @@ +{ + "conversation_id": "conv-456", + "cursor_version": "1.7.2", + "generation_id": "gen-1", + "git_branch": "feature/auth", + "hook_event_name": "subagentStart", + "is_parallel_worker": false, + "model": "claude-sonnet-4-20250514", + "parent_conversation_id": "conv-456", + "subagent_id": "abc-123", + "subagent_model": "claude-sonnet-4-20250514", + "subagent_type": "explore", + "task": "Explore the authentication flow", + "tool_call_id": "tc-789", + "transcript_path": null, + "user_email": null, + "workspace_roots": ["/workspace"] +} diff --git a/packages/agent-bundle/tests/fixtures/events/cursor-subagent-stop.json b/packages/agent-bundle/tests/fixtures/events/cursor-subagent-stop.json new file mode 100644 index 000000000..357853213 --- /dev/null +++ b/packages/agent-bundle/tests/fixtures/events/cursor-subagent-stop.json @@ -0,0 +1,21 @@ +{ + "agent_transcript_path": "/workspace/subagents/abc-123.txt", + "conversation_id": "conv-456", + "cursor_version": "1.7.2", + "description": "Exploring auth flow", + "duration_ms": 45000, + "generation_id": "gen-1", + "hook_event_name": "subagentStop", + "loop_count": 0, + "message_count": 12, + "model": "claude-sonnet-4-20250514", + "modified_files": ["src/auth.ts"], + "status": "completed", + "subagent_type": "explore", + "summary": "Found the login handler.", + "task": "Explore the authentication flow", + "tool_call_count": 8, + "transcript_path": null, + "user_email": null, + "workspace_roots": ["/workspace"] +} diff --git a/packages/agent-bundle/tests/hooks.test.ts b/packages/agent-bundle/tests/hooks.test.ts index c6796dbad..071927c2b 100644 --- a/packages/agent-bundle/tests/hooks.test.ts +++ b/packages/agent-bundle/tests/hooks.test.ts @@ -1225,6 +1225,109 @@ it('round-trips Claude and Codex subagent fields through published wrappers', as } }, 15_000); +it('round-trips the documented Cursor subagent envelopes through published Cursor wrappers', async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-cursor-subagent-hook-codecs-')); + const sourceRoot = join(root, 'src', 'hooks'); + const outputRoot = join(root, 'dist'); + const base = hookModel(root); + const model: NormalizedPlugin = { + ...base, + hooks: [ + { + ...base.hooks[0]!, + event: 'agentStart', + id: 'hook:agent-start:subagent-start', + name: 'subagent-start', + source: join(sourceRoot, 'subagent-start.ts'), + targets: ['cursor'], + }, + { + ...base.hooks[3]!, + event: 'agentStop', + id: 'hook:agent-stop:subagent-stop', + name: 'subagent-stop', + source: join(sourceRoot, 'subagent-stop.ts'), + targets: ['cursor'], + }, + ], + targets: [ + { id: 'target:cursor', name: 'cursor', provenance: { kind: 'config', sourcePath: join(root, 'agent-bundle.config.ts') } }, + ], + }; + + try { + await mkdir(sourceRoot, { recursive: true }); + await Promise.all([ + writeFile(join(root, 'agent-bundle.config.ts'), 'export default {};\n'), + writeFile(join(root, 'package.json'), '{"type":"module"}\n'), + writeFile( + join(sourceRoot, 'subagent-start.ts'), + "export default (event: Record) => ({ outcome: 'deny' as const, reason: `${String(event.sessionId)}:${String(event.agentId)}:${String(event.agentType)}:${String(event.toolUseId)}` });\n", + ), + writeFile( + join(sourceRoot, 'subagent-stop.ts'), + "export default (event: Record) => ({ outcome: 'deny' as const, reason: `${String(event.agentTranscriptPath)}:${String(event.stopHookActive)}:${String(event.lastAssistantMessage)}:${String(event.agentType)}` });\n", + ), + ]); + await build({ model, outputRoot, projectRoot: root, registry: createDefaultRegistry() }); + + const document = JSON.parse(await readFile(join(outputRoot, 'cursor', 'hooks', 'hooks.json'), 'utf8')) as { + readonly hooks: Readonly>; + readonly version: number; + }; + expect(document.version).toBe(1); + expect(document.hooks.subagentStart).toEqual([{ command: 'node "${CURSOR_PLUGIN_ROOT}/hooks/subagent-start.mjs"' }]); + expect(document.hooks.subagentStop).toEqual([{ command: 'node "${CURSOR_PLUGIN_ROOT}/hooks/subagent-stop.mjs"' }]); + + const startInput = JSON.parse(await readFile( + new URL('./fixtures/events/cursor-subagent-start.json', import.meta.url), + 'utf8', + )) as Record; + const stopInput = JSON.parse(await readFile( + new URL('./fixtures/events/cursor-subagent-stop.json', import.meta.url), + 'utf8', + )) as Record; + // https://cursor.com/docs/hooks#subagentstart: { permission, user_message }. + await expect(runNativeHook(join(outputRoot, 'cursor', 'hooks', 'subagent-start.mjs'), startInput)).resolves.toEqual({ + code: 0, + stderr: '', + stdout: JSON.stringify({ + permission: 'deny', + user_message: `${String(startInput.conversation_id)}:${String(startInput.subagent_id)}:${String(startInput.subagent_type)}:${String(startInput.tool_call_id)}`, + }), + }); + // https://cursor.com/docs/hooks#subagentstop: { followup_message }. + await expect(runNativeHook(join(outputRoot, 'cursor', 'hooks', 'subagent-stop.mjs'), stopInput)).resolves.toEqual({ + code: 0, + stderr: '', + stdout: JSON.stringify({ + followup_message: `${String(stopInput.agent_transcript_path)}:false:${String(stopInput.summary)}:${String(stopInput.subagent_type)}`, + }), + }); + // The Claude/Codex agent_id/agent_type spelling is not the Cursor envelope. + await expect(runNativeHook(join(outputRoot, 'cursor', 'hooks', 'subagent-start.mjs'), { + agent_id: 'abc-123', + agent_type: 'explore', + conversation_id: 'conv-456', + hook_event_name: 'subagentStart', + })).resolves.toEqual({ + code: 1, + stderr: 'Agent Bundle hook error: native subagent_id must be a string\n', + stdout: '', + }); + await expect(runNativeHook(join(outputRoot, 'cursor', 'hooks', 'subagent-stop.mjs'), { + ...stopInput, + status: 'cancelled', + })).resolves.toEqual({ + code: 1, + stderr: 'Agent Bundle hook error: native subagentStop status is invalid\n', + stdout: '', + }); + } finally { + await rm(root, { force: true, recursive: true }); + } +}, 15_000); + it('rejects malformed event-specific native input before calling generated Codex and Claude hooks', async () => { const root = await mkdtemp(join(tmpdir(), 'agent-bundle-hooks-native-input-')); const sourceRoot = join(root, 'src', 'hooks'); diff --git a/packages/agent-bundle/tests/route-unit/event-project.test.ts b/packages/agent-bundle/tests/route-unit/event-project.test.ts index af5a5fb5b..52ba5cb7f 100644 --- a/packages/agent-bundle/tests/route-unit/event-project.test.ts +++ b/packages/agent-bundle/tests/route-unit/event-project.test.ts @@ -149,6 +149,89 @@ it('projects subagent-stop continuation only through supported host contracts', .toThrow(/must resolve the invoking host/u); }); +it('projects the Cursor subagent lifecycle through its documented permission and followup_message channels only', async () => { + // https://cursor.com/docs/hooks#subagentstart / #subagentstop (retrieved 2026-09-02). + const startNative = { + conversation_id: 'conv-456', + cursor_version: '1.7.2', + hook_event_name: 'subagentStart', + is_parallel_worker: false, + parent_conversation_id: 'conv-456', + subagent_id: 'abc-123', + subagent_model: 'claude-sonnet-4-20250514', + subagent_type: 'explore', + task: 'Explore the authentication flow', + tool_call_id: 'tc-789', + workspace_roots: ['/workspace'], + }; + const startProps = createCanonicalEventProps('agent/start', startNative, 'cursor', 'subagentStart', '2026-08-28', new AbortController().signal); + const startInput = { + input: { canonical: startProps.canonical, native: startProps.native }, + kind: 'event-route', + routeId: 'event:agent/start', + } as const; + + const denied = await renderRoute({ + default: async () => createElement(Agent.Result, { value: { outcome: 'deny', reason: 'Explore subagents are disabled here.' } }), + }, startInput); + expect(projectEventDocument(denied.document, 'agent/start', 'cursor', 'subagentStart')).toEqual({ + permission: 'deny', + user_message: 'Explore subagents are disabled here.', + }); + const allowed = await renderRoute({ + default: async () => createElement(Agent.Result), + }, startInput); + expect(projectEventDocument(allowed.document, 'agent/start', 'cursor', 'subagentStart')).toBeUndefined(); + const startContext = await renderRoute({ + default: async () => createElement(Agent.Result, null, createElement(Agent.Context, null, 'Read the test conventions first.')), + }, startInput); + expect(() => projectEventDocument(startContext.document, 'agent/start', 'cursor', 'subagentStart')) + .toThrow(/Cursor subagentStart has no additional-context channel/u); + const startReplaced = await renderRoute({ + default: async () => createElement(Agent.Result, { value: { updatedInput: { task: 'Different task' } } }), + }, startInput); + expect(() => projectEventDocument(startReplaced.document, 'agent/start', 'cursor', 'subagentStart')) + .toThrow(/agent\/start cannot replace native input/u); + + const stopNative = { + agent_transcript_path: '/workspace/subagents/abc-123.txt', + conversation_id: 'conv-456', + description: 'Exploring auth flow', + duration_ms: 45_000, + hook_event_name: 'subagentStop', + loop_count: 0, + message_count: 12, + modified_files: ['src/auth.ts'], + status: 'completed', + subagent_type: 'explore', + summary: 'Found the login handler.', + task: 'Explore the authentication flow', + tool_call_count: 8, + }; + const stopProps = createCanonicalEventProps('agent/stop', stopNative, 'cursor', 'subagentStop', '2026-08-28', new AbortController().signal); + const stopInput = { + input: { canonical: stopProps.canonical, native: stopProps.native }, + kind: 'event-route', + routeId: 'event:agent/stop', + } as const; + + const continued = await renderRoute({ + default: async () => createElement(Agent.Result, { value: { outcome: 'deny', reason: 'Run one more focused pass.' } }), + }, stopInput); + expect(projectEventDocument(continued.document, 'agent/stop', 'cursor', 'subagentStop')).toEqual({ + followup_message: 'Run one more focused pass.', + }); + const observed = await renderRoute({ + default: async () => createElement(Agent.Result), + }, stopInput); + expect(projectEventDocument(observed.document, 'agent/stop', 'cursor', 'subagentStop')).toBeUndefined(); + const stopContext = await renderRoute({ + default: async () => createElement(Agent.Result, null, createElement(Agent.Context, null, 'Check the final result.')), + }, stopInput); + expect(() => projectEventDocument(stopContext.document, 'agent/stop', 'cursor', 'subagentStop')) + .toThrow(/Cursor subagentStop has no additional-context channel/u); +}); + it('projects workspace/open as a fire-and-forget observation only', async () => { const props = createCanonicalEventProps( 'workspace/open', From 980e2b97ff3b6ddeb542e74bd077f028d678fb89 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 06:39:52 +0000 Subject: [PATCH 2/9] fix(cursor): validate manifest URLs as the exact emitted string via the pinned uri format Addresses Codex review on #375: new URL() normalizes whitespace and unescaped characters that the pinned plugin schema's format: uri rejects, so the plan reported a generic cursor.schema.plugin error and kept the invalid metadata. homepage/repository now go through the schema's own uri format checker first and fail closed with cursor.manifest..invalid. --- packages/agent-bundle/src/adapters/cursor.ts | 10 +++++-- .../agent-bundle/tests/cursor-adapter.test.ts | 27 +++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/packages/agent-bundle/src/adapters/cursor.ts b/packages/agent-bundle/src/adapters/cursor.ts index 675d34358..6249fcb60 100644 --- a/packages/agent-bundle/src/adapters/cursor.ts +++ b/packages/agent-bundle/src/adapters/cursor.ts @@ -110,6 +110,12 @@ const validatePlugin = validator.compile(pluginSchema); const validateMcp = validator.compile(mcpSchema); const validateHooks = validator.compile(hooksSchema); const validateMarketplace = validator.compile(marketplaceSchema); +/** + * The exact `format: "uri"` check the pinned plugin schema applies to + * `homepage`/`repository`, so metadata is validated as the string that will + * be emitted rather than as the normalized form `new URL()` would accept. + */ +const validateSchemaUri = validator.compile({ type: 'string', format: 'uri' }); /** The pinned Cursor document validators, shared with the unified bundle adapter. */ export const cursorPluginValidator = validatePlugin; @@ -294,7 +300,7 @@ const isNonemptyString = (value: unknown): value is string => typeof value === 'string' && value.trim().length > 0; const isAbsoluteUrl = (value: unknown): value is string => { - if (!isNonemptyString(value)) return false; + if (!isNonemptyString(value) || !validateSchemaUri(value)) return false; try { const url = new URL(value); return url.protocol === 'http:' || url.protocol === 'https:'; @@ -395,7 +401,7 @@ export const planCursorManifestMetadata = ( const url = value[field]; if (url === undefined) continue; if (isAbsoluteUrl(url)) document[field] = url; - else diagnostics.push(errorDiagnostic(`${codePrefix}.manifest.${field}.invalid`, `Cursor ${field} must be an absolute HTTP or HTTPS URL.`)); + else diagnostics.push(errorDiagnostic(`${codePrefix}.manifest.${field}.invalid`, `Cursor ${field} must be an absolute HTTP or HTTPS URL written exactly as the pinned schema's uri format admits (no surrounding whitespace or unescaped characters).`)); } for (const field of ['category', 'license', 'publisher'] as const) { const text = value[field]; diff --git a/packages/agent-bundle/tests/cursor-adapter.test.ts b/packages/agent-bundle/tests/cursor-adapter.test.ts index 446a4c6e3..f14a5d2d9 100644 --- a/packages/agent-bundle/tests/cursor-adapter.test.ts +++ b/packages/agent-bundle/tests/cursor-adapter.test.ts @@ -260,6 +260,33 @@ it('rejects cursor manifest metadata the pinned schema does not admit and emits expect(cursorAdapter.plan(withCursorConfig({})).diagnostics).toEqual([]); }); +it('rejects cursor URLs that new URL() would normalize but the pinned uri format rejects, without a generic schema error', () => { + const normalizedByUrlParser = [ + ' https://example.test/cursor-review ', + 'https://example.test/cursor review', + 'https://example.test/päth', + 'https://example.test/', + ]; + for (const url of normalizedByUrlParser) { + expect(() => new URL(url)).not.toThrow(); + const model = withCursorConfig({ homepage: url, repository: 'https://github.com/example/cursor-review' }); + const plan = cursorAdapter.plan(model); + expect(plan.diagnostics.map((diagnostic) => diagnostic.code)).toEqual(['cursor.manifest.homepage.invalid']); + const manifest = JSON.parse(writeContents(model)['.cursor-plugin/plugin.json']!) as Record; + expect(manifest).not.toHaveProperty('homepage'); + expect(manifest).not.toHaveProperty('repository'); + } + const exact = withCursorConfig({ + homepage: 'https://example.test', + repository: 'https://EXAMPLE.test/a%2Fb?ref=main#readme', + }); + const plan = cursorAdapter.plan(exact); + expect(plan.diagnostics).toEqual([]); + const manifest = JSON.parse(writeContents(exact)['.cursor-plugin/plugin.json']!) as Record; + expect(manifest['homepage']).toBe('https://example.test'); + expect(manifest['repository']).toBe('https://EXAMPLE.test/a%2Fb?ref=main#readme'); +}); + it('copies plugin.logo into the artifact and references it from plugin.json', () => { const model: NormalizedPlugin = { ...plugin(), From 49600ea91a5edc95084d151d90f0c41b7b006855 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 07:14:02 +0000 Subject: [PATCH 3/9] fix(cursor): require every documented subagent envelope field and validate author.email with the pinned format Addresses the second Codex review round on #375: - subagentStart: parent_conversation_id, tool_call_id, subagent_model, and is_parallel_worker are required (only git_branch is documented optional) in both the route envelope validator and the generated wrapper validator. - subagentStop: task, description, summary, duration_ms, message_count, tool_call_count, modified_files, and agent_transcript_path (string|null) are required to match the documented input, which marks no field optional. - Simulation encoders carry neutral values for the mandatory fields canonical input lacks so hook simulation still passes the tightened validators. - author.email goes through the pinned schema's format: email checker so values like dev@example..com fail closed as cursor.manifest.author.email.invalid. --- packages/agent-bundle/src/adapters/cursor.ts | 12 ++++--- .../src/adapters/hook-contract.ts | 33 ++++++++++++++----- .../agent-bundle/src/events/projection.ts | 26 +++++++-------- .../agent-bundle/tests/cursor-adapter.test.ts | 9 +++++ .../agent-bundle/tests/event-project.test.ts | 27 +++++++++++++++ packages/agent-bundle/tests/hooks.test.ts | 33 ++++++++++++++++++- 6 files changed, 112 insertions(+), 28 deletions(-) diff --git a/packages/agent-bundle/src/adapters/cursor.ts b/packages/agent-bundle/src/adapters/cursor.ts index 6249fcb60..fd111a873 100644 --- a/packages/agent-bundle/src/adapters/cursor.ts +++ b/packages/agent-bundle/src/adapters/cursor.ts @@ -111,11 +111,13 @@ const validateMcp = validator.compile(mcpSchema); const validateHooks = validator.compile(hooksSchema); const validateMarketplace = validator.compile(marketplaceSchema); /** - * The exact `format: "uri"` check the pinned plugin schema applies to - * `homepage`/`repository`, so metadata is validated as the string that will - * be emitted rather than as the normalized form `new URL()` would accept. + * The exact `format: "uri"` / `format: "email"` checks the pinned plugin + * schema applies to `homepage`/`repository` and `author.email`, so metadata is + * validated as the string that will be emitted rather than through a looser + * local approximation (`new URL()` normalizes; a hand regex admits `a@b..c`). */ const validateSchemaUri = validator.compile({ type: 'string', format: 'uri' }); +const validateSchemaEmail = validator.compile({ type: 'string', format: 'email' }); /** The pinned Cursor document validators, shared with the unified bundle adapter. */ export const cursorPluginValidator = validatePlugin; @@ -310,7 +312,7 @@ const isAbsoluteUrl = (value: unknown): value is string => { }; const isEmail = (value: unknown): value is string => - isNonemptyString(value) && /^[^\s@]+@[^\s@]+\.[^\s@]+$/u.test(value); + isNonemptyString(value) && validateSchemaEmail(value) === true; const isNonemptyStringArray = (value: unknown): value is readonly string[] => Array.isArray(value) && value.every(isNonemptyString); @@ -387,7 +389,7 @@ export const planCursorManifestMetadata = ( diagnostics.push(errorDiagnostic(`${codePrefix}.manifest.author.name.invalid`, 'Cursor author.name must be a nonempty string.')); } if (author['email'] !== undefined && !isEmail(author['email'])) { - diagnostics.push(errorDiagnostic(`${codePrefix}.manifest.author.email.invalid`, 'Cursor author.email must be a valid email address.')); + diagnostics.push(errorDiagnostic(`${codePrefix}.manifest.author.email.invalid`, "Cursor author.email must be an email address the pinned schema's email format admits.")); } if (extra.length === 0 && isNonemptyString(author['name']) && (author['email'] === undefined || isEmail(author['email']))) { document['author'] = Object.freeze({ diff --git a/packages/agent-bundle/src/adapters/hook-contract.ts b/packages/agent-bundle/src/adapters/hook-contract.ts index 39b8aa898..83f48771a 100644 --- a/packages/agent-bundle/src/adapters/hook-contract.ts +++ b/packages/agent-bundle/src/adapters/hook-contract.ts @@ -534,21 +534,32 @@ export const encodeCursorPlaygroundInput = ( // 2026-09-02): Cursor names the subagent fields subagent_* and reports the // completion summary and follow-up loop count instead of Claude's // last_assistant_message / stop_hook_active pair. + // Every documented field except git_branch is required, so the simulated + // envelope carries neutral values for the ones canonical input lacks. ...(nativeEvent === 'subagentStart' ? { + is_parallel_worker: false, + parent_conversation_id: input.sessionId, subagent_id: input.agentId, + subagent_model: input.model ?? '', subagent_type: input.agentType, task: '', - tool_call_id: input.toolUseId, + tool_call_id: input.toolUseId ?? '', } : {}), ...(nativeEvent === 'subagentStop' ? { agent_transcript_path: input.agentTranscriptPath ?? null, + description: '', + duration_ms: 0, loop_count: input.stopHookActive === true ? 1 : 0, + message_count: 0, + modified_files: [], status: 'completed', subagent_type: input.agentType, - summary: input.lastAssistantMessage, + summary: input.lastAssistantMessage ?? '', + task: '', + tool_call_count: 0, } : {}), session_id: input.sessionId, @@ -771,8 +782,8 @@ export const cursorHookWrapperSource = (entry: TargetHookWrapper): string => [ ' cwd: canonicalInput.cwd,', ' hook_event_name: nativeEvent,', ' ...(canonicalEvent === "stop" ? { loop_count: canonicalInput.stopHookActive === true ? 1 : 0, status: "completed" } : {}),', - ' ...(canonicalEvent === "agentStart" ? { subagent_id: canonicalInput.agentId, subagent_type: canonicalInput.agentType, task: "", tool_call_id: canonicalInput.toolUseId } : {}),', - ' ...(canonicalEvent === "agentStop" ? { agent_transcript_path: canonicalInput.agentTranscriptPath ?? null, loop_count: canonicalInput.stopHookActive === true ? 1 : 0, status: "completed", subagent_type: canonicalInput.agentType, summary: canonicalInput.lastAssistantMessage } : {}),', + ' ...(canonicalEvent === "agentStart" ? { is_parallel_worker: false, parent_conversation_id: canonicalInput.sessionId, subagent_id: canonicalInput.agentId, subagent_model: canonicalInput.model ?? "", subagent_type: canonicalInput.agentType, task: "", tool_call_id: canonicalInput.toolUseId ?? "" } : {}),', + ' ...(canonicalEvent === "agentStop" ? { agent_transcript_path: canonicalInput.agentTranscriptPath ?? null, description: "", duration_ms: 0, loop_count: canonicalInput.stopHookActive === true ? 1 : 0, message_count: 0, modified_files: [], status: "completed", subagent_type: canonicalInput.agentType, summary: canonicalInput.lastAssistantMessage ?? "", task: "", tool_call_count: 0 } : {}),', ' session_id: canonicalInput.sessionId,', ' ...(subagentEvent ? {} : { tool_input: canonicalInput.toolInput, tool_name: canonicalInput.toolName, tool_use_id: canonicalInput.toolUseId }),', ' ...(canonicalEvent === "afterTool" && canonicalInput.toolResponse !== undefined ? { tool_output: JSON.stringify(canonicalInput.toolResponse) } : {}),', @@ -833,15 +844,19 @@ export const cursorHookWrapperSource = (entry: TargetHookWrapper): string => [ ' return;', ' }', ' if (canonicalEvent === "agentStart") {', - ' requireString(input, "subagent_id");', - ' requireString(input, "subagent_type");', - ' requireString(input, "task");', + ' for (const field of ["subagent_id", "subagent_type", "task", "parent_conversation_id", "tool_call_id", "subagent_model"]) requireString(input, field);', + ' if (typeof input.is_parallel_worker !== "boolean") fail("native is_parallel_worker must be a boolean");', + ' if (input.git_branch !== undefined) requireString(input, "git_branch");', ' return;', ' }', ' if (canonicalEvent === "agentStop") {', - ' requireString(input, "subagent_type");', + ' for (const field of ["subagent_type", "task", "description", "summary"]) requireString(input, field);', ' if (!["completed", "error", "aborted"].includes(input.status)) fail("native subagentStop status is invalid");', - ' if (typeof input.loop_count !== "number") fail("native subagentStop loop_count must be a number");', + ' for (const field of ["duration_ms", "message_count", "tool_call_count", "loop_count"]) {', + ' if (typeof input[field] !== "number") fail(`native subagentStop ${field} must be a number`);', + ' }', + ' if (!Array.isArray(input.modified_files) || !input.modified_files.every((file) => typeof file === "string")) fail("native modified_files must be an array of strings");', + ' if (input.agent_transcript_path !== null && typeof input.agent_transcript_path !== "string") fail("native agent_transcript_path must be a string or null");', ' return;', ' }', ' if (typeof input.loop_count !== "number") fail("native stop loop_count must be a number");', diff --git a/packages/agent-bundle/src/events/projection.ts b/packages/agent-bundle/src/events/projection.ts index bb228ceba..9730731de 100644 --- a/packages/agent-bundle/src/events/projection.ts +++ b/packages/agent-bundle/src/events/projection.ts @@ -185,13 +185,15 @@ export const validateNativeEventEnvelope = ( } if (canonicalEvent === 'agent/start') { // https://cursor.com/docs/hooks#subagentstart (retrieved 2026-09-02). + // Only git_branch is documented "(optional)"; every other field is required. requireNativeString(native, 'subagent_id'); requireNativeString(native, 'subagent_type'); requireNativeStringValue(native, 'task'); - for (const field of ['parent_conversation_id', 'tool_call_id', 'subagent_model', 'git_branch']) { - if (Object.hasOwn(native, field)) requireNativeStringValue(native, field); - } - if (Object.hasOwn(native, 'is_parallel_worker')) requireNativeBoolean(native, 'is_parallel_worker'); + requireNativeString(native, 'parent_conversation_id'); + requireNativeString(native, 'tool_call_id'); + requireNativeStringValue(native, 'subagent_model'); + requireNativeBoolean(native, 'is_parallel_worker'); + if (Object.hasOwn(native, 'git_branch')) requireNativeStringValue(native, 'git_branch'); } if (canonicalEvent === 'agent/stop') { // https://cursor.com/docs/hooks#subagentstop (retrieved 2026-09-02). @@ -199,23 +201,21 @@ export const validateNativeEventEnvelope = ( if (!['completed', 'error', 'aborted'].includes(String(native.status))) { return nativeEventError('native status is invalid'); } + // The documented subagentStop input marks no field optional; + // agent_transcript_path is `string | null`. for (const field of ['task', 'description', 'summary']) { - if (Object.hasOwn(native, field)) requireNativeStringValue(native, field); + requireNativeStringValue(native, field); } for (const field of ['duration_ms', 'message_count', 'tool_call_count']) { - if (Object.hasOwn(native, field)) requireNativeNumber(native, field); + requireNativeNumber(native, field); } requireNativeNumber(native, 'loop_count'); - if ( - Object.hasOwn(native, 'modified_files') - && (!Array.isArray(native.modified_files) || !native.modified_files.every((file) => typeof file === 'string')) - ) { + if (!Array.isArray(native.modified_files) || !native.modified_files.every((file) => typeof file === 'string')) { return nativeEventError('native modified_files must be an array of strings'); } if ( - Object.hasOwn(native, 'agent_transcript_path') - && native.agent_transcript_path !== null - && typeof native.agent_transcript_path !== 'string' + !Object.hasOwn(native, 'agent_transcript_path') + || (native.agent_transcript_path !== null && typeof native.agent_transcript_path !== 'string') ) { return nativeEventError('native agent_transcript_path must be a string or null'); } diff --git a/packages/agent-bundle/tests/cursor-adapter.test.ts b/packages/agent-bundle/tests/cursor-adapter.test.ts index f14a5d2d9..c70990fe3 100644 --- a/packages/agent-bundle/tests/cursor-adapter.test.ts +++ b/packages/agent-bundle/tests/cursor-adapter.test.ts @@ -276,13 +276,22 @@ it('rejects cursor URLs that new URL() would normalize but the pinned uri format expect(manifest).not.toHaveProperty('homepage'); expect(manifest).not.toHaveProperty('repository'); } + // author.email goes through the same pinned `format: "email"` checker; a + // hand regex would admit the doubled dot and defer the failure to the + // generic schema pass. + const doubledDot = withCursorConfig({ author: { email: 'dev@example..com', name: 'Example' } }); + expect(cursorAdapter.plan(doubledDot).diagnostics.map((diagnostic) => diagnostic.code)) + .toEqual(['cursor.manifest.author.email.invalid']); + expect(JSON.parse(writeContents(doubledDot)['.cursor-plugin/plugin.json']!)).not.toHaveProperty('author'); const exact = withCursorConfig({ + author: { email: 'dev@example.com', name: 'Example' }, homepage: 'https://example.test', repository: 'https://EXAMPLE.test/a%2Fb?ref=main#readme', }); const plan = cursorAdapter.plan(exact); expect(plan.diagnostics).toEqual([]); const manifest = JSON.parse(writeContents(exact)['.cursor-plugin/plugin.json']!) as Record; + expect(manifest['author']).toEqual({ email: 'dev@example.com', name: 'Example' }); expect(manifest['homepage']).toBe('https://example.test'); expect(manifest['repository']).toBe('https://EXAMPLE.test/a%2Fb?ref=main#readme'); }); diff --git a/packages/agent-bundle/tests/event-project.test.ts b/packages/agent-bundle/tests/event-project.test.ts index c19e6c796..0c06717d8 100644 --- a/packages/agent-bundle/tests/event-project.test.ts +++ b/packages/agent-bundle/tests/event-project.test.ts @@ -132,6 +132,19 @@ it('validates the documented Cursor subagentStart and subagentStop envelopes fai .toThrow(/native subagent_id must be a nonempty string/u); expect(() => validateNativeEventEnvelope({ ...start, is_parallel_worker: 'no' }, startOptions)) .toThrow(/native is_parallel_worker must be a boolean/u); + // Only git_branch is documented "(optional)"; every other field must be present. + const { git_branch: _gitBranch, ...withoutGitBranch } = start; + expect(validateNativeEventEnvelope(withoutGitBranch, startOptions)).toBe(withoutGitBranch); + for (const [field, message] of [ + ['parent_conversation_id', 'native parent_conversation_id must be a nonempty string'], + ['tool_call_id', 'native tool_call_id must be a nonempty string'], + ['subagent_model', 'native subagent_model must be a string'], + ['is_parallel_worker', 'native is_parallel_worker must be a boolean'], + ['task', 'native task must be a string'], + ] as const) { + const { [field]: _omitted, ...missing } = start; + expect(() => validateNativeEventEnvelope(missing, startOptions)).toThrow(message); + } // Claude's agent_id/agent_type spelling is not the Cursor envelope. expect(() => validateNativeEventEnvelope({ agent_id: 'abc-123', @@ -166,6 +179,20 @@ it('validates the documented Cursor subagentStart and subagentStop envelopes fai .toThrow(/native modified_files must be an array of strings/u); expect(() => validateNativeEventEnvelope({ ...stop, agent_transcript_path: 7 }, stopOptions)) .toThrow(/native agent_transcript_path must be a string or null/u); + // The documented subagentStop input marks no field optional. + for (const [field, message] of [ + ['task', 'native task must be a string'], + ['description', 'native description must be a string'], + ['summary', 'native summary must be a string'], + ['duration_ms', 'native duration_ms must be a number'], + ['message_count', 'native message_count must be a number'], + ['tool_call_count', 'native tool_call_count must be a number'], + ['modified_files', 'native modified_files must be an array of strings'], + ['agent_transcript_path', 'native agent_transcript_path must be a string or null'], + ] as const) { + const { [field]: _omitted, ...missing } = stop; + expect(() => validateNativeEventEnvelope(missing, stopOptions)).toThrow(message); + } }); it('validates prompt/submit and session/end host envelopes fail closed', () => { diff --git a/packages/agent-bundle/tests/hooks.test.ts b/packages/agent-bundle/tests/hooks.test.ts index 071927c2b..1ee17871a 100644 --- a/packages/agent-bundle/tests/hooks.test.ts +++ b/packages/agent-bundle/tests/hooks.test.ts @@ -1323,10 +1323,41 @@ it('round-trips the documented Cursor subagent envelopes through published Curso stderr: 'Agent Bundle hook error: native subagentStop status is invalid\n', stdout: '', }); + // Every documented field except git_branch is mandatory: a malformed + // envelope must fail closed before the handler runs with undefined fields. + const { git_branch: _gitBranch, ...startWithoutGitBranch } = startInput; + await expect(runNativeHook(join(outputRoot, 'cursor', 'hooks', 'subagent-start.mjs'), startWithoutGitBranch)) + .resolves.toMatchObject({ code: 0, stderr: '' }); + for (const [field, message] of [ + ['tool_call_id', 'native tool_call_id must be a string'], + ['parent_conversation_id', 'native parent_conversation_id must be a string'], + ['subagent_model', 'native subagent_model must be a string'], + ['is_parallel_worker', 'native is_parallel_worker must be a boolean'], + ] as const) { + const { [field]: _omitted, ...missing } = startInput; + await expect(runNativeHook(join(outputRoot, 'cursor', 'hooks', 'subagent-start.mjs'), missing)).resolves.toEqual({ + code: 1, + stderr: `Agent Bundle hook error: ${message}\n`, + stdout: '', + }); + } + for (const [field, message] of [ + ['description', 'native description must be a string'], + ['duration_ms', 'native subagentStop duration_ms must be a number'], + ['modified_files', 'native modified_files must be an array of strings'], + ['agent_transcript_path', 'native agent_transcript_path must be a string or null'], + ] as const) { + const { [field]: _omitted, ...missing } = stopInput; + await expect(runNativeHook(join(outputRoot, 'cursor', 'hooks', 'subagent-stop.mjs'), missing)).resolves.toEqual({ + code: 1, + stderr: `Agent Bundle hook error: ${message}\n`, + stdout: '', + }); + } } finally { await rm(root, { force: true, recursive: true }); } -}, 15_000); +}, 30_000); it('rejects malformed event-specific native input before calling generated Codex and Claude hooks', async () => { const root = await mkdtemp(join(tmpdir(), 'agent-bundle-hooks-native-input-')); From 736268867b9e57e6051c70d23408c7bb02b4c58a Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 07:45:20 +0000 Subject: [PATCH 4/9] fix(cursor): fail closed on subagentStop continuations Cursor would ignore and on reasons without deny Addresses the third and fourth Codex review rounds on #375: - projectEventDocument accepts the validated native envelope (all production callers pass it); a denied agent/stop on Cursor throws unless the subagent status is completed, since followup_message is consumed only then. The generated wrapper's encodeOutput applies the same check. - The Cursor agent/stop route branch rejects a reason without outcome deny, matching the generated wrapper validator instead of swallowing it. --- .../src/adapters/hook-contract.ts | 7 ++++--- .../playground/lifecycle-replay-service.ts | 2 +- .../agent-bundle/src/events/projection.ts | 20 ++++++++++++++++--- .../agent-bundle/src/mcp-server-runtime.ts | 1 + packages/agent-bundle/tests/hooks.test.ts | 9 +++++++++ .../tests/route-unit/event-project.test.ts | 19 ++++++++++++++++-- 6 files changed, 49 insertions(+), 9 deletions(-) diff --git a/packages/agent-bundle/src/adapters/hook-contract.ts b/packages/agent-bundle/src/adapters/hook-contract.ts index 83f48771a..1014fddbd 100644 --- a/packages/agent-bundle/src/adapters/hook-contract.ts +++ b/packages/agent-bundle/src/adapters/hook-contract.ts @@ -701,7 +701,7 @@ const eventRouteHookWrapperSource = ( ' signal,', ' ...(workspaceRoot === undefined ? {} : { workspace: available({ root: workspaceRoot }, "native") }),', ' }, async () => renderStandalone({ kind: "event", props: { event: canonicalEvent, payload: { canonical: props.canonical, native: props.native } } }, signal));', - ' return projectEventDocument(document, canonicalEvent, target, nativeEvent);', + ' return projectEventDocument(document, canonicalEvent, target, nativeEvent, native);', '};', ] : []), @@ -808,8 +808,9 @@ export const cursorHookWrapperSource = (entry: TargetHookWrapper): string => [ ' if (subagentEvent && result.additionalContext !== undefined) fail(`Cursor ${nativeEvent} has no additional-context channel`);', ' return result;', '};', - 'const encodeOutput = (result) => {', + 'const encodeOutput = (result, nativeInput) => {', ' if (result === undefined) return undefined;', + ' if (canonicalEvent === "agentStop" && result.outcome === "deny" && nativeInput.status !== "completed") fail(`Cursor subagentStop consumes followup_message only when status is "completed"; this subagent reported ${JSON.stringify(nativeInput.status)}`);', ' if (canonicalEvent === "stop" || canonicalEvent === "agentStop") return result.outcome === "deny" ? defined({ followup_message: result.reason }) : undefined;', ' if (canonicalEvent === "agentStart") return result.outcome === "deny" ? defined({ permission: "deny", user_message: result.reason }) : undefined;', ' if (canonicalEvent === "beforeTool") {', @@ -876,7 +877,7 @@ export const cursorHookWrapperSource = (entry: TargetHookWrapper): string => [ ' validateNativeInput(nativeInput);', ' const event = decodeCursorNative(nativeInput);', ' const result = validateResult(await handler(event, { nativeEvent, nativeInput, target }));', - ' const nativeOutput = encodeOutput(result);', + ' const nativeOutput = encodeOutput(result, nativeInput);', ' const output = simulation ? decodeOutput(nativeOutput) : nativeOutput;', ' if (output !== undefined) process.stdout.write(JSON.stringify(output));', '};', diff --git a/packages/agent-bundle/src/dev/playground/lifecycle-replay-service.ts b/packages/agent-bundle/src/dev/playground/lifecycle-replay-service.ts index 42dd64b7d..190c75d8e 100644 --- a/packages/agent-bundle/src/dev/playground/lifecycle-replay-service.ts +++ b/packages/agent-bundle/src/dev/playground/lifecycle-replay-service.ts @@ -480,7 +480,7 @@ export class LifecycleReplayService { let nativeResponse: Readonly> | undefined; let projectionDiagnostic: Readonly<{ readonly code: string; readonly message: string }> | undefined; try { - nativeResponse = projectEventDocument(rendered.document, event, target.target, target.nativeEvent); + nativeResponse = projectEventDocument(rendered.document, event, target.target, target.nativeEvent, nativeInput); } catch (error) { if (!(error instanceof TypeError)) throw error; projectionDiagnostic = Object.freeze({ diff --git a/packages/agent-bundle/src/events/projection.ts b/packages/agent-bundle/src/events/projection.ts index 9730731de..4c1e43f64 100644 --- a/packages/agent-bundle/src/events/projection.ts +++ b/packages/agent-bundle/src/events/projection.ts @@ -436,11 +436,18 @@ const appendContext = (node: AgentDocumentNode, contexts: string[]): void => { } }; +/** + * Projects a rendered event document to the host's native output. Pass the + * validated native envelope when the host's output contract depends on input + * state (Cursor consumes `subagentStop.followup_message` only for a completed + * subagent); the production callers always do. + */ export const projectEventDocument = ( document: AgentDocument, event: CanonicalAgentEvent, target: string, nativeEvent: string, + nativeInput?: Readonly>, ): Readonly> | undefined => { if (target === 'plugin') { throw new TypeError('Composite plugin event projection must resolve the invoking host before projecting output.'); @@ -505,9 +512,16 @@ export const projectEventDocument = ( if (additionalContext !== undefined) { throw new TypeError('Cursor subagentStop has no additional-context channel; only followup_message is documented.'); } - return parsedValue?.outcome === 'deny' - ? Object.freeze({ followup_message: requireDenyReason() }) - : undefined; + if (parsedValue?.reason !== undefined && parsedValue.outcome !== 'deny') { + throw new TypeError('agent/stop reason is only valid when outcome is deny.'); + } + if (parsedValue?.outcome !== 'deny') return undefined; + if (nativeInput !== undefined && nativeInput.status !== 'completed') { + throw new TypeError( + `Cursor subagentStop consumes followup_message only when status is "completed"; this subagent reported ${JSON.stringify(nativeInput.status)}, so the continuation would be ignored.`, + ); + } + return Object.freeze({ followup_message: requireDenyReason() }); } if (parsedValue?.outcome === 'deny') { return Object.freeze({ decision: 'block', reason: requireDenyReason() }); diff --git a/packages/agent-bundle/src/mcp-server-runtime.ts b/packages/agent-bundle/src/mcp-server-runtime.ts index 3d5660739..79063cad5 100644 --- a/packages/agent-bundle/src/mcp-server-runtime.ts +++ b/packages/agent-bundle/src/mcp-server-runtime.ts @@ -576,6 +576,7 @@ const startEventRuntime = async ( event, target, nativeEvent, + props.native, )); }, status: () => ({ diff --git a/packages/agent-bundle/tests/hooks.test.ts b/packages/agent-bundle/tests/hooks.test.ts index 1ee17871a..fe2f5d4b2 100644 --- a/packages/agent-bundle/tests/hooks.test.ts +++ b/packages/agent-bundle/tests/hooks.test.ts @@ -1323,6 +1323,15 @@ it('round-trips the documented Cursor subagent envelopes through published Curso stderr: 'Agent Bundle hook error: native subagentStop status is invalid\n', stdout: '', }); + // followup_message is consumed only when status is "completed"; a denial + // on an errored or aborted subagent fails instead of emitting ignored output. + for (const status of ['error', 'aborted']) { + await expect(runNativeHook(join(outputRoot, 'cursor', 'hooks', 'subagent-stop.mjs'), { ...stopInput, status })).resolves.toEqual({ + code: 1, + stderr: `Agent Bundle hook error: Cursor subagentStop consumes followup_message only when status is "completed"; this subagent reported "${status}"\n`, + stdout: '', + }); + } // Every documented field except git_branch is mandatory: a malformed // envelope must fail closed before the handler runs with undefined fields. const { git_branch: _gitBranch, ...startWithoutGitBranch } = startInput; diff --git a/packages/agent-bundle/tests/route-unit/event-project.test.ts b/packages/agent-bundle/tests/route-unit/event-project.test.ts index 52ba5cb7f..6d5748dba 100644 --- a/packages/agent-bundle/tests/route-unit/event-project.test.ts +++ b/packages/agent-bundle/tests/route-unit/event-project.test.ts @@ -218,13 +218,28 @@ it('projects the Cursor subagent lifecycle through its documented permission and const continued = await renderRoute({ default: async () => createElement(Agent.Result, { value: { outcome: 'deny', reason: 'Run one more focused pass.' } }), }, stopInput); - expect(projectEventDocument(continued.document, 'agent/stop', 'cursor', 'subagentStop')).toEqual({ + expect(projectEventDocument(continued.document, 'agent/stop', 'cursor', 'subagentStop', stopNative)).toEqual({ followup_message: 'Run one more focused pass.', }); + // Cursor consumes followup_message only when status is "completed"; a + // continuation requested for an errored or aborted subagent fails closed + // instead of emitting output Cursor would silently ignore. + for (const status of ['error', 'aborted']) { + expect(() => projectEventDocument(continued.document, 'agent/stop', 'cursor', 'subagentStop', { ...stopNative, status })) + .toThrow(new RegExp(`consumes followup_message only when status is "completed"; this subagent reported "${status}"`, 'u')); + } const observed = await renderRoute({ default: async () => createElement(Agent.Result), }, stopInput); - expect(projectEventDocument(observed.document, 'agent/stop', 'cursor', 'subagentStop')).toBeUndefined(); + expect(projectEventDocument(observed.document, 'agent/stop', 'cursor', 'subagentStop', stopNative)).toBeUndefined(); + expect(projectEventDocument(observed.document, 'agent/stop', 'cursor', 'subagentStop', { ...stopNative, status: 'error' })).toBeUndefined(); + // A reason without a deny outcome is invalid on both published Cursor + // handler paths; the route path must not swallow it. + const reasonWithoutDeny = await renderRoute({ + default: async () => createElement(Agent.Result, { value: { outcome: 'continue', reason: 'Looks fine.' } }), + }, stopInput); + expect(() => projectEventDocument(reasonWithoutDeny.document, 'agent/stop', 'cursor', 'subagentStop', stopNative)) + .toThrow(/agent\/stop reason is only valid when outcome is deny/u); const stopContext = await renderRoute({ default: async () => createElement(Agent.Result, null, createElement(Agent.Context, null, 'Check the final result.')), }, stopInput); From 6d4d7725e1a57685ffd1a9d1a828cda16e74c2f1 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 08:10:38 +0000 Subject: [PATCH 5/9] chore(changeset): align the Cursor contract changeset with the pre-1.0 patch convention and PR reference --- .changeset/cursor-contract-refresh.md | 32 ++++++++++++++------------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/.changeset/cursor-contract-refresh.md b/.changeset/cursor-contract-refresh.md index 69130c6bc..023c972db 100644 --- a/.changeset/cursor-contract-refresh.md +++ b/.changeset/cursor-contract-refresh.md @@ -1,19 +1,21 @@ --- -"agent-bundle": minor +"agent-bundle": patch --- Refresh the Cursor plugin contract against the public docs retrieved 2026-09-02 -(#189). Cursor `subagentStart`/`subagentStop` now validate their documented -`subagent_*` envelopes and project through their real output channels -(`permission: "deny"` + `user_message`; `followup_message`), with wrapper, -playground, and lifecycle-replay fixtures. A new `cursor.*` config extension -emits schema-admitted manifest metadata (`author`, `homepage`, `repository`, -`license`, `keywords`, `publisher`, `category`, `tags`, `minClientVersions`) -into `.cursor-plugin/plugin.json` with `cursor.manifest.*` diagnostics. The -Cursor capability table records every documented hook event (21) with cloud -availability, hook options (`failClosed`, `loop_limit`, prompt hooks), plugin -formats, component discovery, variables, marketplace manifest limits, team -distribution and local-install surfaces, canvases, and the G5-gated agents -component as dated `supported`/`unavailable` rows; the unified bundle mirrors -each row and now intersects Cursor manifest metadata for real. Cursor -`adapterRevision` 1.8.0 → 1.9.0, unified plugin 1.22.0 → 1.23.0. +(#189). Validate the documented Cursor `subagentStart`/`subagentStop` +envelopes (every field except `git_branch` required) in event routes and +generated hook wrappers, project them only through `permission: "deny"` + +`user_message` and `followup_message`, and fail closed when a continuation is +requested for a subagent whose `status` is not `completed`. Add the `cursor.*` +config extension that emits schema-admitted manifest metadata (`author`, +`homepage`, `repository`, `license`, `keywords`, `publisher`, `category`, +`tags`, `minClientVersions`) into `.cursor-plugin/plugin.json`, validated with +the pinned schema's `uri`/`email` formats and reported as `cursor.manifest.*` +diagnostics (`cursor.manifest.field.unknown`, `cursor.manifest.author.*`, +`cursor.manifest..invalid`). Record every documented Cursor hook event +(21) with cloud availability, hook options, plugin formats, component +discovery, variables, marketplace limits, distribution and local-install +surfaces, canvases, and the agents component as dated capability rows, mirrored +through the unified `plugin` adapter. Cursor `adapterRevision` 1.8.0 → 1.9.0, +unified plugin 1.22.0 → 1.23.0. (#375) From 751b052352fc6a39da1b4659a1577a3bdb4e4b81 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 09:20:56 +0000 Subject: [PATCH 6/9] fix(cursor): require the common conversation identifier on subagentStart wrappers Addresses the fifth Codex review round on #375: parent_conversation_id is a subagent field, not a substitute for the common envelope's conversation_id, so the generated wrapper no longer accepts it in place of session_id/conversation_id and no longer falls back to it for sessionId. This matches the route envelope validator in events/projection.ts. --- packages/agent-bundle/src/adapters/hook-contract.ts | 4 ++-- packages/agent-bundle/tests/hooks.test.ts | 9 +++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/agent-bundle/src/adapters/hook-contract.ts b/packages/agent-bundle/src/adapters/hook-contract.ts index 1014fddbd..79dab1a5a 100644 --- a/packages/agent-bundle/src/adapters/hook-contract.ts +++ b/packages/agent-bundle/src/adapters/hook-contract.ts @@ -769,7 +769,7 @@ export const cursorHookWrapperSource = (entry: TargetHookWrapper): string => [ ' cwd: nativeInput.cwd,', ' hookEventName: nativeInput.hook_event_name,', ' lastAssistantMessage: canonicalEvent === "agentStop" ? nativeInput.summary : undefined,', - ' sessionId: nativeInput.session_id ?? nativeInput.conversation_id ?? nativeInput.parent_conversation_id,', + ' sessionId: nativeInput.session_id ?? nativeInput.conversation_id,', ' stopHookActive: canonicalEvent === "stop" || canonicalEvent === "agentStop" ? nativeInput.loop_count > 0 : undefined,', ' toolInput: nativeInput.tool_input,', ' toolName: nativeInput.tool_name,', @@ -834,7 +834,7 @@ export const cursorHookWrapperSource = (entry: TargetHookWrapper): string => [ '};', 'const validateNativeInput = (input) => {', ' if (input.hook_event_name !== nativeEvent) fail(`native hook_event_name must equal ${nativeEvent}`);', - ' if (typeof input.session_id !== "string" && typeof input.conversation_id !== "string" && !(canonicalEvent === "agentStart" && typeof input.parent_conversation_id === "string")) fail("native session_id or conversation_id must be a string");', + ' if (typeof input.session_id !== "string" && typeof input.conversation_id !== "string") fail("native session_id or conversation_id must be a string");', ' if (input.transcript_path !== undefined && input.transcript_path !== null && typeof input.transcript_path !== "string") fail("native transcript_path must be a string or null");', ' if (canonicalEvent === "sessionStart") return;', ' if (canonicalEvent === "beforeTool" || canonicalEvent === "afterTool") {', diff --git a/packages/agent-bundle/tests/hooks.test.ts b/packages/agent-bundle/tests/hooks.test.ts index fe2f5d4b2..1ee912e39 100644 --- a/packages/agent-bundle/tests/hooks.test.ts +++ b/packages/agent-bundle/tests/hooks.test.ts @@ -1315,6 +1315,15 @@ it('round-trips the documented Cursor subagent envelopes through published Curso stderr: 'Agent Bundle hook error: native subagent_id must be a string\n', stdout: '', }); + // The common envelope's conversation_id is required even though the + // subagent envelope also carries parent_conversation_id; the parent id is + // not a substitute for the session identifier. + const { conversation_id: _conversationId, ...startWithoutConversation } = startInput; + await expect(runNativeHook(join(outputRoot, 'cursor', 'hooks', 'subagent-start.mjs'), startWithoutConversation)).resolves.toEqual({ + code: 1, + stderr: 'Agent Bundle hook error: native session_id or conversation_id must be a string\n', + stdout: '', + }); await expect(runNativeHook(join(outputRoot, 'cursor', 'hooks', 'subagent-stop.mjs'), { ...stopInput, status: 'cancelled', From be50997ef7393ca2ece049a642616aad587eb584 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 10:07:25 +0000 Subject: [PATCH 7/9] =?UTF-8?q?chore(changeset):=20record=20the=20unified?= =?UTF-8?q?=20plugin=20revision=20as=201.24.0=20=E2=86=92=201.25.0=20after?= =?UTF-8?q?=20rebasing=20over=20#188?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/cursor-contract-refresh.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/cursor-contract-refresh.md b/.changeset/cursor-contract-refresh.md index 023c972db..8b34033c7 100644 --- a/.changeset/cursor-contract-refresh.md +++ b/.changeset/cursor-contract-refresh.md @@ -18,4 +18,4 @@ diagnostics (`cursor.manifest.field.unknown`, `cursor.manifest.author.*`, discovery, variables, marketplace limits, distribution and local-install surfaces, canvases, and the agents component as dated capability rows, mirrored through the unified `plugin` adapter. Cursor `adapterRevision` 1.8.0 → 1.9.0, -unified plugin 1.22.0 → 1.23.0. (#375) +unified plugin 1.24.0 → 1.25.0. (#375) From e421499c2f82bc43e2d211cdf48e98376d75865f Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 10:30:09 +0000 Subject: [PATCH 8/9] fix(cursor): decode subagent_model into the canonical model field on subagentStart Addresses the sixth Codex review round on #375: the generated wrapper now mirrors the simulation codec (model <-> subagent_model) so handlers observe event.model for real Cursor subagentStart payloads. --- packages/agent-bundle/src/adapters/hook-contract.ts | 1 + packages/agent-bundle/tests/hooks.test.ts | 6 ++++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/agent-bundle/src/adapters/hook-contract.ts b/packages/agent-bundle/src/adapters/hook-contract.ts index 79dab1a5a..9ac741379 100644 --- a/packages/agent-bundle/src/adapters/hook-contract.ts +++ b/packages/agent-bundle/src/adapters/hook-contract.ts @@ -769,6 +769,7 @@ export const cursorHookWrapperSource = (entry: TargetHookWrapper): string => [ ' cwd: nativeInput.cwd,', ' hookEventName: nativeInput.hook_event_name,', ' lastAssistantMessage: canonicalEvent === "agentStop" ? nativeInput.summary : undefined,', + ' model: canonicalEvent === "agentStart" ? nativeInput.subagent_model : undefined,', ' sessionId: nativeInput.session_id ?? nativeInput.conversation_id,', ' stopHookActive: canonicalEvent === "stop" || canonicalEvent === "agentStop" ? nativeInput.loop_count > 0 : undefined,', ' toolInput: nativeInput.tool_input,', diff --git a/packages/agent-bundle/tests/hooks.test.ts b/packages/agent-bundle/tests/hooks.test.ts index 1ee912e39..d6668dc17 100644 --- a/packages/agent-bundle/tests/hooks.test.ts +++ b/packages/agent-bundle/tests/hooks.test.ts @@ -1262,7 +1262,7 @@ it('round-trips the documented Cursor subagent envelopes through published Curso writeFile(join(root, 'package.json'), '{"type":"module"}\n'), writeFile( join(sourceRoot, 'subagent-start.ts'), - "export default (event: Record) => ({ outcome: 'deny' as const, reason: `${String(event.sessionId)}:${String(event.agentId)}:${String(event.agentType)}:${String(event.toolUseId)}` });\n", + "export default (event: Record) => ({ outcome: 'deny' as const, reason: `${String(event.sessionId)}:${String(event.agentId)}:${String(event.agentType)}:${String(event.toolUseId)}:${String(event.model)}` });\n", ), writeFile( join(sourceRoot, 'subagent-stop.ts'), @@ -1293,7 +1293,9 @@ it('round-trips the documented Cursor subagent envelopes through published Curso stderr: '', stdout: JSON.stringify({ permission: 'deny', - user_message: `${String(startInput.conversation_id)}:${String(startInput.subagent_id)}:${String(startInput.subagent_type)}:${String(startInput.tool_call_id)}`, + // subagent_model is the model the subagent will use; it decodes to the + // canonical `model` field so handlers can see it. + user_message: `${String(startInput.conversation_id)}:${String(startInput.subagent_id)}:${String(startInput.subagent_type)}:${String(startInput.tool_call_id)}:${String(startInput.subagent_model)}`, }), }); // https://cursor.com/docs/hooks#subagentstop: { followup_message }. From 4255928eb617a0e51bb8f5367ed0a413169b6d91 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 11:09:49 +0000 Subject: [PATCH 9/9] chore(changeset): describe the Cursor contract refresh in consumer terms --- .changeset/cursor-contract-refresh.md | 28 +++++++++++---------------- 1 file changed, 11 insertions(+), 17 deletions(-) diff --git a/.changeset/cursor-contract-refresh.md b/.changeset/cursor-contract-refresh.md index 8b34033c7..2064b3c69 100644 --- a/.changeset/cursor-contract-refresh.md +++ b/.changeset/cursor-contract-refresh.md @@ -2,20 +2,14 @@ "agent-bundle": patch --- -Refresh the Cursor plugin contract against the public docs retrieved 2026-09-02 -(#189). Validate the documented Cursor `subagentStart`/`subagentStop` -envelopes (every field except `git_branch` required) in event routes and -generated hook wrappers, project them only through `permission: "deny"` + -`user_message` and `followup_message`, and fail closed when a continuation is -requested for a subagent whose `status` is not `completed`. Add the `cursor.*` -config extension that emits schema-admitted manifest metadata (`author`, -`homepage`, `repository`, `license`, `keywords`, `publisher`, `category`, -`tags`, `minClientVersions`) into `.cursor-plugin/plugin.json`, validated with -the pinned schema's `uri`/`email` formats and reported as `cursor.manifest.*` -diagnostics (`cursor.manifest.field.unknown`, `cursor.manifest.author.*`, -`cursor.manifest..invalid`). Record every documented Cursor hook event -(21) with cloud availability, hook options, plugin formats, component -discovery, variables, marketplace limits, distribution and local-install -surfaces, canvases, and the agents component as dated capability rows, mirrored -through the unified `plugin` adapter. Cursor `adapterRevision` 1.8.0 → 1.9.0, -unified plugin 1.24.0 → 1.25.0. (#375) +Add the `cursor.*` config extension so Cursor builds emit manifest metadata +(`author`, `homepage`, `repository`, `license`, `keywords`, `publisher`, +`category`, `tags`, `minClientVersions`) into `.cursor-plugin/plugin.json`; +invalid values are reported as `cursor.manifest.field.unknown`, +`cursor.manifest.author.*`, and `cursor.manifest..invalid` instead of a +generic schema failure. Cursor `subagentStart`/`subagentStop` hooks now +validate the documented envelope (every field except `git_branch` required), +decode `subagent_model` into `event.model`, and only return a +`followup_message` when the subagent `status` is `completed`; the `cursor` and +`plugin` capability reports list every documented Cursor hook event with its +cloud availability. (#375)