diff --git a/adapters/cursor.mjs b/adapters/cursor.mjs index d39ce5c..30fc0d9 100644 --- a/adapters/cursor.mjs +++ b/adapters/cursor.mjs @@ -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', @@ -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 [ { @@ -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 []; } @@ -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 ''; } diff --git a/bin/agent-floor.mjs b/bin/agent-floor.mjs index a232bce..af980f5 100644 --- a/bin/agent-floor.mjs +++ b/bin/agent-floor.mjs @@ -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 }]])) }; } diff --git a/docs/integrations.md b/docs/integrations.md index 7de8da8..64a33dc 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -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 diff --git a/test/protocol.test.mjs b/test/protocol.test.mjs index 842ccf0..d04d4a3 100644 --- a/test/protocol.test.mjs +++ b/test/protocol.test.mjs @@ -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', () => {