Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
617db1a
feat(cursor): add Cursor Agent CLI primary hooks, park supervision, a…
Aug 13, 2026
56e6371
feat(cursor): make Cursor Agent CLI a verified primary harness
Aug 13, 2026
fe43062
docs(cursor): record Cursor as a verified primary across the owning s…
Aug 13, 2026
762aee1
refactor(cursor): name the park's stand-down condition for both its c…
Aug 13, 2026
bea4c78
test: give the pretool fixtures their new dependency and one lint owner
Aug 13, 2026
b21da9b
test: assert the cursor secondmate contract instead of its removed re…
Aug 13, 2026
7f702ed
no-mistakes(review): Serialize Cursor wakes and bind staged context
kunchenguid Aug 13, 2026
cd62e20
no-mistakes(review): Serialize Cursor context and nag state commits
kunchenguid Aug 13, 2026
22dd3bc
no-mistakes(review): Enforce Cursor ceiling before staged context del…
kunchenguid Aug 13, 2026
95b9a92
no-mistakes(review): Serialize Cursor claims and staged context
kunchenguid Aug 13, 2026
5ed0a9c
no-mistakes(review): Serialize Cursor ownership and state commits
kunchenguid Aug 13, 2026
fecf5c8
no-mistakes(review): Protect Cursor context across session takeover
kunchenguid Aug 13, 2026
f231c9c
no-mistakes(review): Preserve Cursor context across session takeover
kunchenguid Aug 13, 2026
dabed16
no-mistakes(review): Enforce owner-keyed Cursor staged context
kunchenguid Aug 13, 2026
8a7a248
no-mistakes(review): Atomically claim Cursor follow-ups and staged co…
kunchenguid Aug 13, 2026
7a44d93
no-mistakes(review): Defer Cursor preCompact staging and simplify sup…
kunchenguid Aug 13, 2026
1acb01f
no-mistakes(review): Serialize Cursor park commits and defer preCompact
kunchenguid Aug 13, 2026
3902f8d
no-mistakes(review): Stop Cursor parks after session takeover
kunchenguid Aug 13, 2026
3055b1a
no-mistakes(test): Route Cursor preCompact context through stop follo…
kunchenguid Aug 13, 2026
e732396
no-mistakes(document): Update Cursor primary documentation
kunchenguid Aug 13, 2026
278b66d
revert(cursor): cut preCompact staging from this change
Aug 13, 2026
d7217ec
no-mistakes(review): Correct Cursor park supersession documentation
kunchenguid Aug 13, 2026
937c228
no-mistakes(document): Clarify Cursor run-tier verification ownership
kunchenguid Aug 13, 2026
a209b85
no-mistakes: apply CI fixes
kunchenguid Aug 13, 2026
9c314ef
no-mistakes: apply CI fixes
kunchenguid Aug 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 16 additions & 11 deletions .agents/skills/harness-adapters/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,22 +59,23 @@ Use that value for interrupt, exit, resume, and skill-invocation facts.

## Primary turn-end guard

The primary integrations for `claude`, `codex`, `opencode`, `pi`, `pi-signed`, and `grok` have empirically validated hook paths for the "no turn ends blind" guard.
The primary integrations for `claude`, `codex`, `opencode`, `pi`, `pi-signed`, `grok`, and `cursor` have empirically validated hook paths 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 `pi-signed` expose passive lifecycle callbacks and force one bounded follow-up when the shared predicate blocks.
Grok selects native blocking or its pre-native bounded resume fallback from the exact running Stop payload; [`docs/turnend-guard.md`](../../../docs/turnend-guard.md) owns that contract.
Kimi is outside the primary turn-end guard scope, while `docs/turnend-guard.md` owns its separate guarded global hook for crew wake signals.
muse is CREWMATE/SCOUT ONLY and has no primary integration at all: its plugin engine (its only hook surface) is disabled in the default build, and its Claude-compatible hook dialect names `asyncRewake` and model reawakening as explicitly unsupported, which is exactly what a firstmate primary's turn-end supervision needs.
`bin/fm-spawn.sh` refuses a `--secondmate` launch on muse for that reason.
cursor is CREWMATE/SCOUT ONLY and has no verified primary turn-end or watcher supervision integration.
`bin/fm-spawn.sh` refuses local and remote `--secondmate` launches on cursor for that reason.
cursor HAS a full hooks system: 20 lifecycle events configurable at project scope in `.cursor/hooks.json`, plus a Claude-Code compatibility name map that also loads `<project>/.claude/settings.json`.
Its `stop` step cannot block - exit 2 there is a silent no-op - so `bin/fm-turnend-guard-cursor.sh` parks the turn boundary on the watcher and returns one bounded `followup_message` instead.
Because Cursor loads the tracked Claude settings too, every Claude-shaped entrypoint whose event Cursor covers stands down on a Cursor-delivered payload.
The exact hook files, commands, scoping rules, and fail-open tradeoffs are owned by `docs/turnend-guard.md`.
`docs/verification/supervision.md` "Turn-end guard" owns active validation evidence.
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.

## Primary pre-arm (PreToolUse) seatbelt

The primary integrations for `claude`, `codex`, `opencode`, `pi`, `pi-signed`, and `grok` also have wired PreToolUse-equivalent hooks that deny a watcher-arm anti-pattern (shell `&`, truncating pipe, bundling, broad `pkill -f fm-watch`) before it runs.
The primary integrations for `claude`, `codex`, `opencode`, `pi`, `pi-signed`, `grok`, and `cursor` also have wired PreToolUse-equivalent hooks that deny a watcher-arm anti-pattern (shell `&`, truncating pipe, bundling, broad `pkill -f fm-watch`) before it runs.
`claude` and `codex` block directly through PreToolUse hooks; `grok` blocks the same way but requires every `$VAR` reference in its hook `command` string to carry an inline `:-default` or it fails to launch the hook entirely.
`opencode`, `pi`, and `pi-signed` block by throwing from `tool.execute.before` / returning `{block: true}` from `tool_call`.
The exact hook files, commands, output-shaping quirks (Claude Code only honors the deny when stdout is empty), and validation transcripts are owned by `docs/arm-pretool-check.md`.
Expand Down Expand Up @@ -368,10 +369,10 @@ The tracked Claude hook entries whose event Grok already covers through its own
Project-local Grok hooks require folder trust, verified with launch-time `--trust`; if the primary firstmate checkout is not trusted for Grok hooks, this primary guard fails open and `fm-guard.sh` remains the next-command alarm.
Grok's primary watcher protocol remains background-notify around `bin/fm-watch-arm.sh`; native Stop continuation does not provide Pi-like extension ownership.

## cursor (VERIFIED CREWMATE/SCOUT 2026-08-11 on tmux and 2026-08-12 on Herdr, Cursor Agent CLI 2026.08.11-e8db854)
## cursor (VERIFIED CREWMATE/SCOUT 2026-08-11 on tmux and 2026-08-12 on Herdr, and SECONDMATE/PRIMARY 2026-08-13, Cursor Agent CLI 2026.08.11-e8db854)

Cursor Agent CLI is a CREWMATE and SCOUT adapter only.
`bin/fm-spawn.sh` refuses local and remote `--secondmate` launches, and `bin/fm-control-lib.sh` refuses a secondmate relaunch, because no primary turn-end or watcher supervision protocol has been verified for Cursor.
Cursor Agent CLI runs crewmate, scout, secondmate, and primary work.
Its primary supervision is the stop-hook park in [`docs/supervision-protocols/cursor.md`](../../../docs/supervision-protocols/cursor.md), registered in tracked `.cursor/hooks.json`; a Cursor primary or secondmate must be launched with `--trust` or no project hook loads at all.
Do not confuse `harness=cursor` using a `cursor-grok-4.5-*` model with `harness=grok`, which is the separate xAI Grok Build CLI and credential surface.

| Fact | Value |
Expand All @@ -388,7 +389,9 @@ Do not confuse `harness=cursor` using a `cursor-grok-4.5-*` model with `harness=
| Trust dialog | `--trust` suppresses it. `--yolo` does NOT, and every task gets a fresh worktree path, so without `--trust` every spawn would block on it. |
| Environment marker | `CURSOR_INVOKED_AS=cursor-agent` on the agent process and its children, plus `CURSOR_AGENT=1` on child/tool processes. Other `CURSOR_*` endpoint and credential variables are not identity markers. |
| Effort | No effort flag exists. The requested axis is recorded in task metadata and never reaches the launch command. |
| Composer | A BARE row whose prompt glyph is `→` (U+2192); no border. Idle placeholders are `Plan, search, build anything` fresh and `Add a follow-up` after a turn. |
| Composer | A BARE row whose prompt glyph is `→` (U+2192); no border. Idle placeholders are `Plan, search, build anything` fresh and `Add a follow-up` after a turn, drawn de-emphasised so a styled capture separates them from real typed text. |
| Primary hooks | Tracked project-scope `.cursor/hooks.json` registers `stop`, `sessionStart`, and two `preToolUse` seatbelts, all anchored through `$CURSOR_PROJECT_DIR`. Cursor ALSO loads `<project>/.claude/settings.json`, so the tracked Claude entries stand down on a Cursor-delivered payload; `docs/turnend-guard.md` owns that predicate. |
| Primary limits | `stop` does not fire in headless `cursor-agent -p`. `preCompact` is deliberately unregistered because it cannot inject context, so a Cursor primary does not re-emit its digest after a compaction; that surface is deferred to a follow-up. Project hooks need `--trust`. |

**Detection ordering is load-bearing.**
Cursor does NOT clear an inherited `CLAUDECODE`, so a cursor worker under a claude primary carries both markers and whichever is tested first wins.
Expand All @@ -402,9 +405,11 @@ An unrelated `node` or `agent` is deliberately left `other`, which the liveness
Because the versioned install path is what identifies the alias, an auto-update changes the resolved target but not the identity rule.

**Cursor parks its terminal cursor outside its composer.**
`#{cursor_y}` pointed below the footer both when idle and with real text typed, and `#{cursor_flag}` was 0.
The tmux composer verdict for a cursor pane is therefore `unknown` in EVERY state; this is expected, not a defect to chase.
Submission is acknowledged from the idle-to-busy transition instead, which is why cursor's `ctrl+c to stop` token is part of the delivery busy union in `bin/fm-composer-lib.sh`.
`#{cursor_y}` pointed below the footer both when idle and with real text typed, and `#{cursor_flag}` was 0, so tmux's cursor row is not a composer locator for a Cursor pane and the cursor-ANCHORED read answers `unknown` in every state.
`bin/fm-tmux-lib.sh` therefore reclassifies a pane it can prove is Cursor the way every cursorless backend already classifies it, letting the bottom-most shape win, so the composite `fm_tmux_composer_state` now reports a real `empty` or `pending` for a Cursor pane on tmux (verified 2026-08-13).
That gate is Cursor's own structural process identity from `bin/fm-cursor-lib.sh`, never the verdict alone, so the strict blank-cursor-row posture stays in force for every other harness and a dead shell still never reads `empty`.
This is what makes away-mode escalation delivery work against a Cursor primary: `bin/fm-supervise-daemon.sh` needs an affirmatively-empty composer before it types, and it needed no Cursor-specific branch once the reader was correct.
Submission is additionally acknowledged from the idle-to-busy transition, which is why cursor's `ctrl+c to stop` token is part of the delivery busy union in `bin/fm-composer-lib.sh`.
Match that TOKEN and never the spinner verb: the same version rendered `Working` in one turn and `Running` in the next.

**Delivery confirmation is verified on tmux and Herdr only.**
Expand Down
34 changes: 34 additions & 0 deletions .cursor/hooks.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
{
"version": 1,
"hooks": {
"sessionStart": [
{
"type": "command",
"command": "\"$CURSOR_PROJECT_DIR\"/bin/fm-sessionstart-cursor.sh --source startup",
"timeout": 180
}
],
"stop": [
{
"type": "command",
"command": "\"$CURSOR_PROJECT_DIR\"/bin/fm-turnend-guard-cursor.sh",
"timeout": 28800,
"loop_limit": 200
}
],
"preToolUse": [
{
"matcher": "Shell",
"type": "command",
"command": "\"$CURSOR_PROJECT_DIR\"/bin/fm-arm-pretool-check.sh --cursor",
"timeout": 10
},
{
"matcher": "Shell",
"type": "command",
"command": "\"$CURSOR_PROJECT_DIR\"/bin/fm-cd-pretool-check.sh --cursor",
"timeout": 10
}
]
}
}
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ state/ runtime records and signals; gitignored
.afk durable away-mode flag; present = sub-supervisor may inject escalations (set by /afk, cleared on user return)
.watch.lock .wake-queue.lock watcher singleton and queue serialization locks
.claude-autoarm.lock .claude-autoarm-epoch .claude-autoarm-failure-notified .claude-autoarm-failure-alarmed .turnend-claude-blocks .turnend-claude-blocks.lock Claude Stop auto-arm single-flight, epoch, failure-episode, attended-alarm, guard-budget, and budget-lock records; never touch
.cursor-park-owner .cursor-park-owner.lock .turnend-cursor-blocks Cursor stop-hook owner record, publication and commit lock, and bounded repair-nag budget; never touch
.hash-* .count-* .stale-* .stale-since-* .paused-* .wedge-escalations-* .seen-* .hb-surfaced-* .last-* .heartbeat-streak watcher internals; never touch
.watch-triage.log watcher's absorbed-wake debug log (size-capped); never relied on, safe to delete
.last-watcher-beat watcher liveness beacon, touched every poll (including while absorbing benign wakes); guard scripts read it
Expand Down Expand Up @@ -179,7 +180,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`, plus `cursor` and `muse` for crewmates and scouts only; never dispatch on an unverified adapter.
The verified harnesses are `claude`, `codex`, `opencode`, `pi`, `pi-signed`, `grok`, `kimi`, and `cursor`, plus `muse` for crewmates and scouts only; 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.
Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ Full detail on every feature lives in [docs/architecture.md](docs/architecture.m

### Requirements

- A verified primary agent harness: Claude Code, Grok, Pi, `pi-signed`, Codex, or OpenCode.
- A verified primary agent harness: Claude Code, Grok, Pi, `pi-signed`, Codex, OpenCode, or Cursor Agent CLI.
- Git and the GitHub CLI, authenticated through `gh auth login`.
- The CLI and dependencies for your selected runtime backend; tmux is the reference default.

Expand All @@ -73,6 +73,8 @@ All three have verified turn-end guard paths when launched with their documented
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.
Cursor Agent CLI is verified as a primary too, using a tracked project-scope `.cursor/hooks.json` whose `stop` hook parks on the watcher between turns, closest in shape to Claude Code's.
Launch it with `--trust`, or none of its project hooks load; it also has no turn-end hook in headless `cursor-agent -p`, so run the primary session interactively.

### Install and launch

Expand Down Expand Up @@ -211,7 +213,7 @@ Firstmate's skills live in two separate places with different audiences:
- [docs/gitlab-merge-watch.md](docs/gitlab-merge-watch.md) - maintainer verification for GitLab merge watching on arbitrary instances.
- [docs/turnend-guard.md](docs/turnend-guard.md) - the primary session's current "no turn ends blind" backstop, scope, loop safety, and compatibility limits.
- [docs/verification/supervision.md](docs/verification/supervision.md) - active maintainer verification for session-start, guard, continuity, and wedge integrations.
- [docs/supervision-protocols/](docs/supervision-protocols/) - rendered primary-harness watcher protocols for Claude, Codex, OpenCode, Pi and `pi-signed`, Grok, and unknown harness fallback.
- [docs/supervision-protocols/](docs/supervision-protocols/) - rendered primary-harness watcher protocols for Claude, Codex, OpenCode, Pi and `pi-signed`, Grok, Cursor, and unknown harness fallback.
- [docs/scripts.md](docs/scripts.md) - the `bin/` toolbelt reference.
- [docs/documentation-audiences.md](docs/documentation-audiences.md) - documentation audiences and the machine-checked placement boundary.
- [`AGENTS.md`](AGENTS.md) - the distro's always-loaded operating contract and routing index for conditional procedures.
Expand Down
4 changes: 1 addition & 3 deletions bin/fm-afk-launch.sh
Original file line number Diff line number Diff line change
Expand Up @@ -164,9 +164,7 @@ fm_afk_launch_record_write() { # <backend> <target> <extra>
}

fm_afk_launch_flag_write() {
local pending="$FM_AFK_LAUNCH_STATE/.afk.pending.$$"
date '+%s' > "$pending" || { rm -f "$pending"; return 1; }
mv "$pending" "$FM_AFK_LAUNCH_STATE/.afk" || { rm -f "$pending"; return 1; }
fm_afk_flag_write "$FM_AFK_LAUNCH_STATE"
}

# Read the recorded terminal into FM_AFK_REC_BACKEND/FM_AFK_REC_TARGET. The third
Expand Down
22 changes: 21 additions & 1 deletion bin/fm-afk-start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,26 @@ daemon_lock_held_by_live_daemon() {
daemon_pid_matches "$pid" "$owner"
}

fm_afk_flag_write() { # <state-dir>
local state=$1 lock="$1/.cursor-park-owner.lock" pending attempt=0 status=1
mkdir -p "$state" || return 1
[ ! -d "$state/.afk" ] || return 1
pending=$(mktemp "$state/.afk.pending.XXXXXX") || return 1
date '+%s' > "$pending" || { rm -f "$pending"; return 1; }
while [ "$attempt" -lt 50 ]; do
attempt=$((attempt + 1))
if fm_lock_try_acquire "$lock"; then
mv "$pending" "$state/.afk" && status=0
fm_lock_release "$lock"
rm -f "$pending" 2>/dev/null || true
return "$status"
fi
[ "$attempt" -lt 50 ] && sleep 0.1
done
rm -f "$pending" 2>/dev/null || true
return 1
}

fm_afk_start_main() {
case "${1:-}" in
'' ) ;;
Expand All @@ -121,7 +141,7 @@ fm_afk_start_main() {
if [ "${FM_AFK_STATE_PREPARED:-0}" = 1 ]; then
[ -f "$FM_AFK_STATE/.afk" ] || { echo "afk: launcher-prepared state is missing" >&2; return 1; }
else
date '+%s' > "$FM_AFK_STATE/.afk"
fm_afk_flag_write "$FM_AFK_STATE" || { echo "afk: failed to write away-mode flag" >&2; return 1; }
fi

local pid
Expand Down
33 changes: 30 additions & 3 deletions bin/fm-arm-pretool-check.sh
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@
# bin/fm-arm-pretool-check.sh --command '<cmd>' [--background true|false]
#
# Stdin mode extracts .toolInput.command for Grok or .tool_input.command for
# Claude and Codex.
# Claude and Codex. Cursor delivers the same .tool_input.command shape with
# tool_name "Shell" (verified live, cursor-agent 2026.08.11-e8db854), so it needs
# no new extraction - only --cursor, which selects Cursor's own deny rendering
# and marks this invocation as the Cursor registration rather than the
# Claude-settings duplicate Cursor also loads.
# 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
Expand All @@ -25,29 +29,36 @@
# ALLOW - exit 0 and no output.
# DENY - exit 2, a Claude-shaped deny object on stderr, and a Grok-shaped
# deny object on stdout unless --claude was supplied.
# DENY, --cursor - exit 0 and Cursor's own decision object on stdout. Cursor
# reads the returned object rather than the exit status, and only that
# rendering is verified to block the command and surface the reason.
# FAIL OPEN - malformed or empty stdin, missing jq for stdin transport,
# 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.
# Grok consumes the stdout decision object.
# OpenCode and Pi consume exit 2 plus stderr.
# Cursor consumes the stdout decision object.
set -u

CMD=""
CMD_SET=0
BACKGROUND=""
CLAUDE_MODE=0
CURSOR_MODE=0

usage() {
cat <<'EOF'
Usage: fm-arm-pretool-check.sh [--command <cmd>] [--background true|false] [--claude]
Usage: fm-arm-pretool-check.sh [--command <cmd>] [--background true|false] [--claude|--cursor]

With no --command, reads a PreToolUse-style JSON payload on stdin (Grok
toolInput.command, or Claude/Codex tool_input.command).
toolInput.command, or Claude/Codex/Cursor tool_input.command).
Exits 0 to allow and 2 to deny.
The deny reason is written to stderr, with a Grok decision object on stdout
unless --claude is supplied.
With --cursor, a deny is Cursor's own decision object on stdout and exit 0,
because Cursor reads the returned object rather than the exit status.
Malformed transport and an unavailable classifier runtime fail open.
EOF
}
Expand Down Expand Up @@ -78,6 +89,10 @@ while [ "$#" -gt 0 ]; do
CLAUDE_MODE=1
shift
;;
--cursor)
CURSOR_MODE=1
shift
;;
-h|--help)
usage
exit 0
Expand All @@ -94,6 +109,14 @@ if [ "$CMD_SET" -eq 0 ]; then
PAYLOAD=$(cat 2>/dev/null || true)
[ -n "$PAYLOAD" ] || exit 0
command -v jq >/dev/null 2>&1 || exit 0
# shellcheck source=bin/fm-hook-host-lib.sh
. "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/fm-hook-host-lib.sh"
# Cursor's own registration passes --cursor. Without it a Cursor-delivered
# payload is the Claude-settings duplicate Cursor also loads, already
# evaluated by that registration, so this copy allows without re-classifying.
if [ "$CURSOR_MODE" -eq 0 ] && fm_hook_payload_is_foreign_host "$PAYLOAD"; then
exit 0
fi
CMD=$(printf '%s' "$PAYLOAD" | jq -r '(.toolInput.command // .tool_input.command // empty)' 2>/dev/null) || exit 0
[ -n "$CMD" ] || exit 0
# Kept for transport parity only.
Expand Down Expand Up @@ -168,6 +191,10 @@ json_escape() {

DETAIL="[$CODE] $REASON"
ESCAPED=$(json_escape "$DETAIL")
if [ "$CURSOR_MODE" -eq 1 ]; then
printf '{"permission":"deny","user_message":"%s"}\n' "$ESCAPED"
exit 0
fi
printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny"},"systemMessage":"%s"}\n' "$ESCAPED" >&2
[ "$CLAUDE_MODE" -eq 1 ] || printf '{"decision":"deny","reason":"%s"}\n' "$ESCAPED"
exit 2
Loading
Loading