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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/cursor-capability-corrections.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"agent-bundle": patch
---

Correct Cursor capability reporting and bundle output: mark the sessionless `workspace/open`/`pluginPaths` envelope unavailable, emit a schema-validated Cursor marketplace document from the pinned official schema, and include Cursor in plugin composite capability intersections.
5 changes: 4 additions & 1 deletion examples/rsc-agent-runtime/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -235,8 +235,11 @@ Host/Origin allowlists mitigate DNS rebinding and cross-origin requests, but the
| `stop` | Supported | `Stop` | `Stop` |
| `agent/start` | `subagentStart` | `SubagentStart` | `SubagentStart` |
| `agent/stop` | `subagentStop` | `SubagentStop` | `SubagentStop` |
| `workspace/open` | `workspaceOpen` | Unavailable | Unavailable |
| `workspace/open` | Unavailable | Unavailable | Unavailable |

Cursor's native `workspaceOpen` is sessionless: it fires without session or
conversation fields and its response returns `pluginPaths`. The generated
session-scoped wrapper and output vocabulary cannot express that envelope.
`agent/start` is context-injection-only on Claude Code and Codex and cannot
block subagent creation. Their `agent/stop` routes can continue the subagent
with the native `decision: "block"` plus `reason` contract. Codex
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,7 @@
"afterTool": "postToolUse",
"beforeTool": "preToolUse",
"sessionStart": "sessionStart",
"stop": "stop",
"workspaceOpen": "workspaceOpen"
"stop": "stop"
},
"eventRoutes": {
"agent/start": { "nativeEvent": "subagentStart", "state": "supported" },
Expand All @@ -30,7 +29,11 @@
"stop": { "nativeEvent": "stop", "state": "supported" },
"tool/after": { "nativeEvent": "postToolUse", "state": "supported" },
"tool/before": { "nativeEvent": "preToolUse", "state": "supported" },
"workspace/open": { "nativeEvent": "workspaceOpen", "state": "supported" }
"workspace/open": {
"nativeEvent": "workspaceOpen",
"reason": "Cursor's workspaceOpen fires outside an agent session with no session or conversation fields, and its response must return pluginPaths; the generated session-scoped wrapper vocabulary cannot express that envelope.",
"state": "unavailable"
}
},
"matchers": {
"agent": "^Task$",
Expand Down Expand Up @@ -90,7 +93,7 @@
"Local-plugin symlinks are realpath checked and rejected when their targets escape ~/.cursor/plugins/local.",
"2026-09-01: cursor/plugins@070189284e702e8a4d2e3cc8913994b204c5337a schemas/plugin.schema.json defines the commands component pointer; https://cursor.com/docs documents agent chat commands as plain Markdown prompt files in commands/ named by filename.",
"2026-08-31: cursor/plugins@070189284e702e8a4d2e3cc8913994b204c5337a schemas/plugin.schema.json defines the rules component pointer; https://cursor.com/docs/plugins documents the rules component.",
"The pinned Cursor hooks schema admits subagentStart, subagentStop, and workspaceOpen as first-class hook arrays; the public Cursor hooks reference documents workspaceOpen and subagent lifecycle payloads.",
"The pinned Cursor hooks schema admits subagentStart, subagentStop, and workspaceOpen as first-class hook arrays; the documented native workspaceOpen contract is sessionless and returns pluginPaths, which the generated wrapper contract does not model (observed 2026-09-01, https://cursor.com/docs/agent/hooks).",
"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."
]
}
Expand Down
46 changes: 41 additions & 5 deletions packages/agent-bundle/src/adapters/cursor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ import {
eventRouteCapabilitiesFrom,
supportedEventRouteNamesFrom,
supportedCapability,
unavailableCapability,
} from './capability-state.ts';
import capabilityTable from './capabilities/cursor-2026-08-28.json' with { type: 'json' };
import {
Expand All @@ -33,6 +32,7 @@ import {
} from './hook-contract.ts';
import schemaProvenance from './schemas/cursor/PROVENANCE.json' with { type: 'json' };
import hooksSchema from './schemas/cursor/hooks.schema.json' with { type: 'json' };
import marketplaceSchema from './schemas/cursor/marketplace.schema.json' with { type: 'json' };
import mcpSchema from './schemas/cursor/mcp.schema.json' with { type: 'json' };
import pluginSchema from './schemas/cursor/plugin.schema.json' with { type: 'json' };
import {
Expand Down Expand Up @@ -62,6 +62,7 @@ const cursorName = 'cursor';
*/
export const cursorArtifactPaths = Object.freeze({
hooks: 'hooks/hooks.json',
marketplace: '.cursor-plugin/marketplace.json',
mcp: 'mcp.json',
plugin: '.cursor-plugin/plugin.json',
});
Expand All @@ -70,11 +71,13 @@ const validator = createDraft7AdapterValidator();
const validatePlugin = validator.compile(pluginSchema);
const validateMcp = validator.compile(mcpSchema);
const validateHooks = validator.compile(hooksSchema);
const validateMarketplace = validator.compile(marketplaceSchema);

/** The pinned Cursor document validators, shared with the unified bundle adapter. */
export const cursorPluginValidator = validatePlugin;
export const cursorMcpValidator = validateMcp;
export const cursorHooksValidator = validateHooks;
export const cursorMarketplaceValidator = validateMarketplace;

const cursorNamePattern = /^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/u;
const cursorNameMaxLength = 64;
Expand Down Expand Up @@ -264,7 +267,7 @@ export const cursorManifest = (
const metadata = Object.freeze({
adapterRevision: '1.5.0',
capabilityRevision: capabilityTable.observedCliVersion,
capabilitySha256: 'e963f86e9074a0c942ebc16190c3f534283c62fea1beda7411f170692dca05f7',
capabilitySha256: 'fd5a8171963f9b1bd05876cc333ba808bdcffb73b49b133bcf681b3a0fd57941',
observedVersion: capabilityTable.observedCliVersion,
schemas: schemaDescriptorsFrom(schemaProvenance, schemaProvenance.observedCliVersion),
});
Expand All @@ -278,11 +281,13 @@ const hookContract = createCursorHookContract({
const artifactValidation = Object.freeze({
documents: Object.freeze([
Object.freeze({ path: cursorArtifactPaths.hooks, required: false, schema: 'hooks' }),
Object.freeze({ path: cursorArtifactPaths.marketplace, required: false, schema: 'marketplace' }),
Object.freeze({ path: cursorArtifactPaths.mcp, required: false, schema: 'mcp' }),
Object.freeze({ path: cursorArtifactPaths.plugin, required: true, schema: 'plugin' }),
]),
schemas: Object.freeze([
Object.freeze({ name: 'hooks', validate: validateJsonSchemaDocument(validateHooks) }),
Object.freeze({ name: 'marketplace', validate: validateJsonSchemaDocument(validateMarketplace) }),
Object.freeze({ name: 'mcp', validate: validateModernMcpDocument(validateJsonSchemaDocument(validateMcp)) }),
Object.freeze({ name: 'plugin', validate: validateJsonSchemaDocument(validatePlugin) }),
]),
Expand Down Expand Up @@ -333,6 +338,34 @@ const artifactLayout: TargetArtifactLayout = Object.freeze({
}),
});

export interface CursorMarketplacePlan {
readonly diagnostics: readonly Diagnostic[];
readonly document?: Record<string, unknown>;
readonly valid: boolean;
}

/** Builds and validates Cursor's official `.cursor-plugin/marketplace.json` document. */
export const planCursorMarketplace = (model: NormalizedPlugin): CursorMarketplacePlan => {
if (model.marketplace !== true) {
return Object.freeze({ diagnostics: Object.freeze([]), valid: false });
}
const document = {
name: `${model.metadata.name}-marketplace`,
owner: { name: model.metadata.name },
plugins: [{
description: model.metadata.description ?? model.metadata.name,
name: model.metadata.name,
source: './',
}],
};
const valid = validateMarketplace(document);
return Object.freeze({
diagnostics: Object.freeze(schemaDiagnostics('marketplace', valid, validateMarketplace.errors)),
document,
valid,
});
};

export const planCursorArtifacts = (model: NormalizedPlugin): TargetArtifactPlan => {
const isSelected = (targets: readonly string[]): boolean => targets.includes(cursorName);
const selectedCommands = (model.commands ?? []).filter((command) => isSelected(command.targets));
Expand All @@ -359,6 +392,8 @@ export const planCursorArtifacts = (model: NormalizedPlugin): TargetArtifactPlan
if (hookDocument !== undefined) diagnostics.push(...schemaDiagnostics('hooks', hookDocumentValid, validateHooks.errors));

const variables = cursorVariables(mcp);
const marketplacePlan = planCursorMarketplace(model);
diagnostics.push(...marketplacePlan.diagnostics);
const plugin = cursorManifest(model, {
...(selectedCommands.length === 0 ? {} : { commands: './commands/' }),
...(hookDocument !== undefined && hookDocumentValid ? { hooks: `./${cursorArtifactPaths.hooks}` } : {}),
Expand All @@ -380,8 +415,9 @@ export const planCursorArtifacts = (model: NormalizedPlugin): TargetArtifactPlan
hookEntries: generatedHooks.hookEntries,
hookManifestPath: cursorArtifactPaths.hooks,
isSelected,
marketplaceRelativePath: '.cursor-plugin/marketplace.json',
marketplaceValid: false,
marketplace: marketplacePlan.document,
marketplaceRelativePath: cursorArtifactPaths.marketplace,
marketplaceValid: marketplacePlan.valid,
mcp,
mcpRelativePath: cursorArtifactPaths.mcp,
mcpValid,
Expand Down Expand Up @@ -413,7 +449,7 @@ export const cursorAdapter: TargetAdapter = Object.freeze({
),
hooks: supportedCapability(evidence),
install: supportedCapability(evidence),
marketplace: unavailableCapability('The pinned Cursor Plugin contract does not define a marketplace document.'),
marketplace: supportedCapability(evidence),
mcp: capabilityStateFromSupport(
capabilityTable.mcp.stdio && capabilityTable.mcp.streamableHttp,
evidence,
Expand Down
100 changes: 79 additions & 21 deletions packages/agent-bundle/src/adapters/plugin.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { createTargetDiagnostics } from './diagnostics.ts';
import type { Diagnostic } from '../core/diagnostics.ts';
import { stableJson } from '../core/digest.ts';
import { sha256Hex, stableJson } from '../core/digest.ts';
import type { NormalizedHook, NormalizedPlugin } from '../core/types.ts';
import {
allMcpPathTokenFields,
Expand All @@ -15,19 +15,22 @@ import {
} from './capability-state.ts';
import claudeCapabilityTable from './capabilities/claude-2.1.250.json' with { type: 'json' };
import codexCapabilityTable from './capabilities/codex-0.147.0.json' with { type: 'json' };
import cursorCapabilityTable from './capabilities/cursor-2026-08-28.json' with { type: 'json' };
import { claudeAdapter, claudeArtifactPaths, claudeHooksValidator, planClaudeArtifacts } from './claude.ts';
import { codexAdapter, codexArtifactPaths, codexPluginDocumentValidator, planCodexArtifacts } from './codex.ts';
import {
createCursorHookContract,
cursorAdapter,
cursorHooksValidator,
cursorManifest,
cursorMarketplaceValidator,
cursorMcpValidator,
cursorPluginNameError,
cursorPluginValidator,
cursorVariables,
emptyCursorHooksDocument,
isValidCursorPluginName,
planCursorMarketplace,
planCursorMcpServer,
} from './cursor.ts';
import {
Expand Down Expand Up @@ -76,6 +79,7 @@ const pluginName = 'plugin';
* document points at dedicated per-hook `hooks/<name>.cursor.mjs` wrappers
* carrying the Cursor codec; the empty document remains only as a
* schema-collision guard when no hook lowers to Cursor.
* Composite capability claims intersect all three pinned host tables.
*
* An Agent Plugins v1 root `plugin.json` is deliberately not emitted: Codex
* selects it ahead of `.codex-plugin/plugin.json` and, under that format,
Expand All @@ -86,6 +90,7 @@ const pluginName = 'plugin';
const codexBundleMcpPath = '.codex-plugin/mcp.json';
const cursorPaths = Object.freeze({
hooks: 'hooks/hooks-cursor.json',
marketplace: '.cursor-plugin/marketplace.json',
mcp: 'mcp.json',
plugin: '.cursor-plugin/plugin.json',
});
Expand Down Expand Up @@ -156,6 +161,7 @@ const artifactValidation = Object.freeze({
Object.freeze({ path: codexBundleMcpPath, required: false, schema: 'codex-mcp' }),
Object.freeze({ path: codexArtifactPaths.plugin, required: true, schema: 'codex-plugin' }),
Object.freeze({ path: cursorPaths.hooks, required: false, schema: 'cursor-hooks' }),
Object.freeze({ path: cursorPaths.marketplace, required: false, schema: 'cursor-marketplace' }),
Object.freeze({ path: cursorPaths.mcp, required: false, schema: 'cursor-mcp' }),
Object.freeze({ path: cursorPaths.plugin, required: false, schema: 'cursor-plugin' }),
]),
Expand All @@ -166,16 +172,24 @@ const artifactValidation = Object.freeze({
// validator widens the pinned pointer to that one relocation.
Object.freeze({ name: 'codex-plugin', validate: (document: unknown) => codexPluginDocumentValidator(codexBundleMcpPath)(document) }),
Object.freeze({ name: 'cursor-hooks', validate: validateJsonSchemaDocument(cursorHooksValidator) }),
Object.freeze({ name: 'cursor-marketplace', validate: validateJsonSchemaDocument(cursorMarketplaceValidator) }),
Object.freeze({ name: 'cursor-mcp', validate: validateJsonSchemaDocument(cursorMcpValidator) }),
Object.freeze({ name: 'cursor-plugin', validate: validateJsonSchemaDocument(cursorPluginValidator) }),
]),
});

const metadata = Object.freeze({
adapterRevision: '1.3.0',
capabilityRevision: `claude ${claudeAdapter.metadata.observedVersion} + codex ${codexAdapter.metadata.observedVersion}`,
capabilitySha256: claudeAdapter.metadata.capabilitySha256,
observedVersion: `${claudeAdapter.metadata.observedVersion}+${codexAdapter.metadata.observedVersion}`,
adapterRevision: '1.4.0',
capabilityRevision: `claude ${claudeAdapter.metadata.observedVersion} + codex ${codexAdapter.metadata.observedVersion} + cursor ${cursorAdapter.metadata.observedVersion}`,
// The composite fingerprint covers every host pin the bundle's capability
// claims depend on, so a single-host capability-table correction at the
// same observed version still changes this manifest identity.
capabilitySha256: sha256Hex(stableJson([
{ capabilitySha256: claudeAdapter.metadata.capabilitySha256, target: 'claude' },
{ capabilitySha256: codexAdapter.metadata.capabilitySha256, target: 'codex' },
{ capabilitySha256: cursorAdapter.metadata.capabilitySha256, target: 'cursor' },
])),
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
// hooks schema differs only in its $id).
Expand Down Expand Up @@ -357,6 +371,16 @@ const plan = (model: NormalizedPlugin): TargetArtifactPlan => {
const targetSourceInputs = model.targets
.filter((target) => target.name === pluginName)
.map((target) => target.provenance.sourcePath);
const cursorMarketplace = planCursorMarketplace(model);
diagnostics.push(...cursorMarketplace.diagnostics);
if (cursorMarketplace.document !== undefined && cursorMarketplace.valid) {
entries.push({
content: `${stableJson(cursorMarketplace.document)}\n`,
kind: 'write',
relativePath: cursorPaths.marketplace,
sourceInputs: sourceInputs(model.metadata.provenance.sourcePath, ...targetSourceInputs),
});
}
if (hookDocument !== undefined && hookDocumentValid) {
const hookSourceInputs = model.hooks
.filter((hook) => hook.targets.includes(pluginName))
Expand Down Expand Up @@ -472,16 +496,33 @@ const plan = (model: NormalizedPlugin): TargetArtifactPlan => {
});
};

const eventCapabilityTables = Object.freeze([
Object.freeze({ name: 'Claude', routes: claudeCapabilityTable.hooks.eventRoutes }),
Object.freeze({ name: 'Codex', routes: codexCapabilityTable.hooks.eventRoutes }),
Object.freeze({ name: 'Cursor', routes: cursorCapabilityTable.hooks.eventRoutes }),
]);
const compositeEventNames = new Set(eventCapabilityTables.flatMap(({ routes }) => Object.keys(routes)));
for (const event of compositeEventNames) {
for (const table of eventCapabilityTables) {
if (!Object.hasOwn(table.routes, event)) {
throw new Error(`Agent plugin bundle event capability table for ${table.name} is missing ${JSON.stringify(event)}.`);
}
}
}

const compositeEventCapabilities = Object.freeze(Object.fromEntries(
Object.keys(claudeCapabilityTable.hooks.eventRoutes)
[...compositeEventNames]
.sort((left, right) => left.localeCompare(right))
.map((event) => {
const capability = `event:${event}`;
return [
capability,
intersectCapabilityStates(
claudeAdapter.capabilities[capability]!,
codexAdapter.capabilities[capability]!,
intersectCapabilityStates(
claudeAdapter.capabilities[capability]!,
codexAdapter.capabilities[capability]!,
),
cursorAdapter.capabilities[capability]!,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Add Cursor to the bundle capability fingerprint

Once this line makes the plugin capability state depend on cursorAdapter, the plugin target metadata at lines 177–181 is no longer an accurate fingerprint: its revision strings still mention only Claude and Codex, while capabilitySha256 is only Claude's hash. Consequently, generated agent-bundle.manifest.json files misstate the evidence behind these three-host capability claims, and matchesTargetMetadata cannot detect a Cursor-only capability-table correction at the same observed version unless an unrelated schema or output also changes. Derive the composite metadata from all three host pins and bump the plugin adapter revision.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. The plugin metadata now derives its fingerprint from all three host pins: capabilityRevision/observedVersion name claude, codex, and cursor, and capabilitySha256 is sha256Hex(stableJson([...])) over the three per-host capability hashes, so a Cursor-only capability-table correction at the same observed version changes the manifest identity. Adapter revision bumped to 1.4.0.

),
];
}),
Expand All @@ -492,23 +533,40 @@ export const pluginAdapter: TargetAdapter = Object.freeze({
artifactLayout,
capabilities: Object.freeze({
...compositeEventCapabilities,
commands: intersectCapabilityStates(claudeAdapter.capabilities.commands!, codexAdapter.capabilities.commands!),
commands: intersectCapabilityStates(
intersectCapabilityStates(claudeAdapter.capabilities.commands!, codexAdapter.capabilities.commands!),
cursorAdapter.capabilities.commands!,
),
install: unavailableCapability(
'Plugin is a multi-host distribution profile, not one host runtime with a single installation transaction.',
),
marketplace: intersectCapabilityStates(claudeAdapter.capabilities.marketplace!, codexAdapter.capabilities.marketplace!),
hooks: intersectCapabilityStates(claudeAdapter.capabilities.hooks!, codexAdapter.capabilities.hooks!),
// Claude supports LSP and Codex has no LSP surface, so the intersection
// is honestly unavailable for the bundle as a whole. The Claude half
// still emits `.lsp.json` at the shared root from the Claude host
// config, which is exactly why this stays unavailable instead of
// supported: nothing about that document reaches Codex or Cursor.
marketplace: intersectCapabilityStates(
intersectCapabilityStates(claudeAdapter.capabilities.marketplace!, codexAdapter.capabilities.marketplace!),
cursorAdapter.capabilities.marketplace!,
),
hooks: intersectCapabilityStates(
intersectCapabilityStates(claudeAdapter.capabilities.hooks!, codexAdapter.capabilities.hooks!),
cursorAdapter.capabilities.hooks!,
),
// Cursor is excluded because it declares no LSP capability surface at all.
// Claude supports LSP and Codex has no LSP surface, so this intersection is
// honestly unavailable even though the Claude half still emits `.lsp.json`.
lsp: intersectCapabilityStates(claudeAdapter.capabilities.lsp!, codexAdapter.capabilities.lsp!),
mcp: intersectCapabilityStates(claudeAdapter.capabilities.mcp!, codexAdapter.capabilities.mcp!),
// The bundle exposes Cursor's real rules directory, but Claude and Codex
// cannot consume it, so the composite row remains the honest intersection.
rules: intersectCapabilityStates(claudeAdapter.capabilities.rules!, codexAdapter.capabilities.rules!),
skills: intersectCapabilityStates(claudeAdapter.capabilities.skills!, codexAdapter.capabilities.skills!),
mcp: intersectCapabilityStates(
intersectCapabilityStates(claudeAdapter.capabilities.mcp!, codexAdapter.capabilities.mcp!),
cursorAdapter.capabilities.mcp!,
),
// The bundle exposes Cursor's real rules directory; the composite row is
// the honest three-host intersection, so it stays non-supported while
// Claude and Codex cannot consume rules.
rules: intersectCapabilityStates(
intersectCapabilityStates(claudeAdapter.capabilities.rules!, codexAdapter.capabilities.rules!),
cursorAdapter.capabilities.rules!,
),
skills: intersectCapabilityStates(
intersectCapabilityStates(claudeAdapter.capabilities.skills!, codexAdapter.capabilities.skills!),
cursorAdapter.capabilities.skills!,
),
}),
hookContract: bundleHookContract,
metadata,
Expand Down
Loading
Loading