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
42 changes: 35 additions & 7 deletions bin/fm-composer-lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -315,11 +315,13 @@ fm_composer_strip_ghost() {
# outside its composer and the composer verdict is therefore always `unknown`.
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_CURRENT_FOOTER_REGEX='^[[:space:]]*(esc to interrupt|thinking\.\.\.[[:space:]]+esc to interrupt|([^[:space:]]+[[:space:]]+)?[^[:space:]]+…([[:space:]]+\([0-9]+[smh]([[: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_CLAUDE_STATUS_PRIMARY_PREFIX_REGEX='^[[:space:]]*⏵⏵[[:space:]]+bypass[[:space:]]+permissions[[:space:]]+on'
FM_DELIVERY_CLAUDE_STATUS_PRIMARY_REGEX='^[[:space:]]*⏵⏵[[:space:]]+bypass[[:space:]]+permissions[[:space:]]+on([[:space:]]+[(]shift[+]tab[[:space:]]+to[[:space:]]+cycle[)]|[[:space:]]+·[[:space:]]+(←[[:space:]]+[[:digit:]]+[[:space:]]+agent([[:space:]]+·[[:space:]]+↓[[:space:]]+to[[:space:]]+manage)?|[[:digit:]]+[[:space:]]+shell([[:space:]]+·[[:space:]]+esc[[:space:]]+to[[:space:]]+interrupt)?[[:space:]]+·[[:space:]]+←[[:space:]]+[[:digit:]]+[[:space:]]+(agent|a…)([[:space:]]+·[[:space:]]+↓[[:space:]]+to[[:space:]]+manage)?))[[:space:]]*$'
FM_DELIVERY_CLAUDE_PERMISSION_MODE_REGEX='(bypass[[:space:]]+permissions|auto[[:space:]]+mode|accept[[:space:]]+edits|plan[[:space:]]+mode)'
FM_DELIVERY_CLAUDE_STATUS_PRIMARY_PREFIX_REGEX="^[[:space:]]*⏵⏵[[:space:]]+${FM_DELIVERY_CLAUDE_PERMISSION_MODE_REGEX}[[:space:]]+on"
FM_DELIVERY_CLAUDE_STATUS_PRIMARY_REGEX="${FM_DELIVERY_CLAUDE_STATUS_PRIMARY_PREFIX_REGEX}([[:space:]]+[(]shift[+]tab[[:space:]]+to[[:space:]]+cycle[)])?([[:space:]]+·[[:space:]]+(←[[:space:]]+[[:digit:]]+[[:space:]]+(agents?|a…)|[[:digit:]]+[[:space:]]+shells?|↓[[:space:]]+to[[:space:]]+manage|esc[[:space:]]+to[[:space:]]+interrupt))*([[:space:]]+(/rc|●[[:space:]]+(low|medium|high)[[:space:]]+·[[:space:]]+/effort))?[[:space:]]*$"
FM_DELIVERY_CLAUDE_STATUS_IDLE_REGEX='^[[:space:]]*[?][[:space:]]+for[[:space:]]+shortcuts[[:space:]]*$'
FM_DELIVERY_CLAUDE_STATUS_CONTINUATION_REGEX='^[[:space:]]*(/rc|●[[:space:]]+(low|medium|high)[[:space:]]+·[[:space:]]+/effort)[[:space:]]*$'
FM_DELIVERY_CODEX_BUSY_REGEX_DEFAULT='esc to interrupt'
FM_DELIVERY_OPENCODE_BUSY_REGEX_DEFAULT='esc interrupt'
Expand Down Expand Up @@ -378,6 +380,7 @@ fm_claude_current_footer_busy() {
footer_shape=$(printf '%s\n' "$plain" | awk \
-v primary="$FM_DELIVERY_CLAUDE_STATUS_PRIMARY_REGEX" \
-v prefix="$FM_DELIVERY_CLAUDE_STATUS_PRIMARY_PREFIX_REGEX" \
-v idle="$FM_DELIVERY_CLAUDE_STATUS_IDLE_REGEX" \
-v continuation="$FM_DELIVERY_CLAUDE_STATUS_CONTINUATION_REGEX" '
{ rows[NR]=$0 }
function previous_nonblank(from, row) {
Expand All @@ -392,7 +395,7 @@ fm_claude_current_footer_busy() {
count++
row=previous_nonblank(row - 1)
}
if (row > 0 && rows[row] ~ primary) print "valid:" row
if (row > 0 && (rows[row] ~ primary || rows[row] ~ idle)) print "valid:" row
else if (count > 0 || (row > 0 && rows[row] ~ prefix)) print "invalid"
}
')
Expand Down Expand Up @@ -711,9 +714,22 @@ fm_composer_classify_content() { # <bordered> <content> [idle_re] [idle_case] [
# exact positive proof they require (`empty`), so unrecognized future verdicts
# fail safe by default.

# _fm_composer_pi_separator_row: a solid pi separator - nothing but `─`, at
# _fm_composer_titled_rule_row: Claude's titled-rule composer boundary starts
# with a long `─` run and includes non-rule title text, so it is distinct from
# Pi's solid separator and still proves the adjacent bare composer shape.
# _fm_composer_pi_separator_row: a solid Pi separator - nothing but `─`, at
# least 8 columns wide. The width floor is a literal substring test so it is
# byte-exact in every locale.
_fm_composer_titled_rule_row() { # <trimmed-row>
local row=$1
fm_composer_normalize_trim_var row
case "$row" in
'────────'*'─') ;;
*) return 1 ;;
esac
[ -n "${row//─/}" ]
}

_fm_composer_pi_separator_row() { # <trimmed-row>
local row=$1
[ -n "$row" ] || return 1
Expand Down Expand Up @@ -1274,8 +1290,20 @@ _fm_composer_select_cursorless() {
fi
if [ "$FM_COMPOSER_SCAN_PI_PAIR_FOUND" = 0 ] \
&& [ "$FM_COMPOSER_SCAN_PI_LAST_SEPARATOR" -gt "$generic" ]; then
FM_COMPOSER_SELECTED_KIND=
return 1
if [ "$FM_COMPOSER_SELECTED_KIND" = bare ] \
&& [ "$FM_COMPOSER_SCAN_PI_LAST_SEPARATOR" -eq $((FM_COMPOSER_SCAN_BARE_ROW + 1)) ] \
&& [ "$FM_COMPOSER_SCAN_BARE_ROW" -gt 0 ]; then
raw=$(_fm_composer_screen_row "$((FM_COMPOSER_SCAN_BARE_ROW - 1))" "$plain")
trimmed=$raw
fm_composer_normalize_trim_var trimmed
if ! _fm_composer_titled_rule_row "$trimmed"; then
FM_COMPOSER_SELECTED_KIND=
return 1
fi
else
FM_COMPOSER_SELECTED_KIND=
return 1
fi
fi
if [ "$FM_COMPOSER_SCAN_SHELL_ROW" -gt "$generic" ]; then
FM_COMPOSER_SELECTED_KIND=
Expand Down
65 changes: 58 additions & 7 deletions bin/fm-turnend-guard.sh
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,12 @@
# primary checkout - the main home or a genuinely marked secondmate home - and
# stay a silent, fast no-op inside child task worktrees.
#
# Away mode (state/.afk): the away-mode daemon owns supervision and runs the
# watcher one-shot, restarting it after every wake, so the watch lock is
# regularly unheld at a turn boundary with nothing wrong. A live
# Away mode (state/.afk): this synchronous turn-boundary guard checks the
# away-daemon owner before it trusts the ownership transfer. If the daemon's
# identity-backed lock is dead or mismatched, it asks bin/fm-afk-launch.sh - the
# terminal-lifecycle owner - to relaunch through its tracked native path. The
# away daemon runs the watcher one-shot, restarting it after every wake, so the
# watch lock is regularly unheld at a turn boundary with nothing wrong. A live
# identity-matched daemon holding this home, plus the unchanged fresh-beacon
# test, is what proves supervision there - see fm_afk_daemon_owns_supervision in
# bin/fm-wake-lib.sh. The strict watcher predicate is unchanged everywhere else.
Expand Down Expand Up @@ -181,9 +184,38 @@ allow_supervised_stop() {
exit 2
}

if fm_watcher_healthy "$STATE" "$WATCH" "$GRACE" "$FM_HOME"; then
allow_supervised_stop
fi
fm_afk_relaunch_heartbeat_mtime() {
local beat=$1
if [ "$(uname)" = Darwin ]; then
stat -f '%.9Fm' "$beat" 2>/dev/null
else
stat -c '%y' "$beat" 2>/dev/null | awk '{print $1 "T" $2}'
fi
}

fm_afk_relaunch_heartbeat_ready() {
local previous=$1 current
fm_afk_daemon_owns_supervision "$STATE" || return 1
current=$(fm_afk_relaunch_heartbeat_mtime "$STATE/.last-watcher-beat" || true)
[ -n "$current" ] || return 1
case "$previous" in
absent) ;;
''|unreadable) return 1 ;;
*) [ "$current" \> "$previous" ] || return 1 ;;
esac
fm_supervision_status "$STATE" "$GRACE"
[ "$FM_SUP_WATCHER_FRESH" = true ]
}

fm_afk_wait_for_relaunch_heartbeat() {
local previous=$1 attempt=0
while [ "$attempt" -lt 30 ]; do
attempt=$((attempt + 1))
fm_afk_relaunch_heartbeat_ready "$previous" && return 0
sleep 0.1
done
return 1
}

# Away mode transfers supervision ownership from the watcher to the away-mode
# daemon, which runs the watcher one-shot and starts its replacement after every
Expand All @@ -194,7 +226,26 @@ fi
# The beacon half of the predicate is deliberately unchanged: a daemon that
# stops restarting its watcher still blocks once the beacon passes grace, and
# a home with no daemon and no watcher blocks exactly as before.
if [ "$FM_SUP_WATCHER_FRESH" = true ] && fm_afk_daemon_owns_supervision "$STATE"; then
if [ -e "$STATE/.afk" ]; then
AFK_RELAUNCH_READY=0
if fm_afk_daemon_owns_supervision "$STATE"; then
AFK_RELAUNCH_READY=1
else
AFK_RELAUNCH_BEAT_PREVIOUS=absent
if [ -e "$STATE/.last-watcher-beat" ]; then
AFK_RELAUNCH_BEAT_PREVIOUS=$(fm_afk_relaunch_heartbeat_mtime "$STATE/.last-watcher-beat" || true)
[ -n "$AFK_RELAUNCH_BEAT_PREVIOUS" ] || AFK_RELAUNCH_BEAT_PREVIOUS=unreadable
fi
if "$SCRIPT_DIR/fm-afk-launch.sh" start >/dev/null 2>&1; then
fm_afk_wait_for_relaunch_heartbeat "$AFK_RELAUNCH_BEAT_PREVIOUS" && AFK_RELAUNCH_READY=1
fi
fi
if [ "$AFK_RELAUNCH_READY" -eq 1 ] \
&& [ "$FM_SUP_WATCHER_FRESH" = true ] \
&& fm_afk_daemon_owns_supervision "$STATE"; then
allow_supervised_stop
fi
elif fm_watcher_healthy "$STATE" "$WATCH" "$GRACE" "$FM_HOME"; then
allow_supervised_stop
fi

Expand Down
8 changes: 8 additions & 0 deletions docs/documentation-audiences.json
Original file line number Diff line number Diff line change
Expand Up @@ -516,6 +516,14 @@
"path": "tests/fixtures/claude-herdr-2.1.258/idle-background-narrow.ansi.txt",
"audience": "maintainer-verification"
},
{
"path": "tests/fixtures/claude-2.1.263-herdr-composer/auto-mode-busy.txt",
"audience": "maintainer-verification"
},
{
"path": "tests/fixtures/claude-2.1.263-herdr-composer/auto-mode-idle.txt",
"audience": "maintainer-verification"
},
{
"path": "tests/fixtures/pi-0.85.0-calm-composer/herdr-calm-off-idle.ansi.txt",
"audience": "maintainer-verification"
Expand Down
30 changes: 28 additions & 2 deletions docs/verification/runtime-backends.md
Original file line number Diff line number Diff line change
Expand Up @@ -775,8 +775,34 @@ Observed guarantees: pending composer input refused injection and raised one ale
The dedicated Herdr daemon workspace topology is covered by `tests/fm-afk-launch.test.sh` and preserves the captain tab's pane count.
The daemon's delivered-once transcript witness is pinned portably by `tests/fm-daemon.test.sh`; refresh the real Herdr plus Pi path against an existing disposable pane, without lifecycle operations, with `FM_AFK_DELIVERY_WITNESS_LIVE=1 FM_AFK_DELIVERY_WITNESS_LIVE_TARGET='<named-session>:<pane-id>' FM_AFK_DELIVERY_WITNESS_LIVE_HOME='<pi-working-directory>' tests/fm-afk-delivery-witness-live-e2e.test.sh`.
The current 2026-09-03 Herdr 0.8.2 plus Claude Code 2.1.259 wrapped-footer away-mode result is recorded in [supervision verification](supervision.md#herdrclaude-wrapped-away-mode-footer-2026-09-03).
The live guard is refreshed from the repository root with `HERDR_LAB_HELPER="$(git rev-parse --show-toplevel)/bin/fm-herdr-lab.sh" FM_AFK_HERDR_CLAUDE_LIVE=1 tests/fm-afk-herdr-claude-busy-guard-live-e2e.test.sh`.
It proves the wrapped idle-footer parse, exactly-once delivery, repeated real foreground-turn deferral with exact spinner rows, and pending human-text preservation in one named non-default lab session.
The newer permission-footer family was verified on 2026-09-08 with Herdr 0.8.2 and Claude Code 2.1.263 in both auto and bypass-permissions modes.
The live guard now waits for three one-second byte-identical full-screen and footer/composer captures after the `/afk` foreground turn settles, because the prior one-sample readiness check could mistake a transient redraw for idle.
Its first-delivery bound is the three-second stable-idle window plus one one-second housekeeping tick plus two seconds of slack, measured on a monotonic clock, for a total of 6,000 ms.

```sh
HERDR_LAB_HELPER=/Users/ivan/Projects/firstmate/bin/fm-herdr-lab.sh \
FM_AFK_HERDR_CLAUDE_LIVE=1 \
FM_AFK_HERDR_CLAUDE_PERMISSION_MODE=auto \
bin/fm-test-run.sh tests/fm-afk-herdr-claude-busy-guard-live-e2e.test.sh
HERDR_LAB_HELPER=/Users/ivan/Projects/firstmate/bin/fm-herdr-lab.sh \
FM_AFK_HERDR_CLAUDE_LIVE=1 \
FM_AFK_HERDR_CLAUDE_PERMISSION_MODE=bypassPermissions \
bin/fm-test-run.sh tests/fm-afk-herdr-claude-busy-guard-live-e2e.test.sh
```

Observed output:

```text
ok - real Herdr 0.8.2 + Claude 2.1.263 (Claude Code) (auto mode): native idle with rendered-idle empty composer submits once
ok - real Herdr 0.8.2 + Claude 2.1.263 (Claude Code) (auto mode): rendered-busy and pending-composer deferrals preserve human text
evidence: permission-mode=auto native=idle rendered=idle composer=empty stable-footer-composer=3 delivery-ms=4059 delivery-bound-ms=6000 delivered_once=1 rendered-busy=1 native-state=working=1 composer=pending=1
ok - real Herdr 0.8.2 + Claude 2.1.263 (Claude Code) (bypass permissions): native idle with rendered-idle empty composer submits once
ok - real Herdr 0.8.2 + Claude 2.1.263 (Claude Code) (bypass permissions): rendered-busy and pending-composer deferrals preserve human text
evidence: permission-mode=bypassPermissions native=idle rendered=idle composer=empty stable-footer-composer=3 delivery-ms=3784 delivery-bound-ms=6000 delivered_once=1 rendered-busy=1 native-state=working=1 composer=pending=1
```

Both named non-default lab sessions tore down with the default-session tripwire intact.
The test-contract correction removed the stale foreground-settlement and two-second delivery assumptions; no production guard was weakened.

## Zellij

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
✳ Schlepping… (1m 19s · ↓ 3.5k tokens)
──────────────── Omawild homepage and closing tasks ─
────────────────────────
⏵⏵ auto mode on (shift+tab to cycle) · ← 1 agent · esc to interrupt /rc
10 changes: 10 additions & 0 deletions tests/fixtures/claude-2.1.263-herdr-composer/auto-mode-idle.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
Ran 2 shell commands

⏺ Captain, that was the away-mode worker's first progress note: branch created, now reading the composer and daemon lifecycle owners. It's working normally. Still waiting on your word for the two crash follow-ups.

✻ Baked for 23s · done 7:22 AM

──────────────── Omawild homepage and closing tasks ─
────────────────────────
⏵⏵ auto mode on (shift+tab to cycle) · ← 1 agent /rc
Loading
Loading