Skip to content
8 changes: 7 additions & 1 deletion .agents/skills/afk/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ batched digest rather than per-wake injections.

3. **Do not separately arm `fm-watch.sh`.** The daemon manages the watcher as
its child; the singleton lock no-ops a stray arm harmlessly.
On Pi and pi-signed, the loaded watcher extension observes the durable flag,
yields its exact attached arm cycle, and resumes one ordinary cycle after
return; no watcher command or Pi restart belongs in this lifecycle.

4. **Acknowledge** in `AGENTS.md` section 9 language: "Captain, away mode is active; I will batch routine updates and surface only decisions, failures, credentials, or review-ready work until you return."

Expand Down Expand Up @@ -151,6 +154,8 @@ It self-handles the routine majority without consuming a firstmate turn.
Captain-relevant events, plus a bounded recheck of a declared external wait that remains idle, escalate to firstmate's context as one pre-read, single-line, batched digest.
The classification predicates (the captain-relevant verb set, declared-pause vocabulary, signal/stale tests, and fleet-scan) live in the shared `bin/fm-classify-lib.sh`, the same library the always-on watcher uses for its own triage when afk is off, so the two modes apply one identical policy.
While `state/.afk` exists the daemon owns the watcher, so the watcher reverts to one-shot and lets the daemon do the triage - the two never run their triage at the same time.
The daemon classifies unseen logical wakes from durable queue state without consuming them, closing the Pi handoff interval while preserving `bin/fm-wake-drain.sh` as the sole queue consumer.
`docs/watcher-continuity.md` owns the exact transfer and deduplication contract.

Classify each wake this way:

Expand Down Expand Up @@ -229,7 +234,8 @@ the operational prefix lets firstmate distinguish it from a real captain message

## Stale-artifact lifecycle

Treat `state/.subsuper-escalations`, its `.since` sidecar, and `state/.subsuper-inject-wedged` as session-scoped delivery artifacts, not as the durable work record.
Treat the daemon's buffered escalation, queue-classification cursor, crash-pending record, check-wake deduplication ledger, and wedge marker as session-scoped delivery artifacts, not as the durable work record.
Their exact paths and cleanup mechanics remain owned by the producing scripts.
Always enter through `bin/fm-afk-launch.sh`, which clears prior-session artifacts only for a fresh entry and preserves the current session's buffer on refresh.
Always exit through `bin/fm-afk-launch.sh stop`, which keeps `state/.afk` present through the daemon's shutdown flush and clears it last.
`docs/herdr-backend.md` "Away-mode supervisor support" owns the current mechanism, and `docs/verification/runtime-backends.md` "Away-mode transport" owns active evidence.
Expand Down
322 changes: 289 additions & 33 deletions .pi/extensions/fm-primary-pi-watch.ts

Large diffs are not rendered by default.

14 changes: 14 additions & 0 deletions .pi/extensions/fm-primary-turnend-guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,21 @@ export default function (pi: ExtensionAPI) {
return { block: true, reason: result.stderr.trim() || "denied by the watcher-arm PreToolUse seatbelt" };
});

// A guard follow-up queued just before AFK entry must not become an away-mode
// model turn. Actionable away-supervisor messages carry a different kind and
// remain deliverable.
pi.on("input", (event) => {
if (event.source !== "extension" || !existsSync(`${state}/.afk`)) return { action: "continue" };
return classifyFirstmateCurrentOperationalText(event.text)?.trim() === "turn-end-guard"
? { action: "handled" }
: { action: "continue" };
});

pi.on("agent_settled", async () => {
if (existsSync(`${state}/.afk`)) {
guardFollowupActive = false;
return;
}
if (guardFollowupActive) {
guardFollowupActive = false;
return;
Expand Down
9 changes: 5 additions & 4 deletions bin/fm-afk-launch.sh
Original file line number Diff line number Diff line change
Expand Up @@ -360,11 +360,12 @@ fm_afk_launch_restore_backup() { # <backup> <had-afk>
rm -f "$FM_AFK_LAUNCH_STATE/.afk" \
"$FM_AFK_LAUNCH_STATE/.subsuper-escalations" \
"$FM_AFK_LAUNCH_STATE/.subsuper-escalations.since" \
"$FM_AFK_LAUNCH_STATE/.subsuper-inject-wedged" || result=1
"$FM_AFK_LAUNCH_STATE/.subsuper-inject-wedged" \
"$FM_AFK_LAUNCH_STATE/.subsuper-seen-wake-seq" || result=1
if [ "$had_afk" -eq 1 ]; then
cp "$backup/.afk" "$FM_AFK_LAUNCH_STATE/.afk" || result=1
fi
for artifact in .subsuper-escalations .subsuper-escalations.since .subsuper-inject-wedged; do
for artifact in .subsuper-escalations .subsuper-escalations.since .subsuper-inject-wedged .subsuper-seen-wake-seq; do
if [ -e "$backup/$artifact" ]; then
cp -p "$backup/$artifact" "$FM_AFK_LAUNCH_STATE/$artifact" || result=1
fi
Expand Down Expand Up @@ -487,7 +488,7 @@ fm_afk_launch_start() {
had_afk=1
cp "$FM_AFK_LAUNCH_STATE/.afk" "$backup/.afk" || { rm -rf "$backup"; return 1; }
fi
for artifact in .subsuper-escalations .subsuper-escalations.since .subsuper-inject-wedged; do
for artifact in .subsuper-escalations .subsuper-escalations.since .subsuper-inject-wedged .subsuper-seen-wake-seq; do
if [ -e "$FM_AFK_LAUNCH_STATE/$artifact" ]; then
cp -p "$FM_AFK_LAUNCH_STATE/$artifact" "$backup/$artifact" || { rm -rf "$backup"; return 1; }
fi
Expand Down Expand Up @@ -545,7 +546,7 @@ fm_afk_launch_start_native() {
had_afk=1
cp "$FM_AFK_LAUNCH_STATE/.afk" "$backup/.afk" || { rm -rf "$backup"; return 1; }
fi
for artifact in .subsuper-escalations .subsuper-escalations.since .subsuper-inject-wedged; do
for artifact in .subsuper-escalations .subsuper-escalations.since .subsuper-inject-wedged .subsuper-seen-wake-seq; do
if [ -e "$FM_AFK_LAUNCH_STATE/$artifact" ]; then
cp -p "$FM_AFK_LAUNCH_STATE/$artifact" "$backup/$artifact" || { rm -rf "$backup"; return 1; }
fi
Expand Down
9 changes: 6 additions & 3 deletions bin/fm-afk-return.sh
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,10 @@ clear_delivery_artifacts() {
rm -f \
"$STATE/.subsuper-escalations" \
"$STATE/.subsuper-escalations.since" \
"$STATE/.subsuper-inject-wedged"
"$STATE/.subsuper-inject-wedged" \
"$STATE/.subsuper-seen-wake-seq" \
"$STATE/.subsuper-pending-wake" \
"$STATE/.subsuper-seen-wake-checks"
}

return_guard() {
Expand Down Expand Up @@ -204,8 +207,8 @@ return_reconcile() {
return 3
fi

rm -f "$GATE"
clear_delivery_artifacts
clear_delivery_artifacts || { rm -f "$evidence" "$blockers" "$drain_err"; return 1; }
rm -f "$GATE" || { rm -f "$evidence" "$blockers" "$drain_err"; return 1; }
rm -f "$evidence" "$blockers" "$drain_err"
printf 'fm-afk-return: catch-up clear; ordinary captain work may proceed\n'
return 0
Expand Down
3 changes: 2 additions & 1 deletion bin/fm-afk-start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,8 @@ fm_afk_clear_stale_artifacts() { # <state-dir>
local state=$1
rm -f "$state/.subsuper-escalations" \
"$state/.subsuper-escalations.since" \
"$state/.subsuper-inject-wedged" 2>/dev/null
"$state/.subsuper-inject-wedged" \
"$state/.subsuper-seen-wake-seq" 2>/dev/null
}

daemon_lock_owner() {
Expand Down
201 changes: 201 additions & 0 deletions bin/fm-pi-arm-tree-retire.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
#!/usr/bin/env bash
set -u

native_pid=${1:-}
token=${2:-}
state=${3:-}
watch_path=${4:-}
home=${5:-}
case "$native_pid" in ''|*[!0-9]*|0|1) exit 2 ;; esac
case "$token" in ''|*[!0-9a-f]*) exit 2 ;; esac
[ -n "$state" ] && [ -n "$watch_path" ] && [ -n "$home" ] || exit 2

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
FM_HOME=$home
FM_STATE_OVERRIDE=$state
export FM_HOME FM_STATE_OVERRIDE
# shellcheck source=bin/fm-wake-lib.sh
. "$SCRIPT_DIR/fm-wake-lib.sh"

proc_root=${FM_PROC_ROOT_OVERRIDE:-/proc}
wrapper_pid_file="$state/.pi-arm-wrapper-$token.pid"
retirement_dir="$state/.pi-arm-retirement-$token"
FM_PI_ARM_WRAPPER_PID_FILE=$wrapper_pid_file
export FM_PI_ARM_WRAPPER_PID_FILE

read_single_pid() {
awk '
NR == 1 && /^[0-9]+$/ && $0 != "0" && $0 != "1" { pid = $0; next }
{ invalid = 1 }
END {
if (!invalid && pid != "") print pid
else exit 1
}
' "$1" 2>/dev/null
}

cleanup_snapshot() {
rm -f \
"$1/wrapper-msys-pid" \
"$1/wrapper-native-pid" \
"$1/watcher-recorded" \
"$1/watcher-msys-pid" \
"$1/watcher-native-pid" \
"$1/watcher-native-pid.tmp" \
"$1/watcher-identity" \
"$1/watcher-recorded.tmp" 2>/dev/null || true
rmdir "$1" 2>/dev/null || true
}

read_watcher_lock_snapshot() {
local lock_home lock_path current_identity
watcher_pid=$(read_single_pid "$state/.watch.lock/pid") || return 1
lock_home=$(cat "$state/.watch.lock/fm-home" 2>/dev/null || true)
lock_path=$(cat "$state/.watch.lock/watcher-path" 2>/dev/null || true)
watcher_identity=$(cat "$state/.watch.lock/pid-identity" 2>/dev/null || true)
[ "$lock_home" = "$home" ] || return 1
[ "$lock_path" = "$watch_path" ] || return 1
[ -n "$watcher_identity" ] || return 1
watcher_native_pid=
current_identity=$(fm_pid_identity "$watcher_pid" 2>/dev/null || true)
if [ "$current_identity" = "$watcher_identity" ]; then
watcher_native_pid=$(read_single_pid "$proc_root/$watcher_pid/winpid") || return 1
fi
watcher_recorded=1
}

record_snapshot() {
local i=0 tmp mapped_native_pid
while [ "$i" -lt 20 ]; do
msys_pid=$(read_single_pid "$wrapper_pid_file" 2>/dev/null || true)
[ -n "$msys_pid" ] && break
sleep 0.05
i=$((i + 1))
done
[ -n "${msys_pid:-}" ] || return 1
mapped_native_pid=$(read_single_pid "$proc_root/$msys_pid/winpid") || return 1
[ "$mapped_native_pid" = "$native_pid" ] || return 1
tree_matches || return 1

watcher_recorded=0
watcher_pid=
watcher_native_pid=
watcher_identity=
if [ -e "$state/.watch.lock" ]; then
read_watcher_lock_snapshot || return 1
fi

tmp="$retirement_dir.${BASHPID:-$$}"
mkdir "$tmp" 2>/dev/null || return 1
if ! printf '%s\n' "$msys_pid" > "$tmp/wrapper-msys-pid" \
|| ! printf '%s\n' "$native_pid" > "$tmp/wrapper-native-pid" \
|| ! printf '%s\n' "$watcher_recorded" > "$tmp/watcher-recorded" \
|| ! printf '%s\n' "$watcher_pid" > "$tmp/watcher-msys-pid" \
|| ! printf '%s\n' "$watcher_native_pid" > "$tmp/watcher-native-pid" \
|| ! printf '%s\n' "$watcher_identity" > "$tmp/watcher-identity" \
|| ! mv "$tmp" "$retirement_dir"; then
cleanup_snapshot "$tmp"
return 1
fi
}

tree_matches() {
local current_native_pid
current_native_pid=$(read_single_pid "$proc_root/$msys_pid/winpid") || return 1
[ "$current_native_pid" = "$native_pid" ] \
&& [ -r "$proc_root/$msys_pid/environ" ] \
&& tr '\0' '\n' < "$proc_root/$msys_pid/environ" 2>/dev/null \
| grep -F -x -- "FM_PI_ARM_TREE_TOKEN=$token" >/dev/null 2>&1
}

if [ ! -e "$retirement_dir" ]; then
record_snapshot || exit 3
fi

msys_pid=$(read_single_pid "$retirement_dir/wrapper-msys-pid") || exit 3
recorded_native_pid=$(read_single_pid "$retirement_dir/wrapper-native-pid") || exit 3
[ "$recorded_native_pid" = "$native_pid" ] || exit 3
watcher_recorded=$(cat "$retirement_dir/watcher-recorded" 2>/dev/null || true)
case "$watcher_recorded" in 0|1) ;; *) exit 3 ;; esac
watcher_pid=
watcher_native_pid=
watcher_identity=
if [ "$watcher_recorded" -eq 1 ]; then
watcher_pid=$(read_single_pid "$retirement_dir/watcher-msys-pid") || exit 3
watcher_native_pid=$(cat "$retirement_dir/watcher-native-pid" 2>/dev/null || true)
case "$watcher_native_pid" in
'') ;;
*[!0-9]*|0|1) exit 3 ;;
esac
watcher_identity=$(cat "$retirement_dir/watcher-identity" 2>/dev/null || true)
[ -n "$watcher_identity" ] || exit 3
fi
watcher_matches() {
local current
[ "$watcher_recorded" -eq 1 ] || return 1
current=$(fm_pid_identity "$watcher_pid") || return 1
[ "$current" = "$watcher_identity" ]
}

record_late_watcher() {
[ "$watcher_recorded" -eq 0 ] || return 0
[ -e "$state/.watch.lock" ] || return 0
read_watcher_lock_snapshot || return 1
if ! printf '%s\n' "$watcher_pid" > "$retirement_dir/watcher-msys-pid" \
|| ! printf '%s\n' "$watcher_native_pid" > "$retirement_dir/watcher-native-pid" \
|| ! printf '%s\n' "$watcher_identity" > "$retirement_dir/watcher-identity" \
|| ! printf '1\n' > "$retirement_dir/watcher-recorded.tmp" \
|| ! mv -f "$retirement_dir/watcher-recorded.tmp" "$retirement_dir/watcher-recorded"; then
return 1
fi
watcher_recorded=1
}

bind_watcher_native_pid() {
local current_native_pid
current_native_pid=$(read_single_pid "$proc_root/$watcher_pid/winpid") || return 1
if [ -n "$watcher_native_pid" ]; then
[ "$current_native_pid" = "$watcher_native_pid" ]
return
fi
if ! printf '%s\n' "$current_native_pid" > "$retirement_dir/watcher-native-pid.tmp" \
|| ! mv -f "$retirement_dir/watcher-native-pid.tmp" "$retirement_dir/watcher-native-pid"; then
return 1
fi
watcher_native_pid=$current_native_pid
}

taskkill_status=0
if tree_matches; then
MSYS2_ARG_CONV_EXCL='*' taskkill.exe /PID "$native_pid" /T /F >/dev/null 2>&1 || taskkill_status=$?
fi
i=0
while [ "$i" -lt 5 ] && tree_matches; do
sleep 0.05
i=$((i + 1))
done
i=0
while [ "$watcher_recorded" -eq 0 ] && [ "$i" -lt 5 ]; do
record_late_watcher || exit 1
[ "$watcher_recorded" -eq 1 ] && break
sleep 0.05
i=$((i + 1))
done
if watcher_matches; then
bind_watcher_native_pid || exit 1
MSYS2_ARG_CONV_EXCL='*' taskkill.exe /PID "$watcher_native_pid" /T /F >/dev/null 2>&1 || taskkill_status=$?
fi
i=0
while [ "$i" -lt 20 ]; do
if ! tree_matches && ! watcher_matches; then
if [ "$taskkill_status" -eq 0 ]; then
cleanup_snapshot "$retirement_dir"
rm -f "$wrapper_pid_file" 2>/dev/null || true
exit 0
fi
exit 1
fi
sleep 0.05
i=$((i + 1))
done
exit 1
Loading