diff --git a/cli/src/cursor/cursorAcpRemoteLauncher.ts b/cli/src/cursor/cursorAcpRemoteLauncher.ts index 0023dc9731..5d8592ecc0 100644 --- a/cli/src/cursor/cursorAcpRemoteLauncher.ts +++ b/cli/src/cursor/cursorAcpRemoteLauncher.ts @@ -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'; @@ -30,6 +31,10 @@ 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; @@ -37,6 +42,8 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase { 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; @@ -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 + }); + const autoReview = isCursorAutoReviewMode(session.getPermissionMode() as PermissionMode); this.spawnedWithAutoReview = autoReview; const backend = createCursorAcpBackend({ @@ -295,6 +310,11 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase { this.happyServer = null; } + if (this.notifyRuleOverlay) { + this.notifyRuleOverlay.cleanup(); + this.notifyRuleOverlay = null; + } + setCursorAcpModelsSnapshot(null); } diff --git a/cli/src/cursor/utils/cursorNotifyRuleOverlay.test.ts b/cli/src/cursor/utils/cursorNotifyRuleOverlay.test.ts new file mode 100644 index 0000000000..e4221c8aac --- /dev/null +++ b/cli/src/cursor/utils/cursorNotifyRuleOverlay.test.ts @@ -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":""'); + // 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); + }); +}); diff --git a/cli/src/cursor/utils/cursorNotifyRuleOverlay.ts b/cli/src/cursor/utils/cursorNotifyRuleOverlay.ts new file mode 100644 index 0000000000..f22a80f318 --- /dev/null +++ b/cli/src/cursor/utils/cursorNotifyRuleOverlay.ts @@ -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 = ''; + +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) ?? ''; + const agentId = sanitizeToken(opts.agentId) ?? ''; + 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 `/.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); + } + 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'); + } 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'); + 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; +} diff --git a/docs/plans/2026-07-24-overseer-summary-emission.md b/docs/plans/2026-07-24-overseer-summary-emission.md new file mode 100644 index 0000000000..8dcf7d078d --- /dev/null +++ b/docs/plans/2026-07-24-overseer-summary-emission.md @@ -0,0 +1,140 @@ +# Overseer summary emission (Half B) β€” 2026-07-24 + +Status: in progress +Owner: feat/overseer-summary-emit (peer of πŸ”overseer prep) +Scope: FORK-ONLY. Never upstream. The whole overseer feature is fork-private. + +## Why this exists (the real WHY β€” keep it here, not in product code) + +The overseer/inbox/session-log is fed by `AGENT_NOTIFY_SUMMARY` lines that agents +emit as the last line of every turn. PR #81 (`feat/overseer-contract-invisible`) +shipped **Half A β€” invisibility**: `stripAgentContract()` hides the trailing +`AGENT_NOTIFY_SUMMARY {...}` line from the human render/copy, and +`extractNotifySummary()` / `collapseRepeats()` parse it corruption-tolerantly +(recovers Cursor's `SUMMARY`->`SUMARY` dup-drop). The raw text survives in the +store so the overseer can read it. + +**Half A is useless for Cursor because nothing emits the line.** The hub +deliberately does not inject the contract for Cursor +(`shouldInjectNotifyContract('cursor') === false`, now orphaned β€” see below), and +no compensating Cursor-side rule was ever created. Cursor is ~95% of the fleet, +so the overseer is effectively blind: it sees turns but gets no self-reported +status/action/summary for almost every session. + +This plan closes that gap: **Half B β€” summary emission for Cursor**, plus a +hub-side deterministic backstop so the overseer never has a fully blind turn even +when rule compliance slips. + +## Constraints learned the hard way (do not re-litigate) + +- **No user-turn prepend.** Non-Cursor ACP flavors used to PREPEND the contract + onto the user's outbound message. That tripped a prompt-injection false + positive and was removed (upstream #1095 / fork #1096, + `fix/skill-lookup-no-user-prepend`). `shouldInjectNotifyContract` is now + orphaned (referenced only by its own test). Do NOT reintroduce a user-turn + prepend for any flavor. +- **cursor-agent has no HAPI system-prompt channel.** Unlike claude/codex/grok/ + opencode (each has `cli/src//utils/systemPrompt.ts`), cursor-agent + ignores ACP `session/new` `mcpServers` for prompt purposes and has no + developer-instructions hook we control. It discovers rules only from: + (a) workspace `.cursor/rules/*.mdc` (MUST be `.mdc` with frontmatter; plain + `.md` is ignored) + `AGENTS.md`, and (b) `~/.cursor` global user rules. +- **No global `~/.cursor` edits.** That would pollute the operator's entire + non-HAPI Cursor experience. Rejected. +- **`--add-dir` is not documented to contribute rules.** Do not rely on it. + +## Decision: per-session transient repo-local `.cursor/rules/*.mdc` overlay + +Write a per-session, workspace-local, **transient** rule file at +`/.cursor/rules/hapi-session.mdc` on spawn and remove/restore it at +teardown. This is the only channel cursor-agent reliably reads that we can scope +to a single HAPI session's workspace. + +Discipline (mandatory, same shape a config overlay would use): + +- **Merge-safe / non-clobbering.** If the user already has a file at that exact + path, back up its contents and restore them verbatim on cleanup. +- **Own-file sentinel.** Our file carries a hidden sentinel comment. A file that + already carries the sentinel is one of ours (a prior or concurrent HAPI + session in the same cwd), never a user's β€” so we never back it up as if it + were user content and never leave a stale copy behind. +- **Created-dir tracking.** We remember whether we created `.cursor` and/or + `.cursor/rules`; on cleanup we prune only the dirs we created, and only if + they are empty. +- **Fail-open, never throw.** Every fs op is wrapped; a failure logs at debug and + is swallowed. A missing rule must never crash a session β€” it just degrades to + the hub backstop below. + +## Two pieces + +### Piece 1 β€” CLI Cursor notify-rule overlay (this branch: `feat/overseer-summary-emit`) + +- `cli/src/cursor/utils/cursorNotifyRuleOverlay.ts` β€” `installCursorNotifyRuleOverlay({ cwd, project, agentId })` + returns `{ cleanup }`. Writes the `.mdc` with `alwaysApply: true` frontmatter; + body mandates ending every response with the canonical machine line. +- Wired into `cli/src/cursor/cursorAcpRemoteLauncher.ts`: install near the top of + `runMainLoop` (before the backend spawns cursor-agent so the file is on disk + first); `cleanup()` in the launcher's `cleanup()`. +- Canonical line shape mirrors `AGENT_NOTIFY_CONTRACT_INLINE_PREFIX` + (`shared/src/overseerEvents.ts`): + `AGENT_NOTIFY_SUMMARY {"version":1,"agent":"","project":"","status":"done|blocked|needs_review|needs_decision|failed|stalled","action":"<=12 words","summary":"one-line triage"}` + +### Piece 2 β€” Hub deterministic backstop (stacked branch: `feat/overseer-summary-fallback`) + +LLMs cannot be 100%-forced to emit a trailing line. For any agent turn where +`extractNotifySummary()` returns `null` (and the turn is not already covered by +the malformed-line / empty-sentinel / tool-failure branches), synthesize a +**minimal** overseer event from the assistant text the hub already has: + +- summary = first non-empty line of the assistant plain text (trimmed/capped), +- status heuristic = default `progress`; no LLM (operator explicitly rejected the + clunky LLM summarizer for v1), +- provenance marks it hub-synthesized so it is distinguishable from a real + self-report and never counted as compliance. + +Wired into `hub/src/sync/overseerEventRecorder.ts` `onAgentMessage`, only on the +`else` path where no `notify` was found. Guarantees the overseer never has a +completely blind agent turn. + +## Stealth requirements (operator-critical β€” "don't freak users out") + +Cursor rules are visible to the user in their workspace. The overlay must read as +ordinary, useful project config, never as surveillance: + +- **Filename:** `.cursor/rules/hapi-session.mdc` β€” innocuous, not + `hapi-overseer-surveillance.mdc`. +- **Rule copy:** "end each response with a one-line machine-readable status + summary for session tracking." True and useful-sounding. Never "so the overseer + can watch you." +- **Prompt teardown:** remove the file at session end (restore any pre-existing + user file). +- **Code comments / labels:** benign ("session summaries", not "overseer + monitoring"). The real WHY lives in this doc, which is fork-only and excluded + from upstream diffs. + +## Scope / non-goals + +- Focus Cursor (the 95% gap). +- kimi + generic ACP share the same removed-prepend gap β€” **optional follow-up**, + not in scope now. +- codex/grok/opencode have clean `systemPrompt.ts` and may already emit via their + own channels β€” optional later. + +## Known edge cases / follow-ups + +- **Concurrent HAPI sessions sharing one cwd:** the sentinel prevents user-data + loss; worst case is one session removing the shared rule mid-run of another + (rule stops applying, hub backstop still covers it). Acceptable for v1. +- **Cursor native `--worktree`:** the backend spawn cwd is `session.path`; if a + future cursor-native worktree changes the effective workspace root, revisit + where the rule is written. Noted, not handled in v1. +- **Rule compliance ceiling:** even with `alwaysApply`, the model may drop the + line. That is exactly why Piece 2 exists. + +## Soup / coordination + +- Fork-only; layers go AFTER `feat/overseer-contract-invisible` in + `config/driver-manifest.yaml`. Coordinate exact placement + stacking + any + rebuild with the "cursor - tooling/meta bot" session. +- CLI change requires an operator `hapi-restart-hub` to take effect. Agents must + not restart / stack-switch.