Skip to content

fix(bin): deliver away-mode escalations into an idle Claude composer - #16

Open
BohnBawerick wants to merge 14 commits into
mainfrom
fm/fm-afk-native-inject-wedge
Open

fix(bin): deliver away-mode escalations into an idle Claude composer#16
BohnBawerick wants to merge 14 commits into
mainfrom
fm/fm-afk-native-inject-wedge

Conversation

@BohnBawerick

@BohnBawerick BohnBawerick commented Aug 22, 2026

Copy link
Copy Markdown
Owner

Intent

Fix away-mode escalation delivery so an overnight away run actually reaches firstmate instead of piling up undelivered for hours.

Observed 2026-08-22 in the captain's home: state/.afk was set at 02:23, the return at 09:10 reported 24401s undelivered. Every escalation survived and was delivered in the return catch-up, so nothing was lost, but nothing reached firstmate during the night. Two workers sat parked on gate decisions from roughly 03:00 until 09:10, and two finished tasks waited to be landed. The max-defer alarm fired and the marker was durable, but a loud alarm nobody is awake to read is not a delivery path.

The overnight daemon log showed 1555 composer=unknown defers on herdr (not busy). Confirm against bin/fm-composer-lib.sh's verdict for an idle Claude pane whose session is waiting between turns rather than mid-turn. Reproduce before fixing. Do not fix a cause you have only reasoned about.

The core question: does the native-hosted away path need a different flush target than the pane hosting the daemon? Weigh at least: (1) make the composer verdict correct for an idle-between-turns Claude pane so the existing flush works; (2) give the native-hosted path a delivery target that does not depend on composer state; (3) make the max-defer escape an actual delivery escalation rather than only an alarm. If more than one is needed, do more than one. Fix this completely.

Chosen shape after reproduction: keep the captain pane as the flush target (a native background job inherits HERDR_PANE_ID of that pane). Fix the composer classifier so a clipped Claude idle pair (glyph immediately under a closing ─, opening ─ dropped from a short capture) reads empty. Point herdr composer capture at --source visible (the live viewport) instead of recent scrollback, and do not tail away the composer pair. On herdr, inject_msg may deliver when the composer is unknown if native agent-state is idle; pending still defers; a dead shell has no idle agent registration and still defers. Max-defer retries that recovery path before alarming.

Acceptance: an away run whose supervisor pane sits idle between turns delivers within the daemon cadence, proven by a test that failed before the change; a wedge that does occur has a bounded automatic recovery path, not only an alarm; catch-up on return still delivers anything genuinely held; regression tests live beside the code; AGENTS.md section 8's away-mode stub, the /afk skill, and any script header or doc whose contract changed must match. Do not leave a doc describing the old behaviour.

Constraints: keep it simple, no second daemon or broker; away mode must never expand approval authority; never broadly kill watchers; stay out of bin/fm-claude-stop-autoarm.sh, bin/fm-turnend-guard.sh, and bin/fm-session-start.sh (parallel task). Delivery mode is no-mistakes; complete only with a PR whose checks are green.

What Changed

  • Composer classifier (bin/fm-composer-lib.sh): a bare agent glyph whose closing sits on the very next row now reads empty instead of failing cursor-less selection as a clipped Pi pair, and a new fm_composer_screen_has_agent_container splits "no container at all" from "a container I cannot judge" for callers that want to act on unknown.
  • herdr backend (bin/backends/herdr.sh): fm_backend_herdr_capture_ansi reads --source visible (the live viewport) instead of tailing recent scrollback; capture/classify/identity collapse into one fm_backend_herdr_composer_read, and fm_backend_herdr_composer_unknown_deliverable reports a pane deliverable only when a styled re-read still says unknown, the screen carries a proven agent container, and native agent-state is idle.
  • Away-mode delivery (bin/fm-backend.sh, bin/fm-supervise-daemon.sh): inject_msg defers on pending as before but now delivers through an unknown composer when the backend proves a live idle agent composer (herdr only; every other backend returns a hard defer), and the max-defer escape retries that same recovery flush before writing the wedge marker and firing the alarm. Regression tests cover the clipped-idle verdict, the visible-source capture, the degraded-unstyled and dead-shell/modal/unidentified-row refusals, the inject path, and max-defer recovery clearing the buffer without a wedge marker; the /afk skill and the architecture, configuration, herdr-backend, wedge-alarm and runtime-backends docs are updated to match.

Risk Assessment

⚠️ Medium: The core fix is tightly gated, well documented, and covered by behavioral tests that trace correctly by hand, but the styled capture now depends on a single unverified-on-older-herdr CLI flag with no fallback, and the dead-shell regression test does not exercise the branch it names.

Testing

I ran the four changed test suites plus the away-mode return suite on the target commit (all pass), then proved the regression bites by replaying the same new tests against base-commit sources, where the clipped-idle-Claude classifier case and the herdr native-idle inject case both fail. For product-level evidence I drove the real away daemon process end to end against a scripted herdr CLI standing in for the captain's supervisor pane: on base commit the run reproduces the reported overnight wedge (nothing submitted, both escalations still buffered, wedge marker written, alarm repeating), and on the target commit the identical run delivers the digest to the pane within one housekeeping tick with an empty buffer and no alarm. The same harness confirms the safety edges still refuse - a dead login shell reporting agent_status=done, and a captain's half-typed draft, both keep deferring and alarm instead of typing - and that after an overnight wedge the return catch-up (bin/fm-afk-return.sh) still delivers everything held. The real-herdr e2e test skips here because no herdr server can run headless on this machine; CI owns it. No screenshot or rendered-HTML artifact applies: the change is a background daemon and a terminal-pane classifier, so the CLI transcript of daemon log lines, submitted pane text, and buffer state is the actual end-user surface.

Evidence: Away-mode delivery: before/after end-to-end transcript

Source: Away-mode delivery: before/after end-to-end transcript

############################################################### # BEFORE - base commit 672c6d0: the reported overnight wedge # ############################################################### === scenario: idle-claude === --- daemon log (state/.supervise-daemon.log) --- daemon starting; target=default:w1:p2; backend=herdr; afk=on; batch=2s ERROR: away-mode escalation undelivered 8s; inject could not confirm a submit (supervisor pane busy or wedged). Buffer + wake-queue preserved; alarm marker written. wedge alarm: ... durable marker .subsuper-inject-wedged is the only signal ERROR: away-mode escalation undelivered 16s; ... ERROR: away-mode escalation undelivered 24s; ... --- what actually reached the supervisor pane --- (nothing submitted) --- undelivered escalation buffer --- STILL BUFFERED: fm-c1 needs a gate decision: approve the schema drop? STILL BUFFERED: fm-c2 done: PR #15 wedge alarm marker --- PRESENT --- composer capture source used --- 52 pane read --source recent ############################################################### # AFTER - target commit e35768b # ############################################################### === scenario: idle-claude === --- daemon log (state/.supervise-daemon.log) --- daemon starting; target=default:w1:p2; backend=herdr; afk=on; batch=2s --- what actually reached the supervisor pane --- SUBMITTED: ⁣FIRSTMATE_OP: v1 away-supervisor: Supervisor escalate (2 event(s)): fm-c1 needs a gate decision: approve the schema drop? | fm-c2 done: PR #15 (pre-read; re-arm not needed — watcher daemon-managed) --- undelivered escalation buffer --- (empty - everything delivered) --- wedge alarm marker --- absent --- composer capture source used --- 1 pane read --source recent 1 pane read --source visible === scenario: dead-shell === (harness exited; herdr still reports agent_status=done -> native idle) --- what actually reached the supervisor pane --- (nothing submitted) --- undelivered escalation buffer --- STILL BUFFERED: fm-c1 needs a gate decision: approve the schema drop? STILL BUFFERED: fm-c2 done: PR #15 wedge alarm marker --- PRESENT === scenario: human-typing === (captain left 'land the parked workers' unsent) --- what actually reached the supervisor pane --- (nothing submitted) --- wedge alarm marker --- PRESENT === scenario: wedge-then-return === ERROR: away-mode escalation undelivered 8s; ... alarm marker written. [captain returns 09:10: harness restarted, away mode off] --- bin/fm-afk-return.sh (the return catch-up the captain sees) --- fm-afk-launch: away mode stopped; daemon terminal torn down and .afk cleared fm-afk-return: catch-up clear; ordinary captain work may proceed --- what actually reached the supervisor pane --- SUBMITTED: ⁣FIRSTMATE_OP: v1 away-supervisor: Supervisor escalate (2 event(s)): fm-c1 needs a gate decision: approve the schema drop? | fm-c2 done: PR #15 (pre-read; re-arm not needed — watcher daemon-managed) --- undelivered escalation buffer --- (empty - everything delivered) --- wedge alarm marker --- absent

Away-mode escalation delivery: end-to-end before/after
======================================================
Each block runs the REAL bin/fm-supervise-daemon.sh process in away mode
against a scripted `herdr` CLI standing in for the captain's supervisor pane.
The pane models a Claude session idle BETWEEN turns: `pane read --source
recent` hands back the clipped repaint fragment the 2026-08-22 daemon log
classified as composer=unknown, `--source visible` hands back the live
viewport, `pane send-text`/`send-keys enter` submit and flip native
agent-state, exactly as herdr does.
Repeated "inject deferred: supervisor composer ..." lines are filtered out of
the daemon log to keep the transcript readable; the counts are visible in the
capture-source tally at the end of each block.

###############################################################
# BEFORE - base commit 672c6d0: the reported overnight wedge   #
###############################################################
=== scenario: idle-claude ===
--- daemon log (state/.supervise-daemon.log) ---
  [2026-08-22T13:31:25+0800] daemon starting (pid 69515); target=default:w1:p2; target_source=FM_SUPERVISOR_TARGET; backend=herdr; backend_source=FM_SUPERVISOR_BACKEND; afk=on; inject_skip='heartbeat'; stale_escalate=999999s; batch=2s
  [2026-08-22T13:31:33+0800] ERROR: away-mode escalation undelivered 8s; inject could not confirm a submit (supervisor pane busy or wedged). Buffer + wake-queue preserved; alarm marker written.
  [2026-08-22T13:31:33+0800] wedge alarm: no OS-level alert channel on Linux; durable marker /tmp/fm-afk-wedge.WjFZSu/state/.subsuper-inject-wedged is the only signal - set config/wedge-alarm (e.g. a command: directive)
  [2026-08-22T13:31:41+0800] ERROR: away-mode escalation undelivered 16s; inject could not confirm a submit (supervisor pane busy or wedged). Buffer + wake-queue preserved; alarm marker written.
  [2026-08-22T13:31:41+0800] wedge alarm: no OS-level alert channel on Linux; durable marker /tmp/fm-afk-wedge.WjFZSu/state/.subsuper-inject-wedged is the only signal - set config/wedge-alarm (e.g. a command: directive)
  [2026-08-22T13:31:49+0800] ERROR: away-mode escalation undelivered 24s; inject could not confirm a submit (supervisor pane busy or wedged). Buffer + wake-queue preserved; alarm marker written.
  [2026-08-22T13:31:49+0800] wedge alarm: no OS-level alert channel on Linux; durable marker /tmp/fm-afk-wedge.WjFZSu/state/.subsuper-inject-wedged is the only signal - set config/wedge-alarm (e.g. a command: directive)
--- what actually reached the supervisor pane ---
  (nothing submitted)
--- undelivered escalation buffer ---
  STILL BUFFERED: fm-c1 needs a gate decision: approve the schema drop?
  STILL BUFFERED: fm-c2 done: PR https://github.com/BohnBawerick/firstmate/pull/15
--- wedge alarm marker ---
  PRESENT
--- composer capture source used ---
       52 pane read --source recent

###############################################################
# AFTER - target commit e35768b                                #
###############################################################
=== scenario: idle-claude ===
--- daemon log (state/.supervise-daemon.log) ---
  [2026-08-22T13:32:01+0800] daemon starting (pid 83824); target=default:w1:p2; target_source=FM_SUPERVISOR_TARGET; backend=herdr; backend_source=FM_SUPERVISOR_BACKEND; afk=on; inject_skip='heartbeat'; stale_escalate=999999s; batch=2s
--- what actually reached the supervisor pane ---
  SUBMITTED: ⁣FIRSTMATE_OP: v1 away-supervisor: Supervisor escalate (2 event(s)): fm-c1 needs a gate decision: approve the schema drop? | fm-c2 done: PR https://github.com/BohnBawerick/firstmate/pull/15 (pre-read; re-arm not needed — watcher daemon-managed)
--- undelivered escalation buffer ---
  (empty - everything delivered)
--- wedge alarm marker ---
  absent
--- composer capture source used ---
        1 pane read --source recent
        1 pane read --source visible

=== scenario: dead-shell ===
--- daemon log (state/.supervise-daemon.log) ---
  [2026-08-22T13:32:09+0800] daemon starting (pid 85780); target=default:w1:p2; target_source=FM_SUPERVISOR_TARGET; backend=herdr; backend_source=FM_SUPERVISOR_BACKEND; afk=on; inject_skip='heartbeat'; stale_escalate=999999s; batch=2s
  [2026-08-22T13:32:20+0800] ERROR: away-mode escalation undelivered 10s; inject could not confirm a submit (supervisor pane busy or wedged). Buffer + wake-queue preserved; alarm marker written.
  [2026-08-22T13:32:20+0800] wedge alarm: no OS-level alert channel on Linux; durable marker /tmp/fm-afk-wedge.ElXH9Y/state/.subsuper-inject-wedged is the only signal - set config/wedge-alarm (e.g. a command: directive)
  [2026-08-22T13:32:29+0800] ERROR: away-mode escalation undelivered 20s; inject could not confirm a submit (supervisor pane busy or wedged). Buffer + wake-queue preserved; alarm marker written.
  [2026-08-22T13:32:29+0800] wedge alarm: no OS-level alert channel on Linux; durable marker /tmp/fm-afk-wedge.ElXH9Y/state/.subsuper-inject-wedged is the only signal - set config/wedge-alarm (e.g. a command: directive)
  [2026-08-22T13:32:38+0800] ERROR: away-mode escalation undelivered 29s; inject could not confirm a submit (supervisor pane busy or wedged). Buffer + wake-queue preserved; alarm marker written.
  [2026-08-22T13:32:38+0800] wedge alarm: no OS-level alert channel on Linux; durable marker /tmp/fm-afk-wedge.ElXH9Y/state/.subsuper-inject-wedged is the only signal - set config/wedge-alarm (e.g. a command: directive)
--- what actually reached the supervisor pane ---
  (nothing submitted)
--- undelivered escalation buffer ---
  STILL BUFFERED: fm-c1 needs a gate decision: approve the schema drop?
  STILL BUFFERED: fm-c2 done: PR https://github.com/BohnBawerick/firstmate/pull/15
--- wedge alarm marker ---
  PRESENT
--- composer capture source used ---
       24 pane read --source recent
       48 pane read --source visible

=== scenario: human-typing ===
--- daemon log (state/.supervise-daemon.log) ---
  [2026-08-22T13:32:46+0800] daemon starting (pid 604); target=default:w1:p2; target_source=FM_SUPERVISOR_TARGET; backend=herdr; backend_source=FM_SUPERVISOR_BACKEND; afk=on; inject_skip='heartbeat'; stale_escalate=999999s; batch=2s
  [2026-08-22T13:32:54+0800] ERROR: away-mode escalation undelivered 9s; inject could not confirm a submit (supervisor pane busy or wedged). Buffer + wake-queue preserved; alarm marker written.
  [2026-08-22T13:32:54+0800] wedge alarm: no OS-level alert channel on Linux; durable marker /tmp/fm-afk-wedge.O1s2pH/state/.subsuper-inject-wedged is the only signal - set config/wedge-alarm (e.g. a command: directive)
  [2026-08-22T13:33:03+0800] ERROR: away-mode escalation undelivered 18s; inject could not confirm a submit (supervisor pane busy or wedged). Buffer + wake-queue preserved; alarm marker written.
  [2026-08-22T13:33:03+0800] wedge alarm: no OS-level alert channel on Linux; durable marker /tmp/fm-afk-wedge.O1s2pH/state/.subsuper-inject-wedged is the only signal - set config/wedge-alarm (e.g. a command: directive)
  [2026-08-22T13:33:11+0800] ERROR: away-mode escalation undelivered 26s; inject could not confirm a submit (supervisor pane busy or wedged). Buffer + wake-queue preserved; alarm marker written.
  [2026-08-22T13:33:11+0800] wedge alarm: no OS-level alert channel on Linux; durable marker /tmp/fm-afk-wedge.O1s2pH/state/.subsuper-inject-wedged is the only signal - set config/wedge-alarm (e.g. a command: directive)
--- what actually reached the supervisor pane ---
  (nothing submitted)
--- undelivered escalation buffer ---
  STILL BUFFERED: fm-c1 needs a gate decision: approve the schema drop?
  STILL BUFFERED: fm-c2 done: PR https://github.com/BohnBawerick/firstmate/pull/15
--- wedge alarm marker ---
  PRESENT
--- composer capture source used ---
       24 pane read --source recent
       24 pane read --source visible

  [captain returns 09:10: harness restarted, away mode off]
  --- bin/fm-afk-return.sh (the return catch-up the captain sees) ---
  fm-afk-launch: away mode stopped; daemon terminal torn down and .afk cleared
  WAKE_ACK_REQUIRED: after handling completes run bin/fm-wake-drain.sh --ack-through 0 --recovery-generation 23226.1787376818.Yi3G0y
  fm-afk-return: catch-up clear; ordinary captain work may proceed
=== scenario: wedge-then-return ===
--- daemon log (state/.supervise-daemon.log) ---
  [2026-08-22T13:33:25+0800] daemon starting (pid 16026); target=default:w1:p2; target_source=FM_SUPERVISOR_TARGET; backend=herdr; backend_source=FM_SUPERVISOR_BACKEND; afk=on; inject_skip='heartbeat'; stale_escalate=999999s; batch=2s
  [2026-08-22T13:33:34+0800] ERROR: away-mode escalation undelivered 9s; inject could not confirm a submit (supervisor pane busy or wedged). Buffer + wake-queue preserved; alarm marker written.
  [2026-08-22T13:33:34+0800] wedge alarm: no OS-level alert channel on Linux; durable marker /tmp/fm-afk-wedge.ZUn5fa/state/.subsuper-inject-wedged is the only signal - set config/wedge-alarm (e.g. a command: directive)
--- what actually reached the supervisor pane ---
  SUBMITTED: ⁣FIRSTMATE_OP: v1 away-supervisor: Supervisor escalate (2 event(s)): fm-c1 needs a gate decision: approve the schema drop? | fm-c2 done: PR https://github.com/BohnBawerick/firstmate/pull/15 (pre-read; re-arm not needed — watcher daemon-managed)
--- undelivered escalation buffer ---
  (empty - everything delivered)
--- wedge alarm marker ---
  absent
--- composer capture source used ---
        8 pane read --source recent
       15 pane read --source visible
Evidence: Reproduction harness used to produce the transcript

Source: Reproduction harness used to produce the transcript

#!/usr/bin/env bash
# End-to-end reproduction of the 2026-08-22 away-mode escalation wedge.
#
# Runs the REAL bin/fm-supervise-daemon.sh process against a scripted `herdr`
# CLI that models a Claude supervisor pane sitting idle between turns:
#   * `pane read --source recent` returns the emitted-output stream. Its last
#     20 rows are partial repaints that carry the idle glyph and the closing
#     rule but not the opening rule - the clip the incident log recorded as
#     1555 composer=unknown defers.
#   * `pane read --source visible` returns the live viewport, which always
#     carries the whole composer.
#   * `pane send-text` appends to the composer buffer; `pane send-keys enter`
#     submits it (logged), flips native agent-state to working for 3s, then
#     back to idle - herdr's native submit confirmation.
#
# Usage: afk-wedge-e2e.sh <repo-root> <scenario>
#   scenario = idle-claude | dead-shell | human-typing
set -u
ROOT=$1
SCENARIO=$2
WORK=$(mktemp -d "${TMPDIR:-/tmp}/fm-afk-wedge.XXXXXX")
STATE="$WORK/state"; mkdir -p "$STATE"
PANE="$WORK/pane"; mkdir -p "$PANE"
: > "$PANE/buf"
: > "$PANE/submitted.log"
echo idle > "$PANE/agent"
echo 0 > "$PANE/working_until"
if [ "$SCENARIO" = wedge-then-return ]; then echo dead-shell > "$PANE/scenario"; else echo "$SCENARIO" > "$PANE/scenario"; fi

FB="$WORK/fakebin"; mkdir -p "$FB"
cat > "$FB/herdr" <<'SH'
#!/usr/bin/env bash
set -u
P="${FM_FAKE_PANE:?}"
log() { printf '%s\n' "$*" >> "$P/calls.log"; }
now() { date +%s; }
scenario=$(cat "$P/scenario")
buf=$(cat "$P/buf")

render_composer() {  # <source>
  local src=$1 line
  case "$scenario" in
    dead-shell)
      # The harness exited; the pane is a bare login shell prompt.
      printf 'paiva@box firstmate %% \n'
      return 0
      ;;
  esac
  line="❯ $buf"
  if [ "$src" = visible ]; then
    # Live viewport: transcript above, then the WHOLE composer pair.
    printf '● Landed the docs pass; two workers are parked on gate decisions.\n'
    printf '\n'
    printf '────────────────────────────────────────────────\n'
    printf '%s\n' "$line"
    printf '────────────────────────────────────────────────\n'
    printf '  firstmate on  main · Opus 5\n'
    printf '  ⏵⏵ bypass permissions on\n'
  else
    # Emitted-output scrollback. The most recent repaint started BELOW the
    # opening rule, so the 20-row tail the adapter used to take carries the
    # glyph and the closing rule but not the opening one - the exact clipped
    # fragment the 2026-08-22 daemon log classified as composer=unknown.
    printf '%s\n' "$line"
    printf '────────────────────────────────────────────────\n'
    printf '  firstmate on  main · Opus 5\n'
    printf '  ⏵⏵ bypass permissions on\n'
  fi
}

cmd=${1:-}; sub=${2:-}
case "$cmd $sub" in
  "status --json")
    printf '{"client":{"version":"0.8.0","protocol":14},"server":{"running":true}}\n' ;;
  "pane get")
    printf '{"result":{"pane":{"pane_id":"%s"}}}\n' "${3:-}" ;;
  "pane read")
    src=recent
    prev=""
    for a in "$@"; do
      if [ "$prev" = --source ]; then src=$a; fi
      prev=$a
    done
    log "pane read --source $src"
    render_composer "$src" ;;
  "agent get")
    st=$(cat "$P/agent")
    if [ "$st" = working ] && [ "$(now)" -ge "$(cat "$P/working_until")" ]; then
      st=idle; echo idle > "$P/agent"
    fi
    case "$scenario" in
      dead-shell) printf '{"result":{"agent":{"agent":"claude","agent_status":"done"}}}\n' ;;
      *) printf '{"result":{"agent":{"agent":"claude","agent_status":"%s"}}}\n' "$st" ;;
    esac ;;
  "pane send-text")
    log "pane send-text"
    printf '%s' "${4:-}" >> "$P/buf" ;;
  "pane send-keys")
    log "pane send-keys ${4:-}"
    if [ "$(cat "$P/buf")" != "" ]; then
      printf '%s\n' "$(cat "$P/buf")" >> "$P/submitted.log"
      : > "$P/buf"
      echo working > "$P/agent"
      echo $(( $(now) + 3 )) > "$P/working_until"
    fi ;;
  *) : ;;
esac
exit 0
SH
chmod +x "$FB/herdr"
export FM_FAKE_PANE="$PANE"

# --- the away run ------------------------------------------------------------
# shellcheck source=/dev/null
. "$ROOT/bin/fm-supervise-daemon.sh"
afk_enter "$STATE"                      # captain sets state/.afk at 02:23
if [ "$SCENARIO" = human-typing ]; then
  printf 'land the parked workers' > "$PANE/buf"   # captain left a half-typed line
fi
escalate_add "$STATE" "fm-c1 needs a gate decision: approve the schema drop?"
escalate_add "$STATE" "fm-c2 done: PR https://github.com/BohnBawerick/firstmate/pull/15"

PATH="$FB:$PATH" \
FM_STATE_OVERRIDE="$STATE" \
FM_SUPERVISOR_TARGET="default:w1:p2" \
FM_SUPERVISOR_BACKEND=herdr \
FM_ESCALATE_BATCH_SECS=2 \
FM_HOUSEKEEPING_TICK=1 \
FM_POLL=1 \
FM_HEARTBEAT=999999 \
FM_CHECK_INTERVAL=999999 \
FM_STALE_ESCALATE_SECS=999999 \
FM_MAX_DEFER_SECS=8 \
FM_INJECT_CONFIRM_SLEEP=0.3 \
FM_BACKEND_HERDR_SUBMIT_MIN_SLEEP=0 \
FM_WEDGE_ALARM_EXEC=discard \
nohup "$ROOT/bin/fm-supervise-daemon.sh" >"$WORK/daemon.out" 2>"$WORK/daemon.err" &
DPID=$!

if [ "$SCENARIO" = wedge-then-return ]; then
  # Overnight: the harness had exited, so every tick deferred and the
  # max-defer alarm fired. At 09:10 the captain restarts Claude in the same
  # pane and leaves away mode - the return catch-up must still deliver.
  sleep 12
  echo "  [captain returns 09:10: harness restarted, away mode off]"
  echo idle-claude > "$PANE/scenario"
  echo "  --- bin/fm-afk-return.sh (the return catch-up the captain sees) ---"
  PATH="$FB:$PATH" FM_STATE_OVERRIDE="$STATE" FM_SUPERVISOR_TARGET="default:w1:p2" \
    FM_SUPERVISOR_BACKEND=herdr \
    "$ROOT/bin/fm-afk-return.sh" 2>&1 | sed -e 's/^/  /' | head -25
  sleep 2
else
  i=0
  while [ $i -lt 30 ]; do
    [ -s "$PANE/submitted.log" ] && break
    sleep 1; i=$((i+1))
  done
  sleep 2
fi
kill "$DPID" 2>/dev/null; wait "$DPID" 2>/dev/null
sleep 1

echo "=== scenario: $SCENARIO ==="
echo "--- daemon log (state/.supervise-daemon.log) ---"
grep -E "daemon starting|inject|escalat|flush|wedge|max-defer" "$STATE/.supervise-daemon.log" 2>/dev/null \
  | sed -e 's/^/  /' | head -30
echo "--- what actually reached the supervisor pane ---"
if [ -s "$PANE/submitted.log" ]; then
  sed -e 's/^/  SUBMITTED: /' "$PANE/submitted.log"
else
  echo "  (nothing submitted)"
fi
echo "--- undelivered escalation buffer ---"
if [ -s "$STATE/.subsuper-escalations" ]; then
  sed -e 's/^/  STILL BUFFERED: /' "$STATE/.subsuper-escalations"
else
  echo "  (empty - everything delivered)"
fi
echo "--- wedge alarm marker ---"
if [ -e "$STATE/.subsuper-inject-wedged" ]; then echo "  PRESENT"; else echo "  absent"; fi
echo "--- composer capture source used ---"
sort "$PANE/calls.log" 2>/dev/null | uniq -c | grep "pane read" | sed -e 's/^/  /'
rm -rf "$WORK"
Evidence: Regression proof: new tests against base-commit sources
$ git archive 672c6d0 | tar -x -C /tmp/fm-baseline # OLD bin/ sources
$ git show e35768b:tests/<file> > /tmp/fm-baseline/tests/<file> # NEW tests only

=== fm-composer-lib === exit=1
not ok - clipped claude idle on herdr: expected empty, got 'unknown'

=== fm-backend-herdr === exit=1
not ok - clipped idle Claude (❯ then closing ─, no opening ─) must read empty, got 'unknown'

=== fm-daemon === exit=1
not ok - inject_msg should deliver when herdr native state is idle even if the composer reads unknown
not ok - herdr unknown+idle inject_msg subshell failed

All of the above pass on the target commit.

Known and deliberately not fixed

Two findings from this run's final review round are shipping unaddressed.
The review step closed at round 4 with no findings selected, so neither reached a fix round.
They are recorded here rather than as follow-up work, so the evidence sits with the change.

ansi-capture-has-no-source-fallback

bin/backends/herdr.sh:2616 - fm_backend_herdr_capture_ansi issues exactly one command, pane read --source visible --lines N --format ansi, with no fallback.

On a Herdr build that rejects --source visible the CLI exits non-zero, fm_backend_herdr_composer_read drops to the plain capture with styled=0, an idle Claude row carrying its dim rotating hint degrades to unknown, and fm_backend_herdr_composer_unknown_deliverable refuses at bin/backends/herdr.sh:2722 precisely because the styled read failed.
Every escalation would then defer for the whole away run, which is the overnight wedge this change exists to remove.

This is latent, not live.
The machine this was built and verified on runs Herdr 0.8.0, where --source visible is supported.
The exposure is the older versions docs/herdr-backend.md:4 still lists as supported: 0.7.1, 0.7.3, 0.7.4 and 0.7.5.

What it would need: keep --source visible as the primary styled read, and add a styled fallback of --source recent --lines 200 --format ansi without the local tail -n.
The original clipping cause was that local tail, not --source recent, so an untailed recent read is a correct styled capture.
The fallback would still have to count as a successful styled capture so the unknown-deliverable path can proceed on it, while a genuinely failed styled read keeps refusing.
This would not widen the composer-unknown override: decision (A), limiting it to a successful styled capture and excluding bare-shell rows, stays as settled.

dead-shell-fixture-never-sets-shell-row

tests/fm-backend-herdr.test.sh:3246 - test_unknown_deliverable_refuses_dead_shell_reporting_done feeds the screen paiva@box firstmate % .

fm_composer_leading_shell_glyph_var (bin/fm-composer-lib.sh:457-469) strips leading whitespace and then requires the row to start with >, $, % or #.
This row starts with p, so FM_COMPOSER_SCAN_SHELL_ROW stays -1 and _fm_composer_select_cursorless refuses through the empty-kind path instead.
The test passes for the same reason as test_unknown_deliverable_refuses_unidentified_row and never executes the shell-row branch at bin/fm-composer-lib.sh:1091 that it claims to pin.

No behaviour changes either way; the fixture pins nothing.

What it would need: a fixture row that actually begins with a shell glyph, so the shell-row branch is the reason the test refuses, kept distinct from the unidentified-row case.

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

⚠️ **Review** - 2 warnings
  • 🚨 bin/fm-supervise-daemon.sh:1247 - bin/fm-supervise-daemon.sh:1247 - the herdr native-idle override accepts every unknown verdict. When fm_backend_herdr_composer_state falls back to the plain capture (styled=0), _fm_composer_classify_bare_wrap returns unknown for REAL typed text instead of pending (bin/fm-composer-lib.sh:989), specifically so callers keep deferring. The change added a new way to reach that fallback: --source visible on an older herdr makes the ANSI read fail. Sequence: captain returns and types an unsent line, ANSI capture fails, verdict unknown, busy_state idle, line 1249 delivers and merges the digest into the captain's line. The intent's rule "pending still defers" is defeated because degraded-capture pending is spelled unknown. Gate the override on a styled capture, or have the adapter distinguish a degraded unknown from a shape unknown.
  • ⚠️ bin/fm-supervise-daemon.sh:1242 - bin/fm-supervise-daemon.sh:1242 - the code, docs and skill all claim "a dead shell has no idle agent registration and still defers", but bin/backends/herdr.sh:2974 maps done to idle, and docs/verification/runtime-backends.md:241 records done as a real herdr status. A pane whose harness exited while herdr still reports done yields composer=unknown (bare shell glyph) plus busy_state=idle, so line 1249 types the digest into a login shell - exactly the hazard the strict blank-row captain decision prevents (bin/fm-composer-lib.sh:44-46). No verification evidence in the repo covers agent get on a dead pane, and the updated test at tests/fm-daemon.test.sh:1971 stubs fm_backend_busy_state to unknown, assuming the premise rather than proving it. Excluding done from the override, or refusing it when the classifier selected a shell row, would close it.
  • ⚠️ .agents/skills/afk/SKILL.md:187 - .agents/skills/afk/SKILL.md:187 still says "A blank or otherwise unidentified input row carries no positive container proof and defers injection, so a modal dialog or a mid-redraw pane is never an injection target." That is now false on herdr: those shapes classify unknown, and with native agent-state idle the daemon injects. The intent requires the /afk skill to match and says "Do not leave a doc describing the old behaviour", and separately requires that away mode never expand approval authority - a digest plus Enter landing on a modal dialog selects its highlighted option. Either narrow the override so the sentence stays true, or rewrite the sentence to state the herdr exception.
  • ℹ️ bin/fm-composer-lib.sh:1081 - bin/fm-composer-lib.sh:1081 - the comment says "Claude's idle composer is a bare agent glyph immediately under its closing ─", but line 1085 requires the separator to be one row BELOW the glyph (and the branch is only reachable for a separator below the candidate, line 1079). The test fixture confirms the real order is glyph then rule. The comment inverts the geometry of a safety gate.
  • ℹ️ bin/backends/herdr.sh:2613 - bin/backends/herdr.sh:2613 - after dropping the tail, the lines local is set and validated but never used; the two-step lines/fetch dance is now just "clamp the argument up to 200" and can collapse to one line. Related: bin/fm-supervise-daemon.sh:1252 and :1256 are the same log line in two branches; the nesting flattens to if unknown &amp;&amp; herdr &amp;&amp; native=idle then deliver; else log; return 1.

🔧 Fix: narrow herdr unknown-composer inject override to proven idle composers
4 issues (3 warnings, 1 info) still open:

  • ⚠️ bin/backends/herdr.sh:2689 - bin/backends/herdr.sh:2689 states condition 4 as "Native agent-state is idle: positive proof a registered agent is waiting between turns rather than mid-turn", and bin/backends/herdr.sh:2709 implements it as fm_backend_herdr_busy_state == idle. The repo's own recorded evidence contradicts that premise: docs/verification/runtime-backends.md:241 records that "live Claude Code 2.1.236 on Herdr 0.8.0 kept agent_status=idle for an entire landed turn, including a multi-second tool call", which is why the same file concludes "native idle cannot close a turn". bin/backends/herdr.sh:3047 also folds blocked into idle, so a pane parked on a permission dialog reads idle too. Concrete sequence: the captain's Claude pane is mid-generation, agent get returns idle, the composer row is fully de-emphasised so the shared classifier answers unknown with a proven bare-glyph container, and the override at bin/fm-supervise-daemon.sh:1252 fires. The only thing that actually stops delivery is the earlier rendered busy-footer guard in pane_is_busy (bin/fm-supervise-daemon.sh), which matches only the detected primary harness's signature and can be overridden by FM_BUSY_REGEX. That is a single rendered-text guard, not the "positive proof" the comment claims, and both bin/backends/herdr.sh:2689 and docs/herdr-backend.md:252 present native idle as the proof. Either state the real dependency (native idle plus the rendered busy guard) or add the rendered busy check inside the override so it does not rely on a caller ordering that a different consumer of fm_backend_composer_unknown_deliverable would not repeat.
  • ⚠️ bin/backends/herdr.sh:2867 - An override-driven delivery is unconfirmable by construction on the exact pane this change targets, so the digest can be retyped every cycle. Trace: inject_msg (bin/fm-supervise-daemon.sh:1250-1256) delivers because the composer reads unknown, then calls fm_backend_send_text_submit. In fm_backend_herdr_send_text_submit the pre-Enter baseline is idle, so after Enter it calls fm_backend_herdr_wait_for_working; per docs/verification/runtime-backends.md:241 Claude on herdr never flips agent_status to working, so that returns idle and control falls to bin/backends/herdr.sh:2867, verdict=$(fm_backend_herdr_composer_state &#34;$target&#34;). If that read still answers unknown - the same de-emphasised composer shape that triggered the override in the first place - the *) arm at bin/backends/herdr.sh:2871 returns unknown, inject_msg logs "submit unconfirmed" and returns 1, and escalate_flush preserves the buffer even though the text was typed and Enter was sent. On the next housekeeping tick the composer reads unknown again, the override fires again, and the same digest is typed into the captain's Claude a second time. Over a night that is repeated duplicate escalations, each starting a real turn. The override adds a delivery path whose success the herdr submit ack cannot report; it needs a confirmation signal that does not route back through the same unknown verdict (for example accepting the rendered busy footer as post-submit proof on this path, as the non-idle-baseline branch at bin/backends/herdr.sh:2875-2880 already does).
  • ⚠️ docs/verification/runtime-backends.md:240 - The intent requires "any script header or doc whose contract changed must match. Do not leave a doc describing the old behaviour." docs/verification/runtime-backends.md:240 still records the verified capture contract as herdr pane read &lt;pane&gt; --source recent --lines N with "a 200-line request plus local trim was stable". bin/backends/herdr.sh:2612-2617 replaced exactly that for composer classification: the source is now --source visible and the local tail is gone. No row records what --source visible was observed to return, which matters because the change rests on an unverified assumption. Specifically, if visible follows a scrolled viewport, a pane the captain left scrolled up overnight shows scrollback with no composer, every read answers unknown with no container, the override refuses, and away mode wedges for the whole night again - fail-safe, but the same observed symptom this change exists to remove. Add the --source visible row (including the scrolled-pane behaviour) or correct line 240 so the table no longer describes the retired composer capture.
  • ℹ️ bin/fm-composer-lib.sh:1344 - bin/fm-composer-lib.sh:1332-1344 documents fm_composer_screen_has_agent_container as proving "a GENUINE agent composer shape" and says 1 covers "a modal or mid-redraw pane"; bin/fm-supervise-daemon.sh:1215 and .agents/skills/afk/SKILL.md:189 repeat that a modal "has no container". That is not what the code does: the function returns 0 for any box kind, and a Claude permission dialog is a bordered box. A real modal is actually refused one step earlier, because its bright dialog text makes fm_composer_classify_screen answer pending, not unknown. The residual gap is narrow but real: for a box, _fm_composer_classify_rows only answers unknown when geometry is ambiguous and every inner row ghost-strips to nothing, and herdr maps agent_status=blocked (an agent parked on a dialog) to native idle (bin/backends/herdr.sh:3047), so such a screen would pass all four override conditions and the digest plus Enter would answer whatever the dialog has highlighted - the "away mode must never expand approval authority" line in the intent. Worth either tightening the function to the shapes it actually claims, or correcting the three comments so the modal refusal is attributed to the verdict rather than to a container check that does not perform it.

🔧 Fix: record herdr visible-source composer capture in verification doc
2 issues (1 warning, 1 info) still open:

  • ⚠️ bin/backends/herdr.sh:2708 - bin/backends/herdr.sh:2708 - fm_composer_screen_has_agent_container accepts the pi selection as container proof, but _fm_composer_pi_verdict (bin/fm-composer-lib.sh:1491) returns unknown and RETURNS EARLY, before _fm_composer_classify_pi_rows ever inspects the region's text, whenever the native identity is not exactly pi or FM_COMPOSER_SCAN_PI_PAIR_VALID is 0 (pair taller than FM_COMPOSER_PI_MAX_LINES, default 8). That reproduces the exact leak round 1 was asked to close - real typed text spelled unknown instead of pending - on a path the fix did not cover. Concrete reachable sequence: the captain's supervisor pane runs pi on herdr and holds a 9-row unsent draft inside the separator pair. fm_backend_herdr_composer_state -> classify_screen selects kind=pi (bin/fm-composer-lib.sh:1075-1081), verdict need-identity, the adapter refetches identity, agent=pi but PI_PAIR_VALID=0, so line 1491 prints unknown without reading a single content row. inject_msg (bin/fm-supervise-daemon.sh:1249) sees unknown, calls the override; the fresh styled capture repeats the same unknown, fm_composer_screen_has_agent_container returns 0 because SELECTED_KIND=pi, agent_status=idle maps to native idle, and the digest plus Enter is typed into the captain's draft. The same path fires for any non-pi harness whose bottom-most shape is a bare ─ ... ─ pair (identity != pi at line 1491) with unsent text in it. Fix at the shared boundary: either exclude pi from fm_composer_screen_has_agent_container unless PI_PAIR_VALID=1 and the identity gate passed, or have the override additionally require the selected region's content to strip empty, so unknown can only mean 'proven container, unjudgeable styling' and never 'proven container, unread text'.
  • ℹ️ bin/backends/herdr.sh:2694 - bin/backends/herdr.sh:2694-2707 copies the capture + caps + classify + need-identity-refetch block of fm_backend_herdr_composer_state (bin/backends/herdr.sh:2655-2670) verbatim, differing only in forcing the styled branch. The two must stay in lockstep for the override's condition 2 to mean what its header claims, and today they only do by hand. Factoring the shared block into one helper that returns the capture plus the resolved verdict (the override then passes a styled-only flag) removes the copy and lets the override reuse the capture it already holds instead of fm_composer_screen_has_agent_container re-stripping and re-scanning the same screen a third time.

🔧 Fix: gate Pi container proof on identity and dedupe herdr composer read
2 warnings still open:

  • ⚠️ bin/backends/herdr.sh:2616 - bin/backends/herdr.sh:2616 - fm_backend_herdr_capture_ansi is now the ONLY styled read, and it issues exactly one command: pane read --source visible --lines N --format ansi. There is no fallback to the --source recent --format ansi form that worked before this change. docs/verification/runtime-backends.md:241 and :253 verify --source visible on Herdr 0.8.0 only, while docs/herdr-backend.md:51 and :118 still treat 0.7.5 as a supported floor. Concrete sequence on a herdr that rejects --source visible: the CLI exits non-zero, fm_backend_herdr_composer_read (bin/backends/herdr.sh:2668-2674) falls to the plain capture with styled=0, an idle Claude row carrying its dim rotating hint runs _fm_composer_classify_bare_row (bin/fm-composer-lib.sh:931-943), which degrades pending to unknown; inject_msg then calls fm_backend_herdr_composer_unknown_deliverable, which returns 1 at bin/backends/herdr.sh:2722 precisely because the styled read failed. Every escalation defers for the whole away run - the exact overnight wedge this change exists to remove, now reachable on any herdr without visible. Note the original clipping cause was the local tail -n &#34;$lines&#34;, not --source recent, so --source recent --lines 200 --format ansi WITHOUT the tail is a correct styled fallback that keeps visible as the primary source the intent requires.
  • ⚠️ tests/fm-backend-herdr.test.sh:3246 - tests/fm-backend-herdr.test.sh:3246 - test_unknown_deliverable_refuses_dead_shell_reporting_done feeds the screen paiva@box firstmate % . fm_composer_leading_shell_glyph_var (bin/fm-composer-lib.sh:457-469) only strips LEADING whitespace and then requires the row to START with &gt;/$/%/#; this row starts with p, so FM_COMPOSER_SCAN_SHELL_ROW stays -1 and FM_COMPOSER_SCAN_BARE_ROW stays -1. _fm_composer_select_cursorless therefore falls through every branch and returns 1 for the empty-SELECTED_KIND reason, i.e. the test passes through the same 'no container at all' path already covered by test_unknown_deliverable_refuses_unidentified_row. The shell-row branch it claims to pin (bin/fm-composer-lib.sh:1091) is never executed, so a regression that dropped that branch would leave this test green. The repo's canonical dead-shell fixtures are glyph-leading (tests/fm-composer-lib.test.sh:384 uses &gt; , :488 uses $ ). Changing the fixture to a glyph-leading prompt row (e.g. % or $ ) while keeping agent_status=done exercises the real branch and still passes against current code.
✅ **Test** - passed

✅ No issues found.

  • bash tests/fm-composer-lib.test.sh - includes the new test_matrix_claude_clipped_closing_rule_is_empty
  • bash tests/fm-backend-herdr.test.sh - includes test_composer_state_claude_clipped_closing_rule_is_empty, test_composer_capture_uses_visible_source, and the five test_unknown_deliverable_* contract cases
  • bash tests/fm-backend.test.sh - includes test_composer_unknown_deliverable_default_is_refusal
  • bash tests/fm-daemon.test.sh - includes test_inject_msg_herdr_unknown_native_idle_delivers and test_max_defer_herdr_unknown_native_idle_flushes
  • bash tests/fm-afk-return.test.sh - return catch-up gate still drains buffered escalations and wedge evidence
  • Red-before proof: extracted base commit 672c6d0 to a scratch tree, overlaid only the NEW test files onto the OLD bin/ sources, and reran all four suites - not ok - clipped claude idle on herdr: expected empty, got &#39;unknown&#39;, not ok - clipped idle Claude ... must read empty, got &#39;unknown&#39;, not ok - inject_msg should deliver when herdr native state is idle even if the composer reads unknown
  • Manual end-to-end away run (/home/paiva/.no-mistakes/evidence/01M0KRMA7WZXW050Z9EGDCFMYH/afk-wedge-e2e.sh): real bin/fm-supervise-daemon.sh process, state/.afk set, two escalations buffered, scripted herdr CLI modelling the captain's pane; scenarios idle-claude, dead-shell, human-typing, wedge-then-return, run against both base and target commits
  • bin/fm-afk-return.sh executed inside the wedge-then-return scenario to confirm the captain-visible return catch-up still delivers a digest that wedged overnight
  • timeout 600 bash tests/fm-afk-inject-herdr-e2e.test.sh - real-herdr e2e; skips in this environment (no herdr server can start headless)
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

…sk record

Reads a project's registered "+hardened" annotation and carries it to the
worker's instructions and the task's durable record, so the quality loop that
bin/fm-quality.sh will drive has a posture and a fixed base commit to work from.
That script is not part of this change; it is referenced by name only.

- bin/fm-project-mode.sh: --quality prints one word, standard or hardened. The
  two-word stdout its three callers parse is untouched, so it gets its own
  output path. The bracket grammar is now position-tolerant: a "+"-prefixed
  token is a flag and never a mode, so "[+hardened local-only]" resolves the
  mode behind it instead of reading the flag as an unknown mode. Unrecognized
  flags are still ignored rather than refused.
- bin/fm-brief.sh: --quality standard|hardened, defaulting to standard and
  refused on scout, dreamer, and secondmate scaffolds. A hardened brief records
  the sibling "Quality contract: quality=hardened" line and one short quality
  gate section; a standard brief records neither and stays byte-identical to
  the pre-quality scaffold.
- bin/fm-spawn.sh: the brief's quality line must agree with --quality, the same
  check the delivery line already gets, in both directions. quality= and
  base_sha= land in the task record; the base commit is captured once at spawn
  and read back on relaunch, never recaptured, because the loop commits each
  round and a later capture would narrow the gate while still reporting success.
- AGENTS.md: one sentence placing quality resolution at intake.

Tests execute the real interfaces. The load-bearing ones prove a project
without "+hardened" and a brief scaffolded without --quality behave exactly as
before: the two-word stdout is pinned across every annotation form, the two
scaffolds are compared byte for byte, and the task record's key set is pinned
so only quality= and base_sha= are additive.
The stage 0a pilot showed the receipt cannot express real findings
and that a missing head_sha makes a drifted base report
not-applicable and exit 0. This revises unpublished schema v1 in
place: require head_sha, duration_ms, engine, threshold, and a
stable finding id; replace survivors[] with per-phase findings[];
and make verify one envelope with phases[]. bounds.budget_minutes
is the missing wall-clock bound.
A short herdr recent tail can drop Claude's opening rule and classify an
idle composer unknown, so native-hosted away-mode never injected.
Classify a glyph immediately under a closing rule as empty, read the
visible viewport for composer capture, and let herdr native idle deliver
when the composer is still unknown. Max-defer retries that path before
alarming.

The native-hosted daemon still injects into the captain pane. A dead
shell has no idle agent registration and still defers.
@BohnBawerick
BohnBawerick force-pushed the fm/fm-afk-native-inject-wedge branch from 711cf8e to bb5f2d0 Compare August 22, 2026 07:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant