diff --git a/bin/fm-composer-lib.sh b/bin/fm-composer-lib.sh index 3270445707c..937d8f3c1c8 100644 --- a/bin/fm-composer-lib.sh +++ b/bin/fm-composer-lib.sh @@ -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. @@ -463,6 +472,12 @@ fm_composer_classify_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() { # local row=$1 [ -n "$row" ] || return 1 @@ -473,6 +488,55 @@ _fm_composer_pi_separator_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() { # + 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 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() { # + 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() { # [extract-wrap] @@ -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= diff --git a/docs/verification/runtime-backends.md b/docs/verification/runtime-backends.md index 176be7da455..d7b29d67aaa 100644 --- a/docs/verification/runtime-backends.md +++ b/docs/verification/runtime-backends.md @@ -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 --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 diff --git a/tests/fm-composer-lib.test.sh b/tests/fm-composer-lib.test.sh index 16464b742c1..a4e058f8ad9 100755 --- a/tests/fm-composer-lib.test.sh +++ b/tests/fm-composer-lib.test.sh @@ -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 - @@ -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 diff --git a/tests/fm-composer-matrix-live-e2e.test.sh b/tests/fm-composer-matrix-live-e2e.test.sh index 9bb78ade445..8945889921c 100755 --- a/tests/fm-composer-matrix-live-e2e.test.sh +++ b/tests/fm-composer-matrix-live-e2e.test.sh @@ -14,7 +14,11 @@ # - the zellij false-positive regression live (when zellij is installed): a # pane whose content changes for reasons unrelated to submission must NOT # report a delivered send, and a real claude-in-zellij `dump-screen -# --ansi` capture must classify empty through the zellij thin adapter. +# --ansi` capture must classify empty through the zellij thin adapter; +# - the herdr titled-rule regression live (when herdr is running): a FOCUSED +# claude pane carries the terminal title on its composer's top rule, the +# shape that read `unknown` and deferred every away-mode escalation. Only a +# real focused herdr pane renders it, so no tmux fixture can cover it. # # Run explicitly with FM_COMPOSER_MATRIX_LIVE=1. No prompt is ever submitted # to any harness, so no model tokens are spent. An absent harness is reported @@ -214,6 +218,112 @@ else note "harness absent, not verified here: zellij (false-positive regression not exercised)" fi +# --- 4. herdr: the real focused-pane composer through the herdr adapter ------ +# The titled-rule regression (the herdr counterpart of the cmux borderless +# composer fix #2029): herdr overlays the pane's terminal title on the TOP rule +# of claude's borderless composer while the pane is FOCUSED, so that rule never +# opens a separated pair and the composer's own bottom rule reads as an +# unmatched separator. That shape read `unknown` and deferred every away-mode +# escalation. Only a real focused herdr pane renders it, so tmux fixtures +# cannot cover it - this is the harness-dependent half of the pair. +if command -v herdr >/dev/null 2>&1 && command -v claude >/dev/null 2>&1; then + hd_version=$(herdr --version 2>/dev/null | head -1) + [ -n "$hd_version" ] || hd_version='version-unknown' + cl_version=$(harness_version claude) + export FM_ROOT_OVERRIDE="$ROOT" + # shellcheck source=/dev/null + . "$ROOT/bin/fm-backend.sh" + if fm_backend_source herdr 2>/dev/null; then + hd_checked=0 + hd_titled=0 + hd_panes=$(fm_backend_herdr_cli default pane list 2>/dev/null || true) + if [ -n "$hd_panes" ]; then + # The vendor-rendered fact this regression turns on, asserted directly and + # independently of any pane's agent_status: on a FOCUSED claude pane the + # rule above the `❯` row carries the terminal title, so it is dashes PLUS + # text. _fm_composer_rule_row must accept it while the strict separator + # predicate feeding the pi identity gate must still reject it. A busy + # focused pane cannot be asserted `empty` (its composer may hold real + # input), but its RENDERING is exactly the shape that broke. + while IFS= read -r hd_pane; do + [ -n "$hd_pane" ] || continue + hd_cap=$(fm_backend_herdr_capture "default:$hd_pane" "$FM_COMPOSER_CAPTURE_LINES" 2>/dev/null || true) + [ -n "$hd_cap" ] || continue + hd_glyph_row=-1 + hd_row=0 + while IFS= read -r hd_line; do + hd_trim=$hd_line + fm_composer_normalize_trim_var hd_trim + if fm_composer_leading_agent_glyph_var hd_g "$hd_trim"; then hd_glyph_row=$hd_row; fi + hd_row=$((hd_row + 1)) + done <&2 + printf 'not ok - herdr (%s) + claude (%s): focused pane %s titled composer rule was NOT recognized; the away-mode titled-rule regression is live again\n' \ + "$hd_version" "$cl_version" "$hd_pane" >&2 + fi + done </dev/null) +EOF + # Every live claude pane herdr reports idle: its composer is empty by + # herdr's own native agent-state, so the shared classifier must agree. + # A focused idle pane is the exact outage shape and is required to pass. + while IFS=$'\t' read -r hd_pane hd_focused; do + [ -n "$hd_pane" ] || continue + hd_verdict=$(fm_backend_herdr_composer_state "default:$hd_pane" 2>/dev/null || true) + if [ "$hd_verdict" = empty ]; then + hd_checked=$((hd_checked + 1)) + CHECKED=$((CHECKED + 1)) + pass "herdr ($hd_version) + claude ($cl_version): idle pane $hd_pane (focused=$hd_focused) classifies empty" + else + FAILED=1 + printf '# herdr pane %s tail at failure:\n' "$hd_pane" >&2 + fm_backend_herdr_capture "default:$hd_pane" 6 2>/dev/null \ + | grep '[^[:space:]]' | tail -6 | sed 's/^/# /' >&2 + printf 'not ok - herdr (%s) + claude (%s): idle pane %s (focused=%s) classified %s, expected empty\n' \ + "$hd_version" "$cl_version" "$hd_pane" "$hd_focused" "${hd_verdict:-unreadable}" >&2 + fi + done </dev/null) +EOF + fi + if [ "$hd_checked" -eq 0 ]; then + note "herdr ($hd_version) running but no idle claude pane to read; live empty verdicts not exercised here" + fi + if [ "$hd_titled" -eq 0 ]; then + note "herdr ($hd_version) exposed no FOCUSED claude pane rendering a titled composer rule; focus a claude pane in herdr to exercise the away-mode regression directly" + fi + else + note "herdr present but adapter source failed; titled-rule shape not exercised here" + fi +else + note "harness absent, not verified here: herdr+claude (titled-rule sandwich not exercised)" +fi + # --- refuse a vacuous pass --------------------------------------------------- [ "$FAILED" -eq 0 ] || fail "live composer-matrix guard observed failures above" [ "$CHECKED" -gt 0 ] || fail "live composer-matrix guard verified nothing (no harness installed?); refusing a vacuous pass"