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
53 changes: 34 additions & 19 deletions .agents/skills/afk/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,17 +79,20 @@ opencode, pi, and grok).
## Busy-guard and composer guard

The daemon never injects into an in-use pane. Two checks run before every
injection (shared with `fm-send.sh` via `bin/fm-tmux-lib.sh`):
injection, dispatched through `bin/fm-backend.sh` for the supervisor's own
backend (tmux or herdr; see "Auto-discovered supervisor pane" below):

- **`pane_is_busy`** - the harness shows a busy footer (agent mid-turn).
- **`pane_input_pending`** - the cursor line holds real unsubmitted text (a
- **`pane_is_busy`** - the harness shows a busy footer (agent mid-turn) on tmux (shared with `fm-send.sh` via `bin/fm-tmux-lib.sh`); on herdr, tries the native `agent.get`-backed busy state first, trusts only `busy` outright, and corroborates every non-`busy` verdict with the same regex-over-capture reader.
- **`pane_input_pending`** - the composer holds real unsubmitted text (a
human's half-typed line, or a previous injection whose Enter was swallowed).
The detector **strips the harness's composer box borders first**, so an idle
*bordered* composer (claude draws `│ > … │`) is correctly read as empty, not
pending. Without this, every idle claude pane looked like pending input and
the daemon deferred 100% of escalations (incident afk-invx-i5).
`FM_COMPOSER_IDLE_RE` still overrides empty-composer matching after border
stripping.
On tmux, the cursor-line detector **strips the harness's composer box
borders first**, so an idle *bordered* composer (claude draws `│ > … │`) is
correctly read as empty, not pending. Without this, every idle claude pane
looked like pending input and the daemon deferred 100% of escalations
(incident afk-invx-i5). `FM_COMPOSER_IDLE_RE` still overrides empty-composer
matching after border stripping. On herdr, the equivalent structural
border-row classifier (`fm_backend_herdr_composer_state`,
docs/herdr-backend.md) plays the same role.

Either condition defers the injection; the buffered escalation survives in
`state/.subsuper-escalations` and is retried on the next housekeeping tick. In
Expand All @@ -108,7 +111,8 @@ So a guard false-positive becomes a visible stall, never an unbounded silent no-

## Submit model

The digest is typed **once** via `send-keys -l`, then submitted with Enter and
The digest is typed **once** (`send-keys -l` on tmux, `pane send-text` on
herdr - both literal, non-submitting sends), then submitted with Enter and
**verified**: Enter is retried (Enter only, never a retype) until the composer
clears.
A submit "landed" only when the composer is confirmed empty afterward, using
Expand Down Expand Up @@ -181,11 +185,12 @@ the marker lets firstmate distinguish it from a real captain message.
durable `state/.subsuper-inject-wedged` marker, and a status-line flash. A
composer false-positive surfaces as a visible stall, never an unbounded silent
no-op.
- **Verified type-once submit model** - the digest is typed once via
`send-keys -l`, then submitted with Enter and verified. Enter is retried,
Enter only and never a retype, until the composer is confirmed empty. That
empty composer is the acknowledgement that the submit landed, using the same
dim-ghost-aware and border-aware detector so a ghost-only or bordered-empty
- **Verified type-once submit model** - the digest is typed once (`send-keys -l`
on tmux, `pane send-text` on herdr), then submitted with Enter and verified.
Enter is retried, Enter only and never a retype, until the composer is
confirmed empty. That empty composer is the acknowledgement that the submit
landed, using the same dim-ghost-aware and border-aware detector (tmux) or
structural border-row classifier (herdr) so a ghost-only or bordered-empty
claude composer counts as submitted rather than a false swallowed Enter.
- **Marker strip** - `strip_injection_marker` removes the sentinel prefix before
classification or relay, so the digest text firstmate sees is clean.
Expand All @@ -194,10 +199,20 @@ the marker lets firstmate distinguish it from a real captain message.
- **Dedupe across signal/stale/scan** - `classify_signal` and `classify_stale`
both check the seen-status marker before escalating, so a status escalated by
one path is not re-escalated by another in the same digest.
- **Auto-discovered supervisor pane** - the daemon resolves its injection target
from `FM_SUPERVISOR_TARGET`, then `$TMUX_PANE`, then a `firstmate:0` fallback
with a warning. The resolution source is logged at startup so a
wrong-but-resolving fallback is detectable.
- **Auto-discovered supervisor pane** - the daemon resolves its own BACKEND
(tmux vs herdr) and TARGET independently, mirroring
`bin/fm-backend.sh`'s own runtime auto-detection. Backend: `FM_SUPERVISOR_BACKEND`
override, then `$TMUX_PANE` set (tmux), then `$HERDR_ENV=1` with
`$HERDR_PANE_ID` present (herdr), then a tmux fallback. Target:
`FM_SUPERVISOR_TARGET` override (a tmux target or a herdr
`"<session>:<pane-id>"` target), then `$TMUX_PANE`, then
`"${HERDR_SESSION:-default}:${HERDR_PANE_ID}"` under herdr, then a
`firstmate:0` fallback with a warning. Both resolution sources are logged at
startup so a wrong-but-resolving fallback is detectable. Other runtime
backends, including zellij, orca, and cmux, are not yet supported as
supervisor backends; the daemon refuses loudly at startup instead of
misapplying tmux primitives to a pane that isn't one
(docs/herdr-backend.md "Away-mode daemon: herdr supervisor-pane support").

## Reliability properties

Expand Down
1 change: 1 addition & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ tests/fm-send-secondmate-marker.test.sh # fm-send from-firstmate marker for ki
tests/fm-wake-daemon-lifecycle-e2e.test.sh # watcher + daemon lifecycle e2e: restart catch-up, batching, dedupe, stale-pane routing, and digest injection
tests/fm-composer-ghost.test.sh # dim-ghost stripping, ghost-only composer detection, and escape-free peek tests
tests/fm-afk-inject-e2e.test.sh # private-socket end-to-end test of the afk injection path (partial-input deferral, swallowed-Enter retry)
tests/fm-afk-inject-herdr-e2e.test.sh # real-herdr end-to-end test of the afk daemon's herdr transport, on an isolated throwaway HERDR_SESSION: partial-input deferral, swallowed-Enter retry, a normal digest, and the max-defer wedge alarm on a persistently pending composer
tests/fm-bootstrap.test.sh # bootstrap dependency, feature-probe, and crew-dispatch reporting tests
tests/fm-session-start.test.sh # fm-session-start.sh: ABSENT vs empty-vs-present digest files, lock-refusal read-only path skipping every mutating step, diagnostics-first section ordering, status-tail bounding, tmux/herdr endpoint liveness, and composition of the real fm-lock/fm-bootstrap/fm-wake-drain scripts
tests/fm-grok-harness.test.sh # grok adapter spawn hook, token guard, teardown cleanup, and session-lock detection tests
Expand Down
35 changes: 34 additions & 1 deletion bin/fm-backend.sh
Original file line number Diff line number Diff line change
Expand Up @@ -417,6 +417,31 @@ fm_backend_busy_state() { # <backend> <target>
esac
}

# fm_backend_composer_state: classify the composer/input row of <target> as
# empty|pending|unknown - the SUBMIT-side classifier each adapter already uses
# internally to verify fm_backend_send_text_submit, exposed generically so a
# caller other than the send path (the away-mode daemon's supervisor-pane
# pending-input guard, bin/fm-supervise-daemon.sh) can ask the same question
# without duplicating per-backend composer-reading logic. tmux and herdr both
# expose a named classifier already (fm_tmux_composer_state,
# fm_backend_herdr_composer_state), as do orca and cmux
# (fm_backend_orca_composer_state, fm_backend_cmux_composer_state); zellij's
# submit path uses an internal content-diff approach with no separately named
# classifier, so it reports unknown here - callers fall back to their own
# policy, exactly as an unknown fm_backend_busy_state already does.
fm_backend_composer_state() { # <backend> <target> -> empty|pending|unknown
local backend=$1
shift
fm_backend_source "$backend" || { printf 'unknown'; return 0; }
case "$backend" in
tmux) fm_tmux_composer_state "$@" ;;
herdr) fm_backend_herdr_composer_state "$@" ;;
orca) fm_backend_orca_composer_state "$@" ;;
cmux) fm_backend_cmux_composer_state "$@" ;;
*) printf 'unknown' ;;
esac
}

# fm_backend_target_exists: cheap, READ-ONLY existence check - does the
# recorded TARGET endpoint still exist on BACKEND? Never starts a server or
# session: for herdr this deliberately queries the pane directly instead of
Expand All @@ -440,7 +465,15 @@ fm_backend_target_exists() { # <backend> <target> [expected-label]
session=${target%%:*}
pane=${target#*:}
[ -n "$session" ] && [ -n "$pane" ] && [ "$pane" != "$target" ] || return 1
HERDR_SESSION="$session" herdr pane get "$pane" >/dev/null 2>&1
# fm_backend_herdr_cli (not a raw HERDR_SESSION-only call): verified
# empirically (docs/herdr-backend.md "Session targeting") that the bare
# env var alone is NOT reliably honored once another herdr server is
# already bound on the machine - it silently queries whatever server IS
# running instead. fm_backend_herdr_cli appends the required --session
# flag on top, so this check is correctly scoped even when the caller's
# own ambient session (e.g. the primary firstmate's default session) is
# a DIFFERENT one than the target's.
fm_backend_herdr_cli "$session" pane get "$pane" >/dev/null 2>&1
;;
zellij)
fm_backend_source zellij || return 1
Expand Down
Loading