Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions .agents/skills/afk/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,17 +24,22 @@ 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.
`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
`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
Expand Down
36 changes: 33 additions & 3 deletions bin/fm-afk-launch.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -29,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
Expand Down Expand Up @@ -527,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"
Expand Down
124 changes: 112 additions & 12 deletions bin/fm-afk-start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -41,11 +47,87 @@ 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.
#
# 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 own_backend own_pane target
harness=$("$FM_AFK_START_DIR/fm-harness.sh" 2>/dev/null) || harness=unknown
[ "$harness" = claude ] || return 1

# 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

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
}

[ "$own_pane" = "$target" ] || 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.
Expand Down Expand Up @@ -130,6 +212,17 @@ fm_afk_flag_write() { # <state-dir>
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
'' ) ;;
Expand All @@ -138,19 +231,26 @@ fm_afk_start_main() {
esac

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 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
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
Expand Down
7 changes: 5 additions & 2 deletions docs/herdr-backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -291,8 +291,11 @@ 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 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.
Expand Down
Loading
Loading