Skip to content
Closed
6 changes: 4 additions & 2 deletions .agents/skills/afk/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,8 @@ Enter is retried (Enter only, never a retype) until the backend confirms the
submit landed.
For tmux that confirmation is normally a proven cleared composer from the shared classifier; an idle baseline transitioning to busy across this submit's own Enter also confirms that the turn started when a working harness hides its composer.
Without that baseline, busy state never converts an `unknown` composer into confirmation.
For herdr, idle-baseline submits first seek native agent-state showing a real turn started, then use the shared classifier when native state remains idle: a cleared composer confirms delivery, while pending text retries Enter and reaches the shared busy-queue verdict only after the retry budget.
For herdr, idle-baseline submits first seek native agent-state showing a real turn started, then use the shared classifier when native state remains idle: Pi takes an identity-corroborated structural-composer path, while other harnesses use the general composer fallback; cleared content confirms delivery, while pending text retries Enter and reaches the shared busy-queue verdict only after the retry budget.
[`docs/herdr-backend.md`](../../../docs/herdr-backend.md#current-transport-behavior) owns the backend-specific native-state, rendered-footer, Pi, and queued-Enter confirmation paths.
A bordered-empty or ghost-only composer is recognized as empty where that backend uses composer confirmation, rather than mistaken for a swallowed Enter.
`fm-send.sh` uses the same primitive and exits non-zero
when a steer's Enter is positively swallowed, so firstmate learns an instruction
Expand Down Expand Up @@ -191,7 +192,8 @@ the operational prefix lets firstmate distinguish it from a real captain message
Enter is retried, Enter only and never a retype, until the backend submit
primitive reports `empty` as its caller-facing success verdict.
For tmux that verdict normally means the shared classifier proved the composer cleared; a baseline-gated idle-to-busy transition may instead prove this Enter started the turn.
For herdr's idle-baseline path it means native agent-state observed a turn start, the shared classifier proved the composer cleared, or the shared queued-Enter verdict proved delivery while busy.
For herdr's idle-baseline path it means native agent-state observed a turn start, the identity-corroborated Pi path or general composer fallback proved the composer cleared, or the shared queued-Enter verdict proved delivery while busy.
The backend-specific paths are owned by [`docs/herdr-backend.md`](../../../docs/herdr-backend.md#current-transport-behavior).
This lets ghost-only or bordered-empty composers count as empty where a composer read is the active confirmation signal.
- **Marker strip** - `strip_injection_marker` removes the current operational
prefix or legacy bare marker before classification or relay, so the digest
Expand Down
71 changes: 46 additions & 25 deletions bin/backends/herdr.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2663,6 +2663,22 @@ fm_backend_herdr_composer_state() { # <target> -> empty|pending|pending-unprove
printf '%s' "$verdict"
}

# Confirm the Pi-specific idle path from the same native identity and a
# structurally empty composer. A non-Pi target stays on native confirmation.
fm_backend_herdr_pi_idle_composer_state() { # <target> -> empty|pending|unknown|not-pi
local target=$1 identity agent agent_status
fm_backend_herdr_parse_target "$target" || { printf 'unknown'; return 0; }
identity=$(fm_backend_herdr_agent_identity_raw "$FM_BACKEND_HERDR_SESSION" "$FM_BACKEND_HERDR_PANE" 2>/dev/null || true)
IFS=$'\t' read -r agent agent_status <<EOF
$identity
EOF
[ "$agent" = pi ] || { printf 'not-pi'; return 0; }
case "$agent_status" in
idle|done|blocked) fm_backend_herdr_composer_state "$target" ;;
*) printf 'unknown' ;;
esac
}

# fm_backend_herdr_rendered_busy_state: busy|idle|unknown from the pane's
# RENDERED busy footer, the same delivery-only signal bin/fm-tmux-lib.sh's
# fm_pane_busy_state reads, scanning the same 40-line tail folded to its last
Expand All @@ -2686,31 +2702,31 @@ fm_backend_herdr_rendered_busy_state() { # <target> [harness] -> busy|idle|unkn

# fm_backend_herdr_send_text_submit: type <text> into <target> once (raw,
# unsubmitted, via send_literal), then submit with a named Enter key, retried
# (Enter only, never retyped) until native agent-state, a cleared composer, or
# fm_composer_queued_enter_verdict confirms delivery. Verified hazard
# (herdr-verification-p2.md "slash/$ autocomplete popup"): a `/`- or
# `$`-prefixed send opens a completion popup within ~0.1s, exactly like tmux's
# claude/codex popups, so the caller's <settle> before the first Enter matters
# here the same way it does for tmux.
# (Enter only, never retyped) until native agent-state, the Pi-specific or
# general cleared-composer path, or fm_composer_queued_enter_verdict confirms
# delivery. Verified hazard (herdr-verification-p2.md "slash/$ autocomplete
# popup"): a `/`- or `$`-prefixed send opens a completion popup within ~0.1s,
# exactly like tmux's claude/codex popups, so the caller's <settle> before the
# first Enter matters here the same way it does for tmux.
#
# Confirmation signal: when the target is legibly idle before Enter,
# submission is confirmed by fm_backend_herdr_wait_for_working observing a
# submit-active agent_status after Enter. Live Claude on Herdr 0.8.0 can
# keep agent_status idle for a whole landed turn, so an idle native result
# falls through to the shared composer verdict: empty is positive delivery,
# proven pending retries Enter, and retries-exhausted pending plus a
# generating busy signal is a queued Enter via
# fm_composer_queued_enter_verdict (bin/fm-composer-lib.sh).
# submit-active agent_status after Enter. Pi and live Claude on Herdr can keep
# agent_status idle for a landed turn, so an idle native result falls through
# to composer confirmation. Pi first requires the same native Pi identity and
# then a structurally empty composer; other harnesses use the general shared
# composer verdict. Empty is positive delivery, proven pending retries Enter,
# and retries-exhausted pending plus a generating busy signal is a queued Enter
# via fm_composer_queued_enter_verdict (bin/fm-composer-lib.sh).
#
# Incident (2026-07-07, followed up on 2026-07-08): a redelivery loop in the
# away-mode daemon. Root cause: composer-content submit confirmation was too
# sensitive to harness rendering details. Real claude/codex use bare prompt
# rows, and real codex adds dynamic idle suggestions after `›`; the later
# ANSI-aware composer classifier now handles that Codex shape, and idle-baseline
# submit confirmation still prefers native agent-state so a faint idle tip
# cannot block a landed send. Composer content is consulted only after native
# state stays idle, as the empty/pending owner, and for submit attempts whose
# pre-Enter agent-state baseline is not legibly idle.
# Incident (2026-07-07, followed up on 2026-08-09): a redelivery loop in the
# away-mode daemon. Native agent-state confirmation fixed composer-rendering
# false negatives for Claude and Codex, but Pi's Herdr state can remain idle
# after Pi consumes a user message, and later live Claude was also observed
# staying idle for a whole landed turn. The ANSI-aware composer classifier
# remains the pre-injection guard and is consulted only after native state stays
# idle, as the identity-corroborated Pi or general empty/pending owner, and for
# submit attempts whose pre-Enter agent-state baseline is not legibly idle.
#
# This also still correctly handles the earlier 2026-07-03 incident (a
# slash-command popup selection/placeholder-fill on the FIRST Enter is not a
Expand Down Expand Up @@ -2759,7 +2775,10 @@ fm_backend_herdr_rendered_busy_state() { # <target> [harness] -> busy|idle|unkn
# supplies the busy primitive.
# Echoes empty|pending|unknown|send-failed, a subset of the proof-carrying
# submit vocabulary. Empty means confirmed submitted for every backend; how
# each backend confirms it is an internal decision.
# each backend confirms it is an internal decision. Herdr prefers native
# agent-state, uses an identity-corroborated structural composer verdict for Pi
# or the general composer fallback for other idle-native harnesses, and uses
# the rendered busy-footer and queued-Enter paths described above.
#
# fm_backend_herdr_queued_enter_busy: delivery-busy for the shared queued-Enter
# conversion. Native agent_status=working is generating; blocked is not (a
Expand All @@ -2780,6 +2799,7 @@ fm_backend_herdr_queued_enter_busy() { # <target> <allow-rendered>
fi
}


fm_backend_herdr_send_text_submit() { # <target> <text> <retries> <enter-sleep> <settle>
local target=$1 text=$2 retries=$3 sleep_s=$4 settle=$5 i=0 verdict baseline confirm_sleep
local raw_status footer_baseline='' allow_rendered=0 enter_sent=0
Expand Down Expand Up @@ -2815,9 +2835,10 @@ fm_backend_herdr_send_text_submit() { # <target> <text> <retries> <enter-sleep>
busy) printf 'empty'; return 0 ;;
unknown) printf 'unknown'; return 0 ;;
esac
# Native stayed idle. Composer empty is positive delivery (a landed
# Claude turn that never flipped agent_status). Proven pending retries.
verdict=$(fm_backend_herdr_composer_state "$target")
# Native stayed idle. Pi requires identity corroboration before the
# structural composer verdict; other harnesses use the general fallback.
verdict=$(fm_backend_herdr_pi_idle_composer_state "$target")
[ "$verdict" = not-pi ] && verdict=$(fm_backend_herdr_composer_state "$target")
case "$verdict" in
empty) printf 'empty'; return 0 ;;
pending|pending-unproven) ;;
Expand Down
11 changes: 9 additions & 2 deletions bin/fm-install-herdr.sh
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,15 @@ trap 'rm -rf "$TMP"' EXIT

printf 'fm-install-herdr.sh: downloading %s from %s\n' "$ASSET" "$URL" >&2
# --fail: HTTP errors; --location: follow redirects; --max-filesize: bound.
curl -fsSL --max-filesize "$FM_HERDR_CI_MAX_BYTES" "$URL" -o "$TMP/$ASSET" \
|| die "download failed for $URL (bounded at $FM_HERDR_CI_MAX_BYTES bytes)"
DOWNLOAD_ATTEMPTS=6
download_attempt=1
while ! curl -fsSL --max-filesize "$FM_HERDR_CI_MAX_BYTES" "$URL" -o "$TMP/$ASSET"; do
[ "$download_attempt" -lt "$DOWNLOAD_ATTEMPTS" ] \
|| die "download failed for $URL after $DOWNLOAD_ATTEMPTS attempts (bounded at $FM_HERDR_CI_MAX_BYTES bytes)"
printf 'fm-install-herdr.sh: download attempt %s failed; retrying\n' "$download_attempt" >&2
sleep $((1 << (download_attempt - 1)))
download_attempt=$((download_attempt + 1))
done

if command -v sha256sum >/dev/null 2>&1; then
ACTUAL_SHA256=$(sha256sum "$TMP/$ASSET" | awk '{print $1}')
Expand Down
2 changes: 1 addition & 1 deletion bin/fm-test-run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ family_for_basename() {
fm-watcher-lock.test.sh|fm-inactive-reconcile.test.sh)
printf '%s\n' watcher-wake-lock
;;
fm-afk-inject-herdr-e2e.test.sh|fm-afk-launch.test.sh|fm-backend-autodetect-smoke.test.sh|\
fm-afk-inject-herdr-e2e.test.sh|fm-afk-pi-herdr-ack-e2e.test.sh|fm-afk-launch.test.sh|fm-backend-autodetect-smoke.test.sh|\
fm-backend-herdr-eventwait-smoke.test.sh|fm-backend-herdr-presentation-e2e.test.sh|\
fm-backend-herdr-launcher-workspace-e2e.test.sh|\
fm-backend-herdr-prune-safety-e2e.test.sh|fm-backend-herdr-respawn-idem-e2e.test.sh|\
Expand Down
5 changes: 3 additions & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,9 @@ The always-on watcher also uses that library's absorb classification on no-verb
In away mode, seen-status dedupe does not clear possible-wedge aging for nonterminal progress, so housekeeping still re-escalates an unchanged idle pane at the configured bound.
The daemon escalates captain-relevant events, plus a bounded recheck for a declared pause that remains idle, as one batched, single-line digest using the canonical `away-supervisor` kind from `bin/fm-operational-input.sh` so firstmate can distinguish it structurally from real messages.
Its supervisor injection path supports tmux and herdr panes, with `FM_SUPERVISOR_BACKEND` and `FM_SUPERVISOR_TARGET` resolved independently from the task-spawn backend.
Pane existence, busy checks, composer checks, capture, and verified submit route through `bin/fm-backend.sh`: tmux keeps the same submit core used by the tmux send backend, while herdr uses native agent-state submit confirmation on idle baselines, a composer empty fallback when native stays idle, and a pre-Enter rendered-footer transition when that baseline is unavailable.
The retries-exhausted queued-Enter decision is owned by `fm_composer_queued_enter_verdict` in `bin/fm-composer-lib.sh`; tmux and herdr provide only their backend-specific busy signals.
Pane existence, busy checks, composer checks, capture, and verified submit route through `bin/fm-backend.sh`: tmux keeps the same submit core used by the tmux send backend, while Herdr uses native agent-state submit confirmation on idle baselines, an identity-corroborated Pi structural-composer path or general composer-empty fallback when native stays idle, and a pre-Enter rendered-footer transition when that baseline is unavailable.
The retries-exhausted queued-Enter decision is owned by `fm_composer_queued_enter_verdict` in `bin/fm-composer-lib.sh`; tmux and Herdr provide only their backend-specific busy signals.
Herdr's confirmation paths are documented in [herdr-backend.md](herdr-backend.md#current-transport-behavior).
Composer classification has one shared owner, `bin/fm-composer-lib.sh`: tmux, herdr, Zellij, Orca, and cmux contribute only a screen capture plus declarative styled, cursor, identity, and row capabilities, while the shared classifier owns every shape and the `empty`/`pending`/`pending-unproven`/`unknown` verdict.
`fm-spawn.sh` also routes Kimi launch readiness through that classifier instead of carrying another shape copy.
The daemon injects only into an affirmatively `empty` composer, so every other or future verdict defers; positive container proof is required, and a blank unidentified row or bare dead-shell prompt cannot receive an escalation.
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/demo-afk-pi-herdr-ack/after-fix.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/demo-afk-pi-herdr-ack/before-fix.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
42 changes: 42 additions & 0 deletions docs/demo-afk-pi-herdr-ack/demo.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Pi on Herdr away-mode acknowledgement

*2026-08-10T00:23:10Z by Showboat 0.6.1*
<!-- showboat-id: 5b9dc79e-1f57-4969-9f88-3572426798df -->

The bounded real reproduction reached Pi visibly while Herdr kept the native agent idle and reported the submit as pending.

```bash {image}
before-fix.png
```

![7d052665-2026-08-10](7d052665-2026-08-10.png)

The recorded pre-fix negative control returned pending with an empty Pi composer and retained the unchanged buffer.

```bash
printf "pre-fix-negative-control=pending\n"
```

```output
pre-fix-negative-control=pending
```

The corrected path keeps native Herdr confirmation for non-Pi agents and adds only the identity-corroborated Pi composer acknowledgement.

```bash
FM_AFK_PI_HERDR_ACK_E2E=1 ../../tests/fm-afk-pi-herdr-ack-e2e.test.sh 2>/dev/null
```

```output
ok - real Pi/Herdr idle-native delivery clears the buffer after one typed digest
ok - real Pi/Herdr unsubmitted input preserves the pending buffer
evidence: pi=0.84.1 herdr=0.7.4 protocol=16 successful_send_texts=1
```

```bash {image}
after-fix.png
```

![3b11f9be-2026-08-10](3b11f9be-2026-08-10.png)

The real control leaves an unsubmitted draft pending, while one delivered digest produces exactly one typed send.
4 changes: 4 additions & 0 deletions docs/documentation-audiences.json
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,10 @@
"path": "docs/decision-hold-lifecycle.md",
"audience": "maintainer-architecture"
},
{
"path": "docs/demo-afk-pi-herdr-ack/demo.md",
"audience": "maintainer-verification"
},
{
"path": "docs/documentation-audiences.md",
"audience": "maintainer-architecture"
Expand Down
6 changes: 4 additions & 2 deletions docs/herdr-backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -213,13 +213,14 @@ Slash and dollar-prefixed input uses the shared harness-aware settle before the
Text is typed once; only Enter is retried.

On an idle or done native baseline, submit confirmation first waits for `working` or `blocked` across a bounded polling window.
If native status stays idle, the shared composer verdict is the next positive signal: a cleared composer is delivery, and proven pending text retries Enter.
If native status stays idle, the shared composer verdict is the next positive signal: Pi first requires the same native Pi identity, while other harnesses use the general composer fallback; a cleared composer is delivery, and proven pending text retries Enter.
A pending or unreadable Pi composer remains unconfirmed and retains the buffer.
After the retry budget, `fm_composer_queued_enter_verdict` treats proven pending text plus a generating busy signal as a queued delivered Enter, and keeps an idle pending composer as a genuine swallow.
On an already active or unreadable baseline, the adapter falls back to conservative composer clearance, with a pre-Enter rendered-footer transition when that baseline is unavailable.
A fully unreadable target stops retrying and reports unknown.
blocked is not treated as a queued-Enter busy signal, so a Cursor pane that reports blocked in every state does not receive that conversion.

Some harnesses never present a legibly idle native baseline at all, so the composer fallback is their only path.
Some harnesses never present a legibly idle native baseline at all, so these fallback signals are their only path.
Herdr reports a Cursor pane `blocked` in every state, and Cursor's mid-turn composer renders its placeholder beside a right-aligned busy token, which is composer content and therefore `pending` on a composer that holds no user text.
That fallback alone reported every delivered steer as unconfirmed, so it is paired with a rendered-footer transition: the pane's verified busy footer is read once before the first Enter, and an idle-to-busy transition across that Enter confirms the submit.
It is the same semantic signal the native path uses and the same one the tmux submit core reads.
Expand Down Expand Up @@ -338,6 +339,7 @@ tests/fm-backend-herdr-eventwait-smoke.test.sh
tests/fm-herdr-session-cleanup.test.sh
tests/fm-herdr-session-cleanup-e2e.test.sh
tests/fm-afk-inject-herdr-e2e.test.sh
tests/fm-afk-pi-herdr-ack-e2e.test.sh
tests/fm-afk-pi-herdr-return-e2e.test.sh
```

Expand Down
17 changes: 16 additions & 1 deletion docs/verification/runtime-backends.md
Original file line number Diff line number Diff line change
Expand Up @@ -246,8 +246,21 @@ No ambient `herdr server stop` command is a supported test operation.

### Submit confirmation

Measured 2026-08-19 against Herdr 0.8.0 and Claude Code 2.1.236 in an isolated `fm-lab-` session.
A bounded end-to-end run on 2026-08-09 used Pi 0.84.1 and Herdr 0.7.4 protocol 16 in the named `fix-afk-pi-herdr-ack` lab session.
The real Pi pane remained native-idle after receiving the away digest, while the Herdr adapter confirmed the identity-corroborated empty Pi composer and cleared the buffer after one typed digest.
A second real control left a human draft unsubmitted, and the adapter retained the new digest without merging into that draft.

```sh
FM_AFK_PI_HERDR_ACK_E2E=1 tests/fm-afk-pi-herdr-ack-e2e.test.sh
```

```text
ok - real Pi/Herdr idle-native delivery clears the buffer after one typed digest
ok - real Pi/Herdr unsubmitted input preserves the pending buffer
evidence: pi=0.84.1 herdr=0.7.4 protocol=16 successful_send_texts=1
```

A separate measurement on 2026-08-19 used Herdr 0.8.0 and Claude Code 2.1.236 in an isolated `fm-lab-` session.
`herdr agent get` reported `agent_status=idle` on every sample across a landed one-word turn and an 8-second `sleep` tool call, while the pane rendered `Pontificating…` then `Sock-hopping… (11s · ↓ 234 tokens)`.
`fm_backend_herdr_send_text_submit` therefore cannot treat native idle as proof of a swallow.
The portable regressions in `tests/fm-backend-herdr.test.sh` and `tests/fm-composer-lib.test.sh` pin the verdicts: native idle plus a cleared composer is delivery, proven pending plus idle is a swallow, and proven pending plus a generating busy signal is a queued Enter.
Expand All @@ -263,6 +276,8 @@ Observed 2026-08-19:
ok - live Herdr submit confirm: Claude Code (2.1.236 (Claude Code)) on herdr 0.8.0 reports empty for a landed idle steer
```

The focused adapter suite also covers a consumed idle Pi message, a swallowed Pi Enter, native Herdr confirmation, and type-once retry behavior.
Pi and pi-signed retain the identity-corroborated path, while Claude's new general idle-composer fallback and the existing Cursor rendered-footer path remain distinct.
### Prune and respawn

The real label-collision reproduction is owned by:
Expand Down
Loading