From 63dbee444cf6c489a26a9cc7ce8417c1d47febce Mon Sep 17 00:00:00 2001 From: Nicholas Tran <26808160+NicholasACTran@users.noreply.github.com> Date: Wed, 26 Aug 2026 01:20:04 -0400 Subject: [PATCH 01/11] fix(afk): route claude+herdr away-mode launch through the terminal-backed path A Claude Code background shell hosting the sub-supervisor daemon inside the captain's own pane leaves a footer token that herdr's claude agent-detection ruleset misreads as permanently "working", so the away-mode busy guard can never read that pane idle and no escalation is ever delivered. Root cause documented in data/firstmate-afk-daemon-wedged-investigation/report.md (2026-08-26): 408 identical busy-guard deferrals over 95 minutes, versus a clean 2h22m run on the terminal-backed FM_SUPERVISOR_TARGET path. Route claude on the herdr backend through bin/fm-afk-launch.sh start (the non-visible tracked terminal path) instead of start-native, and correct the "it is fine" belief recorded in the afk skill and both launcher scripts' header comments. Other harness/backend combinations are unaffected: tmux's rendered busy-fallback regex does not match the background-shell footer token, so only herdr's native agent-status path is implicated. Deliberately not implemented: teaching the busy guard to subtract the daemon's own footer contribution, since that couples firstmate to a private, versioned herdr ruleset that already changes without notice. --- .agents/skills/afk/SKILL.md | 12 ++++++++---- bin/fm-afk-launch.sh | 12 +++++++++--- bin/fm-afk-start.sh | 20 +++++++++++++------- 3 files changed, 30 insertions(+), 14 deletions(-) diff --git a/.agents/skills/afk/SKILL.md b/.agents/skills/afk/SKILL.md index 058a1947844..c8bfb27b5e5 100644 --- a/.agents/skills/afk/SKILL.md +++ b/.agents/skills/afk/SKILL.md @@ -24,17 +24,21 @@ batched digest rather than per-wake injections. The flag survives a firstmate restart, so recovery re-enters afk when it is present. 2. **Ensure the sub-supervisor daemon is running as a tracked background process.** - Its hosting differs by harness. + Its hosting differs by harness AND backend. Pick the right path: - - **Harness WITH a native in-pane tracked-background tool** (e.g. claude's - background bash, grok's background tool): first run + - **Claude on the herdr backend: always use the terminal-backed path below, never `start-native`.** + A Claude Code background shell hosting the daemon in the captain's own pane leaves a `· 1 shell ·` token in that pane's rendered footer for as long as the daemon lives. + Herdr's own claude agent-detection ruleset maps that exact token to `agent_status = "working"` at a priority that beats the idle rule, so the away-mode busy guard reads the captain's pane as permanently busy and can never deliver an escalation into it for the life of the session - proven in `data/firstmate-afk-daemon-wedged-investigation/report.md` (2026-08-26), which found this in every claude+herdr away run since 2026-08-19. + Do not "fix" this by teaching the busy guard to subtract the daemon's own footer contribution; that couples firstmate to a private, versioned herdr ruleset that already changes without notice. + - **Harness WITH a native in-pane tracked-background tool, on any OTHER combination** (e.g. claude's + background bash on tmux, grok's background tool): first run `bin/fm-afk-launch.sh start-native`, then run `FM_AFK_STATE_PREPARED=1 bin/fm-afk-start.sh` through that native tool. This is a deliberate no-separate-terminal exception because the harness-hosted job creates no terminal or layout mutation, and a shell launcher cannot invoke a harness-native background tool. The launcher still owns lifecycle state and records the no-terminal mode, while the daemon inherits and auto-discovers the captain pane. If the native launch fails, run `bin/fm-afk-launch.sh stop` to roll back the prepared lifecycle. Do not wrap it in `nohup ... &` (Codex/herdr can reap fire-and-forget shell children after a tool call returns). - - **Harness WITHOUT one** (e.g. pi): run `bin/fm-afk-launch.sh start`. It is + - **Harness WITHOUT a native in-pane tool (e.g. pi), and claude on herdr per above**: run `bin/fm-afk-launch.sh start`. It is the single owner of the daemon terminal: it creates a NON-VISIBLE tracked terminal for the current backend (a herdr dedicated `--no-focus` workspace, a detached tmux session), records its exact id, and passes the captain pane diff --git a/bin/fm-afk-launch.sh b/bin/fm-afk-launch.sh index 5df2a9d9915..71933255cca 100755 --- a/bin/fm-afk-launch.sh +++ b/bin/fm-afk-launch.sh @@ -6,9 +6,15 @@ # Why this exists (docs/herdr-backend.md "Away-mode daemon terminal launch"): # bin/fm-afk-start.sh execs the supervise daemon in the FOREGROUND of whatever # terminal it is already in. Harnesses with a native in-pane tracked-background -# tool (claude, grok) run it there directly and it is fine. A harness with NO -# native background mechanism (pi) has to manufacture a terminal, and doing that -# by SPLITTING the captain's active pane visibly shrinks it - the regression this +# tool (claude, grok) run it there directly on most backends and it is fine. +# claude on the herdr backend is the proven exception, NOT fine: the in-pane +# background job leaves a footer token that herdr's own claude agent-detection +# ruleset misreads as "working" for the life of the session, so the away-mode +# busy guard can never read that pane idle and the daemon can never deliver an +# escalation into it (data/firstmate-afk-daemon-wedged-investigation/report.md, +# 2026-08-26). A harness with NO native background mechanism (pi), and claude +# on herdr per that exception, has to manufacture a terminal, and doing that by +# SPLITTING the captain's active pane visibly shrinks it - the regression this # script fixes. Instead this creates a non-visible tracked terminal (a herdr tab/ # workspace with --no-focus, or a detached tmux session) that never touches the # captain's active tab, and NEVER uses shell `&` (which herdr/codex can reap). diff --git a/bin/fm-afk-start.sh b/bin/fm-afk-start.sh index e86c54f170a..15afab470f9 100755 --- a/bin/fm-afk-start.sh +++ b/bin/fm-afk-start.sh @@ -20,13 +20,19 @@ # This is the COMMON daemon entry for every backend. HOW it becomes a tracked # background process differs by harness/backend and is owned elsewhere: # - Harnesses with a native in-pane tracked-background tool (e.g. claude, grok) -# run this directly via that tool, so the daemon inherits the captain pane's -# env and auto-discovers it. -# - Harnesses with NO native background mechanism (e.g. pi) run this THROUGH -# bin/fm-afk-launch.sh, which creates a non-visible tracked terminal per -# backend (herdr tab/workspace, tmux detached session) and passes the -# captain pane in as FM_SUPERVISOR_TARGET so injection targets it, not the -# daemon's own new pane. +# run this directly via that tool on most backends, so the daemon inherits +# the captain pane's env and auto-discovers it. EXCEPTION: claude on the +# herdr backend must NOT use this path - the in-pane background job leaves +# a footer token that herdr's own claude agent-detection ruleset misreads as +# "working" for the life of the session, structurally wedging the away-mode +# busy guard (data/firstmate-afk-daemon-wedged-investigation/report.md, +# 2026-08-26; see .agents/skills/afk/SKILL.md for the routing rule). +# - Harnesses with NO native background mechanism (e.g. pi), and claude on +# herdr per the exception above, run this THROUGH bin/fm-afk-launch.sh, +# which creates a non-visible tracked terminal per backend (herdr tab/ +# workspace, tmux detached session) and passes the captain pane in as +# FM_SUPERVISOR_TARGET so injection targets it, not the daemon's own new +# pane. # Do not wrap this in `nohup ... &`: Codex/herdr can reap fire-and-forget shell # children after the tool call returns, while a tracked background terminal stays # attached and has a real lifecycle. From d8f17bce159e7915e3db343bdb419fd888ccb041 Mon Sep 17 00:00:00 2001 From: Nicholas Tran <26808160+NicholasACTran@users.noreply.github.com> Date: Wed, 26 Aug 2026 01:55:20 -0400 Subject: [PATCH 02/11] docs(herdr): close the claude+herdr away-daemon exception gap docs/herdr-backend.md "Away-mode supervisor support" still described native tracked-background execution as safe for every harness with the capability, casting the terminal-backed launcher as the Pi-only fallback. That contradicted the routing fix in .agents/skills/afk/SKILL.md and bin/fm-afk-launch.sh: Claude on Herdr must not host the away daemon in its own pane, because Herdr's Claude agent-detection ruleset reads the daemon's own background-shell footer token as the agent working, at a priority that beats the idle rule, which wedges the busy guard for the life of the session. State the same exception here, in this doc's own voice, so a session that reads this file first gets the same answer as one that reads the skill first. --- docs/herdr-backend.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/herdr-backend.md b/docs/herdr-backend.md index f5a3f528d5f..a21d5ba822c 100644 --- a/docs/herdr-backend.md +++ b/docs/herdr-backend.md @@ -291,8 +291,10 @@ It refuses Zellij, Orca, and cmux as supervisor backends rather than applying th For Herdr, target existence, native state, capture, composer state, and verified submit all route through the shared backend dispatcher and the explicit named-session CLI owner. The pane-independent max-defer alert is configured in [`wedge-alarm.md`](wedge-alarm.md). -Harnesses with native tracked background execution can run the daemon in their terminal. -Pi has no such mechanism. +Harnesses with native tracked background execution can run the daemon in their terminal, with one proven exception: Claude must not host the away daemon this way on Herdr. +Claude renders a live background shell in its own pane footer, and Herdr's Claude agent-detection ruleset reads that footer token as the agent working, at a priority that beats its idle rule. +The busy guard trusts that native state first, so it reads the daemon's own presence as the captain pane being busy and can never deliver an escalation into it for the life of the session (`data/firstmate-afk-daemon-wedged-investigation/report.md`, 2026-08-26). +Pi has no native background mechanism at all, and Claude on Herdr is routed the same way for the reason above. `bin/fm-afk-launch.sh` therefore creates a dedicated unfocused Herdr workspace, runs the daemon there with an explicit supervisor target and backend, records the exact daemon pane, and closes only that pane on stop. It never splits the captain's active tab and never uses shell `&`. Recovery reconciles only the recorded exact id. From 4398ff88ee83a02dc3fd0453b8b41ebeb6aad3be Mon Sep 17 00:00:00 2001 From: Nicholas Tran <26808160+NicholasACTran@users.noreply.github.com> Date: Wed, 26 Aug 2026 03:04:30 -0400 Subject: [PATCH 03/11] no-mistakes(review): enforce claude+herdr refusal in fm-afk-launch start-native --- bin/fm-afk-launch.sh | 24 +++++++++++++++ tests/fm-afk-launch.test.sh | 60 +++++++++++++++++++++++++++++++++++-- 2 files changed, 81 insertions(+), 3 deletions(-) diff --git a/bin/fm-afk-launch.sh b/bin/fm-afk-launch.sh index 71933255cca..efbfad29650 100755 --- a/bin/fm-afk-launch.sh +++ b/bin/fm-afk-launch.sh @@ -35,6 +35,8 @@ # fm-afk-launch.sh start-native # Prepare lifecycle state for a harness-native # background job and record that no terminal exists. +# Refuses on claude+herdr (the exception above) and +# points at `start`, before writing any state. # fm-afk-launch.sh stop Correct-ordered exit: SIGTERM the daemon so its # cleanup flushes WHILE state/.afk is still present, # wait for it, close the recorded terminal by exact @@ -533,8 +535,30 @@ fm_afk_launch_start() { return "$result" } +# The claude+herdr exception, enforced rather than merely documented. A claude +# in-pane background job renders a shell token in the captain pane's footer for +# as long as the daemon lives; herdr's own claude agent-detection ruleset maps +# that token to "working" above its idle rule, so the away-mode busy guard reads +# the captain pane busy forever and no escalation is ever delivered. Refuse this +# ONE combination and name the terminal-backed path; every other harness/backend +# pair keeps the native exception. +fm_afk_launch_native_refused() { + local harness backend + backend=$(discover_supervisor_backend) || true + [ "$backend" = herdr ] || return 1 + harness=$("$FM_AFK_LAUNCH_DIR/fm-harness.sh" 2>/dev/null) || harness=unknown + [ "$harness" = claude ] || return 1 + return 0 +} + fm_afk_launch_start_native() { local backup artifact had_afk=0 result=0 + # Refuse BEFORE touching lifecycle state, so a refusal leaves nothing to undo. + if fm_afk_launch_native_refused; then + fm_afk_launch_log "refusing start-native on claude+herdr: a claude background shell renders in the captain pane's footer, herdr reads that as the agent working, so the away daemon defers forever and away mode silently delivers nothing" + fm_afk_launch_log "use 'bin/fm-afk-launch.sh start' instead (non-visible daemon terminal)" + return 1 + fi mkdir -p "$FM_AFK_LAUNCH_STATE" || return 1 if [ -e "$FM_AFK_LAUNCH_STATE/.afk-return-catchup" ]; then fm_afk_launch_log "return catch-up is still pending; run bin/fm-afk-return.sh check before re-entering away mode" diff --git a/tests/fm-afk-launch.test.sh b/tests/fm-afk-launch.test.sh index 6d0c7bd9d1a..6f3af4b770e 100755 --- a/tests/fm-afk-launch.test.sh +++ b/tests/fm-afk-launch.test.sh @@ -488,7 +488,8 @@ unit_native_lifecycle() { st=$(mktemp -d "${TMPDIR:-/tmp}/fm-afk-native.XXXXXX") mkdir -p "$st/state" : > "$st/state/.subsuper-escalations" - if FM_HOME="$st" FM_STATE_OVERRIDE="$st/state" "$LAUNCH" start-native >/dev/null 2>&1 \ + if FM_HOME="$st" FM_STATE_OVERRIDE="$st/state" FM_SUPERVISOR_BACKEND=tmux \ + "$LAUNCH" start-native >/dev/null 2>&1 \ && [ "$(cut -f1 "$st/state/.afk-daemon-terminal")" = none ] \ && [ -e "$st/state/.afk" ] \ && [ ! -e "$st/state/.subsuper-escalations" ]; then @@ -505,6 +506,58 @@ unit_native_lifecycle() { rm -rf "$st" } +# Regression for the 95-minute wedged away session: claude hosting the daemon in +# its own pane on herdr leaves a footer shell token that herdr reports as +# "working", so the busy guard never sees the captain pane idle. The launcher +# must refuse exactly that pair, write no lifecycle state when it does, and keep +# permitting every neighbouring pair. +unit_native_refuses_claude_on_herdr() { + local st out refused=0 + st=$(mktemp -d "${TMPDIR:-/tmp}/fm-afk-native-guard.XXXXXX") + mkdir -p "$st/state" + + out=$(env -u CURSOR_AGENT -u CURSOR_INVOKED_AS -u PI_CODING_AGENT -u GROK_AGENT \ + CLAUDECODE=1 FM_HOME="$st" FM_STATE_OVERRIDE="$st/state" \ + FM_SUPERVISOR_TARGET=captain FM_SUPERVISOR_BACKEND=herdr \ + "$LAUNCH" start-native 2>&1) || refused=1 + if [ "$refused" -eq 1 ] && [ ! -e "$st/state/.afk" ] && [ ! -e "$st/state/.afk-daemon-terminal" ]; then + pass "native guard: claude+herdr is refused with no lifecycle state written" + else + fail "native guard: claude+herdr native launch was accepted" + fi + case "$out" in + *'bin/fm-afk-launch.sh start'*) pass "native guard: refusal names the terminal-backed path" ;; + *) fail "native guard: refusal did not name the terminal-backed path" ;; + esac + + rm -rf "$st" + st=$(mktemp -d "${TMPDIR:-/tmp}/fm-afk-native-guard.XXXXXX") + mkdir -p "$st/state" + if env -u CURSOR_AGENT -u CURSOR_INVOKED_AS -u PI_CODING_AGENT -u GROK_AGENT \ + CLAUDECODE=1 FM_HOME="$st" FM_STATE_OVERRIDE="$st/state" \ + FM_SUPERVISOR_TARGET=captain FM_SUPERVISOR_BACKEND=tmux \ + "$LAUNCH" start-native >/dev/null 2>&1 \ + && [ "$(cut -f1 "$st/state/.afk-daemon-terminal")" = none ]; then + pass "native guard: claude on a non-herdr backend is still permitted" + else + fail "native guard: claude on a non-herdr backend was refused" + fi + + rm -rf "$st" + st=$(mktemp -d "${TMPDIR:-/tmp}/fm-afk-native-guard.XXXXXX") + mkdir -p "$st/state" + if env -u CURSOR_AGENT -u CURSOR_INVOKED_AS -u CLAUDECODE -u GROK_AGENT \ + PI_CODING_AGENT=true FM_HOME="$st" FM_STATE_OVERRIDE="$st/state" \ + FM_SUPERVISOR_TARGET=captain FM_SUPERVISOR_BACKEND=herdr \ + "$LAUNCH" start-native >/dev/null 2>&1 \ + && [ "$(cut -f1 "$st/state/.afk-daemon-terminal")" = none ]; then + pass "native guard: a non-claude harness on herdr is still permitted" + else + fail "native guard: a non-claude harness on herdr was refused" + fi + rm -rf "$st" +} + unit_native_entry_preserves_prepared_state() { local st st=$(mktemp -d "${TMPDIR:-/tmp}/fm-afk-native-entry.XXXXXX") @@ -759,7 +812,7 @@ unit_clear_failure_aborts_entry() { st=$(mktemp -d "${TMPDIR:-/tmp}/fm-afk-clear-fail.XXXXXX") mkdir -p "$st/state" : > "$st/state/.subsuper-escalations" - if FM_HOME="$st" FM_STATE_OVERRIDE="$st/state" bash -c ' + if FM_HOME="$st" FM_STATE_OVERRIDE="$st/state" FM_SUPERVISOR_BACKEND=tmux bash -c ' . "$1" fm_afk_launch_reconcile() { return 0; } fm_afk_clear_stale_artifacts() { return 1; } @@ -812,7 +865,7 @@ unit_flag_write_failure_aborts() { local st st=$(mktemp -d "${TMPDIR:-/tmp}/fm-afk-flag-fail.XXXXXX") mkdir -p "$st/state" - FM_HOME="$st" FM_STATE_OVERRIDE="$st/state" bash -c ' + FM_HOME="$st" FM_STATE_OVERRIDE="$st/state" FM_SUPERVISOR_BACKEND=tmux bash -c ' . "$1" fm_afk_launch_flag_write() { return 1; } ! fm_afk_launch_start_native @@ -940,6 +993,7 @@ unit_readiness_failure_rolls_back_terminal unit_readiness_failure_preserves_unconfirmed_record unit_tmux_absence_distinguishes_probe_failure unit_native_lifecycle +unit_native_refuses_claude_on_herdr unit_native_entry_preserves_prepared_state unit_close_failure_preserves_record unit_record_publication_atomic From 3960a5455ef477f43d160c478f333c9224b912c7 Mon Sep 17 00:00:00 2001 From: Nicholas Tran <26808160+NicholasACTran@users.noreply.github.com> Date: Wed, 26 Aug 2026 03:08:35 -0400 Subject: [PATCH 04/11] no-mistakes(review): pin tmux backend at afk-return start-native call site --- tests/fm-afk-return.test.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/fm-afk-return.test.sh b/tests/fm-afk-return.test.sh index c345e4f55e2..d5fd613f03d 100755 --- a/tests/fm-afk-return.test.sh +++ b/tests/fm-afk-return.test.sh @@ -241,7 +241,8 @@ test_away_reentry_refuses_pending_return_gate() { mkdir -p "$dir/home/state" "$dir/home/data" "$dir/home/config" printf 'schema\tfm-afk-return.v1\nphase\tblocked\n' > "$dir/home/state/.afk-return-catchup" set +e - out=$(FM_HOME="$dir/home" FM_STATE_OVERRIDE="$dir/home/state" "$ROOT/bin/fm-afk-launch.sh" start-native 2>&1) + out=$(FM_HOME="$dir/home" FM_STATE_OVERRIDE="$dir/home/state" FM_SUPERVISOR_BACKEND=tmux \ + "$ROOT/bin/fm-afk-launch.sh" start-native 2>&1) rc=$? set -e [ "$rc" -ne 0 ] || fail "away re-entry succeeded while return catch-up was pending" From 13be2d0e16823404e523515432148a27e94ad4b8 Mon Sep 17 00:00:00 2001 From: Nicholas Tran <26808160+NicholasACTran@users.noreply.github.com> Date: Wed, 26 Aug 2026 03:16:37 -0400 Subject: [PATCH 05/11] no-mistakes(document): document enforced claude+herdr start-native refusal --- .agents/skills/afk/SKILL.md | 1 + docs/herdr-backend.md | 1 + 2 files changed, 2 insertions(+) diff --git a/.agents/skills/afk/SKILL.md b/.agents/skills/afk/SKILL.md index c8bfb27b5e5..028df87acd7 100644 --- a/.agents/skills/afk/SKILL.md +++ b/.agents/skills/afk/SKILL.md @@ -30,6 +30,7 @@ batched digest rather than per-wake injections. A Claude Code background shell hosting the daemon in the captain's own pane leaves a `· 1 shell ·` token in that pane's rendered footer for as long as the daemon lives. Herdr's own claude agent-detection ruleset maps that exact token to `agent_status = "working"` at a priority that beats the idle rule, so the away-mode busy guard reads the captain's pane as permanently busy and can never deliver an escalation into it for the life of the session - proven in `data/firstmate-afk-daemon-wedged-investigation/report.md` (2026-08-26), which found this in every claude+herdr away run since 2026-08-19. Do not "fix" this by teaching the busy guard to subtract the daemon's own footer contribution; that couples firstmate to a private, versioned herdr ruleset that already changes without notice. + `bin/fm-afk-launch.sh start-native` enforces this rule itself: on claude+herdr it refuses before writing any lifecycle state, so a refusal there is the guard working and there is nothing to roll back - re-enter with `bin/fm-afk-launch.sh start`. - **Harness WITH a native in-pane tracked-background tool, on any OTHER combination** (e.g. claude's background bash on tmux, grok's background tool): first run `bin/fm-afk-launch.sh start-native`, then run diff --git a/docs/herdr-backend.md b/docs/herdr-backend.md index a21d5ba822c..4c1c2cc17c5 100644 --- a/docs/herdr-backend.md +++ b/docs/herdr-backend.md @@ -295,6 +295,7 @@ Harnesses with native tracked background execution can run the daemon in their t Claude renders a live background shell in its own pane footer, and Herdr's Claude agent-detection ruleset reads that footer token as the agent working, at a priority that beats its idle rule. The busy guard trusts that native state first, so it reads the daemon's own presence as the captain pane being busy and can never deliver an escalation into it for the life of the session (`data/firstmate-afk-daemon-wedged-investigation/report.md`, 2026-08-26). Pi has no native background mechanism at all, and Claude on Herdr is routed the same way for the reason above. +`bin/fm-afk-launch.sh start-native` enforces that routing rather than relying on the caller having read it: it refuses the Claude-on-Herdr pair before writing any lifecycle state and names `start` instead, covered by `tests/fm-afk-launch.test.sh`. `bin/fm-afk-launch.sh` therefore creates a dedicated unfocused Herdr workspace, runs the daemon there with an explicit supervisor target and backend, records the exact daemon pane, and closes only that pane on stop. It never splits the captain's active tab and never uses shell `&`. Recovery reconciles only the recorded exact id. From bcbe7b261a2404cfca91963a2e2540e15e6b975b Mon Sep 17 00:00:00 2001 From: Nicholas Tran <26808160+NicholasACTran@users.noreply.github.com> Date: Wed, 26 Aug 2026 03:50:49 -0400 Subject: [PATCH 06/11] fix(afk): refuse claude+herdr direct entry in fm-afk-start.sh The launcher's start-native guard only covers entry through bin/fm-afk-launch.sh. This script is also a documented direct entry point (the native two-step's second half, and a bare direct call), so the same footer-wedge failure was still reachable by skipping the launcher entirely. Key the refusal on hosting mode, not harness+backend alone: the launcher's terminal-backed path also execs this script but always passes FM_SUPERVISOR_TARGET explicitly, so its presence marks an already-safe, launcher-hosted invocation and must stay permitted. --- bin/fm-afk-start.sh | 32 +++++++++++++++++++ tests/fm-afk-launch.test.sh | 63 +++++++++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+) diff --git a/bin/fm-afk-start.sh b/bin/fm-afk-start.sh index 15afab470f9..4bade0114c0 100755 --- a/bin/fm-afk-start.sh +++ b/bin/fm-afk-start.sh @@ -47,11 +47,35 @@ FM_AFK_DAEMON="$FM_AFK_START_DIR/fm-supervise-daemon.sh" # shellcheck source=bin/fm-wake-lib.sh . "$FM_AFK_START_DIR/fm-wake-lib.sh" +# shellcheck source=bin/fm-supervisor-target-lib.sh +. "$FM_AFK_START_DIR/fm-supervisor-target-lib.sh" fm_afk_start_usage() { sed -n '2,14p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' } +# The claude+herdr exception, enforced here too: bin/fm-afk-launch.sh's own +# start-native guard only covers entry THROUGH the launcher. This script is +# also a documented direct entry point (the second half of the native two-step, +# and a bare direct call), so the same wedge is reachable by skipping the +# launcher entirely. Key the refusal on HOSTING MODE, not on harness+backend +# alone: the launcher's own terminal-backed path also execs this script, but +# does so in its OWN separate pane and always passes FM_SUPERVISOR_TARGET +# explicitly so the daemon injects into the captain pane rather than +# discovering its own - that explicit target is what marks a launcher-hosted, +# already-safe invocation. Its absence means this process will auto-discover +# ITS OWN pane as the target, i.e. it IS the pane being hosted in - the exact +# in-pane case that wedges herdr's claude detection. +fm_afk_start_native_refused() { + local harness backend + [ -z "${FM_SUPERVISOR_TARGET:-}" ] || return 1 + backend=$(discover_supervisor_backend) || true + [ "$backend" = herdr ] || return 1 + harness=$("$FM_AFK_START_DIR/fm-harness.sh" 2>/dev/null) || harness=unknown + [ "$harness" = claude ] || return 1 + return 0 +} + # fm_afk_clear_stale_artifacts: on a FRESH away-session entry (the daemon is not # already running), drop the previous away session's leftover escalation-delivery # artifacts so they cannot surface as stale escalations under the new session. @@ -143,6 +167,14 @@ fm_afk_start_main() { * ) echo "usage: $(basename "${BASH_SOURCE[1]:-fm-afk-start.sh}")" >&2; return 2 ;; esac + # Refuse BEFORE touching lifecycle state (before mkdir/flag-write), so a + # refusal leaves nothing to roll back. + if fm_afk_start_native_refused; then + echo "afk: refusing to host the away daemon in this pane on claude+herdr: a claude background shell renders in the captain pane's footer, herdr reads that as the agent working, so the away daemon defers forever and away mode silently delivers nothing" >&2 + echo "afk: use 'bin/fm-afk-launch.sh start' instead (non-visible daemon terminal)" >&2 + return 1 + fi + mkdir -p "$FM_AFK_STATE" if [ "${FM_AFK_STATE_PREPARED:-0}" = 1 ]; then [ -f "$FM_AFK_STATE/.afk" ] || { echo "afk: launcher-prepared state is missing" >&2; return 1; } diff --git a/tests/fm-afk-launch.test.sh b/tests/fm-afk-launch.test.sh index 6f3af4b770e..cfa3dded42d 100755 --- a/tests/fm-afk-launch.test.sh +++ b/tests/fm-afk-launch.test.sh @@ -558,6 +558,68 @@ unit_native_refuses_claude_on_herdr() { rm -rf "$st" } +# --------------------------------------------------------------------------- +# UNIT: bin/fm-afk-start.sh is ALSO a documented direct entry point (the +# native two-step's second half, and a bare direct call), so the launcher's +# own start-native guard above does not cover it - the same wedge is reachable +# by skipping the launcher entirely. This guard is keyed on HOSTING MODE, not +# harness+backend alone: the launcher's terminal-backed path also execs this +# script, but always passes FM_SUPERVISOR_TARGET explicitly (a separate pane +# injecting into the captain's), so its presence proves a safe, already-hosted +# invocation and must stay permitted even on claude+herdr. +# --------------------------------------------------------------------------- +unit_start_refuses_direct_claude_on_herdr() { + local st out refused=0 + st=$(mktemp -d "${TMPDIR:-/tmp}/fm-afk-start-guard.XXXXXX") + mkdir -p "$st/state" + out=$(env -u CURSOR_AGENT -u CURSOR_INVOKED_AS -u PI_CODING_AGENT -u GROK_AGENT -u TMUX_PANE \ + -u FM_SUPERVISOR_TARGET -u FM_SUPERVISOR_BACKEND \ + CLAUDECODE=1 HERDR_ENV=1 HERDR_PANE_ID=pane-1 FM_HOME="$st" FM_STATE_OVERRIDE="$st/state" \ + "$START" 2>&1) || refused=1 + if [ "$refused" -eq 1 ] && [ ! -e "$st/state/.afk" ]; then + pass "start guard: direct claude+herdr entry is refused with no lifecycle state written" + else + fail "start guard: direct claude+herdr entry was accepted" + fi + case "$out" in + *'bin/fm-afk-launch.sh start'*) pass "start guard: refusal names the terminal-backed path" ;; + *) fail "start guard: refusal did not name the terminal-backed path" ;; + esac + rm -rf "$st" + + st=$(mktemp -d "${TMPDIR:-/tmp}/fm-afk-start-guard.XXXXXX") + mkdir -p "$st/state" + # shellcheck disable=SC2016 # $1 is bash -c's own positional param, not this shell's. + out=$(env -u CURSOR_AGENT -u CURSOR_INVOKED_AS -u PI_CODING_AGENT -u GROK_AGENT -u TMUX_PANE \ + CLAUDECODE=1 HERDR_ENV=1 HERDR_PANE_ID=pane-1 FM_HOME="$st" FM_STATE_OVERRIDE="$st/state" \ + FM_SUPERVISOR_TARGET=captain FM_SUPERVISOR_BACKEND=herdr bash -c ' + . "$1" + FM_AFK_DAEMON=/bin/true + fm_afk_start_main + ' _ "$START" 2>&1) + case "$out" in + *'starting supervise daemon'*) pass "start guard: claude+herdr is still permitted when launcher-hosted (FM_SUPERVISOR_TARGET set)" ;; + *) fail "start guard: claude+herdr launcher-hosted entry was refused ($out)" ;; + esac + rm -rf "$st" + + st=$(mktemp -d "${TMPDIR:-/tmp}/fm-afk-start-guard.XXXXXX") + mkdir -p "$st/state" + # shellcheck disable=SC2016 # $1 is bash -c's own positional param, not this shell's. + out=$(env -u CURSOR_AGENT -u CURSOR_INVOKED_AS -u CLAUDECODE -u GROK_AGENT -u TMUX_PANE \ + -u FM_SUPERVISOR_TARGET -u FM_SUPERVISOR_BACKEND \ + PI_CODING_AGENT=true HERDR_ENV=1 HERDR_PANE_ID=pane-1 FM_HOME="$st" FM_STATE_OVERRIDE="$st/state" bash -c ' + . "$1" + FM_AFK_DAEMON=/bin/true + fm_afk_start_main + ' _ "$START" 2>&1) + case "$out" in + *'starting supervise daemon'*) pass "start guard: a non-claude harness on herdr direct entry is still permitted" ;; + *) fail "start guard: a non-claude harness on herdr direct entry was refused ($out)" ;; + esac + rm -rf "$st" +} + unit_native_entry_preserves_prepared_state() { local st st=$(mktemp -d "${TMPDIR:-/tmp}/fm-afk-native-entry.XXXXXX") @@ -994,6 +1056,7 @@ unit_readiness_failure_preserves_unconfirmed_record unit_tmux_absence_distinguishes_probe_failure unit_native_lifecycle unit_native_refuses_claude_on_herdr +unit_start_refuses_direct_claude_on_herdr unit_native_entry_preserves_prepared_state unit_close_failure_preserves_record unit_record_publication_atomic From 95a3e3034c32708527dc3b4ccc424dd1831c6833 Mon Sep 17 00:00:00 2001 From: Nicholas Tran <26808160+NicholasACTran@users.noreply.github.com> Date: Wed, 26 Aug 2026 04:04:24 -0400 Subject: [PATCH 07/11] no-mistakes(review): key afk start guard on same-pane self-injection --- bin/fm-afk-start.sh | 98 ++++++++++++++++++++----- tests/fm-afk-launch.test.sh | 140 +++++++++++++++++++++++++++--------- 2 files changed, 186 insertions(+), 52 deletions(-) diff --git a/bin/fm-afk-start.sh b/bin/fm-afk-start.sh index 4bade0114c0..d884e3822d6 100755 --- a/bin/fm-afk-start.sh +++ b/bin/fm-afk-start.sh @@ -58,21 +58,73 @@ fm_afk_start_usage() { # start-native guard only covers entry THROUGH the launcher. This script is # also a documented direct entry point (the second half of the native two-step, # and a bare direct call), so the same wedge is reachable by skipping the -# launcher entirely. Key the refusal on HOSTING MODE, not on harness+backend -# alone: the launcher's own terminal-backed path also execs this script, but -# does so in its OWN separate pane and always passes FM_SUPERVISOR_TARGET -# explicitly so the daemon injects into the captain pane rather than -# discovering its own - that explicit target is what marks a launcher-hosted, -# already-safe invocation. Its absence means this process will auto-discover -# ITS OWN pane as the target, i.e. it IS the pane being hosted in - the exact -# in-pane case that wedges herdr's claude detection. +# launcher entirely. +# +# The hazard is PHYSICAL: the daemon hosting itself in the very pane it will +# inject escalations into (self-injection). On claude+herdr that pane's footer +# then carries the background-shell token for the life of the session, herdr's +# claude detection reads it as "the agent is working", and every escalation +# defers forever. So the signal is a same-pane comparison, not the presence of +# any environment knob: fm_afk_start_own_pane resolves where THIS process is +# actually running (raw $TMUX_PANE / $HERDR_ENV+$HERDR_PANE_ID, deliberately +# ignoring the FM_SUPERVISOR_TARGET override), while discover_supervisor_target +# resolves where injection will actually land (override first, exactly as +# inject_msg does). The launcher's terminal-backed path stays permitted because +# it runs in its OWN separate pane and targets the captain's - different panes, +# no self-injection. +# +# fm_afk_start_own_backend / fm_afk_start_own_pane: this process's RAW identity. +# Distinct from discover_supervisor_backend/discover_supervisor_target on +# purpose - those answer "where do escalations go", which an operator may +# override; these answer "where am I", which nothing may override. Both return +# non-zero when this process is in no pane at all. +fm_afk_start_own_backend() { + if [ -n "${TMUX_PANE:-}" ]; then + printf 'tmux' + return 0 + fi + if [ "${HERDR_ENV:-}" = "1" ] && [ -n "${HERDR_PANE_ID:-}" ]; then + printf 'herdr' + return 0 + fi + return 1 +} + +fm_afk_start_own_pane() { + if [ -n "${TMUX_PANE:-}" ]; then + printf '%s' "$TMUX_PANE" + return 0 + fi + if [ "${HERDR_ENV:-}" = "1" ] && [ -n "${HERDR_PANE_ID:-}" ]; then + printf '%s:%s' "${HERDR_SESSION:-default}" "$HERDR_PANE_ID" + return 0 + fi + return 1 +} + fm_afk_start_native_refused() { - local harness backend - [ -z "${FM_SUPERVISOR_TARGET:-}" ] || return 1 - backend=$(discover_supervisor_backend) || true - [ "$backend" = herdr ] || return 1 + local harness own_backend own_pane target harness=$("$FM_AFK_START_DIR/fm-harness.sh" 2>/dev/null) || harness=unknown [ "$harness" = claude ] || return 1 + + own_backend=$(fm_afk_start_own_backend) || own_backend='' + own_pane=$(fm_afk_start_own_pane) || own_pane='' + # Deliberate: an unresolvable side means the guard cannot check itself, so it + # permits rather than blocks on unknown state, and says so out loud instead of + # permitting silently. + if [ -z "$own_backend" ] || [ -z "$own_pane" ]; then + echo "afk: cannot resolve this process's own pane (no TMUX_PANE, no HERDR_ENV+HERDR_PANE_ID), so the claude+herdr self-injection guard cannot check itself; permitting this start rather than blocking on unknown state" >&2 + return 1 + fi + [ "$own_backend" = herdr ] || return 1 + + target=$(discover_supervisor_target) || target='' + if [ -z "$target" ]; then + echo "afk: cannot resolve the escalation injection target, so the claude+herdr self-injection guard cannot check itself; permitting this start rather than blocking on unknown state" >&2 + return 1 + fi + + [ "$own_pane" = "$target" ] || return 1 return 0 } @@ -167,13 +219,8 @@ fm_afk_start_main() { * ) echo "usage: $(basename "${BASH_SOURCE[1]:-fm-afk-start.sh}")" >&2; return 2 ;; esac - # Refuse BEFORE touching lifecycle state (before mkdir/flag-write), so a - # refusal leaves nothing to roll back. - if fm_afk_start_native_refused; then - echo "afk: refusing to host the away daemon in this pane on claude+herdr: a claude background shell renders in the captain pane's footer, herdr reads that as the agent working, so the away daemon defers forever and away mode silently delivers nothing" >&2 - echo "afk: use 'bin/fm-afk-launch.sh start' instead (non-visible daemon terminal)" >&2 - return 1 - fi + local had_flag=0 + [ ! -e "$FM_AFK_STATE/.afk" ] || had_flag=1 mkdir -p "$FM_AFK_STATE" if [ "${FM_AFK_STATE_PREPARED:-0}" = 1 ]; then @@ -189,6 +236,19 @@ fm_afk_start_main() { return 0 fi + # Only a genuine FRESH start reaches the guard: a refresh of an already-live, + # already-safely-hosted daemon returned above untouched. Roll the away flag + # back when this invocation is the one that wrote it, so a refusal leaves the + # lifecycle exactly as it found it. + if fm_afk_start_native_refused; then + if [ "${FM_AFK_STATE_PREPARED:-0}" != 1 ] && [ "$had_flag" -eq 0 ]; then + rm -f "$FM_AFK_STATE/.afk" 2>/dev/null || true + fi + echo "afk: refusing to host the away daemon in the same pane it injects into on claude+herdr: a claude background shell renders in that pane's footer, herdr reads that as the agent working, so the away daemon defers forever and away mode silently delivers nothing" >&2 + echo "afk: use 'bin/fm-afk-launch.sh start' instead (non-visible daemon terminal)" >&2 + return 1 + fi + if fm_pid_alive "$pid" && [ -n "$pid" ]; then fm_lock_remove_path "$FM_AFK_LOCK" 2>/dev/null || true fi diff --git a/tests/fm-afk-launch.test.sh b/tests/fm-afk-launch.test.sh index cfa3dded42d..49ee6ab4682 100755 --- a/tests/fm-afk-launch.test.sh +++ b/tests/fm-afk-launch.test.sh @@ -562,60 +562,134 @@ unit_native_refuses_claude_on_herdr() { # UNIT: bin/fm-afk-start.sh is ALSO a documented direct entry point (the # native two-step's second half, and a bare direct call), so the launcher's # own start-native guard above does not cover it - the same wedge is reachable -# by skipping the launcher entirely. This guard is keyed on HOSTING MODE, not -# harness+backend alone: the launcher's terminal-backed path also execs this -# script, but always passes FM_SUPERVISOR_TARGET explicitly (a separate pane -# injecting into the captain's), so its presence proves a safe, already-hosted -# invocation and must stay permitted even on claude+herdr. +# by skipping the launcher entirely. The signal is PHYSICAL: refuse only when +# this process's OWN pane (raw $TMUX_PANE / $HERDR_ENV+$HERDR_PANE_ID) is the +# very pane escalations will be injected into (discover_supervisor_target, +# which honours the FM_SUPERVISOR_TARGET override). Same pane on claude+herdr +# means the daemon hosts itself in the pane it drives - the wedge. Different +# panes (the launcher's own separate terminal targeting the captain's) stay +# permitted, and so does an operator override pointing somewhere else: the +# comparison is where-am-I vs where-does-injection-land, never the mere +# presence of a documented env knob. # --------------------------------------------------------------------------- -unit_start_refuses_direct_claude_on_herdr() { - local st out refused=0 +START_GUARD_CLEAN_ENV=(env -u CURSOR_AGENT -u CURSOR_INVOKED_AS -u CLAUDECODE -u PI_CODING_AGENT + -u GROK_AGENT -u TMUX_PANE -u FM_SUPERVISOR_TARGET -u FM_SUPERVISOR_BACKEND + -u HERDR_ENV -u HERDR_PANE_ID -u HERDR_SESSION) + +# Run fm_afk_start_main in a child shell with a harmless daemon stand-in, so a +# PERMITTED start is observable as its own announcement rather than by execing +# the real daemon. Prints combined output; returns the real exit status. +start_guard_run() { # ... + local st=$1; shift + # shellcheck disable=SC2016 # $1 is bash -c's own positional param. + "${START_GUARD_CLEAN_ENV[@]}" FM_HOME="$st" FM_STATE_OVERRIDE="$st/state" "$@" bash -c ' + . "$1" + FM_AFK_DAEMON=/bin/true + fm_afk_start_main + ' _ "$START" 2>&1 +} + +unit_start_refuses_self_injecting_claude_on_herdr() { + local st out status + + # (1) claude+herdr, own pane IS the injection target -> refused, and no + # lifecycle state left behind. st=$(mktemp -d "${TMPDIR:-/tmp}/fm-afk-start-guard.XXXXXX") mkdir -p "$st/state" - out=$(env -u CURSOR_AGENT -u CURSOR_INVOKED_AS -u PI_CODING_AGENT -u GROK_AGENT -u TMUX_PANE \ - -u FM_SUPERVISOR_TARGET -u FM_SUPERVISOR_BACKEND \ - CLAUDECODE=1 HERDR_ENV=1 HERDR_PANE_ID=pane-1 FM_HOME="$st" FM_STATE_OVERRIDE="$st/state" \ - "$START" 2>&1) || refused=1 - if [ "$refused" -eq 1 ] && [ ! -e "$st/state/.afk" ]; then - pass "start guard: direct claude+herdr entry is refused with no lifecycle state written" + status=0 + out=$(start_guard_run "$st" CLAUDECODE=1 HERDR_ENV=1 HERDR_PANE_ID=pane-1) || status=$? + if [ "$status" -ne 0 ] && [ ! -e "$st/state/.afk" ]; then + pass "start guard: claude+herdr self-injection is refused with no lifecycle state left behind" else - fail "start guard: direct claude+herdr entry was accepted" + fail "start guard: claude+herdr self-injection was accepted ($out)" fi case "$out" in *'bin/fm-afk-launch.sh start'*) pass "start guard: refusal names the terminal-backed path" ;; - *) fail "start guard: refusal did not name the terminal-backed path" ;; + *) fail "start guard: refusal did not name the terminal-backed path ($out)" ;; esac rm -rf "$st" + # (2) claude+herdr, launcher-placed separate terminal: own pane differs from + # the captain pane it injects into -> permitted (not a blanket block). st=$(mktemp -d "${TMPDIR:-/tmp}/fm-afk-start-guard.XXXXXX") mkdir -p "$st/state" - # shellcheck disable=SC2016 # $1 is bash -c's own positional param, not this shell's. - out=$(env -u CURSOR_AGENT -u CURSOR_INVOKED_AS -u PI_CODING_AGENT -u GROK_AGENT -u TMUX_PANE \ - CLAUDECODE=1 HERDR_ENV=1 HERDR_PANE_ID=pane-1 FM_HOME="$st" FM_STATE_OVERRIDE="$st/state" \ - FM_SUPERVISOR_TARGET=captain FM_SUPERVISOR_BACKEND=herdr bash -c ' - . "$1" - FM_AFK_DAEMON=/bin/true - fm_afk_start_main - ' _ "$START" 2>&1) + out=$(start_guard_run "$st" CLAUDECODE=1 HERDR_ENV=1 HERDR_PANE_ID=daemon-pane \ + FM_SUPERVISOR_TARGET=default:captain-pane FM_SUPERVISOR_BACKEND=herdr) + case "$out" in + *'starting supervise daemon'*) pass "start guard: claude+herdr in the launcher's own separate pane is permitted" ;; + *) fail "start guard: launcher-hosted claude+herdr entry was refused ($out)" ;; + esac + rm -rf "$st" + + # (3) claude+herdr with a hand-set FM_SUPERVISOR_TARGET pointing at a pane + # this process is NOT running in -> permitted: no self-injection. + st=$(mktemp -d "${TMPDIR:-/tmp}/fm-afk-start-guard.XXXXXX") + mkdir -p "$st/state" + out=$(start_guard_run "$st" CLAUDECODE=1 HERDR_ENV=1 HERDR_PANE_ID=pane-1 \ + FM_SUPERVISOR_TARGET=default:some-other-pane) + case "$out" in + *'starting supervise daemon'*) pass "start guard: an override naming a different pane is permitted" ;; + *) fail "start guard: an override naming a different pane was refused ($out)" ;; + esac + rm -rf "$st" + + # (3b) the same documented override pointed AT this process's own pane is + # still refused: the comparison is physical, not override-presence-based. + st=$(mktemp -d "${TMPDIR:-/tmp}/fm-afk-start-guard.XXXXXX") + mkdir -p "$st/state" + status=0 + out=$(start_guard_run "$st" CLAUDECODE=1 HERDR_ENV=1 HERDR_PANE_ID=pane-1 \ + FM_SUPERVISOR_TARGET=default:pane-1 FM_SUPERVISOR_BACKEND=herdr) || status=$? + if [ "$status" -ne 0 ] && [ ! -e "$st/state/.afk" ]; then + pass "start guard: an override naming this process's own pane is still refused" + else + fail "start guard: an operator-set override reopened the self-injection wedge ($out)" + fi + rm -rf "$st" + + # (4) a non-claude primary in the same pane -> permitted: only claude's + # footer token wedges herdr's detection. + st=$(mktemp -d "${TMPDIR:-/tmp}/fm-afk-start-guard.XXXXXX") + mkdir -p "$st/state" + out=$(start_guard_run "$st" PI_CODING_AGENT=true HERDR_ENV=1 HERDR_PANE_ID=pane-1) case "$out" in - *'starting supervise daemon'*) pass "start guard: claude+herdr is still permitted when launcher-hosted (FM_SUPERVISOR_TARGET set)" ;; - *) fail "start guard: claude+herdr launcher-hosted entry was refused ($out)" ;; + *'starting supervise daemon'*) pass "start guard: a non-claude harness in the same pane is permitted" ;; + *) fail "start guard: a non-claude harness in the same pane was refused ($out)" ;; esac rm -rf "$st" + # (5) a refresh of an already-live daemon takes the existing refresh path + # untouched: nothing is being started, so nothing is refused. st=$(mktemp -d "${TMPDIR:-/tmp}/fm-afk-start-guard.XXXXXX") mkdir -p "$st/state" - # shellcheck disable=SC2016 # $1 is bash -c's own positional param, not this shell's. - out=$(env -u CURSOR_AGENT -u CURSOR_INVOKED_AS -u CLAUDECODE -u GROK_AGENT -u TMUX_PANE \ - -u FM_SUPERVISOR_TARGET -u FM_SUPERVISOR_BACKEND \ - PI_CODING_AGENT=true HERDR_ENV=1 HERDR_PANE_ID=pane-1 FM_HOME="$st" FM_STATE_OVERRIDE="$st/state" bash -c ' + status=0 + # shellcheck disable=SC2016 # $1 is bash -c's own positional param. + out=$("${START_GUARD_CLEAN_ENV[@]}" CLAUDECODE=1 HERDR_ENV=1 HERDR_PANE_ID=pane-1 \ + FM_HOME="$st" FM_STATE_OVERRIDE="$st/state" bash -c ' . "$1" FM_AFK_DAEMON=/bin/true + daemon_lock_pid() { echo 4242; } + daemon_lock_held_by_live_daemon() { return 0; } fm_afk_start_main - ' _ "$START" 2>&1) + ' _ "$START" 2>&1) || status=$? + case "$status:$out" in + 0*'daemon already running pid=4242'*) pass "start guard: a refresh of a live daemon takes the refresh path, not the refusal" ;; + *) fail "start guard: a refresh of a live daemon was refused (status=$status) ($out)" ;; + esac + rm -rf "$st" + + # (6) neither side resolvable -> permitted, but the guard says out loud that + # it could not check itself rather than permitting silently. + st=$(mktemp -d "${TMPDIR:-/tmp}/fm-afk-start-guard.XXXXXX") + mkdir -p "$st/state" + out=$(start_guard_run "$st" CLAUDECODE=1) + case "$out" in + *'starting supervise daemon'*) pass "start guard: an unresolvable pane permits rather than blocks" ;; + *) fail "start guard: an unresolvable pane was refused ($out)" ;; + esac case "$out" in - *'starting supervise daemon'*) pass "start guard: a non-claude harness on herdr direct entry is still permitted" ;; - *) fail "start guard: a non-claude harness on herdr direct entry was refused ($out)" ;; + *"cannot resolve this process's own pane"*) pass "start guard: unresolvable state emits the could-not-check diagnostic" ;; + *) fail "start guard: unresolvable state permitted silently ($out)" ;; esac rm -rf "$st" } @@ -1056,7 +1130,7 @@ unit_readiness_failure_preserves_unconfirmed_record unit_tmux_absence_distinguishes_probe_failure unit_native_lifecycle unit_native_refuses_claude_on_herdr -unit_start_refuses_direct_claude_on_herdr +unit_start_refuses_self_injecting_claude_on_herdr unit_native_entry_preserves_prepared_state unit_close_failure_preserves_record unit_record_publication_atomic From 48d373997218dd556e84f39b6ad151648205862e Mon Sep 17 00:00:00 2001 From: Nicholas Tran <26808160+NicholasACTran@users.noreply.github.com> Date: Wed, 26 Aug 2026 04:11:46 -0400 Subject: [PATCH 08/11] no-mistakes(review): sanitize ambient pane env at fm-afk-start test call sites --- tests/fm-daemon.test.sh | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/tests/fm-daemon.test.sh b/tests/fm-daemon.test.sh index 962f143464d..f0c7f2c3d63 100755 --- a/tests/fm-daemon.test.sh +++ b/tests/fm-daemon.test.sh @@ -12,6 +12,14 @@ set -u DAEMON="$ROOT/bin/fm-supervise-daemon.sh" AFK_START="$ROOT/bin/fm-afk-start.sh" +# fm-afk-start.sh reads the AMBIENT pane and harness environment to decide whether +# a start would host the daemon in the pane it injects into. These tests are about +# lock/pidfile reclamation, not that guard, so pin a neutral identity: no harness +# marker and no pane manager, which the test runner would otherwise inherit from a +# real captain session. +AFK_START_ENV=(env -u CURSOR_AGENT -u CURSOR_INVOKED_AS -u CLAUDECODE -u PI_CODING_AGENT + -u GROK_AGENT -u TMUX_PANE -u HERDR_ENV -u HERDR_PANE_ID -u HERDR_SESSION + -u FM_SUPERVISOR_TARGET) # Source the daemon's pure functions once. Its main loop is skipped under sourcing # via a BASH_SOURCE guard, so only classify_*/housekeeping/escalate_*/afk_* and the # pane/submit helpers become defined. @@ -31,7 +39,7 @@ test_afk_start_refuses_when_flag_cannot_be_written() { state="$dir/state" mkdir -p "$state/.afk" - out=$(FM_STATE_OVERRIDE="$state" FM_SUPERVISOR_BACKEND=unsupported "$AFK_START" 2>&1) + out=$("${AFK_START_ENV[@]}" FM_STATE_OVERRIDE="$state" FM_SUPERVISOR_BACKEND=unsupported "$AFK_START" 2>&1) status=$? [ "$status" -ne 0 ] || fail "fm-afk-start.sh should fail when state/.afk cannot be written" @@ -46,7 +54,7 @@ test_afk_start_ignores_stale_pidfile_without_lock() { state="$dir/state" printf '%s\n' "$$" > "$state/.supervise-daemon.pid" - out=$(FM_STATE_OVERRIDE="$state" FM_SUPERVISOR_BACKEND=unsupported "$AFK_START" 2>&1) + out=$("${AFK_START_ENV[@]}" FM_STATE_OVERRIDE="$state" FM_SUPERVISOR_BACKEND=unsupported "$AFK_START" 2>&1) status=$? [ "$status" -ne 0 ] || fail "fm-afk-start.sh should attempt daemon startup instead of trusting a pidfile-only live pid" @@ -66,7 +74,7 @@ test_afk_start_reclaims_stale_daemon_lock_reused_pid() { printf '%s\n' "$$" > "$lock/pid" printf '%s\n' "stale daemon identity" > "$lock/pid-identity" - out=$(FM_STATE_OVERRIDE="$state" FM_SUPERVISOR_BACKEND=unsupported "$AFK_START" 2>&1) + out=$("${AFK_START_ENV[@]}" FM_STATE_OVERRIDE="$state" FM_SUPERVISOR_BACKEND=unsupported "$AFK_START" 2>&1) status=$? [ "$status" -ne 0 ] || fail "fm-afk-start.sh should attempt daemon startup after rejecting a reused-pid lock" From 6eb3cd86a464bdf1dba64b1eacd026ebee0ac078 Mon Sep 17 00:00:00 2001 From: Nicholas Tran <26808160+NicholasACTran@users.noreply.github.com> Date: Wed, 26 Aug 2026 04:17:54 -0400 Subject: [PATCH 09/11] no-mistakes(review): pin ambient pane env at remaining afk-start test call sites --- tests/fm-afk-launch.test.sh | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/tests/fm-afk-launch.test.sh b/tests/fm-afk-launch.test.sh index 49ee6ab4682..1fe99f3386d 100755 --- a/tests/fm-afk-launch.test.sh +++ b/tests/fm-afk-launch.test.sh @@ -40,6 +40,14 @@ GLOBAL_CLEANUP() { } trap GLOBAL_CLEANUP EXIT +# bin/fm-afk-start.sh refuses a start that would host the away daemon in the very +# pane it injects into. Every test below that runs its main is about something +# else, so they pin a neutral identity - no harness marker, no pane manager - +# instead of inheriting the test runner's real captain session. +START_GUARD_CLEAN_ENV=(env -u CURSOR_AGENT -u CURSOR_INVOKED_AS -u CLAUDECODE -u PI_CODING_AGENT + -u GROK_AGENT -u TMUX_PANE -u FM_SUPERVISOR_TARGET -u FM_SUPERVISOR_BACKEND + -u HERDR_ENV -u HERDR_PANE_ID -u HERDR_SESSION) + # --------------------------------------------------------------------------- # UNIT 1: fm_afk_clear_stale_artifacts removes exactly the three stale artifacts. # --------------------------------------------------------------------------- @@ -136,7 +144,7 @@ unit_fresh_vs_refresh() { mkdir -p "$lock" printf '%s' "$sleep_pid" > "$lock/pid" ( . "$ROOT/bin/fm-wake-lib.sh"; fm_pid_identity "$sleep_pid" > "$lock/pid-identity" 2>/dev/null ) || true - FM_HOME="$st" FM_STATE_OVERRIDE="$st/state" "$START" >/dev/null 2>&1 + "${START_GUARD_CLEAN_ENV[@]}" FM_HOME="$st" FM_STATE_OVERRIDE="$st/state" "$START" >/dev/null 2>&1 if [ -e "$st/state/.subsuper-escalations" ] && [ -e "$st/state/.subsuper-inject-wedged" ]; then pass "refresh: daemon already alive - stale artifacts preserved (current session's buffer kept)" else @@ -572,9 +580,6 @@ unit_native_refuses_claude_on_herdr() { # comparison is where-am-I vs where-does-injection-land, never the mere # presence of a documented env knob. # --------------------------------------------------------------------------- -START_GUARD_CLEAN_ENV=(env -u CURSOR_AGENT -u CURSOR_INVOKED_AS -u CLAUDECODE -u PI_CODING_AGENT - -u GROK_AGENT -u TMUX_PANE -u FM_SUPERVISOR_TARGET -u FM_SUPERVISOR_BACKEND - -u HERDR_ENV -u HERDR_PANE_ID -u HERDR_SESSION) # Run fm_afk_start_main in a child shell with a harmless daemon stand-in, so a # PERMITTED start is observable as its own announcement rather than by execing @@ -700,7 +705,8 @@ unit_native_entry_preserves_prepared_state() { mkdir -p "$st/state" : > "$st/state/.afk" : > "$st/state/.subsuper-escalations" - FM_HOME="$st" FM_STATE_OVERRIDE="$st/state" FM_AFK_STATE_PREPARED=1 bash -c ' + "${START_GUARD_CLEAN_ENV[@]}" FM_HOME="$st" FM_STATE_OVERRIDE="$st/state" \ + FM_AFK_STATE_PREPARED=1 bash -c ' . "$1" FM_AFK_DAEMON=/bin/true fm_afk_start_main From 750b4986419c2a39288f7e2c183ca8931a74034b Mon Sep 17 00:00:00 2001 From: Nicholas Tran <26808160+NicholasACTran@users.noreply.github.com> Date: Wed, 26 Aug 2026 04:32:54 -0400 Subject: [PATCH 10/11] no-mistakes(lint): move SC2016 disable before prepared-state bash -c call --- tests/fm-afk-launch.test.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/fm-afk-launch.test.sh b/tests/fm-afk-launch.test.sh index 1fe99f3386d..5ef010d7ffe 100755 --- a/tests/fm-afk-launch.test.sh +++ b/tests/fm-afk-launch.test.sh @@ -705,6 +705,7 @@ unit_native_entry_preserves_prepared_state() { mkdir -p "$st/state" : > "$st/state/.afk" : > "$st/state/.subsuper-escalations" + # shellcheck disable=SC2016 # $1 is bash -c's own positional param. "${START_GUARD_CLEAN_ENV[@]}" FM_HOME="$st" FM_STATE_OVERRIDE="$st/state" \ FM_AFK_STATE_PREPARED=1 bash -c ' . "$1" From b1cd4ecf71e9dde587fc61840b4b00f733fe39c5 Mon Sep 17 00:00:00 2001 From: Nicholas Tran <26808160+NicholasACTran@users.noreply.github.com> Date: Wed, 26 Aug 2026 05:13:05 -0400 Subject: [PATCH 11/11] fix(afk): decide the self-injection guard before writing state, narrow its diagnostic The guard must refuse before any lifecycle state is written so a refusal leaves nothing to roll back - tracking that this invocation wrote .afk and reverting it afterward was a repair, not the contract. Reorder fm_afk_start_main so the daemon-lock refresh check and the guard's decision both happen before fm_afk_start_ensure_flag runs. Also narrow the guard's "cannot check myself" diagnostic: it fired whenever either side was unresolvable, including when herdr is provably absent (no TMUX_PANE, no HERDR_ENV+HERDR_PANE_ID) and there is no real ambiguity to report. Resolve the backend first and permit silently when it isn't herdr; only warn when herdr is confirmed present and the pane comparison itself cannot be completed. --- bin/fm-afk-start.sh | 56 +++++++++++++++++++------------------ tests/fm-afk-launch.test.sh | 13 +++++---- 2 files changed, 36 insertions(+), 33 deletions(-) diff --git a/bin/fm-afk-start.sh b/bin/fm-afk-start.sh index d884e3822d6..c8268b12063 100755 --- a/bin/fm-afk-start.sh +++ b/bin/fm-afk-start.sh @@ -107,22 +107,22 @@ fm_afk_start_native_refused() { harness=$("$FM_AFK_START_DIR/fm-harness.sh" 2>/dev/null) || harness=unknown [ "$harness" = claude ] || return 1 - own_backend=$(fm_afk_start_own_backend) || own_backend='' - own_pane=$(fm_afk_start_own_pane) || own_pane='' - # Deliberate: an unresolvable side means the guard cannot check itself, so it - # permits rather than blocks on unknown state, and says so out loud instead of - # permitting silently. - if [ -z "$own_backend" ] || [ -z "$own_pane" ]; then - echo "afk: cannot resolve this process's own pane (no TMUX_PANE, no HERDR_ENV+HERDR_PANE_ID), so the claude+herdr self-injection guard cannot check itself; permitting this start rather than blocking on unknown state" >&2 - return 1 - fi + # Resolve backend FIRST and narrow the "cannot check myself" diagnostic to + # when it is actually true: herdr provably absent (no TMUX_PANE, no + # HERDR_ENV+HERDR_PANE_ID) or resolved to a different backend is not + # ambiguity, it is proof there is nothing to warn about - permit silently. + own_backend=$(fm_afk_start_own_backend) || return 1 [ "$own_backend" = herdr ] || return 1 - target=$(discover_supervisor_target) || target='' - if [ -z "$target" ]; then + own_pane=$(fm_afk_start_own_pane) || { + echo "afk: resolved this process's own backend as herdr but could not resolve its own pane, so the claude+herdr self-injection guard cannot check itself; permitting this start rather than blocking on unknown state" >&2 + return 1 + } + + target=$(discover_supervisor_target) || { echo "afk: cannot resolve the escalation injection target, so the claude+herdr self-injection guard cannot check itself; permitting this start rather than blocking on unknown state" >&2 return 1 - fi + } [ "$own_pane" = "$target" ] || return 1 return 0 @@ -212,6 +212,17 @@ fm_afk_flag_write() { # return 1 } +# Write (or verify) the lifecycle flag on the path that has decided to proceed. +# Shared by the refresh and fresh branches of fm_afk_start_main so neither +# writes it before that decision is made. +fm_afk_start_ensure_flag() { + if [ "${FM_AFK_STATE_PREPARED:-0}" = 1 ]; then + [ -f "$FM_AFK_STATE/.afk" ] || { echo "afk: launcher-prepared state is missing" >&2; return 1; } + else + fm_afk_flag_write "$FM_AFK_STATE" || { echo "afk: failed to write away-mode flag" >&2; return 1; } + fi +} + fm_afk_start_main() { case "${1:-}" in '' ) ;; @@ -219,36 +230,27 @@ fm_afk_start_main() { * ) echo "usage: $(basename "${BASH_SOURCE[1]:-fm-afk-start.sh}")" >&2; return 2 ;; esac - local had_flag=0 - [ ! -e "$FM_AFK_STATE/.afk" ] || had_flag=1 - mkdir -p "$FM_AFK_STATE" - if [ "${FM_AFK_STATE_PREPARED:-0}" = 1 ]; then - [ -f "$FM_AFK_STATE/.afk" ] || { echo "afk: launcher-prepared state is missing" >&2; return 1; } - else - fm_afk_flag_write "$FM_AFK_STATE" || { echo "afk: failed to write away-mode flag" >&2; return 1; } - fi local pid pid=$(daemon_lock_pid 2>/dev/null || true) if daemon_lock_held_by_live_daemon; then + fm_afk_start_ensure_flag || return 1 echo "afk: daemon already running pid=$pid" return 0 fi - # Only a genuine FRESH start reaches the guard: a refresh of an already-live, - # already-safely-hosted daemon returned above untouched. Roll the away flag - # back when this invocation is the one that wrote it, so a refusal leaves the - # lifecycle exactly as it found it. + # Only a genuine FRESH start reaches here (a refresh of an already-live, + # already-safely-hosted daemon returned above, untouched). Decide BEFORE + # writing any lifecycle state, so a refusal leaves nothing to roll back. if fm_afk_start_native_refused; then - if [ "${FM_AFK_STATE_PREPARED:-0}" != 1 ] && [ "$had_flag" -eq 0 ]; then - rm -f "$FM_AFK_STATE/.afk" 2>/dev/null || true - fi echo "afk: refusing to host the away daemon in the same pane it injects into on claude+herdr: a claude background shell renders in that pane's footer, herdr reads that as the agent working, so the away daemon defers forever and away mode silently delivers nothing" >&2 echo "afk: use 'bin/fm-afk-launch.sh start' instead (non-visible daemon terminal)" >&2 return 1 fi + fm_afk_start_ensure_flag || return 1 + if fm_pid_alive "$pid" && [ -n "$pid" ]; then fm_lock_remove_path "$FM_AFK_LOCK" 2>/dev/null || true fi diff --git a/tests/fm-afk-launch.test.sh b/tests/fm-afk-launch.test.sh index 5ef010d7ffe..3207ab3e0ee 100755 --- a/tests/fm-afk-launch.test.sh +++ b/tests/fm-afk-launch.test.sh @@ -683,18 +683,19 @@ unit_start_refuses_self_injecting_claude_on_herdr() { esac rm -rf "$st" - # (6) neither side resolvable -> permitted, but the guard says out loud that - # it could not check itself rather than permitting silently. + # (6) herdr provably absent (no TMUX_PANE, no HERDR_ENV+HERDR_PANE_ID) is not + # ambiguity - there is nothing the guard could have failed to check, so it + # permits SILENTLY rather than crying wolf on every plain claude terminal. st=$(mktemp -d "${TMPDIR:-/tmp}/fm-afk-start-guard.XXXXXX") mkdir -p "$st/state" out=$(start_guard_run "$st" CLAUDECODE=1) case "$out" in - *'starting supervise daemon'*) pass "start guard: an unresolvable pane permits rather than blocks" ;; - *) fail "start guard: an unresolvable pane was refused ($out)" ;; + *'starting supervise daemon'*) pass "start guard: herdr provably absent permits rather than blocks" ;; + *) fail "start guard: herdr provably absent was refused ($out)" ;; esac case "$out" in - *"cannot resolve this process's own pane"*) pass "start guard: unresolvable state emits the could-not-check diagnostic" ;; - *) fail "start guard: unresolvable state permitted silently ($out)" ;; + *"cannot resolve"*) fail "start guard: herdr provably absent still emitted the could-not-check diagnostic ($out)" ;; + *) pass "start guard: herdr provably absent permits without the could-not-check diagnostic" ;; esac rm -rf "$st" }