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
75 changes: 45 additions & 30 deletions bin/backends/herdr.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3093,8 +3093,10 @@ fm_backend_herdr_send_key() { # <target> <key>
# is smaller than the pane's current viewport height (observed threshold ~23
# rows for a default-sized pane), instead of clamping to the last N lines - it
# does not merely ignore the bound, it drops the read entirely. This silently
# broke exactly the small bounded reads this adapter relies on most (including
# the composer-state guard/fallback reads around submit and injection). Workaround:
# broke exactly the small bounded reads this adapter relies on most (the peek
# and watch tails, the rendered busy-footer read, and the shared inbox
# pending-line read; the adapter's own composer reads now take the viewport
# instead, so they need no line count at all). Workaround:
# always request a generous fetch far above any realistic viewport height, then
# trim to the caller's requested bound ourselves with `tail`.
fm_backend_herdr_capture() { # <target> <lines>
Expand All @@ -3116,21 +3118,17 @@ fm_backend_herdr_visible_capture() { # <target>
fm_backend_herdr_cli "$FM_BACKEND_HERDR_SESSION" pane read "$FM_BACKEND_HERDR_PANE" --source visible 2>/dev/null
}

fm_backend_herdr_capture_ansi() { # <target> <lines>
fm_backend_herdr_visible_capture_ansi() { # <target>
fm_backend_herdr_target_ready "$1" || return 1
local lines=${2:-200} fetch out
case "$lines" in ''|*[!0-9]*) lines=200 ;; esac
fetch=$lines
case "$fetch" in ''|*[!0-9]*) fetch=200 ;; *) [ "$fetch" -ge 200 ] || fetch=200 ;; esac
out=$(fm_backend_herdr_cli "$FM_BACKEND_HERDR_SESSION" pane read "$FM_BACKEND_HERDR_PANE" --source recent --lines "$fetch" --format ansi 2>/dev/null) || return 1
printf '%s' "$out" | tail -n "$lines"
fm_backend_herdr_cli "$FM_BACKEND_HERDR_SESSION" pane read "$FM_BACKEND_HERDR_PANE" --source visible --format ansi 2>/dev/null
}

# --- herdr composer capture and capability primitives -----------------------
#
# These functions are the ONLY herdr-specific composer knowledge left: the
# ANSI pane capture (with its small-N workaround), the native `agent get`
# identity probe, and the capability descriptor. Every shape - the bordered
# ANSI viewport capture (`--source visible`, which needs no line count and so
# no small-N workaround), the native `agent get` identity probe, and the
# capability descriptor. Every shape - the bordered
# box, the bare agent-glyph row, opencode's left-bar, and pi's
# identity-gated separated pair (which this adapter pioneered) - now lives in
# the shared owner (bin/fm-composer-lib.sh, fm_composer_classify_screen), so
Expand Down Expand Up @@ -3160,13 +3158,22 @@ fm_backend_herdr_composer_identity() { # <target> -> "<agent>\t<status>"
# only when the classifier reports the verdict depends on it (a pi separator
# pair below every other candidate), preserving this adapter's original
# consult-only-when-needed behavior.
# The capture is the FULL VISIBLE VIEWPORT, never a bounded tail: an overlay
# a harness renders between the composer and the pane bottom - Claude Code's
# slash-command popup is the verified shape (2.1.283, ~19 menu rows) - pushes
# the composer above a tail window, and the bounded read then reports the
# composer as empty while it actually holds typed text. That blindness broke
# fm-control exit (the typed /exit was judged unsent and cleared) and would
# equally defeat this state read's pre-submit concat guard. The composer is
# by definition inside the viewport, and `--source visible` needs none of the
# small-N --lines workaround.
fm_backend_herdr_composer_state() { # <target> -> empty|pending|pending-unproven|unknown
local target=$1 cap caps verdict identity
fm_backend_herdr_parse_target "$target" || { printf 'unknown'; return 0; }
if cap=$(fm_backend_herdr_capture_ansi "$target" "$FM_COMPOSER_CAPTURE_LINES" 2>/dev/null); then
caps=$(printf 'styled=1\ncursor=0\nidentity=1\nrows=%s' "$FM_COMPOSER_CAPTURE_LINES")
elif cap=$(fm_backend_herdr_capture "$target" "$FM_COMPOSER_CAPTURE_LINES"); then
caps=$(printf 'styled=0\ncursor=0\nidentity=1\nrows=%s' "$FM_COMPOSER_CAPTURE_LINES")
if cap=$(fm_backend_herdr_visible_capture_ansi "$target" 2>/dev/null); then
caps=$(printf 'styled=1\ncursor=0\nidentity=1')
elif cap=$(fm_backend_herdr_visible_capture "$target"); then
caps=$(printf 'styled=0\ncursor=0\nidentity=1')
else
printf 'unknown'
return 0
Expand Down Expand Up @@ -3305,10 +3312,12 @@ fm_backend_herdr_queued_enter_busy() { # <target> <allow-rendered>
fi
}

# fm_backend_herdr_proof_lines: how many tail rows the pre-Enter payload proof
# captures. A literal payload wraps, and a tail-only capture of a complete
# wrap would look like the truncation this proof exists to refuse. The bound
# stays inside the selected composer extraction; it is not a whole-pane search.
# fm_backend_herdr_proof_lines: how many composer rows a refused leftover may
# occupy, bounding the Ctrl+U presses a verified clear may need. A literal
# payload wraps, and clearing a multi-row leftover is one press per rendered
# row (live Claude deletes one wrapped row per press). The composer read
# itself is the full visible viewport (fm_backend_herdr_composer_content), so
# this bound no longer sizes a capture.
fm_backend_herdr_proof_lines() { # <text>
local text=$1 lines
lines=$(( (${#text} / 40) + 8 ))
Expand All @@ -3322,14 +3331,20 @@ fm_backend_herdr_proof_lines() { # <text>
}

# fm_backend_herdr_composer_content: the selected composer's visible text.
# The capture is the FULL VISIBLE VIEWPORT, never a bounded tail: an overlay
# rendered between the composer and the pane bottom - Claude Code's
# slash-command popup is the verified shape (2.1.283) - pushes the composer
# above a tail window, so the pre-Enter payload proof would read empty, judge
# the typed command unsent, and clear it (the fm-control exit breakage). The
# viewport is the one bound that always contains the composer.
# Styled capture is preferred. An empty or failed styled read falls through to
# the plain capture so a missing ANSI format does not look like an empty draft.
fm_backend_herdr_composer_content() { # <target> [lines]
local target=$1 lines=${2:-$FM_COMPOSER_CAPTURE_LINES} cap caps
if cap=$(fm_backend_herdr_capture_ansi "$target" "$lines" 2>/dev/null) && [ -n "$cap" ]; then
caps=$(printf 'styled=1\ncursor=0\nidentity=0\nrows=%s' "$lines")
elif cap=$(fm_backend_herdr_capture "$target" "$lines") && [ -n "$cap" ]; then
caps=$(printf 'styled=0\ncursor=0\nidentity=0\nrows=%s' "$lines")
fm_backend_herdr_composer_content() { # <target>
local target=$1 cap caps
if cap=$(fm_backend_herdr_visible_capture_ansi "$target" 2>/dev/null) && [ -n "$cap" ]; then
caps=$(printf 'styled=1\ncursor=0\nidentity=0')
elif cap=$(fm_backend_herdr_visible_capture "$target") && [ -n "$cap" ]; then
caps=$(printf 'styled=0\ncursor=0\nidentity=0')
else
return 1
fi
Expand Down Expand Up @@ -3370,7 +3385,8 @@ fm_backend_herdr_composer_payload_shown() { # <text> <after>
# as delete-to-line-start, repeated across lines of a multiline draft; Ctrl+C
# is not used because it interrupts a running turn. Live Claude deletes one
# wrapped screen row per press, so a single-line leftover can need several
# presses. The press count is bounded by the rows the proof capture covers.
# presses. The press count comes from fm_backend_herdr_proof_lines, which
# sizes it from the payload length, not from the viewport read.
# 0 only when the composer is verified empty again.
fm_backend_herdr_composer_clear() { # <target> <text>
local target=$1 text=$2 presses i=0
Expand All @@ -3385,7 +3401,7 @@ fm_backend_herdr_composer_clear() { # <target> <text>

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 identity proof=0 proof_lines content
local raw_status footer_baseline='' allow_rendered=0 enter_sent=0 identity proof=0 content
fm_backend_herdr_parse_target "$target" || { printf 'unknown'; return 0; }
# Claude on Herdr is the live-verified truncation shape: Enter is withheld
# unless the composer, empty before the send, shows this payload. A suffix
Expand All @@ -3394,15 +3410,14 @@ fm_backend_herdr_send_text_submit() { # <target> <text> <retries> <enter-sleep>
identity=$(fm_backend_herdr_agent_identity_raw "$FM_BACKEND_HERDR_SESSION" "$FM_BACKEND_HERDR_PANE") || identity=
if [ "${identity%%$'\t'*}" = claude ]; then
proof=1
proof_lines=$(fm_backend_herdr_proof_lines "$text")
content=$(fm_backend_herdr_composer_content "$target" "$proof_lines") \
content=$(fm_backend_herdr_composer_content "$target") \
|| { printf 'send-failed'; return 0; }
[ -z "${content//[$' \t\r\n\v\f']/}" ] || { printf 'send-failed'; return 0; }
fi
fm_backend_herdr_send_literal "$target" "$text" || { printf 'send-failed'; return 0; }
sleep "$settle"
if [ "$proof" = 1 ]; then
if ! content=$(fm_backend_herdr_composer_content "$target" "$proof_lines") \
if ! content=$(fm_backend_herdr_composer_content "$target") \
|| ! fm_backend_herdr_composer_payload_shown "$text" "$content"; then
if fm_backend_herdr_composer_clear "$target" "$text"; then
printf 'send-failed'
Expand Down
11 changes: 6 additions & 5 deletions bin/fm-composer-lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -546,11 +546,12 @@ fm_composer_strip_braille() {
'
}

# The bounded row window adapters should capture for a composer read. One
# shared policy (previously three per-backend variables that had drifted to
# 20/20/200): the composer is bottom-anchored, so a small tail window is
# sufficient and keeps stale scrollback (startup banners, old transcript
# boxes) from ever competing with the live composer.
# The bounded row window for adapters that use tail-capture composer reads and
# for the shared inbox confirmation read. One shared policy (previously three
# per-backend variables that had drifted to 20/20/200) keeps stale scrollback
# (startup banners, old transcript boxes) out of those candidate sets. tmux
# and Herdr adapter composer reads use their visible viewports instead; Herdr
# also uses this value as the minimum Ctrl+U clear budget after a refused proof.
FM_COMPOSER_CAPTURE_LINES=${FM_COMPOSER_CAPTURE_LINES:-20}

# Pi allows a multi-line composer between its horizontal separators. Bound the
Expand Down
2 changes: 1 addition & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -2354,7 +2354,7 @@ FM_FLEET_SYNC_PACKED_REFS_LOCK_RETRY_WAIT_SECS=1 # seconds fm-fleet-sync.sh wait
FM_FLEET_SYNC_PACKED_REFS_LOCK_AGE_SECS=30 # min mtime age before fm-fleet-sync.sh treats a leftover packed-refs.lock as provably stale
FM_BUSY_REGEX= # optional override for rendered delivery guards and Grok's isolated task-state fallback; converted worker state ignores it
FM_COMPOSER_IDLE_RE= # optional fleet-wide idle-placeholder regex override (bin/fm-composer-lib.sh); a match alone does not prove emptiness because shape-specific position and ANSI de-emphasis safety gates still apply
FM_COMPOSER_CAPTURE_LINES=20 # fleet-wide bound for tail-capture composer reads; tmux instead supplies its bounded visible pane, while the other adapters use this small window so stale scrollback banners stay out of the candidate set
FM_COMPOSER_CAPTURE_LINES=20 # fleet-wide bound for tail-capture composer reads; it no longer bounds the adapter composer state/content reads on tmux or herdr, which supply their bounded visible pane instead, while the cmux, orca, and Zellij adapters use this small window so stale scrollback banners stay out of the candidate set; it still bounds the shared inbox composer read (bin/fm-task-inbox-lib.sh) on every backend, and on herdr it also floors how many Ctrl+U presses a refused leftover may take
FM_COMPOSER_PI_MAX_LINES=8 # fleet-wide: maximum rows admitted between Pi's identity-corroborated separator pair; taller or ambiguous candidates stay unknown
FM_COMPOSER_GHOST_LUMA_MAX=128 # fleet-wide: max perceived luminance (0.299R+0.587G+0.114B, 0-255) for a TRUECOLOR foreground to count as de-emphasised ghost/placeholder text and be stripped; dim/faint (SGR 2) is stripped regardless. Assumes a dark terminal theme (bin/fm-composer-lib.sh's fm_composer_strip_ghost, used by styled tmux, herdr, and Zellij reads)
GROK_HOME= # optional Grok config home for firstmate's global grok turn-end hook; defaults to ~/.grok
Expand Down
7 changes: 5 additions & 2 deletions docs/herdr-backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -542,6 +542,8 @@ Typed-plane text is typed once; only Enter is retried.
When native `agent get` identity is Claude, the adapter types only into an empty composer.
A Claude composer that already holds text, or cannot be read, before the send is refused with nothing typed.
Before that Enter, the adapter continues only when the selected composer shows the typed payload, or only Claude paste placeholders with no literal remainder.
Every herdr adapter composer read (`fm_backend_herdr_composer_state`, `fm_backend_herdr_composer_content`) captures the full visible viewport, never a bounded tail, while the shared inbox pending-line confirmation read (bin/fm-task-inbox-lib.sh) stays a bounded tail on every backend: an overlay Claude renders between the composer and the pane bottom - the slash-command popup is the verified shape - pushes the composer outside a tail window, and the composer is by definition inside the viewport.
Dated measurement: docs/verification/runtime-backends.md "Claude exit behind the slash-command popup".

That comparison ignores whitespace and U+2063, the invisible mark that starts operational inputs and ends the from-firstmate label.
It ignores U+2063 because Claude's Herdr read-back never shows it.
Expand Down Expand Up @@ -602,7 +604,8 @@ A missed native transition falls through to the composer verdict rather than rep

`pane read --lines N` can return empty output when N is below the viewport height.
The capture owner requests at least 200 lines from Herdr and trims locally to the caller's bound.
This generous floor is required for small composer and peek reads.
This generous floor is required for the small bounded reads that remain: peek and watch tails, the rendered busy-footer read, and the shared steering-inbox pending-line read.
The adapter's own composer reads are exempt because they read the visible viewport instead, which takes no line count (see [Claude composer proof](#claude-composer-proof)).

### Native idle state

Expand All @@ -615,7 +618,7 @@ A human-blocked permission dialog has no busy banner and still surfaces.

Herdr has no direct cursor-row primitive.
The adapter is a thin capture.
It hands a bounded ANSI tail plus Herdr's capability facts to the fleet-wide classifier in `bin/fm-composer-lib.sh`, which owns every shape:
It hands the visible pane's ANSI viewport plus Herdr's capability facts to the fleet-wide classifier in `bin/fm-composer-lib.sh`, which owns every shape:

- Bordered boxes.
- Bare agent-glyph rows, including muse's `⟩`, which the adapter's retired local pattern silently omitted.
Expand Down
36 changes: 36 additions & 0 deletions docs/verification/runtime-backends.md
Original file line number Diff line number Diff line change
Expand Up @@ -1063,6 +1063,7 @@ The CLI matrix was checked directly:
| Keys | `herdr pane send-keys <pane> enter|escape|ctrl+c --session <name>` | Enter and Escape worked; Ctrl-C interrupted foreground work. |
| Capture | `herdr pane read <pane> --source recent --lines N` | Small N could return empty below viewport height; a 200-line request plus local trim was stable. |
| Viewport capture | `herdr pane read <pane> --source visible` | Verified on 2026-09-17 against Herdr 0.8.0 (protocol 19): `herdr pane read --help` documents `--source <SOURCE>` with `[possible values: visible, recent, recent-unwrapped, detection]`; `--source visible` exited 0 and returned 51 lines (the viewport) while `--source recent --lines 200` returned 200. This is the viewport-only read behind `fm_backend_herdr_visible_capture`, which Kimi's trust-dialog gate requires. |
| Styled viewport capture | `herdr pane read <pane> --source visible --format ansi` | Verified on 2026-09-26 against Herdr 0.9.0 with Claude Code 2.1.283: the flag pair exited 0 and returned the viewport with SGR attributes intact, which is the styled read behind `fm_backend_herdr_visible_capture_ansi` that ghost/placeholder stripping needs (see "Claude exit behind the slash-command popup" below). |
| Native state | `herdr agent get <pane>` | Working and done transitions were visible on some harnesses; 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, so submit confirmation falls through to the shared composer verdict. Native `busy` remains positive activity evidence, while native `idle` cannot close a turn and the adapter's semantic lifecycle decides worker state. |
| Restart | guarded named-session stop then start | Workspace, tab, pane, and labels persisted; the agent process and registration did not. |
| Close | `herdr pane close <pane> --session <name>` | The exact one-pane task tab closed; closing a final tab could remove the workspace. |
Expand Down Expand Up @@ -1152,6 +1153,41 @@ 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
```

### Claude exit behind the slash-command popup

Measured 2026-09-26 against Herdr 0.9.0 and Claude Code 2.1.283 in an isolated `fm-lab-` session.

Typing `/exit` makes Claude Code render its command popup between the composer and the pane bottom: about 19 menu rows below a solid rule pair, with the footer row last.
The composer row lands outside a bounded 20-row tail of the pane, so the adapter's bounded composer reads reported the composer as empty while it actually held `/exit`.
The pre-Enter payload proof then judged the typed command unsent, pressed Ctrl+U, and reported `send-failed` without ever pressing Enter, so `bin/fm-control.sh exit` never exited the worker (and `bin/fm-secondmate-restart.sh` inherited the failure through its exit step).

The fix captures the FULL VISIBLE VIEWPORT for every herdr adapter composer read (`pane read --source visible [--format ansi]`, `fm_backend_herdr_composer_state` and `fm_backend_herdr_composer_content`): the composer is by definition inside the viewport, and the viewport is the one bound that always contains it.
The shared inbox pending-line confirmation read (`bin/fm-task-inbox-lib.sh`) stays a bounded tail on every backend, herdr included; its payloads are task lines, not slash commands, so the popup shape does not arise there.
The popup rows sit below the composer's closing rule, which is a structural edge row, so the shared classifier still selects only the composer and the menu rows never read as typed text.
Verified live in the lab: with the popup up the state read answers `pending` (previously `empty`) and the payload proof returns `/exit` (previously empty), the submit presses Enter, and the Claude process exits, leaving the shell prompt.
Growing the window only adds rows above the composer, so the bottom-most-shape selection, the footer zone, and every previously passing verdict are unchanged.

Portable regressions (they fail against the bounded-tail reads and pass against the viewport reads):

```sh
tests/fm-backend-herdr.test.sh
```

```text
ok - fm_backend_herdr_composer_state: a slash-command popup cannot hide a typed composer
ok - fm_backend_herdr_send_text_submit: a typed slash command hidden behind its popup is still proven and submitted
```

Live guard (third scenario of the opt-in guard, verifying the agent actually exited):

```sh
FM_HERDR_SUBMIT_CONFIRM_LIVE=1 tests/fm-herdr-submit-confirm-live-e2e.test.sh
```

```text
ok - live Herdr submit confirm: Claude Code (2.1.283 (Claude Code)) on herdr 0.9.0 proves and submits a typed /exit behind its command popup
```

### Prune and respawn

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