diff --git a/README.md b/README.md index 566d14932b..b0f56e28ff 100644 --- a/README.md +++ b/README.md @@ -123,7 +123,7 @@ never leaves the machine. ### Node kinds 🖥 **Terminal** (xterm + tmux, AI naming) · 🤖 **Agent** (Claude Code / Codex / Antigravity / -Gemini / GitHub Copilot / opencode / Grok / custom) · 📝 **Sticky note** (link to an agent as context) · 🗂 **Group** +Gemini / GitHub Copilot / opencode / Grok / Cursor / custom) · 📝 **Sticky note** (link to an agent as context) · 🗂 **Group** (bind to a **git worktree** for agent-per-branch) · ✏️ **Editor** (Monaco, ⌘S) · 🔀 **Diff** · 🌐 **Web / Video** diff --git a/docs/cursor-agent.md b/docs/cursor-agent.md new file mode 100644 index 0000000000..dbe8e3f1fb --- /dev/null +++ b/docs/cursor-agent.md @@ -0,0 +1,571 @@ +# Cursor Agent CLI (`cursor-agent`) as a nodeterm agent + +Builtin id `cursor` (`AGENT_CONFIG.cursor`, launch `cursor-agent`, prompt behind the `agent` +subcommand). Sections 1 to 8 cover the status-hook leaf: `cursor` is a member of +`AGENT_HOOK_TARGETS`, so a Cursor node gets the RUNNING badge, the unread dot, the completion +notification, `--after` dependencies and trigger targets. The later sections cover every capability +it joined since (model and permission flags, chat, meter, session continuity, NEEDS YOU, +orchestration). Sibling write-ups: +`docs/antigravity-agent.md` (closest precedent), `docs/grok-agent.md`. + +**Where the facts come from.** Measured on `cursor-agent` 2026.09.28-64d2043 (macOS): five headless +(`-p`) runs on a paid account, two zero-cost interactive TUI launches, and the shipped JS bundle +(`~/.local/share/cursor-agent/versions/*/`). The installed CLI auto-updated from 2026.09.23 during +the work. Fixture: `src/shared/agents/__fixtures__/cursor/hook-payloads.json`. + +## 1. The recorded id (for the resume work) + +Every hook payload carries **`conversation_id`** and a duplicate **`session_id`** (equal in every +capture). `normalizeCursor` records `conversation_id` (falling back to `session_id`) as the event's +`sessionId`, which is how every agent's id reaches `agentStatus`. **Verified with zero cost:** +`cursor-agent --resume ` opens that chat with its history. `cursor-agent +create-chat` prints a new empty chat id. The transcript path is +`~/.cursor/projects//agent-transcripts//.jsonl` +(JSONL; the last line is `{"type":"turn_ended","status":"success"|"error"}`), and the payload's +`transcript_path` names it once the first turn is written (null before). + +## 2. Hook config + +- Files read: `~/.cursor/hooks.json`, `/.cursor/hooks.json`, enterprise/team files, and + **`~/.claude/settings.json` (+ project `.claude/settings*.json`)** as Claude compat. +- Shape: `{"version":1,"hooks":{"":[{"command":"...","timeout":5}]}}`, flat entries. +- 21 events exist. We subscribe six (`CURSOR_HOOK_EVENTS`): `beforeSubmitPrompt`, `preToolUse`, + `postToolUse`, `postToolUseFailure`, `stop`, `sessionEnd` (see "Session continuity"). +- Payload envelope (all events): `conversation_id`, `session_id`, `generation_id`, `model`, + `hook_event_name`, `cursor_version`, `workspace_roots[]`, `user_email`, `transcript_path`. Tool + events add `tool_name` (`Shell`, ...), `tool_input`, `tool_use_id`, `cwd` (was `""`), and + `postToolUse` adds `tool_output`, `duration`. Hook env carries `CURSOR_PROJECT_DIR`, + `CURSOR_VERSION`, `CURSOR_TRANSCRIPT_PATH`, `CLAUDE_PROJECT_DIR`, and the parent's env + (so `NODETERM_NODE_ID` reaches our script). +- `sessionStart` fires once at startup, **not on `--resume`** (measured in the TUI and in the bundle). + Re-measured on 2026.10.01 (private tmux, project-level logger, no prompt): a plain `cursor-agent` + fires it within seconds, before any prompt, with `conversation_id` (== `generation_id`); a + `cursor-agent --resume ` (how nodeterm launches EVERY node, minted id included) fired + nothing until `/quit` (`sessionEnd`), two runs. So it is not subscribed: it would not give a fresh + nodeterm node a status before its first turn (no DROPPED chip if it is killed before one). + +## 3. Is the hook a gate? Yes, for some events; silence is the safe default + +From the bundle (`executeCommandHook`) and confirmed by runs: + +| hook result | effect | +|---|---| +| empty stdout, exit 0 | no-op; the tool ran (measured on `preToolUse`) | +| exit 2 | DENY on gating events (`preToolUse`, `beforeShellExecution`, `beforeMCPExecution`, `beforeReadFile`, `subagentStart`) | +| any other non-zero exit, timeout | logged as failed, fail-open unless the entry sets `failClosed` | +| stdout that is not valid JSON | DENY on gating events ("blocked for safety") | + +So the installed command is `sh