Skip to content
12 changes: 7 additions & 5 deletions .agents/skills/harness-adapters/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,13 +202,15 @@ Its broader dark-TRUECOLOR placeholder handling and dark-theme tradeoff are docu
That styled capture is internal to the boolean detector only.
`fm-peek` and every other human or LLM-facing capture path stays plain `tmux capture-pane` with no escape codes.

**Primary-session guard fact (verified 2026-07-04, Claude Code 2.1.201; preserved 2026-07-08, Claude Code 2.1.204; Stop-owned auto-arm revalidated 2026-07-24, Claude Code 2.1.219).**
**Primary-session guard fact.**
[`docs/turnend-guard.md`](../../../docs/turnend-guard.md) owns the current mechanism, and [`docs/verification/supervision.md`](../../../docs/verification/supervision.md#turn-end-guard) owns dated evidence.
This is separate from the per-task crewmate turn-end hook above (that one just `touch`es a marker file in a task's own `.claude/settings.local.json`).
The firstmate PRIMARY's own `.claude/settings.json` registers two Stop hooks: `bin/fm-turnend-guard.sh --claude` and the Stop-owned auto-arm `bin/fm-claude-stop-autoarm.sh` (`asyncRewake: true`, `timeout: 28800`), and exiting the guard with status 2 plus stderr reliably forces the model to continue.
Claude Code's stdin payload to a Stop hook carries a `stop_hook_active` boolean that is `true` when the current stop attempt follows ANY stop-hook-driven continuation, including `asyncRewake` rewakes; the primary guard therefore ignores it in `--claude` mode and uses the cooperative claim/epoch check plus a bounded re-block budget instead, while the codex-mode default still treats it as a one-block loop guard.
Claude Code's stdin payload to a Stop hook carries a `stop_hook_active` boolean that is `true` when the current stop attempt follows ANY stop-hook-driven continuation, including `asyncRewake` rewakes; the primary guard therefore ignores it in `--claude` mode.
The current owner above defines its shared session-ownership boundary, one-shot escalation after two identical no-claim blocks, and separate bounded progression for verified automatic failures; the codex-mode default still treats `stop_hook_active` as a one-block loop guard.
A project-level `.claude/settings.json` only takes effect when Claude Code's project root is that exact directory - it does not walk up from a subdirectory looking for one, so firstmate launches the primary from the repo root.
After those settings are loaded, hook command resolution is still cwd-sensitive because Claude Code runs commands through `/bin/sh` against the session's current cwd; keep the tracked commands anchored through `"$CLAUDE_PROJECT_DIR"/bin/...` and see `docs/turnend-guard.md` for the verified Stop-hook details.
Claude Code's primary watcher protocol is Stop-owned: the auto-arm hook fires on every Stop and foregrounds `bin/fm-watch-arm.sh` when the home is eligible and still needs supervision, and its exit-2 `asyncRewake` rewake is the wake; the model drains and handles wakes but never runs a routine re-arm command.
Claude Code's primary watcher protocol is Stop-owned: the auto-arm hook fires on every Stop and foregrounds `bin/fm-watch-arm.sh` when the home is eligible and still needs supervision, and its exit-2 `asyncRewake` rewake is the wake; the model presents and handles wakes, runs the drain's printed post-handling acknowledgement, and never runs a routine re-arm command.

## codex (VERIFIED 2026-06-11, codex-cli 0.139.0)

Expand Down Expand Up @@ -287,8 +289,8 @@ The follow-up was verified in the interactive TUI; `opencode run` can exit befor
| Interrupt | single Escape |

Pi has no permission system, so crewmates are always autonomous.
Pi's `packages/coding-agent/docs/settings.md` UI and display section documents `regular` as the `tuiMode` default and `fullscreen` as experimental; fullscreen can bury steers by rewriting scrollback, so Firstmate avoids it when the installed CLI supports the override.
`fm-spawn.sh --help` owns the executable-pinning and version-safe launch mechanics.
Pi 0.83 removed the former `--tui-mode` startup option and its `tuiMode` setting, while remaining an interactive terminal application by default; `fm-spawn` therefore omits the obsolete option so Pi-family crews can start on current installations.
`fm-spawn.sh --help` owns the executable-pinning and launch mechanics.
`pi-signed` is the signed wrapper identity verified on version 0.82.0 and exposes the same CLI and TUI behavior as Pi.
Firstmate records `pi-signed` without normalization and refuses rather than falling back to `pi` when that wrapper is unavailable.
The observed signed process tree is an exact `pi-signed` wrapper parent with the Pi application as its child, while tmux reports the foreground command as the exact `pi-launcher` name for both selected executables.
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ state/ runtime records and signals; gitignored
.status-presentation-cursor .status-presentation-lock fleet-wide per-task status identity/byte-offset manifest and serialization lock preventing already-presented status lines from being replayed as new; owned by fm-classify-lib.sh, with each task's row retired by teardown
.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
.claude-autoarm.lock .claude-autoarm-epoch .claude-autoarm-entry-trace .claude-autoarm-entry-trace.lock .claude-autoarm-failure-notified .claude-autoarm-failure-alarmed .turnend-claude-blocks .turnend-claude-blocks.lock .turnend-claude-escalated Claude Stop auto-arm single-flight, epoch, bounded entry diagnostics, failure episode, attended alarm, guard budget, budget lock, and one-shot escalation 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
Expand Down
71 changes: 62 additions & 9 deletions bin/fm-claude-stop-autoarm.sh
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,9 @@ OWNER_LOCK="$STATE/.claude-autoarm.lock"
EPOCH="$STATE/.claude-autoarm-epoch"
FAILURE_NOTICE="$STATE/.claude-autoarm-failure-notified"
FAILURE_ALARM="$STATE/.claude-autoarm-failure-alarmed"
ENTRY_TRACE="$STATE/.claude-autoarm-entry-trace"
ENTRY_TRACE_LOCK="$STATE/.claude-autoarm-entry-trace.lock"
ENTRY_TRACE_MAX_LINES=256
AUTOARM_ATTEMPTS=${FM_CLAUDE_AUTOARM_ATTEMPTS:-2}
case "$AUTOARM_ATTEMPTS" in
1|2|3) : ;;
Expand All @@ -81,6 +84,31 @@ esac
# shellcheck source=bin/fm-hook-host-lib.sh
. "$SCRIPT_DIR/fm-hook-host-lib.sh"

# The bounded volatile entry trace distinguishes a hook that never ran from one
# that took a pre-claim gate. Trace I/O is strictly best-effort and never waits,
# prints, or changes the hook result. Concurrent appends may briefly exceed the
# bound; the next successful trimming claim restores it.
trace_entry_event() { # <entry|gate-name>
local event=$1 count tmp
[ -d "$STATE" ] || return 0
if [ -e "$ENTRY_TRACE" ] && { [ ! -f "$ENTRY_TRACE" ] || [ -L "$ENTRY_TRACE" ]; }; then
return 0
fi
printf 'at=%s pid=%s event=%s\n' "$(date +%s)" "${BASHPID:-$$}" "$event" \
>> "$ENTRY_TRACE" 2>/dev/null || return 0
fm_lock_try_acquire "$ENTRY_TRACE_LOCK" || return 0
count=$(awk 'END { print NR }' "$ENTRY_TRACE" 2>/dev/null || true)
case "$count" in ''|*[!0-9]*) count=0 ;; esac
if [ "$count" -gt "$ENTRY_TRACE_MAX_LINES" ]; then
tmp="$ENTRY_TRACE.tmp.${BASHPID:-$$}"
tail -n "$ENTRY_TRACE_MAX_LINES" "$ENTRY_TRACE" > "$tmp" 2>/dev/null \
&& mv -f "$tmp" "$ENTRY_TRACE" 2>/dev/null
rm -f "$tmp" 2>/dev/null || true
fi
fm_lock_release "$ENTRY_TRACE_LOCK"
return 0
}

# Consume the Stop payload once. The decisions below are state-based; the
# payload is read so a slow writer can never wedge on a full pipe, and its host
# is inspected before anything else runs.
Expand All @@ -93,9 +121,13 @@ PAYLOAD=$(cat 2>/dev/null || true)
# (docs/turnend-guard.md "Harness integrations"). Cursor's own park adapter owns
# its turn boundary, so stand down on a Cursor-delivered payload.
fm_hook_payload_is_foreign_host "$PAYLOAD" && exit 0
trace_entry_event entry

# --- scope: genuine primary checkout only -----------------------------------
fm_primary_scope_matches "$FM_ROOT" "$STATE" || exit 0
if ! fm_primary_scope_matches "$FM_ROOT" "$STATE"; then
trace_entry_event gate-scope
exit 0
fi

# --- identity: only the lock-owning session's hooks may arm ------------------
# A prior session may have died after leaving its numeric harness pid in .lock.
Expand All @@ -107,40 +139,61 @@ RECOVER_SESSION_LOCK=0
if ! fm_session_lock_owned_by_self "$STATE"; then
LOCK_PID=$(cat "$STATE/.lock" 2>/dev/null || true)
case "$LOCK_PID" in
''|*[!0-9]*) exit 0 ;;
'') trace_entry_event gate-lock-missing; exit 0 ;;
*[!0-9]*) trace_entry_event gate-lock-malformed; exit 0 ;;
esac
fm_harness_pid_alive "$LOCK_PID" && exit 0
if fm_harness_pid_alive "$LOCK_PID"; then
trace_entry_event gate-live-session-owner
exit 0
fi
RECOVER_SESSION_LOCK=1
fi

# --- AFK: the away daemon owns the watcher and triage; never rewake ----------
[ -e "$STATE/.afk" ] && exit 0
if [ -e "$STATE/.afk" ]; then
trace_entry_event gate-afk
exit 0
fi

# --- need: in-flight work or an X-mode relay poll ----------------------------
# --- need: work, relay polling, process sources, or queued wake delivery -----
need_supervision() {
fm_supervision_needed "$STATE" "$GRACE"
}
need_supervision || exit 0
if ! need_supervision; then
trace_entry_event gate-no-supervision
exit 0
fi

# --- stale session-lock recovery ---------------------------------------------
# Delegate the claim to fm-lock.sh so its live-owner refusal and write semantics
# remain the single acquisition owner, then re-verify current-session identity
# before touching any auto-arm state.
if [ "$RECOVER_SESSION_LOCK" -eq 1 ]; then
"$SCRIPT_DIR/fm-lock.sh" >/dev/null 2>&1 || exit 0
fm_session_lock_owned_by_self "$STATE" || exit 0
if ! "$SCRIPT_DIR/fm-lock.sh" >/dev/null 2>&1; then
trace_entry_event gate-lock-recovery-failed
exit 0
fi
if ! fm_session_lock_owned_by_self "$STATE"; then
trace_entry_event gate-identity-unresolved
exit 0
fi
fi

# --- single-flight owner claim ------------------------------------------------
# Claude runs one background process per firing with no dedupe. Exactly one
# owner foregrounds the arm and translates its close; every other firing exits
# 0 so one watcher cycle maps to at most one exit-2 rewake.
fm_lock_try_acquire "$OWNER_LOCK" || exit 0
if ! fm_lock_try_acquire "$OWNER_LOCK"; then
trace_entry_event gate-owner-lock-held
exit 0
fi
if ! fm_lock_set_role "$OWNER_LOCK" autoarm; then
trace_entry_event gate-owner-role-failed
fm_lock_release "$OWNER_LOCK"
exit 0
fi
trap 'fm_lock_release "$OWNER_LOCK"' EXIT
trace_entry_event claimed

write_epoch() { # <outcome>
local outcome=$1 seq tmp
Expand Down
14 changes: 7 additions & 7 deletions bin/fm-guard.sh
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
#!/usr/bin/env bash
# Watcher liveness and worktree-tangle guard, called by supervision scripts, by
# fm-wake-drain.sh after it empties queued wakes, and by fm-session-start.sh in
# fm-wake-drain.sh after it presents queued wakes, and by fm-session-start.sh in
# read-only advisory mode whenever session-lock ownership was not verified.
# First, always warn if the firstmate primary checkout (FM_ROOT) is on a named
# non-default branch, because that means firstmate-on-itself work landed in the
# primary instead of an isolated worktree.
# Then, if a task is in flight (a state/<id>.meta exists) or X-mode relay
# polling is active (state/x-watch.check.sh exists) and supervision is not
# Then, if a task is in flight, a process-event source is registered, X-mode
# Relay polling is active, or wake delivery is pending and supervision is not
# healthy, prints a loud, clearly delimited banner so the agent cannot skim past
# it in the tool output of whatever it was doing - the one channel every harness
# has. Supervision health is MODEL-AWARE (fm_watcher_supervision_verdict in
Expand Down Expand Up @@ -37,7 +37,6 @@ STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}"
CONFIG="${FM_CONFIG_OVERRIDE:-$FM_HOME/config}"
WATCH="$SCRIPT_DIR/fm-watch.sh"
GRACE=${FM_GUARD_GRACE:-300}
queue_pending=false
READ_ONLY=${FM_GUARD_READ_ONLY:-0}
case "$READ_ONLY" in 1|true|TRUE|yes|YES) READ_ONLY=1 ;; *) READ_ONLY=0 ;; esac
CONTINUE_LINE=${FM_GUARD_CONTINUE_LINE:-This is a supervision warning only; the guarded operation WILL still run.}
Expand Down Expand Up @@ -150,12 +149,13 @@ fi

# Compute supervision need and watcher-beacon freshness via the shared
# grace-based predicate (bin/fm-supervision-lib.sh). Act when work, an event
# source, or an X-mode relay poll needs supervision.
# source, an X-mode relay poll, or pending wake delivery needs supervision.
fm_supervision_status "$STATE" "$GRACE"
in_flight=$FM_SUP_IN_FLIGHT
sources=$FM_SUP_SOURCES
needed=$FM_SUP_NEEDED
beacon_desc=$FM_SUP_BEACON_DESC
queue_pending=$FM_SUP_QUEUE_PENDING
fm_watcher_supervision_verdict "$STATE" "$WATCH" "$GRACE" "$FM_HOME" "$FM_ROOT"
watcher_healthy=$FM_WATCHER_VERDICT_OK
watcher_down_reason=$FM_WATCHER_VERDICT_REASON
Expand All @@ -167,8 +167,6 @@ if [ "$needed" = false ]; then
exit 0
fi

[ -s "$FM_WAKE_QUEUE" ] && queue_pending=true

# No fresh watcher with tasks in flight is the dangerous state: emit a prominent,
# bordered banner FIRST so it reads as an alarm, not a buried stderr line. Later
# calls in the same episode get a one-line reminder only.
Expand Down Expand Up @@ -207,6 +205,8 @@ if [ "$watcher_healthy" = false ]; then
printf '● %s task(s) in flight, but %s.\n' "$in_flight" "$watcher_cause"
elif [ "$sources" -gt 0 ]; then
printf '● %s process-event source(s) registered, but %s.\n' "$sources" "$watcher_cause"
elif "$queue_pending"; then
printf '● Durable queued wake delivery pending, but %s.\n' "$watcher_cause"
else
printf '● X-mode relay polling needs supervision, but %s.\n' "$watcher_cause"
fi
Expand Down
25 changes: 4 additions & 21 deletions bin/fm-spawn.sh
Original file line number Diff line number Diff line change
Expand Up @@ -108,11 +108,9 @@
# overrides it for this spawn (either kind). A non-flag string containing
# whitespace is treated as a RAW launch command - the escape hatch for verifying
# new adapters. For pi and pi-signed, fm-spawn resolves the selected executable
# name from PATH once, probes that concrete path with --help, and launches the
# same path. It adds --tui-mode regular only when that help advertises the flag;
# a failed or inconclusive probe omits it so older Pi versions remain launchable.
# A missing selected executable refuses before endpoint creation, and pi-signed
# never falls back to pi.
# name from PATH once and launches the same path without the removed --tui-mode
# option. A missing selected executable refuses before endpoint creation, and
# pi-signed never falls back to pi.
# config/secondmate-harness may also carry an optional model and effort as extra
# whitespace-separated tokens ("<harness> [<model>] [<effort>]"). For a
# --secondmate spawn, those tokens apply only when this spawn also resolves its
Expand Down Expand Up @@ -151,7 +149,6 @@
# Launch templates live in launch_template() below; placeholders replaced before launch:
# __BRIEF__ absolute path to data/<task-id>/brief.md
# __PIBIN__ quoted concrete Pi-family executable path resolved from PATH
# __PITUIMODE__ optional --tui-mode regular when that executable advertises it
# __TURNEND__ absolute path to state/<task-id>.turn-ended (for harnesses whose
# turn-end signal rides the launch command, e.g. codex -c notify=[...])
# __PIEXT__ absolute path to state/<task-id>.pi-ext.ts (pi turn-end extension,
Expand Down Expand Up @@ -1087,15 +1084,6 @@ resolve_pi_executable() {
esac
}

# Pi's CLI surface is version-dependent, so probe the resolved executable's help
# before composing the optional regular-TUI flag. An absent or inconclusive probe
# omits the flag so older Pi versions can still spawn.
pi_supports_tui_mode() {
local executable=$1 help
help=$("$executable" --help 2>&1) || return 1
printf '%s\n' "$help" | grep -Eq -- '(^|[[:space:]])--tui-mode([[:space:]=]|$)'
}

# The verified launch command per adapter. The knowledge half of each adapter
# (busy-state source, exit command, dialogs, quirks) lives in the harness-adapters skill.
launch_template() {
Expand All @@ -1121,7 +1109,7 @@ launch_template() {
;;
opencode) printf '%s' 'OPENCODE_CONFIG_CONTENT='\''{"permission":{"*":"allow"}}'\'' opencode __MODELFLAG__--prompt "$(__OPINPUT__ encode launch-brief < __BRIEF__)"' ;;
pi|pi-signed)
printf '%s' '__PIBIN____PITUIMODE__'
printf '%s' '__PIBIN__'
if [ "$kind" = secondmate ]; then
printf '%s' ' __MODELFLAG____EFFORTFLAG__-e __PITURNEND__ -e __PIWATCH__ "$(__OPINPUT__ encode launch-brief < __BRIEF__)"'
else
Expand Down Expand Up @@ -1233,11 +1221,6 @@ case "$HARNESS" in
echo "error: $HARNESS executable not found on PATH; install it or select a different verified harness" >&2
exit 1
}
PI_TUI_MODE=
if pi_supports_tui_mode "$PI_BIN"; then
PI_TUI_MODE=' --tui-mode regular'
fi
LAUNCH=${LAUNCH//__PITUIMODE__/$PI_TUI_MODE}
LAUNCH="FM_PI_HARNESS=$HARNESS $LAUNCH"
;;
cursor)
Expand Down
Loading
Loading