Skip to content
Closed
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
80 changes: 78 additions & 2 deletions bin/fm-composer-lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,15 @@
# bare - an agent prompt glyph row with no border at all (claude `❯`,
# codex `›`, muse `⟩`). The agent glyph is itself the container
# proof; a bare SHELL glyph (`>` `$` `%` `#`) never is.
# Real claude 2.x draws this row BETWEEN two horizontal `─`
# rules, and overlays the terminal title on the top rule while
# the pane is focused, so that rule is dashes PLUS text and does
# not read as a solid separator. The bottom rule is then an
# unmatched separator below the glyph, which the separated-shape
# staleness rule would otherwise read as proof the glyph is
# scrollback; _fm_composer_bare_rule_sandwich is what keeps that
# rule from discarding a live composer (the herdr counterpart of
# the cmux borderless-composer fix, #2029).
# left-bar - opencode: rows prefixed by a heavy left bar `┃` with no
# closing border, holding the idle hint, blank rows, and a
# mode/model footer line.
Expand Down Expand Up @@ -463,6 +472,12 @@ fm_composer_classify_content() { # <bordered> <content> [idle_re] [idle_case] [
# _fm_composer_pi_separator_row: a solid pi separator - nothing but `─`, at
# least 8 columns wide. The width floor is a literal substring test so it is
# byte-exact in every locale.
#
# Deliberately strict, and NOT the place to teach a titled rule: this predicate
# feeds the pi identity conjunction in _fm_composer_pi_verdict, where being
# wrong about what closes a separated composer would promote an unidentified
# blank region into an injection target. The titled variant below is a separate
# predicate with a separate, narrower consumer.
_fm_composer_pi_separator_row() { # <trimmed-row>
local row=$1
[ -n "$row" ] || return 1
Expand All @@ -473,6 +488,55 @@ _fm_composer_pi_separator_row() { # <trimmed-row>
return 1
}

# _fm_composer_rule_row: a horizontal `─` rule that MAY carry a title embedded
# in it, the same tolerance _fm_composer_titled_bottom_ok already grants grok's
# titled bottom border. Claude draws its borderless composer between two such
# rules and overlays the terminal title on the TOP one when the pane is
# focused, so the top rule reads `───…─── Some title ──` - dashes plus text.
# Anchored at both ends (an 8-column dash run to open, a dash to close) with
# every non-dash residue required to be ASCII-printable or whitespace, so a row
# carrying box-drawing structure of its own can never pass as a plain rule.
_fm_composer_rule_row() { # <trimmed-row>
local row=$1 residue
[ -n "$row" ] || return 1
case "$row" in
────────*) ;;
*) return 1 ;;
esac
case "$row" in
*─) ;;
*) return 1 ;;
esac
residue=${row//─/}
residue=$(printf '%s' "$residue" | LC_ALL=C sed 's/[!-~]/ /g')
case "$residue" in
*[![:space:]]*) return 1 ;;
esac
return 0
}

# _fm_composer_bare_rule_sandwich: 0 when the bare agent-glyph row at <row> is
# immediately sandwiched between two horizontal rules - a rule directly above
# (solid or titled) and the window's only separator directly below. That is
# Claude's borderless composer box, not stale scrollback sitting above a live
# separated composer, which is what the unmatched-separator invalidation in
# _fm_composer_select_cursorless otherwise assumes. Adjacency on BOTH edges is
# what keeps that assumption intact everywhere else: a glyph genuinely stranded
# in scrollback has transcript rows, not its own box edges, between it and the
# separator below.
_fm_composer_bare_rule_sandwich() { # <plain-screen> <row>
local plain=$1 row=$2 above below
[ "$row" -ge 1 ] || return 1
[ "$FM_COMPOSER_SCAN_PI_LAST_SEPARATOR" -eq "$((row + 1))" ] || return 1
below=$(_fm_composer_screen_row "$((row + 1))" "$plain")
fm_composer_normalize_trim_var below
_fm_composer_pi_separator_row "$below" || return 1
above=$(_fm_composer_screen_row "$((row - 1))" "$plain")
fm_composer_normalize_trim_var above
_fm_composer_rule_row "$above" || return 1
return 0
}

# 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 @@ -942,10 +1006,22 @@ _fm_composer_select_cursorless() {
FM_COMPOSER_SELECTED_FIRST=$((FM_COMPOSER_SCAN_PI_OPEN + 1))
FM_COMPOSER_SELECTED_LAST=$((FM_COMPOSER_SCAN_PI_CLOSE - 1))
fi
# An unmatched separator BELOW the chosen candidate normally proves that
# candidate stale: a live pi composer is opening where the scan ran out of
# window, so the thing above it is scrollback. The one shape that reads
# exactly like that and is NOT stale is Claude's borderless composer, whose
# own bottom edge is the unmatched separator: its top edge failed to open the
# pair only because a focused pane carries the terminal title on that rule.
# Requiring the glyph to be sandwiched between both edges keeps the staleness
# rule for every other shape while letting that composer survive.
if [ "$FM_COMPOSER_SCAN_PI_PAIR_FOUND" = 0 ] \
&& [ "$FM_COMPOSER_SCAN_PI_LAST_SEPARATOR" -gt "$generic" ]; then
FM_COMPOSER_SELECTED_KIND=
return 1
if ! { [ "$FM_COMPOSER_SELECTED_KIND" = bare ] \
&& [ "$generic" = "$FM_COMPOSER_SCAN_BARE_ROW" ] \
&& _fm_composer_bare_rule_sandwich "$plain" "$FM_COMPOSER_SCAN_BARE_ROW"; }; then
FM_COMPOSER_SELECTED_KIND=
return 1
fi
fi
if [ "$FM_COMPOSER_SCAN_SHELL_ROW" -gt "$generic" ]; then
FM_COMPOSER_SELECTED_KIND=
Expand Down
35 changes: 35 additions & 0 deletions docs/verification/runtime-backends.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,41 @@ The strict blank-row posture held live (a blank shell row deferred injection), a
Kimi was not installed on the verification machine; its bordered shape is pinned by the portable byte-capture regressions in `tests/fm-composer-lib.test.sh`, which also carry the other five adapters' capability profiles for every harness under both a UTF-8 locale and `LC_ALL=C`.
This guard is the refresh command after any harness upgrade; rerun it and update the versions above rather than trusting this table across releases.

### Herdr focused-pane titled composer rule

Claude draws its borderless composer between two horizontal `─` rules, and Herdr overlays the pane's terminal title on the TOP rule while that pane is focused, so the rule renders as dashes plus text.
That shape made the classifier discard the composer row it had already found and return `unknown`, which defers away-mode injection; only the focused pane carries a title, so worker panes stayed readable while a supervisor pane did not.
Verified on 2026-08-11 against the live Herdr session on Linux (dev desktop, SSH, no X display), reading real panes only and submitting nothing.

```sh
FM_COMPOSER_MATRIX_LIVE=1 tests/fm-composer-matrix-live-e2e.test.sh
```

Observed output:

```text
ok - claude (claude 2.1.226.634 (ASBX Claude Code, channel stable)): real idle composer classifies empty
not ok - codex (0.146.1.322 (stable)): idle composer never classified empty (last verdict: unknown)
# harness absent, not verified here: opencode
# harness absent, not verified here: pi
# harness absent, not verified here: grok
# harness absent, not verified here: kimi
# harness absent, not verified here: muse
ok - strict posture live: a blank shell row classifies unknown and injection defers
# harness absent, not verified here: zellij (false-positive regression not exercised)
ok - herdr (herdr 0.8.0) + claude (claude 2.1.226.634 (ASBX Claude Code, channel stable)): focused pane w1:p9 titled composer rule is recognized as a rule
ok - herdr (herdr 0.8.0) + claude (claude 2.1.226.634 (ASBX Claude Code, channel stable)): idle pane w1:p1 (focused=false) classifies empty
ok - herdr (herdr 0.8.0) + claude (claude 2.1.226.634 (ASBX Claude Code, channel stable)): idle pane w1:p9 (focused=true) classifies empty
```

Herdr 0.8.0 with Claude 2.1.226.634 renders the titled rule on the focused pane and a clean rule on unfocused panes, and both now classify `empty`.
The `focused=true` line is the exact shape that failed: before this fix that same pane classified `unknown`, so it is the regression's direct live pass.
The strict separator predicate that gates Pi identity still rejects a titled rule, so recognizing the rule did not relax that gate.
The guard reports explicitly when no focused Claude pane is available, because the titled overlay exists only on a focused pane and a run that never saw one has not exercised this regression.
Codex 0.146.1.322 fails this run on a first-launch directory-trust dialog, which the guard correctly treats as an unreadable composer: this machine's checkout is a subdirectory of the repository root Codex asks to trust, and the guard deliberately preserves trust prompts rather than answering them.
That failure reproduces with these changes reverted and is a property of the machine, not the classifier; the 2026-08-10 run above is Codex's current passing evidence.
Opencode, Pi, Grok, Kimi, Muse, and Zellij were not installed on this machine, so their results above stand from the 2026-08-10 run; the titled-rule shape itself is pinned harness-free by `tests/fm-composer-lib.test.sh`, which covers its four-way titled/clean rule by NBSP/ASCII-space glyph matrix.

`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.

## Herdr
Expand Down
59 changes: 59 additions & 0 deletions tests/fm-composer-lib.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,64 @@ test_matrix_claude_bare_nbsp_row() {
pass "matrix: claude's ❯+NBSP row reads empty on every profile in both locales (#1988)"
}

test_matrix_claude_titled_rule_sandwich() {
# Real focused claude on herdr 0.8.0: the borderless `❯` composer sits
# between two `─` rules, and herdr overlays the pane's terminal title on the
# TOP rule while the pane is focused, so that rule is dashes PLUS text and
# never opens a separated pair. Its bottom rule is then an unmatched
# separator below the glyph, which the separated-shape staleness rule read as
# proof the glyph was scrollback - forcing `unknown` and deferring every
# away-mode escalation into the supervisor's own pane. Only the FOCUSED pane
# carries a title, which is why worker panes stayed readable throughout.
#
# The four-way matrix: top rule titled|clean x glyph NBSP|ASCII space. All
# four are the same live composer and all four must read empty; the
# divergence below is asserted deliberately so a regression that re-breaks
# only the titled half cannot pass by satisfying the clean half.
local titled clean want_empty=0 t g screen glyph
titled=$'──────────────────────── Check Firstmate setup readiness ──'
clean=$'────────────────────────'
for t in titled clean; do
for g in nbsp ascii; do
if [ "$g" = nbsp ]; then glyph="❯$NBSP"; else glyph='❯ '; fi
if [ "$t" = titled ]; then
screen=$'transcript\n'"$titled"$'\n'"$glyph"$'\n'"$clean"$'\n footer'
else
screen=$'transcript\n'"$clean"$'\n'"$glyph"$'\n'"$clean"$'\n footer'
fi
assert_screen "claude $t rule + $g glyph on herdr" empty "$CAPS_STYLED" "$screen" '' probe-absent
assert_screen "claude $t rule + $g glyph on tmux" empty "$CAPS_TMUX" "$screen" 2 probe-absent
want_empty=$((want_empty + 1))
done
done
[ "$want_empty" -eq 4 ] \
|| fail "the four-way titled/clean x NBSP/ASCII matrix must assert all four combinations"

# The narrowing must not widen `empty`. A titled rule above a DEAD SHELL
# glyph stays unknown (the dead-shell rule), and real typed text stays
# pending, or the away-mode daemon would type a digest over half-typed input.
screen=$'transcript\n'"$titled"$'\n$\n'"$clean"$'\n footer'
assert_screen "titled rule cannot promote a dead shell" unknown "$CAPS_STYLED" "$screen" '' probe-absent
screen=$'transcript\n'"$titled"$'\n\n'"$clean"$'\n footer'
assert_screen "titled rule cannot promote a blank row" unknown "$CAPS_STYLED" "$screen" '' probe-absent
screen=$'transcript\n'"$titled"$'\n❯ 1/ api key 2/ authorise\n'"$clean"$'\n footer'
assert_screen "titled rule keeps typed text pending" pending "$CAPS_STYLED" "$screen" '' probe-absent

# Genuine staleness must still be discarded: the sandwich requires a rule on
# BOTH edges, immediately adjacent. A glyph with transcript rows between it
# and the separator below is scrollback above a live composer, as before.
screen=$'❯\ntranscript row\nmore transcript\n'"$clean"$'\n footer'
assert_screen "glyph stranded above a live pair stays stale" unknown "$CAPS_STYLED" "$screen" '' probe-absent
screen=$'transcript\n'"$titled"$'\n❯\ntranscript row\n'"$clean"$'\n footer'
assert_screen "non-adjacent separator stays stale" unknown "$CAPS_STYLED" "$screen" '' probe-absent
screen=$'plain transcript\n❯\n'"$clean"$'\n footer'
assert_screen "no rule above is not a sandwich" unknown "$CAPS_STYLED" "$screen" '' probe-absent
# A row carrying box-drawing structure of its own is not a plain rule.
screen=$'transcript\n│ boxed row │\n❯\n'"$clean"$'\n footer'
assert_screen "a bordered row above is not a rule" unknown "$CAPS_STYLED" "$screen" '' probe-absent
pass "matrix: claude's titled-rule sandwich reads empty without widening empty for shells, blanks, typed text, or scrollback"
}

test_matrix_codex_dim_hint_row() {
# Real idle codex: bold `›`, reset, then an SGR-2 dim hint. Styled captures
# strip the ghost and prove empty; plain captures must defer as unknown -
Expand Down Expand Up @@ -541,6 +599,7 @@ test_idle_placeholder_is_empty
test_idle_placeholder_case_mode_is_explicit
test_real_text_is_pending
test_matrix_claude_bare_nbsp_row
test_matrix_claude_titled_rule_sandwich
test_matrix_codex_dim_hint_row
test_matrix_muse_truecolor_glyph_survives_signal_loss
test_matrix_pi_separated_needs_identity
Expand Down
Loading
Loading