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
20 changes: 20 additions & 0 deletions cli/src/cursor/cursorAcpRemoteLauncher.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React from 'react';
import { basename } from 'node:path';
import { logger } from '@/ui/logger';
import { buildHapiMcpBridge } from '@/codex/utils/buildHapiMcpBridge';
import { convertAgentMessage } from '@/agent/messageConverter';
Expand Down Expand Up @@ -30,13 +31,19 @@ import { cursorPassThroughStatusMessage, parseCursorSpecialCommand } from './cur
import { buildCursorModelsSeedPayload, seedCursorModelsCache } from '@/modules/common/cursorModels';
import { readSharedCursorModelsCache } from '@/modules/common/cursorModelsSharedCache';
import type { AcpSdkBackend } from '@/agent/backends/acp';
import {
installCursorNotifyRuleOverlay,
type CursorNotifyRuleOverlay
} from './utils/cursorNotifyRuleOverlay';

class CursorAcpRemoteLauncher extends RemoteLauncherBase {
private readonly session: CursorSession;
private backend: ReturnType<typeof createCursorAcpBackend> | null = null;
private permissionAdapter: PermissionAdapter | null = null;
private extensionAdapter: CursorExtensionAdapter | null = null;
private happyServer: { stop: () => void } | null = null;
/** Transient workspace `.cursor/rules` overlay for session status summaries. */
private notifyRuleOverlay: CursorNotifyRuleOverlay | null = null;
private abortController = new AbortController();
private displayPermissionMode: PermissionMode | null = null;
private currentBackendModel: string | null = null;
Expand Down Expand Up @@ -71,6 +78,14 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase {
const { server: happyServer, mcpServers } = await buildHapiMcpBridge(session.client);
this.happyServer = happyServer;

// Install the workspace session-summary rule before the backend spawns
// cursor-agent, so the `.cursor/rules` file is on disk when it reads
// workspace rules. Restored/removed in cleanup().
this.notifyRuleOverlay = installCursorNotifyRuleOverlay({
cwd: session.path,
project: basename(session.path) || null
});
Comment on lines +84 to +87

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 Strip notify summaries from the terminal buffer

When this overlay makes Cursor append AGENT_NOTIFY_SUMMARY, terminal-driven sessions that switch to remote still render assistant text from handleAgentMessage via messageBuffer.addMessage(message.text, 'assistant'), and OpencodeDisplay formats msg.content without stripAgentContract. In that TTY path humans will see the supposedly hidden machine line every turn, so strip it before adding to the Ink buffer while still sending the raw text to the hub.

Useful? React with 👍 / 👎.


const autoReview = isCursorAutoReviewMode(session.getPermissionMode() as PermissionMode);
this.spawnedWithAutoReview = autoReview;
const backend = createCursorAcpBackend({
Expand Down Expand Up @@ -295,6 +310,11 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase {
this.happyServer = null;
}

if (this.notifyRuleOverlay) {
this.notifyRuleOverlay.cleanup();
this.notifyRuleOverlay = null;
}

setCursorAcpModelsSnapshot(null);
}

Expand Down
127 changes: 127 additions & 0 deletions cli/src/cursor/utils/cursorNotifyRuleOverlay.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
buildNotifyRuleContent,
HAPI_SESSION_RULE_SENTINEL,
installCursorNotifyRuleOverlay
} from './cursorNotifyRuleOverlay';

describe('buildNotifyRuleContent', () => {
it('includes alwaysApply frontmatter, sentinel, and the contract line', () => {
const content = buildNotifyRuleContent();
expect(content.startsWith('---\nalwaysApply: true\n---')).toBe(true);
expect(content).toContain(HAPI_SESSION_RULE_SENTINEL);
expect(content).toContain('AGENT_NOTIFY_SUMMARY {"version":1,');
expect(content).toContain('"status":"done|blocked|needs_review|needs_decision|failed|stalled"');
});

it('bakes in project and agent id when provided', () => {
const content = buildNotifyRuleContent({ project: 'overseer-summary-emit', agentId: 'peer-7' });
expect(content).toContain('"agent":"peer-7"');
expect(content).toContain('"project":"overseer-summary-emit"');
});

it('falls back to placeholders and sanitizes hostile input', () => {
const content = buildNotifyRuleContent({ project: '"}{evil', agentId: ' ' });
expect(content).toContain('"agent":"<agent-id>"');
// quotes/braces stripped, leaving only safe chars
expect(content).toContain('"project":"evil"');
});

it('reads as benign session tracking, never surveillance', () => {
const content = buildNotifyRuleContent().toLowerCase();
expect(content).toContain('session tracking');
expect(content).not.toContain('overseer');
expect(content).not.toContain('surveillance');
expect(content).not.toContain('monitor');
});
});

describe('installCursorNotifyRuleOverlay', () => {
let cwd: string;

beforeEach(() => {
cwd = mkdtempSync(join(tmpdir(), 'hapi-notify-rule-'));
});

afterEach(() => {
rmSync(cwd, { recursive: true, force: true });
});

const rulePathOf = (root: string) => join(root, '.cursor', 'rules', 'hapi-session.mdc');

it('writes the rule file and reports its path', () => {
const overlay = installCursorNotifyRuleOverlay({ cwd });
expect(overlay.rulePath).toBe(rulePathOf(cwd));
expect(existsSync(overlay.rulePath)).toBe(true);
expect(readFileSync(overlay.rulePath, 'utf-8')).toContain(HAPI_SESSION_RULE_SENTINEL);
});

it('cleanup removes our file and prunes dirs it created', () => {
const overlay = installCursorNotifyRuleOverlay({ cwd });
overlay.cleanup();
expect(existsSync(overlay.rulePath)).toBe(false);
expect(existsSync(join(cwd, '.cursor', 'rules'))).toBe(false);
expect(existsSync(join(cwd, '.cursor'))).toBe(false);
});

it('backs up and restores a pre-existing user rule verbatim', () => {
const rulePath = rulePathOf(cwd);
mkdirSync(join(cwd, '.cursor', 'rules'), { recursive: true });
const userContent = '---\nalwaysApply: false\n---\n# my own rule\n';
writeFileSync(rulePath, userContent, 'utf-8');

const overlay = installCursorNotifyRuleOverlay({ cwd });
// ours is installed over it
expect(readFileSync(rulePath, 'utf-8')).toContain(HAPI_SESSION_RULE_SENTINEL);

overlay.cleanup();
// user's file restored exactly, dirs preserved (we did not create them)
expect(readFileSync(rulePath, 'utf-8')).toBe(userContent);
expect(existsSync(join(cwd, '.cursor', 'rules'))).toBe(true);
});

it('does not prune a .cursor dir that has other content', () => {
mkdirSync(join(cwd, '.cursor'), { recursive: true });
writeFileSync(join(cwd, '.cursor', 'mcp.json'), '{}', 'utf-8');

const overlay = installCursorNotifyRuleOverlay({ cwd });
overlay.cleanup();

// our rule + the rules dir we created are gone...
expect(existsSync(overlay.rulePath)).toBe(false);
expect(existsSync(join(cwd, '.cursor', 'rules'))).toBe(false);
// ...but the pre-existing .cursor dir (with sibling content) survives
expect(existsSync(join(cwd, '.cursor'))).toBe(true);
expect(existsSync(join(cwd, '.cursor', 'mcp.json'))).toBe(true);
});

it('treats a sentinel-bearing file (prior/concurrent session) as ours, not a backup', () => {
const rulePath = rulePathOf(cwd);
mkdirSync(join(cwd, '.cursor', 'rules'), { recursive: true });
writeFileSync(rulePath, buildNotifyRuleContent({ project: 'stale' }), 'utf-8');

const overlay = installCursorNotifyRuleOverlay({ cwd });
overlay.cleanup();
// removed, not "restored" — the sentinel file was ours
expect(existsSync(rulePath)).toBe(false);
});

it('never deletes a user file that replaced ours mid-session', () => {
const overlay = installCursorNotifyRuleOverlay({ cwd });
const userContent = 'the user clobbered our rule with their own\n';
writeFileSync(overlay.rulePath, userContent, 'utf-8');

overlay.cleanup();
expect(readFileSync(overlay.rulePath, 'utf-8')).toBe(userContent);
});

it('cleanup is idempotent', () => {
const overlay = installCursorNotifyRuleOverlay({ cwd });
overlay.cleanup();
expect(() => overlay.cleanup()).not.toThrow();
expect(existsSync(overlay.rulePath)).toBe(false);
});
});
186 changes: 186 additions & 0 deletions cli/src/cursor/utils/cursorNotifyRuleOverlay.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
/**
* Per-session, workspace-local Cursor rule overlay for session status summaries.
*
* cursor-agent has no HAPI-controlled system-prompt channel (unlike claude/codex/
* grok/opencode). It only discovers rules from workspace `.cursor/rules/*.mdc`
* files (plain `.md` is ignored) and global `~/.cursor` user rules. Editing the
* global user rules would pollute the operator's non-HAPI Cursor experience, so
* we install a transient, repo-local rule for the lifetime of a session and
* remove it on teardown.
*
* The rule asks the agent to end each response with a one-line machine-readable
* status summary that this workspace's session tracking records. The line shape
* mirrors `AGENT_NOTIFY_CONTRACT_INLINE_PREFIX` in `shared/src/overseerEvents.ts`.
*
* Non-clobbering discipline (mirrors how a config overlay behaves): if the user
* already has a file at the same path we back up its contents and restore them on
* cleanup; a file that already carries our sentinel is one of ours (a prior or
* concurrent session in the same cwd), so we never treat it as user content. All
* fs work is fail-open — a missing rule must never crash a session.
*/

import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, rmdirSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { logger } from '@/ui/logger';

const RULE_FILENAME = 'hapi-session.mdc';

/**
* Hidden marker identifying files this overlay owns. Lets us distinguish a
* user's pre-existing rule (back up + restore) from one written by another HAPI
* session sharing the cwd (safe to overwrite / remove).
*/
export const HAPI_SESSION_RULE_SENTINEL = '<!-- hapi:session-summary-rule -->';

export interface CursorNotifyRuleOverlay {
/** Absolute path of the rule file this overlay manages. */
readonly rulePath: string;
/** Restore any pre-existing user file / remove ours, prune dirs we created. */
cleanup: () => void;
}

export interface InstallCursorNotifyRuleOverlayOptions {
/** Workspace root cursor-agent is spawned in (session.path). */
cwd: string;
/** Optional project name to bake into the example line. */
project?: string | null;
/** Optional agent id to bake into the example line. */
agentId?: string | null;
}

/**
* Build the `.mdc` rule content. `alwaysApply: true` so cursor-agent applies it
* to every turn without the agent having to opt in. Kept deliberately benign and
* functional: it reads as ordinary session-tracking config, not surveillance.
*/
export function buildNotifyRuleContent(opts: { project?: string | null; agentId?: string | null } = {}): string {
const project = sanitizeToken(opts.project) ?? '<project>';
const agentId = sanitizeToken(opts.agentId) ?? '<agent-id>';
const exampleLine = `AGENT_NOTIFY_SUMMARY {"version":1,"agent":"${agentId}","project":"${project}","status":"done|blocked|needs_review|needs_decision|failed|stalled","action":"<=12 words","summary":"one-line triage"}`;

return [
'---',
'alwaysApply: true',
'---',
HAPI_SESSION_RULE_SENTINEL,
'',
'# Session status summary',
'',
'End every response with a single machine-readable status line so this',
"workspace's session tracking can record progress. Put it on its own final",
'line, after all other content, with no code fences or backticks:',
'',
exampleLine,
'',
'Guidance:',
'- status: pick the closest value; use "blocked" if unsure.',
'- action: concrete next step (required when status is "done" and follow-up',
' remains); 12 words or fewer.',
'- summary: one-line triage of what this turn did.',
'- Keep it as the very last line of every response.',
''
].join('\n');
}

/**
* Install the rule file at `<cwd>/.cursor/rules/hapi-session.mdc`, backing up any
* pre-existing user file. Returns an overlay handle whose `cleanup()` restores
* the prior state. Never throws: on failure it returns a no-op cleanup so callers
* can wire it unconditionally.
*/
export function installCursorNotifyRuleOverlay(
opts: InstallCursorNotifyRuleOverlayOptions
): CursorNotifyRuleOverlay {
const cursorDir = join(opts.cwd, '.cursor');
const rulesDir = join(cursorDir, 'rules');
const rulePath = join(rulesDir, RULE_FILENAME);

// Dirs we create so cleanup can prune exactly what we added (deepest first).
const createdDirs: string[] = [];
// Verbatim contents of a user's pre-existing file, restored on cleanup.
let preExistingContent: string | null = null;
let cleaned = false;

try {
if (!existsSync(cursorDir)) {
mkdirSync(cursorDir, { recursive: true });
createdDirs.push(cursorDir);
Comment on lines +105 to +107

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 Do not create missing workspace roots

With a stale or deleted cwd, this recursive mkdir creates <cwd> just to place .cursor, but cleanup only tracks .cursor and rules, leaving the previously missing workspace root behind and potentially letting Cursor start in an empty project instead of failing on the bad path. Please first verify cwd is an existing directory and fail open without creating it.

Useful? React with 👍 / 👎.

}
if (!existsSync(rulesDir)) {
mkdirSync(rulesDir, { recursive: true });
createdDirs.push(rulesDir);
}

if (existsSync(rulePath)) {
const existing = safeRead(rulePath);
// A file that already carries our sentinel belongs to HAPI (prior or
// concurrent session) — do not treat it as user content to preserve.
if (existing !== null && !existing.includes(HAPI_SESSION_RULE_SENTINEL)) {
preExistingContent = existing;
}
}

writeFileSync(rulePath, buildNotifyRuleContent(opts), 'utf-8');

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 Refuse symlinked Cursor rule targets before writing

When a checked-out workspace contains .cursor/rules/hapi-session.mdc as a symlink, this write follows the link and temporarily overwrites the target with the HAPI rule. That lets an untrusted repo clobber arbitrary user-owned files for the lifetime of the session (and permanently if cleanup does not run), so the overlay should lstat/skip symlinked paths or use a no-follow create/replace path before writing.

Useful? React with 👍 / 👎.

} catch (error) {
logger.debug('[cursor-notify-rule] install failed', error);
}

const cleanup = (): void => {
if (cleaned) return;
cleaned = true;
try {
if (preExistingContent !== null) {
// Restore the user's file exactly as it was.
writeFileSync(rulePath, preExistingContent, 'utf-8');
Comment on lines +132 to +134

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 Avoid clobbering edits to backed-up rule files

When a workspace already has .cursor/rules/hapi-session.mdc, cleanup always restores the in-memory preExistingContent without checking what is currently on disk. If the user or another tool edits/replaces that rule while the HAPI session is running, teardown silently rolls it back to stale contents, unlike the no-backup path which checks for the sentinel before deleting; please only restore when the current file is still the HAPI overlay or otherwise preserve the new user content.

Useful? React with 👍 / 👎.

return;
}

// Only remove the file if it is still ours (a user may have replaced
// it mid-session; never delete their content).
if (existsSync(rulePath)) {
const current = safeRead(rulePath);
if (current === null || current.includes(HAPI_SESSION_RULE_SENTINEL)) {
rmSync(rulePath, { force: true });
}
}

// Prune dirs we created, deepest first, only while empty.
for (const dir of [...createdDirs].reverse()) {
if (isEmptyDir(dir)) {
rmdirSync(dir);
}
}
} catch (error) {
logger.debug('[cursor-notify-rule] cleanup failed', error);
}
};

return { rulePath, cleanup };
}

function safeRead(path: string): string | null {
try {
return readFileSync(path, 'utf-8');
} catch {
return null;
}
}

function isEmptyDir(path: string): boolean {
try {
return readdirSync(path).length === 0;
} catch {
return false;
}
}

/**
* Keep only characters safe to bake into a JSON string example (no quotes/braces/
* newlines). Returns null for empty/whitespace so callers fall back to the
* placeholder token.
*/
function sanitizeToken(value: string | null | undefined): string | null {
if (typeof value !== 'string') return null;
const cleaned = value.replace(/[^A-Za-z0-9._\- /]/g, '').trim();
return cleaned.length > 0 ? cleaned : null;
}
Loading
Loading