Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions bin/backends/herdr.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2876,6 +2876,18 @@ EOF
fm_composer_classify_content "$bordered" "$stripped" "$FM_BACKEND_HERDR_IDLE_RE"
}

# fm_backend_herdr_admit_away_supervisor: atomically admit and submit one
# away-supervisor message, or refuse without changing the composer.
# Herdr exposes no verified conditional composer admission or reservation API.
# This fallback therefore performs no pane read, text send, or key send and
# returns atomic-unavailable. A future implementation may return admitted only
# after one Herdr-supported operation atomically proves an empty composer and
# submits <text>; a re-read or process-local lock is not an admission primitive.
fm_backend_herdr_admit_away_supervisor() { # <target> <text> -> admitted|atomic-unavailable|unknown
printf 'atomic-unavailable'
}




# fm_backend_herdr_send_text_submit: type <text> into <target> once (raw,
Expand Down
93 changes: 50 additions & 43 deletions bin/fm-supervise-daemon.sh
Original file line number Diff line number Diff line change
Expand Up @@ -91,8 +91,9 @@
# FM_SUPERVISOR_HARNESS exact supervisor harness identity. The
# detached launcher passes this explicitly;
# otherwise startup derives it with
# bin/fm-harness.sh. Herdr injection refuses
# when the identity is unknown.
# bin/fm-harness.sh. Herdr defers typed
# injection when its API cannot prove atomic
# composer admission.
# FM_INJECT_SKIP |-prefixes force-self-handle bypassing
# classification (default "heartbeat"); empty
# disables. Use sparingly: it overrides the
Expand Down Expand Up @@ -138,16 +139,17 @@
# its watchdog terminates it and continues to the
# next channel (default 10; invalid/zero uses the
# default).
# FM_INJECT_CONFIRM_RETRIES Enter-retry attempts on a swallowed Enter
# (default 3); the digest is typed once, only
# Enter is retried. Composer-empty detection is
# structural and style-aware (bin/fm-tmux-lib.sh):
# it drops dim/faint ghost text and strips the
# FM_INJECT_CONFIRM_RETRIES tmux Enter-retry attempts on a swallowed
# Enter (default 3); the digest is typed once,
# only Enter is retried. Herdr uses its own
# atomic-admission contract and never treats
# this retry budget as permission to type.
# Composer-empty detection is structural and
# style-aware (bin/fm-tmux-lib.sh): it drops
# dim/faint ghost text and strips the
# harness's box borders before deciding, so a
# ghost-only or bordered-but-empty composer is
# not misread as pending input.
# FM_INJECT_CONFIRM_SLEEP seconds between daemon submit checks
# (default 0.5)
# FM_LOG_MAX_BYTES / FM_LOG_KEEP_LINES / FM_CRASH_* log + crash guards
# FM_STATE_OVERRIDE alternate state dir (testing)
# Logs each wake to state/.supervise-daemon.log (size-capped). Single
Expand Down Expand Up @@ -1374,25 +1376,20 @@ window_for_task() { # <task-key> [state]
}

# --- injection --------------------------------------------------------------
# inject_msg: send one escalation digest to the supervisor pane.
# Returns 0 on successful inject (or empty buffer), non-zero if the pane is
# gone, the supervisor is busy, afk is inactive, or the verified submit cannot
# be confirmed after bounded retries. On non-zero the caller preserves
# the buffer so the escalation survives for the next cycle or the catch-up flush.
# inject_msg: admit one escalation digest to the supervisor pane.
# Returns 0 on successful admission (or empty buffer), non-zero if the pane is
# gone, the supervisor is busy, afk is inactive, or the backend cannot safely
# admit the digest. On non-zero the caller preserves the buffer so the
# escalation survives for the next cycle or the catch-up flush.
#
# Submit model:
# - TYPE ONCE, then submit with Enter. Never retype the digest: a swallowed
# Enter leaves our text in the composer, and retyping would concatenate two
# sentinel-prefixed digests into one corrupted turn.
# - SUBMIT ACK = the backend submit primitive reports `empty` after Enter.
# For tmux that means a cleared composer; for herdr's normal idle-baseline
# path it means native agent-state observed a real turn start.
# Pending means Enter was swallowed; unknown is treated as undelivered by
# this strict daemon path.
# - COMPOSER GUARD before typing: if the cursor line already has real content
# after dim/faint ghost text and borders are ignored (a human's half-typed
# line, or a previous injection's unsent text), defer entirely - injecting
# would merge with the human's text.
# Admission model:
# - The shared composer guard rejects every state except affirmative empty.
# It is an early conservative rejection, not a reservation of the human
# input channel.
# - tmux types once, then retries only Enter after a bounded confirmation.
# - Herdr delegates the complete empty-composer admission decision to
# fm_backend_herdr_admit_away_supervisor. Its current fallback refuses
# typed admission because Herdr has no verified conditional operation.
inject_msg() { # <message> [state]
local msg=$1 state target backend harness retries sleep_s verdict composer encoded omp_bun omp_bin identity
state="${2:-$(_state_root)}"
Expand Down Expand Up @@ -1420,15 +1417,15 @@ inject_msg() { # <message> [state]
log "inject deferred: supervisor pane busy (agent mid-turn)"
return 1
fi
# 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'
# for real unsubmitted text (a human's half-typed line, or a swallowed
# prior injection) and 'unknown' for a bare dead-shell prompt (the agent
# exited to its login shell) or an unreadable pane. Neither is a safe
# 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.
# b) Composer-guard: reject any composer that is not a confirmed-empty
# GENUINE agent composer. The shared classifier
# (fm_backend_composer_state -> fm_composer_classify_content,
# bin/fm-composer-lib.sh) reports 'pending' for real unsubmitted text (a
# human's half-typed line, or a swallowed prior injection) and 'unknown'
# for a bare dead-shell prompt or an unreadable pane. Neither is safe.
# This guard remains an early conservative rejection only. For Herdr,
# `empty` never reserves the composer or authorizes a later typed send;
# its backend admission operation owns that decision.
identity=$(supervisor_omp_identity "$state")
IFS=$'\t' read -r omp_bun omp_bin <<EOF
$identity
Expand All @@ -1438,13 +1435,10 @@ EOF
log "inject deferred: supervisor composer not confirmed-empty (state=${composer:-unknown}: pending input, dead-shell prompt, or unreadable pane)"
return 1
fi
# (4) Type the digest ONCE, then submit with Enter (retry Enter only, never
# retype) via the shared submit primitive. Success = the backend confirms
# submit. An unconfirmed/unknown pane does NOT count as delivered, so the
# buffer is preserved (strict) rather than cleared.
# Dispatches through fm_backend_send_text_submit (bin/fm-backend.sh): for
# backend=tmux this calls fm_backend_tmux_send_text_submit, a verbatim
# re-export of fm_tmux_submit_core - byte-identical to calling it directly.
# (4) Herdr delegates admission to its backend owner below. tmux types the
# digest once, then submits via the shared primitive with Enter-only retries.
# Either backend must provide its own positive delivery proof before this
# function clears the durable escalation buffer.
harness=${FM_SUPERVISOR_HARNESS:-}
case "$harness" in
claude|codex|opencode|pi|pi-signed|omp|grok|kimi) ;;
Expand All @@ -1456,6 +1450,19 @@ EOF
harness=
;;
esac
if [ "$backend" = herdr ]; then
verdict=$(fm_backend_herdr_admit_away_supervisor "$target" "$msg")
case "$verdict" in
admitted) return 0 ;;
atomic-unavailable)
log "inject deferred: Herdr API has no verified atomic composer admission; no text typed"
;;
*)
log "inject deferred: Herdr atomic composer admission not proven (verdict=${verdict:-unknown}); no text typed"
;;
esac
return 1
fi
retries=${FM_INJECT_CONFIRM_RETRIES:-$INJECT_CONFIRM_RETRIES_DEFAULT}
sleep_s=${FM_INJECT_CONFIRM_SLEEP:-$INJECT_CONFIRM_SLEEP_DEFAULT}
verdict=$(fm_backend_send_text_submit "$backend" "$target" "$msg" "$retries" "$sleep_s" "$sleep_s" "" "$harness" "$omp_bun" "$omp_bin")
Expand Down
11 changes: 8 additions & 3 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,12 +85,17 @@ 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 AFK-specific stale and captain-held recovery contract, including local liveness gates, remote owner rechecks, and duplicate-notification suppression, is owned by [the AFK skill](../.agents/skills/afk/SKILL.md#classification-policy).
The daemon escalates captain-relevant events, plus bounded rechecks for a declared pause or a stale remote-owner result including unresolved remote captain-held recovery, 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 prefers native agent-state confirmation and falls back to its ANSI-aware structural composer classifier.
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`.
The shared composer result is an early conservative rejection, not a lease on human input.
Tmux retains its established type-once, Enter-only retry path.
Herdr away delivery calls only `fm_backend_herdr_admit_away_supervisor`, whose `admitted` result requires one conditional operation to prove an empty composer and submit the digest from the same composer-channel boundary.
Herdr exposes no verified conditional admission or reservation primitive today, so that owner returns `atomic-unavailable` without reading, typing, or sending a key, and the daemon preserves the buffered recovery episode.
A second composer read or a process-local lock cannot make the independent Herdr `pane send-text` and `pane send-keys` operations atomic and is not a fallback.
The retries-exhausted queued-Enter decision is owned by `fm_composer_queued_enter_verdict` in `bin/fm-composer-lib.sh`; tmux supplies its established non-OMP busy signal, while herdr invokes the policy only for OMP.
OMP's busy-submit decision remains backend-specific and fails closed outside its narrow queued verdict; the [tmux](tmux-backend.md#current-behavior-and-safety) and [Herdr](herdr-backend.md#current-transport-behavior) guides own the current contracts.
Composer-content classification has one shared owner, `bin/fm-composer-lib.sh`, used by tmux, herdr, Orca, and cmux after each adapter performs its own capture and composer-row recognition.
The daemon injects only into an affirmatively `empty` composer, so both `pending` and `unknown` defer and a bare dead-shell prompt cannot receive an escalation; the current boundary is in [Composer and injection safety](herdr-backend.md#composer-and-injection-safety).
The daemon rejects every pending or unknown composer state; [Herdr's composer and injection safety](herdr-backend.md#composer-and-injection-safety) adds the current atomic-admission deferral.
Unsupported supervisor backends refuse at daemon startup.
Stalled escalation delivery writes `state/.subsuper-inject-wedged` and attempts a configured backend-independent active alert after `FM_MAX_DEFER_SECS` instead of silently deferring forever.
On an unmarked return, `bin/fm-afk-return.sh` owns ordered shutdown, durable catch-up evidence, and the fail-closed gate that keeps ordinary work behind every live firstmate-actionable blocker.
Expand Down
4 changes: 4 additions & 0 deletions docs/herdr-backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,10 @@ A bare shell prompt is never an empty agent composer.
Away-mode injection proceeds only on an affirmative `empty` result, never on unknown.
This prevents a dead agent pane from receiving and possibly executing an escalation as shell input.

Away-mode supervisor delivery has a stricter admission boundary than ordinary typed steering.
The maintainer contract in [`docs/architecture.md`](architecture.md#event-driven-supervision) owns the atomic admission decision.
Current Herdr releases expose no verified conditional composer admission or reservation primitive, so an otherwise empty composer defers the supervisor digest without changing the captain's input.

The current operational envelope starts with U+2063 and `FIRSTMATE_OP: `.
The separate routed-request carrier uses `[fm-from-firstmate]` plus U+2063.
U+2063 survives Herdr terminal input as text, unlike the legacy ASCII control separator that could erase the visible routing label.
Expand Down
20 changes: 14 additions & 6 deletions docs/verification/runtime-backends.md
Original file line number Diff line number Diff line change
Expand Up @@ -959,17 +959,25 @@ ok - real herdr: the watcher fast-path enqueues a stale wake naming the task win

Polling remained active and is covered as the fallback for capability, connect, subscribe, and repeated reader failure.

### Away-mode transport
### Away-mode admission

The Pi/Herdr return and injection path was reverified on Herdr 0.7.3 and Pi 0.80.7:
The guarded runtime regression ran on 2026-08-31 in one named non-default Herdr lab.

```sh
FM_AFK_PI_HERDR_E2E=1 HERDR_LAB_HELPER=bin/fm-herdr-lab.sh \
tests/fm-afk-pi-herdr-return-e2e.test.sh
HERDR_LAB_HELPER=/home/pranay/wd/firstmate/bin/fm-herdr-lab.sh \
tests/fm-afk-inject-herdr-e2e.test.sh
```

Observed guarantees: pending composer input refused injection and raised one alert; idle Pi accepted one marked escalation; the return gate refused ordinary work while a live blocker remained; resolving the blocker allowed the return flow.
The dedicated Herdr daemon workspace topology is covered by `tests/fm-afk-launch.test.sh` and preserves the captain tab's pane count.
```text
ok - real herdr Scenario A: pending input defers, and a later empty composer still preserves the digest without typing
ok - real herdr Scenario B: an idle composer has no typed supervisor injection without atomic admission
ok - real herdr Scenario D: a persistently pending composer raises the max-defer wedge alarm, preserves the buffer, and never crashes the daemon
all real-herdr afk injection e2e tests passed
```

The default-session tripwire held through the lab teardown.
The deterministic daemon regression in `tests/fm-daemon.test.sh` places captain input after the initial empty read and before the Herdr admission call, then proves no submit occurred, the durable escalation remained, and the deferral log named the unavailable atomic admission.
`FM_AFK_PI_HERDR_E2E=1 HERDR_LAB_HELPER=bin/fm-herdr-lab.sh tests/fm-afk-pi-herdr-return-e2e.test.sh` extends the same no-typed-admission guarantee through the real Pi return flow when Pi is installed.

## OMP applicability outside tmux and Herdr

Expand Down
Loading
Loading