Skip to content
Open
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
2 changes: 1 addition & 1 deletion .agents/skills/afk/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 18 additions & 3 deletions bin/fm-composer-lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -965,26 +965,41 @@ _fm_composer_row_content() { # <raw-row> <styled> -> 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() { # <screen> <styled> <ambiguous> <first-row> <last-row>
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
Expand Down
2 changes: 1 addition & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
77 changes: 75 additions & 2 deletions tests/fm-composer-ghost.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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() {
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
Loading