Skip to content
20 changes: 4 additions & 16 deletions .agents/skills/afk/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.
Expand Down Expand Up @@ -232,20 +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
`"<session>:<pane-id>"` 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
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

Expand Down
9 changes: 8 additions & 1 deletion bin/fm-afk-launch.sh
Original file line number Diff line number Diff line change
Expand Up @@ -717,7 +717,14 @@ 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"
# 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)"
Expand Down
26 changes: 16 additions & 10 deletions bin/fm-supervise-daemon.sh
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,9 @@
# FM_SUPERVISOR_TARGET supervisor pane target (override; otherwise
# auto-discovered per backend - $TMUX_PANE
# under tmux, "<session>:<pane-id>" 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 "<session>:<pane-id>"
# target; which one it's read as is decided by
# FM_SUPERVISOR_BACKEND (below), independently.
Expand Down Expand Up @@ -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 "<session>:<pane-id>") > 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 "<session>:<pane-id>"). 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
Expand All @@ -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"
Expand Down
20 changes: 12 additions & 8 deletions bin/fm-supervisor-target-lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,12 @@
# 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).
# 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"

Expand All @@ -33,8 +35,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"
Expand All @@ -48,7 +52,6 @@ discover_supervisor_target() {
printf '%s:%s' "${HERDR_SESSION:-default}" "$HERDR_PANE_ID"
return 0
fi
printf '%s' "$FM_SUPERVISOR_TARGET_DEFAULT"
return 1
}

Expand All @@ -59,7 +62,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"
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 6 additions & 2 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -525,7 +525,11 @@ Set `FM_SUPERVISOR_BACKEND=tmux|herdr` and `FM_SUPERVISOR_TARGET=<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.
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.

Expand Down Expand Up @@ -2376,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 <session>:<pane-id>, otherwise auto-detected
FM_SUPERVISOR_TARGET= # optional supervisor pane target override; tmux target or herdr <session>:<pane-id>; 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
Expand Down
5 changes: 2 additions & 3 deletions docs/turnend-guard.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.

Expand Down
24 changes: 24 additions & 0 deletions tests/fm-afk-launch.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,29 @@ 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" ] \
&& 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 or left no durable record (rc=$rc): $out; log: $(cat "$st/state/.supervise-daemon.log" 2>/dev/null)"
fi
rm -rf "$st"
}

unit_failed_daemon_launch_preserves_the_record() {
local st
st=$(mktemp -d "${TMPDIR:-/tmp}/fm-afk-failed-record.XXXXXX")
Expand Down Expand Up @@ -1687,6 +1710,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
Expand Down
Loading
Loading