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
2 changes: 2 additions & 0 deletions .agents/skills/afk/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ backend (tmux or herdr; see "Auto-discovered supervisor pane" below):

- **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.
The rendered scan has no positional anchor - it can match settled SCROLLBACK text, not only a live spinner footer - so an affirmatively empty composer (proof the turn has ended) short-circuits it first; a genuinely busy pane never reads empty (verified live, 2026-09-01: claude-on-herdr).
- **Composer-state guard** - `inject_msg` reads the full `empty`/`pending`/`pending-unproven`/`unknown` verdict from `fm_backend_composer_state` and injects only when it is affirmatively `empty`.
Every other or future verdict defers, including an unreadable pane, ambiguous geometry, a blank unidentified row, and a bare shell prompt left after the agent exits.
Each adapter contributes only capture and capability facts to the fleet-wide screen classifier in `bin/fm-composer-lib.sh`, which owns every shape and verdict.
Expand All @@ -113,6 +114,7 @@ an ERROR in the daemon log, a durable
catch-up if present), a tmux status-line flash when applicable, and a configurable backend-independent active alert.
`docs/wedge-alarm.md` owns the alert channel setup, and `docs/verification/supervision.md` "Wedge-alarm channels" owns active evidence.
So a guard false-positive becomes a visible stall, never an unbounded silent no-op.
`bin/fm-afk-launch.sh start`/`start-native` refuse a fresh entry when no channel can plausibly reach the captain (`docs/wedge-alarm.md` "Away-mode entry check"), so that promise is checked before the captain walks away, not just at failure time.

## Submit model

Expand Down
33 changes: 33 additions & 0 deletions bin/fm-afk-launch.sh
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,13 @@
# Supported backends: herdr, tmux. Others (zellij, orca, cmux) have no verified
# non-visible-launch primitive here yet and refuse loudly.
#
# Both start and start-native refuse a FRESH entry (not a refresh of an
# already-running daemon) when no active wedge-alarm channel is configured and
# this platform has no built-in default (fm-wedge-alarm-lib.sh's
# wedge_alarm_reliable_channel_configured; docs/wedge-alarm.md). Configure
# config/wedge-alarm with a command: directive, or set it to `off` to
# explicitly accept the durable marker as the only signal.
#
# Test seam: FM_AFK_LAUNCH_ENTRY overrides the command run in the created
# terminal (default bin/fm-afk-start.sh), so a topology test can run a harmless
# placeholder instead of a real daemon. FM_SUPERVISOR_TARGET/FM_SUPERVISOR_BACKEND
Expand Down Expand Up @@ -79,6 +86,8 @@ FM_AFK_LAUNCH_WS_LABEL="firstmate-afk-daemon"
. "$FM_AFK_LAUNCH_DIR/fm-backend.sh"
# shellcheck source=bin/fm-supervisor-target-lib.sh
. "$FM_AFK_LAUNCH_DIR/fm-supervisor-target-lib.sh"
# shellcheck source=bin/fm-wedge-alarm-lib.sh
. "$FM_AFK_LAUNCH_DIR/fm-wedge-alarm-lib.sh"
# fm-afk-start.sh provides the daemon-lock liveness helpers and
# fm_afk_clear_stale_artifacts; it is sourceable (BASH_SOURCE guard) and its
# main does not run on source. It sets `set -eu`, so turn errexit back off for
Expand All @@ -89,6 +98,27 @@ set +e

fm_afk_launch_log() { printf 'fm-afk-launch: %s\n' "$*" >&2; }

# fm_afk_launch_wedge_alarm_preflight: refuse a FRESH away-mode entry when no
# active wedge-alarm channel is configured AND this platform has no built-in
# default (fm-wedge-alarm-lib.sh's wedge_alarm_reliable_channel_configured).
# Without this, a captain on a platform with no built-in channel (verified
# live, 2026-09-01: Linux, no osascript, no org.freedesktop.Notifications
# D-Bus service, and herdr's own "notification show" reporting
# {"reason":"disabled"} rather than posting anything) walks away believing the
# documented promise - a guard false-positive or a genuine stall becomes a
# VISIBLE alert - while every future max-defer wedge fires into a channel that
# was never going to reach anyone. Refusing here, before the captain leaves, is
# more honest than a silent marker discovered hours later. An explicit `off` in
# config/wedge-alarm is a deliberate acknowledgment and passes this check, the
# same as any other explicit channel directive; only the unconfigured/auto
# default that resolves to nothing is refused.
fm_afk_launch_wedge_alarm_preflight() {
wedge_alarm_reliable_channel_configured && return 0
fm_afk_launch_log "refusing away-mode entry: no active wedge-alarm channel is configured, and this platform ($(uname)) has no built-in default; a stuck injection would raise the alarm but reach nobody but the durable state/.subsuper-inject-wedged marker (docs/wedge-alarm.md)"
fm_afk_launch_log "configure config/wedge-alarm with a command: directive that can reach you (a phone push, a pager, etc.), or set it to 'off' to explicitly accept the durable marker as the only signal, then retry"
return 1
}

fm_afk_launch_lock_owned() {
local pid expected actual
[ -d "$FM_AFK_LAUNCH_LOCK" ] || return 1
Expand Down Expand Up @@ -482,6 +512,8 @@ fm_afk_launch_start() {
return 0
fi

fm_afk_launch_wedge_alarm_preflight || return 1

backup=$(mktemp -d "$FM_AFK_LAUNCH_STATE/.afk-launch-backup.XXXXXX") || return 1
if [ -f "$FM_AFK_LAUNCH_STATE/.afk" ]; then
had_afk=1
Expand Down Expand Up @@ -540,6 +572,7 @@ fm_afk_launch_start_native() {
fm_afk_launch_log "daemon already running; refreshed away-mode flag"
return 0
fi
fm_afk_launch_wedge_alarm_preflight || return 1
backup=$(mktemp -d "$FM_AFK_LAUNCH_STATE/.afk-launch-backup.XXXXXX") || return 1
if [ -f "$FM_AFK_LAUNCH_STATE/.afk" ]; then
had_afk=1
Expand Down
72 changes: 26 additions & 46 deletions bin/fm-supervise-daemon.sh
Original file line number Diff line number Diff line change
Expand Up @@ -639,6 +639,25 @@ pane_is_busy() { # <target> [backend]
case "$native" in
busy) return 0 ;;
esac
# An affirmatively EMPTY composer is positive, structural proof the turn has
# ended: the harness renders either a live generating view or an idle prompt,
# never both, and inject_msg's own composer guard already requires exactly
# this verdict before it will ever type into the pane. Checking it here too
# stops the rendered-tail scan below from re-litigating a question this
# positive signal has already answered.
# Verified incident (2026-09-01, claude-on-herdr): the rendered scan has no
# positional anchor - it greps the last 12 non-blank lines for Claude's
# busy-shape signature ("…" + a parenthesized elapsed duration), which a
# live spinner satisfies but so can ordinary SETTLED reply text several
# lines back (e.g. "...holding steady… (2h into the soak test)"). With no
# new output to scroll that line out of view - exactly the state of an
# away-mode pane whose escalations keep failing to land - the false match
# persists for the rest of the session. A live busy pane never shows an
# affirmatively empty composer, so this short-circuit never masks a real
# busy turn.
if [ "$(fm_backend_composer_state "$backend" "$target" 2>/dev/null)" = empty ]; then
return 1
fi
tail40=$(fm_backend_capture "$backend" "$target" 40 2>/dev/null) || return 1
printf '%s' "$tail40" | grep -v '^[[:space:]]*$' | tail -12 \
| fm_busy_lines_match "$harness"
Expand Down Expand Up @@ -719,52 +738,13 @@ escalate_flush() { # <state>
# skipped, never crashing the daemon loop - and the durable marker plus the tmux
# flash stay exactly as before.
#
# Config: config/wedge-alarm (local, gitignored), one channel directive per
# non-empty, non-comment line. FM_WEDGE_ALARM_CHANNEL overrides the file with a
# single directive. Directives:
# off disable the active alert entirely, regardless of position
# (marker + flash remain)
# auto | default platform default: macOS -> osascript; otherwise none
# osascript macOS Notification Center banner (backend-independent)
# herdr herdr UI notification (herdr notification show)
# command:<cmd> run <cmd> via `sh -c`, summary on $1 and on stdin
# An absent config means auto, i.e. default-ON on macOS: the alarm's whole
# purpose is to never be silent, so the reachable OS channel fires unless the
# captain explicitly disables it.

# Print the configured channel directives, one per line. FM_WEDGE_ALARM_CHANNEL
# wins (a single directive); else each non-empty, non-comment line of
# config/wedge-alarm; else "auto".
wedge_alarm_configured_channels() {
local cfg line found=
if [ -n "${FM_WEDGE_ALARM_CHANNEL:-}" ]; then
printf '%s\n' "$FM_WEDGE_ALARM_CHANNEL"
return 0
fi
cfg="${FM_CONFIG_OVERRIDE:-$FM_HOME/config}/wedge-alarm"
if [ -f "$cfg" ]; then
while IFS= read -r line || [ -n "$line" ]; do
line="${line#"${line%%[![:space:]]*}"}"
line="${line%"${line##*[![:space:]]}"}"
[ -n "$line" ] || continue
case "$line" in '#'*) continue ;; esac
printf '%s\n' "$line"
found=1
done < "$cfg"
fi
[ -n "$found" ] || printf 'auto\n'
}

# Resolve the platform's default OS-level channel for `auto`. macOS reaches the
# captain via an osascript Notification Center banner; other platforms have no
# built-in OS channel (the captain wires a command: directive), so this prints
# nothing and wedge_alarm_notify logs that the marker is the only signal.
wedge_alarm_platform_default() {
case "$(uname)" in
Darwin) command -v osascript >/dev/null 2>&1 && printf 'osascript' ;;
*) : ;;
esac
}
# Channel config, resolution, and the away-mode-entry reliability check are
# owned by fm-wedge-alarm-lib.sh (wedge_alarm_configured_channels,
# wedge_alarm_platform_default, wedge_alarm_reliable_channel_configured) so the
# runtime alert here and bin/fm-afk-launch.sh's entry-time refusal can never
# drift apart.
# shellcheck source=bin/fm-wedge-alarm-lib.sh
. "$FM_DAEMON_DIR/fm-wedge-alarm-lib.sh"

wedge_alarm_run_bounded() {
local channel=$1 timeout monitor_was_on=0 pid start elapsed rc
Expand Down
111 changes: 111 additions & 0 deletions bin/fm-wedge-alarm-lib.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
#!/usr/bin/env bash
# fm-wedge-alarm-lib.sh - the single owner of wedge-alarm CHANNEL RESOLUTION:
# what config/wedge-alarm and FM_WEDGE_ALARM_CHANNEL configure, and whether any
# of it can plausibly reach the captain. bin/fm-supervise-daemon.sh sources this
# for the runtime alert (wedge_alarm_notify calls the resolvers below), and
# bin/fm-afk-launch.sh sources it for the away-mode ENTRY check
# (wedge_alarm_reliable_channel_configured), so both consult the identical
# resolution instead of two copies drifting apart.
#
# Config: config/wedge-alarm (local, gitignored), one channel directive per
# non-empty, non-comment line. FM_WEDGE_ALARM_CHANNEL overrides the file with a
# single directive. Directives:
# off disable the active alert entirely, regardless of position
# (marker + flash remain) - also the explicit, deliberate
# acknowledgment that away-mode entry accepts below
# auto | default platform default: macOS -> osascript; otherwise none
# osascript macOS Notification Center banner (backend-independent)
# herdr herdr UI notification (herdr notification show)
# command:<cmd> run <cmd> via `sh -c`, summary on $1 and on stdin
# An absent config means auto, i.e. default-ON on macOS: the alarm's whole
# purpose is to never be silent, so the reachable OS channel fires unless the
# captain explicitly disables it.
#
# Away-mode entry check (verified live, 2026-09-01, Linux/WSL2): an absent or
# `auto` config resolves to NOTHING on any non-macOS platform - no built-in OS
# channel exists here, and `herdr notification show` itself reported
# {"reason":"disabled","shown":false} rather than actually posting anything,
# with no org.freedesktop.Notifications D-Bus service registered at all. A
# max-defer wedge under that silent configuration fires the alarm exactly as
# designed and STILL reaches nobody, because the design's promise - a guard
# false-positive becomes a visible stall, never a silent one - depends on SOME
# channel actually posting. wedge_alarm_reliable_channel_configured is the
# entry-time predicate: true for any EXPLICIT directive (an admitted `off`
# counts, because the captain has consciously accepted marker-only), false only
# when every configured line is `auto`/`default` and the platform resolves it
# to nothing. bin/fm-afk-launch.sh refuses entry loudly on false rather than
# letting the captain walk away believing a channel that will never fire.

# Print the configured channel directives, one per line. FM_WEDGE_ALARM_CHANNEL
# wins (a single directive); else each non-empty, non-comment line of
# config/wedge-alarm; else "auto".
wedge_alarm_configured_channels() {
local cfg line found=
if [ -n "${FM_WEDGE_ALARM_CHANNEL:-}" ]; then
printf '%s\n' "$FM_WEDGE_ALARM_CHANNEL"
return 0
fi
cfg="${FM_CONFIG_OVERRIDE:-$FM_HOME/config}/wedge-alarm"
if [ -f "$cfg" ]; then
while IFS= read -r line || [ -n "$line" ]; do
line="${line#"${line%%[![:space:]]*}"}"
line="${line%"${line##*[![:space:]]}"}"
[ -n "$line" ] || continue
case "$line" in '#'*) continue ;; esac
printf '%s\n' "$line"
found=1
done < "$cfg"
fi
[ -n "$found" ] || printf 'auto\n'
}

# Resolve the platform's default OS-level channel for `auto`. macOS reaches the
# captain via an osascript Notification Center banner; other platforms have no
# built-in OS channel (the captain wires a command: directive), so this prints
# nothing and wedge_alarm_notify logs that the marker is the only signal.
wedge_alarm_platform_default() {
case "$(uname)" in
Darwin) command -v osascript >/dev/null 2>&1 && printf 'osascript' ;;
*) : ;;
esac
}

# wedge_alarm_reliable_channel_configured: 0 when at least one configured
# directive is a directive wedge_alarm_notify's dispatch actually recognizes -
# off, osascript, herdr, command:<cmd> with a NON-EMPTY <cmd> - or an
# auto/default that resolves to a real platform channel; 1 when every
# configured line is auto/default and none resolve to anything, OR when a
# configured line is not a recognized directive at all (a typo or malformed
# line would otherwise pass this check as "reliable" while
# wedge_alarm_notify's dispatch silently no-ops on it at runtime) - both are
# the silent-alarm gaps this predicate exists to catch. A bare `command:` with
# no payload, or one with only whitespace after the colon, falls into the same
# unrecognized-directive rejection as a typo: `sh -c ' '` runs and exits 0
# without doing anything, so wedge_alarm_via_command's own `[ -n "$cmd" ]`
# guard - which treats whitespace as non-empty - can never catch it either;
# treating it as reliable would be exactly the reassurance-that-doesn't-hold
# gap this predicate exists to catch. osascript and herdr are not given the
# same binary-presence check: unlike an empty command: payload, which can
# never work on any machine, a missing osascript/herdr binary is an
# environmental fact that can differ by host and change over time - the same
# category of runtime risk as a command:<cmd> whose <cmd> itself is broken,
# which this predicate deliberately leaves to wedge_alarm_notify's
# best-effort, logged dispatch rather than validating at entry time.
wedge_alarm_reliable_channel_configured() {
local ch found_real=1
while IFS= read -r ch; do
[ -n "$ch" ] || continue
case "$ch" in
auto|default)
[ -n "$(wedge_alarm_platform_default)" ] && found_real=0
;;
off|osascript|herdr|command:*[![:space:]]*)
found_real=0
;;
*)
return 1
;;
esac
done < <(wedge_alarm_configured_channels)
return "$found_real"
}
1 change: 1 addition & 0 deletions docs/scripts.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize
| `fm-afk-return.sh` | Own deterministic return shutdown, catch-up evidence, and the firstmate-actionable blocker gate |
| `fm-supervisor-target-lib.sh` | Resolve the shared supervisor target and backend for the daemon and launcher |
| `fm-supervise-daemon.sh` | Presence-gated away-mode sub-supervisor: self-handle routine wakes, guard injection by the detected primary harness, escalate batched digests, alert on failed delivery |
| `fm-wedge-alarm-lib.sh` | Single owner of wedge-alarm channel resolution shared by the daemon's runtime alert and the launcher's away-mode entry check |
| `fm-crew-state.sh` | Print one deterministic current-state line for a crew |
| `fm-nm-run-lib.sh` | Single owner of shared no-mistakes run-attribution primitives and rules |
| `fm-tangle-lib.sh` | Shared default-branch resolution and primary-checkout tangle classification |
Expand Down
Loading