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/bin/fm-composer-lib.sh b/bin/fm-composer-lib.sh index ef210463823..b9e9c267248 100644 --- a/bin/fm-composer-lib.sh +++ b/bin/fm-composer-lib.sh @@ -965,26 +965,41 @@ _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 - 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} + local furniture_re="^(${idle_re})$" 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" "$furniture_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 return 0 ;; unknown) unknown_seen=1 ;; + empty) empty_seen=1 ;; esac row=$((row + 1)) done + if [ "$empty_seen" != 1 ]; then unknown_seen=1; fi if [ "$unknown_seen" = 1 ] || [ "$ambiguous" = 1 ]; then printf 'unknown' else diff --git a/docs/configuration.md b/docs/configuration.md index e1797073646..81db5178a80 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, only an exact full-row match is excluded from the pending vote regardless of styling, and a box whose only content matches still reads unknown rather than empty; these guards cover exact matching rows and furniture-only boxes, so operators remain responsible for ensuring an override does not exactly match real input 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) diff --git a/tests/fm-composer-ghost.test.sh b/tests/fm-composer-ghost.test.sh index 6ef9eb70bf2..c5d271becd0 100755 --- a/tests/fm-composer-ghost.test.sh +++ b/tests/fm-composer-ghost.test.sh @@ -479,7 +479,13 @@ test_unproved_empty_geometry_fails_closed() { fm_tmux_composer_state "fakepane") ;; idle) - expected=pending-unproven + # A row matching the idle-placeholder regex is excused as furniture + # regardless of styling (issue #2483's hint-row-poisoning case), so + # this row casts no pending vote; furniture alone is never positive + # proof of emptiness though, and no other row in this single-row box + # reads empty on its own merits, 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 +500,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() { @@ -530,6 +536,71 @@ 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_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'" + + # A draft that merely starts with a known placeholder must not be swallowed + # as furniture when another row independently proves the box empty. + printf '╭────────────────────╮\n│ ❯%s │\n│ Ask anything... x │\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 "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" +} + test_all_tmux_harness_composers_share_classification() { local dir fb capture out harness dir="$TMP_ROOT/all-harness-composers"; mkdir -p "$dir" @@ -706,6 +777,8 @@ 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_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