From 4f680a0b80248e37a7bba9cd66dd8785d13c6854 Mon Sep 17 00:00:00 2001 From: Ramin Date: Tue, 20 Oct 2026 17:03:40 +0200 Subject: [PATCH] feat(cursor): support preCompact lifecycle hook - Handle preCompact in cursor adapter to display memory compaction status - Auto-relax agent back to idle after 10s if no subsequent event arrives - Add preCompact to cursorHooks setup list and docs snippet - Add test coverage in test/protocol.test.mjs --- adapters/cursor.mjs | 4 +++- bin/agent-floor.mjs | 31 +++++++++++++++++++++++++++++-- docs/integrations.md | 1 + test/protocol.test.mjs | 3 +++ 4 files changed, 36 insertions(+), 3 deletions(-) diff --git a/adapters/cursor.mjs b/adapters/cursor.mjs index d39ce5c..d839eb0 100644 --- a/adapters/cursor.mjs +++ b/adapters/cursor.mjs @@ -1,6 +1,6 @@ // 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 +// that observe (after*, stop, preCompact, 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'; @@ -34,6 +34,8 @@ 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 'preCompact': + return [{ type: 'status', ...base, status: 'thinking', activity: 'Tidying up memory' }]; case 'stop': return [ { diff --git a/bin/agent-floor.mjs b/bin/agent-floor.mjs index a232bce..886d8b8 100644 --- a/bin/agent-floor.mjs +++ b/bin/agent-floor.mjs @@ -192,7 +192,34 @@ async function hook(kind) { const { fromCursor, cursorResponse } = await import('../adapters/cursor.mjs'); const input = parseJson(await readStdin()); response = cursorResponse(input?.hook_event_name); - await postOrLaunch(fromCursor(input)); + if (response) process.stdout.write(response); + const events = fromCursor(input); + await postOrLaunch(events); + + if (input?.hook_event_name === 'preCompact' && events.length) { + const ev = events[0]; + setTimeout(async () => { + try { + const res = await fetch(`${URL_BASE}/state`); + if (res.ok) { + const state = await res.json(); + const a = state.agents?.[ev.agent]; + if (a && a.status === 'thinking' && a.activity === 'Tidying up memory') { + await postOrLaunch([{ + type: 'status', + agent: ev.agent, + team: ev.team, + source: ev.source, + status: 'idle', + activity: 'Waiting for you', + }]); + } + } + } catch {} + process.exit(0); + }, 10000); + return; + } } } catch { // Floor not running, or bad payload. Stay silent. @@ -251,7 +278,7 @@ 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', 'preCompact', 'stop']; return { version: 1, hooks: Object.fromEntries(names.map((n) => [n, [{ command }]])) }; } diff --git a/docs/integrations.md b/docs/integrations.md index 7de8da8..293ef5a 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -57,6 +57,7 @@ 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" }], + "preCompact": [{ "command": "agent-floor hook cursor" }], "stop": [{ "command": "agent-floor hook cursor" }] } } diff --git a/test/protocol.test.mjs b/test/protocol.test.mjs index 842ccf0..77cad35 100644 --- a/test/protocol.test.mjs +++ b/test/protocol.test.mjs @@ -71,6 +71,9 @@ test('codex notify payload', () => { test('cursor adapter only answers beforeSubmitPrompt', () => { 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'); + const compact = fromCursor({ hook_event_name: 'preCompact', conversation_id: 'c1', workspace_roots: ['/w/site'] }); + assert.equal(compact[0].activity, 'Tidying up memory'); + assert.equal(compact[0].status, 'thinking'); assert.equal(cursorResponse('afterFileEdit'), ''); assert.deepEqual(JSON.parse(cursorResponse('beforeSubmitPrompt')), { continue: true }); });