From ba885d013c0e3281a8f0b5f48abf0eb04ea72967 Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Sun, 9 Aug 2026 22:36:34 -0700 Subject: [PATCH 01/28] refactor(composer): one shape owner behind thin capture adapters, whole matrix fixed Consolidate every composer shape - bordered boxes (all families, geometry, titled bottom borders), bare agent-glyph rows and their wrap regions, opencode's left bar, and pi's identity-gated separator pair - into fm_composer_classify_screen in bin/fm-composer-lib.sh. Adapters now contribute only a capture and a declarative capability descriptor (styled/cursor/identity/rows); capability differences change how confidently a shape is judged, never what the shapes are, so a new harness shape is teachable in exactly one place. Correctness fixes landed as part of the consolidation (audit data/fm-composer-consolidation-audit-s1): - locale-safe Unicode-space normalization in the shared owner (closes the fleet-wide half of #1988; cmux's local byte-exact NBSP case deleted; naming converges with PR #1995's normalization primitive) - muse's bare glyph joins the shared set, unbreaking muse on herdr/cmux/orca - orca learns the borderless bare shape, drops its backward-paged composer window, and can no longer classify a stale startup banner as the composer - tmux tolerates a titled bottom border, unbreaking grok steering - the left-bar shape makes opencode readable on every backend - zellij gets a real classifier through dump-screen --ansi, replacing the content-diff submit heuristic that could confirm an undelivered message and close a --resolve-key decision (the fleet's only false positive) - fm-spawn's kimi launch-readiness regex (the fourth shape copy) now routes through the shared classifier The strict blank-row posture applies fleet-wide (captain decision blank-row-injection-posture): no positive container proof = unknown = defer, replacing tmux's permissive blank-cursor-row rule. Away-mode injection was re-validated end to end on real tmux (defer on partial input and unproven rows, clean delivery with swallowed-Enter retry into proven-empty composers). The tmux submit core gains a baseline-idle turn-started conversion so pi steering stays confirmed while its working screen hides the composer; busy conversion without that baseline remains forbidden. Plain-capture backends now degrade a glyph row carrying trailing text to unknown instead of a false pending, per the approved capability rule. Portable regressions pin the full byte-capture matrix from the audit under a UTF-8 locale and LC_ALL=C, the strict-vs-permissive divergence, and deliberate signal separation; the opt-in live guard (tests/fm-composer-matrix-live-e2e.test.sh) verified every installed harness against the real classifier, recorded in docs/verification/runtime-backends.md. --- .agents/skills/afk/SKILL.md | 3 +- .agents/skills/harness-adapters/SKILL.md | 2 +- bin/backends/cmux.sh | 116 +-- bin/backends/herdr.sh | 294 +------ bin/backends/orca.sh | 110 +-- bin/backends/zellij.sh | 83 +- bin/fm-backend.sh | 25 +- bin/fm-composer-lib.sh | 975 ++++++++++++++++++++-- bin/fm-spawn.sh | 17 +- bin/fm-supervise-daemon.sh | 8 +- bin/fm-test-run.sh | 9 + bin/fm-tmux-lib.sh | 353 +++----- docs/cmux-backend.md | 4 +- docs/configuration.md | 12 +- docs/herdr-backend.md | 7 +- docs/orca-backend.md | 5 +- docs/tmux-backend.md | 10 +- docs/verification/runtime-backends.md | 32 + docs/zellij-backend.md | 6 +- tests/fm-afk-inject-e2e.test.sh | 9 +- tests/fm-backend-cmux.test.sh | 28 +- tests/fm-backend-herdr.test.sh | 18 +- tests/fm-backend-orca.test.sh | 63 +- tests/fm-backend-zellij.test.sh | 92 +- tests/fm-backend.test.sh | 85 +- tests/fm-composer-ghost.test.sh | 41 +- tests/fm-composer-lib.test.sh | 242 ++++++ tests/fm-composer-matrix-live-e2e.test.sh | 183 ++++ tests/fm-daemon.test.sh | 22 +- 29 files changed, 1941 insertions(+), 913 deletions(-) create mode 100755 tests/fm-composer-matrix-live-e2e.test.sh diff --git a/.agents/skills/afk/SKILL.md b/.agents/skills/afk/SKILL.md index aba6e3fb00c..76213b25f03 100644 --- a/.agents/skills/afk/SKILL.md +++ b/.agents/skills/afk/SKILL.md @@ -189,7 +189,8 @@ the operational prefix lets firstmate distinguish it from a real captain message tmux and herdr route their raw styled candidate rows through the shared `fm_composer_strip_ghost` extractor, which removes dim/faint and dark-TRUECOLOR ghost/placeholder text before classification. They read the composer shape from a separately ANSI-stripped plain row because a dark TRUECOLOR border can be stripped 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` still overrides tmux empty-composer matching after shared ghost and border stripping, and `FM_BUSY_REGEX` overrides the rendered delivery guards plus Grok's isolated task-state fallback. + `FM_COMPOSER_IDLE_RE` overrides the fleet-wide empty-composer placeholder set after shared ghost and border stripping on every backend, and `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 (the strict rule owned by `bin/fm-composer-lib.sh`), 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 buffered past `FM_MAX_DEFER_SECS` (default 300s), the daemon attempts one normal flush, which still requires an idle pane and an affirmatively empty composer. If that diff --git a/.agents/skills/harness-adapters/SKILL.md b/.agents/skills/harness-adapters/SKILL.md index df047158328..09b112c1c64 100644 --- a/.agents/skills/harness-adapters/SKILL.md +++ b/.agents/skills/harness-adapters/SKILL.md @@ -38,7 +38,7 @@ Each adapter's `Busy state` row names only which semantic source that harness us Never dispatch a crewmate or secondmate on an unverified adapter. If `config/crew-harness` or `config/secondmate-harness` names an unverified adapter, tell the captain under `AGENTS.md` section 9 that the requested worker runtime is not verified yet, use firstmate's own verified runtime for current work, and ask only whether to verify the requested runtime before future use. Do not pause current work for that future-verification choice, and never launch an unverified adapter. -If the captain asks for a new harness, propose verifying it first: spawn a trivial supervised task using `fm-spawn`'s raw-launch-command escape hatch, confirm every fact empirically, then record the mechanics in `fm-spawn`, its semantic busy source and trust gate in `bin/fm-busy-lib.sh`, any needed `FM_COMPOSER_IDLE_RE` empty-composer override plus any novel bare agent prompt glyph in `bin/fm-composer-lib.sh`'s shared composer classifier (the one fleet-wide owner of the empty/dead-shell/pending decision, so a new harness's own idle composer is not misread as a dead shell), the tmux agent-process liveness classification in `bin/backends/tmux.sh` when the harness can launch a secondmate, and the verified knowledge here. +If the captain asks for a new harness, propose verifying it first: spawn a trivial supervised task using `fm-spawn`'s raw-launch-command escape hatch, confirm every fact empirically, then record the mechanics in `fm-spawn`, its semantic busy source and trust gate in `bin/fm-busy-lib.sh`, any new composer shape, prompt glyph, or idle placeholder in `bin/fm-composer-lib.sh`'s shared screen classifier (the ONE fleet-wide owner of every composer shape and the empty/dead-shell/pending decision - teaching it there gives every backend the shape in the same commit, and no adapter may carry its own copy), the tmux agent-process liveness classification in `bin/backends/tmux.sh` when the harness can launch a secondmate, and the verified knowledge here. ## Detection diff --git a/bin/backends/cmux.sh b/bin/backends/cmux.sh index 4bd093fe67e..0d9791216a3 100644 --- a/bin/backends/cmux.sh +++ b/bin/backends/cmux.sh @@ -529,98 +529,48 @@ fm_backend_cmux_capture() { # [expected-label] printf '%s' "$out" | tail -n "$lines" } -# fm_backend_cmux_composer_state: classify the composer's own row as -# empty|pending|unknown. Adapted from the bordered-row branch of herdr's -# structural classifier (fm_backend_herdr_composer_state) per the build task's -# explicit direction - this is the highest-risk piece of a new backend's -# send-and-verify logic, and cmux's `read-screen` gives plain-text capture -# with no cursor-row primitive and no ANSI style channel like herdr's newer -# `pane read --format ansi` path. Locate the LAST bordered composer row when -# one exists. Current Claude Code also renders a borderless composer as a bare -# agent-prompt row bounded by horizontal rules, which is the only bare shape -# accepted here because cmux cannot identify a cursor row. -FM_BACKEND_CMUX_COMPOSER_LINES=${FM_BACKEND_CMUX_COMPOSER_LINES:-20} -FM_BACKEND_CMUX_IDLE_RE=${FM_BACKEND_CMUX_IDLE_RE:-'^Type a message\.\.\.$'} - -fm_backend_cmux_horizontal_rule() { # - local remaining=$1 - remaining=${remaining//─/} - remaining=${remaining//[[:space:]]/} - [ -n "$1" ] && [ -z "$remaining" ] +# fm_backend_cmux_composer_capture: the cmux composer screen - a bounded +# plain-text tail of the surface. cmux's `read-screen` is plain text by +# construction (its --help: "Read terminal text from a surface as plain +# text"), which is why the capability descriptor below declares styled=0: the +# shared classifier then degrades a glyph row carrying trailing text to +# `unknown` instead of misreading an idle suggestion as unsent input. +fm_backend_cmux_composer_capture() { # [expected-label] + fm_backend_cmux_capture "$1" "$FM_COMPOSER_CAPTURE_LINES" "${2:-}" } -fm_backend_cmux_composer_state() { # [expected-label] -> empty|pending|unknown - local target=$1 expected_label=${2:-} cap line trimmed stripped="" bare="" bordered_index=-1 bare_index=-1 i - local -a rows=() - cap=$(fm_backend_cmux_capture "$target" "$FM_BACKEND_CMUX_COMPOSER_LINES" "$expected_label") || { printf 'unknown'; return 0; } - while IFS= read -r line; do - trimmed="${line#"${line%%[![:space:]]*}"}" - trimmed="${trimmed%"${trimmed##*[![:space:]]}"}" - [ -n "$trimmed" ] || continue - rows+=("$trimmed") - case "$trimmed" in - '│'*'│'|'┃'*'┃'|'|'*'|') - stripped=$trimmed - bordered_index=$((${#rows[@]} - 1)) - ;; - esac - done < <(printf '%s\n' "$cap") - for ((i = 1; i + 1 < ${#rows[@]}; i++)); do - fm_backend_cmux_horizontal_rule "${rows[i - 1]}" || continue - fm_backend_cmux_horizontal_rule "${rows[i + 1]}" || continue - case "${rows[i]}" in - '❯'*|'›'*|'⟩'*) - bare=${rows[i]} - bare_index=$i - ;; - esac - done - if [ "$bare_index" -gt "$bordered_index" ]; then - # cmux has no cursor-position primitive. The horizontal-rule container plus - # an agent-only prompt glyph is the structural proof for this bare row. - case "$bare" in - $'❯\302\240') bare="" ;; - esac - fm_composer_classify_content 0 "$bare" "$FM_BACKEND_CMUX_IDLE_RE" - return 0 - fi - [ "$bordered_index" -ge 0 ] || { printf 'unknown'; return 0; } - stripped=${stripped//│/} - stripped=${stripped//┃/} - stripped=${stripped//|/} - stripped="${stripped#"${stripped%%[![:space:]]*}"}" - stripped="${stripped%"${stripped##*[![:space:]]}"}" - # A bordered row is a genuine composer box. - fm_composer_classify_content 1 "$stripped" "$FM_BACKEND_CMUX_IDLE_RE" +# fm_backend_cmux_composer_caps: static capability facts, not logic (see the +# capability model in bin/fm-composer-lib.sh). +fm_backend_cmux_composer_caps() { + printf 'styled=0\ncursor=0\nidentity=0\nrows=%s\n' "$FM_COMPOSER_CAPTURE_LINES" +} + +# fm_backend_cmux_composer_state: thin adapter - capture plus capabilities in, +# shared verdict out. Every shape (including the borderless claude row this +# adapter once carried its own NBSP workaround for) lives in +# bin/fm-composer-lib.sh, so a new harness shape is taught there once and +# never here. cmux has no identity probe, so the classifier's identity +# sentinel resolves to unknown. +fm_backend_cmux_composer_state() { # [expected-label] -> empty|pending|pending-unproven|unknown + local cap verdict + cap=$(fm_backend_cmux_composer_capture "$1" "${2:-}") || { printf 'unknown'; return 0; } + verdict=$(fm_composer_classify_screen "$(fm_backend_cmux_composer_caps)" "$cap") + [ "$verdict" != need-identity ] || verdict=unknown + printf '%s' "$verdict" } # fm_backend_cmux_send_text_submit: type into once (raw, -# unsubmitted, via send_literal), then submit with a named Enter key, retried -# (Enter only, never retyped) until the composer's own row reads empty. -# Mirrors fm_backend_herdr_send_text_submit's ORIGINAL (composer-row) -# verification strategy: a slash-command popup's first Enter can close the -# popup and fill an argument-hint placeholder into the composer rather than -# submitting, which a raw-diff check would misread as "submitted" - -# classifying the composer row specifically avoids that false positive, so -# the retry loop correctly sends a second Enter when needed. Herdr's adapter -# has since moved its own confirmation to a native agent-state read instead -# (docs/herdr-backend.md "Native agent-state submit confirmation"); cmux has -# no analogous native primitive, so this composer-row approach remains -# cmux's own confirmation strategy. Echoes empty|pending|unknown|send-failed, a -# subset of the proof-carrying submit vocabulary. +# unsubmitted, via send_literal), then drive the shared verify-and-retry-Enter +# loop (bin/fm-composer-lib.sh: fm_composer_submit_retry_core) against the +# shared composer verdict. Echoes empty|pending|unknown|send-failed, a subset +# of the proof-carrying submit vocabulary. fm_backend_cmux_send_text_submit() { # [expected-label] - local target=$1 text=$2 retries=$3 sleep_s=$4 settle=$5 expected_label=${6:-} i=0 state + local target=$1 text=$2 retries=$3 sleep_s=$4 settle=$5 expected_label=${6:-} fm_backend_cmux_parse_target "$target" || { printf 'unknown'; return 0; } fm_backend_cmux_send_literal "$target" "$text" "$expected_label" || { printf 'send-failed'; return 0; } sleep "$settle" - while :; do - fm_backend_cmux_send_key "$target" Enter "$expected_label" || true - sleep "$sleep_s" - state=$(fm_backend_cmux_composer_state "$target" "$expected_label") - [ "$state" = pending ] || { printf '%s' "$state"; return 0; } - i=$((i + 1)) - [ "$i" -lt "$retries" ] || { printf 'pending'; return 0; } - done + fm_composer_submit_retry_core fm_backend_cmux_send_key fm_backend_cmux_composer_state \ + "$target" "$retries" "$sleep_s" "$expected_label" } # fm_backend_cmux_window_of_workspace: echo " " for diff --git a/bin/backends/herdr.sh b/bin/backends/herdr.sh index 7d9afa46412..40fcdb421ac 100644 --- a/bin/backends/herdr.sh +++ b/bin/backends/herdr.sh @@ -2607,156 +2607,17 @@ fm_backend_herdr_capture_ansi() { # printf '%s' "$out" | tail -n "$lines" } -# Thin adapter over the shared plain-text stripper (bin/fm-composer-lib.sh), -# used only for STRUCTURAL row/shape detection where ghost text must be kept so -# the box border or bare prompt glyph is still visible. Content extraction uses -# the shared fm_composer_strip_ghost instead. -fm_backend_herdr_strip_ansi() { # - printf '%s' "$1" | fm_composer_strip_ansi -} - -# fm_backend_herdr_composer_state: classify the composer's own row as -# empty|pending|unknown, scanning a generous tail-window capture of . -# herdr's CLI exposes no cursor-row primitive (unlike tmux's #{cursor_y}), so -# this locates the composer structurally, recognizing THREE shapes and keeping -# whichever match comes LAST (scanning forward), so a shape earlier in -# scrollback/a popup can never outrank the real (bottom-anchored) composer: +# --- herdr composer capture and capability primitives ----------------------- # -# bordered - a boxed composer (verified grok 0.2.82): the row's TRIMMED -# content both STARTS and ENDS with the same border glyph (│, ┃, -# or a plain ASCII |). The box's own top/bottom rows use rounded -# corners (╭─…─╮ / ╰─…─╯), which never match; popup item rows and -# horizontal separator rows carry no border glyph at all; the -# footer help line ("Enter:send │ … │ …") uses │ only as an -# INTERIOR separator and does not start with one, so it never -# matches either. -# bare - an UNBORDERED composer (verified real claude 2.x and codex -# 0.142.x, both under herdr 0.7.1, docs/herdr-backend.md -# "Incident (2026-07-07)"): the row's TRIMMED content starts with -# one of the verified agent-specific prompt glyphs but carries no -# closing border at all - claude's own live input row is a bare -# "❯ …" with no surrounding │, and codex's is a bare "› …". Both -# harnesses ALSO render bordered decorative boxes elsewhere (a -# startup welcome banner, an update-available notice) that -# satisfy the bordered shape above; requiring a match on EITHER -# shape and keeping the last (bottom-most) one is what keeps the -# live composer winning over a stale decorative box still sitting -# in the same capture window - a bordered box is only ever -# followed later on screen by the actual live composer, never the -# reverse, in every harness observed so far. The bare shape is -# deliberately narrower than the bordered content classifier so a -# no-agent shell fallback prompt (`>`, `$`, `%`, or `#`) falls -# through to `unknown` instead of being misread as delivered. -# separated - Pi's composer is one or more content rows between two solid -# horizontal `─` separator rows, with no prompt glyph or side -# borders. This shape is accepted ONLY when Herdr's native -# `agent get` identifies the target as Pi and reports it idle, -# done, or blocked. A missing/stale/non-Pi agent identity, a -# working Pi, an over-tall candidate, or an incomplete separator -# pair remains unknown. This identity + structure conjunction is -# what makes a blank Pi row safe without weakening dead-shell or -# ambiguous-pane refusal. -# -# empty - blank, a bare prompt glyph, known ghost/placeholder text -# ("Type a message...", verified grok 0.2.82's empty-composer -# placeholder), or only de-emphasised ANSI ghost/placeholder text -# recognized by the shared fm_composer_strip_ghost extractor -# (dim/faint or dark-TRUECOLOR foreground). Safe to treat as -# submitted. -# pending - real, unsubmitted text sits in the composer. This deliberately -# also covers a slash-command popup that just closed but only -# auto-completed or filled an argument-hint placeholder into the -# composer (e.g. "/compact" -> "/compact compaction -# instructions", verified live against real grok 0.2.82) - that -# first Enter is a SELECTION, not a submission. -# unknown - the pane could not be read, or no composer row (of either shape) -# was found in the captured window. -# -# Ghost/placeholder note: herdr's ANSI pane read preserves the harness's own -# de-emphasis styling, and the classifier extracts real typed content with the -# shared fm_composer_strip_ghost (bin/fm-composer-lib.sh), which drops dim/faint -# runs (claude's rotating prompt suggestion, codex's idle suggestion after the -# bare `›` prompt) AND dark/muted truecolor foreground runs (grok's placeholder), -# while keeping non-de-emphasised real typed input. This is the same owner the -# tmux adapter routes through, so the two backends cannot drift (task -# afk-herdr-false-pending); it superseded a herdr-only faint byte-pattern check -# that recognized only codex's bold-wrapped bare prompt and missed claude's own -# dim ghost - the overnight away-mode injection wedge on the primary claude pane. -FM_BACKEND_HERDR_COMPOSER_LINES=${FM_BACKEND_HERDR_COMPOSER_LINES:-20} -# Known ghost/placeholder composer text. Extend this if another -# herdr-verified harness needs its own idle placeholder recognized. -FM_BACKEND_HERDR_IDLE_RE=${FM_BACKEND_HERDR_IDLE_RE:-'^Type a message\.\.\.$'} -# Known bare (unbordered) prompt glyphs a composer row may start with: ❯ -# (claude) and › (codex) only. Generic shell-style glyphs > $ % # are still -# recognized after a bordered composer row has already been structurally found. -# Deliberately an alternation, not a `[...]` bracket expression: under a C/POSIX -# locale (LC_CTYPE=C, the fleet default), grep's bracket expressions match -# individual BYTES rather than whole multibyte characters, so `[❯›]` silently -# decomposes into the shared leading UTF-8 byte (0xE2) and spuriously matches -# ANY multibyte glyph in that range - including box-drawing corners like ╰, -# misclassifying a bordered composer's bottom border row as the bare shape. -# An alternation's branches are matched as whole literal byte sequences and -# stay correct regardless of locale. -FM_BACKEND_HERDR_BARE_PROMPT_RE=${FM_BACKEND_HERDR_BARE_PROMPT_RE:-'^(❯|›)'} -# Pi allows a multi-line composer between its horizontal separators. Bound the -# structural candidate so two unrelated transcript rules with an arbitrarily -# large region between them can never be promoted into a composer. -FM_BACKEND_HERDR_PI_COMPOSER_MAX_LINES=${FM_BACKEND_HERDR_PI_COMPOSER_MAX_LINES:-8} - -fm_backend_herdr_pi_separator_row() { # - local row=$1 - row="${row#"${row%%[![:space:]]*}"}" - row="${row%"${row##*[![:space:]]}"}" - [ "${#row}" -ge 8 ] || return 1 - [ -z "${row//─/}" ] -} - -# Locate the content and closing-row position of the bottom-most complete pair -# of Pi separator rows. A separator closes the preceding candidate and -# immediately opens the next, so an earlier transcript rule can never outrank -# the live bottom composer pair. Globals let the caller compare this shape's -# screen position with generic bordered/bare candidates without losing empty -# composer content through command substitution. -fm_backend_herdr_pi_composer_find() { # - local cap=$1 line plain open=0 lines=0 candidate="" max row=0 open_row=0 - max=$FM_BACKEND_HERDR_PI_COMPOSER_MAX_LINES - case "$max" in ''|*[!0-9]*|0) max=8 ;; esac - FM_BACKEND_HERDR_PI_PAIR_FOUND=0 - FM_BACKEND_HERDR_PI_PAIR_VALID=0 - FM_BACKEND_HERDR_PI_PAIR_OPEN_LINE=0 - FM_BACKEND_HERDR_PI_PAIR_LINE=0 - FM_BACKEND_HERDR_PI_LAST_SEPARATOR_LINE=0 - FM_BACKEND_HERDR_PI_CONTENT="" - while IFS= read -r line; do - row=$((row + 1)) - plain=$(fm_backend_herdr_strip_ansi "$line") - if fm_backend_herdr_pi_separator_row "$plain"; then - FM_BACKEND_HERDR_PI_LAST_SEPARATOR_LINE=$row - if [ "$open" -eq 1 ]; then - FM_BACKEND_HERDR_PI_PAIR_FOUND=1 - FM_BACKEND_HERDR_PI_PAIR_OPEN_LINE=$open_row - FM_BACKEND_HERDR_PI_PAIR_LINE=$row - if [ "$lines" -le "$max" ]; then - FM_BACKEND_HERDR_PI_PAIR_VALID=1 - FM_BACKEND_HERDR_PI_CONTENT=$candidate - else - FM_BACKEND_HERDR_PI_PAIR_VALID=0 - FM_BACKEND_HERDR_PI_CONTENT="" - fi - fi - open=1 - open_row=$row - lines=0 - candidate="" - elif [ "$open" -eq 1 ]; then - [ -z "$candidate" ] || candidate="${candidate}"$'\n' - candidate="${candidate}${line}" - lines=$((lines + 1)) - fi - done < -> \t local out @@ -2764,106 +2625,43 @@ fm_backend_herdr_agent_identity_raw() { # -> \t printf '%s' "$out" | jq -r '[.result.agent.agent // "", .result.agent.agent_status // ""] | @tsv' 2>/dev/null } -fm_backend_herdr_composer_state() { # -> empty|pending|unknown - local target=$1 session pane cap line trimmed found=0 shape="" raw_match="" bordered=0 stripped - local identity agent agent_status row=0 generic_line=0 +# fm_backend_herdr_composer_identity: the native agent identity/state probe +# backing the shared classifier's separated (pi) shape - the genuine herdr +# primitive no other backend has natively. +fm_backend_herdr_composer_identity() { # -> "\t" + fm_backend_herdr_parse_target "$1" || return 1 + fm_backend_herdr_agent_identity_raw "$FM_BACKEND_HERDR_SESSION" "$FM_BACKEND_HERDR_PANE" +} + +# fm_backend_herdr_composer_state: thin adapter - capture plus capabilities +# in, shared verdict out. The ANSI capture is preferred (styled=1 lets the +# shared classifier strip ghost/placeholder text); when it fails on an older +# herdr, the plain capture degrades the descriptor to styled=0 rather than +# letting ghost text be misread as typed input. Identity is fetched lazily, +# only when the classifier reports the verdict depends on it (a pi separator +# pair below every other candidate), preserving this adapter's original +# consult-only-when-needed behavior. +fm_backend_herdr_composer_state() { # -> empty|pending|pending-unproven|unknown + local target=$1 cap caps verdict identity fm_backend_herdr_parse_target "$target" || { printf 'unknown'; return 0; } - session=$FM_BACKEND_HERDR_SESSION - pane=$FM_BACKEND_HERDR_PANE - cap=$(fm_backend_herdr_capture_ansi "$target" "$FM_BACKEND_HERDR_COMPOSER_LINES" 2>/dev/null \ - || fm_backend_herdr_capture "$target" "$FM_BACKEND_HERDR_COMPOSER_LINES") || { printf 'unknown'; return 0; } - # Structural scan: locate the bottom-most composer row and remember its RAW - # (styled) bytes. Shape detection runs on the plain row (fm_backend_herdr_strip_ansi - # keeps ghost text so the border/prompt glyph is still visible); the raw row is - # kept for ANSI-aware content extraction after the scan. - while IFS= read -r line; do - row=$((row + 1)) - trimmed=$(fm_backend_herdr_strip_ansi "$line") - trimmed="${trimmed#"${trimmed%%[![:space:]]*}"}" - trimmed="${trimmed%"${trimmed##*[![:space:]]}"}" - [ -n "$trimmed" ] || continue - case "$trimmed" in - '│'*'│'|'┃'*'┃'|'|'*'|') - shape=bordered - raw_match=$line - generic_line=$row - found=1 - ;; - *) - if printf '%s' "$trimmed" | grep -qE "$FM_BACKEND_HERDR_BARE_PROMPT_RE"; then - shape=bare - raw_match=$line - generic_line=$row - found=1 - fi - ;; - esac - done < <(printf '%s\n' "$cap") - # Pi has no prompt glyph or side border. Compare its bottom-most complete - # separator pair with the last generic match so an earlier bordered transcript - # row can never suppress the live Pi composer. Identity is consulted only when - # a lower separator pair could change the verdict. - fm_backend_herdr_pi_composer_find "$cap" - if [ "$FM_BACKEND_HERDR_PI_PAIR_FOUND" -eq 1 ] \ - && [ "$FM_BACKEND_HERDR_PI_PAIR_LINE" -gt "$generic_line" ] \ - && [ "$generic_line" -lt "$FM_BACKEND_HERDR_PI_PAIR_OPEN_LINE" ]; then - identity=$(fm_backend_herdr_agent_identity_raw "$session" "$pane" 2>/dev/null || true) - IFS=$'\t' read -r agent agent_status </dev/null); then + caps=$(printf 'styled=1\ncursor=0\nidentity=1\nrows=%s' "$FM_COMPOSER_CAPTURE_LINES") + elif cap=$(fm_backend_herdr_capture "$target" "$FM_COMPOSER_CAPTURE_LINES"); then + caps=$(printf 'styled=0\ncursor=0\nidentity=1\nrows=%s' "$FM_COMPOSER_CAPTURE_LINES") + else + printf 'unknown' + return 0 + fi + verdict=$(fm_composer_classify_screen "$caps" "$cap") + if [ "$verdict" = need-identity ]; then + if identity=$(fm_backend_herdr_composer_identity "$target" 2>/dev/null) && [ -n "$identity" ]; then + verdict=$(fm_composer_classify_screen "$caps" "$cap" '' "$identity") + else + verdict=unknown + fi + [ "$verdict" != need-identity ] || verdict=unknown + fi + printf '%s' "$verdict" } # fm_backend_herdr_send_text_submit: type into once (raw, diff --git a/bin/backends/orca.sh b/bin/backends/orca.sh index dc9307de4f6..422a732313b 100644 --- a/bin/backends/orca.sh +++ b/bin/backends/orca.sh @@ -223,76 +223,34 @@ if (r.terminal && Array.isArray(r.terminal.tail)) { ' } -fm_backend_orca_json_field() { # - local field=$1 - printf '%s' "$2" | node -e ' -const fs = require("fs"); -const field = process.argv[1]; -const data = JSON.parse(fs.readFileSync(0, "utf8")); -if (data.ok === false) process.exit(2); -const r = data.result || {}; -const term = r.terminal || {}; -function scalar(v) { - return (typeof v === "string" || typeof v === "number" || typeof v === "boolean") ? String(v) : ""; -} -let v = ""; -if (field === "limited") v = scalar(r.limited ?? term.limited); -if (field === "oldestCursor") v = scalar(r.oldestCursor || term.oldestCursor); -if (field === "nextCursor") v = scalar(r.nextCursor || term.nextCursor); -if (field === "latestCursor") v = scalar(r.latestCursor || term.latestCursor); -if (!v) process.exit(1); -process.stdout.write(v); -' "$field" +# fm_backend_orca_composer_capture: the orca composer screen - one bounded +# tail read of the live terminal. Deliberately NOT the old 200-line +# backward-paged read: the composer is bottom-anchored, and paging back into +# scrollback is what let a stale startup banner (codex's bordered +# "permissions" box) compete with - and once outrank - the live composer. +fm_backend_orca_composer_capture() { # [expected-label] + fm_backend_orca_capture "$1" "$FM_COMPOSER_CAPTURE_LINES" } -fm_backend_orca_read_text_paged() { # - local terminal=$1 limit=${2:-200} out limited oldest cursor_out text older_text - fm_backend_orca_tool_check || return 1 - out=$(orca terminal read --terminal "$terminal" --limit "$limit" --json) || return 1 - printf '%s' "$out" | fm_backend_orca_json_ok || return 1 - text=$(fm_backend_orca_json_text "$out") || return 1 - limited=$(fm_backend_orca_json_field limited "$out" 2>/dev/null || true) - oldest=$(fm_backend_orca_json_field oldestCursor "$out" 2>/dev/null || true) - if [ "$limited" = true ] && [ -n "$oldest" ]; then - cursor_out=$(orca terminal read --terminal "$terminal" --cursor "$oldest" --limit "$limit" --json) || return 1 - printf '%s' "$cursor_out" | fm_backend_orca_json_ok || return 1 - older_text=$(fm_backend_orca_json_text "$cursor_out") || return 1 - text="${older_text}"$'\n'"${text}" - fi - printf '%s' "$text" +# fm_backend_orca_composer_caps: static capability facts, not logic (see the +# capability model in bin/fm-composer-lib.sh). Orca's `terminal read` returns +# plain text; whether it can emit ANSI is unverified (orca is not installed +# on the verification machine), so styled stays 0 - the conservative +# degradation - until a live capture proves otherwise. +fm_backend_orca_composer_caps() { + printf 'styled=0\ncursor=0\nidentity=0\nrows=%s\n' "$FM_COMPOSER_CAPTURE_LINES" } -FM_BACKEND_ORCA_COMPOSER_LINES=${FM_BACKEND_ORCA_COMPOSER_LINES:-200} -FM_BACKEND_ORCA_IDLE_RE=${FM_BACKEND_ORCA_IDLE_RE:-'^Type a message\.\.\.$'} - -# fm_backend_orca_composer_state: classify the composer's own bordered row as -# empty|pending|unknown. Real text stays pending, including a slash-command -# popup that closed by filling an argument-hint placeholder into the composer; -# that first Enter selected the popup item, it did not submit the command. -fm_backend_orca_composer_state() { # -> empty|pending|unknown - local terminal=$1 cap line trimmed stripped="" found=0 - cap=$(fm_backend_orca_read_text_paged "$terminal" "$FM_BACKEND_ORCA_COMPOSER_LINES") || { printf 'unknown'; return 0; } - while IFS= read -r line; do - trimmed="${line#"${line%%[![:space:]]*}"}" - trimmed="${trimmed%"${trimmed##*[![:space:]]}"}" - [ -n "$trimmed" ] || continue - case "$trimmed" in - '│'*'│'|'┃'*'┃'|'|'*'|') : ;; - *) continue ;; - esac - stripped=$trimmed - found=1 - done < <(printf '%s\n' "$cap") - [ "$found" -eq 1 ] || { printf 'unknown'; return 0; } - stripped=${stripped//│/} - stripped=${stripped//┃/} - stripped=${stripped//|/} - stripped="${stripped#"${stripped%%[![:space:]]*}"}" - stripped="${stripped%"${stripped##*[![:space:]]}"}" - # A row was found only by the bordered shape above, so content came from a - # genuine composer box - delegate to the shared owner with bordered=1. A bare - # dead-shell prompt has no bordered row and already returned 'unknown' above. - fm_composer_classify_content 1 "$stripped" "$FM_BACKEND_ORCA_IDLE_RE" +# fm_backend_orca_composer_state: thin adapter - capture plus capabilities in, +# shared verdict out. Every shape (bordered boxes AND the borderless bare-glyph +# row this adapter never learned, which left every claude/codex/pi/muse steer +# unconfirmed) lives in bin/fm-composer-lib.sh. +fm_backend_orca_composer_state() { # [expected-label] -> empty|pending|pending-unproven|unknown + local cap verdict + cap=$(fm_backend_orca_composer_capture "$1") || { printf 'unknown'; return 0; } + verdict=$(fm_composer_classify_screen "$(fm_backend_orca_composer_caps)" "$cap") + [ "$verdict" != need-identity ] || verdict=unknown + printf '%s' "$verdict" } fm_backend_orca_send_key() { # @@ -312,22 +270,18 @@ fm_backend_orca_send_key() { # esac } -# fm_backend_orca_send_text_submit: type once, then retry Enter until -# the composer row reads empty. Retries send only Enter, so a slash-command -# popup placeholder fill gets the required second Enter without duplicating text. +# fm_backend_orca_send_text_submit: type once, then drive the shared +# verify-and-retry-Enter loop (bin/fm-composer-lib.sh: +# fm_composer_submit_retry_core) against the shared composer verdict, so a +# slash-command popup placeholder fill gets the required second Enter without +# duplicating text. fm_backend_orca_send_text_submit() { # - local terminal=$1 text=$2 retries=$3 sleep_s=$4 settle=$5 i=0 state + local terminal=$1 text=$2 retries=$3 sleep_s=$4 settle=$5 fm_backend_orca_tool_check || { printf 'send-failed'; return 0; } fm_backend_orca_send_literal "$terminal" "$text" || { printf 'send-failed'; return 0; } sleep "$settle" - while :; do - fm_backend_orca_send_key "$terminal" Enter || true - sleep "$sleep_s" - state=$(fm_backend_orca_composer_state "$terminal") - [ "$state" = pending ] || { printf '%s' "$state"; return 0; } - i=$((i + 1)) - [ "$i" -lt "$retries" ] || { printf 'pending'; return 0; } - done + fm_composer_submit_retry_core fm_backend_orca_send_key fm_backend_orca_composer_state \ + "$terminal" "$retries" "$sleep_s" } fm_backend_orca_kill() { # diff --git a/bin/backends/zellij.sh b/bin/backends/zellij.sh index d00dcdebae3..332830a4859 100644 --- a/bin/backends/zellij.sh +++ b/bin/backends/zellij.sh @@ -119,6 +119,11 @@ FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" # shellcheck source=bin/fm-backend-hometag-lib.sh . "$FM_BACKEND_ZELLIJ_ROOT/bin/fm-backend-hometag-lib.sh" +# Shared composer classification (the fleet-wide shape catalogue and verdict +# owner; this adapter contributes only capture and capability facts). +# shellcheck source=bin/fm-composer-lib.sh +. "$FM_BACKEND_ZELLIJ_ROOT/bin/fm-composer-lib.sh" + # Verified minimum: report.md recommends "likely Zellij 0.44 or newer" for # returned pane/tab IDs and dump-screen --pane-id; empirically verified # against the installed 0.44.0 (docs/zellij-backend.md). @@ -488,36 +493,62 @@ fm_backend_zellij_capture() { # [expected-label] printf '%s' "$out" | tail -n "$lines" } +# --- zellij composer capture and capability primitives ---------------------- +# +# `zellij action dump-screen --ansi` ("Preserve ANSI styling in the dump +# output", verified live at zellij 0.44.0 against real Claude Code) gives +# zellij a styled capture, so the shared classifier reads its composer with +# the same ghost-stripping confidence as tmux and herdr. Every shape lives in +# the shared owner (bin/fm-composer-lib.sh, fm_composer_classify_screen); +# this adapter contributes only the capture and its capability facts. + +# fm_backend_zellij_composer_capture: bounded styled tail of the pane. When +# --ansi is unsupported (an older zellij), the caller falls back to the plain +# dump and a styled=0 descriptor - see fm_backend_zellij_composer_state. +fm_backend_zellij_composer_capture() { # [expected-label] + fm_backend_zellij_target_ready "$1" "${2:-}" || return 1 + local out + out=$(fm_backend_zellij_cli "$FM_BACKEND_ZELLIJ_SESSION" action dump-screen --pane-id "$FM_BACKEND_ZELLIJ_PANE" --ansi 2>/dev/null) || return 1 + [ -n "$out" ] || return 1 + printf '%s' "$out" | tail -n "$FM_COMPOSER_CAPTURE_LINES" +} + +# fm_backend_zellij_composer_state: thin adapter - capture plus capabilities +# in, shared verdict out. This replaced the content-diff submit heuristic +# that was the fleet's only FALSE-POSITIVE delivery confirmation: a pane +# whose content changed for any reason (a spinner, streaming output, a +# clock) read as "submitted", which could close a --resolve-key decision for +# a message the crew never received. A dead pane still fails safe here: the +# unconditional-exit-0 CLI quirk (file header) yields an empty dump, which +# classifies unknown - never a confirmation. +fm_backend_zellij_composer_state() { # [expected-label] -> empty|pending|pending-unproven|unknown + local target=$1 expected_label=${2:-} cap caps verdict + if cap=$(fm_backend_zellij_composer_capture "$target" "$expected_label"); then + caps=$(printf 'styled=1\ncursor=0\nidentity=0\nrows=%s' "$FM_COMPOSER_CAPTURE_LINES") + elif cap=$(fm_backend_zellij_capture "$target" "$FM_COMPOSER_CAPTURE_LINES" "$expected_label") && [ -n "$cap" ]; then + caps=$(printf 'styled=0\ncursor=0\nidentity=0\nrows=%s' "$FM_COMPOSER_CAPTURE_LINES") + else + printf 'unknown' + return 0 + fi + verdict=$(fm_composer_classify_screen "$caps" "$cap") + [ "$verdict" != need-identity ] || verdict=unknown + printf '%s' "$verdict" +} + # fm_backend_zellij_send_text_submit: type into once (raw, -# unsubmitted, via send_literal), then submit with a named Enter key, retried -# (Enter only, never retyped) until the pane visibly changes. Unlike herdr's -# current native agent-state idle-baseline verifier and composer-state -# fallback, zellij still uses a content-diff strategy because its CLI has no -# cursor-row/ANSI capture primitive exposed: -# capture the pane right after typing (before any Enter) as the TYPED baseline, -# then after each Enter attempt capture again - unchanged means Enter was -# swallowed (retry); changed means submitted. This content-diff approach is -# also the load-bearing defense against the -# unconditional-exit-0 CLI quirk documented in the file header: a truly dead -# target never shows a change, so it correctly reports pending/unknown rather -# than a false "sent". Echoes empty|pending|unknown|send-failed, a subset of the -# proof-carrying submit vocabulary. +# unsubmitted, via send_literal), then drive the shared verify-and-retry-Enter +# loop (bin/fm-composer-lib.sh: fm_composer_submit_retry_core) against the +# real composer verdict above. Echoes empty|pending|unknown|send-failed, a +# subset of the proof-carrying submit vocabulary. Only a positively classified +# empty composer confirms delivery - a pane that merely CHANGED does not, so +# the old heuristic's false "delivery confirmed" cannot recur. fm_backend_zellij_send_text_submit() { # [expected-label] - local target=$1 text=$2 retries=$3 sleep_s=$4 settle=$5 expected_label=${6:-} typed after i=0 + local target=$1 text=$2 retries=$3 sleep_s=$4 settle=$5 expected_label=${6:-} fm_backend_zellij_send_literal "$target" "$text" "$expected_label" || { printf 'send-failed'; return 0; } sleep "$settle" - typed=$(fm_backend_zellij_capture "$target" 6 "$expected_label") || { printf 'unknown'; return 0; } - while :; do - fm_backend_zellij_send_key "$target" Enter "$expected_label" || true - sleep "$sleep_s" - after=$(fm_backend_zellij_capture "$target" 6 "$expected_label") || { printf 'unknown'; return 0; } - if [ "$after" != "$typed" ]; then - printf 'empty' - return 0 - fi - i=$((i + 1)) - [ "$i" -lt "$retries" ] || { printf 'pending'; return 0; } - done + fm_composer_submit_retry_core fm_backend_zellij_send_key fm_backend_zellij_composer_state \ + "$target" "$retries" "$sleep_s" "$expected_label" } # fm_backend_zellij_kill: remove the task's tab, best-effort (mirrors diff --git a/bin/fm-backend.sh b/bin/fm-backend.sh index e505b99f757..2882f4a6af2 100644 --- a/bin/fm-backend.sh +++ b/bin/fm-backend.sh @@ -793,19 +793,19 @@ fm_backend_busy_state() { # esac } -# fm_backend_composer_state: classify the composer/input row of as +# fm_backend_composer_state: classify the composer/input area of as # empty|pending|pending-unproven|unknown for callers that need a pre-submit -# input guard or an adapter's conservative submit fallback. It is exposed so a -# caller other than the send path (the away-mode daemon's supervisor-pane -# pending-input guard, bin/fm-supervise-daemon.sh) can ask the same question -# without duplicating per-backend composer-reading logic. tmux and herdr both -# expose a named classifier already (fm_tmux_composer_state, -# fm_backend_herdr_composer_state), as do orca and cmux -# (fm_backend_orca_composer_state, fm_backend_cmux_composer_state); zellij's -# submit path uses an internal content-diff approach with no separately named -# classifier, so it reports unknown here - callers fall back to their own -# policy, exactly as an unknown fm_backend_busy_state already does. -fm_backend_composer_state() { # -> empty|pending|pending-unproven|unknown +# input guard, a submit acknowledgement, or a launch-readiness check. It is +# exposed so a caller other than the send path (the away-mode daemon's +# supervisor-pane pending-input guard in bin/fm-supervise-daemon.sh, and +# fm-spawn.sh's kimi readiness/delivery checks) can ask the same question +# without duplicating per-backend composer reading. Every adapter's named +# classifier is a THIN wrapper - capture plus a capability descriptor fed to +# the one shared shape owner (bin/fm-composer-lib.sh, +# fm_composer_classify_screen) - so no backend can hold a private shape +# assumption; zellij's classifier reads `dump-screen --ansi`, which replaced +# its old no-classifier content-diff reporting. +fm_backend_composer_state() { # [expected-label] -> empty|pending|pending-unproven|unknown local backend=$1 shift fm_backend_source "$backend" || { printf 'unknown'; return 0; } @@ -814,6 +814,7 @@ fm_backend_composer_state() { # -> empty|pending|pending-unp herdr) fm_backend_herdr_composer_state "$@" ;; orca) fm_backend_orca_composer_state "$@" ;; cmux) fm_backend_cmux_composer_state "$@" ;; + zellij) fm_backend_zellij_composer_state "$@" ;; *) printf 'unknown' ;; esac } diff --git a/bin/fm-composer-lib.sh b/bin/fm-composer-lib.sh index b7b795c09b0..e0716ed7fd8 100644 --- a/bin/fm-composer-lib.sh +++ b/bin/fm-composer-lib.sh @@ -1,57 +1,106 @@ #!/usr/bin/env bash -# bin/fm-composer-lib.sh - the ONE fleet-wide owner of composer-content -# classification, shared by every session-provider adapter: the tmux path -# through bin/fm-tmux-lib.sh, and bin/backends/{herdr,orca,cmux}.sh directly. +# bin/fm-composer-lib.sh - the ONE fleet-wide owner of composer classification: +# every shape a verified harness draws, every glyph, every container proof, and +# the empty|pending|pending-unproven|unknown verdict, shared by every +# session-provider adapter (tmux via bin/fm-tmux-lib.sh, and +# bin/backends/{herdr,orca,cmux,zellij}.sh) and by fm-spawn.sh's kimi +# launch-readiness check. # -# WHY THIS EXISTS (task fm-composer-shellglyph-safety): the four adapters each -# carried their own copy of the "is this composer row empty / pending / not an -# agent composer" decision, and the copies drifted. The dangerous drift: a BARE -# shell prompt glyph (`>`, `$`, `%`, `#`) - what a pane shows once its agent has -# exited to a plain login shell - was treated as an empty, ready-to-inject -# AGENT composer. The away-mode escalation injector (bin/fm-supervise-daemon.sh) -# reads composer-emptiness to decide whether a pane is a safe injection target, -# so a dead-shell pane misread as "empty" meant an escalation could be typed -# into (and, worst case, executed by) that shell. Consolidating the one decision -# here means the safety rule cannot silently drift across adapters again. +# WHY THIS EXISTS (tasks fm-composer-shellglyph-safety and +# fm-composer-thin-adapter-refactor-r1): the adapters each carried their own +# copy of composer shape knowledge, and every copy drifted. The audited result +# (data/fm-composer-consolidation-audit-s1) was a 5-adapter x 6-harness matrix +# in which no adapter was right about more than five harnesses, no two adapters +# were wrong in the same places, and one harness was unreadable everywhere. +# The consolidation rule that prevents a recurrence: an adapter CAPTURES a +# screen and DESCRIBES its capabilities; it never classifies. A new harness +# shape is taught to fm_composer_classify_screen below, once, and every backend +# that can capture a screen learns it in the same commit. # -# THE SAFETY RULE this owner enforces: a bare shell prompt glyph is a genuine -# empty agent composer ONLY when it appears INSIDE a real agent-composer -# container - a bordered composer box, where the harness draws its own prompt -# glyph (e.g. claude's older `| > ... |`). On a bare, unstructured row it is a -# dead-shell prompt and is NEVER "empty"; it classifies as `unknown` (not a safe -# injection target). The AGENT prompt glyphs `❯` (claude), `›` (codex), and -# `⟩` (U+27E9, muse) are a genuine empty agent composer either way, bordered or -# bare. Every agent glyph must be listed in ALL THREE places below - the -# ghost-stripped-to-empty fallback, the bare-row case, and the leading-glyph -# strip - because a glyph present in only some of them classifies inconsistently -# depending on how its harness happens to colour the row. +# THE CAPABILITY MODEL: adapters differ in what their capture primitive can +# see, and those differences enter here as DATA (the argument), never as +# adapter code. Capability differences change how CONFIDENTLY a shape can be +# judged; they never change what the shapes ARE: +# styled=1 the capture preserves ANSI styling, so ghost/placeholder text +# is detectable and can be stripped (tmux -e, herdr --format +# ansi, zellij dump-screen --ansi). With styled=0 (cmux, orca) +# ghost text is unreadable, so a bare glyph row or left-bar row +# carrying trailing non-idle text degrades to `unknown` rather +# than `pending`: the text may be the harness's own idle +# suggestion, and a false `pending` blocks every safe caller. +# cursor=1 a cursor row is supplied (tmux #{cursor_y} only). The cursor +# anchors shape selection: the shape containing the cursor is the +# composer. Without it, the bottom-most shape wins. +# identity=1 a native agent identity/state probe exists (herdr `agent get`; +# the tmux pi foreground-process probe). Identity is what makes +# Pi's blank separated composer provable; with identity=0 that +# shape stays `unknown`. +# rows= the capture's bounded row count (informational). # -# GHOST/PLACEHOLDER TEXT is the other half of this owner (task -# afk-herdr-false-pending): a harness fills an otherwise-empty composer with -# de-emphasized ghost text - claude's rotating prompt suggestion, codex's idle -# suggestion, grok's placeholder - which a plain capture cannot tell apart from -# text a human typed, so the away-mode injector reads the idle pane as "pending -# input" and defers every escalation (the overnight wedge that motivated this -# consolidation). fm_composer_strip_ghost is the ONE ANSI-aware extractor of -# "real typed content": it drops every de-emphasized run - dim/faint (SGR 2, how -# claude and codex render ghost text) AND a dark/muted TRUECOLOR foreground (how -# grok renders placeholder/hint text) - and keeps only normal-intensity, -# normally-coloured text. Consolidating it here means the two ANSI-capable -# adapters (tmux via bin/fm-tmux-lib.sh, herdr via bin/backends/herdr.sh) cannot -# drift into per-harness one-off strips again; the previous herdr-only faint -# byte-pattern check missed claude's own dim ghost (its prompt glyph is not -# bold-wrapped) and no adapter covered grok's truecolor placeholder at all. +# THE STRICT BLANK-ROW RULE (captain decision blank-row-injection-posture, +# 2026-08-09): a blank or otherwise unidentified input row with no positive +# container proof is `unknown` and callers defer. This replaced tmux's +# permissive "blank cursor row = empty = safe to inject" rule fleet-wide: a +# blank row under the cursor can be a modal dialog, a dead shell between +# transcript rules, or a mid-redraw pane, and the away-mode injector types +# escalations into whatever it calls empty. Positive container proof means one +# of the shapes in the catalogue below. # -# Each adapter still owns its own CAPTURE and structural row-finding, because -# those use genuinely different primitives (tmux's visible-pane box scan, -# herdr's ANSI tail scan, orca/cmux's plain read-screen). Once an adapter has a -# candidate composer row it hands the RAW styled row to -# fm_composer_strip_ghost for the real-typed-content extraction, strips the box -# borders, trims, and hands the result plus a flag to -# fm_composer_classify_content for the shared -# empty|pending|unknown verdict. orca/cmux read a plain (unstyled) screen so -# they have no ghost styling to strip and rely on the idle-placeholder match -# below. Re-sourcing is a cheap idempotent redefinition, so this file needs no +# THE SHAPE CATALOGUE (all verified against real harnesses; byte-level +# captures in data/fm-composer-consolidation-audit-s1/report.md and +# docs/verification/runtime-backends.md): +# bordered - a complete boxed composer: a top border, side-bordered content +# rows of the same family, and a bottom border (grok, kimi, +# older claude). The bottom border may carry a TITLE (grok +# writes its model name there); a titled bottom border that +# still starts and ends with the family's rule glyph is +# tolerated, not ambiguity. +# 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. +# 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. +# separated - pi: content rows between two solid horizontal `─` rules, no +# glyph and no side border. Provable only with a live agent +# identity reporting an idle/done/blocked pi (herdr `agent +# get`; the tmux foreground-process probe), because a blank +# region between two transcript rules is otherwise exactly the +# strict rule's unidentifiable blank row. +# +# THE SAFETY RULE for glyphs: a bare shell prompt glyph (`>` `$` `%` `#`) - +# what a pane shows once its agent has exited to a plain login shell - is a +# genuine empty agent composer ONLY inside a bordered container. On a bare row +# it is a dead-shell prompt and classifies `unknown` (never a safe injection +# target). The AGENT glyphs `❯` (claude), `›` (codex), and `⟩` (U+27E9, muse) +# are a genuine empty agent composer either way. Both glyph sets are declared +# exactly once below; every decision reaches them through the declarations. +# +# GHOST/PLACEHOLDER TEXT (task afk-herdr-false-pending): a harness fills an +# otherwise-empty composer with de-emphasized ghost text - claude's rotating +# prompt suggestion, codex's idle suggestion, grok's placeholder - which a +# plain capture cannot tell apart from text a human typed. +# fm_composer_strip_ghost is the ONE ANSI-aware extractor of "real typed +# content": it drops every de-emphasized run - dim/faint (SGR 2) AND a +# dark/muted TRUECOLOR foreground - and keeps only normal-intensity, +# normally-coloured text. +# +# UNICODE WHITESPACE (issue #1988; open PRs #1995/#2047 target the same +# defect and #1995's naming is adopted here so the implementations converge): +# a harness may separate its prompt glyph from composer content with a +# non-ASCII space. Real claude 2.x draws its EMPTY composer as exactly `❯` +# followed by U+00A0 NO-BREAK SPACE. POSIX `[[:space:]]` includes U+00A0 only +# under some locales, so every trim used to be locale-dependent: the same live +# pane read `empty` under a UTF-8 shell and `pending` under LC_ALL=C (a +# daemon, launchd, or ssh context), deferring every away-mode escalation. +# fm_composer_normalize_trim_var is the one fix: it maps every code point +# Unicode gives the property White_Space=Yes outside ASCII onto a plain ASCII +# space before any trim or comparison, byte-exactly, so the verdict cannot +# depend on the ambient locale. Glyph strips use literal byte-exact pattern +# removal for the same reason: `${v#?}` removes one BYTE under LC_ALL=C and +# one CHARACTER under UTF-8, which used to leave partial multibyte residue. +# +# Re-sourcing is a cheap idempotent redefinition, so this file needs no # include guard (matching bin/fm-tmux-lib.sh). # fm_composer_strip_ansi: drop every CSI escape sequence, leaving plain text. @@ -66,9 +115,63 @@ fm_composer_strip_ansi() { LC_ALL=C sed "s/${esc}\\[[0-9;:?]*[[:alpha:]]//g" } +# Every code point Unicode gives the property White_Space=Yes that lies OUTSIDE +# ASCII, as UTF-8 byte sequences. Built from octal escapes rather than written +# literally so each entry stays reviewable in source instead of being an +# invisible character: +# U+0085 NEXT LINE U+00A0 NO-BREAK SPACE +# U+1680 OGHAM SPACE MARK U+2000..U+200A EN QUAD..HAIR SPACE +# U+2028 LINE SEPARATOR U+2029 PARAGRAPH SEPARATOR +# U+202F NARROW NO-BREAK SPACE U+205F MEDIUM MATHEMATICAL SPACE +# U+3000 IDEOGRAPHIC SPACE +# ASCII whitespace is absent because POSIX `[[:space:]]` already covers it. +# U+200B ZERO WIDTH SPACE is deliberately absent: Unicode gives it +# White_Space=No (a format character), so listing it would substitute this +# owner's own guess for the property it claims to follow. The live harness +# guard (bin/fm-test-run.sh, live-harness-optin) is what catches a harness +# that starts drawing its composer with a character outside this property. +FM_COMPOSER_UNICODE_SPACES=() +for _fm_composer_space_octal in \ + '\0302\0205' '\0302\0240' '\0341\0232\0200' \ + '\0342\0200\0200' '\0342\0200\0201' '\0342\0200\0202' '\0342\0200\0203' \ + '\0342\0200\0204' '\0342\0200\0205' '\0342\0200\0206' '\0342\0200\0207' \ + '\0342\0200\0210' '\0342\0200\0211' '\0342\0200\0212' \ + '\0342\0200\0250' '\0342\0200\0251' '\0342\0200\0257' \ + '\0342\0201\0237' '\0343\0200\0200'; do + printf -v _fm_composer_space_utf8 '%b' "$_fm_composer_space_octal" + FM_COMPOSER_UNICODE_SPACES+=("$_fm_composer_space_utf8") +done +unset -v _fm_composer_space_octal _fm_composer_space_utf8 + +# fm_composer_normalize_spaces_var: the ONE Unicode-whitespace mapping. +# Replaces in place through the named variable so no caller needs a subshell. +# Substitution, never deletion: deleting would silently join "foobar" +# into one token, while a space preserves the separation the harness drew. +fm_composer_normalize_spaces_var() { # + local __fmns_name=$1 __fmns_text=${!1} __fmns_space + for __fmns_space in "${FM_COMPOSER_UNICODE_SPACES[@]}"; do + __fmns_text=${__fmns_text//"$__fmns_space"/ } + done + printf -v "$__fmns_name" '%s' "$__fmns_text" +} + +# fm_composer_normalize_trim_var: the one whitespace-normalizing trim shared by +# this owner and every structural row scan - map Unicode whitespace onto ASCII +# space, then strip leading and trailing whitespace, in place through the named +# variable. Idempotent, locale-independent. +fm_composer_normalize_trim_var() { # + local __fmnt_name=$1 __fmnt_text + fm_composer_normalize_spaces_var "$__fmnt_name" + __fmnt_text=${!__fmnt_name} + __fmnt_text="${__fmnt_text#"${__fmnt_text%%[![:space:]]*}"}" + __fmnt_text="${__fmnt_text%"${__fmnt_text##*[![:space:]]}"}" + printf -v "$__fmnt_name" '%s' "$__fmnt_text" +} + # fm_composer_strip_ghost: the ONE fleet-wide ANSI-aware extractor of "real typed # content" from a captured, styled composer row. Reads the styled line on stdin -# (from `tmux capture-pane -e` or `herdr pane read --format ansi`) and prints the +# (from `tmux capture-pane -e`, `herdr pane read --format ansi`, or +# `zellij action dump-screen --ansi`) and prints the # plain, non-ghost text on stdout, dropping: # - dim/faint runs (SGR 2): how claude and codex render ghost/suggestion text. # A reset (SGR 0) or normal-intensity (SGR 22) ends a dim run. @@ -169,17 +272,90 @@ fm_composer_strip_ghost() { ' } -# fm_composer_classify_content: the single shared composer-content verdict. -# 1 when came from a genuine agent-composer container (a -# bordered composer box, or a structurally-identified bare AGENT -# prompt row); 0 for a bare, unstructured row (e.g. tmux's raw -# cursor line that carried no box border). -# the candidate composer content, already border-stripped and -# whitespace-trimmed by the caller. -# [idle_re] optional per-harness idle-placeholder regex (e.g. grok's -# "Type a message...") that reads as empty; matched both before and -# after a leading prompt glyph is stripped, so a pattern written -# with or without the glyph both land. +# The prompt glyphs, each declared exactly once (see THE SAFETY RULE above). +# AGENT glyphs are a genuine empty agent composer on any row, bordered or bare. +# SHELL glyphs are one only INSIDE a composer container; on a bare row they are +# a dead-shell prompt and must never read `empty`. Newline-separated and +# consumed by `read` rather than word splitting, so `$`, `%`, and `#` stay +# literal and no entry is ever exposed to pathname expansion. +FM_COMPOSER_AGENT_PROMPT_GLYPHS=$(printf '%s\n' '❯' '›' '⟩') +FM_COMPOSER_SHELL_PROMPT_GLYPHS=$(printf '%s\n' '>' '$' '%' '#') + +# The ONE fleet-wide idle-placeholder set: composer text a harness renders in +# an EMPTY composer that a plain capture cannot tell from typed text. Grok's +# bordered placeholder and opencode's left-bar hint (which continues with a +# rotating quoted suggestion, hence the unanchored tail). FM_COMPOSER_IDLE_RE +# overrides for an unverified harness; matching is case-insensitive. +FM_COMPOSER_IDLE_RE_DEFAULT='^Type a message\.\.\.$|^Ask anything\.\.\.' + +# Opencode draws a mode/model footer line INSIDE its left-bar composer +# ("Build · GPT-5.5 Fast OpenAI · high"). It is composer furniture, not typed +# text, and only the run's LAST row is ever matched against it. +FM_COMPOSER_LEFTBAR_FOOTER_RE_DEFAULT='^(Build|Plan)[[:space:]]+·[[:space:]]+' + +# The bounded row window adapters should capture for a composer read. One +# shared policy (previously three per-backend variables that had drifted to +# 20/20/200): the composer is bottom-anchored, so a small tail window is +# sufficient and keeps stale scrollback (startup banners, old transcript +# boxes) from ever competing with the live composer. +FM_COMPOSER_CAPTURE_LINES=${FM_COMPOSER_CAPTURE_LINES:-20} + +# Pi allows a multi-line composer between its horizontal separators. Bound the +# structural candidate so two unrelated transcript rules with an arbitrarily +# large region between them can never be promoted into a composer. +FM_COMPOSER_PI_MAX_LINES=${FM_COMPOSER_PI_MAX_LINES:-8} + +# 0 when is exactly one glyph drawn from . +_fm_composer_is_prompt_glyph() { # + local content=$1 glyph + while IFS= read -r glyph; do + [ -n "$glyph" ] || continue + [ "$content" = "$glyph" ] && return 0 + done < to the ONE prompt +# glyph begins with once its leading whitespace is ignored, or to the +# empty string (returning 1) when it begins with none. Both glyph lists are +# reached here, so no caller can respell them and drift. Returning the matched +# glyph as a LITERAL string lets every caller remove it byte-exactly with +# `${v#"$glyph"}`, which is correct in every locale. +fm_composer_leading_prompt_glyph_var() { # + local __fmpg_out=$1 __fmpg_text=$2 __fmpg_glyph + __fmpg_text="${__fmpg_text#"${__fmpg_text%%[![:space:]]*}"}" + while IFS= read -r __fmpg_glyph; do + [ -n "$__fmpg_glyph" ] || continue + case "$__fmpg_text" in + "$__fmpg_glyph"*) printf -v "$__fmpg_out" '%s' "$__fmpg_glyph"; return 0 ;; + esac + done < + local __fmag_out=$1 __fmag_text=$2 __fmag_glyph + __fmag_text="${__fmag_text#"${__fmag_text%%[![:space:]]*}"}" + while IFS= read -r __fmag_glyph; do + [ -n "$__fmag_glyph" ] || continue + case "$__fmag_text" in + "$__fmag_glyph"*) printf -v "$__fmag_out" '%s' "$__fmag_glyph"; return 0 ;; + esac + done < 1 when came from a genuine agent-composer container (a +# bordered composer box, an identity-proven separated composer, or +# a structurally-identified left-bar row); 0 for a bare +# agent-glyph row, where only the agent glyph itself is proof. +# the candidate composer content, border-stripped by the caller. +# [idle_re] optional idle-placeholder regex; empty means no idle matching. +# The screen classifier below passes the resolved fleet-wide idle +# set; this parameter stays pure so a direct caller's semantics +# cannot shift underneath it. +# [idle_case] `sensitive` (default) or `insensitive`. +# [plain_content] the UNSTRIPPED plain row, consulted when ghost stripping +# emptied an unbordered row: muse's `⟩` sits at luminance ~150, +# close enough to the ghost threshold that a raised threshold +# strips it, and the plain row is what keeps that pane readable. +# Content and plain_content are normalized and re-trimmed on entry, so the +# verdict never depends on which whitespace alphabet the calling adapter +# trimmed with. fm_composer_classify_content() { # [idle_re] [idle_case] [plain_content] - local bordered=$1 content=$2 idle_re=${3:-} idle_case=${4:-sensitive} plain_content - plain_content=${5:-$content} + local bordered=$1 idle_re=${3:-} idle_case=${4:-sensitive} content plain_content glyph='' + content=$2 + fm_composer_normalize_trim_var content + plain_content=${5:-$2} + fm_composer_normalize_trim_var plain_content if [ "$bordered" != 1 ] && [ -z "$content" ] && [ -n "$plain_content" ]; then - case "$plain_content" in - '❯'|'›'|'⟩') printf 'empty'; return 0 ;; - *) printf 'unknown'; return 0 ;; - esac + if _fm_composer_is_prompt_glyph "$plain_content" "$FM_COMPOSER_AGENT_PROMPT_GLYPHS"; then + printf 'empty'; return 0 + fi + printf 'unknown'; return 0 fi # A bare prompt glyph on its own row. - case "$content" in - '❯'|'›'|'⟩') - # Agent prompt glyph: a genuine empty agent composer, bordered or bare. - printf 'empty'; return 0 ;; - '>'|'$'|'%'|'#') - # Shell prompt glyph: empty ONLY inside a composer box (the harness's own - # prompt). Bare, it is a dead-shell prompt - never a safe injection target. - if [ "$bordered" = 1 ]; then printf 'empty'; else printf 'unknown'; fi - return 0 ;; - esac - # Nothing on the row = empty composer. + # Agent prompt glyph: a genuine empty agent composer, bordered or bare. + if _fm_composer_is_prompt_glyph "$content" "$FM_COMPOSER_AGENT_PROMPT_GLYPHS"; then + printf 'empty'; return 0 + fi + # Shell prompt glyph: empty ONLY inside a composer container (the harness's + # own prompt). Bare, it is a dead-shell prompt - never a safe injection + # target. + if _fm_composer_is_prompt_glyph "$content" "$FM_COMPOSER_SHELL_PROMPT_GLYPHS"; then + if [ "$bordered" = 1 ]; then printf 'empty'; else printf 'unknown'; fi + return 0 + fi + # Nothing on the row = empty composer. The caller proved the container. [ -n "$content" ] || { printf 'empty'; return 0; } # Known idle placeholder (matched before a leading glyph is stripped). if fm_composer_idle_matches "$content" "$idle_re" "$idle_case"; then printf 'empty'; return 0 fi - # Strip a leading prompt glyph, then re-judge the remainder. - case "$content" in - '❯ '*|'› '*|'⟩ '*|'> '*|'$ '*|'% '*|'# '*) content=${content#??} ;; - '❯'*|'›'*|'⟩'*|'>'*|'$'*|'%'*|'#'*) content=${content#?} ;; - esac - content="${content#"${content%%[![:space:]]*}"}" - content="${content%"${content##*[![:space:]]}"}" + # Strip a leading prompt glyph byte-exactly, then re-judge the remainder. + if fm_composer_leading_prompt_glyph_var glyph "$content"; then + content=${content#*"$glyph"} + fi + fm_composer_normalize_trim_var content [ -n "$content" ] || { printf 'empty'; return 0; } # Known idle placeholder (matched again after the leading glyph was stripped, # e.g. "❯ Type a message..."). @@ -231,3 +427,612 @@ fm_composer_classify_content() { # [idle_re] [idle_case] [ # Real, unsubmitted content remains. printf 'pending'; return 0 } + +# --- The screen classifier --------------------------------------------------- +# +# fm_composer_classify_screen [cursor_row] [identity] +# newline-separated key=value capability facts (see header). +# the captured screen: ANSI-preserving when styled=1, plain +# otherwise. +# [cursor_row] zero-based row index of the cursor within , only +# meaningful when caps carry cursor=1. +# [identity] "\t" from the backend's native identity probe, +# only meaningful when caps carry identity=1. +# Prints exactly one verdict: empty | pending | pending-unproven | unknown, +# or the internal sentinel `need-identity` when caps declare identity=1, no +# identity string was supplied, and the verdict depends on it. Adapters answer +# `need-identity` by running their identity probe once and re-calling; the +# sentinel never escapes an adapter. Identity stays a lazy second pass so the +# common non-pi read never pays for the probe. +# +# Consumers that can overwrite input or confirm delivery must accept only the +# exact positive proof they require (`empty`), so unrecognized future verdicts +# fail safe by default. + +# _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. +_fm_composer_pi_separator_row() { # + local row=$1 + [ -n "$row" ] || return 1 + [ -z "${row//─/}" ] || return 1 + case "$row" in + *────────*) return 0 ;; + esac + return 1 +} + +# 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() { # + local pane=$1 cy=${2:-} + local line indent left_stripped trimmed kind family side_family + local top_inner top_spaces='' geometry_check=0 geometry_ambiguous=0 + local content_inner content_spaces bottom_inner bottom_spaces glyph + local current_indent='' current_family='' row=0 top=-1 valid=0 content_rows=0 + # Complete-box results: the box containing the cursor (cursor mode) or the + # bottom-most complete box (no cursor). + FM_COMPOSER_SCAN_BOX_TOP=-1 + FM_COMPOSER_SCAN_BOX_BOTTOM=-1 + FM_COMPOSER_SCAN_BOX_AMBIG=0 + FM_COMPOSER_SCAN_UNSAFE=0 + FM_COMPOSER_SCAN_CURSOR_EDGE=0 + FM_COMPOSER_SCAN_BARE_ROW=-1 + FM_COMPOSER_SCAN_LEFTBAR_START=-1 + FM_COMPOSER_SCAN_LEFTBAR_END=-1 + FM_COMPOSER_SCAN_PI_PAIR_FOUND=0 + FM_COMPOSER_SCAN_PI_PAIR_VALID=0 + FM_COMPOSER_SCAN_PI_OPEN=-1 + FM_COMPOSER_SCAN_PI_CLOSE=-1 + FM_COMPOSER_SCAN_PI_LAST_SEPARATOR=-1 + local leftbar_start=-1 pi_open=-1 pi_lines=0 pi_max + pi_max=$FM_COMPOSER_PI_MAX_LINES + case "$pi_max" in ''|*[!0-9]*|0) pi_max=8 ;; esac + while IFS= read -r line; do + indent=${line%%[![:space:]]*} + left_stripped="${line#"${line%%[![:space:]]*}"}" + trimmed=$left_stripped + fm_composer_normalize_trim_var trimmed + kind= + family= + case "$trimmed" in + '╭'*'╮') kind=top; family=rounded ;; + '┌'*'┐') kind=top; family=light ;; + '╔'*'╗') kind=top; family=double ;; + '┏'*'┓') kind=top; family=heavy ;; + '╰'*'╯') kind=bottom; family=rounded ;; + '└'*'┘') kind=bottom; family=light ;; + '╚'*'╝') kind=bottom; family=double ;; + '┗'*'┛') kind=bottom; family=heavy ;; + '+'*'+') kind=ascii; family=ascii ;; + esac + # Pi separator rows: a solid `─` rule at least 8 columns wide. A separator + # closes the preceding candidate and immediately opens the next, so an + # earlier transcript rule can never outrank the live bottom composer pair. + if _fm_composer_pi_separator_row "$trimmed"; then + FM_COMPOSER_SCAN_PI_LAST_SEPARATOR=$row + if [ "$pi_open" -ge 0 ]; then + FM_COMPOSER_SCAN_PI_PAIR_FOUND=1 + FM_COMPOSER_SCAN_PI_OPEN=$pi_open + FM_COMPOSER_SCAN_PI_CLOSE=$row + if [ "$pi_lines" -le "$pi_max" ]; then + FM_COMPOSER_SCAN_PI_PAIR_VALID=1 + else + FM_COMPOSER_SCAN_PI_PAIR_VALID=0 + fi + fi + pi_open=$row + pi_lines=0 + elif [ "$pi_open" -ge 0 ]; then + pi_lines=$((pi_lines + 1)) + fi + # Left-bar rows (opencode): a heavy left bar `┃` opening the row with no + # closing side border. A `┃…┃` row is a bordered box row, not a left bar. + case "$trimmed" in + '┃'*'┃') leftbar_start=-1 ;; + '┃'*) + if [ "$leftbar_start" -lt 0 ]; then leftbar_start=$row; fi + FM_COMPOSER_SCAN_LEFTBAR_START=$leftbar_start + FM_COMPOSER_SCAN_LEFTBAR_END=$row + ;; + *) leftbar_start=-1 ;; + esac + # Bare agent-glyph rows: the glyph itself is the container proof. Bare + # shell glyphs are deliberately not candidates (dead-shell rule). + if fm_composer_leading_agent_glyph_var glyph "$trimmed"; then + FM_COMPOSER_SCAN_BARE_ROW=$row + fi + # Cursor safety: a cursor sitting on a structural edge row is never an + # input row. + if [ -n "$cy" ] && [ "$row" -eq "$cy" ] && fm_composer_row_has_edge "$trimmed"; then + FM_COMPOSER_SCAN_CURSOR_EDGE=1 + fi + # Complete-box state machine (all border families, geometry, ambiguity). + if [ "$kind" = top ] || { [ "$kind" = ascii ] && [ "$top" -lt 0 ]; }; then + if [ -n "$cy" ] && [ "$top" -ge 0 ] && [ "$top" -lt "$cy" ] && [ "$cy" -le "$row" ]; then + FM_COMPOSER_SCAN_UNSAFE=1 + fi + top=$row + current_family=$family + current_indent=$indent + valid=1 + content_rows=0 + geometry_ambiguous=0 + geometry_check=1 + top_inner=$trimmed + case "$family" in + rounded) top_inner=${top_inner#╭}; top_inner=${top_inner%╮}; top_spaces=${top_inner//─/ } ;; + light) top_inner=${top_inner#┌}; top_inner=${top_inner%┐}; top_spaces=${top_inner//─/ } ;; + double) top_inner=${top_inner#╔}; top_inner=${top_inner%╗}; top_spaces=${top_inner//═/ } ;; + heavy) top_inner=${top_inner#┏}; top_inner=${top_inner%┓}; top_spaces=${top_inner//━/ } ;; + ascii) top_inner=${top_inner#+}; top_inner=${top_inner%+}; top_spaces=${top_inner//-/ } ;; + esac + case "$top_spaces" in + *[![:space:]]*) geometry_check=0; geometry_ambiguous=1 ;; + esac + elif [ "$kind" = bottom ] || { [ "$kind" = ascii ] && [ "$top" -ge 0 ]; }; then + if [ "$top" -ge 0 ] && [ "$family" = "$current_family" ] \ + && [ "$valid" = 1 ] && [ "$content_rows" -gt 0 ]; then + [ "$indent" = "$current_indent" ] || geometry_ambiguous=1 + if [ "$geometry_check" = 1 ]; then + bottom_inner=$trimmed + case "$family" in + rounded) bottom_inner=${bottom_inner#╰}; bottom_inner=${bottom_inner%╯}; bottom_spaces=${bottom_inner//─/ } ;; + light) bottom_inner=${bottom_inner#└}; bottom_inner=${bottom_inner%┘}; bottom_spaces=${bottom_inner//─/ } ;; + double) bottom_inner=${bottom_inner#╚}; bottom_inner=${bottom_inner%╝}; bottom_spaces=${bottom_inner//═/ } ;; + heavy) bottom_inner=${bottom_inner#┗}; bottom_inner=${bottom_inner%┛}; bottom_spaces=${bottom_inner//━/ } ;; + ascii) bottom_inner=${bottom_inner#+}; bottom_inner=${bottom_inner%+}; bottom_spaces=${bottom_inner//-/ } ;; + esac + if [ "$bottom_spaces" != "$top_spaces" ]; then + # A TITLED bottom border (grok writes its model name there) is + # tolerated when the inner still starts and ends with the family's + # own rule glyph: the corners, family, indent, and every content + # row's geometry were already proven. Anything else is ambiguity. + if ! _fm_composer_titled_bottom_ok "$family" "$bottom_inner"; then + geometry_ambiguous=1 + fi + fi + fi + if [ -n "$cy" ]; then + if [ "$top" -lt "$cy" ] && [ "$cy" -le "$row" ]; then + FM_COMPOSER_SCAN_BOX_TOP=$top + FM_COMPOSER_SCAN_BOX_BOTTOM=$row + FM_COMPOSER_SCAN_BOX_AMBIG=$geometry_ambiguous + fi + else + FM_COMPOSER_SCAN_BOX_TOP=$top + FM_COMPOSER_SCAN_BOX_BOTTOM=$row + FM_COMPOSER_SCAN_BOX_AMBIG=$geometry_ambiguous + fi + elif [ -n "$cy" ]; then + if { [ "$top" -ge 0 ] && [ "$top" -lt "$cy" ] && [ "$cy" -le "$row" ]; } \ + || [ "$row" -eq "$cy" ]; then + FM_COMPOSER_SCAN_UNSAFE=1 + fi + fi + top=-1 + current_family= + current_indent= + valid=0 + content_rows=0 + elif [ "$top" -ge 0 ]; then + side_family= + case "$trimmed" in + '│'*'│') side_family=single ;; + '┃'*'┃') side_family=heavy ;; + '║'*'║') side_family=double ;; + '|'*'|') side_family=ascii ;; + esac + case "$current_family:$side_family" in + rounded:single|light:single|heavy:heavy|double:double|ascii:ascii) + content_rows=$((content_rows + 1)) + [ "$indent" = "$current_indent" ] || geometry_ambiguous=1 + if [ "$geometry_check" = 1 ]; then + content_inner=$trimmed + case "$side_family" in + single) content_inner=${content_inner#│}; content_inner=${content_inner%│} ;; + heavy) content_inner=${content_inner#┃}; content_inner=${content_inner%┃} ;; + double) content_inner=${content_inner#║}; content_inner=${content_inner%║} ;; + ascii) content_inner=${content_inner#|}; content_inner=${content_inner%|} ;; + esac + if content_spaces=$(fm_composer_geometry_spaces "$content_inner"); then + [ "$content_spaces" = "$top_spaces" ] || geometry_ambiguous=1 + else + geometry_ambiguous=1 + fi + fi + ;; + *) valid=0 ;; + esac + fi + row=$((row + 1)) + done < + local family=$1 inner=$2 dash + fm_composer_normalize_trim_var inner + case "$family" in + rounded|light) dash='─' ;; + double) dash='═' ;; + heavy) dash='━' ;; + ascii) dash='-' ;; + *) return 1 ;; + esac + case "$inner" in + "$dash"*"$dash") return 0 ;; + esac + return 1 +} + +# fm_composer_row_has_edge: 0 when the trimmed row starts or ends with a +# box-drawing/edge glyph - a structural row, never an input row. +fm_composer_row_has_edge() { # + local row=$1 + fm_composer_normalize_trim_var row + case "$row" in + '│'*|*'│'|'┃'*|*'┃'|'║'*|*'║'|'╭'*|*'╭'|'╮'*|*'╮'|\ + '┌'*|*'┌'|'┐'*|*'┐'|'╔'*|*'╔'|'╗'*|*'╗'|'┏'*|*'┏'|'┓'*|*'┓'|\ + '╰'*|*'╰'|'╯'*|*'╯'|'└'*|*'└'|'┘'*|*'┘'|'╚'*|*'╚'|'╝'*|*'╝'|\ + '┗'*|*'┗'|'┛'*|*'┛'|'─'*|*'─'|'━'*|*'━'|'═'*|*'═'|'|'*|*'|'|'+'*|*'+') + return 0 + ;; + esac + return 1 +} + +# fm_composer_geometry_spaces: prove a box content row blank to the same width +# as its border. One leading prompt glyph is blanked (every prompt glyph +# occupies one column), the content is normalized so a Unicode space cannot +# defeat the blankness proof, then every remaining ASCII-printable is mapped to +# a space; any other residue fails the proof. +fm_composer_geometry_spaces() { # -> spaces + local content=$1 glyph + fm_composer_normalize_spaces_var content + if fm_composer_leading_prompt_glyph_var glyph "$content"; then + content=${content/"$glyph"/ } + fi + content=$(printf '%s' "$content" | LC_ALL=C sed 's/[!-~]/ /g') + case "$content" in + *[![:space:]]*) return 1 ;; + esac + printf '%s' "$content" +} + +# _fm_composer_screen_row: print row (zero-based) of . +_fm_composer_screen_row() { # + printf '%s\n' "$2" | sed -n "$(($1 + 1))p" +} + +# _fm_composer_row_content: extract the classification content of one raw row: +# ghost-strip when styled, plain otherwise, normalize-trim, and strip one +# matching pair of side border glyphs. +_fm_composer_row_content() { # -> content on stdout + local raw=$1 styled=$2 stripped + if [ "$styled" = 1 ]; then + stripped=$(printf '%s\n' "$raw" | fm_composer_strip_ghost) + else + stripped=$(printf '%s\n' "$raw" | fm_composer_strip_ansi) + fi + fm_composer_normalize_trim_var stripped + case "$stripped" in + '│'*'│') stripped=${stripped#│}; stripped=${stripped%│} ;; + '┃'*'┃') stripped=${stripped#┃}; stripped=${stripped%┃} ;; + '║'*'║') stripped=${stripped#║}; stripped=${stripped%║} ;; + '|'*'|') stripped=${stripped#|}; stripped=${stripped%|} ;; + esac + fm_composer_normalize_trim_var stripped + printf '%s' "$stripped" +} + +# _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). +_fm_composer_classify_rows() { # + local screen=$1 styled=$2 ambiguous=$3 first=$4 last=$5 + local row raw content plain state unknown_seen=0 + 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) + state=$(fm_composer_classify_content 1 "$content" \ + "${FM_COMPOSER_IDLE_RE:-$FM_COMPOSER_IDLE_RE_DEFAULT}" insensitive "$plain") + case "$state" in + pending) + if [ "$ambiguous" = 1 ]; then printf 'pending-unproven'; else printf 'pending'; fi + return 0 + ;; + unknown) unknown_seen=1 ;; + esac + row=$((row + 1)) + done + if [ "$unknown_seen" = 1 ] || [ "$ambiguous" = 1 ]; then + printf 'unknown' + else + printf 'empty' + fi +} + +# _fm_composer_classify_bare_row: the bare agent-glyph row verdict, including +# the styled=0 degradation: without styling, trailing text after the glyph may +# be the harness's own idle suggestion (claude's rotating dim hint, codex's +# `Use /skills ...`), so it must read `unknown` rather than a false `pending`. +_fm_composer_classify_bare_row() { # + local screen=$1 styled=$2 row=$3 raw content plain state + raw=$(_fm_composer_screen_row "$row" "$screen") + content=$(_fm_composer_row_content "$raw" "$styled") + plain=$(_fm_composer_row_content "$raw" 0) + state=$(fm_composer_classify_content 0 "$content" \ + "${FM_COMPOSER_IDLE_RE:-$FM_COMPOSER_IDLE_RE_DEFAULT}" insensitive "$plain") + if [ "$styled" != 1 ] && [ "$state" = pending ]; then + printf 'unknown' + return 0 + fi + printf '%s' "$state" +} + +# _fm_composer_wrap_region_ok: 0 when every row STRICTLY BELOW +# through is non-blank and carries no structural edge - the +# contiguity proof that those rows are the bare composer's wrapped input +# rather than unrelated screen content. +_fm_composer_wrap_region_ok() { # + local plain=$1 g=$2 cy=$3 row line trimmed + row=$((g + 1)) + while [ "$row" -le "$cy" ]; do + line=$(_fm_composer_screen_row "$row" "$plain") + trimmed=$line + fm_composer_normalize_trim_var trimmed + [ -n "$trimmed" ] || return 1 + if fm_composer_row_has_edge "$trimmed"; then return 1; fi + row=$((row + 1)) + done + return 0 +} + +# _fm_composer_classify_bare_wrap: the bare composer plus its wrap region. +# Content is the glyph row (glyph stripped) plus every continuation row down +# to the cursor. Ghost-stripped-to-nothing rows are an empty composer whose +# suggestion happened to wrap; any surviving text is pending when styling can +# prove it real and unknown otherwise (the same styled=0 degradation as the +# glyph row itself). +_fm_composer_classify_bare_wrap() { # + local screen=$1 styled=$2 g=$3 cy=$4 row raw content glyph='' text_seen=0 + row=$g + while [ "$row" -le "$cy" ]; do + raw=$(_fm_composer_screen_row "$row" "$screen") + content=$(_fm_composer_row_content "$raw" "$styled") + if [ "$row" -eq "$g" ] && fm_composer_leading_agent_glyph_var glyph "$content"; then + content=${content#*"$glyph"} + fi + fm_composer_normalize_trim_var content + [ -z "$content" ] || text_seen=1 + row=$((row + 1)) + done + if [ "$text_seen" = 0 ]; then + printf 'empty' + return 0 + fi + if [ "$styled" = 1 ]; then printf 'pending'; else printf 'unknown'; fi +} + +# _fm_composer_classify_leftbar: opencode's left-bar composer. Blank rows and +# the idle hint read empty; the run's LAST row may be the mode/model footer +# (composer furniture, never typed text). Real content is pending when styling +# can prove it real, unknown otherwise. +_fm_composer_classify_leftbar() { # [only-row] + local screen=$1 styled=$2 first=$3 last=$4 only=${5:-} + local row raw content pending_seen=0 footer_re + footer_re=${FM_COMPOSER_LEFTBAR_FOOTER_RE:-$FM_COMPOSER_LEFTBAR_FOOTER_RE_DEFAULT} + row=$first + while [ "$row" -le "$last" ]; do + if [ -n "$only" ] && [ "$row" -ne "$only" ]; then row=$((row + 1)); continue; fi + raw=$(_fm_composer_screen_row "$row" "$screen") + content=$(_fm_composer_row_content "$raw" "$styled") + case "$content" in + '┃'*) content=${content#┃} ;; + esac + fm_composer_normalize_trim_var content + if [ -z "$content" ]; then row=$((row + 1)); continue; fi + if fm_composer_idle_matches "$content" "${FM_COMPOSER_IDLE_RE:-$FM_COMPOSER_IDLE_RE_DEFAULT}" insensitive; then + row=$((row + 1)); continue + fi + if [ "$row" -eq "$last" ] && [ -z "$only" ] \ + && fm_composer_idle_matches "$content" "$footer_re" sensitive; then + row=$((row + 1)); continue + fi + pending_seen=1 + row=$((row + 1)) + done + if [ "$pending_seen" = 1 ]; then + if [ "$styled" = 1 ]; then printf 'pending'; else printf 'unknown'; fi + else + printf 'empty' + fi +} + +fm_composer_classify_screen() { # [cursor_row] [identity] + local caps=$1 screen=$2 cy=${3:-} identity=${4:-} + local styled=0 cursor=0 has_identity=0 kv plain + local generic=-1 winner='' + while IFS= read -r kv; do + case "$kv" in + styled=1) styled=1 ;; + cursor=1) cursor=1 ;; + identity=1) has_identity=1 ;; + esac + done < [expected-label] + local send_key_fn=$1 state_fn=$2 target=$3 retries=$4 sleep_s=$5 expected_label=${6:-} i=0 state + while :; do + "$send_key_fn" "$target" Enter "$expected_label" || true + sleep "$sleep_s" + state=$("$state_fn" "$target" "$expected_label") + case "$state" in + pending|pending-unproven) ;; + *) printf '%s' "$state"; return 0 ;; + esac + i=$((i + 1)) + [ "$i" -lt "$retries" ] || { printf '%s' "$state"; return 0; } + done +} + +# The pi separated-shape verdict: identity + structure conjunction (herdr's +# rule, now fleet-wide). A missing identity capability keeps the shape +# unknown; an unfetched identity on an identity-capable backend asks the +# adapter to probe (lazily) and re-call; a working or unreadable pi defers. +_fm_composer_pi_verdict() { # + local screen=$1 styled=$2 has_identity=$3 identity=$4 agent agent_status + if [ "$has_identity" != 1 ]; then + printf 'unknown' + return 0 + fi + if [ -z "$identity" ]; then + printf 'need-identity' + return 0 + fi + agent=${identity%%$'\t'*} + agent_status=${identity#*$'\t'} + case "$agent:$agent_status" in + pi:idle|pi:done|pi:blocked) + if [ "$FM_COMPOSER_SCAN_PI_PAIR_VALID" = 1 ]; then + _fm_composer_classify_rows "$screen" "$styled" 0 \ + "$((FM_COMPOSER_SCAN_PI_OPEN + 1))" "$((FM_COMPOSER_SCAN_PI_CLOSE - 1))" + else + printf 'unknown' + fi + ;; + *) + # A working pi, an unreadable identity, or a non-pi agent whose pane + # nevertheless shows a separator pair below every other shape: none can + # authorize injection into the blank separated region. + printf 'unknown' + ;; + esac +} diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index 9e98b731e67..48dcaaf384c 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -2018,9 +2018,16 @@ kimi_capture() { fm_backend_capture "$BACKEND" "$T" 120 "$W" 2>/dev/null || true } -kimi_capture_has_empty_composer() { # - printf '%s\n' "$1" \ - | grep -Eq '^[[:space:]]*(│|┃|\|)[[:space:]]*>[[:space:]]*(│|┃|\|)[[:space:]]*$' +# Kimi launch-readiness and delivery route their composer-emptiness half +# through the shared classifier (bin/fm-composer-lib.sh via +# fm_backend_composer_state), the same owner every steer and injection guard +# reads. This retired a fourth, spawn-local copy of composer shape knowledge - +# a hardcoded bordered `│ > │` regex that would have silently broken kimi +# spawn readiness fleet-wide the day kimi's TUI goes borderless the way +# claude's did. The banner and brief-echo greps below are launch-progress +# signals, not composer shapes, so they stay here. +kimi_composer_is_empty() { + [ "$(fm_backend_composer_state "$BACKEND" "$T" "$W" 2>/dev/null)" = empty ] } kimi_wait_for_ready() { @@ -2028,7 +2035,7 @@ kimi_wait_for_ready() { while [ "$i" -lt "$max" ]; do pane=$(kimi_capture) if printf '%s\n' "$pane" | grep -Fq 'Welcome to Kimi Code!' \ - || kimi_capture_has_empty_composer "$pane"; then + || kimi_composer_is_empty; then return 0 fi i=$((i + 1)) @@ -2039,7 +2046,7 @@ kimi_wait_for_ready() { kimi_delivery_is_confirmed() { # local pane=$1 - kimi_capture_has_empty_composer "$pane" || return 1 + kimi_composer_is_empty || return 1 if { printf '%s\n' "$pane" | grep -Fq '✨' \ && printf '%s\n' "$pane" | grep -Fq 'Read the brief at'; } \ || printf '%s\n' "$pane" \ diff --git a/bin/fm-supervise-daemon.sh b/bin/fm-supervise-daemon.sh index 400a8bf5357..881d56108e7 100755 --- a/bin/fm-supervise-daemon.sh +++ b/bin/fm-supervise-daemon.sh @@ -556,9 +556,11 @@ mark_escalated_seen() { # # # pane_input_pending returns 0 unless the composer is positively proven empty. # This includes real unsubmitted text, ambiguous structure, unreadable state, -# and future verdicts. The detector drops dim/faint ghost text and strips the -# harness's composer box borders, so an aligned ghost-only or idle bordered -# claude composer ("│ > … │") is correctly proven empty. +# blank or otherwise unidentified rows (the strict container-proof rule owned +# by bin/fm-composer-lib.sh), and future verdicts. The detector drops +# dim/faint ghost text and strips the harness's composer box borders, so an +# aligned ghost-only or idle bordered claude composer ("│ > … │") is correctly +# proven empty while a modal dialog or dead shell never is. # pane_is_busy / pane_input_pending: BACKEND-AWARE (dispatch goes through # bin/fm-backend.sh's generic per-backend primitives rather than a hand-rolled # case statement here). defaults to tmux when omitted, so every diff --git a/bin/fm-test-run.sh b/bin/fm-test-run.sh index b1867c53289..b4626e1b0b6 100755 --- a/bin/fm-test-run.sh +++ b/bin/fm-test-run.sh @@ -181,6 +181,7 @@ family_for_basename() { ;; fm-afk-pi-herdr-return-e2e.test.sh|\ fm-cmux-claude-composer-live-e2e.test.sh|\ + fm-composer-matrix-live-e2e.test.sh|\ fm-codex-continuity-live-e2e.test.sh|fm-grok-continuity-live-e2e.test.sh|\ fm-grok-stop-live-e2e.test.sh|fm-harness-liveness-drift-live-e2e.test.sh|\ fm-muse-signals-live-e2e.test.sh|\ @@ -932,6 +933,14 @@ families_for_changed_path() { printf '%s\n' pure-contract-unit printf '%s\n' pr-forge ;; + bin/fm-composer-lib.sh) + # The shared shape catalogue is vendor-rendered signal; a change to it + # re-selects the live guard (fm-composer-matrix-live-e2e) alongside the + # portable families. + printf '%s\n' backend-dispatch + printf '%s\n' pure-contract-unit + printf '%s\n' live-harness-optin + ;; bin/fm-spawn.sh|bin/fm-send.sh|bin/fm-harness.sh|\ bin/fm-peek.sh|bin/fm-composer*) printf '%s\n' backend-dispatch diff --git a/bin/fm-tmux-lib.sh b/bin/fm-tmux-lib.sh index e8284ba1e01..80f3cc27440 100755 --- a/bin/fm-tmux-lib.sh +++ b/bin/fm-tmux-lib.sh @@ -58,10 +58,14 @@ # All functions are `set -u` and `set -e` safe (guarded tmux calls, explicit # returns) so they can be sourced into either context. # -# Composer-content classification (empty|pending|unknown, and the fleet-wide -# rule that a BARE shell prompt glyph is a dead shell, not an empty agent -# composer) is NOT owned here: it is the shared bin/fm-composer-lib.sh, sourced -# below and reused by every backend adapter so the decision cannot drift. +# Composer classification is NOT owned here: every shape, glyph, border +# family, geometry rule, and verdict decision lives in the shared +# bin/fm-composer-lib.sh (fm_composer_classify_screen), sourced below and +# reused by every backend adapter so the decision cannot drift. This file +# keeps only tmux's genuine capture-side primitives - the styled pane +# capture, the #{cursor_y} cursor read, the pi foreground-process identity +# probe, and the capability descriptor - plus the busy detection and submit +# cores that consume the shared verdict. # shellcheck source=bin/fm-composer-lib.sh . "$(dirname -- "${BASH_SOURCE[0]}")/fm-composer-lib.sh" @@ -123,245 +127,101 @@ fm_busy_lines_match() { # [harness] # so the tmux and herdr adapters cannot drift apart on what counts as ghost text. fm_tmux_strip_ghost() { fm_composer_strip_ghost; } -# fm_tmux_composer_row_state: classify one raw styled candidate row. -# A structural caller forces bordered=1; the compatibility fallback passes 0 -# and may recognize a busy footer. -fm_tmux_composer_row_state() { # [bordered] [allow-busy] -> empty|pending|unknown - local raw=$1 bordered=${2:-0} allow_busy=${3:-1} plain stripped - plain=$(printf '%s\n' "$raw" | fm_composer_strip_ansi) - plain="${plain#"${plain%%[![:space:]]*}"}" - plain="${plain%"${plain##*[![:space:]]}"}" - stripped=$(printf '%s\n' "$raw" | fm_composer_strip_ghost) - stripped="${stripped#"${stripped%%[![:space:]]*}"}" - stripped="${stripped%"${stripped##*[![:space:]]}"}" - case "$stripped" in - '│'*'│') stripped=${stripped#│}; stripped=${stripped%│} ;; - '┃'*'┃') stripped=${stripped#┃}; stripped=${stripped%┃} ;; - '║'*'║') stripped=${stripped#║}; stripped=${stripped%║} ;; - '|'*'|') stripped=${stripped#|}; stripped=${stripped%|} ;; - esac - stripped="${stripped#"${stripped%%[![:space:]]*}"}" - stripped="${stripped%"${stripped##*[![:space:]]}"}" - if [ "$allow_busy" = 1 ] && [ -n "$stripped" ] \ - && printf '%s' "$stripped" | grep -qiE "${FM_BUSY_REGEX:-$FM_TMUX_BUSY_REGEX_DEFAULT}"; then - printf 'empty'; return 0 - fi - fm_composer_classify_content "$bordered" "$stripped" "${FM_COMPOSER_IDLE_RE:-}" insensitive "$plain" +# --- tmux composer capture and capability primitives ------------------------ +# +# These four functions are the ONLY tmux-specific composer knowledge left: +# how to capture a styled screen, how to read the cursor row, how to probe a +# live pi agent, and the static capability facts. Every shape, glyph, border +# family, and verdict decision lives in the shared owner +# (bin/fm-composer-lib.sh, fm_composer_classify_screen), so a new harness +# shape is taught there once and never here. + +# fm_tmux_composer_capture: the visible pane WITH ANSI styling. The styled +# capture is consumed internally by the classifier and is NEVER surfaced +# (fm-peek and every human/LLM-facing path stay plain). +fm_tmux_composer_capture() { # + tmux capture-pane -e -p -t "$1" -S 0 -E - 2>/dev/null } -fm_tmux_row_has_composer_edge() { # - local row=$1 - row="${row#"${row%%[![:space:]]*}"}" - row="${row%"${row##*[![:space:]]}"}" - case "$row" in - '│'*|*'│'|'┃'*|*'┃'|'║'*|*'║'|'╭'*|*'╭'|'╮'*|*'╮'|\ - '┌'*|*'┌'|'┐'*|*'┐'|'╔'*|*'╔'|'╗'*|*'╗'|'┏'*|*'┏'|'┓'*|*'┓'|\ - '╰'*|*'╰'|'╯'*|*'╯'|'└'*|*'└'|'┘'*|*'┘'|'╚'*|*'╚'|'╝'*|*'╝'|\ - '┗'*|*'┗'|'┛'*|*'┛'|'─'*|*'─'|'━'*|*'━'|'═'*|*'═'|'|'*|*'|'|'+'*|*'+') - return 0 - ;; - esac - return 1 +# fm_tmux_composer_cursor_row: the pane's cursor row, zero-based, relative to +# the visible pane - tmux's genuine primitive that no other backend has. +fm_tmux_composer_cursor_row() { # + tmux display-message -p -t "$1" '#{cursor_y}' 2>/dev/null } -fm_tmux_composer_geometry_spaces() { # -> spaces - local content=$1 probe - probe="${content#"${content%%[![:space:]]*}"}" - case "$probe" in - '>'*) content=${content/>/ } ;; - '❯'*) content=${content/❯/ } ;; - '›'*) content=${content/›/ } ;; - esac - content=$(printf '%s' "$content" | LC_ALL=C sed 's/[!-~]/ /g') - case "$content" in - *[![:space:]]*) return 1 ;; - esac - printf '%s' "$content" +# fm_tmux_composer_caps: the tmux capability descriptor - static data, not +# logic (see the capability model in bin/fm-composer-lib.sh). +fm_tmux_composer_caps() { + printf 'styled=1\ncursor=1\nidentity=1\nrows=0\n' } -# fm_tmux_find_composer_box: print the zero-based top and bottom rows of the -# complete bordered box that structurally contains the cursor, plus whether its -# geometry is ambiguous. The cursor may be on any content row or on the bottom -# border; no fixed cursor offset is used. -fm_tmux_find_composer_box() { # -> " " - local cy=$1 pane=$2 line indent left_stripped trimmed kind family current_family= - local side_family top_inner top_spaces='' geometry_check=0 geometry_ambiguous=0 - local content_inner content_spaces bottom_inner bottom_spaces - local current_indent= - local row=0 top=-1 valid=0 content_rows=0 unsafe=0 cursor_structural=0 - while IFS= read -r line; do - indent=${line%%[![:space:]]*} - left_stripped="${line#"${line%%[![:space:]]*}"}" - trimmed="${left_stripped%"${left_stripped##*[![:space:]]}"}" - kind= - family= - case "$trimmed" in - '╭'*'╮') kind=top; family=rounded ;; - '┌'*'┐') kind=top; family=light ;; - '╔'*'╗') kind=top; family=double ;; - '┏'*'┓') kind=top; family=heavy ;; - '╰'*'╯') kind=bottom; family=rounded ;; - '└'*'┘') kind=bottom; family=light ;; - '╚'*'╝') kind=bottom; family=double ;; - '┗'*'┛') kind=bottom; family=heavy ;; - '+'*'+') kind=ascii; family=ascii ;; - esac - if [ "$row" -eq "$cy" ] && fm_tmux_row_has_composer_edge "$trimmed"; then - cursor_structural=1 - fi - if [ "$kind" = top ] || { [ "$kind" = ascii ] && [ "$top" -lt 0 ]; }; then - if [ "$top" -ge 0 ] && [ "$top" -lt "$cy" ] && [ "$cy" -le "$row" ]; then - unsafe=1 - fi - top=$row - current_family=$family - current_indent=$indent - valid=1 - content_rows=0 - geometry_ambiguous=0 - geometry_check=1 - top_inner=$trimmed - case "$family" in - rounded) top_inner=${top_inner#╭}; top_inner=${top_inner%╮}; top_spaces=${top_inner//─/ } ;; - light) top_inner=${top_inner#┌}; top_inner=${top_inner%┐}; top_spaces=${top_inner//─/ } ;; - double) top_inner=${top_inner#╔}; top_inner=${top_inner%╗}; top_spaces=${top_inner//═/ } ;; - heavy) top_inner=${top_inner#┏}; top_inner=${top_inner%┓}; top_spaces=${top_inner//━/ } ;; - ascii) top_inner=${top_inner#+}; top_inner=${top_inner%+}; top_spaces=${top_inner//-/ } ;; - esac - case "$top_spaces" in - *[![:space:]]*) geometry_check=0; geometry_ambiguous=1 ;; - esac - elif [ "$kind" = bottom ] || { [ "$kind" = ascii ] && [ "$top" -ge 0 ]; }; then - if [ "$top" -ge 0 ] && [ "$family" = "$current_family" ] \ - && [ "$valid" = 1 ] && [ "$content_rows" -gt 0 ] \ - && [ "$top" -lt "$cy" ] && [ "$cy" -le "$row" ]; then - [ "$indent" = "$current_indent" ] || geometry_ambiguous=1 - if [ "$geometry_check" = 1 ]; then - bottom_inner=$trimmed - case "$family" in - rounded) bottom_inner=${bottom_inner#╰}; bottom_inner=${bottom_inner%╯}; bottom_spaces=${bottom_inner//─/ } ;; - light) bottom_inner=${bottom_inner#└}; bottom_inner=${bottom_inner%┘}; bottom_spaces=${bottom_inner//─/ } ;; - double) bottom_inner=${bottom_inner#╚}; bottom_inner=${bottom_inner%╝}; bottom_spaces=${bottom_inner//═/ } ;; - heavy) bottom_inner=${bottom_inner#┗}; bottom_inner=${bottom_inner%┛}; bottom_spaces=${bottom_inner//━/ } ;; - ascii) bottom_inner=${bottom_inner#+}; bottom_inner=${bottom_inner%+}; bottom_spaces=${bottom_inner//-/ } ;; - esac - [ "$bottom_spaces" = "$top_spaces" ] || geometry_ambiguous=1 - fi - printf '%s %s %s' "$top" "$row" "$geometry_ambiguous" - return 0 - fi - if { [ "$top" -ge 0 ] && [ "$top" -lt "$cy" ] && [ "$cy" -le "$row" ]; } \ - || [ "$row" -eq "$cy" ]; then - unsafe=1 - fi - top=-1 - current_family= - current_indent= - valid=0 - content_rows=0 - elif [ "$top" -ge 0 ]; then - side_family= - case "$trimmed" in - '│'*'│') side_family=single ;; - '┃'*'┃') side_family=heavy ;; - '║'*'║') side_family=double ;; - '|'*'|') side_family=ascii ;; - esac - case "$current_family:$side_family" in - rounded:single|light:single|heavy:heavy|double:double|ascii:ascii) - content_rows=$((content_rows + 1)) - [ "$indent" = "$current_indent" ] || geometry_ambiguous=1 - if [ "$geometry_check" = 1 ]; then - content_inner=$trimmed - case "$side_family" in - single) content_inner=${content_inner#│}; content_inner=${content_inner%│} ;; - heavy) content_inner=${content_inner#┃}; content_inner=${content_inner%┃} ;; - double) content_inner=${content_inner#║}; content_inner=${content_inner%║} ;; - ascii) content_inner=${content_inner#|}; content_inner=${content_inner%|} ;; - esac - if content_spaces=$(fm_tmux_composer_geometry_spaces "$content_inner"); then - [ "$content_spaces" = "$top_spaces" ] || geometry_ambiguous=1 - else - geometry_ambiguous=1 - fi - fi - ;; - *) valid=0 ;; - esac - fi - row=$((row + 1)) - done <idle" or "piworking"; exits 1 when the pane is not a +# live pi. +fm_tmux_composer_identity() { # + local target=$1 tty pgid tpgid comm found=0 + tty=$(tmux display-message -p -t "$target" '#{pane_tty}' 2>/dev/null) || tty= + case "$tty" in + /dev/*) + while read -r _ pgid tpgid comm; do + [ -n "$comm" ] || continue + [ "$pgid" = "$tpgid" ] || continue + case "${comm##*/}" in + pi|pi-signed|pi-launcher|Pi) found=1 ;; + esac + done </dev/null) EOF - if [ "$top" -ge 0 ] && [ "$top" -lt "$cy" ]; then - unsafe=1 + ;; + esac + if [ "$found" -ne 1 ]; then + comm=$(tmux display-message -p -t "$target" '#{pane_current_command}' 2>/dev/null) || comm= + case "${comm##*/}" in + pi|pi-signed|pi-launcher) found=1 ;; + esac fi - if [ "$unsafe" = 1 ] || [ "$cursor_structural" = 1 ]; then - return 2 + [ "$found" -eq 1 ] || return 1 + if fm_pane_is_busy "$target" pi; then + printf 'pi\tworking' + else + printf 'pi\tidle' fi - return 1 } -# fm_tmux_composer_state classification contract: -# A row is structural only when its first or last non-whitespace character is a -# composer edge. A complete box has matching border families and bounded top and -# bottom rows. The proof-carrying verdict is empty for proven emptiness, pending -# for proven text in established structure, pending-unproven for text in -# ambiguous structure, and unknown for unreadable state. Consumers that can -# overwrite input or confirm delivery must accept only the exact positive proof -# they require, so unrecognized future verdicts fail safe by default. Empty -# requires positive proof: a genuinely empty composer, an all-empty unambiguous -# box, an empty non-bordered fallback row, or the submit core's proven -# busy-queued Enter conversion. +# fm_tmux_composer_state: the tmux composer verdict - a thin adapter over the +# shared screen classifier. The verdict contract (empty | pending | +# pending-unproven | unknown, positive proof required for empty, unrecognized +# future verdicts failing safe) is owned by bin/fm-composer-lib.sh. Identity +# is fetched lazily, only when the classifier reports the verdict depends on +# it (a pi separator pair under the cursor), so the common read never pays +# for the process probe. fm_tmux_composer_state() { # -> empty|pending|pending-unproven|unknown - local target=$1 cy raw pane plain box box_status top bottom geometry_ambiguous - local row row_raw state unknown_seen=0 - cy=$(tmux display-message -p -t "$target" '#{cursor_y}' 2>/dev/null) || { printf 'unknown'; return 0; } + local target=$1 cy pane verdict identity + cy=$(fm_tmux_composer_cursor_row "$target") || { printf 'unknown'; return 0; } case "$cy" in ''|*[!0-9]*) printf 'unknown'; return 0 ;; esac - pane=$(tmux capture-pane -e -p -t "$target" -S 0 -E - 2>/dev/null) || { printf 'unknown'; return 0; } - plain=$(printf '%s\n' "$pane" | fm_composer_strip_ansi) - if box=$(fm_tmux_find_composer_box "$cy" "$plain"); then - top=${box%% *} - box=${box#* } - bottom=${box%% *} - geometry_ambiguous=${box#* } - row=$((top + 1)) - while [ "$row" -lt "$bottom" ]; do - row_raw=$(printf '%s\n' "$pane" | sed -n "$((row + 1))p") - state=$(fm_tmux_composer_row_state "$row_raw" 1 0) - case "$state" in - pending) - if [ "$geometry_ambiguous" = 1 ]; then - printf 'pending-unproven' - else - printf 'pending' - fi - return 0 - ;; - unknown) unknown_seen=1 ;; - esac - row=$((row + 1)) - done - if [ "$unknown_seen" = 1 ] || [ "$geometry_ambiguous" = 1 ]; then - printf 'unknown' + pane=$(fm_tmux_composer_capture "$target") || { printf 'unknown'; return 0; } + verdict=$(fm_composer_classify_screen "$(fm_tmux_composer_caps)" "$pane" "$cy") + if [ "$verdict" = need-identity ]; then + if identity=$(fm_tmux_composer_identity "$target") && [ -n "$identity" ]; then + verdict=$(fm_composer_classify_screen "$(fm_tmux_composer_caps)" "$pane" "$cy" "$identity") else - printf 'empty' - fi - return 0 - else - box_status=$? - if [ "$box_status" -eq 2 ]; then - printf 'unknown' - return 0 + verdict=unknown fi + [ "$verdict" != need-identity ] || verdict=unknown fi - raw=$(tmux capture-pane -e -p -t "$target" -S "$cy" -E "$cy" 2>/dev/null) \ - || { printf 'unknown'; return 0; } - if fm_tmux_row_has_composer_edge "$(printf '%s\n' "$raw" | fm_composer_strip_ansi)"; then - printf 'unknown' - return 0 - fi - fm_tmux_composer_row_state "$raw" 0 + printf '%s' "$verdict" } # fm_pane_input_pending: 0 when the composer is not proven empty, so pending @@ -392,14 +252,41 @@ fm_pane_is_busy() { # [harness] # `empty` so the caller does not re-send), while an idle pane keeps `pending` as # a genuine swallow. Pending-unproven receives the same Enter retry budget but # never reaches this exception. -fm_tmux_submit_enter_core() { # - local target=$1 retries=$2 sleep_s=$3 i=0 state +# Turn-started confirmation (the strict blank-row posture's counterpart): a +# harness whose mid-turn screen the classifier cannot positively identify (pi +# replaces its separated composer while working) reads `unknown` right after a +# successful submit. When and only when the pane was IDLE before the text was +# typed, an idle-to-busy transition across our Enter is proof the harness +# accepted the submission - the same semantic signal herdr's native +# agent-state confirmation uses, read from the pane's verified busy footer. +# The busy read is polled across the remaining retry budget because the turn +# takes a beat to render. Without the baseline (a direct +# fm_tmux_submit_enter_core caller, or a pane already busy before typing) an +# `unknown` verdict is preserved untouched: busy conversion without the +# transition evidence could mark an undelivered message delivered. +fm_tmux_submit_enter_core() { # [baseline-idle] + local target=$1 retries=$2 sleep_s=$3 baseline_idle=${4:-} i=0 j state while :; do tmux send-keys -t "$target" Enter 2>/dev/null || true sleep "$sleep_s" state=$(fm_tmux_composer_state "$target") case "$state" in pending|pending-unproven) ;; + unknown) + if [ "$baseline_idle" = 1 ]; then + j=0 + while [ "$j" -lt "$retries" ]; do + if fm_pane_is_busy "$target"; then + printf 'empty' + return 0 + fi + j=$((j + 1)) + [ "$j" -ge "$retries" ] || sleep "$sleep_s" + done + fi + printf 'unknown' + return 0 + ;; *) printf '%s' "$state"; return 0 ;; esac i=$((i + 1)) @@ -422,8 +309,12 @@ fm_tmux_submit_enter_core() { # } fm_tmux_submit_core() { # - local target=$1 text=$2 retries=$3 sleep_s=$4 settle=$5 + local target=$1 text=$2 retries=$3 sleep_s=$4 settle=$5 baseline_idle=1 + # The turn-started baseline must predate our own typing: a pane already + # busy before the text lands can turn "busy" for reasons unrelated to our + # Enter, so only a clean idle-to-busy transition may confirm a submit. + if fm_pane_is_busy "$target"; then baseline_idle=0; fi tmux send-keys -t "$target" -l "$text" 2>/dev/null || { printf 'send-failed'; return 0; } sleep "$settle" - fm_tmux_submit_enter_core "$target" "$retries" "$sleep_s" + fm_tmux_submit_enter_core "$target" "$retries" "$sleep_s" "$baseline_idle" } diff --git a/docs/cmux-backend.md b/docs/cmux-backend.md index 5d1cfeb8815..8f54d577508 100644 --- a/docs/cmux-backend.md +++ b/docs/cmux-backend.md @@ -92,8 +92,8 @@ Spawn-time worktree discovery sends begin and end markers around `pwd`, captures Literal send and Enter are separate calls. Enter, Escape, and Ctrl-C are supported. -The composer verifier locates the last bordered composer row or a later bare agent-prompt row bounded by horizontal rules, then delegates the content decision to `bin/fm-composer-lib.sh`. -The bounded bare shape supports Claude's borderless `❯` composer, with or without a trailing U+00A0 non-breaking space, without relying on a cursor primitive that `read-screen` does not provide. +The composer verifier is a thin adapter: it captures a bounded plain-text tail and hands it with cmux's capability facts to the fleet-wide classifier in `bin/fm-composer-lib.sh`, which owns every shape, including Claude's borderless `❯` row with its U+00A0 separator. +`read-screen` is plain text with no cursor primitive, so the shared classifier degrades a glyph row carrying trailing text to `unknown` rather than misreading a harness's own idle suggestion as unsent input. An unstructured bare prompt is `unknown`, and a slash-popup placeholder remains `pending`, so only Enter is retried and text is never retyped. cmux exposes no native generic agent busy signal, so supervision uses capture/hash polling for screen changes and each harness adapter's semantic lifecycle for worker state. Grok alone retains its isolated rendered-tail fallback. diff --git a/docs/configuration.md b/docs/configuration.md index 1d1c121ed7a..49b76d9c24a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -507,17 +507,9 @@ FM_PROC_ROOT_OVERRIDE= # alternate /proc root for Linux process-identity reads FM_BACKEND= # optional runtime backend override for new spawns; tmux/herdr/zellij/orca/cmux support ship/scout spawns, codex-app is not accepted FM_TRACE_CONTEXT= # optional trace-context override; see "Trace context propagation" HERDR_SESSION=default # herdr-only: named session for normal backend ops; not enough for destructive cleanup (docs/herdr-backend.md) -FM_BACKEND_HERDR_COMPOSER_LINES=20 # herdr-only: tail lines scanned by composer-state guard/fallback paths; idle-baseline submit confirmation uses agent-state -FM_BACKEND_HERDR_IDLE_RE='^Type a message\.\.\.$' # herdr-only: empty-composer placeholder regex after shared ghost extraction plus border and prompt stripping -FM_BACKEND_HERDR_BARE_PROMPT_RE='^(❯|›)' # herdr-only: verified agent glyphs recognized as an UNBORDERED (bare) composer row, e.g. Claude's ❯ or Codex's ›; an alternation, not a `[...]` bracket expression, so a C-locale byte-decomposed match can never misfire on an unrelated multibyte glyph; shell glyphs remain unknown rather than empty, and de-emphasised ghost/placeholder text reads empty through shared fm_composer_strip_ghost (docs/herdr-backend.md "Composer and injection safety") -FM_BACKEND_HERDR_PI_COMPOSER_MAX_LINES=8 # herdr-only: maximum rows admitted between Pi's native-identity-corroborated separator pair; taller or ambiguous candidates stay unknown (docs/herdr-backend.md "Composer and injection safety") FM_BACKEND_HERDR_SUBMIT_POLLS=6 # herdr-only: agent-state samples spread across each Enter attempt's budget when confirming a submit (docs/herdr-backend.md "Current transport behavior") FM_BACKEND_HERDR_SUBMIT_MIN_SLEEP=0.6 # herdr-only: minimum per-Enter confirmation budget before polling agent-state after an idle baseline -FM_BACKEND_ORCA_COMPOSER_LINES=200 # orca-only: terminal-read lines scanned to locate the composer row for submit verification -FM_BACKEND_ORCA_IDLE_RE='^Type a message\.\.\.$' # orca-only: empty-composer placeholder regex after border/prompt stripping FM_ZELLIJ_SESSION=firstmate # zellij-only: named session for normal backend ops and test isolation (docs/zellij-backend.md) -FM_BACKEND_CMUX_COMPOSER_LINES=20 # cmux-only: tail lines scanned to locate the composer row for submit verification -FM_BACKEND_CMUX_IDLE_RE='^Type a message\.\.\.$' # cmux-only: empty-composer placeholder regex after border/prompt stripping CMUX_SOCKET_PASSWORD= # cmux-only: socket password fallback when config/cmux-socket-password is absent (docs/cmux-backend.md) FM_SESSION_START_STATUS_TAIL=5 # state/*.status lines printed per task in the session-start digest; each line is capped by bin/fm-line-cap-lib.sh FM_SESSION_START_QUEUED_LIMIT=20 # plain queued backlog rows in the session-start digest; in-flight, held, and blocked rows are never bounded and done rows are never listed @@ -584,7 +576,9 @@ 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 empty-composer regex, applied after ghost and border stripping +FM_COMPOSER_IDLE_RE= # optional fleet-wide override of the empty-composer placeholder set, applied after ghost and border stripping on every backend (bin/fm-composer-lib.sh) +FM_COMPOSER_CAPTURE_LINES=20 # fleet-wide: bounded tail rows every backend captures for a composer read; the composer is bottom-anchored, so a small window keeps stale scrollback banners 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, shared by the tmux and herdr composer readers) GROK_HOME= # optional Grok config home for firstmate's global grok turn-end hook; defaults to ~/.grok FM_SEND_RETRIES=3 # fm-send Enter-retry attempts after typing the line once diff --git a/docs/herdr-backend.md b/docs/herdr-backend.md index fc92fd2fb1b..8324265c5c3 100644 --- a/docs/herdr-backend.md +++ b/docs/herdr-backend.md @@ -228,12 +228,13 @@ A human-blocked permission dialog has no busy banner and still surfaces. ## Composer and injection safety Herdr has no direct cursor-row primitive. -The adapter locates the bottom-most recognized bordered row, Claude `❯` row, Codex `›` row, or a Pi separator region admitted only when native identity is exactly Pi and state is idle, done, or blocked. -A working Pi, pending middle row, missing identity, incomplete separator pair, or over-tall candidate remains pending or unknown. +The adapter is a thin capture: it hands a bounded ANSI tail plus Herdr's capability facts to the fleet-wide classifier in `bin/fm-composer-lib.sh`, which owns every shape - bordered boxes, bare agent-glyph rows (including muse's `⟩`, which the adapter's retired local pattern silently omitted), opencode's left bar, and the Pi separator region this adapter pioneered, admitted only when native `agent get` identity is exactly Pi and state is idle, done, or blocked. +A working Pi, pending middle row, missing identity, incomplete separator pair, or over-tall candidate remains unknown or pending. +Identity stays a lazy second read, consulted only when a separator pair could change the verdict. ANSI capture preserves de-emphasized placeholder style. `bin/fm-composer-lib.sh` is the fleet-wide owner that strips dim or faint runs and dark truecolor placeholders while retaining bright typed input. -If a future Herdr version strips ANSI style, ghost suggestions become pending rather than empty, which safely defers injection and eventually raises the wedge alarm. +If the ANSI capture ever fails, the plain fallback declares itself unstyled and the classifier degrades a glyph row carrying trailing text to `unknown` instead of misreading ghost suggestions as typed input, which safely defers injection and eventually raises the wedge alarm. A bare shell prompt is never an empty agent composer. Away-mode injection proceeds only on an affirmative `empty` result, never on unknown. diff --git a/docs/orca-backend.md b/docs/orca-backend.md index 42b9815cec5..e654dfaa647 100644 --- a/docs/orca-backend.md +++ b/docs/orca-backend.md @@ -49,8 +49,9 @@ Spawn registers the repository, creates an independent worktree, reuses only the Exact command flags and response parsing are owned by `bin/backends/orca.sh` and script help. `fm-peek.sh` reads with `orca terminal read`. -`fm-send.sh` types and verifies composer clearance, follows `oldestCursor` when Orca returns a limited page, and retries Enter without retyping when a slash popup first fills an argument placeholder. -A bare shell row is `unknown`, not an empty agent composer. +`fm-send.sh` types and verifies composer clearance through the fleet-wide classifier in `bin/fm-composer-lib.sh`, retrying Enter without retyping when a slash popup first fills an argument placeholder. +The composer read is one bounded tail of the live terminal and never pages backward into scrollback, so a stale startup banner cannot compete with the bottom-anchored composer. +A bare shell row is `unknown`, not an empty agent composer, and plain-text captures degrade a glyph row carrying trailing text to `unknown` rather than a false `pending`. The watcher has no native Orca busy signal, so each harness adapter's semantic lifecycle supplies worker state. Grok alone retains its isolated rendered-tail fallback. diff --git a/docs/tmux-backend.md b/docs/tmux-backend.md index 0d7366c3046..9f735750481 100644 --- a/docs/tmux-backend.md +++ b/docs/tmux-backend.md @@ -67,10 +67,10 @@ Run the real-harness guard after any harness upgrade and before trusting refresh ### Composer, busy state, and delivery Agent liveness and composer safety are separate checks. -For a bordered composer, the tmux reader locates the complete box structurally and classifies every content row through the shared ANSI and ghost handling in `bin/fm-composer-lib.sh`. -Real text on any content row is pending, while only an unambiguous box with every row empty is proven empty. -Unreadable, incomplete, or structurally ambiguous boxes fail closed, and panes without a bordered composer retain the compatible cursor-row classification. -The shared classifier accepts a shell glyph as an empty agent composer only inside a verified bordered composer. +The tmux reader is a thin adapter over the fleet-wide classifier in `bin/fm-composer-lib.sh`: it contributes one styled full-pane capture, the `#{cursor_y}` cursor row, and a Pi foreground-process identity probe, and the shape containing the cursor - a complete bordered box (titled bottom borders tolerated), a bare agent-glyph row with its wrapped input, opencode's left bar, or Pi's identity-corroborated separator pair - decides the verdict. +Real text in an identified shape is pending, while only positively proven emptiness reads empty. +A blank or otherwise unidentified cursor row is `unknown` and every consumer defers: this strict container-proof rule replaced the earlier permissive blank-row reading, so a modal dialog, a dead shell between stale rules, or a mid-redraw pane is never an injection target. +The shared classifier accepts a shell glyph as an empty agent composer only inside a bordered container. A bare shell prompt is `unknown`, so away-mode escalation is never injected into a dead shell. Busy state is not read from rendered text on this backend. @@ -89,6 +89,8 @@ OpenCode 1.18.4 has one busy-queue exception. While OpenCode is mid-turn, Enter queues the message but leaves its text visible until the turn completes. After the normal retry budget, only structurally proven pending text in a provably busy pane is accepted as queued, while an idle pane remains `pending` as a genuine swallowed Enter. Ambiguous pending text never receives the busy-queue conversion. +A second, baseline-gated conversion covers harnesses whose mid-turn screen the classifier cannot identify (Pi replaces its separated composer while working): when and only when the pane was idle before the text was typed, an idle-to-busy transition across the submit's own Enter confirms delivery, the same turn-started signal Herdr reads natively. +Without that baseline, an `unknown` verdict is preserved untouched, so a busy-looking pane can never convert an unread composer into a confirmation. `tests/fm-tmux-submit-busy.test.sh` covers busy and idle panes with proven, ambiguous, and cleared composers. ## Limits and regression entry points diff --git a/docs/verification/runtime-backends.md b/docs/verification/runtime-backends.md index f22b704250d..87efa4f4963 100644 --- a/docs/verification/runtime-backends.md +++ b/docs/verification/runtime-backends.md @@ -180,6 +180,37 @@ Valid cleanup removed only the exact task-bound target and left the control wind The metadata-only validation covers tmux, Herdr, Zellij, Orca, and cmux before backend dispatch. Claude, Codex, OpenCode, Pi, pi-signed, Grok, Kimi, and Muse share that backend cleanup boundary; their harness-specific hook files, tokens, and session-log sidecars are cleaned only after it, so no harness needs a separate endpoint parser. +## Composer classification matrix + +The shared composer classifier (`bin/fm-composer-lib.sh`, `fm_composer_classify_screen`) owns every composer shape fleet-wide; each backend contributes only a capture and a capability descriptor. +The live half of that guarantee was verified on 2026-08-09 against every installed harness on tmux 3.6a, macOS arm64, on an isolated private socket, with no prompt submitted to any harness. + +```sh +FM_COMPOSER_MATRIX_LIVE=1 tests/fm-composer-matrix-live-e2e.test.sh +``` + +Observed output: + +```text +ok - claude (2.1.226 (Claude Code)): real idle composer classifies empty +ok - codex (codex-cli 0.146.0): real idle composer classifies empty +ok - opencode (1.14.46): real idle composer classifies empty +ok - pi (0.84.0): real idle composer classifies empty +ok - grok (grok 1.0.0 (3cd0d0cbcebe) [stable]): real idle composer classifies empty +# harness absent, not verified here: kimi +ok - muse (Muse Code 0.1.0 (0.1.0-R708.1)): real idle composer classifies empty +ok - strict posture live: a blank shell row classifies unknown and injection defers +ok - zellij (zellij 0.44.0): unrelated pane change never confirms delivery (verdict: unknown) +ok - live composer-matrix guard verified 8 live surface(s) +``` + +Every installed harness's real idle composer reached a proven `empty`, including pi through the tmux foreground-process identity probe, grok through the titled-bottom-border tolerance, and opencode through the left-bar shape; codex and opencode first parked on vendor update-available modals that the strict classifier correctly refused until the guard's single non-submitting Escape dismissed them. +The strict blank-row posture held live (a blank shell row deferred injection), and a zellij pane changing for reasons unrelated to submission never confirmed a delivery, replacing the retired content-diff heuristic's false positive. +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. + +`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 The compatibility floor is protocol 14. @@ -577,6 +608,7 @@ All real tests use a uniquely named session and `tests/zellij-test-safety.sh`; t | Literal send | `zellij action paste --pane-id -- ` | Left text unsubmitted. | | Keys | `send-keys --pane-id Enter`, `Esc`, and one argument `Ctrl c` | All three shared operations worked. | | Capture | `dump-screen --pane-id ` or `--full` | Worked with no attached client; no line-bound flag exists. | +| Styled capture | `dump-screen --pane-id --ansi` | Preserved ANSI styling ("Composer classification matrix" above); feeds the zellij composer classifier. | | Close | `close-tab-by-id ` | Removed the live task pane and tab together. | | Failure exit | actions against missing targets | Returned exit 0, requiring structural preflight and output-shape validation. | diff --git a/docs/zellij-backend.md b/docs/zellij-backend.md index c9f440b468e..6cd5d63ea75 100644 --- a/docs/zellij-backend.md +++ b/docs/zellij-backend.md @@ -76,8 +76,10 @@ There is a narrow visible race between those calls that no current Zellij flag c Literal send uses bracketed paste followed by a separate explicit Enter. The adapter supports `Enter`, `Esc`, and the one-argument key expression `Ctrl c` through the shared key vocabulary. -Zellij exposes no cursor-row, ANSI composer style, or native agent-state signal, so submit acknowledgement remains content-delta based. -This can distinguish no change from a changed screen but is less precise than tmux's structural box reader or Herdr's native state plus structural classifier. +Zellij exposes no cursor-row or native agent-state signal, but `dump-screen --ansi` (verified at 0.44.0) preserves styling, so the composer is read through the same fleet-wide classifier as tmux and herdr (`bin/fm-composer-lib.sh`), with ghost and placeholder text stripped before the verdict. +Submit acknowledgement requires a positively classified empty composer. +The retired content-delta acknowledgement could report a message delivered whenever the pane changed for any reason - a spinner, streaming output, a clock - which could silently close a decision record for a message the crew never received; a pane that merely changed no longer confirms anything. +A dead pane still fails safe: Zellij's unconditional-exit-0 actions dump nothing, and an empty dump classifies `unknown`, never a confirmation. Viewport capture has no line-bound option. Routine reads use `dump-screen` and larger peeks use `dump-screen --full`, followed by local trimming. diff --git a/tests/fm-afk-inject-e2e.test.sh b/tests/fm-afk-inject-e2e.test.sh index 598f2a1a274..41030be3a87 100755 --- a/tests/fm-afk-inject-e2e.test.sh +++ b/tests/fm-afk-inject-e2e.test.sh @@ -93,8 +93,15 @@ cleanup() { trap cleanup EXIT INT TERM _buf= +# The drawn composer row carries a real agent prompt glyph, matching the +# production supervisor pane this daemon injects into: under the strict +# container-proof rule (captain decision blank-row-injection-posture) a bare +# unidentified row is never a safe injection target, so the fixture must +# render the shape the classifier positively proves - "❯ " when idle, +# "❯ " while input is pending. The glyph is rendering only; it never +# enters the buffer, so submitted-content assertions are unchanged. redraw() { - printf '\r\033[K%s' "$_buf" + printf '\r\033[K\xe2\x9d\xaf %s' "$_buf" } submit_line() { local _line=$_buf _c _hex diff --git a/tests/fm-backend-cmux.test.sh b/tests/fm-backend-cmux.test.sh index be623b8478c..4c6c0c906ea 100755 --- a/tests/fm-backend-cmux.test.sh +++ b/tests/fm-backend-cmux.test.sh @@ -329,7 +329,7 @@ test_dispatch_composer_state_routes_cmux() { dir="$TMP_ROOT/dispatch-composer"; mkdir -p "$dir/responses" target="aaaaaaaa-0000-0000-0000-000000000000:bbbbbbbb-1111-1111-1111-111111111111" cmux_panes_response "$dir" 1 "bbbbbbbb-1111-1111-1111-111111111111" - cmux_read_screen_response "$dir" 2 $' ╭────────────────────────╮\n │ ❯ hello captain │\n ╰──────── Composer ─────╯' + cmux_read_screen_response "$dir" 2 $' ╭────────────────────────╮\n │ ❯ hello captain │\n ╰──────── Composer ─────╯' fb=$(make_cmux_fakebin "$dir") out=$( PATH="$fb:$PATH" FM_CMUX_LOG="$dir/log" FM_CMUX_RESPONSES="$dir/responses" \ bash -c '. "$0/bin/fm-backend.sh"; fm_backend_composer_state cmux "$1"' "$ROOT" "$target" ) @@ -762,7 +762,15 @@ test_composer_state_borderless_claude_nbsp_prompt_is_empty() { pass "fm_backend_cmux_composer_state: a borderless Claude '❯'+NBSP composer row reads empty under LC_ALL=C" } -test_composer_state_borderless_claude_text_is_pending() { +test_composer_state_borderless_claude_text_is_unknown_plain() { + # Capability degradation (the consolidated classifier's styled=0 rule): on + # cmux's plain-text capture, text after a bare agent glyph is unreadable - + # it may be the harness's own idle suggestion (claude's rotating dim hint, + # codex's "Use /skills ..."), which a plain read cannot tell from typed + # input. The verdict is `unknown` (defer, loud refusal at fm-send), never a + # false `pending` that would misreport an idle pane as holding unsent text. + # The same bytes on a styled backend (tmux/herdr/zellij) classify pending + # when bright and empty when ghost - pinned in tests/fm-composer-lib.test.sh. local dir fb out dir="$TMP_ROOT/composer-borderless-claude-text"; mkdir -p "$dir/responses" cmux_panes_response "$dir" 1 "bbbbbbbb-1111-1111-1111-111111111111" @@ -770,8 +778,8 @@ test_composer_state_borderless_claude_text_is_pending() { fb=$(make_cmux_fakebin "$dir") out=$( PATH="$fb:$PATH" FM_CMUX_LOG="$dir/log" FM_CMUX_RESPONSES="$dir/responses" \ bash -c '. "$0/bin/backends/cmux.sh"; fm_backend_cmux_composer_state "aaaaaaaa-0000-0000-0000-000000000000:bbbbbbbb-1111-1111-1111-111111111111"' "$ROOT" ) - [ "$out" = pending ] || fail "a borderless Claude row with typed text should read pending, got '$out'" - pass "fm_backend_cmux_composer_state: a borderless Claude row with typed text reads pending" + [ "$out" = unknown ] || fail "plain-capture text after a bare glyph must degrade to unknown, got '$out'" + pass "fm_backend_cmux_composer_state: plain-capture text after a bare glyph degrades to unknown (never false pending)" } test_composer_state_ghost_placeholder_is_empty() { @@ -790,7 +798,7 @@ test_composer_state_real_text_is_pending() { local dir fb out dir="$TMP_ROOT/composer-pending"; mkdir -p "$dir/responses" cmux_panes_response "$dir" 1 "bbbbbbbb-1111-1111-1111-111111111111" - cmux_read_screen_response "$dir" 2 $' ╭────────────────────────╮\n │ ❯ hello captain │\n ╰──────── Composer ─────╯\n\n Enter:send' + cmux_read_screen_response "$dir" 2 $' ╭────────────────────────╮\n │ ❯ hello captain │\n ╰──────── Composer ─────╯\n\n Enter:send' fb=$(make_cmux_fakebin "$dir") out=$( PATH="$fb:$PATH" FM_CMUX_LOG="$dir/log" FM_CMUX_RESPONSES="$dir/responses" \ bash -c '. "$0/bin/backends/cmux.sh"; fm_backend_cmux_composer_state "aaaaaaaa-0000-0000-0000-000000000000:bbbbbbbb-1111-1111-1111-111111111111"' "$ROOT" ) @@ -808,7 +816,7 @@ test_composer_state_popup_placeholder_fill_is_pending() { local dir fb out dir="$TMP_ROOT/composer-popup-placeholder"; mkdir -p "$dir/responses" cmux_panes_response "$dir" 1 "bbbbbbbb-1111-1111-1111-111111111111" - cmux_read_screen_response "$dir" 2 $' ╭──────────────────────────────────────╮\n │ ❯ /compact compaction instructions │\n ╰──────────────── Composer ─────────────╯\n\n Enter:send' + cmux_read_screen_response "$dir" 2 $' ╭──────────────────────────────────────╮\n │ ❯ /compact compaction instructions │\n ╰──────────────── Composer ─────────────╯\n\n Enter:send' fb=$(make_cmux_fakebin "$dir") out=$( PATH="$fb:$PATH" FM_CMUX_LOG="$dir/log" FM_CMUX_RESPONSES="$dir/responses" \ bash -c '. "$0/bin/backends/cmux.sh"; fm_backend_cmux_composer_state "aaaaaaaa-0000-0000-0000-000000000000:bbbbbbbb-1111-1111-1111-111111111111"' "$ROOT" ) @@ -875,8 +883,8 @@ test_send_text_submit_detects_swallowed_enter() { cmux_panes_response "$dir" 5 "bbbbbbbb-1111-1111-1111-111111111111" cmux_panes_response "$dir" 7 "bbbbbbbb-1111-1111-1111-111111111111" cmux_panes_response "$dir" 9 "bbbbbbbb-1111-1111-1111-111111111111" - cmux_read_screen_response "$dir" 6 $' ╭────────────────────────╮\n │ ❯ hello captain │\n ╰──────── Composer ─────╯\n\n Enter:send' - cmux_read_screen_response "$dir" 10 $' ╭────────────────────────╮\n │ ❯ hello captain │\n ╰──────── Composer ─────╯\n\n Enter:send' + cmux_read_screen_response "$dir" 6 $' ╭────────────────────────╮\n │ ❯ hello captain │\n ╰──────── Composer ─────╯\n\n Enter:send' + cmux_read_screen_response "$dir" 10 $' ╭────────────────────────╮\n │ ❯ hello captain │\n ╰──────── Composer ─────╯\n\n Enter:send' fb=$(make_cmux_fakebin "$dir") out=$( PATH="$fb:$PATH" FM_CMUX_LOG="$dir/log" FM_CMUX_RESPONSES="$dir/responses" \ bash -c '. "$0/bin/backends/cmux.sh"; fm_backend_cmux_send_text_submit "aaaaaaaa-0000-0000-0000-000000000000:bbbbbbbb-1111-1111-1111-111111111111" "hello captain" 2 0.01 0.01' "$ROOT" ) @@ -901,7 +909,7 @@ test_send_text_submit_popup_autocomplete_requires_second_enter() { cmux_panes_response "$dir" 1 "bbbbbbbb-1111-1111-1111-111111111111" cmux_panes_response "$dir" 3 "bbbbbbbb-1111-1111-1111-111111111111" cmux_panes_response "$dir" 5 "bbbbbbbb-1111-1111-1111-111111111111" - cmux_read_screen_response "$dir" 6 $' ╭──────────────────────────────────────╮\n │ ❯ /compact compaction instructions │\n ╰──────────────── Composer ─────────────╯\n\n Enter:send' + cmux_read_screen_response "$dir" 6 $' ╭──────────────────────────────────────╮\n │ ❯ /compact compaction instructions │\n ╰──────────────── Composer ─────────────╯\n\n Enter:send' # 7: list-panes (target_ready via send_key Enter #2) # 8: send-key enter (#2) - actually submits # 9: list-panes (target_ready via composer_state capture) @@ -1138,7 +1146,7 @@ test_composer_state_bare_prompt_is_empty test_composer_state_borderless_claude_prompt_is_empty test_composer_state_borderless_claude_prompt_outranks_stale_bordered_row test_composer_state_borderless_claude_nbsp_prompt_is_empty -test_composer_state_borderless_claude_text_is_pending +test_composer_state_borderless_claude_text_is_unknown_plain test_composer_state_ghost_placeholder_is_empty test_composer_state_real_text_is_pending test_composer_state_popup_placeholder_fill_is_pending diff --git a/tests/fm-backend-herdr.test.sh b/tests/fm-backend-herdr.test.sh index b76393da415..4fba797a6c1 100755 --- a/tests/fm-backend-herdr.test.sh +++ b/tests/fm-backend-herdr.test.sh @@ -3009,7 +3009,7 @@ test_composer_state_ghost_placeholder_is_empty() { test_composer_state_real_text_is_pending() { local dir log resp fb out dir="$TMP_ROOT/composer-pending"; mkdir -p "$dir/responses"; log="$dir/log"; resp="$dir/responses"; : > "$log" - printf ' ╭────────────────────────╮\n │ ❯ hello captain │\n ╰──────── Composer ─────╯\n\n Enter:send\n' > "$resp/1.out" + printf ' ╭────────────────────────╮\n │ ❯ hello captain │\n ╰──────── Composer ─────╯\n\n Enter:send\n' > "$resp/1.out" fb=$(make_herdr_fakebin "$dir") out=$( PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_composer_state default:w1:p2' "$ROOT" ) @@ -3028,7 +3028,7 @@ test_composer_state_real_text_is_pending() { test_composer_state_popup_placeholder_fill_is_pending() { local dir log resp fb out dir="$TMP_ROOT/composer-popup-placeholder"; mkdir -p "$dir/responses"; log="$dir/log"; resp="$dir/responses"; : > "$log" - printf ' ╭──────────────────────────────────────╮\n │ ❯ /compact compaction instructions │\n ╰──────────────── Composer ─────────────╯\n\n Enter:send\n' > "$resp/1.out" + printf ' ╭──────────────────────────────────────╮\n │ ❯ /compact compaction instructions │\n ╰──────────────── Composer ─────────────╯\n\n Enter:send\n' > "$resp/1.out" fb=$(make_herdr_fakebin "$dir") out=$( PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_composer_state default:w1:p2' "$ROOT" ) @@ -3236,7 +3236,7 @@ test_composer_state_claude_dim_ghost_row_with_real_text_is_pending() { test_composer_state_grok_dark_truecolor_placeholder_is_empty() { local dir log resp fb out dir="$TMP_ROOT/composer-grok-truecolor-ghost"; mkdir -p "$dir/responses"; log="$dir/log"; resp="$dir/responses"; : > "$log" - printf ' \x1b[38;2;86;82;110m\xe2\x95\xad\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x95\xae\x1b[39m\n \x1b[38;2;86;82;110m\xe2\x94\x82\x1b[38;2;224;222;244m \xe2\x9d\xaf \x1b[38;2;50;47;70mType a message...\x1b[38;2;86;82;110m \xe2\x94\x82\x1b[39m\n \x1b[38;2;86;82;110m\xe2\x95\xb0\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x95\xaf\x1b[39m\n' > "$resp/1.out" + printf ' \x1b[38;2;86;82;110m\xe2\x95\xad\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x95\xae\x1b[39m\n \x1b[38;2;86;82;110m\xe2\x94\x82\x1b[38;2;224;222;244m \xe2\x9d\xaf \x1b[38;2;50;47;70mType a message...\x1b[38;2;86;82;110m \xe2\x94\x82\x1b[39m\n \x1b[38;2;86;82;110m\xe2\x95\xb0\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x95\xaf\x1b[39m\n' > "$resp/1.out" fb=$(make_herdr_fakebin "$dir") out=$( PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_composer_state default:w1:p2' "$ROOT" ) @@ -3248,7 +3248,7 @@ test_composer_state_grok_dark_truecolor_placeholder_is_empty() { test_composer_state_grok_bright_truecolor_real_text_is_pending() { local dir log resp fb out dir="$TMP_ROOT/composer-grok-truecolor-real"; mkdir -p "$dir/responses"; log="$dir/log"; resp="$dir/responses"; : > "$log" - printf ' \x1b[38;2;86;82;110m\xe2\x94\x82\x1b[38;2;224;222;244m \xe2\x9d\xaf fix the login bug \x1b[38;2;86;82;110m\xe2\x94\x82\x1b[39m\n' > "$resp/1.out" + printf ' \x1b[38;2;86;82;110m\xe2\x95\xad\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x95\xae\x1b[39m\n \x1b[38;2;86;82;110m\xe2\x94\x82\x1b[38;2;224;222;244m \xe2\x9d\xaf fix the login bug \x1b[38;2;86;82;110m\xe2\x94\x82\x1b[39m\n \x1b[38;2;86;82;110m\xe2\x95\xb0\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x95\xaf\x1b[39m\n' > "$resp/1.out" fb=$(make_herdr_fakebin "$dir") out=$( PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_composer_state default:w1:p2' "$ROOT" ) @@ -3620,8 +3620,9 @@ test_dispatch_composer_state_routes_by_backend() { # fm_backend_composer_state (the generic per-backend composer/pending-input # classifier the away-mode daemon dispatches through - bin/fm-supervise-daemon.sh's # pane_input_pending) must route to each backend's OWN named classifier with - # the target passed through unchanged, fall back to unknown for a backend with - # no named classifier (zellij), and unknown for an unrecognized backend name. + # the target passed through unchanged - every backend has one now, all thin + # wrappers over the shared fm_composer_classify_screen - and report unknown + # for an unrecognized backend name. # Sourced-guards are pre-set so fm_backend_source no-ops and these stubs are # never clobbered by the real per-backend files trying (and failing) a live call. ( @@ -3634,13 +3635,14 @@ test_dispatch_composer_state_routes_by_backend() { fm_tmux_composer_state() { [ "$1" = "sess:win" ] || fail "tmux composer_state got wrong target: $1"; printf 'pending'; } fm_backend_herdr_composer_state() { [ "$1" = "default:w1:p2" ] || fail "herdr composer_state got wrong target: $1"; printf 'empty'; } fm_backend_orca_composer_state() { [ "$1" = "term-1" ] || fail "orca composer_state got wrong target: $1"; printf 'empty'; } + fm_backend_zellij_composer_state() { [ "$1" = "sess:7" ] || fail "zellij composer_state got wrong target: $1"; printf 'empty'; } [ "$(fm_backend_composer_state tmux sess:win)" = pending ] || fail "composer_state did not dispatch to the tmux classifier" [ "$(fm_backend_composer_state herdr default:w1:p2)" = empty ] || fail "composer_state did not dispatch to the herdr classifier" [ "$(fm_backend_composer_state orca term-1)" = empty ] || fail "composer_state did not dispatch to the orca classifier" - [ "$(fm_backend_composer_state zellij sess:win)" = unknown ] || fail "composer_state should report unknown for zellij (no named classifier yet)" + [ "$(fm_backend_composer_state zellij sess:7)" = empty ] || fail "composer_state did not dispatch to the zellij classifier" [ "$(fm_backend_composer_state bogus x)" = unknown ] || fail "composer_state should report unknown for an unrecognized backend" ) || fail "composer_state dispatch subshell failed" - pass "fm_backend_composer_state dispatches tmux/herdr/orca to their named classifiers, unknown for zellij/unrecognized backends" + pass "fm_backend_composer_state dispatches every backend to its named thin classifier, unknown for unrecognized backends" } test_scripts_route_explicit_target_through_meta_backend() { diff --git a/tests/fm-backend-orca.test.sh b/tests/fm-backend-orca.test.sh index 60dcf2c6042..08855dc88ef 100755 --- a/tests/fm-backend-orca.test.sh +++ b/tests/fm-backend-orca.test.sh @@ -138,8 +138,7 @@ test_send_text_submit_verifies_empty_composer_after_enter() { orca_case send-submit printf '{"ok":true,"result":{"send":{"handle":"term-123","accepted":true}}}\n' > "$RESP/1.out" printf '{"ok":true,"result":{"send":{"handle":"term-123","accepted":true}}}\n' > "$RESP/2.out" - printf '{"ok":true,"result":{"terminal":{"tail":["╭──╮","│ > │","╰──╯"],"limited":true,"oldestCursor":"cursor-old"},"limited":true,"oldestCursor":"cursor-old"}}\n' > "$RESP/3.out" - printf '{"ok":true,"result":{"terminal":{"tail":["╭──╮","│ > │","╰──╯"],"latestCursor":"cursor-new"}}}\n' > "$RESP/4.out" + printf '{"ok":true,"result":{"terminal":{"tail":["╭───╮","│ > │","╰───╯"]}}}\n' > "$RESP/3.out" out=$( PATH="$FB:$PATH" FM_ORCA_LOG="$LOG" FM_ORCA_RESPONSES="$RESP" \ bash -c '. "$0/bin/backends/orca.sh"; fm_backend_orca_send_text_submit term-123 "hello captain" 3 0.01 0.01' "$ROOT" ) [ "$out" = empty ] || fail "send_text_submit should report empty on successful Orca send, got '$out'" @@ -147,27 +146,46 @@ test_send_text_submit_verifies_empty_composer_after_enter() { "send_text_submit did not type the text literally before Enter" assert_contains "$(cat "$LOG")" $'orca\x1f''terminal'$'\x1f''send'$'\x1f''--terminal'$'\x1f''term-123'$'\x1f''--text'$'\x1f\x1f''--enter'$'\x1f''--json' \ "send_text_submit did not send Enter after typing" - assert_contains "$(cat "$LOG")" $'orca\x1f''terminal'$'\x1f''read'$'\x1f''--terminal'$'\x1f''term-123'$'\x1f''--cursor'$'\x1f''cursor-old'$'\x1f''--limit' \ - "send_text_submit did not follow cursor-backed reads when Orca reports a limited page" - pass "fm_backend_orca_send_text_submit: verifies empty composer after Enter" + # The composer read is ONE bounded tail read: the old backward paging + # (--cursor follow-ups on a limited page) is deleted, because paging into + # scrollback is what let a stale startup banner compete with the live + # composer (audit fm-composer-consolidation-audit-s1, section 3.3). + assert_not_contains "$(cat "$LOG")" $'\x1f''--cursor'$'\x1f' \ + "the composer read must never page backward into scrollback" + pass "fm_backend_orca_send_text_submit: verifies empty composer after Enter with one bounded read" } -test_send_text_submit_keeps_current_tail_when_limited() { - local out log_text enter_count - orca_case send-submit-limited-current-pending +test_send_text_submit_borderless_claude_confirms() { + # The #2029 analogue this adapter never received: a borderless claude + # composer (bare `❯` row between horizontal rules) must confirm a submit. + # Before consolidation orca knew only the bordered shape, so every steer to + # a borderless harness exited unconfirmed and --resolve-key never closed. + local out + orca_case send-submit-borderless printf '{"ok":true,"result":{"send":{"handle":"term-123","accepted":true}}}\n' > "$RESP/1.out" printf '{"ok":true,"result":{"send":{"handle":"term-123","accepted":true}}}\n' > "$RESP/2.out" - printf '{"ok":true,"result":{"terminal":{"tail":["noise","│ > hello captain │"],"limited":true,"oldestCursor":"cursor-old"},"limited":true,"oldestCursor":"cursor-old"}}\n' > "$RESP/3.out" - printf '{"ok":true,"result":{"terminal":{"tail":["╭──╮","│ > │","╰──╯"],"latestCursor":"cursor-new"}}}\n' > "$RESP/4.out" - printf '{"ok":true,"result":{"send":{"handle":"term-123","accepted":true}}}\n' > "$RESP/5.out" - printf '{"ok":true,"result":{"terminal":{"tail":["│ > │"]}}}\n' > "$RESP/6.out" + printf '{"ok":true,"result":{"terminal":{"tail":["────────────────","❯","────────────────"]}}}\n' > "$RESP/3.out" out=$( PATH="$FB:$PATH" FM_ORCA_LOG="$LOG" FM_ORCA_RESPONSES="$RESP" \ bash -c '. "$0/bin/backends/orca.sh"; fm_backend_orca_send_text_submit term-123 "hello captain" 3 0.01 0.01' "$ROOT" ) - [ "$out" = empty ] || fail "send_text_submit should keep the limited current tail and retry, got '$out'" - log_text=$(cat "$LOG") - enter_count=$(printf '%s\n' "$log_text" | grep -c $'orca\x1fterminal\x1fsend\x1f--terminal\x1fterm-123\x1f--text\x1f\x1f--enter\x1f--json') - [ "$enter_count" -eq 2 ] || fail "send_text_submit should see pending text in the current tail before older cursor text, got $enter_count Enter(s)" - pass "fm_backend_orca_send_text_submit: preserves current tail when limited reads fetch older cursor text" + [ "$out" = empty ] || fail "a borderless claude composer should confirm the submit, got '$out'" + pass "fm_backend_orca_send_text_submit: a borderless claude composer confirms delivery (the missing #2029 shape)" +} + +test_composer_state_stale_banner_never_wins() { + # The audit's confidently-wrong case (section 3.3): codex's startup banner + # (`│ permissions: YOLO mode │` inside a rounded box) classified as the + # composer, reading `pending` for a row that is not a composer at all. With + # the full shape catalogue the live bare row below the banner wins; with a + # plain capture its trailing hint text is unreadable, so the verdict is + # `unknown` (defer) - never the banner's false `pending`. + local out + orca_case composer-stale-banner + printf '{"ok":true,"result":{"terminal":{"tail":["╭────────────────────────╮","│ permissions: YOLO mode │","╰────────────────────────╯","› Use /skills to list available skills"]}}}\n' > "$RESP/1.out" + out=$( PATH="$FB:$PATH" FM_ORCA_LOG="$LOG" FM_ORCA_RESPONSES="$RESP" \ + bash -c '. "$0/bin/backends/orca.sh"; fm_backend_orca_composer_state term-123' "$ROOT" ) + [ "$out" != pending ] || fail "a stale startup banner must never classify as pending composer text" + [ "$out" = unknown ] || fail "the plain-capture codex hint should defer as unknown, got '$out'" + pass "fm_backend_orca_composer_state: a stale startup banner cannot outrank the live composer row" } test_send_text_submit_retries_when_composer_stays_pending() { @@ -175,9 +193,9 @@ test_send_text_submit_retries_when_composer_stays_pending() { orca_case send-submit-pending printf '{"ok":true,"result":{"send":{"handle":"term-123","accepted":true}}}\n' > "$RESP/1.out" printf '{"ok":true,"result":{"send":{"handle":"term-123","accepted":true}}}\n' > "$RESP/2.out" - printf '{"ok":true,"result":{"terminal":{"tail":["│ > hello captain │"]}}}\n' > "$RESP/3.out" + printf '{"ok":true,"result":{"terminal":{"tail":["╭─────────────────╮","│ > hello captain │","╰─────────────────╯"]}}}\n' > "$RESP/3.out" printf '{"ok":true,"result":{"send":{"handle":"term-123","accepted":true}}}\n' > "$RESP/4.out" - printf '{"ok":true,"result":{"terminal":{"tail":["│ > │"]}}}\n' > "$RESP/5.out" + printf '{"ok":true,"result":{"terminal":{"tail":["╭─────────────────╮","│ > │","╰─────────────────╯"]}}}\n' > "$RESP/5.out" out=$( PATH="$FB:$PATH" FM_ORCA_LOG="$LOG" FM_ORCA_RESPONSES="$RESP" \ bash -c '. "$0/bin/backends/orca.sh"; fm_backend_orca_send_text_submit term-123 "hello captain" 3 0.01 0.01' "$ROOT" ) [ "$out" = empty ] || fail "send_text_submit should retry Enter until the composer clears, got '$out'" @@ -190,7 +208,7 @@ test_send_text_submit_retries_when_composer_stays_pending() { test_composer_state_popup_placeholder_fill_is_pending() { local out orca_case composer-popup-placeholder - printf '{"ok":true,"result":{"terminal":{"tail":[" ╭──────────────────────────────────────╮"," │ ❯ /compact compaction instructions │"," ╰──────────────── Composer ─────────────╯",""," Enter:send"]}}}\n' > "$RESP/1.out" + printf '{"ok":true,"result":{"terminal":{"tail":[" ╭──────────────────────────────────────╮"," │ ❯ /compact compaction instructions │"," ╰──────────────── Composer ─────────────╯",""," Enter:send"]}}}\n' > "$RESP/1.out" out=$( PATH="$FB:$PATH" FM_ORCA_LOG="$LOG" FM_ORCA_RESPONSES="$RESP" \ bash -c '. "$0/bin/backends/orca.sh"; fm_backend_orca_composer_state term-123' "$ROOT" ) [ "$out" = pending ] || fail "a popup-close-with-placeholder-fill must still read as pending (not yet submitted), got '$out'" @@ -219,7 +237,7 @@ test_send_text_submit_popup_autocomplete_requires_second_enter() { # 3: read - composer still holds real pending text printf '{"ok":true,"result":{"send":{"handle":"term-123","accepted":true}}}\n' > "$RESP/1.out" printf '{"ok":true,"result":{"send":{"handle":"term-123","accepted":true}}}\n' > "$RESP/2.out" - printf '{"ok":true,"result":{"terminal":{"tail":[" ╭──────────────────────────────────────╮"," │ ❯ /compact compaction instructions │"," ╰──────────────── Composer ─────────────╯",""," Enter:send"]}}}\n' > "$RESP/3.out" + printf '{"ok":true,"result":{"terminal":{"tail":[" ╭──────────────────────────────────────╮"," │ ❯ /compact compaction instructions │"," ╰──────────────── Composer ─────────────╯",""," Enter:send"]}}}\n' > "$RESP/3.out" # 4: Enter #2 actually submits # 5: read - composer is empty printf '{"ok":true,"result":{"send":{"handle":"term-123","accepted":true}}}\n' > "$RESP/4.out" @@ -1285,7 +1303,8 @@ test_capture_fails_on_orca_error_json test_runtime_check_accepts_ready_orca_status test_runtime_check_refuses_unready_orca_status test_send_text_submit_verifies_empty_composer_after_enter -test_send_text_submit_keeps_current_tail_when_limited +test_send_text_submit_borderless_claude_confirms +test_composer_state_stale_banner_never_wins test_send_text_submit_retries_when_composer_stays_pending test_composer_state_popup_placeholder_fill_is_pending test_composer_state_bare_shell_prompt_is_unknown diff --git a/tests/fm-backend-zellij.test.sh b/tests/fm-backend-zellij.test.sh index 5039379f8b1..80577df80fc 100755 --- a/tests/fm-backend-zellij.test.sh +++ b/tests/fm-backend-zellij.test.sh @@ -908,7 +908,15 @@ test_forced_secondmate_teardown_kills_zellij_children_with_child_home_tag() { pass "fm-teardown.sh: force cleanup kills zellij children using the child home tag" } -# --- send_text_submit: delta-based verify-and-retry -------------------------- +# --- send_text_submit: classifier-based verify-and-retry --------------------- +# +# The old content-diff strategy ("pane changed after Enter = submitted") was +# the fleet's only FALSE-POSITIVE delivery confirmation and is deleted; these +# tests pin its replacement: the shared composer classifier read through +# `dump-screen --ansi` (styled=1), where only a positively classified empty +# composer confirms delivery. +# Call numbering per attempt: list-panes + paste, then per Enter attempt +# list-panes + send-keys followed by list-panes + dump-screen --ansi. test_send_text_submit_detects_landed_send() { local dir fb out @@ -916,20 +924,20 @@ test_send_text_submit_detects_landed_send() { zellij_pane_response "$dir" 1 7 3 zellij_pane_response "$dir" 3 7 3 zellij_pane_response "$dir" 5 7 3 - zellij_pane_response "$dir" 7 7 3 - printf '%s' $'❯ hello captain' > "$dir/responses/4.out" - printf '%s' $'hello captain\n❯' > "$dir/responses/8.out" + printf '%s' $'hello captain\n❯ ' > "$dir/responses/6.out" fb=$(make_zellij_fakebin "$dir") out=$( PATH="$fb:$PATH" FM_ZELLIJ_LOG="$dir/log" FM_ZELLIJ_RESPONSES="$dir/responses" \ FM_ZELLIJ_SESSION_LIST="firstmate" \ bash -c '. "$0/bin/backends/zellij.sh"; fm_backend_zellij_send_text_submit firstmate:7 "hello captain" 3 0.01 0.01' "$ROOT" ) - [ "$out" = empty ] || fail "send_text_submit should report empty (submitted) once the pane visibly changes, got '$out'" + [ "$out" = empty ] || fail "send_text_submit should report empty once the composer positively classifies empty, got '$out'" zellij_assert_call_order "$dir/log" $'\x1f''list-panes'$'\x1f''--json' $'\x1f''paste' \ "send_text_submit did not verify the pane before paste" zellij_assert_call_order "$dir/log" $'\x1f''list-panes'$'\x1f''--json' $'\x1f''dump-screen' \ "send_text_submit did not verify the pane before capture" + assert_contains "$(cat "$dir/log")" $'\x1f''dump-screen'$'\x1f''--pane-id'$'\x1f''7'$'\x1f''--ansi' \ + "send_text_submit did not read the composer through the styled dump" assert_contains "$(cat "$dir/log")" $'\x1f''paste'$'\x1f''--pane-id'$'\x1f''7'$'\x1f''--'$'\x1f''hello captain' "send_text_submit did not type the literal text first" - pass "fm_backend_zellij_send_text_submit: reports 'empty' once the pane content changes after Enter (submitted)" + pass "fm_backend_zellij_send_text_submit: reports 'empty' once the composer classifies empty (submitted)" } test_send_text_submit_detects_swallowed_enter() { @@ -940,18 +948,75 @@ test_send_text_submit_detects_swallowed_enter() { zellij_pane_response "$dir" 5 7 3 zellij_pane_response "$dir" 7 7 3 zellij_pane_response "$dir" 9 7 3 - zellij_pane_response "$dir" 11 7 3 - printf '%s' $'❯ hello captain' > "$dir/responses/4.out" - printf '%s' $'❯ hello captain' > "$dir/responses/8.out" - printf '%s' $'❯ hello captain' > "$dir/responses/12.out" + printf '%s' $'❯ hello captain' > "$dir/responses/6.out" + printf '%s' $'❯ hello captain' > "$dir/responses/10.out" fb=$(make_zellij_fakebin "$dir") out=$( PATH="$fb:$PATH" FM_ZELLIJ_LOG="$dir/log" FM_ZELLIJ_RESPONSES="$dir/responses" \ FM_ZELLIJ_SESSION_LIST="firstmate" \ bash -c '. "$0/bin/backends/zellij.sh"; fm_backend_zellij_send_text_submit firstmate:7 "hello captain" 2 0.01 0.01' "$ROOT" ) - [ "$out" = pending ] || fail "send_text_submit should report pending once retries are exhausted with no visible change, got '$out'" + [ "$out" = pending ] || fail "send_text_submit should report pending once retries are exhausted with the text still in the composer, got '$out'" zellij_assert_call_order "$dir/log" $'\x1f''list-panes'$'\x1f''--json' $'\x1f''send-keys' \ "send_text_submit did not verify the pane before send-keys" - pass "fm_backend_zellij_send_text_submit: reports 'pending' when the pane never changes after retried Enters (swallowed)" + pass "fm_backend_zellij_send_text_submit: reports 'pending' when the composer still holds the text after retried Enters (swallowed)" +} + +test_send_text_submit_unrelated_change_is_not_delivery() { + # THE false-positive regression (audit fm-composer-consolidation-audit-s1, + # section 3.5, verified live): a pane whose content changes for reasons + # unrelated to submission - a clock, a spinner, streaming output - must NOT + # read as delivered while the typed text still sits in the composer. The + # deleted content-diff heuristic reported `empty` here and let fm-send close + # --resolve-key decision records for a message the crew never received. + local dir fb out + dir="$TMP_ROOT/submit-false-positive"; mkdir -p "$dir/responses" + zellij_pane_response "$dir" 1 7 3 + zellij_pane_response "$dir" 3 7 3 + zellij_pane_response "$dir" 5 7 3 + zellij_pane_response "$dir" 7 7 3 + zellij_pane_response "$dir" 9 7 3 + printf '%s' $'clock 12:00:01\n❯ hello captain' > "$dir/responses/6.out" + printf '%s' $'clock 12:00:02\n❯ hello captain' > "$dir/responses/10.out" + fb=$(make_zellij_fakebin "$dir") + out=$( PATH="$fb:$PATH" FM_ZELLIJ_LOG="$dir/log" FM_ZELLIJ_RESPONSES="$dir/responses" \ + FM_ZELLIJ_SESSION_LIST="firstmate" \ + bash -c '. "$0/bin/backends/zellij.sh"; fm_backend_zellij_send_text_submit firstmate:7 "hello captain" 2 0.01 0.01' "$ROOT" ) + [ "$out" != empty ] || fail "an unrelated pane change must never read as delivered (the content-diff false positive)" + [ "$out" = pending ] || fail "the still-typed composer should classify pending, got '$out'" + pass "fm_backend_zellij_send_text_submit: an unrelated pane change is not a delivery confirmation (false-positive regression)" +} + +test_composer_state_reads_styled_dump() { + local dir fb out + dir="$TMP_ROOT/composer-styled"; mkdir -p "$dir/responses" + zellij_pane_response "$dir" 1 7 3 + # Real claude-in-zellij capture shape (audit section 3.5): ESC[m ❯ U+00A0. + printf 'transcript line\n\033[m\342\235\257\302\240' > "$dir/responses/2.out" + fb=$(make_zellij_fakebin "$dir") + out=$( PATH="$fb:$PATH" FM_ZELLIJ_LOG="$dir/log" FM_ZELLIJ_RESPONSES="$dir/responses" \ + FM_ZELLIJ_SESSION_LIST="firstmate" \ + bash -c '. "$0/bin/backends/zellij.sh"; fm_backend_zellij_composer_state firstmate:7' "$ROOT" ) + [ "$out" = empty ] || fail "the real claude-in-zellij ANSI dump should classify empty, got '$out'" + assert_contains "$(cat "$dir/log")" $'\x1f''dump-screen'$'\x1f''--pane-id'$'\x1f''7'$'\x1f''--ansi' \ + "composer_state did not request the styled dump" + pass "fm_backend_zellij_composer_state: classifies the real claude-in-zellij --ansi dump as empty" +} + +test_composer_state_dead_pane_is_unknown() { + # The unconditional-exit-0 CLI quirk (file header): a dead target dumps + # nothing. Both the styled and the plain fallback come back empty, so the + # verdict must be unknown - never a confirmation. + local dir fb out + dir="$TMP_ROOT/composer-dead"; mkdir -p "$dir/responses" + zellij_pane_response "$dir" 1 7 3 + zellij_pane_response "$dir" 3 7 3 + : > "$dir/responses/2.out" + : > "$dir/responses/4.out" + fb=$(make_zellij_fakebin "$dir") + out=$( PATH="$fb:$PATH" FM_ZELLIJ_LOG="$dir/log" FM_ZELLIJ_RESPONSES="$dir/responses" \ + FM_ZELLIJ_SESSION_LIST="firstmate" \ + bash -c '. "$0/bin/backends/zellij.sh"; fm_backend_zellij_composer_state firstmate:7' "$ROOT" ) + [ "$out" = unknown ] || fail "a dead pane's empty dumps must classify unknown, got '$out'" + pass "fm_backend_zellij_composer_state: a dead pane (empty dumps) reads unknown, never a confirmation" } test_send_text_submit_send_failed_when_session_absent() { @@ -1113,6 +1178,9 @@ test_teardown_passes_recorded_tab_id_to_zellij_kill test_forced_secondmate_teardown_kills_zellij_children_with_child_home_tag test_send_text_submit_detects_landed_send test_send_text_submit_detects_swallowed_enter +test_send_text_submit_unrelated_change_is_not_delivery +test_composer_state_reads_styled_dump +test_composer_state_dead_pane_is_unknown test_send_text_submit_send_failed_when_session_absent test_send_text_submit_send_failed_when_pane_absent test_scripts_route_explicit_target_through_meta_backend diff --git a/tests/fm-backend.test.sh b/tests/fm-backend.test.sh index 69e87a82607..ece981b1222 100755 --- a/tests/fm-backend.test.sh +++ b/tests/fm-backend.test.sh @@ -672,56 +672,51 @@ strip_send_preflight() { # awk -v preflight="$preflight" '$0 != preflight { print }' "$1" } -test_send_conformance_old_vs_new() { - local old_bin fb log_old log_new home rc_old rc_new filtered_old filtered_new - old_bin=$(build_old_bin send-old) +# The byte-identical old-vs-new tmux log comparison this test used to run +# covered the P1 backend extraction, which promised an unchanged command +# sequence. The composer consolidation (fm-composer-thin-adapter-refactor-r1) +# deliberately changed that sequence - the submit core reads a busy baseline +# before typing (its idle-to-busy turn-started confirmation) and the composer +# verdict comes from one full styled capture instead of a second band capture - +# so the current contract is asserted directly instead. +test_send_tmux_contract() { + local fb log home rc fb=$(make_send_fakebin "$TMP_ROOT/send-fake") home="$TMP_ROOT/send-home"; mkdir -p "$home/state" - log_old="$TMP_ROOT/send-old.log"; log_new="$TMP_ROOT/send-new.log" - filtered_old="$TMP_ROOT/send-old.filtered.log"; filtered_new="$TMP_ROOT/send-new.filtered.log" + log="$TMP_ROOT/send-new.log" - # Case 1: --key path. - run_send_case "$old_bin" "$fb" "$log_old" "$home" -- "sess:win" --key Escape - rc_old=$? - run_send_case "$ROOT" "$fb" "$log_new" "$home" -- "sess:win" --key Escape - rc_new=$? - expect_code "$rc_old" "$rc_new" "fm-send --key: old vs new exit code" - assert_contains "$(cat "$log_new")" $'\x1f''display-message'$'\x1f''-p'$'\x1f''-t'$'\x1f''sess:win'$'\x1f''#{pane_id}' \ + # Case 1: --key path - target verified, named key sent, no typing. + run_send_case "$ROOT" "$fb" "$log" "$home" -- "sess:win" --key Escape + rc=$? + expect_code 0 "$rc" "fm-send --key should succeed against a live fake pane" + assert_contains "$(cat "$log")" $'\x1f''display-message'$'\x1f''-p'$'\x1f''-t'$'\x1f''sess:win'$'\x1f''#{pane_id}' \ "fm-send --key did not verify the explicit tmux target before sending" - strip_send_preflight "$log_old" > "$filtered_old" - strip_send_preflight "$log_new" > "$filtered_new" - diff -u "$filtered_old" "$filtered_new" > "$TMP_ROOT/send-diff-key.txt" 2>&1 \ - || fail "fm-send --key: tmux command log differs old vs new"$'\n'"$(cat "$TMP_ROOT/send-diff-key.txt")" - assert_contains "$(cat "$log_new")" $'\x1f''Escape' "fm-send --key did not send the named key" - - # Case 2: plain text (0.3s settle, no popup). - run_send_case "$old_bin" "$fb" "$log_old" "$home" -- "sess:win" hello captain - rc_old=$? - run_send_case "$ROOT" "$fb" "$log_new" "$home" -- "sess:win" hello captain - rc_new=$? - expect_code "$rc_old" "$rc_new" "fm-send plain text: old vs new exit code" - strip_send_preflight "$log_old" > "$filtered_old" - strip_send_preflight "$log_new" > "$filtered_new" - diff -u "$filtered_old" "$filtered_new" > "$TMP_ROOT/send-diff-plain.txt" 2>&1 \ - || fail "fm-send plain text: tmux command log differs old vs new"$'\n'"$(cat "$TMP_ROOT/send-diff-plain.txt")" - assert_contains "$(cat "$log_new")" $'\x1f''send-keys'$'\x1f''-t'$'\x1f''sess:win'$'\x1f''-l'$'\x1f''hello captain' \ - "fm-send did not send the literal text with send-keys -l" - assert_contains "$(cat "$log_new")" $'\x1f''Enter' "fm-send did not submit with Enter" + assert_contains "$(cat "$log")" $'\x1f''Escape' "fm-send --key did not send the named key" + assert_not_contains "$(cat "$log")" $'\x1f''-l'$'\x1f' "fm-send --key must not type literal text" - # Case 3: a slash command still opens the popup-settle path (verified - # elsewhere in tests/fm-send-popup-settle.test.sh) and still ends in the - # same tmux command shape: send-keys -l, then a retried Enter. - run_send_case "$old_bin" "$fb" "$log_old" "$home" -- "sess:win" /some-skill - rc_old=$? - run_send_case "$ROOT" "$fb" "$log_new" "$home" -- "sess:win" /some-skill - rc_new=$? - expect_code "$rc_old" "$rc_new" "fm-send /skill: old vs new exit code" - strip_send_preflight "$log_old" > "$filtered_old" - strip_send_preflight "$log_new" > "$filtered_new" - diff -u "$filtered_old" "$filtered_new" > "$TMP_ROOT/send-diff-slash.txt" 2>&1 \ - || fail "fm-send /skill: tmux command log differs old vs new"$'\n'"$(cat "$TMP_ROOT/send-diff-slash.txt")" + # Case 2: plain text - typed literally exactly once, submitted with Enter, + # confirmed against the bordered-empty fake composer. + run_send_case "$ROOT" "$fb" "$log" "$home" -- "sess:win" hello captain + rc=$? + expect_code 0 "$rc" "fm-send plain text should confirm against the empty fake composer" + assert_contains "$(cat "$log")" $'\x1f''send-keys'$'\x1f''-t'$'\x1f''sess:win'$'\x1f''-l'$'\x1f''hello captain' \ + "fm-send did not send the literal text with send-keys -l" + [ "$(grep -c $'\x1f''-l'$'\x1f' "$log")" -eq 1 ] \ + || fail "fm-send must type the text exactly once (Enter-only retries, never a retype)" + assert_contains "$(cat "$log")" $'\x1f''Enter' "fm-send did not submit with Enter" + + # Case 3: a slash command still opens the popup-settle path (verified in + # tests/fm-send-popup-settle.test.sh) and ends in the same command shape: + # one literal type, then Enter. + run_send_case "$ROOT" "$fb" "$log" "$home" -- "sess:win" /some-skill + rc=$? + expect_code 0 "$rc" "fm-send /skill should confirm against the empty fake composer" + assert_contains "$(cat "$log")" $'\x1f''send-keys'$'\x1f''-t'$'\x1f''sess:win'$'\x1f''-l'$'\x1f''/some-skill' \ + "fm-send /skill did not type the literal slash command" + [ "$(grep -c $'\x1f''-l'$'\x1f' "$log")" -eq 1 ] \ + || fail "fm-send /skill must type the text exactly once" - pass "fm-send.sh: explicit tmux targets are verified, while --key/plain/slash send command shape stays old-compatible" + pass "fm-send.sh: explicit tmux targets are verified; text types once and submits with Enter" } # --- old vs new: fm-peek.sh -------------------------------------------------- @@ -1135,7 +1130,7 @@ test_backend_validate_spawn_accepts_orca test_meta_get_and_backend_of_meta test_resolve_selector_three_forms test_backend_of_selector_matches_explicit_target_meta -test_send_conformance_old_vs_new +test_send_tmux_contract test_peek_conformance_old_vs_new test_spawn_symlinked_project_prefix_avoids_false_refusal test_teardown_conformance_old_vs_new diff --git a/tests/fm-composer-ghost.test.sh b/tests/fm-composer-ghost.test.sh index 0bbed9e968d..ab3362b6b01 100755 --- a/tests/fm-composer-ghost.test.sh +++ b/tests/fm-composer-ghost.test.sh @@ -362,7 +362,15 @@ test_bordered_busy_signatures_are_pending() { pass "fm_tmux_composer_state: typed Pi and Grok busy signatures inside a box are pending" } -test_non_bordered_busy_footer_remains_empty() { +test_non_bordered_busy_footer_is_unknown_strict() { + # STRICT divergence (captain decision blank-row-injection-posture): a bare + # busy-footer row under the cursor is not a composer container, so it no + # longer reads `empty` the way the old allow-busy compatibility fallback + # did. Its one load-bearing consumer - submit confirmation on a harness + # whose mid-turn screen hides the composer (pi) - moved to the submit + # core's baseline-idle turn-started conversion (fm_tmux_submit_core), which + # requires an idle-to-busy transition across our own Enter instead of + # trusting any busy-looking row. local dir fb capture out dir="$TMP_ROOT/non-bordered-busy"; mkdir -p "$dir" fb=$(make_fake_tmux "$dir") @@ -370,9 +378,9 @@ test_non_bordered_busy_footer_remains_empty() { printf 'Working...\n' > "$capture" out=$(PATH="$fb:$PATH" FM_FAKE_STYLED="$capture" FM_FAKE_CY=0 \ fm_tmux_composer_state "fakepane") - [ "$out" = empty ] \ - || fail "a non-bordered busy footer should remain empty, got '$out'" - pass "fm_tmux_composer_state: non-bordered busy footers retain compatibility behavior" + [ "$out" = unknown ] \ + || fail "a non-bordered busy footer must read unknown under the strict rule, got '$out'" + pass "fm_tmux_composer_state: a bare busy-footer row reads unknown (strict container-proof rule)" } test_clipped_bordered_box_is_unknown() { @@ -535,7 +543,14 @@ test_unrecognized_state_defers_input_guard() { pass "fm_pane_input_pending: unrecognized states defer by default" } -test_fallback_capture_race_with_edge_is_unknown() { +test_single_capture_leaves_no_fallback_race() { + # The old reader captured twice (a full-pane scan, then a separate + # cursor-row band capture), so a pane redraw between the two could hand the + # verdict a row the scan never saw. The consolidated reader classifies ONE + # capture (bin/fm-composer-lib.sh, fm_composer_classify_screen), so the + # race is structurally gone: a divergent band-capture row (served via + # FM_FAKE_ROW, which only a band capture would read) must have no effect on + # the verdict. local dir fb capture row_capture out dir="$TMP_ROOT/fallback-race"; mkdir -p "$dir" fb=$(make_fake_tmux "$dir") @@ -545,9 +560,9 @@ test_fallback_capture_race_with_edge_is_unknown() { printf '│ > │\n' > "$row_capture" out=$(PATH="$fb:$PATH" FM_FAKE_STYLED="$capture" FM_FAKE_ROW="$row_capture" FM_FAKE_CY=0 \ fm_tmux_composer_state "fakepane") - [ "$out" = unknown ] \ - || fail "an edge appearing between full-pane and fallback captures should be unknown, got '$out'" - pass "fm_tmux_composer_state: fallback capture races cannot admit unbounded edges" + [ "$out" = pending ] \ + || fail "the verdict must come from the one full capture (agent glyph + typed text = pending), got '$out'" + pass "fm_tmux_composer_state: one capture feeds the classifier; no band-capture race remains" } test_legitimate_empty_routes_remain_empty() { @@ -555,12 +570,14 @@ test_legitimate_empty_routes_remain_empty() { dir="$TMP_ROOT/legitimate-empty"; mkdir -p "$dir" fb=$(make_fake_tmux "$dir") capture="$dir/styled.txt" - for fixture in bordered double-bordered agent-prompt blank; do + # A blank pane is deliberately absent here: under the strict container-proof + # rule (captain decision blank-row-injection-posture) a blank cursor row is + # unknown, pinned by tests/fm-daemon.test.sh and tests/fm-composer-lib.test.sh. + for fixture in bordered double-bordered agent-prompt; do case "$fixture" in bordered) printf '╭────╮\n│ │\n╰────╯\n' > "$capture"; cursor=1 ;; double-bordered) printf '╔════╗\n║ ║\n╚════╝\n' > "$capture"; cursor=1 ;; agent-prompt) printf '›\n' > "$capture"; cursor=0 ;; - blank) printf '\n' > "$capture"; cursor=0 ;; esac out=$(PATH="$fb:$PATH" FM_FAKE_STYLED="$capture" FM_FAKE_CY="$cursor" \ fm_tmux_composer_state "fakepane") @@ -640,7 +657,7 @@ test_two_row_composer_reads_text_above_empty_cursor_row test_wrapped_composer_reads_all_content_rows test_bottom_border_cursor_reads_ghost_only_box_as_empty test_bordered_busy_signatures_are_pending -test_non_bordered_busy_footer_remains_empty +test_non_bordered_busy_footer_is_unknown_strict test_clipped_bordered_box_is_unknown test_asymmetric_composer_edges_are_unknown test_mismatched_box_families_are_unknown @@ -650,7 +667,7 @@ test_differing_widths_use_asymmetric_verdicts test_wide_composer_text_is_pending test_all_tmux_harness_composers_share_classification test_unrecognized_state_defers_input_guard -test_fallback_capture_race_with_edge_is_unknown +test_single_capture_leaves_no_fallback_race test_legitimate_empty_routes_remain_empty test_non_bordered_composer_uses_compatibility_fallback test_non_bordered_interior_edges_are_pending diff --git a/tests/fm-composer-lib.test.sh b/tests/fm-composer-lib.test.sh index db685f29f53..596b4780861 100755 --- a/tests/fm-composer-lib.test.sh +++ b/tests/fm-composer-lib.test.sh @@ -133,6 +133,237 @@ test_real_text_is_pending() { pass "fm_composer_classify_content: real unsubmitted text reads pending (including a popup argument-hint fill)" } +# ============================================================================= +# fm_composer_classify_screen: the adapter-facing screen classifier and the +# correctness matrix (audit data/fm-composer-consolidation-audit-s1, task +# fm-composer-thin-adapter-refactor-r1). +# +# Fixtures are the audit's byte-level captures of six REAL idle harnesses: +# claude 2.1.226 (bare `❯` + U+00A0 NO-BREAK SPACE), codex 0.146.0 (bold `›` +# + SGR-2 dim hint), muse (truecolor `⟩`, 38;2;90;160;255), pi (blank row +# between solid `─` rules), opencode 1.14.46 (left-bar `┃` rows), and grok +# 1.0.0 (bordered box with a TITLED bottom border), plus claude captured +# inside zellij through `dump-screen --ansi` (`ESC[m` `❯` U+00A0). +# +# Capability profiles mirror the real adapters' descriptors: tmux +# (styled+cursor+identity), herdr/zellij (styled), cmux/orca (plain). Every +# emptiness verdict is asserted under the ambient UTF-8 locale AND LC_ALL=C, +# pinning the locale-safe Unicode-space normalization (issue #1988). +# ============================================================================= + +ESC=$(printf '\033') +NBSP=$(printf '\302\240') +CAPS_TMUX=$'styled=1\ncursor=1\nidentity=1\nrows=0' +CAPS_STYLED=$'styled=1\ncursor=0\nidentity=1\nrows=20' # herdr +CAPS_STYLED_NOID=$'styled=1\ncursor=0\nidentity=0\nrows=20' # zellij +CAPS_PLAIN=$'styled=0\ncursor=0\nidentity=0\nrows=20' # cmux, orca + +# assert_screen