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
35 changes: 7 additions & 28 deletions .agents/skills/afk/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,26 +115,14 @@ That doorbell is Firstmate's only when `open` verifies the record in this home,
This is how firstmate tells a daemon escalation apart from a real message in the same pane.
For other harnesses, the operational prefix travels with the message text; neither carrier relies on harness-level typed-vs-injected detection.

### Busy-guard and composer guard
### Injection guards

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

- **Primary-pane busy guard** - `pane_is_busy` trusts Herdr native `busy` when available, otherwise matches rendered output against only the detected primary harness's signature.
This narrow delivery guard never classifies a recorded worker task and never uses a global union of vendor patterns.
- **Composer-state guard** - `inject_msg` reads the full `empty`/`pending`/`pending-unproven`/`unknown` verdict from `fm_backend_composer_state` and injects only when it is affirmatively `empty`.
Every other or future verdict defers, including an unreadable pane, ambiguous geometry, a blank unidentified row, and a bare shell prompt left after the agent exits.
Each adapter contributes only capture and capability facts to the fleet-wide screen classifier in `bin/fm-composer-lib.sh`, which owns every shape and verdict.
It preserves proven idle composers as empty but requires a genuine container around shell glyphs; see `docs/herdr-backend.md` "Composer and injection safety" for the operator contract.
`pane_input_pending` is the tested fail-closed predicate for callers that need to know whether the composer is unsafe: it treats every result except exact `empty` as pending.

A busy primary pane, or any composer verdict other than `empty`, defers the injection; the buffered escalation survives in `state/.subsuper-escalations` and is retried on the next housekeeping tick.
In afk mode the composer guard is belt-and-suspenders (no human is typing), but it protects against the race window between the captain returning and their message landing, a dead shell, and the daemon's own previous injection sitting unsent.
[Composer and injection safety](../../../docs/herdr-backend.md#composer-and-injection-safety) owns the supervisor-pane guards, including Herdr's positive harness-process proof.
A deferred escalation survives in `state/.subsuper-escalations` and is retried on the next housekeeping tick.
The guards protect against the race window between the captain returning and their message landing, a dead shell, and the daemon's own previous injection sitting unsent.

**Max-defer escape (the daemon must never silently wedge).**
If anything stays buffered past `FM_MAX_DEFER_SECS` (default 300), the daemon
attempts one normal flush, which still requires an idle pane and an affirmatively empty composer.
If anything stays buffered past `FM_MAX_DEFER_SECS` (default 300), the daemon attempts one normal flush under the same injection guards.
The alarm is defense in depth rather than a substitute for keeping every genuinely idle supported composer injectable.
If that submit cannot be confirmed, it raises a loud, rate-limited wedge alarm:
an ERROR in the daemon log naming the last delivery failure, a durable
Expand Down Expand Up @@ -200,23 +188,14 @@ The single-line format makes submission unambiguous across harnesses; the carrie
- **Single-line digest** - embedded newlines are collapsed to a literal
separator before injection, so submission is unambiguous regardless of
harness.
- **Busy and composer guards on the supervisor pane** - before injecting, the daemon runs the detected-primary-harness rendered busy guard and reads `fm_backend_composer_state` directly.
Only `empty` permits injection; `pending` protects half-typed or swallowed input, and `unknown` protects unreadable panes and bare dead-shell prompts.
Every other result preserves the buffer for retry, so the daemon never merges its digest into the captain's half-typed line or types it into a shell.
- **Supervisor-pane guards** - see [Injection guards](#injection-guards) and its operator-contract pointer.
- The active backend passes its capture plus declarative styled, cursor, identity, and row capabilities to the shared screen classifier; all structural recognition and verdict logic remains in `bin/fm-composer-lib.sh`.
Styled captures let that owner remove dim/faint and dark-TRUECOLOR ghost or placeholder text while shape detection uses the ANSI-stripped screen, so a dark border is not lost with ghost content.
A ghost-only or idle bordered composer such as claude's `│ > ... │` therefore reads empty without allowing an unbordered shell prompt to do the same.
`FM_COMPOSER_IDLE_RE` overrides the shared idle-placeholder regex, but a match alone never bypasses the classifier's shape-specific position and ANSI de-emphasis safety gates.
`FM_BUSY_REGEX` overrides the rendered delivery guards plus Grok's isolated task-state fallback.
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.
- **Max-defer escape** - the daemon must never silently wedge. If anything stays
buffered past `FM_MAX_DEFER_SECS` (default 300s), the daemon attempts one
normal flush, which still requires an idle pane and an affirmatively empty composer. If that
cannot confirm a submit, it raises a loud, rate-limited wedge alarm: ERROR log,
durable `state/.subsuper-inject-wedged` marker, a tmux status-line flash when
applicable, and a backend-independent active alert. A
composer false-positive surfaces as a visible stall, never an unbounded silent
no-op.
- **Max-defer escape** - see the max-defer policy under [Injection guards](#injection-guards).
- **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 backend submit
Expand Down
57 changes: 56 additions & 1 deletion bin/fm-composer-lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,14 @@
# different, self-proving thing: real claude 2.x draws exactly
# that (`─` rule, `❯`+NBSP, `─` rule), so the glyph inside the
# pair carries the shape and no identity is needed.
# Claude writes a session's TITLE into that pair's top rule
# once the session has one (a resumed or backgrounded
# conversation: `──────── Firstmate operational input ─`,
# captured live through Herdr on claude 2.1.284). A titled rule
# opens a pair only for this self-proving form: the rows down to
# the next solid rule must hold an agent-glyph row, so a titled
# rule over anything else stays the ordinary text it was and can
# never promote a blank region into a composer.
#
# THE COMPOSER FOOTER ZONE (task firstmate-doorbell-vals-pending-p1): a
# harness draws its own furniture BELOW the composer - a user statusLine, a
Expand Down Expand Up @@ -762,6 +770,25 @@ _fm_composer_pi_separator_row() { # <trimmed-row>
return 1
}

# _fm_composer_titled_rule_row: a `─` rule carrying a title - at least 8
# leading `─` columns, one space-padded title holding no `─`, then a closing
# `─` run (see the separated shape in this file's header). Byte-exact literal
# tests only, so the answer is the same in every locale.
_fm_composer_titled_rule_row() { # <trimmed-row>
local row=$1 title
case "$row" in
────────*─) ;;
*) return 1 ;;
esac
title="${row#"${row%%[!─]*}"}"
title="${title%"${title##*[!─]}"}"
case "$title" in
*─*) return 1 ;;
' '*[!\ ]*' ') return 0 ;;
esac
return 1
}

# Row-scan results are returned through FM_COMPOSER_SCAN_* globals (bash 3.2
# has no nameref); they are internal to this owner.
_fm_composer_scan_screen() { # <plain-screen> <cursor-or-empty> [extract-wrap]
Expand Down Expand Up @@ -799,6 +826,7 @@ _fm_composer_scan_screen() { # <plain-screen> <cursor-or-empty> [extract-wrap]
FM_COMPOSER_SCAN_LEFTBAR_GLYPH_ROW=-1
FM_COMPOSER_SCAN_LEFTBAR_GLYPH=
local leftbar_start=-1 pi_open=-1 pi_lines=0 pi_max
local titled_open=-1 titled_lines=0 titled_glyph_row=-1 titled_glyph=''
local probe row_glyph row_glyph_row
local box_glyph_row=-1 box_glyph='' pi_glyph_row=-1 pi_glyph=''
pi_max=$FM_COMPOSER_PI_MAX_LINES
Expand Down Expand Up @@ -844,9 +872,23 @@ _fm_composer_scan_screen() { # <plain-screen> <cursor-or-empty> [extract-wrap]
# Pi separator rows: a solid `─` rule at least 8 columns wide. A separator
# closes the preceding candidate and immediately opens the next, so an
# earlier transcript rule can never outrank the live bottom composer pair.
# A titled rule (claude's top rule once the session has a title) opens a
# pair only when an agent-glyph row proves the composer before the next
# solid rule closes it; unproven, it is ordinary text to the rules below.
if _fm_composer_pi_separator_row "$trimmed"; then
FM_COMPOSER_SCAN_PI_LAST_SEPARATOR=$row
if [ "$pi_open" -ge 0 ]; then
if [ "$titled_open" -ge 0 ] && [ "$titled_glyph_row" -ge 0 ]; then
FM_COMPOSER_SCAN_PI_PAIR_FOUND=1
FM_COMPOSER_SCAN_PI_OPEN=$titled_open
FM_COMPOSER_SCAN_PI_CLOSE=$row
if [ "$titled_lines" -le "$pi_max" ]; then
FM_COMPOSER_SCAN_PI_PAIR_VALID=1
else
FM_COMPOSER_SCAN_PI_PAIR_VALID=0
fi
FM_COMPOSER_SCAN_PI_GLYPH_ROW=$titled_glyph_row
FM_COMPOSER_SCAN_PI_GLYPH=$titled_glyph
elif [ "$pi_open" -ge 0 ]; then
FM_COMPOSER_SCAN_PI_PAIR_FOUND=1
FM_COMPOSER_SCAN_PI_OPEN=$pi_open
FM_COMPOSER_SCAN_PI_CLOSE=$row
Expand All @@ -862,7 +904,20 @@ _fm_composer_scan_screen() { # <plain-screen> <cursor-or-empty> [extract-wrap]
pi_lines=0
pi_glyph_row=-1
pi_glyph=''
titled_open=-1
else
if _fm_composer_titled_rule_row "$trimmed"; then
titled_open=$row
titled_lines=0
titled_glyph_row=-1
titled_glyph=''
elif [ "$titled_open" -ge 0 ]; then
titled_lines=$((titled_lines + 1))
if [ "$titled_glyph_row" -lt 0 ] && [ "$row_glyph_row" -ge 0 ]; then
titled_glyph_row=$row_glyph_row
titled_glyph=$row_glyph
fi
fi
if [ "$pi_open" -ge 0 ]; then
pi_lines=$((pi_lines + 1))
if [ "$pi_glyph_row" -lt 0 ] && [ "$row_glyph_row" -ge 0 ]; then
Expand Down
24 changes: 19 additions & 5 deletions bin/fm-supervise-daemon.sh
Original file line number Diff line number Diff line change
Expand Up @@ -1410,7 +1410,7 @@ window_for_task() { # <task-key> [state]
# line, or a previous injection's unsent text), defer entirely - injecting
# would merge with the human's text.
inject_msg() { # <message> [state]
local msg=$1 state target backend retries sleep_s verdict composer encoded bytes errf err='' body
local msg=$1 state target backend retries sleep_s verdict composer process_state encoded bytes errf err='' body
state="${2:-$(_state_root)}"
# (1) Presence-gate: inject ONLY when afk is active. When afk is off, the
# daemon self-handles and stays quiet; firstmate drives the normal always-on
Expand Down Expand Up @@ -1446,17 +1446,31 @@ inject_msg() { # <message> [state]
# 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
# prior injection) and 'unknown' for an unidentified prompt or an
# unreadable pane. Defer on anything that is not affirmatively 'empty'.
# A shell can share an agent's prompt glyph, so Herdr also needs the
# process proof below. A deferred escalation
# stays buffered for the next cycle or the catch-up flush.
composer=$(fm_backend_composer_state "$backend" "$target" 2>/dev/null)
if [ "$composer" != empty ]; then
INJECT_LAST_FAILURE="deferred: supervisor composer not confirmed-empty (state=${composer:-unknown}: pending input, dead-shell prompt, or unreadable pane)"
log "inject $INJECT_LAST_FAILURE"
return 1
fi
# A rendered Claude glyph can survive over a shell after Claude exits.
# Require the shared process classifier's positive harness verdict, never a
# lingering Herdr registration or merely a non-shell foreground process.
if [ "$backend" = herdr ]; then
process_state=unreadable
if fm_backend_herdr_parse_target "$target"; then
process_state=$(fm_backend_herdr_pane_process_state "$FM_BACKEND_HERDR_SESSION" "$FM_BACKEND_HERDR_PANE" 2>/dev/null)
fi
if [ "$process_state" != agent ]; then
INJECT_LAST_FAILURE="deferred: supervisor harness not confirmed-live (process=${process_state:-unreadable})"
log "inject $INJECT_LAST_FAILURE"
return 1
fi
fi
# c) A primary that strips invisible characters from submitted prompts gets
# the owner's record-backed doorbell instead of the typed envelope, so
# the away-mode return check can still tell this escalation from the
Expand Down
3 changes: 1 addition & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -216,8 +216,7 @@ Pane existence, busy checks, composer checks, capture, and verified submit route
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.
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.
The current operator boundary is in [Composer and injection safety](herdr-backend.md#composer-and-injection-safety).
The supervisor-pane injection guards are owned by [Composer and injection safety](herdr-backend.md#composer-and-injection-safety).
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, the record archive, durable catch-up evidence, the return brief, and the fail-closed gate that keeps ordinary work behind every live firstmate-actionable blocker the away session could not fix.
Expand Down
10 changes: 8 additions & 2 deletions docs/herdr-backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -630,6 +630,7 @@ It hands the visible pane's ANSI viewport plus Herdr's capability facts to the f

- Bordered boxes.
- Bare agent-glyph rows, including muse's `⟩`, which the adapter's retired local pattern silently omitted.
- Claude's titled composer, including resumed or backgrounded conversations.
- opencode's left bar.
- The Pi separator region this adapter pioneered, admitted only when native `agent get` identity is exactly Pi and state is idle or done.

Expand All @@ -651,8 +652,13 @@ That safely defers injection and eventually raises the wedge alarm.

### Away-mode injection

A bare shell prompt is never an empty agent composer.
Away-mode injection proceeds only on an affirmative `empty` result, never on unknown.
Before typing, `inject_msg` in `bin/fm-supervise-daemon.sh` requires the supervisor pane to exist and pass the primary-pane busy guard.
That guard trusts Herdr native `busy` when available, otherwise matches rendered output against only the detected primary harness's signature; it never classifies a recorded worker task.
The composer guard requires the exact `empty` verdict from `fm_backend_composer_state`; every other or future verdict defers.
A shell can display Claude's `❯` glyph, so even an empty-looking composer does not prove an agent is alive.
Herdr additionally requires the exact `agent` verdict from `fm_backend_herdr_pane_process_state`, whose process proof is described under [Restart and liveness behavior](#restart-and-liveness-behavior).
A lingering native registration or a non-shell foreground process alone is insufficient; `shell`, `other`, `unreadable`, and every unrecognized process verdict defer before typing or publishing an operational-input record.
Deferred escalations remain buffered for retry.
This prevents a dead agent pane from receiving and possibly executing an escalation as shell input.

### Operational input markers
Expand Down
18 changes: 18 additions & 0 deletions docs/verification/runtime-backends.md
Original file line number Diff line number Diff line change
Expand Up @@ -724,6 +724,24 @@ Cursor is deliberately outside this cursor-anchored empty-composer matrix becaus

`zellij action dump-screen --pane-id <id> --ansi` was verified at zellij 0.44.0 to preserve ANSI styling (real Claude Code rendered inside a zellij pane dumped `ESC[m` `❯` U+00A0 for its idle composer row), which is the capability the zellij composer classifier reads.

### 2026-10-02 claude 2.1.284 titled composer rule through Herdr

Verified on 2026-10-02 on macOS arm64 (Darwin 25.6.0) against Claude Code 2.1.284 in an isolated `fm-lab-` session on Herdr 0.9.1, read through Herdr's ANSI viewport capture with its exact capability descriptor (`styled=1`, `cursor=0`, `identity=1`).
Once a Claude session carries a title, which a resumed or backgrounded conversation does, Claude writes that title into the composer's top rule: `──────── Firstmate operational input ─`, then the bare `❯` + U+00A0 row, then a solid `─` closing rule.
The titled rule is not a solid separator, so no pair formed, the closing rule read as an unpaired separator below the `❯` row, and the idle composer classified `unknown`.
A lab primary's away daemon reproduced the production log line for that screen on every tick:

```text
inject deferred: supervisor composer not confirmed-empty (state=unknown: pending input, dead-shell prompt, or unreadable pane)
```

Pressing left opens Claude's agents view and moves the conversation to the background; Escape returns to it, and from then on the top rule carries the title.
The classifier now lets a titled rule open a pair only when an agent-glyph row sits between it and the next solid rule.
On the same live pane the unmodified library answered `unknown` and the fixed library answered `empty`, one escalation was then typed and submitted, a typed draft in the titled composer answered `pending`, and Claude's agents view (`❯ describe a task for a new session`) answered `pending` under this home's `dark-ansi` theme.

`test_matrix_claude_titled_top_rule` in `tests/fm-composer-lib.test.sh` carries the captured rows and pins the three refusals the fix must keep: a typed draft, a dead shell prompt under or below a titled rule, and an unreadable or blank region.
For a shell that displays an agent glyph, `test_inject_msg_defers_on_shell_with_agent_glyph` and `test_inject_msg_herdr_requires_positive_process_proof` in `tests/fm-daemon.test.sh` pin the separate [injection safety boundary](../herdr-backend.md#away-mode-injection).

### 2026-09-20 claude 2.1.236 statusLine footer through Herdr

Verified on 2026-09-20 on macOS arm64 (Darwin 25.6.0) against Claude Code 2.1.236 running as Firstmate workers in Herdr 0.8.0 panes, read through Herdr's ANSI capture with its exact capability descriptor (`styled=1`, `cursor=0`, `identity=1`, `rows=20`).
Expand Down
Loading
Loading