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
8 changes: 8 additions & 0 deletions .changeset/cursor-workspace-open-observation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"agent-bundle": minor
---

Support Cursor's canonical `workspace/open` event route as a fire-and-forget
observation. The generated wrapper accepts Cursor's sessionless `workspaceOpen`
envelope and emits no output; the optional native `pluginPaths` return channel
is deliberately not modeled.
10 changes: 6 additions & 4 deletions examples/rsc-agent-runtime/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -235,11 +235,13 @@ 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` | Unavailable | Unavailable | Unavailable |
| `workspace/open` | Supported (observe-only; native `pluginPaths` return not modeled) | 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.
Cursor's native `workspaceOpen` is sessionless and its optional `pluginPaths`
return is deliberately not modeled. Event routes observe the documented
workspace envelope and emit no native output; plain `hooks.events.workspaceOpen`
handlers remain unavailable because their session-scoped vocabulary cannot
express it.
`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 @@ -29,11 +29,7 @@
"stop": { "nativeEvent": "stop", "state": "supported" },
"tool/after": { "nativeEvent": "postToolUse", "state": "supported" },
"tool/before": { "nativeEvent": "preToolUse", "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"
}
"workspace/open": { "nativeEvent": "workspaceOpen", "state": "supported" }
},
"matchers": {
"agent": "^Task$",
Expand Down Expand Up @@ -83,7 +79,7 @@
"workspaceRoot": "${workspaceFolder}"
},
"provenance": {
"observedAt": "2026-08-31",
"observedAt": "2026-09-02",
"cursorServerBuild": "9746bf00534f29fc29f1deb9ddfb5448f7905eb0",
"evidence": [
"Adapter target is the full Cursor Plugin contract, not the root-manifest portable Agent Plugin contract.",
Expand All @@ -93,7 +89,8 @@
"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 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-02: https://cursor.com/docs/hooks#workspaceopen documents workspaceOpen input as sessionless ({ hook_event_name, cursor_version, workspace_roots, user_email }; conversation_id/generation_id/model/session_id/transcript_path omitted) and its output pluginPaths as string[] (optional), so an empty response is legal; the generated event-route wrapper validates that envelope and projects the observation-only canonical workspace/open family to no output — the native pluginPaths return channel is deliberately not modeled and never emitted.",
"2026-09-02: live native capture on Cursor CLI (cursor-agent 2026.08.31-4057e58, trusted project hooks, non-interactive -p run): workspaceOpen fired on workspace open with exactly the documented sessionless envelope (hook_event_name, cursor_version, workspace_roots, user_email; no conversation_id/generation_id/model/session_id/transcript_path) while the same run's sessionStart carried session and conversation ids; the capture hook exited 0 with no stdout and the workspace open plus agent session proceeded, proving the pluginPaths-omitted empty response is legal on the real binary.",
"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
9 changes: 8 additions & 1 deletion packages/agent-bundle/src/adapters/hook-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,14 @@ export const createNativeEventStarter = (
stop_hook_active: false,
});
case 'workspace/open':
return deepFreeze(base);
return deepFreeze(target === 'cursor'
? {
cursor_version: 'lifecycle-replay',
hook_event_name: nativeEvent,
user_email: null,
workspace_roots: ['/tmp'],
}
: base);
default: {
const exhaustive: never = canonicalEvent;
return exhaustive;
Expand Down
29 changes: 29 additions & 0 deletions packages/agent-bundle/src/events/projection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,24 @@ export const validateNativeEventEnvelope = (
return nativeEventError(`native hook_event_name must equal ${nativeEvent}`);
}
if (target === 'cursor') {
if (canonicalEvent === 'workspace/open') {
if (
!Array.isArray(native.workspace_roots)
|| native.workspace_roots.length === 0
|| !native.workspace_roots.every((root) => typeof root === 'string' && root.trim() !== '')
Comment on lines +58 to +62

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 Populate request workspace from the validated roots

For every Cursor workspaceOpen invocation, the newly accepted envelope contains workspace_roots but no cwd; however, both generated execution paths still derive the runAgentRequest workspace exclusively from native.cwd (hook-contract.ts for standalone and mcp-server-runtime.ts for shared). Consequently, (await agent()).workspace is reported as unavailable for this route despite the host supplying workspace identity, which also prevents workspace-scoped providers/notices from using the opened workspace. Thread an appropriate root from this validated field into both execution paths, with explicit semantics for multi-root workspaces.

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 in a45992e: both shared and standalone event execution now prefer native cwd and fall back to workspace_roots[0]; the scalar first-root multi-root rule is documented. The #298 envelope, projection, lifecycle replay, and real shared-route tests pass.

) {
return nativeEventError('native workspace_roots must be a nonempty array of nonempty strings');
}
requireNativeString(native, 'cursor_version');
if (
Object.hasOwn(native, 'user_email')
&& native.user_email !== null
&& typeof native.user_email !== 'string'
) {
return nativeEventError('native user_email must be a string or null');
}
return native;
}
if (typeof native.session_id !== 'string' && typeof native.conversation_id !== 'string') {
return nativeEventError('native session_id or conversation_id must be a string');
}
Expand Down Expand Up @@ -278,5 +296,16 @@ export const projectEventDocument = (
},
});
}
if (event === 'workspace/open') {
if (parsedValue?.outcome === 'deny' || parsedValue?.updatedInput !== undefined) {
throw new TypeError('workspace/open is observation-only on every supported host and cannot deny or replace native input.');
}
if (additionalContext !== undefined) {
throw new TypeError(
'Cursor\'s workspaceOpen has no context/output channel; the native pluginPaths return channel is deliberately not modeled.',
);
}
return undefined;
}
return undefined;
};
6 changes: 3 additions & 3 deletions packages/agent-bundle/tests/adapter-capability-states.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -419,8 +419,8 @@ it('reports the evidence-backed G10 event family matrix without inferred support
});
}
expect(registry.get('cursor').capabilities['event:workspace/open']).toMatchObject({
reason: expect.stringContaining('pluginPaths'),
state: 'unavailable',
evidence: { observedVersion: '2026-08-28', target: 'cursor' },
state: 'supported',
});
for (const target of ['claude', 'codex'] as const) {
for (const capability of allNativeHosts) {
Expand All @@ -441,7 +441,7 @@ it('reports the evidence-backed G10 event family matrix without inferred support
});
}
expect(registry.get('plugin').capabilities['event:workspace/open']).toMatchObject({
reason: expect.stringContaining('pluginPaths'),
reason: expect.not.stringContaining('pluginPaths'),
state: 'unavailable',
});
expect(registry.get('plugin').capabilities['event:workspace/open']).toMatchObject({
Expand Down
1 change: 1 addition & 0 deletions packages/agent-bundle/tests/adapter-metadata.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,7 @@ it('records observed capability versions and rehashes schema snapshots against p
}

if (target === 'cursor') {
expect(sha256Hex(capability)).toBe('46f47e4daa8d072b8319440f912a72ea488b22893803d89382cec18998fb13eb');
const pluginSchema = JSON.parse(await readFile(
new URL('../src/adapters/schemas/cursor/plugin.schema.json', import.meta.url),
'utf8',
Expand Down
35 changes: 35 additions & 0 deletions packages/agent-bundle/tests/event-project.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,38 @@ it('validates native event envelopes with the generated wrapper error contract',
expect(() => validateNativeEventEnvelope([], options))
.toThrow('Agent Bundle event route error: stdin JSON value must be an object');
});

it('validates Cursor workspaceOpen without inventing an agent session', () => {
const options = {
canonicalEvent: 'workspace/open' as const,
nativeEvent: 'workspaceOpen',
target: 'cursor',
};
const documented = {
cursor_version: '1.7.2',
hook_event_name: 'workspaceOpen',
user_email: null,
workspace_roots: ['/abs/path'],
};

expect(validateNativeEventEnvelope(documented, options)).toBe(documented);
for (const workspaceRoots of [undefined, [], [''], ['/abs/path', 7]]) {
expect(() => validateNativeEventEnvelope({
...documented,
workspace_roots: workspaceRoots,
}, options)).toThrow(/native workspace_roots must be a nonempty array of nonempty strings/u);
}
expect(() => validateNativeEventEnvelope({ ...documented, cursor_version: '' }, options))
.toThrow(/native cursor_version must be a nonempty string/u);
expect(() => validateNativeEventEnvelope({ ...documented, user_email: 7 }, options))
.toThrow(/native user_email must be a string or null/u);

expect(() => validateNativeEventEnvelope({
...documented,
hook_event_name: 'sessionStart',
}, {
canonicalEvent: 'session/start',
nativeEvent: 'sessionStart',
target: 'cursor',
})).toThrow(/native session_id or conversation_id must be a string/u);
});
82 changes: 82 additions & 0 deletions packages/agent-bundle/tests/hooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1034,6 +1034,88 @@
}
}, 15_000);

it('runs the Cursor workspace/open lifecycle starter through a generated wrapper with empty stdout', async () => {
const root = await mkdtemp(join(tmpdir(), 'agent-bundle-cursor-workspace-open-'));
const sourceRoot = join(root, 'src', 'events', 'workspace');
const packageRoot = join(root, 'node_modules', 'agent-bundle');
const wrapper = join(root, 'event-route-workspace-open.mjs');
const base = hookModel(root);
const model: NormalizedPlugin = {
...base,
hooks: [{
event: 'workspaceOpen',
eventRoute: { event: 'workspace/open', fallback: 'none', runtime: 'standalone' },
id: 'hook:event-route:workspace-open',
name: 'event-route-workspace-open',
provenance: { kind: 'conventional', sourcePath: join(sourceRoot, 'open.mjs') },
source: join(sourceRoot, 'open.mjs'),
targets: ['cursor'],
tools: [],
}],
targets: [{
id: 'target:cursor',
name: 'cursor',
provenance: { kind: 'config', sourcePath: join(root, 'agent-bundle.config.ts') },
}],
};

try {
await Promise.all([
mkdir(sourceRoot, { recursive: true }),
mkdir(packageRoot, { 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(packageRoot, 'package.json'), JSON.stringify({
exports: {
'./event-ipc': './event-ipc.mjs',
'./event-project': './event-project.mjs',
},
type: 'module',
})),
writeFile(
join(packageRoot, 'event-ipc.mjs'),
`export * from ${JSON.stringify(pathToFileURL(join(process.cwd(), 'packages/agent-bundle/dist/event-ipc.js')).href)};\n`,
),
writeFile(
join(packageRoot, 'event-project.mjs'),
`export * from ${JSON.stringify(pathToFileURL(join(process.cwd(), 'packages/agent-bundle/dist/event-project.js')).href)};\n`,
),
writeFile(join(sourceRoot, 'open.mjs'), [
`import { Agent } from ${JSON.stringify(pathToFileURL(join(process.cwd(), 'packages/rsc-runtime/dist/index.js')).href)};`,
`import { createElement } from ${JSON.stringify(pathToFileURL(join(process.cwd(), 'packages/agent-bundle/node_modules/react/index.js')).href)};`,
'',
'export default async function WorkspaceOpen() {',
' return createElement(Agent.Result);',
'}',
'',
].join('\n')),
]);
const targetRegistry = createDefaultRegistry();
const plan = targetRegistry.get('cursor').plan(model);
const generated = plan.hookEntries?.find((entry) => entry.relativePath === 'hooks/event-route-workspace-open.mjs');
const starter = targetRegistry.hookContract('cursor')?.nativeEventStarter?.('workspace/open');

expect(plan.diagnostics).toEqual([]);
expect(generated).toBeDefined();
await writeFile(wrapper, generated!.virtualSource);
expect(starter).toEqual({
cursor_version: 'lifecycle-replay',
hook_event_name: 'workspaceOpen',
user_email: null,
workspace_roots: ['/tmp'],
});
await expect(runNativeHook(wrapper, starter!)).resolves.toEqual({

Check failure on line 1109 in packages/agent-bundle/tests/hooks.test.ts

View workflow job for this annotation

GitHub Actions / Verify (Node 24)

packages/agent-bundle/tests/hooks.test.ts > runs the Cursor workspace/open lifecycle starter through a generated wrapper with empty stdout

expected { code%3A 1%2C …(2) } to deeply equal { code%3A +0%2C stderr%3A ''%2C stdout%3A '' } - Expected + Received { - "code"%3A 0%2C - "stderr"%3A ""%2C + "code"%3A 1%2C + "stderr"%3A "node%3Ainternal/modules/package_json_reader%3A301 + throw new ERR_MODULE_NOT_FOUND(packageName%2C fileURLToPath(base)%2C null); + ^ + + Error [ERR_MODULE_NOT_FOUND]%3A Cannot find package '@agent-bundle/runtime' imported from /tmp/agent-bundle-rstest-w2/agent-bundle-cursor-workspace-open-UXJ2Mm/event-route-workspace-open.mjs + at Object.getPackageJSONURL (node%3Ainternal/modules/package_json_reader%3A301%3A9) + at packageResolve (node%3Ainternal/modules/esm/resolve%3A784%3A25) + at moduleResolve (node%3Ainternal/modules/esm/resolve%3A873%3A18) + at defaultResolve (node%3Ainternal/modules/esm/resolve%3A1006%3A11) + at #cachedDefaultResolve (node%3Ainternal/modules/esm/loader%3A705%3A20) + at #resolveAndMaybeBlockOnLoaderThread (node%3Ainternal/modules/esm/loader%3A725%3A38) + at ModuleLoader.resolveSync (node%3Ainternal/modules/esm/loader%3A763%3A56) + at #resolve (node%3Ainternal/modules/esm/loader%3A687%3A17) + at ModuleLoader.getOrCreateModuleJob (node%3Ainternal/modules/esm/loader%3A607%3A35) + at ModuleJob.syncLink (node%3Ainternal/modules/esm/module_job%3A276%3A33) { + code%3A 'ERR_MODULE_NOT_FOUND' + } + + Node.js v24.20.0 + "%2C "stdout"%3A ""%2C }
code: 0,
stderr: '',
stdout: '',
});
} finally {
await rm(root, { force: true, recursive: true });
}
}, 15_000);

it('round-trips Claude and Codex subagent fields through published wrappers', async () => {
const root = await mkdtemp(join(tmpdir(), 'agent-bundle-subagent-hook-codecs-'));
const sourceRoot = join(root, 'src', 'hooks');
Expand Down
42 changes: 42 additions & 0 deletions packages/agent-bundle/tests/host-adapters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -610,6 +610,48 @@ it('diagnoses a plain Cursor workspaceOpen hook instead of lowering a session-sc
expect(plan.entries.some((entry) => entry.relativePath === 'hooks/hooks.json')).toBe(false);
});

it('plans a Cursor workspace/open event route without enabling the plain hook vocabulary', () => {
const model: NormalizedPlugin = {
...plugin,
hooks: [{
event: 'workspaceOpen',
eventRoute: { event: 'workspace/open', fallback: 'none', runtime: 'shared' },
id: 'hook:event-route:workspace-open',
name: 'event-route-workspace-open',
provenance: { kind: 'conventional', sourcePath: '/workspace/src/events/workspace/open.tsx' },
source: '/workspace/src/events/workspace/open.tsx',
targets: ['cursor'],
tools: [],
}],
marketplace: undefined,
mcpServers: [],
skills: [],
targets: [{
id: 'target:cursor',
name: 'cursor',
provenance: { kind: 'config', sourcePath: '/workspace/agent-bundle.config.ts' },
}],
};
const plan = createDefaultRegistry().get('cursor').plan(model);
const hooks = plan.entries.find((entry) => entry.relativePath === 'hooks/hooks.json');

expect(plan.diagnostics).toEqual([]);
expect(hooks?.kind).toBe('write');
expect(JSON.parse(hooks?.kind === 'write' ? hooks.content : '{}')).toEqual({
hooks: {
workspaceOpen: [{
command: 'node "${CURSOR_PLUGIN_ROOT}/hooks/event-route-workspace-open.mjs"',
}],
},
version: 1,
});
expect(plan.hookEntries).toContainEqual(expect.objectContaining({
nativeEvent: 'workspaceOpen',
relativePath: 'hooks/event-route-workspace-open.mjs',
target: 'cursor',
}));
});

it('anchors compiled Claude MCP entries with absolute arguments, plugin-root cwd, and the env anchor', () => {
const compiled = {
...plugin,
Expand Down
11 changes: 4 additions & 7 deletions packages/agent-bundle/tests/route-graph.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -797,7 +797,7 @@ it('discovers only the seven v1 event families and validates their component con
expect(graph.diagnostics[1]?.sourcePath).toBe(join(root, 'src/events/tool/before.tsx'));
});

it('fails unavailable event routes before packaging for every selected target', async () => {
it('fails unavailable event routes before packaging while admitting supported targets', async () => {
const eventSource = 'export default async function WorkspaceOpen() { return undefined; }\n';
const configSource = [
'export default {',
Expand All @@ -819,7 +819,7 @@ it('fails unavailable event routes before packaging for every selected target',
code: 'AB4824',
target: 'claude',
}));
expect(unrestricted.diagnostics).toContainEqual(expect.objectContaining({
expect(unrestricted.diagnostics).not.toContainEqual(expect.objectContaining({
code: 'AB4824',
target: 'cursor',
}));
Expand All @@ -835,11 +835,8 @@ it('fails unavailable event routes before packaging for every selected target',
});

const restricted = await inspect({ root: restrictedRoot });
expect(restricted.state).toBe('invalid');
expect(restricted.diagnostics).toContainEqual(expect.objectContaining({
code: 'AB4824',
target: 'cursor',
}));
expect(restricted.state).toBe('ready');
expect(restricted.diagnostics).toEqual([]);
});

it('rejects malformed event route targets with AB4825', async () => {
Expand Down
Loading
Loading