From bdfe642e5c12ab149274932cd7f869987a28e47a Mon Sep 17 00:00:00 2001 From: Pablo Ontiveros Date: Mon, 14 Sep 2026 11:13:28 -0600 Subject: [PATCH 1/6] test(composer): pin Claude NBSP idle row --- tests/fm-composer-ghost.test.sh | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/fm-composer-ghost.test.sh b/tests/fm-composer-ghost.test.sh index 6ef9eb70bf2..9ce3d9d2ef4 100755 --- a/tests/fm-composer-ghost.test.sh +++ b/tests/fm-composer-ghost.test.sh @@ -530,6 +530,30 @@ test_wide_composer_text_is_pending() { pass "fm_tmux_composer_state: emoji and CJK text remain pending under the C locale" } +test_claude_nbsp_idle_row_is_empty() { + local dir fb capture out nbsp + dir="$TMP_ROOT/claude-nbsp"; mkdir -p "$dir" + fb=$(make_fake_tmux "$dir") + capture="$dir/styled.txt" + nbsp=$(printf '\302\240') + + # Claude's idle bordered composer uses U+276F followed by U+00A0. The tmux + # daemon runs under LC_ALL=C, where POSIX whitespace matching alone does not + # trim that separator (issue #2483). + printf '╭────────────╮\n│ ❯%s │\n╰────────────╯\n' "$nbsp" > "$capture" + out=$(PATH="$fb:$PATH" LC_ALL=C FM_FAKE_STYLED="$capture" FM_FAKE_CY=1 \ + fm_tmux_composer_state "fakepane") + [ "$out" = empty ] \ + || fail "Claude's bordered U+276F+NBSP idle row should be empty, got '$out'" + + printf '╭────────────╮\n│ ❯ fix │\n╰────────────╯\n' > "$capture" + out=$(PATH="$fb:$PATH" LC_ALL=C FM_FAKE_STYLED="$capture" FM_FAKE_CY=1 \ + fm_tmux_composer_state "fakepane") + [ "$out" = pending ] \ + || fail "Claude's bordered composer with typed text should be pending, got '$out'" + pass "fm_tmux_composer_state: Claude's bordered U+276F+NBSP idle row is empty while typed text stays pending" +} + test_all_tmux_harness_composers_share_classification() { local dir fb capture out harness dir="$TMP_ROOT/all-harness-composers"; mkdir -p "$dir" @@ -706,6 +730,7 @@ test_misaligned_box_is_unknown test_unproved_empty_geometry_fails_closed test_differing_widths_use_asymmetric_verdicts test_wide_composer_text_is_pending +test_claude_nbsp_idle_row_is_empty test_all_tmux_harness_composers_share_classification test_unrecognized_state_defers_input_guard test_single_capture_leaves_no_fallback_race From f9ec7ec27e3a772ecdc96c1d7341cbad8f26c74e Mon Sep 17 00:00:00 2001 From: Pablo Ontiveros Date: Mon, 14 Sep 2026 11:32:39 -0600 Subject: [PATCH 2/6] no-mistakes(review): fix(composer): idle-placeholder rows no longer outrank empty verdict --- bin/fm-composer-lib.sh | 11 +++++- tests/fm-composer-ghost.test.sh | 68 ++++++++++++++++++++++++++++++++- 2 files changed, 75 insertions(+), 4 deletions(-) diff --git a/bin/fm-composer-lib.sh b/bin/fm-composer-lib.sh index ef210463823..cb646710127 100644 --- a/bin/fm-composer-lib.sh +++ b/bin/fm-composer-lib.sh @@ -965,17 +965,24 @@ _fm_composer_row_content() { # -> content on stdout # _fm_composer_classify_rows: shared multi-row container verdict for the box # and separated shapes: pending beats empty, an unreadable row is unknown, and # geometry ambiguity turns pending into pending-unproven and empty into -# unknown (an ambiguous container is not positive proof). +# unknown (an ambiguous container is not positive proof). A row that is +# nothing but a known idle placeholder is furniture, not a vote for pending, +# regardless of whether ghost stripping could prove it dim. _fm_composer_classify_rows() { # local screen=$1 styled=$2 ambiguous=$3 first=$4 last=$5 local row raw content plain state unknown_seen=0 + local idle_re=${FM_COMPOSER_IDLE_RE:-$FM_COMPOSER_IDLE_RE_DEFAULT} row=$first while [ "$row" -le "$last" ]; do raw=$(_fm_composer_screen_row "$row" "$screen") content=$(_fm_composer_row_content "$raw" "$styled") plain=$(_fm_composer_row_content "$raw" 0) + if [ -n "$content" ] && fm_composer_idle_matches "$content" "$idle_re" insensitive; then + row=$((row + 1)) + continue + fi state=$(fm_composer_classify_content 1 "$content" \ - "${FM_COMPOSER_IDLE_RE:-$FM_COMPOSER_IDLE_RE_DEFAULT}" insensitive "$plain" 1 "$styled") + "$idle_re" insensitive "$plain" 1 "$styled") case "$state" in pending) if [ "$ambiguous" = 1 ]; then printf 'pending-unproven'; else printf 'pending'; fi diff --git a/tests/fm-composer-ghost.test.sh b/tests/fm-composer-ghost.test.sh index 9ce3d9d2ef4..03e4f0a5595 100755 --- a/tests/fm-composer-ghost.test.sh +++ b/tests/fm-composer-ghost.test.sh @@ -479,7 +479,12 @@ test_unproved_empty_geometry_fails_closed() { fm_tmux_composer_state "fakepane") ;; idle) - expected=pending-unproven + # A row matching the idle-placeholder regex is recognized furniture + # regardless of styling (issue #2483's hint-row-poisoning case), so + # this row reads empty rather than pending; ambiguous geometry then + # cannot prove that empty, so the box falls to unknown, same as the + # ghost case above. + expected=unknown printf '╭────────────╮\n│ idle hint │\n╰────────────╯\n' > "$capture" out=$(PATH="$fb:$PATH" FM_FAKE_STYLED="$capture" FM_FAKE_CY=1 \ FM_COMPOSER_IDLE_RE='^idle hint$' fm_tmux_composer_state "fakepane") @@ -494,7 +499,7 @@ test_unproved_empty_geometry_fails_closed() { [ "$out" = "$expected" ] \ || fail "unproved geometry '$fixture' should be $expected, got '$out'" done - pass "fm_tmux_composer_state: unproved ghost and malformed geometry stay unknown while styled placeholder-like text stays pending-unproven" + pass "fm_tmux_composer_state: unproved ghost, idle-placeholder, and malformed geometry all fail closed to unknown" } test_differing_widths_use_asymmetric_verdicts() { @@ -554,6 +559,63 @@ test_claude_nbsp_idle_row_is_empty() { pass "fm_tmux_composer_state: Claude's bordered U+276F+NBSP idle row is empty while typed text stays pending" } +test_padded_bordered_nbsp_idle_row_is_empty() { + local dir fb capture out nbsp + dir="$TMP_ROOT/claude-nbsp-padded"; mkdir -p "$dir" + fb=$(make_fake_tmux "$dir") + capture="$dir/styled.txt" + nbsp=$(printf '\302\240') + + # A grok-style padded bordered composer: blank rows above and below the + # U+276F+NBSP idle row (issue #2483's still-open "padded" fixture). The + # blank padding rows must not turn a genuinely idle composer unknown. + printf '╭────────────╮\n│ │\n│ ❯%s │\n│ │\n╰────────────╯\n' \ + "$nbsp" > "$capture" + out=$(PATH="$fb:$PATH" LC_ALL=C FM_FAKE_STYLED="$capture" FM_FAKE_CY=2 \ + fm_tmux_composer_state "fakepane") + [ "$out" = empty ] \ + || fail "a padded bordered U+276F+NBSP idle row should be empty, got '$out'" + + printf '╭────────────╮\n│ │\n│ ❯ fix │\n│ │\n╰────────────╯\n' > "$capture" + out=$(PATH="$fb:$PATH" LC_ALL=C FM_FAKE_STYLED="$capture" FM_FAKE_CY=2 \ + fm_tmux_composer_state "fakepane") + [ "$out" = pending ] \ + || fail "a padded bordered composer with typed text should be pending, got '$out'" + pass "fm_tmux_composer_state: a padded bordered U+276F+NBSP idle row is empty while typed text stays pending" +} + +test_bright_furniture_row_does_not_poison_idle_verdict() { + local dir fb capture out nbsp + dir="$TMP_ROOT/furniture-row"; mkdir -p "$dir" + fb=$(make_fake_tmux "$dir") + capture="$dir/styled.txt" + nbsp=$(printf '\302\240') + + # A bright (non-dim, non-truecolor-ghosted) suggestion row below the idle + # glyph row - issue #2483's "hint-row poisoning": _fm_composer_classify_rows + # applies pending-beats-empty across every row in the box, so a furniture + # row that ghost-stripping cannot remove used to win over the otherwise- + # empty glyph row. A row matching the shared idle-placeholder regex is + # recognized furniture regardless of styling, so the box stays empty. + printf '╭────────────────────╮\n│ ❯%s │\n│ Ask anything... │\n╰────────────────────╯\n' \ + "$nbsp" > "$capture" + out=$(PATH="$fb:$PATH" LC_ALL=C FM_FAKE_STYLED="$capture" FM_FAKE_CY=1 \ + fm_tmux_composer_state "fakepane") + [ "$out" = empty ] \ + || fail "a bright idle-placeholder furniture row should not poison an empty composer, got '$out'" + + # Real typed text on that same second row must still read pending - the + # furniture exception is a regex match on known placeholder text, not a + # blanket pass for every non-glyph row. + printf '╭────────────────────╮\n│ ❯%s │\n│ fix the login bug │\n╰────────────────────╯\n' \ + "$nbsp" > "$capture" + out=$(PATH="$fb:$PATH" LC_ALL=C FM_FAKE_STYLED="$capture" FM_FAKE_CY=1 \ + fm_tmux_composer_state "fakepane") + [ "$out" = pending ] \ + || fail "real typed text on a second composer row should stay pending, got '$out'" + pass "fm_tmux_composer_state: a bright idle-placeholder furniture row does not poison an empty composer, real text still pending" +} + test_all_tmux_harness_composers_share_classification() { local dir fb capture out harness dir="$TMP_ROOT/all-harness-composers"; mkdir -p "$dir" @@ -731,6 +793,8 @@ test_unproved_empty_geometry_fails_closed test_differing_widths_use_asymmetric_verdicts test_wide_composer_text_is_pending test_claude_nbsp_idle_row_is_empty +test_padded_bordered_nbsp_idle_row_is_empty +test_bright_furniture_row_does_not_poison_idle_verdict test_all_tmux_harness_composers_share_classification test_unrecognized_state_defers_input_guard test_single_capture_leaves_no_fallback_race From d6bf58fb8114765da38b61ad2aad225ffa95fab7 Mon Sep 17 00:00:00 2001 From: Pablo Ontiveros Date: Mon, 14 Sep 2026 11:40:50 -0600 Subject: [PATCH 3/6] no-mistakes(review): test(composer): drop unproven padded-bordered regression test --- tests/fm-composer-ghost.test.sh | 26 -------------------------- 1 file changed, 26 deletions(-) diff --git a/tests/fm-composer-ghost.test.sh b/tests/fm-composer-ghost.test.sh index 03e4f0a5595..40285ff0862 100755 --- a/tests/fm-composer-ghost.test.sh +++ b/tests/fm-composer-ghost.test.sh @@ -559,31 +559,6 @@ test_claude_nbsp_idle_row_is_empty() { pass "fm_tmux_composer_state: Claude's bordered U+276F+NBSP idle row is empty while typed text stays pending" } -test_padded_bordered_nbsp_idle_row_is_empty() { - local dir fb capture out nbsp - dir="$TMP_ROOT/claude-nbsp-padded"; mkdir -p "$dir" - fb=$(make_fake_tmux "$dir") - capture="$dir/styled.txt" - nbsp=$(printf '\302\240') - - # A grok-style padded bordered composer: blank rows above and below the - # U+276F+NBSP idle row (issue #2483's still-open "padded" fixture). The - # blank padding rows must not turn a genuinely idle composer unknown. - printf '╭────────────╮\n│ │\n│ ❯%s │\n│ │\n╰────────────╯\n' \ - "$nbsp" > "$capture" - out=$(PATH="$fb:$PATH" LC_ALL=C FM_FAKE_STYLED="$capture" FM_FAKE_CY=2 \ - fm_tmux_composer_state "fakepane") - [ "$out" = empty ] \ - || fail "a padded bordered U+276F+NBSP idle row should be empty, got '$out'" - - printf '╭────────────╮\n│ │\n│ ❯ fix │\n│ │\n╰────────────╯\n' > "$capture" - out=$(PATH="$fb:$PATH" LC_ALL=C FM_FAKE_STYLED="$capture" FM_FAKE_CY=2 \ - fm_tmux_composer_state "fakepane") - [ "$out" = pending ] \ - || fail "a padded bordered composer with typed text should be pending, got '$out'" - pass "fm_tmux_composer_state: a padded bordered U+276F+NBSP idle row is empty while typed text stays pending" -} - test_bright_furniture_row_does_not_poison_idle_verdict() { local dir fb capture out nbsp dir="$TMP_ROOT/furniture-row"; mkdir -p "$dir" @@ -793,7 +768,6 @@ test_unproved_empty_geometry_fails_closed test_differing_widths_use_asymmetric_verdicts test_wide_composer_text_is_pending test_claude_nbsp_idle_row_is_empty -test_padded_bordered_nbsp_idle_row_is_empty test_bright_furniture_row_does_not_poison_idle_verdict test_all_tmux_harness_composers_share_classification test_unrecognized_state_defers_input_guard From 236cd385b4ae6a9bcd25ccee5ca9976bfb2feb14 Mon Sep 17 00:00:00 2001 From: Pablo Ontiveros Date: Mon, 14 Sep 2026 11:52:16 -0600 Subject: [PATCH 4/6] no-mistakes(document): docs(composer): note IDLE_RE furniture-row exception to safety gates --- .agents/skills/afk/SKILL.md | 2 +- docs/configuration.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.agents/skills/afk/SKILL.md b/.agents/skills/afk/SKILL.md index 0046d63e020..b5f01430f6e 100644 --- a/.agents/skills/afk/SKILL.md +++ b/.agents/skills/afk/SKILL.md @@ -192,7 +192,7 @@ the operational prefix lets firstmate distinguish it from a real captain message - 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_COMPOSER_IDLE_RE` overrides the shared idle-placeholder regex; see `docs/configuration.md`'s `FM_COMPOSER_IDLE_RE` entry for exactly which rows a match can and cannot bypass gates for. `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 diff --git a/docs/configuration.md b/docs/configuration.md index e1797073646..da442e526d7 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1080,7 +1080,7 @@ FM_FLEET_SYNC_PACKED_REFS_LOCK_RETRIES=3 # fetch retries after fm-fleet-s FM_FLEET_SYNC_PACKED_REFS_LOCK_RETRY_WAIT_SECS=1 # seconds fm-fleet-sync.sh waits before each of those retries 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_IDLE_RE= # optional fleet-wide idle-placeholder regex override (bin/fm-composer-lib.sh); inside a bordered box or separated composer, any row whose content matches is treated as furniture and excluded from the pending vote outright, regardless of styling, while a non-matching row still needs shape-specific position and ANSI de-emphasis proof to read empty - so an overly broad override can hide real typed text that happens to match 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_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) From d2b5983bd7aac7d2a0058ba817ac11be7128f63d Mon Sep 17 00:00:00 2001 From: Pablo Ontiveros Date: Mon, 14 Sep 2026 12:29:38 -0600 Subject: [PATCH 5/6] =?UTF-8?q?no-mistakes(ci):=20Fixed=20CI:=20round-2's?= =?UTF-8?q?=20furniture-skip=20in=20=5Ffm=5Fcomposer=5Fclassify=5Frows=20(?= =?UTF-8?q?bin/fm-composer-lib.sh)=20let=20a=20box=20with=20ONLY=20an=20id?= =?UTF-8?q?le-regex-matching=20row=20default=20to=20`empty`=20with=20zero?= =?UTF-8?q?=20corroboration,=20breaking=20the=20pre-existing=20safety=20te?= =?UTF-8?q?st=20in=20tests/fm-daemon.test.sh=20(bright/styled=20text=20mat?= =?UTF-8?q?ching=20a=20custom=20FM=5FCOMPOSER=5FIDLE=5FRE=20override=20mus?= =?UTF-8?q?t=20stay=20non-empty).=20Fixed=20by=20requiring=20at=20least=20?= =?UTF-8?q?one=20row=20to=20independently=20prove=20`empty`=20before=20the?= =?UTF-8?q?=20box=20can=20resolve=20to=20`empty`;=20furniture=20rows=20are?= =?UTF-8?q?=20excused=20from=20voting=20but=20never=20themselves=20count?= =?UTF-8?q?=20as=20proof,=20so=20an=20idle-only=20box=20now=20reads=20`unk?= =?UTF-8?q?nown`=20(safe)=20instead=20of=20`empty`.=20Verified=20this=20pr?= =?UTF-8?q?eserves=20the=20round-2=20hint-row-poisoning=20fix=20and=20the?= =?UTF-8?q?=20ambiguous-geometry=20furniture=20test.=20Updated=20docs/conf?= =?UTF-8?q?iguration.md's=20FM=5FCOMPOSER=5FIDLE=5FRE=20entry=20and=20a=20?= =?UTF-8?q?test=20comment=20to=20match.=20Ran=20fm-daemon.test.sh,=20fm-co?= =?UTF-8?q?mposer-ghost.test.sh,=20fm-composer-lib.test.sh,=20and=206=20ot?= =?UTF-8?q?her=20composer-related=20suites=20locally=20=E2=80=94=20all=20p?= =?UTF-8?q?ass=20(exit=200,=20no=20`not=20ok`=20lines)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bin/fm-composer-lib.sh | 11 +++++++++-- docs/configuration.md | 2 +- tests/fm-composer-ghost.test.sh | 9 +++++---- 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/bin/fm-composer-lib.sh b/bin/fm-composer-lib.sh index cb646710127..7fc478ce5b2 100644 --- a/bin/fm-composer-lib.sh +++ b/bin/fm-composer-lib.sh @@ -967,10 +967,15 @@ _fm_composer_row_content() { # -> content on stdout # geometry ambiguity turns pending into pending-unproven and empty into # unknown (an ambiguous container is not positive proof). A row that is # nothing but a known idle placeholder is furniture, not a vote for pending, -# regardless of whether ghost stripping could prove it dim. +# regardless of whether ghost stripping could prove it dim - but furniture +# rows are only ever excused, never themselves the proof: skipping every row +# in the box leaves no positive evidence of emptiness (indistinguishable from +# real typed text that happens to match the placeholder pattern, e.g. a +# caller-supplied FM_COMPOSER_IDLE_RE override), so at least one row must +# still resolve to empty on its own merits before the box reads empty. _fm_composer_classify_rows() { # local screen=$1 styled=$2 ambiguous=$3 first=$4 last=$5 - local row raw content plain state unknown_seen=0 + local row raw content plain state unknown_seen=0 empty_seen=0 local idle_re=${FM_COMPOSER_IDLE_RE:-$FM_COMPOSER_IDLE_RE_DEFAULT} row=$first while [ "$row" -le "$last" ]; do @@ -989,9 +994,11 @@ _fm_composer_classify_rows() { # "$capture" out=$(PATH="$fb:$PATH" FM_FAKE_STYLED="$capture" FM_FAKE_CY=1 \ From d8c0936dde7d0d9a5febe0d9c34ae92db8ed43ee Mon Sep 17 00:00:00 2001 From: Pablo Ontiveros Date: Mon, 14 Sep 2026 15:02:37 -0600 Subject: [PATCH 6/6] fix(composer): require exact furniture row matches --- bin/fm-composer-lib.sh | 3 ++- docs/configuration.md | 2 +- tests/fm-composer-ghost.test.sh | 9 +++++++++ 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/bin/fm-composer-lib.sh b/bin/fm-composer-lib.sh index 7fc478ce5b2..b9e9c267248 100644 --- a/bin/fm-composer-lib.sh +++ b/bin/fm-composer-lib.sh @@ -977,12 +977,13 @@ _fm_composer_classify_rows() { # "$capture" + out=$(PATH="$fb:$PATH" LC_ALL=C FM_FAKE_STYLED="$capture" FM_FAKE_CY=1 \ + fm_tmux_composer_state "fakepane") + [ "$out" = pending ] \ + || fail "a draft extending an idle-placeholder prefix should stay pending, got '$out'" pass "fm_tmux_composer_state: a bright idle-placeholder furniture row does not poison an empty composer, real text still pending" }