Skip to content
Open
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
49 changes: 44 additions & 5 deletions adapters/cursor.mjs
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
// Cursor agent hooks -> Agent Floor events.
// Cursor writes a JSON payload to the hook's stdin. We only subscribe to hooks
// that observe (after*, stop, beforeSubmitPrompt) so Agent Floor can never change
// a permission decision. See docs/integrations.md for the hooks.json snippet.
// that observe (after*, stop, sessionEnd, subagent*, beforeSubmitPrompt) so
// Agent Floor can never change a permission decision. See docs/integrations.md
// for the hooks.json snippet.
import { basename } from 'node:path';
import { shortId, teamFromCwd } from './common.mjs';
import { pretty, shortId, teamFromCwd } from './common.mjs';

export function fromCursor(input) {
if (!input || typeof input !== 'object') return [];
const root = Array.isArray(input.workspace_roots) ? input.workspace_roots[0] : undefined;
const convId = input.conversation_id || input.session_id;
const lead = `cursor:${shortId(convId)}`;
const team = teamFromCwd(root);
const base = {
agent: `cursor:${shortId(input.conversation_id)}`,
team: teamFromCwd(root),
agent: lead,
team,
source: 'cursor',
name: process.env.AGENT_FLOOR_NAME || 'Cursor',
role: 'Lead',
Expand All @@ -34,6 +38,38 @@ export function fromCursor(input) {
return [{ type: 'tool', ...base, tool: 'MCP', activity: `MCP: ${input.tool_name ?? 'tool'}` }];
case 'afterAgentResponse':
return [{ type: 'status', ...base, status: 'thinking', activity: `Replied: ${input.text ?? ''}` }];
case 'subagentStart': {
const parentId = input.parent_conversation_id ? `cursor:${shortId(input.parent_conversation_id)}` : lead;
const subId = input.subagent_id ? `${parentId}:${shortId(input.subagent_id, 12)}` : `${parentId}:subagent`;
const type = input.subagent_type || 'generalPurpose';
const desc = input.task || input.description || 'Starting task';
return [
{ type: 'message', agent: parentId, team, source: 'cursor', to: subId, text: desc },
{
type: 'join',
agent: subId,
parent: parentId,
team,
source: 'cursor',
name: pretty(type),
role: type,
status: 'working',
activity: desc,
text: desc,
},
];
}
case 'subagentStop': {
const parentId = input.parent_conversation_id ? `cursor:${shortId(input.parent_conversation_id)}` : lead;
const subId = input.subagent_id ? `${parentId}:${shortId(input.subagent_id, 12)}` : `${parentId}:subagent`;
const isErr = input.status === 'error';
const summary = input.summary || (isErr ? 'Encountered error' : 'Task completed');
return [
{ type: 'message', agent: subId, parent: parentId, team, source: 'cursor', to: parentId, text: summary, status: isErr ? 'error' : 'done' },
{ type: 'status', agent: subId, status: isErr ? 'error' : 'done', activity: summary },
{ type: 'status', ...base, agent: parentId, status: 'thinking', activity: `Reviewing ${pretty(input.subagent_type || 'helper')} report` },
];
}
case 'stop':
return [
{
Expand All @@ -43,6 +79,8 @@ export function fromCursor(input) {
activity: input.status === 'error' ? 'Stopped with an error' : 'Waiting for you',
},
];
case 'sessionEnd':
return [{ type: 'leave', ...base, activity: 'Session ended' }];
default:
return [];
}
Expand All @@ -51,5 +89,6 @@ export function fromCursor(input) {
/** What each Cursor hook must print on stdout. Observing hooks print nothing. */
export function cursorResponse(hookName) {
if (hookName === 'beforeSubmitPrompt') return JSON.stringify({ continue: true });
if (hookName === 'subagentStart') return JSON.stringify({ permission: 'allow' });
return '';
}
13 changes: 12 additions & 1 deletion bin/agent-floor.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,18 @@ function claudeHooks() {

function cursorHooks() {
const command = hookCmd('cursor');
const names = ['beforeSubmitPrompt', 'afterAgentThought', 'afterAgentResponse', 'afterFileEdit', 'afterShellExecution', 'afterMCPExecution', 'stop'];
const names = [
'beforeSubmitPrompt',
'afterAgentThought',
'afterAgentResponse',
'afterFileEdit',
'afterShellExecution',
'afterMCPExecution',
'subagentStart',
'subagentStop',
'stop',
'sessionEnd',
];
return { version: 1, hooks: Object.fromEntries(names.map((n) => [n, [{ command }]])) };
}

Expand Down
7 changes: 5 additions & 2 deletions docs/integrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,12 +57,15 @@ If your Codex build supports Claude-style hooks with `hook_event_name` payloads
"afterMCPExecution": [{ "command": "agent-floor hook cursor" }],
"afterAgentResponse": [{ "command": "agent-floor hook cursor" }],
"afterAgentThought": [{ "command": "agent-floor hook cursor" }],
"stop": [{ "command": "agent-floor hook cursor" }]
"subagentStart": [{ "command": "agent-floor hook cursor" }],
"subagentStop": [{ "command": "agent-floor hook cursor" }],
"stop": [{ "command": "agent-floor hook cursor" }],
"sessionEnd": [{ "command": "agent-floor hook cursor" }]
}
}
```

Hooks that gate permissions (`beforeShellExecution`, `beforeMCPExecution`, `beforeReadFile`) are deliberately left out. Hook names have changed between Cursor releases, so if one is not recognised by your version, Cursor ignores it.
Hooks that gate permissions (`beforeShellExecution`, `beforeMCPExecution`, `beforeReadFile`) are deliberately left out; `subagentStart` is allowed through so spawned helpers join the floor. Hook names have changed between Cursor releases, so if one is not recognised by your version, Cursor ignores it.

## Your own agents

Expand Down
37 changes: 36 additions & 1 deletion test/protocol.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -68,11 +68,46 @@ test('codex notify payload', () => {
assert.equal(evs[0].team, 'api');
});

test('cursor adapter only answers beforeSubmitPrompt', () => {
test('cursor adapter only answers beforeSubmitPrompt and subagentStart', () => {
const evs = fromCursor({ hook_event_name: 'afterFileEdit', conversation_id: 'c1', workspace_roots: ['/w/site'], file_path: '/w/site/a.md' });
assert.equal(evs[0].activity, 'Edited a.md');
assert.equal(cursorResponse('afterFileEdit'), '');
assert.deepEqual(JSON.parse(cursorResponse('beforeSubmitPrompt')), { continue: true });
assert.deepEqual(JSON.parse(cursorResponse('subagentStart')), { permission: 'allow' });
});

test('cursor adapter maps subagents and sessionEnd', () => {
const base = { conversation_id: 'conv123', workspace_roots: ['/home/me/app'] };
const start = fromCursor({
...base,
hook_event_name: 'subagentStart',
subagent_id: 'sub_1',
subagent_type: 'explore',
task: 'Investigate auth flow',
});
assert.equal(start.length, 2);
assert.equal(start[0].type, 'message');
assert.equal(start[0].to, 'cursor:conv123:sub1');
assert.equal(start[1].type, 'join');
assert.equal(start[1].parent, 'cursor:conv123');
assert.equal(start[1].role, 'explore');

const stop = fromCursor({
...base,
hook_event_name: 'subagentStop',
subagent_id: 'sub_1',
subagent_type: 'explore',
status: 'completed',
summary: 'Auth verified',
});
assert.equal(stop.length, 3);
assert.equal(stop[0].type, 'message');
assert.equal(stop[0].to, 'cursor:conv123');
assert.equal(stop[1].status, 'done');
assert.equal(stop[2].status, 'thinking');

const end = fromCursor({ ...base, hook_event_name: 'sessionEnd' });
assert.equal(end[0].type, 'leave');
});

test('sweep removes departed agents and empty teams', () => {
Expand Down