From d6a06250b9435f0e2ffb302fd60d079ae87038a3 Mon Sep 17 00:00:00 2001 From: Jonathan Demirgian Date: Tue, 4 Aug 2026 22:18:39 +0000 Subject: [PATCH 1/3] feat: add cursor-agent (Composer 2.5) as a verified crew harness Onboard cursor-agent as a directly-driven crew harness (grok/kimi Shape A: interactive pane plus native beforeSubmitPrompt/stop user hooks), slotting into the existing adapter machinery with no cursor-special path. - fm-harness.sh: cursor process-ancestry detection. - fm-spawn.sh: launch template (--force --trust --model, no effort flag, no trailing command so the pane reports cursor-agent), pre-pane hook install, busy-gen arming, the per-task registry entry and worktree pointer, and a per-worktree .cursor/cli.json that neutralizes commit/PR agent attribution without editing the shared global cli-config.json. - fm-cursor-turnend-hook.sh (new): idempotent, foreign-preserving install of the firstmate-owned ~/.cursor/hooks.json entries plus a guarded hook that writes cursor-hook busy/idle events and dedupes the stop interrupt double-fire by generation_id. - fm-busy-lib.sh: cursor-hook semantic source and the open fm_busy_cursor_verified gate. - fm-composer-lib.sh and fm-tmux-lib.sh: the dim cursor composer glyph (U+2192). - bin/backends/tmux.sh and fm-session-lock-lib.sh: cursor-agent liveness and identity. - fm-teardown.sh: cursor pointer, registry entry, stop-dedupe dir, state token, and cli.json cleanup. - harness-adapters/SKILL.md, docs/verification/runtime-backends.md, AGENTS.md: verified cursor facts and the verified-adapter list. Tests: new tests/fm-cursor-harness.test.sh plus cursor cases in the composer, busy-wiring, and tmux-liveness suites. --- .agents/skills/harness-adapters/SKILL.md | 35 ++- AGENTS.md | 2 +- bin/backends/tmux.sh | 2 +- bin/fm-busy-lib.sh | 19 ++ bin/fm-composer-lib.sh | 11 +- bin/fm-cursor-turnend-hook.sh | 291 +++++++++++++++++ bin/fm-harness.sh | 8 +- bin/fm-session-lock-lib.sh | 7 +- bin/fm-spawn.sh | 83 ++++- bin/fm-teardown.sh | 29 +- bin/fm-tmux-lib.sh | 1 + docs/verification/runtime-backends.md | 22 ++ tests/fm-busy-adapter-wiring.test.sh | 23 ++ tests/fm-composer-ghost.test.sh | 23 +- tests/fm-composer-lib.test.sh | 8 +- tests/fm-cursor-harness.test.sh | 378 +++++++++++++++++++++++ tests/fm-tmux-agent-liveness.test.sh | 8 + 17 files changed, 929 insertions(+), 21 deletions(-) create mode 100755 bin/fm-cursor-turnend-hook.sh create mode 100755 tests/fm-cursor-harness.test.sh diff --git a/.agents/skills/harness-adapters/SKILL.md b/.agents/skills/harness-adapters/SKILL.md index 03735fefb1..5bbb561e98 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, pi-signed, grok, and kimi. +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, pi-signed, grok, kimi, and cursor. user-invocable: false metadata: internal: true @@ -127,6 +127,7 @@ The supported launch-profile flags below are verified locally; each row records | pi / pi-signed | `--model ` | `--thinking ` | Verified 2026-07-27 on Pi and pi-signed 0.82.0. Both expose the same accepted thinking levels and completed the same model-qualified max-thinking smoke. | | 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. | | kimi | `--model ` | none | Verified 2026-07-25 on Kimi Code CLI 0.29.1. | +| cursor | `--model ` | none | Verified 2026-08-04 on cursor-agent 2026.07.23-e383d2b. `--model composer-2.5` selects Composer 2.5 non-fast (the `-fast` variant is a separate model id). Effort is encoded in the model id, not a separate flag, so firstmate emits none and records the requested effort in meta. | The concrete `harness` field owns adapter identity independently of the model provider: `harness=pi` with `model=xai/grok-*` is Pi using xAI, not `harness=grok`, and does not require Grok CLI login; `harness=grok` remains the standalone Grok Build CLI adapter. No script resolves that split for you: establish which credential store a tuple reads from the discovery surfaces below plus `quota-axi auth --json`'s per-provider sources, and show that reasoning rather than inferring it from a harness, model, or source name. @@ -144,6 +145,7 @@ Use the discovery surface in the current authenticated environment because suppo | pi / pi-signed | Run the selected executable as ` --list-models [search]`; Pi's installed `docs/models.md` owns how built-in, extension-registered, and custom provider/model entries reach that list. | | grok | Run `grok models`, which lists the models available to the current Grok installation and account. | | kimi | Run `kimi provider list --json`, which lists the current provider and model configuration. | +| cursor | Run `cursor-agent --list-models` (or `cursor-agent models`), which lists the models available to the current Cursor account; `composer-2.5` and `composer-2.5-fast` are distinct entries. | For an unfamiliar harness or model namespace, establish support and provider identity from that harness's authoritative CLI help, model listing, or current documentation rather than guessing from a name or prefix. A listing that reaches the account and does not contain the model is concrete evidence the model is unsupported: block that candidate and quote the result. @@ -163,6 +165,7 @@ Natural language is acceptable if uncertain. - pi and pi-signed: 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) handles this through the structural composer reader; 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. - kimi: `/`, for example `/no-mistakes`. +- cursor: `/`, for example `/no-mistakes`; cursor's own slash menu is built-ins only (`/model`, `/ask`, `/plan`, ...), so `/no-mistakes` is a firstmate skill invoked by natural language unless a cursor skill is registered. Use natural language if the exact form is uncertain. ## Submission acknowledgement hazards @@ -397,3 +400,33 @@ The delivery-only spinner match covers the full moon-phase glyph set rather than Each Kimi crew worktree receives a gitignored `.fm-kimi-turnend` token pointer, and the global hook touches that task's `state/.turn-ended` only when the Stop payload's `cwd`, pointer, and registry entry all agree. A guarded silent hook cannot be verified from absence of effect, so prove invocation with an unguarded probe before concluding that the hook did not fire. The guarded turn-end signal remains a wake notification; standalone Kimi has no busy-state source until one is live-verified. + +## cursor (VERIFIED 2026-08-04, cursor-agent 2026.07.23-e383d2b, model composer-2.5 non-fast) + +Cursor Composer's CLI (`cursor-agent`), a multi-model gateway driven directly like grok/kimi (not ACP). +Launch with a positional prompt: `cursor-agent --force --trust --model composer-2.5 "$()"`. + +| Fact | Value | +|---|---| +| Busy state | Native user hooks `beforeSubmitPrompt` (busy) and `stop` (idle), live-verified to bracket a real interactive turn; a real semantic source like claude's, not a rendered-tail fallback. `bin/fm-busy-lib.sh` owns the `cursor-hook` source and its `fm_busy_cursor_verified` gate. | +| Exit command | `/exit` (or `/quit`); both fire `sessionEnd` and terminate the process. | +| Interrupt | single `Esc` (aborts the running turn and returns the draft to the composer). A single `Ctrl-C` at idle is a harmless no-op. | +| Skill invocation | `/` via natural language; cursor's slash menu is built-ins only, so `/no-mistakes` is invoked in prose. | +| Autonomy | `--force` (footer shows `Run Everything`); auto-approves every tool execution, verified fully unattended. `--yolo` is an alias; `--auto-review` is NOT fully unattended and must not be used for a crewmate. | +| Trust | `--trust` suppresses the workspace-trust dialog at launch in a fresh worktree, so no post-launch keystroke is needed. | +| Env marker | None verified as exported to child/tool processes; detection is process ancestry on the `cursor-agent` command name only. | +| Resume | `cursor-agent --resume [chatId]`, `--continue`, or the `resume`/`ls` subcommands. | +| Composer | Bordered box; the prompt glyph is `→` (U+2192) rendered dim (SGR 2), and the idle placeholder is `Add a follow-up`, also dim. | + +Turn-end and busy hook: cursor loads USER hooks from `~/.cursor/hooks.json` (schema version 1, entries are `{"command":...,"type":"command"}` directly in each event's array), which run from `~/.cursor` - NOT the worktree. +`fm-spawn` installs one firstmate-owned entry each for `beforeSubmitPrompt` and `stop` through `bin/fm-cursor-turnend-hook.sh`, plus a static guarded hook script and a private token registry under `~/.cursor/fm-turn-end.d/`, and it preserves the captain's own cursor hooks and other `hooks.json` keys. +Because user hooks run from `~/.cursor`, the guard keys on a `.fm-cursor-turnend` pointer found among the payload's `workspace_roots[]` (NOT cwd), so it is a silent no-op for every non-firstmate cursor session and for a worktree without a matching registry token. +An interrupted turn fires `stop` twice for one generation (`aborted` then `error`), so the hook dedupes `stop` by `generation_id` to enqueue exactly one wake; `beforeSubmitPrompt` and a completed `stop` fire once each. + +Launch-template rule: the launch command MUST let the shell exec `cursor-agent` as its sole/last command with no trailing `;` command. +The launcher execs node under `exec -a "$0"`, so with a clean exec the pane's `#{pane_current_command}` reports `cursor-agent` (which `bin/backends/tmux.sh`'s alive-set matches); any trailing shell command leaves the pane leader as `bash` and breaks liveness. + +Attribution (hard gate): cursor defaults `attribution.attributeCommitsToAgent` and `attributePRsToAgent` to `true` in the global `~/.cursor/cli-config.json`, producing `Co-authored-by: Cursor `, which violates `AGENTS.md`. +`fm-spawn` neutralizes this per worktree with a gitignored `.cursor/cli.json` project config (`{"attribution":{"attributeCommitsToAgent":false,"attributePRsToAgent":false}}`) that cursor reads by default and merges over the global (its `--disable-project-configs` flag documents that default read), so no destructive or racy edit of the shared global config is needed and the override is removed with the worktree. +The `→` composer glyph is handled by `bin/fm-composer-lib.sh` (a `→` agent-glyph arm) and `bin/fm-tmux-lib.sh` (the empty-box geometry proof), so a dim, idle cursor composer classifies empty rather than as pending input. +`docs/verification/runtime-backends.md` owns the dated live-verification evidence, and `data/cursor-verify/report.md` holds the raw probe transcript. diff --git a/AGENTS.md b/AGENTS.md index b599b50f88..e89d216246 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -164,7 +164,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`, `pi-signed`, `grok`, and `kimi`; never dispatch on an unverified adapter. +The verified harnesses are `claude`, `codex`, `opencode`, `pi`, `pi-signed`, `grok`, `kimi`, and `cursor`; never dispatch on an unverified adapter. If static `config/crew-harness` or `config/secondmate-harness` 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-harness.sh` owns static resolution, and `bin/fm-spawn.sh` owns launch flags and fail-closed validation. diff --git a/bin/backends/tmux.sh b/bin/backends/tmux.sh index 454f840594..5dddb91195 100644 --- a/bin/backends/tmux.sh +++ b/bin/backends/tmux.sh @@ -160,7 +160,7 @@ fm_backend_tmux_classify_process_name() { # [argv0] -> agent|shell|other base=${path##*/} base=${base#-} case "$base" in - *claude*|*codex*|*opencode*|*grok*|*kimi*|pi|pi-signed|pi-launcher|Pi) printf 'agent' ;; + *claude*|*codex*|*opencode*|*grok*|*kimi*|*cursor*|pi|pi-signed|pi-launcher|Pi) printf 'agent' ;; zsh|bash|sh|dash|ash|ksh|mksh|tcsh|csh|fish) printf 'shell' ;; *) if fm_harness_path_name "$path" >/dev/null || fm_harness_path_name "$argv0" >/dev/null; then diff --git a/bin/fm-busy-lib.sh b/bin/fm-busy-lib.sh index d12cebc304..6ead302194 100755 --- a/bin/fm-busy-lib.sh +++ b/bin/fm-busy-lib.sh @@ -34,6 +34,8 @@ # codex-hook, codex-appserver reserved: Codex, gated by # fm_busy_codex_semantic_source # kimi-wire, kimi-hook reserved: standalone Kimi, gated by fm_busy_kimi_verified +# cursor-hook cursor beforeSubmitPrompt (busy) and stop (idle) user +# hooks, gated by fm_busy_cursor_verified # Firstmate-owned sources accepted for every converted adapter: # fm-spawn the launch-brief turn seeded at spawn # fm-interrupt a firstmate-controlled interruption of the worker @@ -96,6 +98,19 @@ fm_busy_kimi_verified() { [ -n "$FM_BUSY_KIMI_VERIFIED_VERSIONS" ] } +# Cursor (cursor-agent / Composer) verification gate. Unlike Kimi and Codex this +# gate is OPEN: cursor's native beforeSubmitPrompt (busy) and stop (idle) user +# hooks were live-verified to bracket a real interactive turn on a +# firstmate-launched worker, including the interrupt path, on cursor-agent +# 2026.07.23-e383d2b (data/cursor-verify/report.md; the interrupt fires stop +# twice, aborted then error, for the same generation_id, so the installed hook +# dedupes stop by generation_id). fm-spawn arms and wires the cursor busy +# contract behind this gate, and the installed user hook writes cursor-hook +# busy/idle events through fm-busy-event.sh with the task's armed gen. +fm_busy_cursor_verified() { + return 0 +} + # fm_busy_codex_appserver_observable: capability/version negotiation for the # Codex app-server turn lifecycle. Returns 0 only when a pane worker's turns # are observable through the app-server protocol on the installed binary. @@ -177,6 +192,10 @@ fm_busy_sources_for_harness() { # fm_busy_kimi_verified || { printf ''; return 0; } adapter='kimi-wire kimi-hook' ;; + cursor*) + fm_busy_cursor_verified || { printf ''; return 0; } + adapter=cursor-hook + ;; *) printf ''; return 0 ;; esac printf '%s fm-spawn fm-interrupt fm-recovery' "$adapter" diff --git a/bin/fm-composer-lib.sh b/bin/fm-composer-lib.sh index 6e2509ec2c..8933a02fa9 100644 --- a/bin/fm-composer-lib.sh +++ b/bin/fm-composer-lib.sh @@ -185,14 +185,17 @@ fm_composer_classify_content() { # [idle_re] [idle_case] [ plain_content=${5:-$content} if [ "$bordered" != 1 ] && [ -z "$content" ] && [ -n "$plain_content" ]; then case "$plain_content" in - '❯'|'›') printf 'empty'; return 0 ;; + '❯'|'›'|'→') printf 'empty'; return 0 ;; *) printf 'unknown'; return 0 ;; esac fi # A bare prompt glyph on its own row. case "$content" in - '❯'|'›') + '❯'|'›'|'→') # Agent prompt glyph: a genuine empty agent composer, bordered or bare. + # `→` (U+2192) is cursor-agent's composer glyph, rendered dim (SGR 2) so + # fm_composer_strip_ghost normally removes it before this point; the arm + # keeps a surviving bare `→` from reading as a dead shell. printf 'empty'; return 0 ;; '>'|'$'|'%'|'#') # Shell prompt glyph: empty ONLY inside a composer box (the harness's own @@ -208,8 +211,8 @@ fm_composer_classify_content() { # [idle_re] [idle_case] [ fi # Strip a leading prompt glyph, then re-judge the remainder. case "$content" in - '❯ '*|'› '*|'> '*|'$ '*|'% '*|'# '*) content=${content#??} ;; - '❯'*|'›'*|'>'*|'$'*|'%'*|'#'*) content=${content#?} ;; + '❯ '*|'› '*|'→ '*|'> '*|'$ '*|'% '*|'# '*) content=${content#??} ;; + '❯'*|'›'*|'→'*|'>'*|'$'*|'%'*|'#'*) content=${content#?} ;; esac content="${content#"${content%%[![:space:]]*}"}" content="${content%"${content##*[![:space:]]}"}" diff --git a/bin/fm-cursor-turnend-hook.sh b/bin/fm-cursor-turnend-hook.sh new file mode 100755 index 0000000000..fb26fd5fed --- /dev/null +++ b/bin/fm-cursor-turnend-hook.sh @@ -0,0 +1,291 @@ +#!/usr/bin/env bash +# Install or remove Firstmate's guarded cursor-agent crew turn-end + busy hook. +# +# This command is the sole owner of the edit to $HOME/.cursor/hooks.json. That +# file is JSON, so it carries no comment markers the way Kimi's TOML region does; +# Firstmate's entries are instead identified structurally, by a command that runs +# the Firstmate-owned hook script fm-cursor-turnend.sh. install validates the +# existing hooks.json (a regular non-symlink file holding a JSON object), adds or +# replaces exactly one Firstmate entry for beforeSubmitPrompt (busy) and one for +# stop (turn-end/idle), and preserves every foreign hook and top-level key. +# remove excises only Firstmate's entries. A missing, malformed, symlinked, or +# non-object hooks.json is refused without a write. Writes are atomic and skipped +# when the serialized result is byte-identical, so a repeat install is a no-op. +# +# The installed hook always exits 0 and stays silent. Because user hooks run from +# $HOME/.cursor (NOT the worktree), it reads the task worktree from the payload's +# workspace_roots[], not cwd: it looks for a .fm-cursor-turnend pointer in one of +# those roots, and only when the pointer names a Firstmate-created token in +# $HOME/.cursor/fm-turn-end.d/ does it write the task's busy/idle event through +# fm-busy-event.sh and touch its turn-end marker. It dedupes stop by +# generation_id, because an interrupted cursor turn fires stop twice (aborted +# then error) for one generation. +# +# Usage: +# fm-cursor-turnend-hook.sh install +# fm-cursor-turnend-hook.sh remove +set -u + +case "${1:-}" in + install|remove) ACTION=$1 ;; + -h|--help) + sed -n '2,32{s/^# \{0,1\}//;p;}' "$0" + exit 0 + ;; + *) + printf 'usage: %s install|remove\n' "${0##*/}" >&2 + exit 2 + ;; +esac + +if [ -z "${HOME:-}" ]; then + printf 'fm-cursor-turnend-hook: refused: HOME is unset.\n' >&2 + exit 1 +fi +if ! command -v python3 >/dev/null 2>&1; then + printf 'fm-cursor-turnend-hook: refused: python3 is required to edit hooks.json.\n' >&2 + exit 1 +fi +if [ "$ACTION" = install ] && ! command -v jq >/dev/null 2>&1; then + printf 'fm-cursor-turnend-hook: refused: jq is required by the installed cursor turn-end hook.\n' >&2 + exit 1 +fi + +python3 - "$ACTION" "$HOME/.cursor" <<'PY' +import json +import os +import shutil +import stat +import sys +import tempfile + +ACTION = sys.argv[1] +CONFIG_DIR = sys.argv[2] +CONFIG = os.path.join(CONFIG_DIR, "hooks.json") +HOOK = os.path.join(CONFIG_DIR, "fm-cursor-turnend.sh") +REGISTRY = os.path.join(CONFIG_DIR, "fm-turn-end.d") +EVENTS = ("beforeSubmitPrompt", "stop") +# The structural identity of a Firstmate entry: its command runs this script. +HOOK_NAME = "fm-cursor-turnend.sh" + +HOOK_BYTES = b'''#!/usr/bin/env bash +# Firstmate cursor turn-end + busy hook. Managed by fm-cursor-turnend-hook.sh. +# Deliberately passive: every path is silent and exits zero. Guarded per task by +# a .fm-cursor-turnend pointer in one of the payload's workspace_roots and a +# matching token in $HOME/.cursor/fm-turn-end.d/. stop is deduped by +# generation_id to absorb cursor's interrupt double-fire (aborted then error). +set +e +exec >/dev/null 2>&1 +payload= +IFS= read -r payload || [ -n "$payload" ] || exit 0 +command -v jq >/dev/null 2>&1 || exit 0 +[ -n "${HOME:-}" ] || exit 0 +event=$(printf '%s' "$payload" | jq -er '.hook_event_name | strings' 2>/dev/null) || exit 0 +case "$event" in beforeSubmitPrompt|stop) : ;; *) exit 0 ;; esac +roots=$(printf '%s' "$payload" | jq -r '.workspace_roots[]? | strings' 2>/dev/null) || exit 0 +pointer= +while IFS= read -r root; do + [ -n "$root" ] || continue + case "$root" in /*) : ;; *) continue ;; esac + if [ -f "$root/.fm-cursor-turnend" ]; then pointer="$root/.fm-cursor-turnend"; break; fi +done </dev/null || [ -n "$first" ] || exit 0 +case "$first" in token=*) token=${first#token=} ;; *) exit 0 ;; esac +case "$token" in fm.????????????) : ;; *) exit 0 ;; esac +case "$token" in *[!A-Za-z0-9._-]*) exit 0 ;; esac +reg="$HOME/.cursor/fm-turn-end.d/$token" +[ -f "$reg" ] || exit 0 +turnend= statedir= taskid= gen= busyevent= +while IFS= read -r line; do + case "$line" in + turnend=*) turnend=${line#turnend=} ;; + statedir=*) statedir=${line#statedir=} ;; + id=*) taskid=${line#id=} ;; + gen=*) gen=${line#gen=} ;; + busyevent=*) busyevent=${line#busyevent=} ;; + esac +done < "$reg" +case "$turnend" in /*.turn-ended) : ;; *) turnend= ;; esac +case "$statedir" in /*) : ;; *) statedir= ;; esac +case "$busyevent" in /*fm-busy-event.sh) : ;; *) busyevent= ;; esac +case "$gen" in ''|*[!A-Za-z0-9._-]*) gen= ;; esac +case "$taskid" in ''|*[!A-Za-z0-9._-]*) taskid= ;; esac +apply_busy() { # + [ -n "$busyevent" ] && [ -n "$statedir" ] && [ -n "$taskid" ] && [ -n "$gen" ] || return 0 + [ -x "$busyevent" ] || return 0 + "$busyevent" apply "$statedir" "$taskid" "$1" --gen "$gen" --source cursor-hook --event "$2" 2>/dev/null || true +} +case "$event" in + beforeSubmitPrompt) + apply_busy busy before-submit-prompt + ;; + stop) + genid=$(printf '%s' "$payload" | jq -er '.generation_id | strings' 2>/dev/null) || genid= + case "$genid" in *[!A-Za-z0-9._-]*) genid= ;; esac + if [ -n "$genid" ]; then + stops="$reg.stops" + mkdir -p "$stops" 2>/dev/null || true + mkdir "$stops/$genid" 2>/dev/null || exit 0 + fi + [ -n "$turnend" ] && touch -- "$turnend" 2>/dev/null || true + apply_busy idle stop + ;; +esac +exit 0 +''' + + +def refuse(reason): + print(f"fm-cursor-turnend-hook: refused: {reason}", file=sys.stderr) + raise SystemExit(1) + + +def regular_not_symlink(path, label): + try: + info = os.lstat(path) + except FileNotFoundError: + return None + if stat.S_ISLNK(info.st_mode) or not stat.S_ISREG(info.st_mode): + refuse(f"{label} is not a regular non-symlink file at {path}.") + return info + + +def our_command(): + # A single-quoted absolute path is safe for the shell cursor runs the + # command with, and stable across Firstmate homes because HOME is shared. + quoted = "'" + HOOK.replace("'", "'\\''") + "'" + return "bash " + quoted + + +def is_ours(entry): + return ( + isinstance(entry, dict) + and isinstance(entry.get("command"), str) + and HOOK_NAME in entry["command"] + ) + + +def load_config(): + info = regular_not_symlink(CONFIG, "cursor hooks.json") + if info is None: + return None, {} + with open(CONFIG, "rb") as stream: + raw = stream.read() + if raw.strip() == b"": + return raw, {} + try: + parsed = json.loads(raw.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as error: + refuse(f"hooks.json is not valid JSON: {error}.") + if not isinstance(parsed, dict): + refuse("hooks.json is not a JSON object.") + hooks = parsed.get("hooks") + if hooks is not None and not isinstance(hooks, dict): + refuse("hooks.json has an unexpected non-object 'hooks' value.") + for event in EVENTS: + existing = (hooks or {}).get(event) + if existing is not None and not isinstance(existing, list): + refuse(f"hooks.json has an unexpected non-array '{event}' hook list.") + return raw, parsed + + +def serialize(obj): + return (json.dumps(obj, indent=2, sort_keys=True) + "\n").encode("utf-8") + + +def atomic_write(path, data, mode): + fd, temporary = tempfile.mkstemp(prefix=f".{os.path.basename(path)}.", dir=os.path.dirname(path)) + try: + os.fchmod(fd, mode) + with os.fdopen(fd, "wb") as stream: + fd = -1 + stream.write(data) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, path) + except Exception: + if fd >= 0: + os.close(fd) + try: + os.unlink(temporary) + except FileNotFoundError: + pass + raise + + +def ensure_hook_script(): + existing = regular_not_symlink(HOOK, "Firstmate hook script") + need_write = True + if existing is not None: + with open(HOOK, "rb") as stream: + need_write = stream.read() != HOOK_BYTES + if not need_write and stat.S_IMODE(existing.st_mode) != 0o700: + need_write = True + if need_write: + atomic_write(HOOK, HOOK_BYTES, 0o700) + + +try: + if not os.path.isdir(CONFIG_DIR) or os.path.islink(CONFIG_DIR): + refuse(f"cursor config directory is missing or unexpected at {CONFIG_DIR}.") + raw, parsed = load_config() + + if ACTION == "install": + # Registry directory the installed hook resolves task tokens through. + if os.path.lexists(REGISTRY): + info = os.lstat(REGISTRY) + if stat.S_ISLNK(info.st_mode) or not stat.S_ISDIR(info.st_mode): + refuse(f"Firstmate registry is not a regular directory at {REGISTRY}.") + os.makedirs(REGISTRY, mode=0o700, exist_ok=True) + os.chmod(REGISTRY, 0o700) + ensure_hook_script() + + candidate = dict(parsed) + candidate.setdefault("version", 1) + hooks = dict(candidate.get("hooks") or {}) + command = our_command() + for event in EVENTS: + kept = [e for e in list(hooks.get(event, [])) if not is_ours(e)] + kept.append({"command": command, "type": "command"}) + hooks[event] = kept + candidate["hooks"] = hooks + data = serialize(candidate) + mode = stat.S_IMODE(os.lstat(CONFIG).st_mode) if raw is not None else 0o644 + if raw != data: + atomic_write(CONFIG, data, mode) + else: + if raw is not None: + candidate = dict(parsed) + hooks = dict(candidate.get("hooks") or {}) + for event in EVENTS: + if event in hooks: + kept = [e for e in list(hooks.get(event, [])) if not is_ours(e)] + if kept: + hooks[event] = kept + else: + del hooks[event] + candidate["hooks"] = hooks + # If nothing but Firstmate's own scaffolding remains, remove the file + # so a home Firstmate created is left as it was found (absent). + leftover_keys = set(candidate.keys()) - {"version", "hooks"} + if not hooks and not leftover_keys: + os.unlink(CONFIG) + else: + data = serialize(candidate) + if raw != data: + atomic_write(CONFIG, data, stat.S_IMODE(os.lstat(CONFIG).st_mode)) + if os.path.lexists(HOOK): + regular_not_symlink(HOOK, "Firstmate hook script") + os.unlink(HOOK) + if os.path.lexists(REGISTRY): + info = os.lstat(REGISTRY) + if stat.S_ISLNK(info.st_mode) or not stat.S_ISDIR(info.st_mode): + refuse(f"Firstmate registry is not a regular directory at {REGISTRY}.") + shutil.rmtree(REGISTRY) +except OSError as error: + refuse(f"filesystem operation failed: {error}.") +PY diff --git a/bin/fm-harness.sh b/bin/fm-harness.sh index 824b95804d..ff3a26b93d 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|pi-signed|grok|kimi|unknown +# Usage: fm-harness.sh print own harness: claude|codex|opencode|pi|pi-signed|grok|kimi|cursor|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 @@ -54,6 +54,11 @@ detect_own() { *opencode*) echo opencode; return ;; *grok*) echo grok; return ;; kimi) echo kimi; return ;; + # cursor-agent's launcher execs node under `exec -a "$0"`, so the command + # name a crew ancestor exposes is the cursor-agent basename. No verified + # env marker is exported to child/tool processes (the wrapper sets + # CURSOR_INVOKED_AS only in its own process), so detection is ancestry-only. + *cursor*) echo cursor; return ;; pi-signed) echo pi; return ;; pi) echo pi; return ;; node*|python*) @@ -64,6 +69,7 @@ detect_own() { *codex*) echo codex; return ;; *opencode*) echo opencode; return ;; *grok*) echo grok; return ;; + *cursor*) echo cursor; return ;; *" pi "*|*/pi) echo pi; return ;; esac ;; esac diff --git a/bin/fm-session-lock-lib.sh b/bin/fm-session-lock-lib.sh index 0706b664c8..31623e3686 100644 --- a/bin/fm-session-lock-lib.sh +++ b/bin/fm-session-lock-lib.sh @@ -9,13 +9,16 @@ # This file is sourced by scripts and has no side effects on source. # Known harness command names; extend when a new adapter is verified. -FM_HARNESS_RE='claude|codex|opencode|grok|kimi|^pi$|^pi-signed$' +# cursor's command is `cursor-agent`; match it in full rather than a bare +# `cursor` so a firstmate script basename such as fm-cursor-turnend-hook.sh is +# never mistaken for the harness process. +FM_HARNESS_RE='claude|codex|opencode|grok|kimi|cursor-agent|^pi$|^pi-signed$' # The same harnesses as exact executable names. Keep in sync with # FM_HARNESS_RE. Used only for the stricter path evidence below, where the # loose regex would also match ordinary firstmate paths such as # bin/fm-claude-stop-autoarm.sh. -FM_HARNESS_NAMES=(claude codex opencode grok kimi pi-signed pi) +FM_HARNESS_NAMES=(claude codex opencode grok kimi cursor-agent pi-signed pi) # Print the exact harness name carried by executable path $1 - its own basename # or any directory component - or return 1. diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index f1ad34bdd9..36728093e2 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -133,6 +133,10 @@ # a firstmate-owned global hook and registry, and a gitignored per-task pointer. # 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. +# cursor uses firstmate-owned entries in $HOME/.cursor/hooks.json plus a global +# registry, a gitignored .fm-cursor-turnend worktree pointer and a state token, +# and a gitignored .cursor/cli.json that neutralizes commit/PR agent attribution +# for that worktree only. # On success prints: spawned harness= kind= [mode= yolo=] window= worktree= # A ship task records the explicit mode/yolo it was passed; a secondmate spawn records # mode=secondmate, yolo=off, home=, and projects=; a scout records neither, and both the @@ -376,7 +380,7 @@ spawn_remote_secondmate() { harness=$("$FM_ROOT/bin/fm-harness.sh" secondmate) fi case "$harness" in - claude|codex|opencode|pi|pi-signed|grok|kimi) ;; + claude|codex|opencode|pi|pi-signed|grok|kimi|cursor) ;; *) fm_lock_release "$registry_lock" || true fm_lock_release "$SPAWN_TASK_LOCK" || true @@ -783,7 +787,7 @@ FIRSTMATE_HOME= if [ "$KIND" = secondmate ]; then case "${POS[1]:-}" in - ''|claude|codex|opencode|pi|pi-signed|grok|kimi) + ''|claude|codex|opencode|pi|pi-signed|grok|kimi|cursor) ARG3=${POS[1]:-} ;; *' '*) @@ -849,6 +853,16 @@ launch_template() { # Its turn-end signal is a globally configured Stop hook plus a guarded # per-task worktree token, so no launch placeholder belongs here. kimi) printf '%s' '__KIMIBIN__ __MODELFLAG__--auto' ;; + # cursor-agent (Cursor Composer): a positional prompt starts the supervised + # interactive TUI. --force auto-approves every tool execution (verified fully + # unattended) and --trust pre-trusts the fresh worktree so no post-launch + # keystroke is needed. There is NO effort flag (cursor encodes effort in the + # model id, not a separate flag), so no __EFFORTFLAG__ placeholder. Its + # turn-end and busy signals are native user hooks installed below, not part + # of the launch command. The template MUST end with cursor-agent as the + # shell's sole/last command (no trailing `;`) so the shell execs it and the + # pane reports `cursor-agent` for liveness (data/cursor-verify/report.md). + cursor) printf '%s' 'cursor-agent --force --trust __MODELFLAG__"$(__OPINPUT__ encode launch-brief < __BRIEF__)"' ;; *) return 1 ;; esac } @@ -961,7 +975,7 @@ model_flag_for_harness() { local harness=$1 model=$2 [ -n "$model" ] && [ "$model" != default ] || return 0 case "$harness" in - claude|codex|opencode|pi|pi-signed|grok|kimi) + claude|codex|opencode|pi|pi-signed|grok|kimi|cursor) printf -- '--model %s ' "$(shell_quote "$model")" ;; esac @@ -1021,6 +1035,17 @@ case "$LAUNCH" in ;; esac +# cursor installs its firstmate-owned user hook (~/.cursor/hooks.json entries + +# the guarded fm-cursor-turnend.sh script) before pane creation, so an unsafe or +# surprising hooks.json refuses the spawn rather than launching an unmonitored +# worker. The install is idempotent and preserves the captain's own cursor hooks. +if [ "$HARNESS" = cursor ] && [ "$KIND" != secondmate ]; then + "$FM_ROOT/bin/fm-cursor-turnend-hook.sh" install || { + echo "error: refusing cursor spawn because the global turn-end hook could not be installed safely" >&2 + exit 1 + } +fi + json_escape() { printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g' } @@ -1789,6 +1814,18 @@ if [ "$KIND" != secondmate ]; then exit 1 } ;; + cursor*) + # cursor has a live-verified semantic source (its beforeSubmitPrompt/stop + # user hooks), so it arms the busy contract like the other verified + # adapters. The gate stays consulted so a future close of it (a broken + # installed version) would fall back to unknown rather than a stale busy. + if fm_busy_cursor_verified; then + BUSY_GEN=$("$FM_ROOT/bin/fm-busy-event.sh" arm "$STATE_REAL" "$ID") || { + echo "error: failed to arm the busy-state contract for $ID" >&2 + exit 1 + } + fi + ;; kimi*) # Standalone Kimi stays unknown until fm_busy_kimi_verified opens on a # live-verified installed version (bin/fm-busy-lib.sh owns the gate and @@ -1983,6 +2020,46 @@ EOF printf 'token=%s\n' "${auth_file##*/}" > "$WT/.fm-kimi-turnend" exclude_path '.fm-kimi-turnend' ;; + cursor*) + # cursor's native beforeSubmitPrompt/stop user hooks are global and static + # (installed above by fm-cursor-turnend-hook.sh), so all per-task data lives + # in a private registry entry the hook resolves from the payload. User hooks + # run from ~/.cursor, so the guard keys on a .fm-cursor-turnend pointer found + # among the payload's workspace_roots[], not cwd. The registry entry carries + # this task's turn-end marker, state dir, id, busy gen, and this home's + # fm-busy-event.sh path so the shared hook writes cursor-hook busy/idle + # events for the right incarnation. The hook is a guarded no-op for every + # non-firstmate cursor session and for a worktree without a matching token. + CURSOR_AUTH_DIR="$HOME/.cursor/fm-turn-end.d" + mkdir -p "$CURSOR_AUTH_DIR" + old_umask=$(umask) + umask 077 + auth_file=$(mktemp "$CURSOR_AUTH_DIR/fm.XXXXXXXXXXXX") + umask "$old_umask" + { + printf 'turnend=%s\n' "$TURNEND" + printf 'statedir=%s\n' "$STATE_REAL" + printf 'id=%s\n' "$ID" + printf 'gen=%s\n' "$BUSY_GEN" + printf 'busyevent=%s\n' "$FM_ROOT/bin/fm-busy-event.sh" + } > "$auth_file" + printf '%s\n' "${auth_file##*/}" > "$STATE/$ID.cursor-turnend-token" + printf 'token=%s\n' "${auth_file##*/}" > "$WT/.fm-cursor-turnend" + exclude_path '.fm-cursor-turnend' + # Attribution neutralization (AGENTS.md: never add an agent name as a commit + # co-author). cursor defaults attribution.attributeCommitsToAgent and + # attributePRsToAgent to true in the GLOBAL ~/.cursor/cli-config.json, which + # cursor rewrites at runtime and which concurrent workers share. Instead of a + # destructive or racy global edit, drop a per-worktree project config + # (.cursor/cli.json) that cursor reads by default and merges over the global + # (proven by cursor's own --disable-project-configs flag: "Ignore + # .cursor/cli.json files"), setting both flags false for this worktree only. + # It is gitignored and removed with the worktree, so no restore is needed and + # concurrent cursor tasks never race. + mkdir -p "$WT/.cursor" + printf '%s\n' '{"version":1,"attribution":{"attributeCommitsToAgent":false,"attributePRsToAgent":false}}' > "$WT/.cursor/cli.json" + exclude_path '.cursor/cli.json' + ;; esac fi diff --git a/bin/fm-teardown.sh b/bin/fm-teardown.sh index 14c8987153..4e1101e3a7 100755 --- a/bin/fm-teardown.sh +++ b/bin/fm-teardown.sh @@ -487,6 +487,16 @@ remove_kimi_turnend_auth() { rm -f "$hooks_dir/$token" } +remove_cursor_turnend_auth() { + local state_dir=$1 id=$2 token hooks_dir + token=$(cat "$state_dir/$id.cursor-turnend-token" 2>/dev/null || true) + case "$token" in ''|*[!A-Za-z0-9._-]*) return 0 ;; esac + hooks_dir="$HOME/.cursor/fm-turn-end.d" + # Remove the registry entry and its per-generation stop-dedupe directory. + rm -f "$hooks_dir/$token" + rm -rf "$hooks_dir/$token.stops" +} + retire_busy_state() { local state_dir=$1 id=$2 gen=${3:-} if [ -n "$gen" ]; then @@ -1617,14 +1627,16 @@ cleanup_firstmate_home_children() { 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" "$child_wt/.fm-kimi-turnend" + "$child_wt/.fm-grok-turnend" "$child_wt/.fm-kimi-turnend" \ + "$child_wt/.fm-cursor-turnend" "$child_wt/.cursor/cli.json" 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/.opencode/plugins/fm-busy-state.js" \ - "$child_wt/.fm-grok-turnend" "$child_wt/.fm-kimi-turnend" + "$child_wt/.fm-grok-turnend" "$child_wt/.fm-kimi-turnend" \ + "$child_wt/.fm-cursor-turnend" "$child_wt/.cursor/cli.json" 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 : @@ -1641,6 +1653,7 @@ cleanup_firstmate_home_children() { fi remove_grok_turnend_auth "$sub_state" "$child_id" remove_kimi_turnend_auth "$sub_state" "$child_id" + remove_cursor_turnend_auth "$sub_state" "$child_id" remove_pr_poll_artifacts "$sub_state" "$child_id" || return 1 child_busy_gen=$(meta_value "$child_meta" busy_gen) if [ -z "$child_busy_gen" ]; then @@ -1649,7 +1662,8 @@ cleanup_firstmate_home_children() { retire_busy_state "$sub_state" "$child_id" "$child_busy_gen" || 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" "$sub_state/$child_id.kimi-turnend-token" + "$sub_state/$child_id.grok-turnend-token" "$sub_state/$child_id.kimi-turnend-token" \ + "$sub_state/$child_id.cursor-turnend-token" done } @@ -1791,7 +1805,8 @@ if [ "$BACKEND" = orca ] && [ "$KIND" != secondmate ]; then fi rm -f "$WT/.claude/settings.local.json" "$WT/.opencode/plugins/fm-turn-end.js" \ "$WT/.opencode/plugins/fm-busy-state.js" \ - "$WT/.fm-grok-turnend" "$WT/.fm-kimi-turnend" + "$WT/.fm-grok-turnend" "$WT/.fm-kimi-turnend" \ + "$WT/.fm-cursor-turnend" "$WT/.cursor/cli.json" 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" @@ -1804,7 +1819,8 @@ elif [ -d "$WT" ] && [ "$KIND" != secondmate ]; then 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" "$WT/.fm-kimi-turnend" + "$WT/.fm-grok-turnend" "$WT/.fm-kimi-turnend" \ + "$WT/.fm-cursor-turnend" "$WT/.cursor/cli.json" # 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 @@ -1892,6 +1908,7 @@ if [ "$KIND" = secondmate ]; then fi remove_grok_turnend_auth "$STATE" "$ID" remove_kimi_turnend_auth "$STATE" "$ID" +remove_cursor_turnend_auth "$STATE" "$ID" 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. @@ -1900,7 +1917,7 @@ remove_pr_poll_artifacts "$STATE" "$ID" || exit 1 retire_busy_state "$STATE" "$ID" "$BUSY_GEN" || exit 1 rm -f "$STATE/$ID.status" "$STATE/$ID.turn-ended" "$STATE/$ID.meta" \ "$STATE/$ID.pi-ext.ts" "$STATE/$ID.grok-turnend-token" \ - "$STATE/$ID.kimi-turnend-token" + "$STATE/$ID.kimi-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 e8284ba1e0..08de034242 100755 --- a/bin/fm-tmux-lib.sh +++ b/bin/fm-tmux-lib.sh @@ -171,6 +171,7 @@ fm_tmux_composer_geometry_spaces() { # -> spaces '>'*) content=${content/>/ } ;; '❯'*) content=${content/❯/ } ;; '›'*) content=${content/›/ } ;; + '→'*) content=${content/→/ } ;; esac content=$(printf '%s' "$content" | LC_ALL=C sed 's/[!-~]/ /g') case "$content" in diff --git a/docs/verification/runtime-backends.md b/docs/verification/runtime-backends.md index 544c8526fd..7d5d77fc9e 100644 --- a/docs/verification/runtime-backends.md +++ b/docs/verification/runtime-backends.md @@ -135,6 +135,28 @@ tests/fm-tmux-submit-busy.test.sh Expected structural matrix: real text on any content row is pending; all-empty complete boxes are empty; unreadable, incomplete, or unsafe boxes are unknown; and non-bordered panes retain cursor-row compatibility. Expected submit matrix: proven pending plus busy is accepted as queued; proven pending plus idle remains pending; ambiguous pending is never converted by the busy exception; and only a proven empty composer succeeds directly. +### cursor (2026-08-04, cursor-agent 2026.07.23-e383d2b) + +Cursor Composer was verified with an interactive worker launched in a separate pane and an unguarded probe hook at `~/.cursor/hooks.json`; the raw probe transcript is `data/cursor-verify/report.md` and its `probe-log-snapshot.txt`. +The confirmed facts the adapter depends on: + +- Liveness: a worker launched so the shell execs `cursor-agent` as its sole command reports `#{pane_current_command}` = `cursor-agent`, stable at idle and after a turn, so `bin/backends/tmux.sh`'s `*cursor*` alive-set arm matches; a trailing shell command instead leaves the pane leader as `bash`, which is why the launch template ends with `cursor-agent`. +- Busy/turn-end: the native `beforeSubmitPrompt` (busy) and `stop` (turn-end/idle) user hooks fire for the interactive worker, carry `workspace_roots[]`, `generation_id`, and `hook_event_name`, and an interrupted turn fires `stop` twice for one `generation_id` (`aborted` then `error`), so the installed hook dedupes `stop` by `generation_id`. +- Attribution: the global `~/.cursor/cli-config.json` defaults `attributeCommitsToAgent`/`attributePRsToAgent` to `true`; cursor reads a per-project `.cursor/cli.json` by default (its `--disable-project-configs` flag documents that default read) and merges it over the global, so a gitignored per-worktree `.cursor/cli.json` disabling both flags neutralizes attribution without a global edit. +- Composer: the idle composer is a bordered box whose `→` (U+2192) glyph and `Add a follow-up` placeholder are both dim (SGR 2). + +CI-enforced portable regressions: + +```sh +tests/fm-cursor-harness.test.sh # launch template, hook guard + busy lifecycle + stop dedupe, teardown, attribution, detection, lock +tests/fm-busy-adapter-wiring.test.sh # cursor-hook is the only trusted cursor source +tests/fm-composer-ghost.test.sh # dim → glyph + Add a follow-up idle composer reads empty +tests/fm-composer-lib.test.sh # → agent-glyph classification +tests/fm-tmux-agent-liveness.test.sh # a cursor-agent foreground process classifies alive +``` + +The live trailer-free-commit proof (a cursor worker committing with no `Co-authored-by: Cursor` trailer) requires an interactive worker and is captured during the supervised cursor test-drive, since a crewmate cannot launch the cursor TUI in its own pane. + ### Cleanup endpoint identity The cleanup identity boundary was validated on 2026-07-28 with tmux 3.6a and metadata fixtures for every supported backend. diff --git a/tests/fm-busy-adapter-wiring.test.sh b/tests/fm-busy-adapter-wiring.test.sh index 70f222010b..6dfca9adea 100755 --- a/tests/fm-busy-adapter-wiring.test.sh +++ b/tests/fm-busy-adapter-wiring.test.sh @@ -342,10 +342,33 @@ test_kimi_and_grok_install_no_unverified_wiring() { pass "kimi and grok install no unverified semantic wiring and classify through their own gates" } +test_cursor_trusts_only_its_verified_hook_source() { + local state trusted out gen + state="$TMP_ROOT/cursor-gate/state" + mkdir -p "$state" + fm_busy_cursor_verified || fail "cursor's verification gate must be open" + trusted=$(fm_busy_sources_for_harness cursor) + [ "$trusted" = "cursor-hook fm-spawn fm-interrupt fm-recovery" ] \ + || fail "cursor must trust exactly its hook source plus the firstmate-owned sources, got '$trusted'" + # A record written by another adapter's source must not classify a cursor task. + "$ROOT/bin/fm-busy-event.sh" arm "$state" gate-c >/dev/null + gen=$(fm_busy_current_gen "$state" gate-c) + "$ROOT/bin/fm-busy-event.sh" apply "$state" gate-c busy --gen "$gen" --source claude-hook --event x >/dev/null + out=$(fm_busy_classify tmux fake:w cursor gate-c "$state") + [ "$out" = "unknown source-mismatch" ] \ + || fail "a foreign source must not classify a cursor task, got '$out'" + # Its own hook source is trusted. + "$ROOT/bin/fm-busy-event.sh" apply "$state" gate-c idle --gen "$gen" --source cursor-hook --event stop >/dev/null + out=$(fm_busy_classify tmux fake:w cursor gate-c "$state") + [ "$out" = "idle cursor-hook" ] || fail "cursor-hook must classify a cursor task, got '$out'" + pass "cursor trusts only its verified cursor-hook source and the firstmate-owned sources" +} + test_pi_extension_semantic_lifecycle test_pi_extension_serializes_settle_before_next_start test_pi_extension_stale_incarnation_rejected test_kimi_and_grok_install_no_unverified_wiring +test_cursor_trusts_only_its_verified_hook_source test_opencode_plugin_semantic_lifecycle test_claude_hooks_semantic_lifecycle test_claude_hooks_stale_incarnation_harmless diff --git a/tests/fm-composer-ghost.test.sh b/tests/fm-composer-ghost.test.sh index 7249574ea3..521354984c 100755 --- a/tests/fm-composer-ghost.test.sh +++ b/tests/fm-composer-ghost.test.sh @@ -252,6 +252,22 @@ test_dark_truecolor_bare_shell_prompt_is_unknown() { pass "fm_tmux_composer_state: dark truecolor shell prompts read unknown" } +test_cursor_dim_glyph_and_ghost_composer_is_not_pending() { + local dir fb capture + dir="$TMP_ROOT/cursor-ghost"; mkdir -p "$dir" + fb=$(make_fake_tmux "$dir") + capture="$dir/styled.txt" + # cursor's pristine composer is a bordered box whose → prompt glyph and + # "Add a follow-up" placeholder are both dim (SGR 2). The shared ghost owner + # drops the whole run, so the structurally-read box is empty, not pending. + printf '╭────────────────────╮\n│ \033[2m→ Add a follow-up\033[0m │\n╰────────────────────╯\n' > "$capture" + if PATH="$fb:$PATH" FM_FAKE_STYLED="$capture" FM_FAKE_CY=1 \ + fm_pane_input_pending "fakepane"; then + fail "cursor's dim → glyph + Add a follow-up ghost composer falsely read as pending" + fi + pass "fm_pane_input_pending: cursor's dim → glyph and Add a follow-up placeholder is NOT pending" +} + test_real_text_with_trailing_ghost_is_pending() { local dir fb capture dir="$TMP_ROOT/mixed"; mkdir -p "$dir" @@ -473,13 +489,16 @@ test_all_tmux_harness_composers_share_classification() { dir="$TMP_ROOT/all-harness-composers"; mkdir -p "$dir" fb=$(make_fake_tmux "$dir") capture="$dir/styled.txt" - for harness in claude codex opencode pi pi-signed grok; do + for harness in claude codex opencode pi pi-signed grok cursor; do case "$harness" in claude) printf '╭────────────╮\n│ ❯ \033[2mtry\033[0m │\n╰────────────╯\n' > "$capture" ;; codex) printf '╭────────────╮\n│ › \033[2mtip\033[0m │\n╰────────────╯\n' > "$capture" ;; opencode) printf '╭────────────╮\n│ > │\n╰────────────╯\n' > "$capture" ;; pi|pi-signed) printf '╭────────────╮\n│ │\n╰────────────╯\n' > "$capture" ;; grok) printf '╭────────────╮\n│ ❯ \033[38;2;50;47;70mType\033[0m │\n╰────────────╯\n' > "$capture" ;; + # cursor renders its whole idle composer - the → glyph and the + # "Add a follow-up" placeholder - dim (SGR 2), so ghost stripping empties it. + cursor) printf '╭────────────────────╮\n│ \033[2m→ Add a follow-up\033[0m │\n╰────────────────────╯\n' > "$capture" ;; esac out=$(PATH="$fb:$PATH" FM_FAKE_STYLED="$capture" FM_FAKE_CY=1 \ fm_tmux_composer_state "fakepane") @@ -488,6 +507,7 @@ test_all_tmux_harness_composers_share_classification() { case "$harness" in claude|grok) printf '╭────────────╮\n│ ❯ fix │\n╰────────────╯\n' > "$capture" ;; codex) printf '╭────────────╮\n│ › fix │\n╰────────────╯\n' > "$capture" ;; + cursor) printf '╭────────────╮\n│ → fix │\n╰────────────╯\n' > "$capture" ;; opencode|pi|pi-signed) printf '╭────────────╮\n│ > fix │\n╰────────────╯\n' > "$capture" ;; esac out=$(PATH="$fb:$PATH" FM_FAKE_STYLED="$capture" FM_FAKE_CY=1 \ @@ -606,6 +626,7 @@ test_normal_text_still_pending test_colored_text_with_2_payload_still_pending test_dark_truecolor_ghost_only_composer_is_not_pending test_dark_truecolor_bare_shell_prompt_is_unknown +test_cursor_dim_glyph_and_ghost_composer_is_not_pending test_real_text_with_trailing_ghost_is_pending test_two_row_composer_reads_text_above_empty_cursor_row test_wrapped_composer_reads_all_content_rows diff --git a/tests/fm-composer-lib.test.sh b/tests/fm-composer-lib.test.sh index cf93cf36a0..c677d189ad 100755 --- a/tests/fm-composer-lib.test.sh +++ b/tests/fm-composer-lib.test.sh @@ -77,9 +77,15 @@ test_agent_glyphs_are_empty_bordered_and_bare() { local out out=$(classify 0 '❯'); [ "$out" = empty ] || fail "bare claude '❯' should read empty, got '$out'" out=$(classify 0 '›'); [ "$out" = empty ] || fail "bare codex '›' should read empty, got '$out'" + out=$(classify 0 '→'); [ "$out" = empty ] || fail "bare cursor '→' should read empty, got '$out'" out=$(classify 1 '❯'); [ "$out" = empty ] || fail "bordered claude '❯' should read empty, got '$out'" out=$(classify 1 '›'); [ "$out" = empty ] || fail "bordered codex '›' should read empty, got '$out'" - pass "fm_composer_classify_content: agent prompt glyphs (❯ claude, › codex) read empty bordered or bare" + out=$(classify 1 '→'); [ "$out" = empty ] || fail "bordered cursor '→' should read empty, got '$out'" + # The cursor glyph after a strip that emptied the content still reads empty. + out=$(classify 0 '' '' sensitive '→'); [ "$out" = empty ] || fail "a stripped cursor '→' should read empty, got '$out'" + # Real text after the cursor glyph is still pending. + out=$(classify 1 '→ deploy staging now'); [ "$out" = pending ] || fail "bordered '→ ' should be pending, got '$out'" + pass "fm_composer_classify_content: agent prompt glyphs (❯ claude, › codex, → cursor) read empty bordered or bare" } # --- Empty content and idle placeholder ------------------------------------- diff --git a/tests/fm-cursor-harness.test.sh b/tests/fm-cursor-harness.test.sh new file mode 100755 index 0000000000..c50ab96694 --- /dev/null +++ b/tests/fm-cursor-harness.test.sh @@ -0,0 +1,378 @@ +#!/usr/bin/env bash +# Behavior tests for the verified cursor-agent (Cursor Composer) crewmate adapter: +# launch template, the guarded global user hook + per-task registry, the semantic +# busy lifecycle its beforeSubmitPrompt/stop hooks drive, the stop-double-fire +# dedupe, the non-destructive commit/PR attribution neutralization, teardown +# cleanup, detection, and session-lock holder recognition. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +# shellcheck source=/dev/null +. "$ROOT/bin/fm-busy-lib.sh" + +SPAWN="$ROOT/bin/fm-spawn.sh" +TEARDOWN="$ROOT/bin/fm-teardown.sh" +CURSOR_HOOK="$ROOT/bin/fm-cursor-turnend-hook.sh" +TMP_ROOT=$(fm_test_tmproot fm-cursor-harness) +PYTHON_BIN=$(command -v python3) || fail "test needs python3" +PYTHON_BIN_DIR=$(dirname "$PYTHON_BIN") +JQ_BIN=$(command -v jq) || fail "test needs jq" +BASE_PATH=${FM_TEST_BASE_PATH:-$PYTHON_BIN_DIR:/usr/bin:/bin:/usr/sbin:/sbin} + +make_spawn_fakebin() { + local dir=$1 fakebin + fakebin=$(fm_fakebin "$dir") + cat > "$fakebin/tmux" <<'SH' +#!/usr/bin/env bash +set -u +case "$*" in + *"#{pane_current_path}"*) printf '%s\n' "${FM_FAKE_PANE_PATH:-}"; exit 0 ;; +esac +case "${1:-}" in + display-message) printf 'firstmate\n'; exit 0 ;; + list-windows) exit 0 ;; + has-session|new-session|new-window|kill-window) exit 0 ;; + send-keys) + prev= + for arg in "$@"; do + if [ "$prev" = -l ]; then printf '%s\n' "$arg" >> "$FM_FAKE_LAUNCH_LOG"; break; fi + prev=$arg + done + exit 0 + ;; +esac +exit 0 +SH + chmod +x "$fakebin/tmux" + fm_fake_exit0 "$fakebin" treehouse gh-axi gh + ln -s "$JQ_BIN" "$fakebin/jq" + printf '%s\n' "$fakebin" +} + +make_spawn_case() { # + local name=$1 id=$2 case_dir home proj wt fakebin + case_dir="$TMP_ROOT/$name" + home="$case_dir/home" + proj="$case_dir/project" + wt="$case_dir/wt" + fakebin=$(make_spawn_fakebin "$case_dir/fake") + mkdir -p "$home/data/$id" "$home/projects" "$home/state" "$home/config" "$home/.cursor" + printf 'brief for cursor\n' > "$home/data/$id/brief.md" + printf 'cursor\n' > "$home/config/crew-harness" + fm_git_worktree "$proj" "$wt" "wt-$name" + touch "$home/state/.last-watcher-beat" + : > "$case_dir/launch.log" + printf '%s\n' "$case_dir|$home|$proj|$wt|$fakebin" +} + +read_spawn_record() { + IFS='|' read -r CASE_DIR HOME_DIR PROJ_DIR WT_DIR FAKEBIN_DIR < [extra spawn args...] + local case_dir=$1 home=$2 proj=$3 wt=$4 fakebin=$5 id=$6 + shift 6 + 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" \ + FM_FAKE_LAUNCH_LOG="$case_dir/launch.log" \ + PATH="$fakebin:$BASE_PATH" \ + "$SPAWN" "$id" "$proj" --harness cursor --mode no-mistakes --yolo off "$@" 2>&1 +} + +# Feed one JSON hook payload to the installed global hook, as cursor would. +drive_hook() { # [status] + local home=$1 event=$2 genid=$3 root=$4 status=${5:-completed} payload + if [ "$event" = stop ]; then + payload=$(printf '{"hook_event_name":"stop","generation_id":"%s","status":"%s","workspace_roots":["%s"]}' \ + "$genid" "$status" "$root") + else + payload=$(printf '{"hook_event_name":"%s","generation_id":"%s","workspace_roots":["%s"]}' \ + "$event" "$genid" "$root") + fi + printf '%s' "$payload" | HOME="$home" PATH="$JQ_BIN_DIR:$BASE_PATH" \ + bash "$home/.cursor/fm-cursor-turnend.sh" +} +JQ_BIN_DIR=$(dirname "$JQ_BIN") + +classify() { fm_busy_classify tmux fake:w cursor "$1" "$2"; } + +test_cursor_spawn_installs_hook_registers_token_and_neutralizes_attribution() { + local id rec out rc launch token meta + id=cursor-spawn-z1 + rec=$(make_spawn_case spawn "$id") + read_spawn_record "$rec" + out=$(run_spawn "$CASE_DIR" "$HOME_DIR" "$PROJ_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$id" \ + --model composer-2.5 --effort high) + rc=$? + expect_code 0 "$rc" "cursor spawn should succeed: $out" + assert_contains "$out" "spawned $id harness=cursor" "cursor spawn did not report success" + + # Launch template: --force --trust --model, positional brief, NO effort flag, + # NO turn-end placeholder, and cursor-agent as the last command (no trailing ;). + launch=$(cat "$CASE_DIR/launch.log") + assert_contains "$launch" "cursor-agent --force --trust --model 'composer-2.5' " \ + "cursor launch did not use --force --trust --model: $launch" + assert_not_contains "$launch" "--effort" "cursor launch emitted a nonexistent effort flag" + assert_not_contains "$launch" "__TURNEND__" "cursor launch retained a turn-end placeholder" + assert_not_contains "$launch" "turn-ended" "cursor launch embedded a turn-end path" + case "$launch" in + *';') fail "cursor launch ended with a trailing command; pane would not report cursor-agent: $launch" ;; + esac + + meta="$HOME_DIR/state/$id.meta" + assert_grep 'harness=cursor' "$meta" "cursor meta lost its harness" + assert_grep 'model=composer-2.5' "$meta" "cursor meta lost the requested model" + assert_grep 'effort=high' "$meta" "cursor meta did not retain the unsupported effort axis" + + # Global user hook + entries for both events. + assert_present "$HOME_DIR/.cursor/fm-cursor-turnend.sh" "cursor hook script was not installed" + "$JQ_BIN" -e '.hooks.beforeSubmitPrompt[0].command | contains("fm-cursor-turnend.sh")' \ + "$HOME_DIR/.cursor/hooks.json" >/dev/null || fail "hooks.json lacks the beforeSubmitPrompt entry" + "$JQ_BIN" -e '.hooks.stop[0].command | contains("fm-cursor-turnend.sh")' \ + "$HOME_DIR/.cursor/hooks.json" >/dev/null || fail "hooks.json lacks the stop entry" + + # Per-task registry token + pointer, with the turn-end path kept out of the pointer. + assert_grep 'token=' "$WT_DIR/.fm-cursor-turnend" "cursor pointer did not contain a token" + assert_no_grep "$id.turn-ended" "$WT_DIR/.fm-cursor-turnend" "cursor pointer exposed the turn-end path" + token=$(sed -n 's/^token=//p' "$WT_DIR/.fm-cursor-turnend") + assert_present "$HOME_DIR/.cursor/fm-turn-end.d/$token" "cursor registry entry was not written" + assert_present "$HOME_DIR/state/$id.cursor-turnend-token" "cursor state token was not recorded" + assert_grep "busyevent=$ROOT/bin/fm-busy-event.sh" "$HOME_DIR/.cursor/fm-turn-end.d/$token" \ + "cursor registry entry did not record this home's fm-busy-event path" + + # Attribution neutralization: per-worktree .cursor/cli.json, both flags false, gitignored. + assert_present "$WT_DIR/.cursor/cli.json" "cursor attribution neutralization file was not written" + "$JQ_BIN" -e '.attribution.attributeCommitsToAgent == false and .attribution.attributePRsToAgent == false' \ + "$WT_DIR/.cursor/cli.json" >/dev/null \ + || fail "cursor .cursor/cli.json did not disable both commit and PR attribution" + local excl + excl=$(git -C "$WT_DIR" rev-parse --git-path info/exclude) + case "$excl" in /*) : ;; *) excl="$WT_DIR/$excl" ;; esac + assert_grep '.cursor/cli.json' "$excl" "attribution file was not gitignored" + assert_grep '.fm-cursor-turnend' "$excl" "cursor pointer was not gitignored" + pass "fm-spawn: cursor installs its hook, registers a task token, and neutralizes commit/PR attribution" +} + +test_cursor_hook_busy_lifecycle_and_stop_dedupe() { + local id rec out state token root turnend + id=cursor-busy-z2 + rec=$(make_spawn_case busy "$id") + read_spawn_record "$rec" + out=$(run_spawn "$CASE_DIR" "$HOME_DIR" "$PROJ_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$id") + expect_code 0 $? "cursor spawn should succeed: $out" + state="$HOME_DIR/state" + turnend="$state/$id.turn-ended" + + # Seed after spawn: the launch brief is a submitted turn. + out=$(classify "$id" "$state") + [ "$out" = "busy fm-spawn" ] || fail "seed after spawn must be 'busy fm-spawn', got '$out'" + + # stop closes the turn: idle cursor-hook + the turn-end notification touch. + rm -f "$turnend" + drive_hook "$HOME_DIR" stop gen-A "$WT_DIR" completed || fail "stop hook drive failed" + [ -f "$turnend" ] || fail "stop hook did not touch the turn-end marker" + out=$(classify "$id" "$state") + [ "$out" = "idle cursor-hook" ] || fail "stop must classify 'idle cursor-hook', got '$out'" + + # beforeSubmitPrompt opens the next turn. + drive_hook "$HOME_DIR" beforeSubmitPrompt gen-B "$WT_DIR" || fail "beforeSubmitPrompt drive failed" + out=$(classify "$id" "$state") + [ "$out" = "busy cursor-hook" ] || fail "beforeSubmitPrompt must classify 'busy cursor-hook', got '$out'" + + # Interrupt double-fire: stop fires twice for one generation (aborted, then + # error). The dedupe must touch the marker and apply idle exactly once. + rm -f "$turnend" + drive_hook "$HOME_DIR" stop gen-B "$WT_DIR" aborted || fail "aborted stop drive failed" + [ -f "$turnend" ] || fail "the aborted stop must still wake firstmate" + rm -f "$turnend" + drive_hook "$HOME_DIR" stop gen-B "$WT_DIR" error || fail "error stop drive failed" + [ -e "$turnend" ] && fail "the duplicate error stop for one generation was not deduped" + out=$(classify "$id" "$state") + [ "$out" = "idle cursor-hook" ] || fail "after the interrupt the task must be idle, got '$out'" + pass "cursor hook opens on beforeSubmitPrompt, closes on stop, and dedupes the interrupt double-fire" +} + +test_cursor_hook_requires_registered_workspace_token() { + local id rec out state token turnend evil + id=cursor-auth-z3 + rec=$(make_spawn_case auth "$id") + read_spawn_record "$rec" + out=$(run_spawn "$CASE_DIR" "$HOME_DIR" "$PROJ_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$id") + expect_code 0 $? "cursor spawn should succeed: $out" + state="$HOME_DIR/state" + turnend="$state/$id.turn-ended" + token=$(sed -n 's/^token=//p' "$WT_DIR/.fm-cursor-turnend") + + # A workspace with no firstmate pointer is inert and silent. + evil="$CASE_DIR/evil" + mkdir -p "$evil" + rm -f "$turnend" + out=$(drive_hook "$HOME_DIR" stop gen-x "$evil" completed 2>&1) + expect_code 0 $? "cursor hook must never block a tokenless session" + [ -z "$out" ] || fail "cursor hook printed into a tokenless session: $out" + assert_absent "$turnend" "a tokenless cursor workspace touched a task marker" + + # A pointer whose token is not on the first line is rejected. + { + printf '%s\n' 'ignored' + printf 'token=%s\n' "$token" + } > "$WT_DIR/.fm-cursor-turnend" + rm -f "$turnend" + drive_hook "$HOME_DIR" stop gen-y "$WT_DIR" completed + assert_absent "$turnend" "cursor pointer accepted a token outside the first line" + + # The genuine registered pointer works. + printf 'token=%s\n' "$token" > "$WT_DIR/.fm-cursor-turnend" + rm -f "$turnend" + drive_hook "$HOME_DIR" stop gen-z "$WT_DIR" completed + assert_present "$turnend" "the registered cursor pointer did not touch the turn-end marker" + pass "cursor global hook fires only for a workspace holding a firstmate registry token" +} + +test_cursor_teardown_removes_pointer_registry_and_attribution() { + local id rec out token + id=cursor-teardown-z4 + rec=$(make_spawn_case teardown "$id") + read_spawn_record "$rec" + out=$(run_spawn "$CASE_DIR" "$HOME_DIR" "$PROJ_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$id") + expect_code 0 $? "cursor spawn should succeed before teardown: $out" + token=$(sed -n 's/^token=//p' "$WT_DIR/.fm-cursor-turnend") + # Simulate an accumulated stop-dedupe directory so teardown must clear it too. + mkdir -p "$HOME_DIR/.cursor/fm-turn-end.d/$token.stops/gen-A" + + HOME="$HOME_DIR" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$HOME_DIR" \ + FM_STATE_OVERRIDE="$HOME_DIR/state" FM_DATA_OVERRIDE="$HOME_DIR/data" \ + FM_PROJECTS_OVERRIDE="$HOME_DIR/projects" FM_CONFIG_OVERRIDE="$HOME_DIR/config" \ + FM_SPAWN_NO_GUARD=1 PATH="$FAKEBIN_DIR:$BASE_PATH" \ + "$TEARDOWN" "$id" --force >/dev/null 2>&1 || fail "cursor teardown failed" + + assert_absent "$WT_DIR/.fm-cursor-turnend" "cursor pointer survived teardown" + assert_absent "$WT_DIR/.cursor/cli.json" "cursor attribution file survived teardown" + assert_absent "$HOME_DIR/.cursor/fm-turn-end.d/$token" "cursor registry token survived teardown" + assert_absent "$HOME_DIR/.cursor/fm-turn-end.d/$token.stops" "cursor stop-dedupe dir survived teardown" + assert_absent "$HOME_DIR/state/$id.cursor-turnend-token" "cursor state token survived teardown" + pass "fm-teardown: cursor pointer, registry token, dedupe dir, and attribution file are removed" +} + +test_cursor_hook_install_is_idempotent_and_preserves_foreign_hooks() { + local home config count + home="$TMP_ROOT/install-idem" + config="$home/.cursor/hooks.json" + mkdir -p "$home/.cursor" + cat > "$config" <<'JSON' +{ + "version": 1, + "notifications": true, + "hooks": { + "afterFileEdit": [ { "command": ".cursor/hooks/format.sh" } ] + } +} +JSON + HOME="$home" "$CURSOR_HOOK" install || fail "cursor hook install refused a realistic hooks.json" + cp "$config" "$home/once.json" + HOME="$home" "$CURSOR_HOOK" install || fail "second cursor hook install failed" + cmp -s "$home/once.json" "$config" || fail "second cursor hook install changed hooks.json bytes" + count=$("$JQ_BIN" '.hooks.stop | length' "$config") + [ "$count" -eq 1 ] || fail "idempotent install left $count stop entries" + "$JQ_BIN" -e '.hooks.afterFileEdit[0].command == ".cursor/hooks/format.sh" and .notifications == true' \ + "$config" >/dev/null || fail "install did not preserve the captain's foreign hook and keys" + + HOME="$home" "$CURSOR_HOOK" remove || fail "cursor hook removal failed" + "$JQ_BIN" -e '.hooks.stop == null and .hooks.beforeSubmitPrompt == null' "$config" >/dev/null \ + || fail "removal left firstmate hook entries behind" + "$JQ_BIN" -e '.hooks.afterFileEdit[0].command == ".cursor/hooks/format.sh"' "$config" >/dev/null \ + || fail "removal discarded the captain's foreign hook" + assert_absent "$home/.cursor/fm-cursor-turnend.sh" "removal left the firstmate hook script" + assert_absent "$home/.cursor/fm-turn-end.d" "removal left the firstmate registry" + pass "cursor hook install is idempotent, preserves foreign hooks, and removal is clean" +} + +test_cursor_hook_install_refuses_malformed_hooks_json() { + local home config out rc + home="$TMP_ROOT/install-malformed" + config="$home/.cursor/hooks.json" + mkdir -p "$home/.cursor" + printf '{"hooks": [broken\n' > "$config" + cp "$config" "$home/before.json" + rc=0 + out=$(HOME="$home" "$CURSOR_HOOK" install 2>&1) || rc=$? + [ "$rc" -ne 0 ] || fail "malformed hooks.json was accepted" + assert_contains "$out" "not valid JSON" "malformed refusal lacked its concrete reason" + cmp -s "$home/before.json" "$config" || fail "malformed refusal changed hooks.json bytes" + assert_absent "$home/.cursor/fm-cursor-turnend.sh" "malformed refusal wrote the hook script" + pass "cursor hook install refuses malformed hooks.json without writing" +} + +test_cursor_detection_uses_ancestry_after_markers() { + local dir fakebin cfg out + dir="$TMP_ROOT/detection" + fakebin=$(fm_fakebin "$dir") + cfg="$dir/config" + mkdir -p "$cfg" + cat > "$fakebin/ps" <<'SH' +#!/usr/bin/env bash +set -u +field= +pid= +prev= +for arg in "$@"; do + [ "$prev" = -o ] && field=$arg + [ "$prev" = -p ] && pid=$arg + prev=$arg +done +case "$field:$pid" in + comm=:4242) printf '/home/u/.local/bin/cursor-agent\n' ;; + comm=:*) printf '/bin/bash\n' ;; + ppid=:4242) printf '1\n' ;; + ppid=:*) printf '4242\n' ;; + args=:*) printf 'bash\n' ;; +esac +SH + chmod +x "$fakebin/ps" + out=$(env -u CLAUDECODE -u PI_CODING_AGENT -u GROK_AGENT \ + PATH="$fakebin:$BASE_PATH" FM_CONFIG_OVERRIDE="$cfg" "$ROOT/bin/fm-harness.sh") + [ "$out" = cursor ] || fail "cursor ancestry detection returned '$out'" + out=$(CLAUDECODE=1 PATH="$fakebin:$BASE_PATH" FM_CONFIG_OVERRIDE="$cfg" "$ROOT/bin/fm-harness.sh") + [ "$out" = claude ] || fail "verified env-marker precedence changed, got '$out'" + pass "fm-harness: cursor-agent is detected by ancestry after env-marker precedence" +} + +test_cursor_session_lock_identity() { + local home fakebin out + home="$TMP_ROOT/session-lock-home" + fakebin=$(fm_fakebin "$TMP_ROOT/session-lock-fake") + mkdir -p "$home/state" + cat > "$fakebin/ps" <<'SH' +#!/usr/bin/env bash +case "$*" in + *"comm="*) printf '%s\n' '/home/u/.local/bin/cursor-agent'; exit 0 ;; + *"args="*) printf '%s\n' 'cursor-agent'; exit 0 ;; +esac +exit 1 +SH + chmod +x "$fakebin/ps" + FM_HOME="$home" PATH="$fakebin:$BASE_PATH" "$ROOT/bin/fm-lock.sh" \ + || fail "fm-lock did not acquire from cursor ancestry" + printf '%s\n' "$$" > "$home/state/.lock" + out=$(FM_HOME="$home" PATH="$fakebin:$BASE_PATH" "$ROOT/bin/fm-lock.sh" status) + assert_contains "$out" "lock: held by live harness pid" \ + "fm-lock did not recognize cursor as a live holder" + pass "fm-lock recognizes cursor ancestry and live lock holders" +} + +test_cursor_spawn_installs_hook_registers_token_and_neutralizes_attribution +test_cursor_hook_busy_lifecycle_and_stop_dedupe +test_cursor_hook_requires_registered_workspace_token +test_cursor_teardown_removes_pointer_registry_and_attribution +test_cursor_hook_install_is_idempotent_and_preserves_foreign_hooks +test_cursor_hook_install_refuses_malformed_hooks_json +test_cursor_detection_uses_ancestry_after_markers +test_cursor_session_lock_identity + +echo "all fm-cursor-harness tests passed" diff --git a/tests/fm-tmux-agent-liveness.test.sh b/tests/fm-tmux-agent-liveness.test.sh index 26371c9b55..21d003448f 100755 --- a/tests/fm-tmux-agent-liveness.test.sh +++ b/tests/fm-tmux-agent-liveness.test.sh @@ -53,6 +53,7 @@ export PATH # the executable identity, which is exactly the signal under test. ln -s "$SLEEP_BIN" "$LAB/bin/claude-link" ln -s "$SLEEP_BIN" "$LAB/bin/pi" +ln -s "$SLEEP_BIN" "$LAB/bin/cursor-agent" ln -s "$SLEEP_BIN" "$LAB/bin/notaharness" # A launcher whose own process identity is a bare shell, running the harness as @@ -144,6 +145,13 @@ wait_for_state "$SESSION:agent" alive \ || fail "a running harness-named foreground process must classify alive" pass "tmux liveness: a harness-named foreground process classifies alive" +# cursor-agent is a node wrapper whose pane reports `cursor-agent` (argv[0]); the +# alive-set arm must match it so a cursor secondmate is never seen as dead. +new_window cursor "$LAB/bin/cursor-agent" 900 +wait_for_state "$SESSION:cursor" alive \ + || fail "a running cursor-agent foreground process must classify alive" +pass "tmux liveness: a cursor-agent foreground process classifies alive" + # --- a version name blinds one source --------------------------------------- # Giving a genuine harness-named executable the version-string argv[0] that # Claude Code 2.1.220 reports drives the two sources apart on both supported From 5705ef317a28f816a7fd93972566e0a8311250ab Mon Sep 17 00:00:00 2001 From: Jonathan Demirgian Date: Tue, 4 Aug 2026 22:57:14 +0000 Subject: [PATCH 2/3] no-mistakes(review): narrow cursor harness detection glob to cursor-agent --- bin/fm-harness.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bin/fm-harness.sh b/bin/fm-harness.sh index ff3a26b93d..5b2c8fc463 100755 --- a/bin/fm-harness.sh +++ b/bin/fm-harness.sh @@ -58,7 +58,7 @@ detect_own() { # name a crew ancestor exposes is the cursor-agent basename. No verified # env marker is exported to child/tool processes (the wrapper sets # CURSOR_INVOKED_AS only in its own process), so detection is ancestry-only. - *cursor*) echo cursor; return ;; + *cursor-agent*) echo cursor; return ;; pi-signed) echo pi; return ;; pi) echo pi; return ;; node*|python*) @@ -69,7 +69,7 @@ detect_own() { *codex*) echo codex; return ;; *opencode*) echo opencode; return ;; *grok*) echo grok; return ;; - *cursor*) echo cursor; return ;; + *cursor-agent*) echo cursor; return ;; *" pi "*|*/pi) echo pi; return ;; esac ;; esac From b67d79e592e4315d3edc32680ecb266a85d62c66 Mon Sep 17 00:00:00 2001 From: Jonathan Demirgian Date: Tue, 4 Aug 2026 23:06:53 +0000 Subject: [PATCH 3/3] no-mistakes(document): add cursor to configuration.md verified-harness set --- docs/configuration.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/configuration.md b/docs/configuration.md index 462b77038d..509023149e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -206,7 +206,7 @@ The full cmux home label also includes a short hash of the resolved `FM_ROOT` pa ## Harness support -claude, codex, opencode, pi, pi-signed, grok, and kimi are empirically verified for crewmate and secondmate launches; [README requirements](../README.md#requirements) own the set supported for the primary session. +claude, codex, opencode, pi, pi-signed, grok, kimi, and cursor are empirically verified for crewmate and secondmate launches; [README requirements](../README.md#requirements) own the set supported for the primary session. New harnesses get verified through a supervised trial task before joining the set. The verified adapter knowledge - each harness's busy-state source, 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).