From c05a4d57cef67154bdb0dd020e5e1a4e465862eb Mon Sep 17 00:00:00 2001 From: 420tombombadil Date: Thu, 24 Sep 2026 12:53:07 -0600 Subject: [PATCH 1/7] fix(afk): refuse away-mode pane escalation without an operator pane handle With no FM_SUPERVISOR_TARGET, no $TMUX_PANE, and no herdr pane, supervisor target discovery printed the constant firstmate:0 and the away daemon armed pane escalation against it whenever a pane of that name existed, which can be an unrelated crew or login shell. Discovery now returns no target in that case, and the daemon refuses to arm, naming target_source=UNAVAILABLE on stderr and in its durable log, instead of reporting a guessed target. The script-owned launcher's existing refusal names the same verdict. Explicit override, tmux pane, and herdr pane resolution are unchanged. Refs #1506 (defect A) --- .agents/skills/afk/SKILL.md | 7 ++- bin/fm-afk-launch.sh | 2 +- bin/fm-supervise-daemon.sh | 26 +++++---- bin/fm-supervisor-target-lib.sh | 19 ++++--- docs/configuration.md | 6 +- tests/fm-afk-launch.test.sh | 23 ++++++++ tests/fm-daemon.test.sh | 98 ++++++++++++++++++++++++++++++++- 7 files changed, 154 insertions(+), 27 deletions(-) diff --git a/.agents/skills/afk/SKILL.md b/.agents/skills/afk/SKILL.md index b296c56a2c6..47386d144bb 100644 --- a/.agents/skills/afk/SKILL.md +++ b/.agents/skills/afk/SKILL.md @@ -239,9 +239,10 @@ The single-line format makes submission unambiguous across harnesses; the carrie `$HERDR_PANE_ID` present (herdr), then a tmux fallback. Target: `FM_SUPERVISOR_TARGET` override (a tmux target or a herdr `":"` target), then `$TMUX_PANE`, then - `"${HERDR_SESSION:-default}:${HERDR_PANE_ID}"` under herdr, then a - `firstmate:0` fallback with a warning. Both resolution sources are logged at - startup so a wrong-but-resolving fallback is detectable. Other runtime + `"${HERDR_SESSION:-default}:${HERDR_PANE_ID}"` under herdr. With none of + those handles the daemon refuses to arm and logs `target_source=UNAVAILABLE` + instead of guessing a pane (docs/configuration.md owns the contract). Both + resolution sources are logged at startup. Other runtime backends, including zellij, orca, and cmux, are not yet supported as supervisor backends; the daemon refuses loudly at startup instead of misapplying tmux primitives to a pane that isn't one diff --git a/bin/fm-afk-launch.sh b/bin/fm-afk-launch.sh index 7cb5b11d83d..512c36fcc56 100755 --- a/bin/fm-afk-launch.sh +++ b/bin/fm-afk-launch.sh @@ -717,7 +717,7 @@ fm_afk_launch_start() { fm_afk_launch_record_require || return 1 # Capture the captain pane FIRST, before creating anything. captain_target=$(discover_supervisor_target) || { - fm_afk_launch_log "could not resolve the captain supervisor pane (set FM_SUPERVISOR_TARGET)" + fm_afk_launch_log "away-mode pane escalation unavailable: no operator pane handle (target_source=UNAVAILABLE; no FM_SUPERVISOR_TARGET, TMUX_PANE, or HERDR_ENV+HERDR_PANE_ID); refusing to launch the daemon" return 1; } captain_backend=$(discover_supervisor_backend) || { fm_afk_launch_log "could not resolve the captain supervisor backend (set FM_SUPERVISOR_BACKEND)" diff --git a/bin/fm-supervise-daemon.sh b/bin/fm-supervise-daemon.sh index 7a7191807df..d0dcb54dc6a 100755 --- a/bin/fm-supervise-daemon.sh +++ b/bin/fm-supervise-daemon.sh @@ -80,8 +80,9 @@ # FM_SUPERVISOR_TARGET supervisor pane target (override; otherwise # auto-discovered per backend - $TMUX_PANE # under tmux, ":" from -# $HERDR_PANE_ID under herdr - then -# firstmate:0 fallback). Accepts either a +# $HERDR_PANE_ID under herdr; with none of +# these the daemon refuses to arm and logs +# target_source=UNAVAILABLE). Accepts either a # tmux target or a herdr ":" # target; which one it's read as is decided by # FM_SUPERVISOR_BACKEND (below), independently. @@ -1818,9 +1819,12 @@ fm_super_main() { # --- auto-discover the supervisor target (the pane running firstmate) ----- # Priority: FM_SUPERVISOR_TARGET override > $TMUX_PANE (tmux; inherited from # the pane that launched the daemon, normally firstmate's own) > - # $HERDR_PANE_ID (herdr, composed into ":") > firstmate:0 - # fallback. Exporting the result into FM_SUPERVISOR_TARGET makes inject_msg - # (which reads that env var) use the discovered pane without an extra global. + # $HERDR_PANE_ID (herdr, composed into ":"). With none of + # those handles there is no verifiable operator session: refuse to arm pane + # escalation instead of aiming it at a constant that may name an unrelated + # crew or login shell (kunchenguid/firstmate#1506). Exporting the result into + # FM_SUPERVISOR_TARGET makes inject_msg (which reads that env var) use the + # discovered pane without an extra global. local discovered target_source target_source="FM_SUPERVISOR_TARGET" if [ -z "${FM_SUPERVISOR_TARGET:-}" ]; then @@ -1829,13 +1833,15 @@ fm_super_main() { elif [ "${HERDR_ENV:-}" = "1" ] && [ -n "${HERDR_PANE_ID:-}" ]; then target_source="HERDR_ENV(HERDR_PANE_ID)" else - target_source="FALLBACK(firstmate:0)" + target_source="UNAVAILABLE" fi fi - if discovered=$(discover_supervisor_target); then - : # resolved cleanly - else - echo "warn: could not auto-discover supervisor pane (no FM_SUPERVISOR_TARGET, TMUX_PANE, or HERDR_ENV/HERDR_PANE_ID); falling back to '$discovered' — verify this is firstmate's pane" >&2 + if ! discovered=$(discover_supervisor_target); then + echo "error: away-mode pane escalation unavailable: no operator pane handle (target_source=UNAVAILABLE; no FM_SUPERVISOR_TARGET, TMUX_PANE, or HERDR_ENV+HERDR_PANE_ID); refusing to arm - set FM_SUPERVISOR_TARGET and FM_SUPERVISOR_BACKEND to firstmate's own pane" >&2 + log "startup refused: away-mode pane escalation unavailable; target_source=UNAVAILABLE; backend_source=$backend_source" + fm_lock_release "$LOCK" 2>/dev/null || true + rm -f "$PIDFILE" 2>/dev/null || true + exit 1 fi FM_SUPERVISOR_TARGET="$discovered" local TARGET="$FM_SUPERVISOR_TARGET" diff --git a/bin/fm-supervisor-target-lib.sh b/bin/fm-supervisor-target-lib.sh index 7f613db2028..d439926989a 100755 --- a/bin/fm-supervisor-target-lib.sh +++ b/bin/fm-supervisor-target-lib.sh @@ -14,10 +14,11 @@ # in bin/fm-supervise-daemon.sh, so its unit tests (tests/fm-daemon.test.sh) # keep exercising the same names after the daemon sources this file. -# Default supervisor pane target/backend when nothing is configured or detected. -# "firstmate:0" is a tmux session:window name, so the bare fallback (nothing -# configured, nothing detected) assumes tmux - matching the daemon's pre-herdr -# behavior byte-for-byte when run outside both tmux and herdr. +# Library-mode defaults for the daemon's sourced inject/alarm helpers, which +# read FM_SUPERVISOR_TARGET/FM_SUPERVISOR_BACKEND with these as the unset +# fallback. They are never a discovery result: discover_supervisor_target prints +# nothing when no operator pane handle exists, and the executed daemon refuses +# to arm rather than aim pane escalation at a constant (kunchenguid/firstmate#1506). FM_SUPERVISOR_TARGET_DEFAULT="firstmate:0" FM_SUPERVISOR_BACKEND_DEFAULT="tmux" @@ -33,8 +34,10 @@ FM_SUPERVISOR_BACKEND_DEFAULT="tmux" # fm_backend_herdr_session) and $HERDR_PANE_ID. Checked after $TMUX_PANE so a # tmux pane nested inside herdr still resolves to tmux, matching # fm_backend_detect's innermost-first rule. -# 4. FM_SUPERVISOR_TARGET_DEFAULT - legacy tmux fallback (may not resolve if the -# session is named differently). Returns 1 so the caller can warn. +# 4. Nothing: print nothing and return 1. Without one of the handles above +# there is no verifiable operator session, so the caller must refuse +# rather than guess a pane (a constant like firstmate:0 can name an +# unrelated crew or login shell). discover_supervisor_target() { if [ -n "${FM_SUPERVISOR_TARGET:-}" ]; then printf '%s' "$FM_SUPERVISOR_TARGET" @@ -48,7 +51,6 @@ discover_supervisor_target() { printf '%s:%s' "${HERDR_SESSION:-default}" "$HERDR_PANE_ID" return 0 fi - printf '%s' "$FM_SUPERVISOR_TARGET_DEFAULT" return 1 } @@ -59,7 +61,8 @@ discover_supervisor_target() { # 1. FM_SUPERVISOR_BACKEND env (explicit override). # 2. $TMUX_PANE set - tmux. # 3. $HERDR_ENV=1 (with $HERDR_PANE_ID present) - herdr. -# 4. FM_SUPERVISOR_BACKEND_DEFAULT (tmux) - matches the target fallback. Returns 1. +# 4. FM_SUPERVISOR_BACKEND_DEFAULT (tmux) - the transport for an explicit +# FM_SUPERVISOR_TARGET given without a backend. Returns 1. discover_supervisor_backend() { if [ -n "${FM_SUPERVISOR_BACKEND:-}" ]; then printf '%s' "$FM_SUPERVISOR_BACKEND" diff --git a/docs/configuration.md b/docs/configuration.md index c97571fc862..1e08bc11c34 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -525,8 +525,10 @@ Set `FM_SUPERVISOR_BACKEND=tmux|herdr` and `FM_SUPERVISOR_TARGET=` to ov Without overrides, backend detection uses `$TMUX_PANE` first, then `HERDR_ENV=1` with `HERDR_PANE_ID`, then falls back to `tmux`. That keeps a tmux pane nested inside herdr on the tmux transport, matching the runtime backend's innermost-first rule. -Target detection uses `FM_SUPERVISOR_TARGET`, then `$TMUX_PANE`, then `"${HERDR_SESSION:-default}:${HERDR_PANE_ID}"` under herdr, then the legacy `firstmate:0` tmux fallback with a warning. - +Target detection uses `FM_SUPERVISOR_TARGET`, then `$TMUX_PANE`, then `"${HERDR_SESSION:-default}:${HERDR_PANE_ID}"` under herdr. +With none of those operator pane handles, away-mode pane escalation is unavailable and nothing is aimed at a guessed pane. +The daemon then refuses to arm, naming `target_source=UNAVAILABLE` on stderr and in `state/.supervise-daemon.log`. +`bin/fm-afk-launch.sh start` refuses before launching it with the same verdict on stderr and in that log. Selecting any other supervisor backend, including `zellij`, `orca`, or `cmux`, refuses at daemon startup instead of trying tmux injection primitives against a non-tmux pane. ## Away-mode wedge alarm channels (config/wedge-alarm) diff --git a/tests/fm-afk-launch.test.sh b/tests/fm-afk-launch.test.sh index ac477a8864e..6b903a157d1 100755 --- a/tests/fm-afk-launch.test.sh +++ b/tests/fm-afk-launch.test.sh @@ -214,6 +214,28 @@ unit_daemon_entry_requires_the_record() { rm -rf "$st" } +# kunchenguid/firstmate#1506 defect A: with no explicit FM_SUPERVISOR_TARGET, no +# $TMUX_PANE, and no herdr pane there is no operator pane to hand the daemon, so +# `start` refuses by naming the unavailable target source and launches nothing. +unit_start_refuses_without_operator_pane_handle() { + local st out rc + st=$(mktemp -d "${TMPDIR:-/tmp}/fm-afk-no-handle.XXXXXX") + mkdir -p "$st/state" + enter_posture "$st" || fail "no handle: could not enter fixture posture" + out=$(env -u TMUX -u TMUX_PANE -u HERDR_ENV -u HERDR_PANE_ID -u FM_SUPERVISOR_TARGET -u FM_SUPERVISOR_BACKEND \ + FM_HOME="$st" FM_STATE_OVERRIDE="$st/state" FM_AFK_LAUNCH_ENTRY="$SLEEPER" "$LAUNCH" start 2>&1) + rc=$? + if [ "$rc" -ne 0 ] && printf '%s' "$out" | grep -F 'target_source=UNAVAILABLE' >/dev/null \ + && ! printf '%s' "$out" | grep -F 'firstmate:0' >/dev/null \ + && [ ! -e "$st/state/.afk" ] && [ ! -e "$st/state/.afk-daemon-terminal" ] \ + && [ -f "$st/state/.afk-contract" ]; then + pass "no handle: start refuses naming target_source=UNAVAILABLE, launches no daemon terminal, and keeps the record" + else + fail "no handle: start did not refuse cleanly (rc=$rc): $out" + fi + rm -rf "$st" +} + unit_failed_daemon_launch_preserves_the_record() { local st st=$(mktemp -d "${TMPDIR:-/tmp}/fm-afk-failed-record.XXXXXX") @@ -1687,6 +1709,7 @@ unit_pi_never_launches_the_daemon unit_test_harness_seam_requires_the_marker unit_pi_enter_stop_does_not_claim_a_daemon_terminal unit_daemon_entry_requires_the_record +unit_start_refuses_without_operator_pane_handle unit_failed_daemon_launch_preserves_the_record unit_stop_archives_the_record_last unit_relative_paths_are_absolute_before_daemon_launch diff --git a/tests/fm-daemon.test.sh b/tests/fm-daemon.test.sh index efc0e6bda52..7db91f6eff2 100755 --- a/tests/fm-daemon.test.sh +++ b/tests/fm-daemon.test.sh @@ -2972,11 +2972,101 @@ test_discover_supervisor_target_herdr() { [ "$out" = "iso1:w1:p9" ] || fail "herdr target should use an explicit HERDR_SESSION: $out" if out=$(FM_SUPERVISOR_TARGET='' TMUX_PANE='' HERDR_ENV='' HERDR_PANE_ID='' discover_supervisor_target); then - fail "bare fallback should return non-zero" + fail "no operator pane handle should return non-zero" fi - [ "$out" = "firstmate:0" ] || fail "bare fallback should still print firstmate:0: $out" + [ -z "$out" ] || fail "no operator pane handle must print no target, not a constant: $out" + + pass "discover_supervisor_target: override > TMUX_PANE > herdr ':' composition > no target" +} + +# Run the real daemon executable against an isolated home with every ambient +# pane handle removed, plus any extra env assignments, then wait (iteration +# bounded) until it either exits on its own or logs its armed startup line. +# A daemon still running at that point is stopped. Prints its exit status, or +# "armed" when it had to be stopped; stderr lands in /daemon.err. +run_daemon_startup() { # [VAR=value...] + local dir=$1 pid i=0 rc=armed + shift + env -u TMUX -u TMUX_PANE -u HERDR_ENV -u HERDR_PANE_ID -u HERDR_SESSION \ + -u FM_SUPERVISOR_TARGET -u FM_SUPERVISOR_BACKEND \ + PATH="$dir/fakebin:$PATH" FM_HOME="$dir" FM_STATE_OVERRIDE="$dir/state" \ + FM_POLL=1 FM_HEARTBEAT=999999 FM_CHECK_INTERVAL=999999 FM_INJECT_FAIL_SLEEP=1 \ + "$@" "$DAEMON" >"$dir/daemon.out" 2>"$dir/daemon.err" & + pid=$! + while [ "$i" -lt 150 ]; do + if ! kill -0 "$pid" 2>/dev/null; then + wait "$pid" + rc=$? + break + fi + grep -q 'daemon starting' "$dir/state/.supervise-daemon.log" 2>/dev/null && break + sleep 0.1 + i=$((i + 1)) + done + if [ "$rc" = armed ]; then + kill -TERM "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true + fi + printf '%s' "$rc" +} + +# kunchenguid/firstmate#1506 defect A: with no explicit FM_SUPERVISOR_TARGET, no +# $TMUX_PANE, and no herdr pane there is no verifiable operator-session handle. +# A pane named firstmate:0 existing (here every tmux target resolves, as an +# unrelated crew or login shell of that name would) must not turn that absence +# into a delivery target: the daemon refuses to arm pane escalation, names the +# unavailable target source on stderr and in its durable log, and never logs +# the armed startup line. +test_daemon_refuses_to_arm_without_operator_pane_handle() { + local dir rc err + dir=$(make_supercase daemon-no-pane-handle) + rc=$(run_daemon_startup "$dir") + err=$(cat "$dir/daemon.err" 2>/dev/null) + + [ "$rc" != armed ] || fail "daemon armed pane escalation with no operator pane handle (log: $(cat "$dir/state/.supervise-daemon.log" 2>/dev/null))" + [ "$rc" != 0 ] || fail "daemon exited 0 without an operator pane handle; it must report a refusal" + assert_contains "$err" "target_source=UNAVAILABLE" "the refusal did not name the unavailable target source on stderr" + assert_not_contains "$err" "firstmate:0" "the refusal still offered the firstmate:0 constant as a target" + assert_contains "$(cat "$dir/state/.supervise-daemon.log" 2>/dev/null)" "target_source=UNAVAILABLE" \ + "the durable daemon log did not record the unavailable target source" + assert_not_contains "$(cat "$dir/state/.supervise-daemon.log" 2>/dev/null)" "daemon starting" \ + "the daemon logged an armed startup without an operator pane handle" + assert_absent "$dir/state/.supervise-daemon.pid" "the refused daemon left its pid file behind" + assert_absent "$dir/state/.supervise-daemon.lock" "the refused daemon left its singleton lock held" + pass "daemon refuses to arm pane escalation when no operator pane handle exists (#1506 defect A)" +} + +# The three verifiable handles still arm the daemon exactly as before. +test_daemon_arms_on_each_verifiable_operator_pane_handle() { + local dir rc log + dir=$(make_supercase daemon-handle-explicit) + rc=$(run_daemon_startup "$dir" FM_SUPERVISOR_TARGET=explicit:pane FM_SUPERVISOR_BACKEND=tmux) + log=$(cat "$dir/state/.supervise-daemon.log" 2>/dev/null) + [ "$rc" = armed ] || fail "explicit FM_SUPERVISOR_TARGET no longer arms the daemon (rc=$rc): $(cat "$dir/daemon.err")" + assert_contains "$log" "target=explicit:pane; target_source=FM_SUPERVISOR_TARGET; backend=tmux" \ + "explicit override startup line changed" + + dir=$(make_supercase daemon-handle-tmux) + rc=$(run_daemon_startup "$dir" TMUX_PANE=%42) + log=$(cat "$dir/state/.supervise-daemon.log" 2>/dev/null) + [ "$rc" = armed ] || fail "TMUX_PANE no longer arms the daemon (rc=$rc): $(cat "$dir/daemon.err")" + assert_contains "$log" "target=%42; target_source=TMUX_PANE; backend=tmux; backend_source=TMUX_PANE" \ + "TMUX_PANE startup line changed" + + dir=$(make_supercase daemon-handle-herdr) + cat > "$dir/fakebin/herdr" <<'SH' +#!/usr/bin/env bash +[ "${1:-} ${2:-} ${3:-}" = "pane get w1:p9" ] && exit 0 +exit 1 +SH + chmod +x "$dir/fakebin/herdr" + rc=$(run_daemon_startup "$dir" HERDR_ENV=1 HERDR_PANE_ID=w1:p9 HERDR_SESSION=fm-lab-daemon-handle) + log=$(cat "$dir/state/.supervise-daemon.log" 2>/dev/null) + [ "$rc" = armed ] || fail "HERDR_ENV+HERDR_PANE_ID no longer arms the daemon (rc=$rc): $(cat "$dir/daemon.err")" + assert_contains "$log" "target=fm-lab-daemon-handle:w1:p9; target_source=HERDR_ENV(HERDR_PANE_ID); backend=herdr; backend_source=HERDR_ENV" \ + "herdr startup line changed" - pass "discover_supervisor_target: override > TMUX_PANE > herdr ':' composition > firstmate:0 fallback" + pass "daemon still arms on an explicit target, a tmux pane, and a herdr pane" } test_pane_is_busy_herdr_native_busy_state() { @@ -3271,6 +3361,8 @@ test_fm_send_exits_nonzero_on_initial_send_failure test_fm_send_exits_nonzero_on_unproven_submit test_discover_supervisor_backend_precedence test_discover_supervisor_target_herdr +test_daemon_refuses_to_arm_without_operator_pane_handle +test_daemon_arms_on_each_verifiable_operator_pane_handle test_pane_is_busy_herdr_native_busy_state test_primary_busy_guard_is_harness_scoped test_pane_is_busy_defaults_to_tmux_when_backend_omitted From 14e831407a23f07616349689f256d22bb0bc56ed Mon Sep 17 00:00:00 2001 From: 420tombombadil Date: Thu, 24 Sep 2026 17:01:29 -0600 Subject: [PATCH 2/7] fix(afk): record the launcher's no-handle refusal durably `bin/fm-afk-launch.sh start` refused without an operator pane handle only on stderr, so a later look at the home could not tell that refusal from a launch that was never attempted. It now appends the same `startup refused ... target_source=UNAVAILABLE` line the daemon writes to state/.supervise-daemon.log, keeping the stderr refusal. Refs #1506 (defect A) --- bin/fm-afk-launch.sh | 7 +++++++ tests/fm-afk-launch.test.sh | 7 ++++--- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/bin/fm-afk-launch.sh b/bin/fm-afk-launch.sh index 512c36fcc56..044b582fc5f 100755 --- a/bin/fm-afk-launch.sh +++ b/bin/fm-afk-launch.sh @@ -718,6 +718,13 @@ fm_afk_launch_start() { # Capture the captain pane FIRST, before creating anything. captain_target=$(discover_supervisor_target) || { fm_afk_launch_log "away-mode pane escalation unavailable: no operator pane handle (target_source=UNAVAILABLE; no FM_SUPERVISOR_TARGET, TMUX_PANE, or HERDR_ENV+HERDR_PANE_ID); refusing to launch the daemon" + # Durable record in the daemon's own append-only log and line format, so a + # later look at the home tells this refusal apart from a launch never tried. + if ! { mkdir -p "$FM_AFK_LAUNCH_STATE" \ + && printf '[%s] startup refused: away-mode pane escalation unavailable; target_source=UNAVAILABLE; refused_by=fm-afk-launch start\n' \ + "$(date '+%Y-%m-%dT%H:%M:%S%z')" >> "$FM_AFK_LAUNCH_STATE/.supervise-daemon.log"; } 2>/dev/null; then + fm_afk_launch_log "could not record the refusal in $FM_AFK_LAUNCH_STATE/.supervise-daemon.log" + fi return 1; } captain_backend=$(discover_supervisor_backend) || { fm_afk_launch_log "could not resolve the captain supervisor backend (set FM_SUPERVISOR_BACKEND)" diff --git a/tests/fm-afk-launch.test.sh b/tests/fm-afk-launch.test.sh index 6b903a157d1..84f3e3637a6 100755 --- a/tests/fm-afk-launch.test.sh +++ b/tests/fm-afk-launch.test.sh @@ -228,10 +228,11 @@ unit_start_refuses_without_operator_pane_handle() { if [ "$rc" -ne 0 ] && printf '%s' "$out" | grep -F 'target_source=UNAVAILABLE' >/dev/null \ && ! printf '%s' "$out" | grep -F 'firstmate:0' >/dev/null \ && [ ! -e "$st/state/.afk" ] && [ ! -e "$st/state/.afk-daemon-terminal" ] \ - && [ -f "$st/state/.afk-contract" ]; then - pass "no handle: start refuses naming target_source=UNAVAILABLE, launches no daemon terminal, and keeps the record" + && [ -f "$st/state/.afk-contract" ] \ + && grep -E '^\[[0-9T:+-]+\] startup refused: .*target_source=UNAVAILABLE' "$st/state/.supervise-daemon.log" >/dev/null 2>&1; then + pass "no handle: start refuses naming target_source=UNAVAILABLE on stderr and in the daemon log, launches no daemon terminal, and keeps the record" else - fail "no handle: start did not refuse cleanly (rc=$rc): $out" + fail "no handle: start did not refuse cleanly or left no durable record (rc=$rc): $out; log: $(cat "$st/state/.supervise-daemon.log" 2>/dev/null)" fi rm -rf "$st" } From c6f615220202ef61d7571339ee39bbe21dc21397 Mon Sep 17 00:00:00 2001 From: 420tombombadil Date: Thu, 24 Sep 2026 17:33:56 -0600 Subject: [PATCH 3/7] no-mistakes(document): Document supervisor target refusal and native failure behavior --- .agents/skills/afk/SKILL.md | 17 ++--------------- docs/configuration.md | 1 + 2 files changed, 3 insertions(+), 15 deletions(-) diff --git a/.agents/skills/afk/SKILL.md b/.agents/skills/afk/SKILL.md index 47386d144bb..f05d5dc3beb 100644 --- a/.agents/skills/afk/SKILL.md +++ b/.agents/skills/afk/SKILL.md @@ -232,21 +232,8 @@ The single-line format makes submission unambiguous across harnesses; the carrie (`fm-wake-lib.sh`) instead of `flock`, which is absent on macOS. - **Dedupe across signal/stale/scan** - all three paths use the shared status presentation markers defined by `bin/fm-classify-lib.sh`, so a successfully classified span is not re-escalated by another path in the same digest. Never treat a reported unreadable state as classified; the shared library header owns that marker contract, and the marker does not clear or suppress possible-wedge aging for a nonterminal progress line. -- **Auto-discovered supervisor pane** - the daemon resolves its own BACKEND - (tmux vs herdr) and TARGET independently, mirroring - `bin/fm-backend.sh`'s own runtime auto-detection. Backend: `FM_SUPERVISOR_BACKEND` - override, then `$TMUX_PANE` set (tmux), then `$HERDR_ENV=1` with - `$HERDR_PANE_ID` present (herdr), then a tmux fallback. Target: - `FM_SUPERVISOR_TARGET` override (a tmux target or a herdr - `":"` target), then `$TMUX_PANE`, then - `"${HERDR_SESSION:-default}:${HERDR_PANE_ID}"` under herdr. With none of - those handles the daemon refuses to arm and logs `target_source=UNAVAILABLE` - instead of guessing a pane (docs/configuration.md owns the contract). Both - resolution sources are logged at startup. Other runtime - backends, including zellij, orca, and cmux, are not yet supported as - supervisor backends; the daemon refuses loudly at startup instead of - misapplying tmux primitives to a pane that isn't one - (docs/herdr-backend.md "Away-mode supervisor support"). +- **Supervisor pane discovery** - use [the away-mode supervisor configuration](../../../docs/configuration.md#away-mode-supervisor-backend-fm_supervisor_backend--fm_supervisor_target) for the backend and target resolution contract, including refusal when no operator pane handle exists. + Check the daemon's startup result before treating away supervision as armed. ### Stale-artifact lifecycle diff --git a/docs/configuration.md b/docs/configuration.md index 1e08bc11c34..1fa288c357f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -529,6 +529,7 @@ Target detection uses `FM_SUPERVISOR_TARGET`, then `$TMUX_PANE`, then `"${HERDR_ With none of those operator pane handles, away-mode pane escalation is unavailable and nothing is aimed at a guessed pane. The daemon then refuses to arm, naming `target_source=UNAVAILABLE` on stderr and in `state/.supervise-daemon.log`. `bin/fm-afk-launch.sh start` refuses before launching it with the same verdict on stderr and in that log. +On the native Claude and Grok background-job path, the `state/.afk` flag is written before this daemon refusal; the [turn-end guard](turnend-guard.md#guard-predicates) blocks the away turn when no daemon owns supervision. Selecting any other supervisor backend, including `zellij`, `orca`, or `cmux`, refuses at daemon startup instead of trying tmux injection primitives against a non-tmux pane. ## Away-mode wedge alarm channels (config/wedge-alarm) From 96178917d17b6ac5dcce5fb7b139f90819c62ae2 Mon Sep 17 00:00:00 2001 From: 420tombombadil Date: Fri, 25 Sep 2026 21:35:40 -0600 Subject: [PATCH 4/7] no-mistakes(document): Fix stale away-mode supervisor documentation link --- .agents/skills/afk/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.agents/skills/afk/SKILL.md b/.agents/skills/afk/SKILL.md index f05d5dc3beb..589e6eb8fa4 100644 --- a/.agents/skills/afk/SKILL.md +++ b/.agents/skills/afk/SKILL.md @@ -119,7 +119,7 @@ For other harnesses, the operational prefix travels with the message text; neith The daemon never injects into an in-use pane. Two checks run before every injection, dispatched through `bin/fm-backend.sh` for the supervisor's own -backend (tmux or herdr; see "Auto-discovered supervisor pane" below): +backend (tmux or herdr; see [supervisor configuration](../../../docs/configuration.md#away-mode-supervisor-backend-fm_supervisor_backend--fm_supervisor_target)): - **Primary-pane busy guard** - `pane_is_busy` trusts Herdr native `busy` when available, otherwise matches rendered output against only the detected primary harness's signature. This narrow delivery guard never classifies a recorded worker task and never uses a global union of vendor patterns. From 194d464000f816e6c81bd3592215bb182e4bcf0b Mon Sep 17 00:00:00 2001 From: 420tombombadil Date: Fri, 25 Sep 2026 23:06:37 -0600 Subject: [PATCH 5/7] fix(afk): keep the library-mode supervisor target default lint-clean FM_SUPERVISOR_TARGET_DEFAULT is no longer printed by discovery, but the daemon's inject and wedge-alarm helpers still read it after sourcing this library as the unset fallback for FM_SUPERVISOR_TARGET. Full cross-file ShellCheck therefore reported it as unused (SC2034). Mark it with the repository's sourced-global directive instead of changing behavior. Also restore the paragraph break before the unsupported-backend sentence in docs/configuration.md. Refs #1506 (defect A) --- bin/fm-supervisor-target-lib.sh | 1 + docs/configuration.md | 1 + 2 files changed, 2 insertions(+) diff --git a/bin/fm-supervisor-target-lib.sh b/bin/fm-supervisor-target-lib.sh index d439926989a..a0203cb21aa 100755 --- a/bin/fm-supervisor-target-lib.sh +++ b/bin/fm-supervisor-target-lib.sh @@ -19,6 +19,7 @@ # fallback. They are never a discovery result: discover_supervisor_target prints # nothing when no operator pane handle exists, and the executed daemon refuses # to arm rather than aim pane escalation at a constant (kunchenguid/firstmate#1506). +# shellcheck disable=SC2034 # Read by fm-supervise-daemon.sh's inject/alarm helpers after sourcing, not this lib. FM_SUPERVISOR_TARGET_DEFAULT="firstmate:0" FM_SUPERVISOR_BACKEND_DEFAULT="tmux" diff --git a/docs/configuration.md b/docs/configuration.md index 1fa288c357f..9fade7f310b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -530,6 +530,7 @@ With none of those operator pane handles, away-mode pane escalation is unavailab The daemon then refuses to arm, naming `target_source=UNAVAILABLE` on stderr and in `state/.supervise-daemon.log`. `bin/fm-afk-launch.sh start` refuses before launching it with the same verdict on stderr and in that log. On the native Claude and Grok background-job path, the `state/.afk` flag is written before this daemon refusal; the [turn-end guard](turnend-guard.md#guard-predicates) blocks the away turn when no daemon owns supervision. + Selecting any other supervisor backend, including `zellij`, `orca`, or `cmux`, refuses at daemon startup instead of trying tmux injection primitives against a non-tmux pane. ## Away-mode wedge alarm channels (config/wedge-alarm) From bab1ee68dfe907b4a8a6e0eb2d1ff8d944663d3a Mon Sep 17 00:00:00 2001 From: 420tombombadil Date: Fri, 25 Sep 2026 23:11:10 -0600 Subject: [PATCH 6/7] no-mistakes(document): Clarify supervisor target discovery requirements --- docs/configuration.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/configuration.md b/docs/configuration.md index 9fade7f310b..5e5cc1d22fd 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -2380,7 +2380,7 @@ FM_SEND_SETTLE=1 # seconds fm-send waits after a successful typed-plane s FM_PENDING_REPLY_GRACE_SECS=120 # seconds after marked-request delivery before a completed turn without a correlated parent report is eligible for its one recovery repost # sub-supervisor (bin/fm-supervise-daemon.sh); presence-gated via /afk FM_SUPERVISOR_BACKEND= # optional supervisor pane backend override; tmux/herdr only, otherwise detects $TMUX_PANE then HERDR_ENV/HERDR_PANE_ID before tmux fallback -FM_SUPERVISOR_TARGET= # optional supervisor pane target override; tmux target or herdr :, otherwise auto-detected +FM_SUPERVISOR_TARGET= # optional supervisor pane target override; tmux target or herdr :; without it, discovery requires TMUX_PANE or HERDR_ENV=1 with HERDR_PANE_ID FM_INJECT_SKIP=heartbeat # |-prefixes force-self-handled bypassing classification; empty disables FM_ESCALATE_BATCH_SECS=90 # buffer window for batched escalation digests; 0 = flush immediately FM_MAX_DEFER_SECS=300 # max buffered escalation age before retry plus wedge alarm; 0 disables From 1ae07a1aaa47bcb6b779073360ea6a51b426b136 Mon Sep 17 00:00:00 2001 From: 420tombombadil Date: Tue, 29 Sep 2026 09:18:13 -0600 Subject: [PATCH 7/7] no-mistakes(document): Document supervisor refusal and daemon ownership conditions --- .agents/skills/afk/SKILL.md | 2 +- docs/architecture.md | 2 +- docs/turnend-guard.md | 5 ++--- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/.agents/skills/afk/SKILL.md b/.agents/skills/afk/SKILL.md index 589e6eb8fa4..37d38482d25 100644 --- a/.agents/skills/afk/SKILL.md +++ b/.agents/skills/afk/SKILL.md @@ -103,7 +103,7 @@ Destructive, irreversible, and security-sensitive actions are never pre-authoriz ## The daemon, where it still runs -On the harnesses that still launch the daemon (every verified harness except Pi and pi-signed, and except away mode on a home with `config/supervision-host`), the mechanics below are unchanged. +On the harnesses that still launch the daemon (every verified harness except Pi and pi-signed, and except away mode on a home with `config/supervision-host`), the delivery mechanics below apply once startup succeeds. ### Operational prefix contract diff --git a/docs/architecture.md b/docs/architecture.md index de28808b9ce..31e98ea70a2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -208,7 +208,7 @@ The always-on watcher also uses that library's absorb classification on no-verb The daemon's declared-wait window ages against the crew's own latest status line rather than against pane busy state, because a declared wait can legitimately hold a pane busy, and only a status append that stops declaring the wait ends that routing and restores wedge detection. A wake already decorated as a possible wedge does not override the daemon's own declared-wait verdict either, so a declaration keeps its pane on the recheck cadence instead of the wedge cadence. In away mode, seen-status dedupe does not clear possible-wedge aging for nonterminal progress, so housekeeping still re-escalates an unchanged idle pane at the configured bound. -Away-mode housekeeping has no worktree-write deferral of its own, so while `state/.afk` exists a quiet crew that is writing its own worktree still escalates as a possible wedge at that bound. +When the away daemon is running, its housekeeping has no worktree-write deferral, so a quiet crew that is writing its own worktree still escalates as a possible wedge at that bound. The daemon escalates captain-relevant events, plus a bounded recheck for a declared external wait that is still declared, as one batched, single-line digest using the canonical `away-supervisor` kind from `bin/fm-operational-input.sh`; a Claude Code primary receives that owner's record-backed doorbell instead of the stripped invisible marker, so firstmate can distinguish the escalation from ordinary captain messages. Captain-held transfers remain silent until return while the away record exists. Its supervisor injection path supports tmux and herdr panes, with `FM_SUPERVISOR_BACKEND` and `FM_SUPERVISOR_TARGET` resolved independently from the task-spawn backend. diff --git a/docs/turnend-guard.md b/docs/turnend-guard.md index 23af69a0eb1..c51157717c3 100644 --- a/docs/turnend-guard.md +++ b/docs/turnend-guard.md @@ -46,8 +46,7 @@ When the guard acts, the harness integration must do one of two things: The mid-turn pull warning uses the model-aware supervision verdict described below, while the turn-end guard keeps the PID-strict watcher predicate. Away and quiet mode are the one place the turn-end guard accepts a different supervisor. -While `state/.afk` exists, in either mode (`bin/fm-wake-lib.sh`'s `fm_afk_mode`), the daemon owns supervision. -A live identity-matched daemon with a fresh beacon then satisfies that boundary in place of a watcher process holding the lock. +During away or quiet mode, the daemon may satisfy this boundary in place of a watcher process holding the lock; its ownership proof and the no-daemon result are defined under [Away and quiet mode daemon ownership](#away-and-quiet-mode-daemon-ownership). The guard remains a backstop. [`watcher-continuity.md`](watcher-continuity.md) owns normal continuity. @@ -170,7 +169,7 @@ It keys the once-per-episode dedup on that condition rather than the beacon mtim ### Away and quiet mode daemon ownership -While `state/.afk` exists the daemon (`bin/fm-supervise-daemon.sh`) owns supervision and runs the watcher one-shot, in either away or quiet mode. +When `state/.afk` exists and the daemon (`bin/fm-supervise-daemon.sh`) is running, it owns supervision and runs the watcher one-shot, in either away or quiet mode. The watcher exits on every wake and the daemon starts its replacement. A turn boundary therefore regularly lands in a hand-off where no watcher process holds the lock and nothing is wrong.