Skip to content
Merged
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
6 changes: 4 additions & 2 deletions .agents/skills/afk/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,9 +93,10 @@ injection, dispatched through `bin/fm-backend.sh` for the supervisor's own
backend (tmux or herdr; see "Auto-discovered supervisor pane" below):

- **Primary-pane busy guard** - `pane_is_busy` keeps the Herdr native-busy fast path except for a Herdr primary detected as Claude, where native `working` is diagnostic only because the tracked away daemon shell can keep it set after the foreground turn ends.
For that pair, the shared position- and shape-aware current-footer predicate is the rendered busy proof used before injection and around submit confirmation; rendered idle falls through to the affirmative `empty` composer guard, and unreadable or structurally ambiguous capture still defers.
For that pair, the shared position- and shape-aware current-context predicate is the rendered busy proof used before injection and around submit confirmation; styled capture capabilities keep dim and dark truecolor ghosts out of the typed-input verdict, rendered idle falls through to the affirmative `empty` composer guard, and unreadable or structurally ambiguous capture still defers.
Submit confirmation for the same pair requires that predicate to transition from idle across the queued Enter or requires the composer to clear; native `working` alone never proves delivery.
Busy or composer deferrals name `native-busy`, `rendered-busy`, or `composer=<verdict>` in the daemon log, while an unreadable busy-guard capture is named `unreadable`; Herdr's semantic `busy` diagnostic is recorded as native `working`.
Busy or composer deferrals name `native-busy`, `rendered-busy`, or `composer=<verdict>` in the daemon log, while an unreadable busy-guard capture is named `unreadable`; every `rendered-busy` deferral includes the exact matched row, and Herdr's semantic `busy` diagnostic is recorded as native `working`.
The post-alarm Herdr+Claude recovery exception is the byte-stable elapsed-row gate described in `docs/herdr-backend.md`; it never overrides a pending or unknown composer verdict.
The full contract is in `docs/herdr-backend.md` under “Away-mode supervisor support”.
- **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.
Expand All @@ -115,6 +116,7 @@ an ERROR in the daemon log, a durable
`state/.subsuper-inject-wedged` marker (surface it on the "while you were out"
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.
After that alarm, only the documented Herdr+Claude byte-stable rendered-row recovery may recheck the composer, and it still requires exact `empty`.
So a guard false-positive becomes a visible stall, never an unbounded silent no-op.

## Submit model
Expand Down
13 changes: 10 additions & 3 deletions bin/backends/herdr.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2673,12 +2673,19 @@ fm_backend_herdr_composer_state() { # <target> -> empty|pending|pending-unprove
# shared matcher uses its union of verified tokens, which is what the submit
# core wants: it has no recorded harness for the pane.
fm_backend_herdr_rendered_busy_state() { # <target> [harness] -> busy|idle|unknown
local target=$1 harness=${2:-} cap visible
cap=$(fm_backend_herdr_capture "$target" 40) || { printf 'unknown'; return 0; }
local target=$1 harness=${2:-} cap visible caps
if cap=$(fm_backend_herdr_capture_ansi "$target" 40 2>/dev/null) && [ -n "$cap" ]; then
caps=$'styled=1\ncursor=0\nidentity=0\nrows=12'
elif cap=$(fm_backend_herdr_capture "$target" 40); then
caps=$'styled=0\ncursor=0\nidentity=0\nrows=12'
else
printf 'unknown'
return 0
fi
visible=$(printf '%s' "$cap" | grep -v '^[[:space:]]*$' | tail -12)
[ -n "$visible" ] || { printf 'unknown'; return 0; }
if [ "$harness" = claude ]; then
if printf '%s' "$visible" | fm_claude_current_footer_busy; then
if printf '%s' "$visible" | fm_claude_current_footer_busy "$caps"; then
printf 'busy'
else
case "$?" in
Expand Down
47 changes: 42 additions & 5 deletions bin/fm-composer-lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,8 @@ fm_composer_strip_ghost() {
FM_DELIVERY_BUSY_REGEX_DEFAULT='esc (to )?interrupt|Working\.\.\.|Ctrl\+c:cancel|ctrl\+c to stop'
FM_DELIVERY_CLAUDE_BUSY_REGEX_DEFAULT='esc to interrupt|…[[:space:]]+\([0-9]+[smh]'
FM_DELIVERY_CLAUDE_CURRENT_FOOTER_REGEX='^[[:space:]]*(esc to interrupt|thinking\.\.\.[[:space:]]+esc to interrupt|[^[:space:]]+[[:space:]]+[^[:space:]]+…[[:space:]]+\([0-9]+[smh]([[:space:]]+[·•][^)]*)?\))[[:space:]]*$'
FM_DELIVERY_CLAUDE_ACTIVE_COMPOSER_REGEX='Press up to edit queued messages'
FM_DELIVERY_CLAUDE_ACTIVE_TOOL_REGEX='Running…[[:space:]]+\([0-9]+[smh].*timeout'
FM_DELIVERY_CODEX_BUSY_REGEX_DEFAULT='esc to interrupt'
FM_DELIVERY_OPENCODE_BUSY_REGEX_DEFAULT='esc interrupt'
FM_DELIVERY_PI_BUSY_REGEX_DEFAULT='Working\.\.\.'
Expand All @@ -329,7 +331,8 @@ FM_DELIVERY_CURSOR_BUSY_REGEX_DEFAULT='ctrl\+c to stop'
FM_DELIVERY_KIMI_BUSY_REGEX_DEFAULT='^[[:space:]]*(🌑|🌒|🌓|🌔|🌕|🌖|🌗|🌘)[[:space:]]+·[[:space:]]+'

fm_busy_lines_match() { # [harness]
local harness=${1:-} lines regex
local harness=${1:-} lines regex matched
FM_BUSY_MATCHED_ROW=
IFS= read -r -d '' lines || true
if [ -n "${FM_BUSY_REGEX:-}" ]; then
regex=$FM_BUSY_REGEX
Expand All @@ -350,13 +353,20 @@ fm_busy_lines_match() { # [harness]
;;
esac
fi
[ -n "$regex" ] && printf '%s' "$lines" | grep -qiE "$regex"
[ -n "$regex" ] || return 1
matched=$(printf '%s' "$lines" | grep -iE "$regex" | tail -1)
[ -n "$matched" ] || return 1
FM_BUSY_MATCHED_ROW=$matched
return 0
}

# fm_claude_current_footer_busy returns 0 for busy, 1 for idle, and 2 for
# unreadable or structurally ambiguous state.
fm_claude_current_footer_busy() {
local lines plain footer footer_row composer caps verdict
local capture_caps=${1:-} lines plain footer footer_row composer caps verdict active_rows preceding screen_verdict
local active_hint=0 active_tool=0
FM_CLAUDE_BUSY_MATCHED_ROW=
[ -n "$capture_caps" ] || capture_caps=$'styled=0\ncursor=0\nidentity=0\nrows=12'
IFS= read -r -d '' lines || true
[ -n "$lines" ] || return 2
plain=$(printf '%s' "$lines" | fm_composer_strip_ansi) || return 2
Expand All @@ -369,17 +379,44 @@ fm_claude_current_footer_busy() {
NF { last=NR }
END { for (row=1; row < last; row++) print rows[row] }
')
_fm_composer_scan_screen "$composer" ''
_fm_composer_select_cursorless "$composer" || return 2
screen_verdict=$(fm_composer_classify_screen "$capture_caps" "$lines")
active_rows=$(printf '%s\n' "$composer" | awk \
-v first="$FM_COMPOSER_SELECTED_FIRST" -v last="$FM_COMPOSER_SELECTED_LAST" \
'NR - 1 >= first && NR - 1 <= last { print }')
preceding=$(printf '%s\n' "$composer" | awk \
-v first="$FM_COMPOSER_SELECTED_FIRST" 'NR <= first { print }' \
| grep -v '^[[:space:]]*$' | tail -8)
printf '%s\n' "$active_rows" | grep -qE "$FM_DELIVERY_CLAUDE_ACTIVE_COMPOSER_REGEX" && active_hint=1
printf '%s\n' "$preceding" | grep -qE "$FM_DELIVERY_CLAUDE_ACTIVE_TOOL_REGEX" && active_tool=1
if { [ "$screen_verdict" = empty ] \
|| { [ "$screen_verdict" = pending ] && [ "$active_hint" = 1 ]; }; } \
&& { [ "$active_hint" = 1 ] \
|| { [ "$active_tool" = 1 ] \
&& printf '%s\n' "$preceding" | grep -Fq '(ctrl+b to run in background)'; }; }; then
if fm_busy_lines_match claude <<< "$preceding"; then
# shellcheck disable=SC2034 # Output read by sourcing callers after this function returns.
FM_CLAUDE_BUSY_MATCHED_ROW=${FM_BUSY_MATCHED_ROW:-unknown}
return 0
fi
return 2
fi
case "$screen_verdict" in
pending|pending-unproven) return 1 ;;
esac
caps=$(printf '%s\n' 'styled=0' 'cursor=0' 'identity=0' 'rows=12')
verdict=$(fm_composer_classify_screen "$caps" "$composer")
[ "$verdict" = empty ] || return 2
_fm_composer_scan_screen "$composer" ''
_fm_composer_select_cursorless "$composer" || return 2
[ "$footer_row" -eq $((FM_COMPOSER_SELECTED_BOUNDARY + 1)) ] || return 2
if [ -n "${FM_BUSY_REGEX:-}" ]; then
if printf '%s\n' "$footer" | fm_busy_lines_match claude; then
FM_CLAUDE_BUSY_MATCHED_ROW=$footer
return 0
fi
elif printf '%s\n' "$footer" | grep -qE "$FM_DELIVERY_CLAUDE_CURRENT_FOOTER_REGEX"; then
# shellcheck disable=SC2034 # Output read by sourcing callers after this function returns.
FM_CLAUDE_BUSY_MATCHED_ROW=$footer
return 0
fi
return 1
Expand Down
117 changes: 106 additions & 11 deletions bin/fm-supervise-daemon.sh
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,11 @@
# absent file/var means auto: on macOS that is
# an OS-level notification, so the alarm is
# never silent. See wedge_alarm_notify below
# FM_RENDERED_BUSY_RECOVERY_POLLS
# identical Claude rendered-busy polls needed
# after a max-defer alarm before the daemon may
# re-check an elapsed footer against an empty
# composer (default 3)
# and docs/configuration.md.
# FM_WEDGE_ALARM_EXEC notifier seam: when set, every notifier
# channel routes through this command as
Expand Down Expand Up @@ -153,7 +158,7 @@ FM_DAEMON_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$FM_DAEMON_DIR/.." && pwd)}"
FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}"

# Shared tmux pane primitives for supervisor injection (busy/composer detection
# Shared pane primitives for supervisor injection (busy/composer detection
# + verify-retry submit). Sourced at top level so BOTH the executed daemon and
# the unit tests (which source this file for its pure functions) get the
# corrected composer detection. Stale task rechecks use fm-backend.sh below.
Expand Down Expand Up @@ -204,9 +209,13 @@ HOUSEKEEPING_TICK_DEFAULT=15
# the normal flush path and, if that cannot confirm a submit, raises a loud wedge
# alarm. The escape hatch makes a guard false-positive visible instead of silent.
MAX_DEFER_SECS_DEFAULT=300
RENDERED_BUSY_RECOVERY_POLLS_DEFAULT=3
WEDGE_ALARM_TIMEOUT_SECS_DEFAULT=10
WEDGE_ALARM_LAST_EPOCH=0
WEDGE_ALARM_NOTIFIER_PID=
FM_RENDERED_BUSY_LAST_ROW=
FM_RENDERED_BUSY_STREAK=0
FM_RENDERED_BUSY_RECOVERY_SUBCAUSE=
# The captain-relevant verb set and the status classifiers (last_status_line,
# status_is_captain_relevant, window_to_task, scan_captain_relevant_statuses) now
# live in bin/fm-classify-lib.sh, shared with the always-on watcher.
Expand Down Expand Up @@ -596,9 +605,10 @@ fm_daemon_primary_harness() {
}

pane_is_busy() { # <target> [backend]
local target=$1 backend=${2:-tmux} native tail40 visible harness claude_footer_rc
local target=$1 backend=${2:-tmux} native tail40 visible harness claude_footer_rc claude_capture_caps
FM_PANE_BUSY_REASON=
FM_PANE_NATIVE_BUSY_STATE=
FM_PANE_BUSY_MATCHED_ROW=
fm_daemon_primary_harness >/dev/null
harness=${FM_DAEMON_PRIMARY_HARNESS:-unknown}
native=$(fm_backend_busy_state "$backend" "$target" 2>/dev/null)
Expand All @@ -612,17 +622,25 @@ pane_is_busy() { # <target> [backend]
# Herdr's native working state includes Claude's tracked away daemon shell.
# For this pair, native state is diagnostic only and the rendered Claude
# active-turn signature is the positive foreground-busy proof.
tail40=$(fm_backend_capture "$backend" "$target" 40 2>/dev/null) || {
FM_PANE_BUSY_REASON='unreadable'
return 1
}
if declare -F fm_backend_herdr_capture_ansi >/dev/null 2>&1 \
&& tail40=$(fm_backend_herdr_capture_ansi "$target" 40 2>/dev/null) \
&& [ -n "$tail40" ]; then
claude_capture_caps=$'styled=1\ncursor=0\nidentity=0\nrows=12'
else
claude_capture_caps=$'styled=0\ncursor=0\nidentity=0\nrows=12'
tail40=$(fm_backend_capture "$backend" "$target" 40 2>/dev/null) || {
FM_PANE_BUSY_REASON='unreadable'
return 1
}
fi
visible=$(printf '%s' "$tail40" | grep -v '^[[:space:]]*$' | tail -12)
[ -n "$visible" ] || {
FM_PANE_BUSY_REASON='unreadable'
return 1
}
if printf '%s' "$visible" | fm_claude_current_footer_busy; then
if fm_claude_current_footer_busy "$claude_capture_caps" <<< "$visible"; then
FM_PANE_BUSY_REASON='rendered-busy'
FM_PANE_BUSY_MATCHED_ROW=${FM_CLAUDE_BUSY_MATCHED_ROW:-unknown}
return 0
else
claude_footer_rc=$?
Expand All @@ -642,11 +660,63 @@ pane_is_busy() { # <target> [backend]
if printf '%s' "$tail40" | grep -v '^[[:space:]]*$' | tail -12 \
| fm_busy_lines_match "$harness"; then
FM_PANE_BUSY_REASON='rendered-busy'
visible=$(printf '%s' "$tail40" | grep -v '^[[:space:]]*$' | tail -12)
if fm_busy_lines_match "$harness" <<< "$visible"; then
FM_PANE_BUSY_MATCHED_ROW=${FM_BUSY_MATCHED_ROW:-unknown}
fi
return 0
fi
return 1
}

rendered_busy_recovery_ready() { # <state> <backend> <harness> <native-state> <matched-row>
local state=$1 backend=$2 harness=$3 native_state=$4 matched_row=$5 polls terminal_record composer
FM_RENDERED_BUSY_RECOVERY_COMPOSER=
FM_RENDERED_BUSY_RECOVERY_SUBCAUSE=
[ "$backend" = herdr ] && [ "$harness" = claude ] || return 1
[ -s "$state/.subsuper-inject-wedged" ] || {
FM_RENDERED_BUSY_LAST_ROW=
FM_RENDERED_BUSY_STREAK=0
return 1
}
case "$native_state" in
working)
terminal_record=$(cat "$state/.afk-daemon-terminal" 2>/dev/null || true)
[ "$terminal_record" = $'none\t-\tnative' ] || {
FM_RENDERED_BUSY_LAST_ROW=
FM_RENDERED_BUSY_STREAK=0
return 1
}
;;
idle|done)
;;
*)
FM_RENDERED_BUSY_RECOVERY_SUBCAUSE='native-unknown'
FM_RENDERED_BUSY_LAST_ROW=
FM_RENDERED_BUSY_STREAK=0
return 1
;;
esac
printf '%s\n' "$matched_row" | grep -qE '\([0-9]+[smh]([[:space:]·•]|\))' || {
FM_RENDERED_BUSY_LAST_ROW=
FM_RENDERED_BUSY_STREAK=0
return 1
}
polls=${FM_RENDERED_BUSY_RECOVERY_POLLS:-$RENDERED_BUSY_RECOVERY_POLLS_DEFAULT}
case "$polls" in ''|*[!0-9]*|0) polls=$RENDERED_BUSY_RECOVERY_POLLS_DEFAULT ;; esac
if [ "$matched_row" = "$FM_RENDERED_BUSY_LAST_ROW" ]; then
FM_RENDERED_BUSY_STREAK=$((FM_RENDERED_BUSY_STREAK + 1))
else
FM_RENDERED_BUSY_LAST_ROW=$matched_row
FM_RENDERED_BUSY_STREAK=1
fi
[ "$FM_RENDERED_BUSY_STREAK" -ge "$polls" ] || return 1
composer=$(fm_backend_composer_state "$backend" "${FM_SUPERVISOR_TARGET:-$FM_SUPERVISOR_TARGET_DEFAULT}" 2>/dev/null)
FM_RENDERED_BUSY_RECOVERY_COMPOSER=${composer:-unknown}
[ "$composer" = empty ] || return 1
return 0
}

# pane_input_pending dispatches through fm_backend_composer_state and treats
# every verdict except exact empty as unsafe. inject_msg reads the full verdict
# directly and applies the same positive-proof boundary.
Expand Down Expand Up @@ -1348,7 +1418,7 @@ window_for_task() { # <task-key> [state]
# line, or a previous injection's unsent text), defer entirely - injecting
# would merge with the human's text.
inject_msg() { # <message> [state]
local msg=$1 state target backend harness retries sleep_s verdict composer encoded native_state busy_rc
local msg=$1 state target backend harness retries sleep_s verdict composer encoded native_state busy_rc matched_row recovery_polls
state="${2:-$(_state_root)}"
# (1) Presence-gate: inject ONLY when afk is active. When afk is off, the
# daemon self-handles and stays quiet; firstmate drives the normal always-on
Expand Down Expand Up @@ -1378,15 +1448,38 @@ inject_msg() { # <message> [state]
native_state=${FM_PANE_NATIVE_BUSY_STATE:-unknown}
if [ "$busy_rc" -eq 0 ] || [ "${FM_PANE_BUSY_REASON:-}" = unreadable ]; then
case "${FM_PANE_BUSY_REASON:-native-busy}" in
native-busy|rendered-busy)
rendered-busy)
matched_row=${FM_PANE_BUSY_MATCHED_ROW:-unknown}
harness=$(fm_daemon_primary_harness)
if rendered_busy_recovery_ready "$state" "$backend" "$harness" "$native_state" "$matched_row"; then
recovery_polls=${FM_RENDERED_BUSY_RECOVERY_POLLS:-$RENDERED_BUSY_RECOVERY_POLLS_DEFAULT}
composer=empty
log "inject recovery: alarm-fired stable rendered-busy row for ${recovery_polls} polls; native-state=$native_state; composer=empty; matched-row=$matched_row"
FM_RENDERED_BUSY_LAST_ROW=
FM_RENDERED_BUSY_STREAK=0
else
if [ -n "${FM_RENDERED_BUSY_RECOVERY_SUBCAUSE:-}" ]; then
log "inject deferred: supervisor pane busy (native state not proven safe; subcause=${FM_RENDERED_BUSY_RECOVERY_SUBCAUSE}; recovery-from=rendered-busy; native-state=$native_state; matched-row=$matched_row)"
elif [ -n "${FM_RENDERED_BUSY_RECOVERY_COMPOSER:-}" ]; then
log "inject deferred: supervisor composer not confirmed-empty (state=${FM_RENDERED_BUSY_RECOVERY_COMPOSER}: pending input, dead-shell prompt, or unreadable pane; subcause=composer=${FM_RENDERED_BUSY_RECOVERY_COMPOSER}; recovery-from=rendered-busy; native-state=$native_state; matched-row=$matched_row)"
else
log "inject deferred: supervisor pane busy (agent mid-turn; subcause=rendered-busy; native-state=$native_state; matched-row=$matched_row)"
fi
return 1
fi
;;
native-busy)
log "inject deferred: supervisor pane busy (agent mid-turn; subcause=${FM_PANE_BUSY_REASON:-native-busy}; native-state=$native_state)"
return 1
;;
*)
log "inject deferred: supervisor pane unreadable (subcause=${FM_PANE_BUSY_REASON:-unknown}; native-state=$native_state)"
return 1
;;
esac
return 1
fi
FM_RENDERED_BUSY_LAST_ROW=
FM_RENDERED_BUSY_STREAK=0
# b) Composer-guard: inject ONLY into a confirmed-empty GENUINE agent
# composer. The shared classifier (fm_backend_composer_state ->
# fm_composer_classify_content, bin/fm-composer-lib.sh) reports 'pending'
Expand All @@ -1396,7 +1489,9 @@ inject_msg() { # <message> [state]
# target - typing the escalation into a shell could execute it - so defer
# on anything that is not affirmatively 'empty'. A deferred escalation
# stays buffered for the next cycle or the catch-up flush.
composer=$(fm_backend_composer_state "$backend" "$target" 2>/dev/null)
if [ "${composer:-}" != empty ]; then
composer=$(fm_backend_composer_state "$backend" "$target" 2>/dev/null)
fi
if [ "$composer" != empty ]; then
log "inject deferred: supervisor composer not confirmed-empty (state=${composer:-unknown}: pending input, dead-shell prompt, or unreadable pane; subcause=composer=${composer:-unknown}; native-state=$native_state)"
return 1
Expand Down
Loading
Loading