diff --git a/.agents/skills/afk/SKILL.md b/.agents/skills/afk/SKILL.md index 4a09c3a07..8b21546cc 100644 --- a/.agents/skills/afk/SKILL.md +++ b/.agents/skills/afk/SKILL.md @@ -80,7 +80,7 @@ explicit word - the daemon just batches the notification. The daemon prefixes every injection with `FM_INJECT_MARK` (U+2063 INVISIBLE SEPARATOR), which has no normal keyboard keystroke and survives terminal transport as UTF-8 text. This is how firstmate tells a daemon escalation apart from a real message in the same pane. -The marker travels with the message text; it does not rely on harness-level typed-vs-injected detection, which is not portable across claude, codex, opencode, pi, and grok. +The marker travels with the message text; it does not rely on harness-level typed-vs-injected detection, which is not portable across claude, codex, cursor, opencode, pi, and grok. ## Busy-guard and composer guard diff --git a/.agents/skills/firstmate-orca/SKILL.md b/.agents/skills/firstmate-orca/SKILL.md index 854d49793..414ff9331 100644 --- a/.agents/skills/firstmate-orca/SKILL.md +++ b/.agents/skills/firstmate-orca/SKILL.md @@ -13,7 +13,7 @@ It does not replace `AGENTS.md`, `docs/orca-backend.md`, or `harness-adapters`. Orca is a runtime backend, not an agent harness. The runtime backend owns the task endpoint and, for Orca, the task worktree. -The harness is the agent process launched inside that endpoint, such as `claude`, `codex`, `opencode`, `pi`, or `grok`. +The harness is the agent process launched inside that endpoint, such as `claude`, `codex`, `cursor`, `opencode`, `pi`, or `grok`. Load `harness-adapters` for harness-specific launch, interrupt, resume, trust-dialog, and skill-invocation facts. Implementation details, metadata fields, teardown guarantees, limitations, and smoke evidence live in `docs/orca-backend.md`. diff --git a/.agents/skills/harness-adapters/SKILL.md b/.agents/skills/harness-adapters/SKILL.md index 86c684f7c..cb50fb22a 100644 --- a/.agents/skills/harness-adapters/SKILL.md +++ b/.agents/skills/harness-adapters/SKILL.md @@ -1,6 +1,6 @@ --- name: harness-adapters -description: Agent-only reference for firstmate harness operations. Use before spawning or recovering a crewmate or secondmate, handling a trust dialog, sending a harness-specific skill invocation, interrupting or exiting an agent, resuming an exited agent, or verifying a new harness adapter. Contains verified facts for claude, codex, opencode, pi, and grok. +description: Agent-only reference for firstmate harness operations. Use before spawning or recovering a crewmate or secondmate, handling a trust dialog, sending a harness-specific skill invocation, interrupting or exiting an agent, resuming an exited agent, or verifying a new harness adapter. Contains verified facts for claude, codex, cursor, opencode, pi, and grok. user-invocable: false metadata: internal: true @@ -52,7 +52,7 @@ Use that value for interrupt, exit, resume, and skill-invocation facts. Every verified primary harness has an empirically validated hook path for the "no turn ends blind" guard. `claude` and `codex` block directly through Stop hooks that preserve exit status 2 and stderr from `bin/fm-turnend-guard.sh`. -`opencode`, `pi`, and `grok` expose passive lifecycle callbacks for this purpose, so their tracked primary adapters force one bounded follow-up or resume when the shared predicate blocks. +`cursor`, `opencode`, `pi`, and `grok` expose passive lifecycle callbacks for this purpose, so their tracked primary adapters force one bounded follow-up or resume when the shared predicate blocks. The exact hook files, commands, validation transcripts, scoping rules, and fail-open tradeoffs are owned by `docs/turnend-guard.md`. When changing any primary turn-end hook, validate the real harness behavior in a scratch project or throwaway home before trusting it, then update that doc and the relevant concise fact below. @@ -72,6 +72,7 @@ Full mechanics, scoping, dated commands, payloads, and fail-open evidence live i - `claude`: verified native `SessionStart` stdout injection; `.claude/settings.json` matches `startup`, `resume`, and `clear`, but not `compact`. - `codex`: verified on 0.144.4; `.codex/hooks.json` receives `source=startup`, and wrapper stdout reaches model context. +- `cursor`: `.cursor/hooks.json` registers the wrapper on the bundle-confirmed `sessionStart` event, but Cursor Agent 2026.07.16-899851b executes no hooks at all for accounts without Cursor's server-side hooks rollout, so this adapter is documented fail-open and the emitted supervision block plus AGENTS.md section 3 remain the working enforcement. - `opencode`: verified on 1.17.18; `session.created` plus `client.session.promptAsync` starts the nudge turn in the TUI, while `opencode run` remains fail-open headless. - `pi`: verified native `session_start`; the existing primary extension handles `startup`, `new`, and `resume` and uses `pi.sendMessage` to inject context without racing a positional launch prompt. - `grok`: the 0.2.103 project `SessionStart` event fires with `source=new`, but stdout does not reach model context; the tracked project hook remains fail-open, and a global token-guarded fallback requires a captain decision. @@ -81,6 +82,7 @@ Full mechanics, scoping, dated commands, payloads, and fail-open evidence live i At session start, `bin/fm-session-start.sh` prints exactly one watcher supervision block for the detected primary harness. Do not substitute another harness's wait shape when resuming supervision. Claude and Grok use tracked background-notify cycles around `bin/fm-watch-arm.sh`. +Cursor uses a tracked background Shell task around `bin/fm-watch-arm.sh`. Codex uses bounded foreground checkpoints through `bin/fm-watch-checkpoint.sh` because Codex cannot reason while a foreground tool call is running. OpenCode uses `.opencode/plugins/fm-primary-watch-arm.js`, which coordinates with the turn-end guard plugin and wakes the TUI with `client.session.promptAsync`. Pi uses the tracked `.pi/extensions/fm-primary-turnend-guard.ts` plus the tracked `.pi/extensions/fm-primary-pi-watch.ts`, both project-local extensions Pi auto-discovers once trusted. @@ -105,6 +107,7 @@ The supported launch-profile flags below are verified locally; each row records |---|---|---|---| | claude | `--model ` | `--effort ` | Verified on Claude Code 2.1.196. | | codex | `--model ` | `-c 'model_reasoning_effort=""'` | Verified on codex-cli 0.142.1. The installed binary schema contains `model_reasoning_effort`, the active config uses it, and the bundled model catalog advertises only low/medium/high/xhigh. `max` is omitted. | +| cursor | `--model ` | none | Verified on Cursor Agent 2026.07.16-899851b (2026-07-18). `--help` documents quoted bracket model overrides such as `claude-opus-4-8[context=1m,effort=high,fast=false]`, but there is no standalone effort flag, so firstmate records and omits the separate effort axis and leaves any effort choice inside the caller's model string. | | grok | `--model ` | `--reasoning-effort ` | Verified on grok 0.2.99 (2026-07-13). `--effort` is an alias, but firstmate's profile axis is reasoning effort. As of 0.2.99 the ceiling is `high`; both `xhigh` and `max` are rejected with `use one of: high, medium, low`, so firstmate omits them. | | pi | `--model ` | `--thinking ` | Verified 2026-07-13 on Pi 0.80.6. `pi --help` advertises `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, and `max`; `pi --print --model openai-codex/gpt-5.6-sol --thinking max 'Reply with exactly OK.'` completed successfully. | | opencode | `--model ` | none for firstmate's interactive launch | Verified on opencode 1.17.6. `opencode run` has `--variant`, but firstmate launches the interactive `opencode --prompt` path, which has no verified effort flag. | @@ -119,6 +122,7 @@ Natural language is acceptable if uncertain. - claude: `/`, for example `/no-mistakes`. - codex: `$`, for example `$no-mistakes`; `/` is claude-only and codex rejects it as "Unrecognized command". +- cursor: natural language; no dedicated skill invocation syntax was verified. - opencode: no separate verified skill invocation beyond normal slash-command behavior; use natural language if the exact skill command is uncertain. - pi: no separate verified skill invocation beyond normal command behavior; use natural language if the exact skill command is uncertain. - grok: `/`, for example `/no-mistakes` (same form as claude). Verified end to end: grok discovers the user-level `no-mistakes` skill, `/no-mistakes` invokes it, and grok drives a real `no-mistakes axi run`. Like codex's `$`/`/` popups, typing `/` opens grok's slash-autocomplete, so a too-fast Enter selects the popup entry instead of sending, and for an argument-taking command (like `/no-mistakes`'s optional task-first argument) that first Enter only expands the popup selection into an argument-hint placeholder rather than submitting - a genuine second Enter is required (see the grok section below for the 2026-07-03 incident and fix). `fm_tmux_submit_core`'s retried Enter (used by `fm-send` on the tmux backend) already handles this correctly by reading the cursor row; the herdr backend needed a dedicated fix (`fm_backend_herdr_composer_state`, docs/herdr-backend.md) because its prior delta-based verification false-positived on that same popup-close content change. @@ -184,6 +188,44 @@ The tracked hook anchors to `pwd -P`, verifies that root is firstmate-shaped and Codex's primary watcher protocol is `bin/fm-watch-checkpoint.sh --seconds "${FM_CODEX_WATCH_CHECKPOINT:-180}"`, not `bin/fm-watch-arm.sh`. The checkpoint is deliberately foreground and bounded so Codex regains control regularly to process user messages and queued wakes. +## cursor (CLI VERIFIED 2026-07-18, Cursor Agent 2026.07.16-899851b; first verified 2026-07-14 on 2026.07.09-a3815c0) + +Cursor Agent's local CLI command is `cursor-agent`. +The installer also symlinks a generic `~/.local/bin/agent` to the same launcher; firstmate always launches and matches the specific `cursor-agent` name, never `agent`. +Firstmate launches workers with `--force`, `--approve-mcps`, an explicit `--workspace`, and the positional brief. +For model and effort behavior, see the [launch-profile-axes table](#launch-profile-axes). + +| Fact | Value | +|---|---| +| Busy-pane signature | The composer footer shows an end-anchored `ctrl+c to stop` exactly while a turn is running (2026.07.16); older 2026.07.09 builds showed a braille spinner followed by `Running tokens`. The shared busy regex matches both. | +| Exit command | `/quit` (plus one Enter to accept its slash popup) exits cleanly at idle; two rapid `Ctrl+C` presses at idle also exit, but a delayed second press misses the confirmation window. | +| Interrupt | Single `Ctrl+C` mid-turn cancels the active turn (the tool call reports `Cancelled`) and returns to an idle composer; the interrupted prompt is not restored into the composer on 2026.07.16. | +| Trust dialog | First interactive launch in a new workspace shows `Workspace Trust Required`; press `a` (`Trust this workspace`) to proceed, and trust persists per path. Print mode needs `--trust` instead. | +| Autonomy | `--force` (`--yolo` is only its alias and is avoided because Firstmate's unrelated project setting already uses that word). | +| Resume | `cursor-agent --continue` resumes the most recent session for the workspace; `--resume [chatId]` is also available. | +| Skill invocation | Natural language; no dedicated syntax was verified. | +| Submit shape | The first Enter after typed text may leave the prompt in the composer; `fm_tmux_submit_core`'s cursor-row-verified retry sends the genuine second Enter, so `fm-send` steering works unchanged. | +| Non-TTY launch | Without a TTY, `cursor-agent` falls back to print mode and fails with `No prompt provided for print mode`; never pipe its interactive launch. | + +`cursor-agent` is a Node launcher whose process arguments retain the exact `cursor-agent` executable path. +On macOS a sole-column `ps -o comm=` reports that argv[0] path directly, and on the `node` `pane_current_command` path the tmux liveness probe requires the exact `cursor-agent` argv[0] basename; neither ever matches a generic process named `agent`. +`cursor-agent` exports `CURSOR_AGENT=1` and `CURSOR_INVOKED_AS=cursor-agent` to its tool subprocesses (verified 2026-07-18), but detection deliberately stays ancestry-based: inherited environment survives nested launches (a cursor tool shell inside a Claude session still carries `CLAUDECODE=1`), so an env shortcut would misattribute nested harnesses. + +Hook execution is server-gated: on 2026-07-18, Cursor Agent 2026.07.16-899851b executed no project, user, Claude-compat, or plugin hooks in print or interactive mode for an individual Pro account, while the 2026-07-14 evidence on 2026.07.09-a3815c0 recorded project `preToolUse` and `stop` hooks firing for a different account. +Every Cursor hook integration is therefore strictly fail-open: the wiring is tracked and correct per the installed bundle's hook contract, and supervision never depends on a hook firing. +`docs/turnend-guard.md` owns the dated evidence for both readings. + +Cursor workers receive a correctly structured task plugin through `--plugin-dir`, plus one additive user-level fallback stop hook that is token-scoped to Firstmate task worktrees and no-ops elsewhere. +It never overwrites a target project's Cursor hooks, and `bin/fm-cursor-lib.sh` owns its atomic install, the lock against concurrent spawns and teardowns, and the deterministic removal of the shared hook artifacts when the last Cursor task's token is gone. +When hooks do not execute, worker turn-end signals simply never arrive and the watcher's pane-based staleness path supervises the worker. + +The primary checkout's `.cursor/hooks.json` registers a `sessionStart` nudge, `preToolUse` seatbelts, and a passive `stop` hook. +The stop adapter returns one `followup_message` when the shared predicate blocks and uses Cursor's `loop_count` plus `loop_limit: 1` to prevent recursion. +Cursor's primary watcher protocol is a tracked background Shell task running `bin/fm-watch-arm.sh`; task completion is the wake, and it works with or without hook execution. + +Desktop Agents Window hook loading is documented by Cursor's shared project-hook contract but was not exercised here. +Before treating that entry point as fully verified, run `bin/fm-session-start.sh` from the desktop Agents Window and confirm the tool subprocess has a `cursor-agent` ancestor or another Cursor-specific marker. + ## opencode (VERIFIED 2026-06-11, v1.15.7-1.17.6) | Fact | Value | diff --git a/.cursor/hooks.json b/.cursor/hooks.json new file mode 100644 index 000000000..0907c5ae0 --- /dev/null +++ b/.cursor/hooks.json @@ -0,0 +1,34 @@ +{ + "version": 1, + "hooks": { + "sessionStart": [ + { + "command": "\"$CURSOR_PROJECT_DIR\"/bin/fm-sessionstart-nudge.sh", + "timeout": 10, + "failClosed": false + } + ], + "preToolUse": [ + { + "command": "\"$CURSOR_PROJECT_DIR\"/bin/fm-arm-pretool-check.sh", + "matcher": "Shell", + "timeout": 10, + "failClosed": true + }, + { + "command": "\"$CURSOR_PROJECT_DIR\"/bin/fm-cd-pretool-check.sh", + "matcher": "Shell", + "timeout": 10, + "failClosed": true + } + ], + "stop": [ + { + "command": "\"$CURSOR_PROJECT_DIR\"/bin/fm-turnend-guard-cursor.sh", + "timeout": 30, + "loop_limit": 1, + "failClosed": false + } + ] + } +} diff --git a/AGENTS.md b/AGENTS.md index cdf7b1682..ad46b0b9d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -85,6 +85,8 @@ state/ volatile runtime signals; gitignored .status appended by crewmates: ": " wake-event lines, not current-state truth .turn-ended touched by turn-end hooks .grok-turnend-token firstmate-owned grok hook registry token for the task; removed by teardown + .cursor-turnend-token firstmate-owned Cursor hook registry token for the task; teardown removes it and clears the shared user-level hook after the last Cursor task (bin/fm-cursor-lib.sh) + .cursor-plugin/ generated per-task Cursor plugin passed via --plugin-dir; removed by teardown .meta written by fm-spawn: window=, worktree=, project=, harness=, model=, effort=, kind=, mode=, yolo=, tasktmp=; kind=secondmate also records home= and projects=; a non-default runtime backend records further backend-specific fields (docs/configuration.md "Runtime backend"; bin/fm-backend.sh, section 8); fm-pr-check, including through fm-pr-merge, records one canonical pr= and GitHub's pr_head= when available; fm-x-link appends x_request=, x_request_ts=, x_followups=, and optional x_platform=/x_reply_max_chars= for an X-mode-originated task (section 14) .check.sh authenticated slow poll; the watcher dispatches validated PR data and the byte-identified X shim through trusted repository scripts, runs registered custom checks from hash-validated private snapshots, and rejects every other state check without execution .check-trust private content binding created by fm-check-register.sh for an intentional custom check @@ -150,7 +152,7 @@ A silent bootstrap section needs no action; for any printed actionable diagnosti ## 4. Harness and runtime dispatch Load `harness-adapters` before every spawn or recovery and before trust handling, skill invocation, interrupt, exit, resume, or adapter verification. -The verified harnesses are `claude`, `codex`, `opencode`, `pi`, and `grok`; never dispatch on an unverified adapter. +The verified harnesses are `claude`, `codex`, `cursor`, `opencode`, `pi`, and `grok`; never dispatch on an unverified adapter. If configured harness data names an unverified adapter, report it and fall back only to a verified adapter rather than launching it. `docs/configuration.md` owns dispatch-profile and runtime-backend schemas, `bin/fm-dispatch-select.sh` owns selector mechanics, `bin/fm-harness.sh` owns static resolution, and `bin/fm-spawn.sh` owns launch flags and fail-closed validation. diff --git a/README.md b/README.md index 407aa376d..83ad82fb6 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ Full detail on every feature lives in [docs/architecture.md](docs/architecture.m ### Requirements -- A verified agent harness: Claude Code, Grok, Pi, Codex, or OpenCode. +- A verified agent harness: Claude Code, Grok, Pi, Codex, Cursor, or OpenCode. - Git and the GitHub CLI, authenticated through `gh auth login`. - tmux, for the reference session backend. @@ -71,7 +71,7 @@ Claude Code and Grok use background-notify wake cycles; Pi uses its tracked prim All three have verified turn-end guard paths when launched with their documented setup. Pick whichever one matches your subscription and workflow. -Codex and OpenCode are also verified and supported as primary harnesses; Codex uses bounded foreground checkpoints, and OpenCode uses a TUI plugin, so both carry more harness-specific supervision tradeoffs than the three co-primaries. +Codex, Cursor, and OpenCode are also verified and supported as primary harnesses; Codex uses bounded foreground checkpoints, Cursor uses a tracked background Shell task, and OpenCode uses a TUI plugin, so all three carry more harness-specific supervision tradeoffs than the three co-primaries. ### Install and launch @@ -192,7 +192,7 @@ Firstmate's skills live in two separate places with different audiences: - [docs/cmux-backend.md](docs/cmux-backend.md) - setup guide for the experimental cmux backend, plus its verification notes and known gaps. - [docs/codex-app-backend.md](docs/codex-app-backend.md) - Codex App backend boundary, evidence, and rollout contract. - [docs/turnend-guard.md](docs/turnend-guard.md) - the primary session's structural "no turn ends blind" backstop: verified per-harness hook mechanisms, scoping, loop safety, and fail-open tradeoffs. -- [docs/supervision-protocols/](docs/supervision-protocols/) - rendered primary-harness watcher protocols for Claude, Codex, OpenCode, Pi, Grok, and unknown harness fallback. +- [docs/supervision-protocols/](docs/supervision-protocols/) - rendered primary-harness watcher protocols for Claude, Codex, Cursor, OpenCode, Pi, Grok, and unknown harness fallback. - [docs/scripts.md](docs/scripts.md) - the `bin/` toolbelt reference. - [`AGENTS.md`](AGENTS.md) - the distro's always-loaded operating contract and routing index for conditional procedures. - [CONTRIBUTING.md](CONTRIBUTING.md) - how to contribute, including the dev/test commands. diff --git a/bin/backends/tmux.sh b/bin/backends/tmux.sh index dee5813d6..be0a40640 100644 --- a/bin/backends/tmux.sh +++ b/bin/backends/tmux.sh @@ -136,15 +136,26 @@ fm_backend_tmux_current_command() { # tmux display-message -p -t "$1" '#{pane_current_command}' 2>/dev/null } +# Cursor's launcher retains cursor-agent as argv[0] while tmux reports its +# JavaScript runtime as pane_current_command=node. Resolve the pane shell's +# foreground process-group leader and require the Cursor-specific executable. +fm_backend_tmux_current_args() { # + local pid tpgid + pid=$(tmux display-message -p -t "$1" '#{pane_pid}' 2>/dev/null) || return 1 + case "$pid" in ''|*[!0-9]*) return 1 ;; esac + tpgid=$(ps -o tpgid= -p "$pid" 2>/dev/null | tr -d ' ') || return 1 + case "$tpgid" in ''|*[!0-9]*) return 1 ;; esac + ps -o args= -p "$tpgid" 2>/dev/null +} + # fm_backend_tmux_agent_alive: CONFIDENT liveness of a live harness-agent # PROCESS in 's pane, distinct from fm_backend_target_exists's # pane-PRESENCE-only check (a pane that still exists but is sitting at a bare # idle shell passes THAT check as "alive" - the secondmate-liveness gap # AGENTS.md's session-start guarantee closes). See docs/tmux-backend.md # "Agent liveness probe" for the empirical basis. Prints one of: -# alive - the foreground command is one of the verified harness binaries -# (claude, codex, opencode, grok - each confirmed to run as its -# own process name, never wrapped by a generic interpreter). +# alive - the foreground command is one of the verified harness binaries, +# or node with argv[0] specifically naming cursor-agent. # dead - the foreground command is a bare shell: nothing is running in # the pane, so a prior agent process has exited. # unknown - anything else, INCLUDING a bare "node"/"python" interpreter @@ -155,12 +166,20 @@ fm_backend_tmux_current_command() { # # signal (bin/fm-bootstrap.sh's secondmate-liveness sweep gates a # respawn on `dead` only). fm_backend_tmux_agent_alive() { # - local target=$1 comm + local target=$1 comm args first comm=$(fm_backend_tmux_current_command "$target") || { printf 'unknown'; return 0; } comm=${comm#-} case "$comm" in '') printf 'unknown' ;; *claude*|*codex*|*opencode*|*grok*) printf 'alive' ;; + node) + args=$(fm_backend_tmux_current_args "$target") || { printf 'unknown'; return 0; } + first=${args%%[[:space:]]*} + case "${first##*/}" in + cursor-agent) printf 'alive' ;; + *) printf 'unknown' ;; + esac + ;; zsh|bash|sh|dash|ash|ksh|mksh|tcsh|csh|fish) printf 'dead' ;; *) printf 'unknown' ;; esac diff --git a/bin/fm-arm-pretool-check.sh b/bin/fm-arm-pretool-check.sh index 6ac8941b9..a57e118a3 100755 --- a/bin/fm-arm-pretool-check.sh +++ b/bin/fm-arm-pretool-check.sh @@ -15,7 +15,7 @@ # bin/fm-arm-pretool-check.sh --command '' [--background true|false] # # Stdin mode extracts .toolInput.command for Grok or .tool_input.command for -# Claude and Codex. +# Claude, Codex, and Cursor. # CLI mode is used by OpenCode and Pi after their adapters extract the exact # command string. # --background remains accepted for compatibility, but harness-native tracked @@ -29,7 +29,8 @@ # missing Node or policy owner, or an invalid policy response. # # Claude requires stdout to remain empty on deny. -# Codex blocks on exit 2 and displays stderr. +# Codex blocks on exit 2 and displays stderr; Cursor blocks on exit 2 when its +# account rollout executes hooks (docs/arm-pretool-check.md). # Grok consumes the stdout decision object. # OpenCode and Pi consume exit 2 plus stderr. set -u diff --git a/bin/fm-bootstrap.sh b/bin/fm-bootstrap.sh index 753c5cec3..4565a037d 100755 --- a/bin/fm-bootstrap.sh +++ b/bin/fm-bootstrap.sh @@ -401,6 +401,13 @@ secondmate_liveness_sweep() { verdict=$(fm_backend_agent_alive "$backend" "$target" 2>/dev/null) || verdict="unknown" case "$harness" in claude|codex|opencode|pi|grok) ;; + cursor) + # Only tmux's argv0-based classifier is verified to read a live + # node-wrapped cursor-agent correctly (docs/tmux-backend.md); herdr's + # native agent registry is unverified for cursor-agent, so a non-tmux + # cursor "dead" verdict is downgraded rather than trusted for respawn. + if [ "$backend" != tmux ] && [ "$verdict" = dead ]; then verdict=unknown; fi + ;; *) [ "$verdict" = dead ] && verdict=unknown ;; esac case "$verdict" in @@ -655,7 +662,7 @@ crew_dispatch_validate() { return 0 fi err=$(jq -r ' - def verified($h): ["claude","codex","opencode","pi","grok"] | index($h); + def verified($h): ["claude","codex","cursor","opencode","pi","grok"] | index($h); def effort_ok($h; $e): if $e == null then true elif ($e | type) != "string" then false @@ -663,7 +670,7 @@ crew_dispatch_validate() { elif $h == "codex" then (["low","medium","high","xhigh"] | index($e)) elif $h == "grok" then (["low","medium","high"] | index($e)) elif $h == "pi" then (["low","medium","high","xhigh","max"] | index($e)) - elif $h == "opencode" then false + elif ($h == "cursor" or $h == "opencode") then false else true end; def use_profiles($u): diff --git a/bin/fm-brief.sh b/bin/fm-brief.sh index 56f3008d3..1f4002e1f 100755 --- a/bin/fm-brief.sh +++ b/bin/fm-brief.sh @@ -129,7 +129,7 @@ else PROJECT_CLONES_NOTE="The projects above are local clones for work you supervise; they are not an exclusive ownership claim." fi cat > "$BRIEF" <' # # Stdin mode extracts .toolInput.command for Grok or .tool_input.command for -# Claude and Codex. CLI mode is used by OpenCode and Pi after their adapters +# Claude, Codex, and Cursor. CLI mode is used by OpenCode and Pi after their adapters # extract the exact command string. # # Exit/output contract (identical shape to bin/fm-arm-pretool-check.sh): @@ -30,7 +30,8 @@ # missing Node or policy owner, or an invalid policy response. # # Claude requires stdout to remain empty on deny. -# Codex blocks on exit 2 and displays stderr. +# Codex blocks on exit 2 and displays stderr; Cursor blocks on exit 2 when its +# account rollout executes hooks (docs/cd-guard.md). # Grok consumes the stdout decision object. # OpenCode and Pi consume exit 2 plus stderr. set -u diff --git a/bin/fm-cursor-lib.sh b/bin/fm-cursor-lib.sh new file mode 100644 index 000000000..fe6e03c73 --- /dev/null +++ b/bin/fm-cursor-lib.sh @@ -0,0 +1,215 @@ +#!/usr/bin/env bash +# Shared owner of Firstmate's Cursor user-level turn-end fallback hook contract. +# +# ONE owner for the global-hook lifecycle that fm-spawn.sh (install) and +# fm-teardown.sh (per-task removal plus last-task cleanup) both rely on: +# - ~/.cursor/hooks/fm-turn-end.sh the shared fallback hook script +# - ~/.cursor/hooks/fm-turn-end.d/fm.* per-task token registry files +# - ~/.cursor/hooks.json one additive Firstmate stop entry +# +# Why a lock: concurrent cursor spawns rewrite the same shared hook script and +# merge the same hooks.json, and a concurrent teardown may decide "no tokens +# remain" and remove both. fm_cursor_hooks_lock serializes +# spawn: [create token -> install hook script -> merge hooks.json] +# teardown: [remove token -> if registry empty, remove script + entry] +# so cleanup can never observe an empty registry between a spawn's token +# creation and its hook install. The lock is a mkdir lockdir with bounded +# retries and mtime-based stale takeover. +# +# Failure posture: +# - install: the hook script write is atomic (mktemp+chmod+mv in the same +# directory), so a concurrently stopping Cursor session can never read a +# truncated hook. A malformed pre-existing hooks.json is never overwritten; +# install fails loudly instead. +# - cleanup: every uncertain path (lock timeout, malformed hooks.json, +# unreadable registry) SKIPS cleanup and leaves the fallback installed. +# The installed hook is a strict no-op without a registered token, so a +# skipped cleanup is safe and the next last-task teardown retries it. +# +# The hook itself stays fail-open for Cursor: it validates the workspace token +# pointer against the private registry and exits 0 with an empty hook response +# in every case. Cursor Agent 2026.07.16-899851b does not execute user hooks at +# all for accounts without the server-side hooks rollout (docs/turnend-guard.md), +# so this fallback is forward-compatible signal plumbing, not a load-bearing +# guarantee; the watcher's staleness path owns the degradation. + +# Portable mtime in epoch seconds; kept local so this leaf lib stays standalone. +fm_cursor_path_mtime() { + if [ "$(uname)" = Darwin ]; then + stat -f %m "$1" 2>/dev/null + else + stat -c %Y "$1" 2>/dev/null + fi +} + +fm_cursor_shell_quote() { + local s=${1//\'/\'\\\'\'} + printf "'%s'" "$s" +} + +# fm_cursor_hooks_lock : acquire the serialization lock for the +# shared hook artifacts under . Bounded wait (FM_CURSOR_HOOK_LOCK_TRIES +# x 0.1s, default ~5s); a lockdir older than FM_CURSOR_HOOK_LOCK_STALE_SECS +# (default 120) is taken over as abandoned. Returns 1 on timeout. +fm_cursor_hooks_lock() { + local hooks_dir=$1 lockdir tries=${FM_CURSOR_HOOK_LOCK_TRIES:-50} i=0 age now mtime + lockdir="$hooks_dir/.fm-turn-end.lock" + mkdir -p "$hooks_dir" 2>/dev/null || return 1 + while :; do + if mkdir "$lockdir" 2>/dev/null; then + return 0 + fi + mtime=$(fm_cursor_path_mtime "$lockdir") + now=$(date +%s) + if [ -n "$mtime" ] && [ -n "$now" ]; then + age=$((now - mtime)) + if [ "$age" -gt "${FM_CURSOR_HOOK_LOCK_STALE_SECS:-120}" ]; then + rmdir "$lockdir" 2>/dev/null || true + continue + fi + fi + i=$((i + 1)) + [ "$i" -lt "$tries" ] || return 1 + sleep 0.1 + done +} + +fm_cursor_hooks_unlock() { + rmdir "$1/.fm-turn-end.lock" 2>/dev/null || true +} + +# fm_cursor_write_turnend_hook : atomically write the +# shared fallback hook script. The script no-ops unless a workspace root +# carries a .fm-cursor-turnend pointer naming a live token in . +fm_cursor_write_turnend_hook() { + local hooks_dir=$1 auth_dir=$2 sq_auth_dir tmp + sq_auth_dir=$(fm_cursor_shell_quote "$auth_dir") + tmp=$(mktemp "$hooks_dir/fm-turn-end.sh.tmp.XXXXXXXXXXXX") || return 1 + cat > "$tmp" </dev/null || true) +if [ -n "\$payload" ] && command -v jq >/dev/null 2>&1; then + printf '%s' "\$payload" | jq -r '.workspace_roots[]? | select(type == "string")' 2>/dev/null | + while IFS= read -r workspace; do + p="\$workspace/.fm-cursor-turnend" + [ -f "\$p" ] && [ ! -L "\$p" ] || continue + first= + IFS= read -r -n 256 first < "\$p" 2>/dev/null || [ -n "\$first" ] || continue + case "\$first" in token=*) token=\${first#token=} ;; *) continue ;; esac + case "\$token" in fm.????????????) : ;; *) continue ;; esac + case "\$token" in *[!A-Za-z0-9._-]*) continue ;; esac + t=\$(cat "\$auth_dir/\$token" 2>/dev/null) || continue + case "\$t" in /*.turn-ended) : ;; *) continue ;; esac + touch "\$t" 2>/dev/null || true + done +fi +printf '%s\n' '{}' +exit 0 +EOF + chmod +x "$tmp" || { rm -f "$tmp"; return 1; } + mv -f "$tmp" "$hooks_dir/fm-turn-end.sh" || { rm -f "$tmp"; return 1; } +} + +# fm_cursor_hooks_json_mergeable : the existing file (when present) +# must be an object whose .hooks is an object and whose .hooks.stop is an array. +fm_cursor_hooks_json_mergeable() { + jq -e 'type == "object" and ((.hooks? // {}) | type == "object") and ((.hooks?.stop? // []) | type == "array")' "$1" >/dev/null 2>&1 +} + +# fm_cursor_merge_stop_entry : additively register +# the fallback stop hook in /hooks.json (idempotent, atomic write). +# Refuses a malformed existing file instead of overwriting it. +fm_cursor_merge_stop_entry() { + local cursor_home=$1 hook_command=$2 hooks_file tmp source + hooks_file="$cursor_home/hooks.json" + tmp=$(mktemp "$cursor_home/hooks.json.tmp.XXXXXXXXXXXX") || return 1 + if [ -f "$hooks_file" ]; then + if ! fm_cursor_hooks_json_mergeable "$hooks_file"; then + rm -f "$tmp" + echo "error: existing Cursor hooks file is not mergeable: $hooks_file" >&2 + return 1 + fi + source="$hooks_file" + else + printf '%s\n' '{}' > "$tmp.source" + source="$tmp.source" + fi + if ! jq --arg command "$hook_command" ' + .version = (.version // 1) + | .hooks = (.hooks // {}) + | .hooks.stop = ((.hooks.stop // []) as $hooks + | if any($hooks[]?; .command? == $command) then $hooks + else $hooks + [{"command": $command, "timeout": 5, "failClosed": false}] + end) + ' "$source" > "$tmp"; then + rm -f "$tmp" "$tmp.source" + echo "error: failed to merge Firstmate's Cursor stop hook into $hooks_file" >&2 + return 1 + fi + mv -f "$tmp" "$hooks_file" || { rm -f "$tmp" "$tmp.source"; return 1; } + rm -f "$tmp.source" +} + +# fm_cursor_registry_empty : true when no fm.* token files remain. +fm_cursor_registry_empty() { + local auth_dir=$1 f + [ -d "$auth_dir" ] || return 0 + for f in "$auth_dir"/fm.*; do + [ -e "$f" ] || [ -L "$f" ] || continue + return 1 + done + return 0 +} + +# fm_cursor_remove_stop_entry : remove only +# Firstmate's own stop entry from hooks.json, preserving every other entry and +# leaving a malformed or missing file untouched (atomic write). +fm_cursor_remove_stop_entry() { + local cursor_home=$1 hook_command=$2 hooks_file tmp + hooks_file="$cursor_home/hooks.json" + [ -f "$hooks_file" ] || return 0 + fm_cursor_hooks_json_mergeable "$hooks_file" || return 0 + tmp=$(mktemp "$cursor_home/hooks.json.tmp.XXXXXXXXXXXX") || return 1 + if ! jq --arg command "$hook_command" ' + if (.hooks?.stop? // null) == null then . + else + .hooks.stop = [.hooks.stop[] | select(.command? != $command)] + | if .hooks.stop == [] then .hooks |= del(.stop) else . end + end + ' "$hooks_file" > "$tmp"; then + rm -f "$tmp" + return 1 + fi + mv -f "$tmp" "$hooks_file" || { rm -f "$tmp"; return 1; } +} + +# fm_cursor_remove_turnend_auth : teardown entry point. Removes +# the task's token from the registry, then - when that was the last token - +# deterministically removes the shared hook script, Firstmate's hooks.json stop +# entry, and the empty registry directory. Uncertainty always skips cleanup. +fm_cursor_remove_turnend_auth() { + local state_dir=$1 id=$2 token cursor_home hooks_dir auth_dir hook_command + token=$(cat "$state_dir/$id.cursor-turnend-token" 2>/dev/null || true) + case "$token" in ''|*[!A-Za-z0-9._-]*) return 0 ;; esac + cursor_home="$HOME/.cursor" + hooks_dir="$cursor_home/hooks" + auth_dir="$hooks_dir/fm-turn-end.d" + if ! fm_cursor_hooks_lock "$hooks_dir"; then + # Another spawn or teardown owns the artifacts right now; remove only our + # token and leave the shared hook installed (a strict no-op without tokens). + rm -f "$auth_dir/$token" + return 0 + fi + rm -f "$auth_dir/$token" + if fm_cursor_registry_empty "$auth_dir"; then + # The registered hooks.json command is the shell-quoted script path + # (exactly what fm-spawn merged), so match that same form here. + hook_command=$(fm_cursor_shell_quote "$hooks_dir/fm-turn-end.sh") + fm_cursor_remove_stop_entry "$cursor_home" "$hook_command" || true + rm -f "$hooks_dir/fm-turn-end.sh" + rmdir "$auth_dir" 2>/dev/null || true + fi + fm_cursor_hooks_unlock "$hooks_dir" +} diff --git a/bin/fm-harness.sh b/bin/fm-harness.sh index 3267e9220..4c88f86f3 100755 --- a/bin/fm-harness.sh +++ b/bin/fm-harness.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Detect the agent harness this process tree runs on. -# Usage: fm-harness.sh print own harness: claude|codex|opencode|pi|grok|unknown +# Usage: fm-harness.sh print own harness: claude|codex|cursor|opencode|pi|grok|unknown # fm-harness.sh crew print the effective CREWMATE harness # (config/crew-harness; "default" resolves to own) # fm-harness.sh secondmate print the harness the PRIMARY uses to launch @@ -40,6 +40,7 @@ detect_own() { for _ in 1 2 3 4 5 6 7 8; do comm=$(ps -o comm= -p "$pid" 2>/dev/null) || break case "$(basename "$comm")" in + *cursor-agent*) echo cursor; return ;; *claude*) echo claude; return ;; *codex*) echo codex; return ;; *opencode*) echo opencode; return ;; @@ -49,6 +50,7 @@ detect_own() { # Bare interpreter: match the harness name in its script path. args=$(ps -o args= -p "$pid" 2>/dev/null) case "$args" in + *cursor-agent*) echo cursor; return ;; *claude*) echo claude; return ;; *codex*) echo codex; return ;; *opencode*) echo opencode; return ;; diff --git a/bin/fm-lock.sh b/bin/fm-lock.sh index 33e4b0d27..b6a2cbe7a 100755 --- a/bin/fm-lock.sh +++ b/bin/fm-lock.sh @@ -15,7 +15,7 @@ LOCK="$STATE/.lock" mkdir -p "$STATE" # Known harness command names; extend when a new adapter is verified. -HARNESS_RE='claude|codex|opencode|grok|^pi$' +HARNESS_RE='cursor-agent|claude|codex|opencode|grok|^pi$' harness_pid() { local pid=$$ comm args diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index fc177354b..7a975b842 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -33,7 +33,7 @@ # profile consultation. A --secondmate spawn is exempt and resolves the SECONDMATE # harness (config/secondmate-harness -> config/crew-harness -> own), so the # secondmate-vs-crewmate split is DURABLE across every respawn (recovery, -# /updatefirstmate, restart). A bare adapter name (claude|codex|opencode|pi|grok) +# /updatefirstmate, restart). A bare adapter name (claude|codex|cursor|opencode|pi|grok) # overrides it for this spawn (either kind). A non-flag string containing # whitespace is treated as a RAW launch command - the escape hatch for verifying # new adapters. @@ -55,6 +55,11 @@ # provisioned firstmate home; the default is kind=ship. # Before a secondmate launch, the home is locally fast-forwarded to the primary # default-branch commit when safe; skipped syncs warn and launch unchanged. +# Cursor workers use --force, --approve-mcps, --workspace, and a task plugin +# under state/. The plugin is paired with one additive user-level stop-hook +# fallback (bin/fm-cursor-lib.sh owns the shared-artifact contract) because +# cursor-agent does not execute hooks for accounts without Cursor's +# server-side hooks rollout (verified 2026-07-18 on 2026.07.16-899851b). # Ship/scout spawns refuse to launch unless the resolved task path is a real # git worktree root distinct from the primary project checkout. # Batch dispatch: pass one or more `id=repo` pairs instead of a single , e.g. @@ -73,6 +78,8 @@ # written by this script; outside the worktree to avoid pi's trust gate) # __PITURNEND__ absolute path to .pi/extensions/fm-primary-turnend-guard.ts in a pi secondmate home # __PIWATCH__ absolute path to .pi/extensions/fm-primary-pi-watch.ts in a pi secondmate home +# __CURSORPLUGIN__ absolute path to the task's Cursor plugin root +# __WORKSPACE__ absolute path to the task worktree or secondmate home # Per-harness turn-end hooks are installed automatically; some live outside the worktree. # grok uses a firstmate-owned global hook under ${GROK_HOME:-$HOME/.grok}/hooks # plus a gitignored .fm-grok-turnend worktree pointer and a state token. @@ -108,6 +115,8 @@ SUB_HOME_MARKER=".fm-secondmate-home" . "$SCRIPT_DIR/fm-gate-refuse-lib.sh" # shellcheck source=bin/fm-pr-lib.sh . "$SCRIPT_DIR/fm-pr-lib.sh" +# shellcheck source=bin/fm-cursor-lib.sh +. "$SCRIPT_DIR/fm-cursor-lib.sh" # Fail closed before any fleet mutation: a no-mistakes gate agent must never spawn # a direct report (see bin/fm-gate-refuse-lib.sh). fm_refuse_if_gate_agent @@ -287,7 +296,7 @@ FIRSTMATE_HOME= if [ "$KIND" = secondmate ]; then case "${POS[1]:-}" in - ''|claude|codex|opencode|pi|grok) + ''|claude|codex|cursor|opencode|pi|grok) ARG3=${POS[1]:-} ;; *' '*) @@ -332,6 +341,15 @@ launch_template() { printf '%s' 'codex __MODELFLAG____EFFORTFLAG__--dangerously-bypass-approvals-and-sandbox -c "notify=[\"bash\",\"-c\",\"touch __TURNEND__\"]" "$(cat __BRIEF__)"' fi ;; + cursor) + # --approve-mcps keeps a project-configured MCP server's trust prompt from + # wedging an unattended worker; --force already grants command execution. + if [ "$kind" = secondmate ]; then + printf '%s' 'cursor-agent --force --approve-mcps --workspace __WORKSPACE__ __MODELFLAG__"$(cat __BRIEF__)"' + else + printf '%s' 'cursor-agent --force --approve-mcps --plugin-dir __CURSORPLUGIN__ --workspace __WORKSPACE__ __MODELFLAG__"$(cat __BRIEF__)"' + fi + ;; opencode) printf '%s' 'OPENCODE_CONFIG_CONTENT='\''{"permission":{"*":"allow"}}'\'' opencode __MODELFLAG__--prompt "$(cat __BRIEF__)"' ;; pi) if [ "$kind" = secondmate ]; then @@ -435,7 +453,7 @@ model_flag_for_harness() { local harness=$1 model=$2 [ -n "$model" ] && [ "$model" != default ] || return 0 case "$harness" in - claude|codex|opencode|pi|grok) + claude|codex|cursor|opencode|pi|grok) printf -- '--model %s ' "$(shell_quote "$model")" ;; esac @@ -474,6 +492,9 @@ effort_flag_for_harness() { low|medium|high|xhigh|max) printf -- '--thinking %s ' "$(shell_quote "$effort")" ;; esac ;; + # Cursor accepts --model but has no standalone effort flag. Parameterized + # model values can carry an effort setting when the caller explicitly puts + # it in the model string; the separate firstmate effort axis is omitted. # opencode's interactive `opencode --prompt` launch has a verified --model # flag but no verified effort flag. Its `opencode run --variant` flag belongs # to a different, non-interactive launch mode, so fm-spawn does not pass it. @@ -918,6 +939,50 @@ EOF codex*) # codex: turn-end rides the launch command via -c notify=[...] and __TURNEND__. ;; + cursor*) + # cursor-agent accepts a correctly structured --plugin-dir but does not + # execute hooks at all for accounts without Cursor's server-side hooks + # rollout (verified 2026-07-18 on 2026.07.16-899851b; docs/turnend-guard.md). + # Keep the task plugin wired for forward compatibility, and install one + # additive user-level fallback hook. The fallback is a no-op unless a + # workspace root carries a valid Firstmate token pointer. + # bin/fm-cursor-lib.sh owns the shared-artifact contract: the hook script + # write is atomic, the hooks.json merge is additive and idempotent, and + # the lock serializes this install against a concurrent teardown's + # last-token cleanup. On lock timeout the install proceeds anyway - it + # converges to the same installed state and must never block a spawn. + CURSOR_HOME="$HOME/.cursor" + CURSOR_HOOKS_DIR="$CURSOR_HOME/hooks" + CURSOR_AUTH_DIR="$CURSOR_HOOKS_DIR/fm-turn-end.d" + CURSOR_PLUGIN_DIR="$STATE/$ID.cursor-plugin" + mkdir -p "$CURSOR_AUTH_DIR" "$CURSOR_PLUGIN_DIR/.cursor-plugin" "$CURSOR_PLUGIN_DIR/hooks" + cursor_locked=0 + if fm_cursor_hooks_lock "$CURSOR_HOOKS_DIR"; then + cursor_locked=1 + fi + old_umask=$(umask) + umask 077 + auth_file=$(mktemp "$CURSOR_AUTH_DIR/fm.XXXXXXXXXXXX") + umask "$old_umask" + printf '%s\n' "$TURNEND" > "$auth_file" + printf '%s\n' "${auth_file##*/}" > "$STATE/$ID.cursor-turnend-token" + if ! fm_cursor_write_turnend_hook "$CURSOR_HOOKS_DIR" "$CURSOR_AUTH_DIR"; then + [ "$cursor_locked" -eq 0 ] || fm_cursor_hooks_unlock "$CURSOR_HOOKS_DIR" + echo "error: failed to install Firstmate's Cursor turn-end hook in $CURSOR_HOOKS_DIR" >&2 + exit 1 + fi + cursor_hook_command=$(shell_quote "$CURSOR_HOOKS_DIR/fm-turn-end.sh") + if ! fm_cursor_merge_stop_entry "$CURSOR_HOME" "$cursor_hook_command"; then + [ "$cursor_locked" -eq 0 ] || fm_cursor_hooks_unlock "$CURSOR_HOOKS_DIR" + exit 1 + fi + [ "$cursor_locked" -eq 0 ] || fm_cursor_hooks_unlock "$CURSOR_HOOKS_DIR" + printf 'token=%s\n' "${auth_file##*/}" > "$WT/.fm-cursor-turnend" + exclude_path '.fm-cursor-turnend' + cursor_plugin_hook=$(json_escape "$cursor_hook_command") + printf '{"name":"firstmate-task-%s","version":"1.0.0","hooks":"hooks/hooks.json"}\n' "$ID" > "$CURSOR_PLUGIN_DIR/.cursor-plugin/plugin.json" + printf '{"hooks":{"stop":[{"command":"%s","timeout":5,"failClosed":false}]}}\n' "$cursor_plugin_hook" > "$CURSOR_PLUGIN_DIR/hooks/hooks.json" + ;; grok*) # grok fires a Stop hook at every turn boundary (verified, grok 0.2.73), the # clean equivalent of codex's notify= and pi's turn_end. But grok only loads @@ -1034,6 +1099,8 @@ sq_turnend=$(shell_quote "$TURNEND") sq_piext=$(shell_quote "$STATE/$ID.pi-ext.ts") sq_piturnend=$(shell_quote "$PROJ_ABS/.pi/extensions/fm-primary-turnend-guard.ts") sq_piwatch=$(shell_quote "$PROJ_ABS/.pi/extensions/fm-primary-pi-watch.ts") +sq_cursorplugin=$(shell_quote "$STATE/$ID.cursor-plugin") +sq_workspace=$(shell_quote "$WT") MODELFLAG=$(model_flag_for_harness "$HARNESS" "$MODEL") EFFORTFLAG=$(effort_flag_for_harness "$HARNESS" "$EFFORT") LAUNCH=${LAUNCH//__MODELFLAG__/$MODELFLAG} @@ -1043,6 +1110,8 @@ LAUNCH=${LAUNCH//__TURNEND__/$sq_turnend} LAUNCH=${LAUNCH//__PIEXT__/$sq_piext} LAUNCH=${LAUNCH//__PITURNEND__/$sq_piturnend} LAUNCH=${LAUNCH//__PIWATCH__/$sq_piwatch} +LAUNCH=${LAUNCH//__CURSORPLUGIN__/$sq_cursorplugin} +LAUNCH=${LAUNCH//__WORKSPACE__/$sq_workspace} if [ "$KIND" = secondmate ]; then sq_home=$(shell_quote "$PROJ_ABS") LAUNCH="FM_ROOT_OVERRIDE= FM_STATE_OVERRIDE= FM_DATA_OVERRIDE= FM_PROJECTS_OVERRIDE= FM_CONFIG_OVERRIDE= FM_HOME=$sq_home $LAUNCH" diff --git a/bin/fm-supervision-instructions.sh b/bin/fm-supervision-instructions.sh index 3da0ac19d..b8653773a 100755 --- a/bin/fm-supervision-instructions.sh +++ b/bin/fm-supervision-instructions.sh @@ -81,7 +81,7 @@ if [ -z "$HARNESS" ]; then fi case "$HARNESS" in - claude|codex|opencode|pi|grok) SNIPPET="$DOC_DIR/$HARNESS.md" ;; + claude|codex|cursor|opencode|pi|grok) SNIPPET="$DOC_DIR/$HARNESS.md" ;; *) HARNESS=unknown; SNIPPET="$DOC_DIR/unknown.md" ;; esac [ -f "$SNIPPET" ] || SNIPPET="$DOC_DIR/unknown.md" @@ -139,6 +139,9 @@ repair_line() { codex) printf '%s%s%s%s\n' "$prefix" 'resume supervision with a foreground checkpoint: bin/fm-watch-checkpoint.sh --seconds ' "$checkpoint_seconds" '.' ;; + cursor) + printf '%s%s\n' "$prefix" 'resume supervision with bin/fm-watch-arm.sh as its own Cursor tracked background Shell task, never shell &.' + ;; pi) printf '%s%s%s%s%s%s\n' "$prefix" 'resume supervision with the Pi tool fm_watch_arm_pi or restart Pi with -e ' "$pi_turnend_ext" ' -e ' "$pi_ext" ' if the extension is not loaded.' ;; diff --git a/bin/fm-teardown.sh b/bin/fm-teardown.sh index 9d84f4d6c..afe40c445 100755 --- a/bin/fm-teardown.sh +++ b/bin/fm-teardown.sh @@ -98,6 +98,8 @@ SUB_HOME_MARKER=".fm-secondmate-home" . "$SCRIPT_DIR/fm-gate-refuse-lib.sh" # shellcheck source=bin/fm-pr-lib.sh . "$SCRIPT_DIR/fm-pr-lib.sh" +# shellcheck source=bin/fm-cursor-lib.sh +. "$SCRIPT_DIR/fm-cursor-lib.sh" if [ "$#" -lt 1 ] || ! fm_task_id_path_safe "$1"; then echo "error: invalid teardown request" >&2 exit 2 @@ -258,6 +260,10 @@ remove_pr_poll_artifacts() { fi } +# Cursor turn-end auth removal (per-task token plus deterministic last-task +# cleanup of the shared user-level hook artifacts) is owned by +# fm_cursor_remove_turnend_auth in bin/fm-cursor-lib.sh. + # Resolve the PR number for a worktree branch via gh-axi. Echoes the number on a # single match and returns 0; returns non-zero on no match or any lookup failure, # so the caller treats it as "no PR found" (fail-safe). @@ -653,7 +659,7 @@ validate_worktree_teardown_safety() { echo "Restore the git index state, or get the captain's explicit OK to discard, then --force." >&2 return 1 fi - dirty=$(printf '%s\n' "$dirty_raw" | grep -vE '^\?\? (\.claude/|\.fm-grok-turnend$)' | head -1 || true) + dirty=$(printf '%s\n' "$dirty_raw" | grep -vE '^\?\? (\.claude/|\.fm-cursor-turnend$|\.fm-grok-turnend$)' | head -1 || true) if ! unpushed_raw=$(git -C "$WT" log --oneline HEAD --not --remotes -- 2>/dev/null); then if worktree_safety_blocked_by_lock "commits not on a remote"; then @@ -983,12 +989,12 @@ cleanup_firstmate_home_children() { elif [ "$child_backend" = orca ]; then if [ -n "$child_wt" ] && [ -d "$child_wt" ]; then validate_child_worktree_for_removal "$child_wt" "$child_proj" >/dev/null || return 1 - rm -f "$child_wt/.claude/settings.local.json" "$child_wt/.opencode/plugins/fm-turn-end.js" "$child_wt/.fm-grok-turnend" + rm -f "$child_wt/.claude/settings.local.json" "$child_wt/.opencode/plugins/fm-turn-end.js" "$child_wt/.fm-cursor-turnend" "$child_wt/.fm-grok-turnend" fi fm_backend_remove_worktree "$child_backend" "$child_orca_worktree_id" || return 1 elif [ -n "$child_wt" ] && [ -d "$child_wt" ]; then validate_child_worktree_for_removal "$child_wt" "$child_proj" >/dev/null || return 1 - rm -f "$child_wt/.claude/settings.local.json" "$child_wt/.opencode/plugins/fm-turn-end.js" "$child_wt/.fm-grok-turnend" + rm -f "$child_wt/.claude/settings.local.json" "$child_wt/.opencode/plugins/fm-turn-end.js" "$child_wt/.fm-cursor-turnend" "$child_wt/.fm-grok-turnend" if [ -n "$child_proj" ] && [ -d "$child_proj" ] && command -v treehouse >/dev/null 2>&1; then if teardown_treehouse_return "$child_wt" "$child_proj" "child worktree"; then : @@ -1005,7 +1011,9 @@ cleanup_firstmate_home_children() { fi remove_grok_turnend_auth "$sub_state" "$child_id" remove_pr_poll_artifacts "$sub_state" "$child_id" || return 1 - rm -f "$sub_state/$child_id.status" "$sub_state/$child_id.turn-ended" "$sub_state/$child_id.meta" "$sub_state/$child_id.pi-ext.ts" "$sub_state/$child_id.grok-turnend-token" + fm_cursor_remove_turnend_auth "$sub_state" "$child_id" + rm -rf "$sub_state/$child_id.cursor-plugin" + rm -f "$sub_state/$child_id.status" "$sub_state/$child_id.turn-ended" "$sub_state/$child_id.meta" "$sub_state/$child_id.pi-ext.ts" "$sub_state/$child_id.grok-turnend-token" "$sub_state/$child_id.cursor-turnend-token" done } @@ -1095,7 +1103,7 @@ if [ "$BACKEND" = orca ] && [ "$KIND" != secondmate ]; then git -C "$WT" branch -D "$branch" >/dev/null 2>&1 || true fi fi - rm -f "$WT/.claude/settings.local.json" "$WT/.opencode/plugins/fm-turn-end.js" "$WT/.fm-grok-turnend" + rm -f "$WT/.claude/settings.local.json" "$WT/.opencode/plugins/fm-turn-end.js" "$WT/.fm-cursor-turnend" "$WT/.fm-grok-turnend" fi [ -z "$T_ORCA" ] || fm_backend_kill "$BACKEND" "$T" "$(meta_value "$META" zellij_tab_id)" "fm-$ID" 2>/dev/null || true fm_backend_remove_worktree "$BACKEND" "$ORCA_WORKTREE_ID" @@ -1107,7 +1115,7 @@ elif [ -d "$WT" ] && [ "$KIND" != secondmate ]; then fi fi # Remove our hook file so a reused pool worktree cannot fire signals for a dead task. - rm -f "$WT/.claude/settings.local.json" "$WT/.opencode/plugins/fm-turn-end.js" "$WT/.fm-grok-turnend" + rm -f "$WT/.claude/settings.local.json" "$WT/.opencode/plugins/fm-turn-end.js" "$WT/.fm-cursor-turnend" "$WT/.fm-grok-turnend" # Kills remaining processes in the worktree (including the agent), resets, returns # to pool. treehouse resolves the pool from the working directory, so run it from # the project. teardown_treehouse_return tolerates transient and stale git locks @@ -1131,12 +1139,14 @@ if [ "$KIND" = secondmate ]; then remove_secondmate_registry_entry "$ID" fi remove_grok_turnend_auth "$STATE" "$ID" +fm_cursor_remove_turnend_auth "$STATE" "$ID" +rm -rf "$STATE/$ID.cursor-plugin" fm_backend_clear_transition "$BACKEND" "$STATE" "$T" || true # Remove the per-task temp root (/tmp/fm-/, incl. its gotmp/) recorded by spawn. # Read before the state-file rm below; empty (pre-fix tasks without tasktmp=) is a no-op. [ -n "$TASK_TMP" ] && rm -rf "$TASK_TMP" remove_pr_poll_artifacts "$STATE" "$ID" || exit 1 -rm -f "$STATE/$ID.status" "$STATE/$ID.turn-ended" "$STATE/$ID.meta" "$STATE/$ID.pi-ext.ts" "$STATE/$ID.grok-turnend-token" +rm -f "$STATE/$ID.status" "$STATE/$ID.turn-ended" "$STATE/$ID.meta" "$STATE/$ID.pi-ext.ts" "$STATE/$ID.grok-turnend-token" "$STATE/$ID.cursor-turnend-token" if [ "$KIND" != scout ] && [ "$KIND" != secondmate ] && [ "$MODE" != local-only ]; then "$FM_ROOT/bin/fm-fleet-sync.sh" "$PROJ" || true fi diff --git a/bin/fm-tmux-lib.sh b/bin/fm-tmux-lib.sh index 1dfabd89d..e59b6a1ee 100755 --- a/bin/fm-tmux-lib.sh +++ b/bin/fm-tmux-lib.sh @@ -47,9 +47,11 @@ . "$(dirname -- "${BASH_SOURCE[0]}")/fm-composer-lib.sh" # Busy footers per harness (mirror fm-watch.sh). claude/codex: "esc to -# interrupt"; opencode: "esc interrupt"; pi: "Working..."; grok: "Ctrl+c:cancel" -# (grok's mid-turn cancel hint, shown iff a turn is running - verified grok 0.2.73). -FM_TMUX_BUSY_REGEX_DEFAULT='esc (to )?interrupt|Working\.\.\.|Ctrl\+c:cancel' +# interrupt"; opencode: "esc interrupt"; pi: "Working..."; grok: "Ctrl+c:cancel"; +# Cursor: an end-anchored "ctrl+c to stop" composer footer (verified 2026-07-18 +# on Cursor Agent 2026.07.16-899851b; the end anchor keeps mid-transcript prose +# from matching), plus the older 2026.07.09 "Running tokens" status line. +FM_TMUX_BUSY_REGEX_DEFAULT='esc (to )?interrupt|Working\.\.\.|Ctrl\+c:cancel|ctrl\+c to stop[[:space:]]*$|^[[:space:]]*[^[:space:]]+[[:space:]]+Running[[:space:]]+[0-9]+[[:space:]]+tokens([[:space:]]|$)' # fm_tmux_strip_ghost: thin adapter over the shared, fleet-wide ghost extractor # fm_composer_strip_ghost (bin/fm-composer-lib.sh). It drops de-emphasised diff --git a/bin/fm-turnend-guard-cursor.sh b/bin/fm-turnend-guard-cursor.sh new file mode 100755 index 000000000..5f9500f2e --- /dev/null +++ b/bin/fm-turnend-guard-cursor.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# Adapt Cursor's stop-hook loop_count/followup_message contract to the shared +# firstmate turn-end predicate. Fail open at the hook boundary. +set -u + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +payload=$(cat 2>/dev/null || true) +[ -n "$payload" ] || { printf '%s\n' '{}'; exit 0; } +command -v jq >/dev/null 2>&1 || { printf '%s\n' '{}'; exit 0; } + +loop_count=$(printf '%s' "$payload" | jq -r '.loop_count // 0' 2>/dev/null) \ + || { printf '%s\n' '{}'; exit 0; } +case "$loop_count" in + ''|*[!0-9]*) printf '%s\n' '{}'; exit 0 ;; +esac +[ "$loop_count" -eq 0 ] || { printf '%s\n' '{}'; exit 0; } + +translated=$(printf '%s' "$payload" | jq '. + {stop_hook_active: false}' 2>/dev/null) \ + || { printf '%s\n' '{}'; exit 0; } +err=$(mktemp "${TMPDIR:-/tmp}/fm-turnend-cursor.XXXXXX") \ + || { printf '%s\n' '{}'; exit 0; } +if printf '%s' "$translated" | "$SCRIPT_DIR/fm-turnend-guard.sh" 2>"$err"; then + rm -f "$err" + printf '%s\n' '{}' + exit 0 +else + status=$? +fi +if [ "$status" -eq 2 ]; then + reason=$(cat "$err" 2>/dev/null || true) + rm -f "$err" + [ -n "$reason" ] || reason='TURN WOULD END BLIND: resume the session-start supervision protocol.' + jq -cn --arg message "$reason" '{followup_message: $message}' + exit 0 +fi +rm -f "$err" +printf '%s\n' '{}' +exit 0 diff --git a/bin/fm-turnend-guard.sh b/bin/fm-turnend-guard.sh index c279cff58..44f3271cd 100755 --- a/bin/fm-turnend-guard.sh +++ b/bin/fm-turnend-guard.sh @@ -11,8 +11,8 @@ # This script is push-based: verified harness turn-end hooks invoke it every time # the primary is about to end a turn. # Claude and codex can block directly by preserving exit status 2 and stderr. -# OpenCode, pi, and grok adapters use the same predicate and force one bounded -# follow-up because their turn-end events are passive. +# OpenCode, pi, grok, and cursor adapters use the same predicate and force one +# bounded follow-up because their turn-end events are passive. # See docs/turnend-guard.md for the per-harness mechanics, validation evidence, # and fail-open tradeoffs. # diff --git a/bin/fm-watch.sh b/bin/fm-watch.sh index 4ea6da70a..9cf14f98d 100755 --- a/bin/fm-watch.sh +++ b/bin/fm-watch.sh @@ -114,8 +114,10 @@ SIGNAL_GRACE=${FM_SIGNAL_GRACE:-30} # seconds to linger after a signal so trai # claude/codex: "esc to interrupt"; opencode: "esc interrupt"; pi: "Working..."; # grok: "Ctrl+c:cancel" (the mid-turn cancel hint in grok's keybind bar, shown iff a # turn is running; absent when idle - verified grok 0.2.73, ASCII to avoid the -# locale fragility of matching grok's braille spinner glyph directly). -BUSY_REGEX=${FM_BUSY_REGEX:-'esc (to )?interrupt|Working\.\.\.|Ctrl\+c:cancel'} +# locale fragility of matching grok's braille spinner glyph directly); Cursor: +# an end-anchored "ctrl+c to stop" composer footer (verified 2026-07-18 on +# 2026.07.16-899851b), plus the older 2026.07.09 "Running tokens" line. +BUSY_REGEX=${FM_BUSY_REGEX:-'esc (to )?interrupt|Working\.\.\.|Ctrl\+c:cancel|ctrl\+c to stop[[:space:]]*$|^[[:space:]]*[^[:space:]]+[[:space:]]+Running[[:space:]]+[0-9]+[[:space:]]+tokens([[:space:]]|$)'} # Always-on wake triage: most wakes during a long crew validation are benign (a # working: note or turn-end while a pipeline runs, a no-change heartbeat). Rather # than wake firstmate's LLM for each, this watcher classifies every wake in bash diff --git a/docs/architecture.md b/docs/architecture.md index 7bb809fe6..9a61e13ba 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -46,6 +46,7 @@ The default path remains local-only; live GitHub enrichment exists only behind t Optional X mode integrates with the watcher only after explicit opt-in; [configuration.md](configuration.md#x-mode-env) owns its generated-artifact and dispatch mechanics. At session start, `bin/fm-session-start.sh` emits exactly one primary-harness supervision block rendered by `bin/fm-supervision-instructions.sh` from `docs/supervision-protocols/`. +<<<<<<< HEAD That block owns the live wait shape for the running primary harness: Claude and Grok use background-notify cycles, Codex uses bounded foreground checkpoints, Pi uses its two tracked primary extensions, and OpenCode uses its TUI plugin. `bin/fm-watch-arm.sh` remains the verified arm wrapper for protocols that call it; it forks the watcher as a tracked child, verifies it is genuinely alive with a fresh liveness beacon, and prints an honest `started`, `attached`, or nonzero `FAILED` status. On `attached` it stays live across identity-matched successors, and an unexplained clean child close either attaches to a verified healthy successor or becomes the typed nonzero `watcher: FAILED - cycle ended without an actionable reason` result. @@ -53,6 +54,11 @@ The arm layer records one bounded lifecycle row per observed cycle in `state/.wa Pi and OpenCode verify session-lock ownership and launch one singleton successor from their child-close handlers before delivering an actionable wake prompt, with bounded exponential retry for failed restoration. Claude keeps its tracked background-task protocol and adds a narrow PreToolUse continuity gate that allows drain and arm recovery while refusing only other fleet commands when tasks are in flight and no identity-matched live watcher holds the home lock. The existing turn-end guard is unchanged and remains the final backstop for all five harness protocols. +======= +That block owns the live wait shape for the running primary harness: Claude and Grok use background-notify cycles, Codex uses bounded foreground checkpoints, Cursor uses a tracked background Shell task, Pi uses its two tracked primary extensions, and OpenCode uses its TUI plugin. +`bin/fm-watch-arm.sh` remains the verified arm wrapper for protocols that call it; it forks the watcher as a tracked child, verifies it is genuinely alive with a fresh liveness beacon, and prints exactly one honest status line (`started` / `attached` / restart-only `healthy` / `FAILED`, the last exiting non-zero). +On `attached` it stays live until that existing cycle ends so background-notify harnesses do not get an empty false wake from a healthy no-op exit. +>>>>>>> 012bda5 (no-mistakes(document): docs: add Cursor to remaining harness lists and inventories) Its `--restart` mode signals only the watcher recorded in the current home's `state/.watch.lock`, so restarting one home cannot kill sibling secondmate watchers. A pull-based guard (`bin/fm-guard.sh`) warns through supervision tool output if the primary checkout is tangled, or if tasks are in flight and that watcher stops running or queued wakes are waiting to be drained. The drain script calls that guard after emptying the queue, which avoids repeating the queued-wakes warning for records it just consumed while still warning on stale watcher liveness. @@ -131,7 +137,7 @@ The session-start bootstrap step surfaces either the active rule block or a conc When the file exists, `fm-spawn.sh` refuses crewmate and scout launches without an explicit harness, so `config/crew-harness` is only automatic when no dispatch profile file is active. Secondmate launches are exempt because they resolve the secondmate harness and any optional secondmate model or effort tokens instead. Unsupported effort values are still recorded in task meta when passed to `fm-spawn.sh`, but the launch template omits any effort flag that the selected harness does not accept. -That keeps spawn launch compatible across claude, codex, grok, pi, and opencode while preserving the requested profile for later audit. +That keeps spawn launch compatible across claude, codex, cursor, grok, pi, and opencode while preserving the requested profile for later audit. ## Optional secondmates diff --git a/docs/arm-pretool-check.md b/docs/arm-pretool-check.md index 5d388f579..12f1e6254 100644 --- a/docs/arm-pretool-check.md +++ b/docs/arm-pretool-check.md @@ -34,6 +34,7 @@ It tokenizes the bytes and classifies lexical execution positions only. `bin/fm-arm-pretool-check.sh` supports these entry forms: - Stdin JSON at `.tool_input.command` for Claude and Codex. +- Stdin JSON at `.tool_input.command` for Cursor. - Stdin JSON at `.toolInput.command` for Grok. - `--command ` for OpenCode and Pi. - `--background` as a compatibility-only field that never changes the decision. @@ -168,6 +169,7 @@ Prose may improve without changing adapter behavior. | Harness | Exact command field | Adapter behavior on checker exit 2 | | --- | --- | --- | +| Cursor | `.tool_input.command` | `.cursor/hooks.json` forwards the complete stdin payload and Cursor blocks on exit 2. | | Codex | `.tool_input.command` | The `.codex/hooks.json` command forwards the complete stdin payload and Codex blocks on exit 2. | | Claude | `.tool_input.command` | `.claude/settings.json` forwards stdin with `--claude`, leaving stdout empty and returning the stderr deny object. | | Grok | `.toolInput.command` | `.grok/hooks/fm-primary-pretool-check.json` forwards stdin and Grok consumes the stdout `decision=deny` object. | @@ -237,6 +239,18 @@ Native supervision paths were also validated in the same scratch project: Every native-path automatic marker was present and every deny sentinel remained absent. +## Cursor live validation record, 2026-07-14 + +Cursor Agent `2026.07.09-a3815c0` was run in a git-initialized scratch project with a project `.cursor/hooks.json`. +Command run: `cursor-agent --print --trust --force --model gpt-5.6-sol-high "Use the shell tool to run printf CURSORTOOL, then report the output."`. +The `preToolUse` payload contained `tool_name: "Shell"` and `.tool_input.command: "printf CURSORTOOL"`, along with `cwd`, `workspace_roots`, `conversation_id`, and `session_id`. +The hook process received `CURSOR_PROJECT_DIR` equal to the scratch project. +Returning `{"permission":"allow"}` allowed the command and the model observed `CURSORTOOL`. +Cursor's documented and installed exit-code contract maps exit 2 to a deny, so the tracked `Shell` matcher forwards the complete stdin payload to `bin/fm-arm-pretool-check.sh`; the checker remains the semantic owner. + +A 2026-07-18 revalidation on Cursor Agent `2026.07.16-899851b` with an individual Pro account found no hook execution at all for accounts without Cursor's server-side hooks rollout (`docs/turnend-guard.md` owns the dated evidence). +The tracked Cursor seatbelt is therefore fail-open at the platform layer: it denies automatically only when Cursor executes project hooks, and the emitted supervision instructions remain the working guard otherwise. + ## Automated validation `tests/fm-arm-pretool-check.test.sh` owns the adversarial acceptance matrix. diff --git a/docs/cd-guard.md b/docs/cd-guard.md index 2d8082e1c..4b3dfd053 100644 --- a/docs/cd-guard.md +++ b/docs/cd-guard.md @@ -74,10 +74,11 @@ It does not permit `cd /home/project`, because an absolute-path `cd` remains a p ## Transport and fail-open behavior -`bin/fm-cd-pretool-check.sh` supports all five harness entry shapes used by the tracked adapters: +`bin/fm-cd-pretool-check.sh` supports every harness entry shape used by the tracked adapters: - Claude sends stdin JSON at `.tool_input.command` and adds `--claude` to preserve Claude's stderr-only deny requirement. - Codex sends stdin JSON at `.tool_input.command` without `--claude`. +- Cursor sends stdin JSON at `.tool_input.command` without `--claude`. - Grok sends stdin JSON at `.toolInput.command`. - OpenCode sends the exact command string through `--command `. - Pi sends the exact command string through `--command `. @@ -98,6 +99,7 @@ Identical in shape to `docs/arm-pretool-check.md`: - Default deny mode also writes `{"decision":"deny","reason":"[persistent-cd] reason"}` to stdout for Grok. - `--claude` suppresses stdout completely because Claude ignores a PreToolUse deny when stdout is nonempty. - Codex blocks on exit 2 and displays stderr. +- Cursor blocks on exit 2 when its account has Cursor's server-side hooks rollout; without it no hooks execute at all, so the Cursor wiring is fail-open at the platform layer (`docs/turnend-guard.md`, 2026-07-18 record). - OpenCode throws only when the checker exits 2. - Pi returns `{block: true}` only when the checker exits 2. @@ -114,6 +116,7 @@ The cd-guard never duplicates shell lexing; it adds only the cd-specific decisio | --- | --- | --- | | Claude | `.claude/settings.json` PreToolUse Bash hook forwarding stdin with `--claude` | Blocks the tool call; stderr deny object, stdout empty. | | Codex | `.codex/hooks.json` PreToolUse hook that anchors from `pwd -P`, verifies the hook-loaded firstmate root, and forwards the payload | Blocks on exit 2 and displays stderr. | +| Cursor | `.cursor/hooks.json` `preToolUse` Shell hook anchored on `CURSOR_PROJECT_DIR` | Blocks on exit 2. | | Grok | `.grok/hooks/fm-primary-cd-check.json` PreToolUse hook anchored on `${GROK_WORKSPACE_ROOT:-}` | Consumes the stdout `decision=deny` object. | | OpenCode | `.opencode/plugins/fm-primary-cd-check.js` `tool.execute.before` | Throws, which surfaces as the failed tool result. | | Pi | `.pi/extensions/fm-primary-turnend-guard.ts` `tool_call` handler | Returns `{block: true}`; piggybacks on the already-loaded primary extension so no extra `-e` flag is needed. | diff --git a/docs/configuration.md b/docs/configuration.md index 25560837d..1eb574234 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -162,7 +162,7 @@ The full cmux home label also includes a short hash of the resolved `FM_ROOT` pa ## Harness support -claude, codex, opencode, pi, and grok are all empirically verified; new harnesses get verified through a supervised trial task before joining the set. +claude, codex, cursor, opencode, pi, and grok are verified adapters; new harnesses get verified through a supervised trial task before joining the set. The verified adapter knowledge - busy signatures, interrupt and exit commands, skill-invocation syntax, and per-harness quirks - lives in [`.agents/skills/harness-adapters/SKILL.md`](../.agents/skills/harness-adapters/SKILL.md). Launch mechanics, including the verified command templates, live in [`bin/fm-spawn.sh`](../bin/fm-spawn.sh). Primary-session turn-end guard integrations for verified harnesses are tracked as repo-level hook files and documented in [`docs/turnend-guard.md`](turnend-guard.md). @@ -181,6 +181,7 @@ When `config/crew-dispatch.json` exists, crewmate and scout spawns require an ex The inherited-local-material contract is owned by `secondmate-provisioning`; for harness behavior, its propagated config items make a secondmate's own crewmates, dispatch profiles, and backlog backend use the primary values. `config/secondmate-harness` is not inherited because secondmates do not launch secondmates. For grok, `fm-spawn.sh` installs one firstmate-owned global turn-end hook under `$GROK_HOME/hooks/`, or `~/.grok/hooks/` when `GROK_HOME` is unset, and drops a per-task `.fm-grok-turnend` pointer in the worktree, with teardown removing the task token and pointer. +For Cursor, `fm-spawn.sh` passes a correctly structured task plugin and additively merges one token-scoped fallback stop hook into `~/.cursor/hooks.json`; `bin/fm-cursor-lib.sh` owns the atomic install, the concurrency lock, and the last-task teardown cleanup that removes the shared hook artifacts once no task tokens remain, and `docs/turnend-guard.md` records the verified account-gated hook-execution evidence. For Pi secondmate launches, `fm-spawn.sh` starts Pi with `-e` pointed at the secondmate home's own tracked `.pi/extensions/fm-primary-pi-watch.ts` and `.pi/extensions/fm-primary-turnend-guard.ts`, both already present from the secondmate home's git worktree. ## Crew dispatch profiles (config/crew-dispatch.json) @@ -410,7 +411,7 @@ FM_STALE_WORKTREE_LOCK_RETRY_WAIT_SECS= # legacy alias for FM_TREEHOUSE_RETURN FM_FLEET_SYNC_PACKED_REFS_LOCK_RETRIES=3 # fetch retries after fm-fleet-sync.sh hits the orphaned .git/packed-refs.lock signature FM_FLEET_SYNC_PACKED_REFS_LOCK_RETRY_WAIT_SECS=1 # seconds fm-fleet-sync.sh waits before each of those retries FM_FLEET_SYNC_PACKED_REFS_LOCK_AGE_SECS=30 # min mtime age before fm-fleet-sync.sh treats a leftover packed-refs.lock as provably stale -FM_BUSY_REGEX='esc (to )?interrupt|Working\.\.\.|Ctrl\+c:cancel' # busy-pane signatures, shared by watcher, fm-crew-state pane fallback, and tmux helper +FM_BUSY_REGEX='esc (to )?interrupt|Working\.\.\.|Ctrl\+c:cancel|ctrl\+c to stop[[:space:]]*$|^[[:space:]]*[^[:space:]]+[[:space:]]+Running[[:space:]]+[0-9]+[[:space:]]+tokens([[:space:]]|$)' # busy-pane signatures, shared by watcher, fm-crew-state pane fallback, and tmux helper FM_COMPOSER_IDLE_RE= # optional empty-composer regex, applied after ghost and border stripping FM_COMPOSER_GHOST_LUMA_MAX=128 # fleet-wide: max perceived luminance (0.299R+0.587G+0.114B, 0-255) for a TRUECOLOR foreground to count as de-emphasised ghost/placeholder text and be stripped; dim/faint (SGR 2) is stripped regardless. Assumes a dark terminal theme (bin/fm-composer-lib.sh's fm_composer_strip_ghost, shared by the tmux and herdr composer readers) GROK_HOME= # optional Grok config home for firstmate's global grok turn-end hook; defaults to ~/.grok diff --git a/docs/orca-backend.md b/docs/orca-backend.md index 6be2ad58f..690602715 100644 --- a/docs/orca-backend.md +++ b/docs/orca-backend.md @@ -1,7 +1,7 @@ # Orca Backend Orca is an experimental runtime backend for firstmate. -It is distinct from the crewmate harness: the harness is the agent process firstmate launches (`claude`, `codex`, `opencode`, `pi`, or `grok`), while Orca owns the task worktree and terminal endpoint underneath that process. +It is distinct from the crewmate harness: the harness is the agent process firstmate launches (`claude`, `codex`, `cursor`, `opencode`, `pi`, or `grok`), while Orca owns the task worktree and terminal endpoint underneath that process. Firstmate agents operating this backend should load the agent-only [`firstmate-orca`](../.agents/skills/firstmate-orca/SKILL.md) checklist before switching to Orca, spawning or supervising Orca-backed work, smoke-testing, debugging task state, or reconciling Orca metadata. ## Setup diff --git a/docs/scripts.md b/docs/scripts.md index 8a5bb5a9c..24f2d3064 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -24,6 +24,8 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize | `fm-primary-scope-lib.sh` | Shared marker-or-plain-checkout primary-home predicate for tracked hooks | | `fm-turnend-guard.sh` | Shared primary turn-end guard predicate so no turn ends blind (docs/turnend-guard.md) | | `fm-turnend-guard-grok.sh` | Grok Stop-hook adapter for the primary turn-end guard | +| `fm-turnend-guard-cursor.sh` | Cursor stop-hook adapter for the primary turn-end guard | +| `fm-cursor-lib.sh` | Shared owner of Cursor's user-level turn-end fallback hook install, lock, and last-task cleanup | | `fm-arm-pretool-check.sh` | Stable PreToolUse transport for the watcher-arm command policy (docs/arm-pretool-check.md) | | `fm-arm-command-policy.mjs` | Semantic owner of the watcher-arm PreToolUse policy (docs/arm-pretool-check.md) | | `fm-continuity-pretool-check.sh` | Narrow Claude recovery gate when in-flight work has no live watcher lock (docs/arm-pretool-check.md) | diff --git a/docs/sessionstart-nudge.md b/docs/sessionstart-nudge.md index 1507595d1..b97a8c979 100644 --- a/docs/sessionstart-nudge.md +++ b/docs/sessionstart-nudge.md @@ -21,6 +21,7 @@ Every path exits 0, including malformed state and adapter errors, because Claude |---|---|---| | Claude | `.claude/settings.json` registers `SessionStart` for `startup`, `resume`, and `clear`, excludes `compact`, and invokes the wrapper through `CLAUDE_PROJECT_DIR`. | Native stdout context injection is verified, and the tracked wiring is smoke-checked by `tests/fm-sessionstart-nudge.test.sh`. | | Codex | `.codex/hooks.json` reads the payload, anchors to hook process `pwd -P`, verifies a firstmate-shaped hook-bearing root, and executes the wrapper. | Native stdout context injection is verified on Codex 0.144.4. | +| Cursor | `.cursor/hooks.json` registers the wrapper on the `sessionStart` event through `CURSOR_PROJECT_DIR` with `failClosed: false`. | Documented fail-open: Cursor Agent 2026.07.16-899851b executes no hooks for accounts without Cursor's server-side hooks rollout (2026-07-18, `docs/turnend-guard.md`), and `sessionStart` is confirmed in the installed bundle's hook-event registry rather than by an observed firing. | | OpenCode | `.opencode/plugins/fm-primary-sessionstart-nudge.js` listens for `session.created`, runs the wrapper once per session id, and calls `client.session.promptAsync` only when the wrapper prints a nudge. | Verified in the interactive TUI on OpenCode 1.17.18 and intentionally fail-open in headless `opencode run`. | | Pi | `.pi/extensions/fm-primary-turnend-guard.ts` handles `session_start` reasons `startup`, `new`, and `resume`, then injects the wrapper output with `pi.sendMessage`. | The custom message enters model context without racing an initial positional prompt, and the changed extension passes strict TypeScript checking on Pi 0.80.10. | | Grok | `.grok/hooks/fm-primary-sessionstart-nudge.json` registers a project `SessionStart` hook and invokes the wrapper through inline-defaulted `${GROK_WORKSPACE_ROOT:-}`. | The project event fires on Grok 0.2.103, but hook stdout does not reach model context, so this path is documented fail-open. | @@ -92,6 +93,12 @@ OPENCODE_CONFIG_CONTENT='{"permission":{"*":"allow"}}' opencode --prompt 'Reply The TUI created session `ses_08d62aad7ffe12xoJfGf0jHxJU`, accepted the `promptAsync` message, and rendered `OPENCODE_SESSIONSTART_CONTEXT` as the model result. This verifies `session.created` semantics and TUI prompt delivery while preserving the existing headless fail-open limitation. +### Cursor Agent 2026.07.16-899851b (2026-07-18) + +A scratch-project catch-all hook logger registered on `sessionStart`, `preToolUse`, and `stop` recorded nothing across print-mode (`--print --trust --force`) and interactive tmux runs that both completed a Shell tool call, so no hook event - including `sessionStart` - executes for this individual Pro account. +The installed bundle's hook-event registry contains `sessionStart` with an output validator accepting an `env` map, and its config reader discovers enterprise, team, user, project, and Claude-compat hook files, so the tracked wiring matches the installed contract and activates only when Cursor's server-side rollout enables hook execution. +The full probe commands and bundle evidence live in `docs/turnend-guard.md`. + ### Claude and Pi wiring smoke checks `jq empty .claude/settings.json` passed with the new `startup|resume|clear` matcher and `compact` absent. @@ -107,5 +114,5 @@ The underlying Claude SessionStart stdout injection and Pi `session_start` event `tests/fm-sessionstart-nudge.test.sh` proves wrapper silence for both gate signals, an unmarked linked worktree, a missing state directory, and an already-owned lock. It proves exact one-line output for a plain primary and a marked linked secondmate primary. -It also verifies tracked wrapper registration for Claude, Codex, OpenCode, Pi, and Grok. +It also verifies tracked wrapper registration for Claude, Codex, Cursor, OpenCode, Pi, and Grok. `tests/fm-turnend-guard.test.sh` continues to cover the same shared primary scope through the turn-end path. diff --git a/docs/supervision-protocols/cursor.md b/docs/supervision-protocols/cursor.md new file mode 100644 index 000000000..e1daf08d4 --- /dev/null +++ b/docs/supervision-protocols/cursor.md @@ -0,0 +1,22 @@ +Mode: Cursor tracked background-notify supervision. + +When this session owns supervision and away mode is not active: +1. Drain first with `bin/fm-wake-drain.sh`. +2. Source `__FM_X_MODE_ENV__` first when X mode is active. +3. Run `bin/fm-watch-arm.sh` as its own Shell tool call with `block_until_ms: 0`. +4. Never bundle the arm command with other commands. +5. Never use shell `&` for watcher supervision. + When Cursor's account rollout executes project hooks, a shell `&`, a truncating pipe, or bundling is also denied automatically by the `preToolUse` seatbelt registered in `.cursor/hooks.json`; otherwise this instruction is the guard (docs/turnend-guard.md). +6. Perform the one non-blocking status check required for a directly backgrounded Shell call. +7. Treat `watcher: started ...` and `watcher: attached ...` as proof that one live cycle exists. + On attach, the background task stays live until that existing cycle ends. +8. Treat `watcher: FAILED - no live watcher with a fresh beacon` as an alarm and repair it before ending the turn. +9. When Cursor reports that the background task completed with `signal:`, `stale:`, `check:`, or `heartbeat`, drain queued wakes, handle them, then start exactly one fresh background task. +10. Do not invent a wake from an attach-status line alone. + Drain and act only on real wake records or a real watcher reason line. +11. If a forced restart is genuinely needed, run `bin/fm-watch-arm.sh --restart` through the same tracked background task mechanism. +12. Do not send idle progress while the watcher is parked. + +Cursor's tracked Shell task completion is the wake mechanism. +The watcher itself remains `bin/fm-watch.sh`, and `bin/fm-watch-arm.sh` is only the verified arm wrapper. +The tracked stop hook adapts Cursor's `loop_count` and `followup_message` fields to the shared turn-end guard as a one-follow-up backstop. diff --git a/docs/tmux-backend.md b/docs/tmux-backend.md index 544781e39..702e1388f 100644 --- a/docs/tmux-backend.md +++ b/docs/tmux-backend.md @@ -99,7 +99,9 @@ Verified the same session: a persisting parent process running a child command ( The classifier (`fm_backend_tmux_agent_alive`) maps the observed name to `alive`, `dead`, or `unknown`: -- `alive` - the name contains `claude`, `codex`, `opencode`, or `grok`. All four were confirmed to run as their own literal process name (`ps -ef`, 2026-07-07): `claude` and `codex` and `opencode` are each a native compiled binary (`file` reports Mach-O), so their `comm` is their own binary name with no interpreter wrapper to hide behind. +- `alive` - the name contains `claude`, `codex`, `opencode`, or `grok`, or the pane reports `node` and that pane process's first argument has the exact basename `cursor-agent`. + The four native-name harnesses were confirmed with `ps -ef` on 2026-07-07. + Cursor's stricter argument check was confirmed on 2026-07-14 and never matches a generic process named `agent` or a bare `node`. - `dead` - the name is a bare shell (`zsh`, `bash`, `sh`, `dash`, `ash`, `ksh`, `mksh`, `tcsh`, `csh`, `fish`). - `unknown` - anything else, including an unreadable pane. @@ -111,6 +113,33 @@ The classifier deliberately reports `unknown` for `node`/`python`/`python3` rath Practical effect: a dead `pi` secondmate is not auto-healed by the liveness sweep today; it is reported as `skipped: liveness probe inconclusive` instead, which still surfaces it for a human to act on. Resolving this would need either a `pi`-specific env marker inspectable from outside the process (mirroring `PI_CODING_AGENT=true`, which `bin/fm-harness.sh` already uses for self-detection but which is not readable from a different process without deeper introspection) or accepting the argument-inspection fragility - not attempted here. +### Cursor Agent verification, 2026-07-14 + +Version: `2026.07.09-a3815c0`. +Launch command: `cursor-agent --force --model gpt-5.6-sol-high`. +Command run while the interactive TUI was live: `tmux display-message -p -t fm-cursor-verify '#{pane_current_command}'`. +Exact output: `node`. +Commands then read the same pane's `#{pane_pid}` and ran `ps -o pid=,ppid=,pgid=,comm=,args= -g `. +The single foreground row's arguments began with `/Users/shawn.petros/.local/bin/cursor-agent --use-system-ca .../cursor-agent/versions/2026.07.09-a3815c0/index.js`. +This is why the liveness classifier inspects only a `node` pane's own pid and requires argv[0]'s basename to equal `cursor-agent`; unrelated Node programs remain `unknown`. + +The busy TUI was captured while the agent ran `sleep 30`. +Its status line was `⠘⠆ Running 50 tokens`, while the idle TUI had no `Running ... tokens` line. +The shared busy regex therefore matches a leading spinner token followed by `Running`, a numeric token count, and `tokens`, without depending on one animated braille glyph. + +### Cursor Agent reverification, 2026-07-18 + +Version: `2026.07.16-899851b`. +Launch command in a scratch tmux session: `cursor-agent --force --model auto`. +`tmux list-panes -F '#{pane_pid} #{pane_current_command}'` reported `node`; `ps -o tpgid= -p ` named the pane pid itself, and a sole-column `ps -o comm= -p ` printed the full argv[0] `/Users/abhishekbojja/.local/bin/cursor-agent`, with `ps -o args=` beginning `/Users/abhishekbojja/.local/bin/cursor-agent --use-system-ca .../versions/2026.07.16-899851b/index.js`. +Both liveness paths therefore hold on this build: macOS `comm` resolves to the exact `cursor-agent` basename directly, and the `node` route still requires the `cursor-agent` argv[0] basename. + +The busy signature changed on this build. +While a turn ran (`sleep 20` through the Shell tool), the composer footer row read `→ Add a follow-up ctrl+c to stop` and a transient `1 task` line appeared; the `Running tokens` status line of 2026.07.09 never appeared. +At idle the footer showed only `→ Add a follow-up` with no `ctrl+c to stop`. +The shared busy regex therefore matches an end-anchored `ctrl+c to stop` (the anchor keeps mid-transcript prose from matching) and retains the older `Running tokens` pattern for pre-2026.07.16 builds. +Piping the interactive launch through `tee` broke the TTY and made `cursor-agent` fail with `Error: No prompt provided for print mode`, so liveness probes must never wrap the launch in a pipe. + ## Limitations None specific to tmux for the reference path itself - it is the fully verified reference backend, while Orca and cmux are the backends without secondmate support. diff --git a/docs/turnend-guard.md b/docs/turnend-guard.md index eac25f5dd..b3c6c2270 100644 --- a/docs/turnend-guard.md +++ b/docs/turnend-guard.md @@ -42,6 +42,8 @@ All verified primary harnesses have a tracked integration: - `claude`: `.claude/settings.json` registers a `Stop` hook command anchored through `"$CLAUDE_PROJECT_DIR"/bin/fm-turnend-guard.sh`. - `codex`: `.codex/hooks.json` registers a `Stop` hook that reads the hook payload once, anchors the executable to the hook command process working directory, verifies that root is firstmate-shaped and hook-bearing, and pipes the original payload to that checkout's `bin/fm-turnend-guard.sh`. +- `cursor`: `.cursor/hooks.json` registers a fail-open `stop` hook anchored through `CURSOR_PROJECT_DIR`. + `bin/fm-turnend-guard-cursor.sh` maps Cursor's zero-based `loop_count` to the shared loop guard and returns the shared predicate's exit-2 reason as one `followup_message`. - `opencode`: `.opencode/plugins/fm-primary-turnend-guard.js` listens for `session.idle`, lets the watcher-arm coordinator handle normal idle supervision first, runs the shared guard only when that coordinator does not act, and uses `client.session.promptAsync` to force one follow-up prompt when the guard returns 2. - `pi`: `.pi/extensions/fm-primary-turnend-guard.ts` listens for `agent_settled`, marks the extension version loaded for session-start checks, runs the shared guard once per logical agent run, and uses `pi.sendUserMessage(..., { deliverAs: "followUp" })` to force one follow-up prompt when the guard returns 2. - `grok`: `.grok/hooks/fm-primary-turnend-guard.json` registers a `Stop` hook that invokes `bin/fm-turnend-guard-grok.sh`. @@ -52,7 +54,7 @@ Claude and Codex support a direct blocking Stop hook. For those harnesses, exit status 2 plus stderr from `bin/fm-turnend-guard.sh` blocks the stop and feeds the reason back into the model. Both payloads include `stop_hook_active`; when it is true, the shared guard exits 0 so the harness can end after one forced continuation. -OpenCode, Pi, and Grok expose passive lifecycle callbacks for this purpose. +Cursor, OpenCode, Pi, and Grok expose passive lifecycle callbacks for this purpose. Their adapters fail open at the hook boundary to avoid corrupting a user session, but they force one follow-up turn when the shared predicate blocks. Each adapter carries its own in-process or environment loop guard so the forced follow-up does not recursively schedule another follow-up. Pi keeps that latch active across every internal tool turn and clears it only when the generated guard follow-up reaches `agent_settled`, or immediately when follow-up delivery fails. @@ -79,6 +81,31 @@ Observed output: the first command printed `/outside`, the second comma The tracked command therefore treats hook process PWD as the hook-loaded firstmate root and does not let payload `cwd` choose an executable. It still passes the original payload to `bin/fm-turnend-guard.sh`, so the shared loop guard reads `stop_hook_active`. +Cursor Agent `2026.07.09-a3815c0` was validated on 2026-07-14 in a git-initialized scratch project. +Hook file used: `.cursor/hooks.json`. +Command run: `cursor-agent --print --trust --force --model gpt-5.6-sol-high "Reply with exactly FIRST"`. +Observed output: the project `stop` hook received `status`, `loop_count: 0`, `workspace_roots`, `conversation_id`, and `session_id`; `CURSOR_PROJECT_DIR` and hook process PWD both resolved to the scratch project; returning `{"followup_message":"CURSORHOOK: reply with exactly SECOND"}` caused one same-session continuation. +The tracked adapter sets `loop_limit: 1` and also allows any payload whose `loop_count` is already nonzero. +The local tracked-background Shell mechanism was exercised with `block_until_ms: 0`; the command returned a background-task handle immediately and later reported `CURSOR_BG_DONE` with exit code 0. +Desktop Agents Window proof remains incomplete: this environment did not expose a desktop Agents Window process tree, so the remaining check is to run `bin/fm-session-start.sh` from that window and confirm its tool subprocess either descends from `cursor-agent` or exposes a Cursor-specific marker that can be added without matching a generic `agent` process. + +Task-worker hook loading was tested separately with a correctly structured plugin root containing `.cursor-plugin/plugin.json` with `"hooks":"hooks/hooks.json"` and `hooks/hooks.json` containing an absolute `stop` callback. +Commands run: `cursor-agent --print --trust --force --plugin-dir --workspace --model gpt-5.6-sol-high "Reply exactly PLUGIN"` and the equivalent interactive TUI launch. +Exact model outputs were `PLUGIN` and `PLUGININTERACTIVE`, but the callback marker remained absent in both cases. +Debug startup reported `plugin_imports_team_settings_ms: 1` and no ad hoc plugin-hook execution; user and project hooks still fired. +Firstmate therefore keeps passing the correctly structured task plugin for forward compatibility and installs one additive entry in `~/.cursor/hooks.json` as the documented fallback. +That fallback reads `workspace_roots`, requires a gitignored `.fm-cursor-turnend` token in the task worktree, validates the token against a private Firstmate registry, and no-ops for unrelated Cursor sessions. +It merges into the existing `stop` array and refuses malformed existing configuration instead of overwriting it. +`bin/fm-cursor-lib.sh` owns the shared-artifact mechanics: the hook script is written atomically (mktemp plus rename in the same directory), install and teardown serialize on a bounded mkdir lock, and the teardown that removes the last registry token also removes the shared hook script, Firstmate's own `hooks.json` stop entry, and the empty registry directory, so no Firstmate global state persists after the final Cursor task. +Every uncertain cleanup path (lock timeout, malformed `hooks.json`) skips cleanup and leaves the strict-no-op hook installed for the next teardown to retry. + +Cursor Agent `2026.07.16-899851b` was revalidated on 2026-07-18 in a git-initialized scratch project on an individual Pro account, and hook execution proved to be gated server-side. +Commands run: `cursor-agent --print --output-format text --trust --force --model auto "Use the shell tool to run: printf HOOKPROBE. Then reply with exactly DONE."` (with cwd in the scratch project and again with `--workspace`), the same probe through `--plugin-dir` with the structured task plugin, and an interactive tmux TUI turn that executed a Shell tool call in a workspace whose `.cursor/projects//.workspace-trusted` marker existed. +Observed output: the model ran the Shell tool and answered `DONE`/`TUIDONE` in every case, but no registered hook executed - not the project `.cursor/hooks.json` `preToolUse`/`stop` catch-all logger, not the plugin's `hooks/hooks.json` stop hook, and not the pre-existing user-level `~/.claude/settings.json` Stop hook that this build's config reader also discovers. +The installed bundle contains the full hook executor (event registry including `sessionStart`, `preToolUse`, `stop`; `loop_limit`, `failClosed`, and timeout handling; `CURSOR_PROJECT_DIR` and `CLAUDE_PROJECT_DIR` env injection; enterprise/team/user/project plus Claude-compat config paths) and a `claude_code_hooks_enabled` server-config field, and locally flipping that cached flag was reverted by the next server config refresh. +The 2026-07-14 record above (hooks firing on 2026.07.09-a3815c0) was collected on a different account, so hook availability varies by account rollout, not only CLI version. +Every tracked Cursor hook integration therefore stays strictly fail-open: the turn-end guard, seatbelts, nudge, and worker turn-end fallback activate only when Cursor executes hooks, and the watcher's pane-based staleness supervision plus the emitted supervision protocol carry the load when it does not. + OpenCode 1.17.6 was validated with project plugins under scratch `.opencode/plugins/`. Hook file used: `.opencode/plugins/fm-smoke.js` for throw testing and `.opencode/plugins/fm-primary-turnend-guard.js` for follow-up testing. Command run for passive behavior: `opencode run --print-logs --log-level DEBUG --dangerously-skip-permissions 'Say hi in exactly one word.'`. @@ -147,6 +174,7 @@ No Herdr command was issued and no fleet state was touched; the experiment wrote ## Tests -`tests/fm-turnend-guard.test.sh` covers the shared predicate, primary scoping (including a secondmate's own home being guarded like the main primary while its child worktrees stay exempt), `FM_HOME` and `FM_STATE_OVERRIDE` precedence, Pi logical-run latch behavior for no-tool and multi-tool runs, fail-open behavior without `jq`, tracked hook registration for all five harnesses, and the Grok adapter's forced-resume loop guard and permission-mode regression. +`tests/fm-turnend-guard.test.sh` covers the shared predicate, primary scoping (including a secondmate's own home being guarded like the main primary while its child worktrees stay exempt), `FM_HOME` and `FM_STATE_OVERRIDE` precedence, Pi logical-run latch behavior for no-tool and multi-tool runs, fail-open behavior without `jq`, tracked hook registration for the existing integrations, and the Grok adapter's forced-resume loop guard and permission-mode regression. +`tests/fm-cursor-harness.test.sh` covers Cursor's tracked hook registration and follow-up adapter. The default behavior suite does not invoke live language-model harnesses. `FM_PI_LIVE_E2E=1 tests/fm-pi-primary-live-e2e.test.sh` opts into the isolated interactive Pi regression recorded above. diff --git a/tests/fm-arm-pretool-check.test.sh b/tests/fm-arm-pretool-check.test.sh index 52345bd43..abe7668d2 100755 --- a/tests/fm-arm-pretool-check.test.sh +++ b/tests/fm-arm-pretool-check.test.sh @@ -3,8 +3,8 @@ # Behavior tests for the watcher-arm PreToolUse seatbelt (docs/arm-pretool-check.md). # # bin/fm-arm-command-policy.mjs is the single owner of command classification. -# This suite drives the stable shell transport through all five harness entry -# forms and asserts the per-harness wiring contract without spawning a harness. +# This suite drives every stable shell transport shape and asserts each +# harness's wiring contract without spawning a harness. # Empirical harness evidence lives in docs/arm-pretool-check.md. set -u @@ -497,6 +497,19 @@ test_codex_hooks_pretool_wired() { pass ".codex/hooks.json: PreToolUse hook invokes the shared checker" } +test_cursor_hooks_pretool_wired() { + local settings command matcher + settings="$ROOT/.cursor/hooks.json" + [ -f "$settings" ] || fail "tracked Cursor primary hooks are missing" + command=$(jq -r '.hooks.preToolUse[0].command // empty' "$settings") + [ -n "$command" ] || fail "preToolUse hook command is missing from Cursor primary hooks" + assert_contains "$command" 'CURSOR_PROJECT_DIR' "Cursor pretool hook must anchor via CURSOR_PROJECT_DIR" + assert_contains "$command" 'fm-arm-pretool-check.sh' "Cursor pretool hook must invoke the shared checker" + matcher=$(jq -r '.hooks.preToolUse[0].matcher // empty' "$settings") + [ "$matcher" = "Shell" ] || fail "Cursor pretool hook must matcher-scope to Shell, got: $matcher" + pass ".cursor/hooks.json: preToolUse hook invokes the shared checker" +} + test_opencode_pretool_plugin_wired() { local plugin content plugin="$ROOT/.opencode/plugins/fm-primary-pretool-check.js" @@ -554,6 +567,7 @@ test_grok_pretool_hook_wired test_grok_turnend_hook_uses_safe_var_pattern test_claude_settings_pretool_hook_wired test_codex_hooks_pretool_wired +test_cursor_hooks_pretool_wired test_opencode_pretool_plugin_wired test_pi_extension_carries_pretool_check test_shellcheck_clean diff --git a/tests/fm-bootstrap.test.sh b/tests/fm-bootstrap.test.sh index 851ba66ec..82efe6abd 100755 --- a/tests/fm-bootstrap.test.sh +++ b/tests/fm-bootstrap.test.sh @@ -776,6 +776,8 @@ unsupported grok max effort is flagged^{"rules":[{"when":"deep current work","us unsupported grok xhigh effort is flagged^{"rules":[{"when":"deep current work","use":{"harness":"grok","model":"grok-4","effort":"xhigh"}}]}^exact^CREW_DISPATCH: invalid config/crew-dispatch.json - invalid effort: grok:xhigh pi max effort is accepted^{"rules":[{"when":"deep coding","use":{"harness":"pi","model":"openai-codex/gpt-5.6-sol","effort":"max"}}]}^empty^ unsupported opencode effort is flagged^{"rules":[{"when":"opencode work","use":{"harness":"opencode","model":"anthropic/claude-sonnet-4-5","effort":"high"}}]}^exact^CREW_DISPATCH: invalid config/crew-dispatch.json - invalid effort: opencode:high +cursor profile without standalone effort is accepted^{"rules":[{"when":"local Cursor work","use":{"harness":"cursor","model":"gpt-5.6-sol-high"}}]}^empty^ +unsupported cursor standalone effort is flagged^{"rules":[{"when":"local Cursor work","use":{"harness":"cursor","model":"gpt-5.6-sol-high","effort":"high"}}]}^exact^CREW_DISPATCH: invalid config/crew-dispatch.json - invalid effort: cursor:high array use with quota-balanced is accepted^{"rules":[{"when":"big feature","use":[{"harness":"claude","model":"claude-sonnet-5","effort":"high"},{"harness":"codex","model":"gpt-5.5","effort":"high"}],"select":"quota-balanced"}]}^empty^ array use without select is accepted^{"rules":[{"when":"big feature","use":[{"harness":"claude"},{"harness":"codex"}]}]}^empty^ empty array use is flagged^{"rules":[{"when":"big feature","use":[]}]}^exact^CREW_DISPATCH: invalid config/crew-dispatch.json - each rule needs at least one use profile diff --git a/tests/fm-cd-pretool-check.test.sh b/tests/fm-cd-pretool-check.test.sh index d34bdda3a..8608b7f62 100755 --- a/tests/fm-cd-pretool-check.test.sh +++ b/tests/fm-cd-pretool-check.test.sh @@ -398,6 +398,18 @@ test_codex_wiring() { pass ".codex/hooks.json: PreToolUse invokes the cd-guard alongside the arm guard" } +test_cursor_wiring() { + local settings command + settings="$ROOT/.cursor/hooks.json" + [ -f "$settings" ] || fail "tracked .cursor/hooks.json is missing" + command=$(jq -r '[.hooks.preToolUse[].command | select(contains("fm-cd-pretool-check.sh"))][0] // empty' "$settings") + [ -n "$command" ] || fail "Cursor preToolUse must invoke fm-cd-pretool-check.sh" + assert_contains "$command" 'CURSOR_PROJECT_DIR' "Cursor cd hook must anchor from CURSOR_PROJECT_DIR" + jq -e '[.hooks.preToolUse[].command | select(contains("fm-arm-pretool-check.sh"))] | length == 1' "$settings" >/dev/null \ + || fail "Cursor cd hook must not displace the watcher-arm hook" + pass ".cursor/hooks.json: preToolUse invokes the cd-guard alongside the arm guard" +} + test_grok_wiring() { local settings command settings="$ROOT/.grok/hooks/fm-primary-cd-check.json" @@ -454,6 +466,7 @@ test_prefilter_skips_node_without_cd_substring test_policy_cli_direct test_claude_wiring test_codex_wiring +test_cursor_wiring test_grok_wiring test_opencode_wiring test_pi_wiring diff --git a/tests/fm-cursor-harness.test.sh b/tests/fm-cursor-harness.test.sh new file mode 100755 index 000000000..736860d06 --- /dev/null +++ b/tests/fm-cursor-harness.test.sh @@ -0,0 +1,260 @@ +#!/usr/bin/env bash +# Focused behavior tests for the local Cursor harness adapter. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +TMP_ROOT=$(fm_test_tmproot fm-cursor-harness) + +test_detects_cursor_agent_ancestry_without_generic_agent_match() { + local fakebin out + fakebin=$(fm_fakebin "$TMP_ROOT/detect") + cat > "$fakebin/ps" <<'SH' +#!/usr/bin/env bash +field=${2:-} +pid=${4:-} +case "$field:$pid" in + comm=:4242) printf '%s\n' node ;; + args=:4242) printf '%s\n' '/opt/cursor/cursor-agent --use-system-ca /opt/cursor/index.js' ;; + ppid=:4242) printf '%s\n' 1 ;; + comm=:*) printf '%s\n' bash ;; + args=:*) printf '%s\n' bash ;; + ppid=:*) printf '%s\n' 4242 ;; +esac +SH + chmod +x "$fakebin/ps" + # Drop the Layer-1 env markers so a claude/pi/grok/cursor host session cannot + # short-circuit the ancestry walk this test pins with its fake ps. + out=$(env -u CLAUDECODE -u PI_CODING_AGENT -u GROK_AGENT -u CURSOR_AGENT \ + PATH="$fakebin:/usr/bin:/bin" "$ROOT/bin/fm-harness.sh") + [ "$out" = cursor ] || fail "cursor-agent ancestry should detect cursor, got '$out'" + + cat > "$fakebin/ps" <<'SH' +#!/usr/bin/env bash +field=${2:-} +pid=${4:-} +case "$field:$pid" in + comm=:4242) printf '%s\n' node ;; + args=:4242) printf '%s\n' '/opt/something/agent --serve' ;; + ppid=:4242) printf '%s\n' 1 ;; + comm=:*) printf '%s\n' bash ;; + args=:*) printf '%s\n' bash ;; + ppid=:*) printf '%s\n' 4242 ;; +esac +SH + out=$(env -u CLAUDECODE -u PI_CODING_AGENT -u GROK_AGENT -u CURSOR_AGENT \ + PATH="$fakebin:/usr/bin:/bin" "$ROOT/bin/fm-harness.sh") + [ "$out" = unknown ] || fail "generic agent ancestry must stay unknown, got '$out'" + pass "fm-harness detects cursor-agent specifically and rejects generic agent" +} + +test_tmux_liveness_requires_cursor_agent_argv0() { + local out + FM_BACKEND_LIB_DIR="$ROOT/bin" + # shellcheck source=bin/backends/tmux.sh + . "$ROOT/bin/backends/tmux.sh" + fm_backend_tmux_current_command() { printf '%s\n' node; } + fm_backend_tmux_current_args() { printf '%s\n' '/opt/cursor/cursor-agent --use-system-ca /opt/cursor/index.js'; } + out=$(fm_backend_tmux_agent_alive fake) + [ "$out" = alive ] || fail "node with cursor-agent argv0 should be alive, got '$out'" + + fm_backend_tmux_current_args() { printf '%s\n' '/opt/something/agent --serve'; } + out=$(fm_backend_tmux_agent_alive fake) + [ "$out" = unknown ] || fail "generic node agent should stay unknown, got '$out'" + pass "tmux liveness attributes node only through exact cursor-agent argv0" +} + +test_tmux_args_resolve_foreground_process_group() { + local out + out=$( + FM_BACKEND_LIB_DIR="$ROOT/bin" + # shellcheck source=bin/backends/tmux.sh + . "$ROOT/bin/backends/tmux.sh" + tmux() { + printf '%s\n' 4242 + } + ps() { + if [ "$*" = "-o tpgid= -p 4242" ]; then + printf '%s\n' 7777 + elif [ "$*" = "-o args= -p 7777" ]; then + printf '%s\n' '/opt/cursor/cursor-agent --use-system-ca /opt/cursor/index.js' + else + return 1 + fi + } + fm_backend_tmux_current_args fake + ) + [ "$out" = '/opt/cursor/cursor-agent --use-system-ca /opt/cursor/index.js' ] \ + || fail "tmux args should come from the pane foreground process group, got '$out'" + pass "tmux liveness resolves the pane foreground process instead of its shell" +} + +test_cursor_busy_signature_is_specific() { + # shellcheck source=bin/fm-tmux-lib.sh + . "$ROOT/bin/fm-tmux-lib.sh" + printf '%s\n' ' ⠘⠆ Running 50 tokens' | grep -qiE "$FM_TMUX_BUSY_REGEX_DEFAULT" \ + || fail "Cursor 2026.07.09 Running token status should match the busy regex" + printf '%s\n' ' → Add a follow-up ctrl+c to stop' | grep -qiE "$FM_TMUX_BUSY_REGEX_DEFAULT" \ + || fail "Cursor 2026.07.16 ctrl+c to stop footer should match the busy regex" + if printf '%s\n' 'Running tests now' | grep -qiE "$FM_TMUX_BUSY_REGEX_DEFAULT"; then + fail "ordinary prose containing Running must not match the Cursor busy signature" + fi + if printf '%s\n' 'Press ctrl+c to stop the dev server before rebuilding' | grep -qiE "$FM_TMUX_BUSY_REGEX_DEFAULT"; then + fail "mid-line prose containing ctrl+c to stop must not match the end-anchored busy signature" + fi + pass "Cursor busy signatures match both build generations without prose false positives" +} + +test_cursor_primary_sessionstart_nudge_is_wired() { + local hooks nudge + hooks="$ROOT/.cursor/hooks.json" + [ -f "$hooks" ] || fail "tracked Cursor hooks are missing" + nudge=$(jq -r '.hooks.sessionStart[0].command // empty' "$hooks") + assert_contains "$nudge" 'CURSOR_PROJECT_DIR' "Cursor sessionStart nudge must anchor through CURSOR_PROJECT_DIR" + assert_contains "$nudge" 'fm-sessionstart-nudge.sh' "Cursor sessionStart nudge must invoke the shared wrapper" + jq -e '.hooks.sessionStart[0].failClosed == false' "$hooks" >/dev/null \ + || fail "Cursor sessionStart nudge must stay fail-open" + pass "tracked Cursor hooks register the shared session-start nudge fail-open" +} + +# fm-cursor-lib contract: atomic idempotent install, token-scoped registry, and +# deterministic last-task cleanup that preserves foreign hooks.json entries. +test_cursor_lib_install_is_idempotent_and_cleanup_is_last_token_scoped() { + local home hooks_dir auth_dir hook_cmd state + home="$TMP_ROOT/lib-home" + state="$TMP_ROOT/lib-state" + hooks_dir="$home/.cursor/hooks" + auth_dir="$hooks_dir/fm-turn-end.d" + mkdir -p "$auth_dir" "$state" + ( + # shellcheck source=bin/fm-cursor-lib.sh + . "$ROOT/bin/fm-cursor-lib.sh" + # shellcheck disable=SC2030 # HOME is deliberately overridden only inside this subshell + HOME="$home" + printf '%s\n' '{"version":1,"hooks":{"stop":[{"command":"user-own-hook.sh","timeout":9,"failClosed":false}]}}' > "$home/.cursor/hooks.json" + hook_cmd=$(fm_cursor_shell_quote "$hooks_dir/fm-turn-end.sh") + fm_cursor_hooks_lock "$hooks_dir" || exit 61 + fm_cursor_write_turnend_hook "$hooks_dir" "$auth_dir" || exit 62 + fm_cursor_merge_stop_entry "$home/.cursor" "$hook_cmd" || exit 63 + fm_cursor_merge_stop_entry "$home/.cursor" "$hook_cmd" || exit 64 + fm_cursor_hooks_unlock "$hooks_dir" + [ -x "$hooks_dir/fm-turn-end.sh" ] || exit 65 + [ "$(jq '.hooks.stop | length' "$home/.cursor/hooks.json")" = 2 ] || exit 66 + ls "$hooks_dir"/fm-turn-end.sh.tmp.* >/dev/null 2>&1 && exit 67 + + # Two registered tokens: removing the first must keep every shared artifact. + printf '%s\n' "$state/a.turn-ended" > "$auth_dir/fm.aaaaaaaaaaaa" + printf '%s\n' "$state/b.turn-ended" > "$auth_dir/fm.bbbbbbbbbbbb" + printf '%s\n' 'fm.aaaaaaaaaaaa' > "$state/task-a.cursor-turnend-token" + printf '%s\n' 'fm.bbbbbbbbbbbb' > "$state/task-b.cursor-turnend-token" + fm_cursor_remove_turnend_auth "$state" task-a + [ ! -e "$auth_dir/fm.aaaaaaaaaaaa" ] || exit 68 + [ -x "$hooks_dir/fm-turn-end.sh" ] || exit 69 + [ "$(jq '.hooks.stop | length' "$home/.cursor/hooks.json")" = 2 ] || exit 70 + + # Removing the last token must clear the script, our entry, and the registry + # dir while preserving the user's own stop entry and the file's version. + fm_cursor_remove_turnend_auth "$state" task-b + [ ! -e "$auth_dir/fm.bbbbbbbbbbbb" ] || exit 71 + [ ! -e "$hooks_dir/fm-turn-end.sh" ] || exit 72 + [ ! -d "$auth_dir" ] || exit 73 + [ ! -d "$hooks_dir/.fm-turn-end.lock" ] || exit 74 + [ "$(jq '.hooks.stop | length' "$home/.cursor/hooks.json")" = 1 ] || exit 75 + [ "$(jq -r '.hooks.stop[0].command' "$home/.cursor/hooks.json")" = 'user-own-hook.sh' ] || exit 76 + [ "$(jq -r '.version' "$home/.cursor/hooks.json")" = 1 ] || exit 77 + ) || fail "cursor lib install/cleanup contract failed (subshell exit $?)" + pass "cursor lib installs idempotently and cleans shared artifacts only after the last token" +} + +test_cursor_lib_cleanup_skips_on_held_lock_and_malformed_hooks_json() { + local home hooks_dir auth_dir state + home="$TMP_ROOT/lib-home2" + state="$TMP_ROOT/lib-state2" + hooks_dir="$home/.cursor/hooks" + auth_dir="$hooks_dir/fm-turn-end.d" + mkdir -p "$auth_dir" "$state" + ( + # shellcheck source=bin/fm-cursor-lib.sh + . "$ROOT/bin/fm-cursor-lib.sh" + # shellcheck disable=SC2030 # HOME is deliberately overridden only inside this subshell + HOME="$home" + fm_cursor_write_turnend_hook "$hooks_dir" "$auth_dir" || exit 61 + + # A held (fresh) lock: cleanup removes only the token and leaves the hook. + printf '%s\n' "$state/c.turn-ended" > "$auth_dir/fm.cccccccccccc" + printf '%s\n' 'fm.cccccccccccc' > "$state/task-c.cursor-turnend-token" + mkdir "$hooks_dir/.fm-turn-end.lock" || exit 62 + FM_CURSOR_HOOK_LOCK_TRIES=2 fm_cursor_remove_turnend_auth "$state" task-c + [ ! -e "$auth_dir/fm.cccccccccccc" ] || exit 63 + [ -x "$hooks_dir/fm-turn-end.sh" ] || exit 64 + rmdir "$hooks_dir/.fm-turn-end.lock" + + # A malformed hooks.json is never rewritten; the rest of cleanup still runs. + printf '%s\n' 'not json at all' > "$home/.cursor/hooks.json" + printf '%s\n' "$state/d.turn-ended" > "$auth_dir/fm.dddddddddddd" + printf '%s\n' 'fm.dddddddddddd' > "$state/task-d.cursor-turnend-token" + fm_cursor_remove_turnend_auth "$state" task-d + [ "$(cat "$home/.cursor/hooks.json")" = 'not json at all' ] || exit 65 + [ ! -e "$hooks_dir/fm-turn-end.sh" ] || exit 66 + [ ! -d "$auth_dir" ] || exit 67 + ) || fail "cursor lib cleanup safety contract failed (subshell exit $?)" + pass "cursor lib cleanup skips shared removal under a held lock and never rewrites malformed hooks.json" +} + +test_cursor_primary_hooks_are_wired() { + local hooks pretool stop + hooks="$ROOT/.cursor/hooks.json" + [ -f "$hooks" ] || fail "tracked Cursor hooks are missing" + pretool=$(jq -r '.hooks.preToolUse[0].command // empty' "$hooks") + stop=$(jq -r '.hooks.stop[0].command // empty' "$hooks") + assert_contains "$pretool" 'CURSOR_PROJECT_DIR' "Cursor preToolUse must anchor through CURSOR_PROJECT_DIR" + assert_contains "$pretool" 'fm-arm-pretool-check.sh' "Cursor preToolUse must invoke the shared checker" + assert_contains "$stop" 'CURSOR_PROJECT_DIR' "Cursor stop hook must anchor through CURSOR_PROJECT_DIR" + assert_contains "$stop" 'fm-turnend-guard-cursor.sh' "Cursor stop hook must invoke its adapter" + [ "$(jq -r '.hooks.preToolUse[0].matcher' "$hooks")" = Shell ] \ + || fail "Cursor preToolUse must be scoped to Shell" + [ "$(jq -r '.hooks.stop[0].loop_limit' "$hooks")" = 1 ] \ + || fail "Cursor stop hook must allow at most one follow-up" + pass "tracked Cursor hooks wire pre-tool policy and one-follow-up turn-end guard" +} + +test_cursor_turnend_adapter_maps_followup_and_loop_guard() { + local dir out log + dir="$TMP_ROOT/turnend" + log="$dir/guard.log" + mkdir -p "$dir/bin" + cp "$ROOT/bin/fm-turnend-guard-cursor.sh" "$dir/bin/" + cat > "$dir/bin/fm-turnend-guard.sh" <> "$log" +printf '%s\n' 'TURN WOULD END BLIND: cursor adapter smoke' >&2 +exit 2 +SH + chmod +x "$dir/bin/fm-turnend-guard.sh" "$dir/bin/fm-turnend-guard-cursor.sh" + + out=$(printf '%s' '{"status":"completed","loop_count":0}' | "$dir/bin/fm-turnend-guard-cursor.sh") + [ "$(printf '%s' "$out" | jq -r '.followup_message')" = 'TURN WOULD END BLIND: cursor adapter smoke' ] \ + || fail "Cursor adapter did not return the shared guard reason as followup_message" + jq -e '.stop_hook_active == false' "$log" >/dev/null \ + || fail "Cursor adapter did not translate the first stop for the shared guard" + + : > "$log" + out=$(printf '%s' '{"status":"completed","loop_count":1}' | "$dir/bin/fm-turnend-guard-cursor.sh") + [ "$out" = '{}' ] || fail "Cursor adapter should allow a repeated stop, got '$out'" + [ ! -s "$log" ] || fail "Cursor adapter must not invoke the shared guard after a follow-up" + pass "Cursor stop adapter maps exit 2 to one follow-up and prevents recursion" +} + +test_detects_cursor_agent_ancestry_without_generic_agent_match +test_tmux_liveness_requires_cursor_agent_argv0 +test_tmux_args_resolve_foreground_process_group +test_cursor_busy_signature_is_specific +test_cursor_primary_sessionstart_nudge_is_wired +test_cursor_lib_install_is_idempotent_and_cleanup_is_last_token_scoped +test_cursor_lib_cleanup_skips_on_held_lock_and_malformed_hooks_json +test_cursor_primary_hooks_are_wired +test_cursor_turnend_adapter_maps_followup_and_loop_guard + +echo "# all fm-cursor-harness tests passed" diff --git a/tests/fm-gotmp.test.sh b/tests/fm-gotmp.test.sh index 03f0afafa..3bc05d51b 100755 --- a/tests/fm-gotmp.test.sh +++ b/tests/fm-gotmp.test.sh @@ -65,6 +65,8 @@ make_fake_root() { ln -s "$ROOT/bin/fm-gate-refuse-lib.sh" "$fake/bin/fm-gate-refuse-lib.sh" # fm-pr-lib.sh: teardown uses its canonical task-ID validator for poll cleanup. ln -s "$ROOT/bin/fm-pr-lib.sh" "$fake/bin/fm-pr-lib.sh" + # fm-cursor-lib.sh: teardown sources it for Cursor turn-end auth cleanup. + ln -s "$ROOT/bin/fm-cursor-lib.sh" "$fake/bin/fm-cursor-lib.sh" # fm-guard.sh: stub (teardown calls it with `|| true`). cat > "$fake/bin/fm-guard.sh" <<'SH' #!/usr/bin/env bash @@ -165,6 +167,8 @@ test_teardown_skips_gracefully_without_tasktmp() { ln -s "$ROOT/bin/fm-gate-refuse-lib.sh" "$fake/bin/fm-gate-refuse-lib.sh" # fm-pr-lib.sh: teardown uses its canonical task-ID validator for poll cleanup. ln -s "$ROOT/bin/fm-pr-lib.sh" "$fake/bin/fm-pr-lib.sh" + # fm-cursor-lib.sh: teardown sources it for Cursor turn-end auth cleanup. + ln -s "$ROOT/bin/fm-cursor-lib.sh" "$fake/bin/fm-cursor-lib.sh" cat > "$fake/bin/fm-guard.sh" <<'SH' #!/usr/bin/env bash exit 0 diff --git a/tests/fm-secondmate-liveness.test.sh b/tests/fm-secondmate-liveness.test.sh index cb8b19d18..72ce461f7 100755 --- a/tests/fm-secondmate-liveness.test.sh +++ b/tests/fm-secondmate-liveness.test.sh @@ -344,6 +344,46 @@ test_sweep_never_acts_on_unverified_harness_dead_reading() { pass "sweep: an unverified harness makes a dead-looking probe inconclusive" } +test_sweep_trusts_cursor_dead_only_on_tmux() { + local w fb tmuxfb herdrfb log out + w=$(new_world sweep-cursor-backends) + fb=$(make_toolchain "$w"); tmuxfb=$(make_liveness_tmux "$w") + log="$w/calls.log"; : > "$log" + + # tmux backend: a bare-shell pane under a cursor secondmate is a verified + # confident-dead reading (docs/tmux-backend.md), so the sweep acts on it. + add_sm_home "$w" sm1 firstmate:fm-sm1 cursor + out=$(run_bootstrap "$tmuxfb:$fb" "$w/home" zsh "$log") + assert_contains "$(cat "$log")" "kill-window -t firstmate:fm-sm1" \ + "a confident-dead cursor secondmate on tmux should be killed for respawn" + rm -f "$w/home/state/sm1.meta" + + # herdr backend: herdr's native agent registry is unverified for + # cursor-agent, so its dead/no-agent reading must be downgraded to + # inconclusive instead of triggering a respawn of a possibly-live agent. + herdrfb="$w/herdrfb" + mkdir -p "$herdrfb" + cat > "$herdrfb/herdr" <<'SH' +#!/usr/bin/env bash +# Minimal herdr liveness fake: the pane exists but no agent is registered, +# which fm_backend_herdr_pane_agent_state maps to no-agent -> dead. +case "$1 $2" in + 'pane get') printf '{"result":{"pane":{"pane_id":"%s"}}}\n' "$3" ;; + 'agent get') printf '{"error":{"code":"agent_not_found"}}\n' >&2; exit 1 ;; + *) exit 1 ;; +esac +SH + chmod +x "$herdrfb/herdr" + add_sm_home "$w" sm2 hsess:p1 cursor + printf 'backend=herdr\n' >> "$w/home/state/sm2.meta" + : > "$log" + out=$(run_bootstrap "$herdrfb:$tmuxfb:$fb" "$w/home" zsh "$log") + assert_contains "$out" "SECONDMATE_LIVENESS: secondmate sm2: skipped: liveness probe inconclusive (backend=herdr)" \ + "a cursor dead reading on a non-tmux backend must downgrade to inconclusive" + [ ! -s "$log" ] || fail "a non-tmux cursor dead reading must never kill or respawn: $(cat "$log")" + pass "sweep: cursor dead verdicts are trusted on tmux and downgraded on unverified backends" +} + test_sweep_converges_no_retouch_once_alive() { local w fb tmuxfb log out1 out2 w=$(new_world sweep-idempotent) @@ -408,6 +448,7 @@ test_sweep_respawns_confirmed_dead_secondmate test_sweep_leaves_alive_secondmate_untouched test_sweep_never_acts_on_inconclusive_reading test_sweep_never_acts_on_unverified_harness_dead_reading +test_sweep_trusts_cursor_dead_only_on_tmux test_sweep_converges_no_retouch_once_alive test_sweep_skipped_under_detect_only test_sweep_noop_with_no_secondmate_meta diff --git a/tests/fm-sessionstart-nudge.test.sh b/tests/fm-sessionstart-nudge.test.sh index 10992880f..b9b111f4b 100755 --- a/tests/fm-sessionstart-nudge.test.sh +++ b/tests/fm-sessionstart-nudge.test.sh @@ -158,6 +158,12 @@ test_tracked_harness_registration() { assert_contains "$command" 'root=$(pwd -P)' "Codex SessionStart hook is not pwd-anchored" assert_contains "$command" 'fm-sessionstart-nudge.sh' "Codex SessionStart hook does not invoke the wrapper" + command=$(jq -r '.hooks.sessionStart[0].command // empty' "$ROOT/.cursor/hooks.json") + assert_contains "$command" 'CURSOR_PROJECT_DIR' "Cursor sessionStart hook must anchor through CURSOR_PROJECT_DIR" + assert_contains "$command" 'fm-sessionstart-nudge.sh' "Cursor sessionStart hook does not invoke the wrapper" + jq -e '.hooks.sessionStart[0].failClosed == false' "$ROOT/.cursor/hooks.json" >/dev/null \ + || fail "Cursor sessionStart hook must stay fail-open" + command=$(jq -r '.hooks.SessionStart[0].hooks[0].command' "$ROOT/.grok/hooks/fm-primary-sessionstart-nudge.json") # shellcheck disable=SC2016 assert_contains "$command" '${GROK_WORKSPACE_ROOT:-}' "Grok SessionStart hook lacks an inline-default workspace root" @@ -176,7 +182,7 @@ test_tracked_harness_registration() { assert_contains "$opencode_plugin" 'fm-sessionstart-nudge.sh' "OpenCode plugin does not invoke the wrapper" assert_contains "$opencode_plugin" 'promptAsync' "OpenCode plugin does not prompt the nudge turn" - pass "all five verified harnesses register the shared session-start nudge" + pass "all six verified harnesses register the shared session-start nudge" } test_genuine_primary_nudges diff --git a/tests/fm-spawn-dispatch-profile.test.sh b/tests/fm-spawn-dispatch-profile.test.sh index d549c9bce..af4a3bf59 100755 --- a/tests/fm-spawn-dispatch-profile.test.sh +++ b/tests/fm-spawn-dispatch-profile.test.sh @@ -84,7 +84,7 @@ run_spawn() { local home=$1 wt=$2 fakebin=$3 launchlog=$4 shift 4 : > "$launchlog" - FM_ROOT_OVERRIDE='' FM_HOME="$home" \ + HOME="$home" FM_ROOT_OVERRIDE='' FM_HOME="$home" \ FM_STATE_OVERRIDE="$home/state" FM_DATA_OVERRIDE="$home/data" \ FM_PROJECTS_OVERRIDE="$home/projects" FM_CONFIG_OVERRIDE="$home/config" \ FM_SPAWN_NO_GUARD=1 FM_FAKE_PANE_PATH="$wt" TMUX="fake,1,0" \ @@ -330,6 +330,41 @@ test_opencode_threads_model_and_ignores_effort_axis() { pass "opencode receives --model and omits the unsupported effort axis" } +test_cursor_threads_model_and_omits_effort_axis() { + local rec id out status launch fallback + id=profile-cursor-z18 + rec=$(make_spawn_case profile-cursor cursor "$id") + read_case_record "$rec" + + out=$(run_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$LAUNCH_LOG" "$id" "$PROJ_DIR" --model gpt-5.6-sol-high --effort high) + status=$? + expect_code 0 "$status" "cursor spawn with model and recorded effort should succeed" + assert_meta_profile "$HOME_DIR/state/$id.meta" cursor gpt-5.6-sol-high high + launch=$(cat "$LAUNCH_LOG") + assert_contains "$launch" "cursor-agent --force --approve-mcps --plugin-dir '$HOME_DIR/state/$id.cursor-plugin' --workspace '$WT_DIR' --model 'gpt-5.6-sol-high'" \ + "cursor launch did not thread force, approve-mcps, plugin, workspace, and model flags" + assert_not_contains "$launch" "--effort" "cursor launch must not invent a standalone effort flag" + assert_not_contains "$launch" "--reasoning-effort" "cursor launch must not pass another harness's reasoning flag" + assert_grep '"hooks":"hooks/hooks.json"' "$HOME_DIR/state/$id.cursor-plugin/.cursor-plugin/plugin.json" \ + "cursor task plugin manifest does not point at hooks/hooks.json" + assert_grep '"stop"' "$HOME_DIR/state/$id.cursor-plugin/hooks/hooks.json" \ + "cursor task plugin does not register a stop hook" + fallback=$(jq -r '.hooks.stop[0].command' "$HOME_DIR/state/$id.cursor-plugin/hooks/hooks.json") + assert_contains "$fallback" "$HOME_DIR/.cursor/hooks/fm-turn-end.sh" \ + "cursor task plugin stop hook must use the absolute fallback path" + assert_grep 'fm-turn-end.sh' "$HOME_DIR/.cursor/hooks.json" \ + "cursor fallback hook was not merged into the isolated user config" + assert_grep 'token=fm.' "$WT_DIR/.fm-cursor-turnend" \ + "cursor task worktree pointer is missing" + printf '%s' '{"workspace_roots":["/unrelated/project"]}' | sh -c "$fallback" >/dev/null + assert_absent "$HOME_DIR/state/$id.turn-ended" \ + "cursor fallback hook must no-op outside a token-scoped task worktree" + printf '%s' "$(jq -cn --arg root "$WT_DIR" '{workspace_roots: [$root]}')" | sh -c "$fallback" >/dev/null + assert_present "$HOME_DIR/state/$id.turn-ended" \ + "cursor fallback hook did not resolve the task token and touch turn-ended" + pass "cursor receives --model, records but omits effort, and wires plugin plus additive fallback hook" +} + test_pi_threads_model_and_max_effort() { local rec id out status launch id=profile-pi-z8 @@ -397,6 +432,7 @@ test_grok_threads_model_and_reasoning_effort test_grok_omits_invalid_max_reasoning_effort test_grok_omits_invalid_xhigh_reasoning_effort test_opencode_threads_model_and_ignores_effort_axis +test_cursor_threads_model_and_omits_effort_axis test_pi_threads_model_and_max_effort test_batch_forwards_shared_profile_flags test_active_dispatch_profile_does_not_block_secondmate_launch diff --git a/tests/fm-supervision-instructions.test.sh b/tests/fm-supervision-instructions.test.sh index c1072063a..c749a49c2 100755 --- a/tests/fm-supervision-instructions.test.sh +++ b/tests/fm-supervision-instructions.test.sh @@ -91,6 +91,18 @@ test_grok_command_sources_effective_config() { pass "grok rendered command sources the effective x-mode config" } +test_cursor_is_tracked_background_notify() { + local out + out=$("$RENDER" --harness cursor) + assert_contains "$out" "Mode: Cursor tracked background-notify supervision." "Cursor snippet missing background-notify mode" + assert_contains "$out" "block_until_ms: 0" "Cursor snippet missing tracked background task instruction" + assert_contains "$out" "bin/fm-watch-arm.sh" "Cursor snippet missing watcher arm" + assert_not_contains "$out" "foreground checkpoint" "Cursor must not use the Codex foreground checkpoint" + out=$("$RENDER" --harness cursor --repair-line) + assert_contains "$out" "Cursor tracked background Shell task" "Cursor repair line is not background-notify shaped" + pass "Cursor supervision uses tracked background Shell completion" +} + test_pi_snippet_uses_effective_extension_path() { local home out turnend watch home="$TMP_ROOT/pi-home" @@ -113,4 +125,5 @@ test_conditional_stanzas test_repair_lines test_grok_is_background_notify test_grok_command_sources_effective_config +test_cursor_is_tracked_background_notify test_pi_snippet_uses_effective_extension_path