From 77e36fd709e617ba51d72e78e64188f8c4a85b34 Mon Sep 17 00:00:00 2001 From: Christopher McKay Date: Sat, 22 Aug 2026 18:20:24 -0400 Subject: [PATCH] fix(composer): stop a blocked pi pane from proving an empty composer A pi worker parked on an interactive prompt - a permission dialog, a question menu, a trust dialog - reports agent_status=blocked, because it is waiting on a human keystroke. Pi draws that menu above its separator pair, so the composer region between the rules is blank and structure alone looks like a free composer. _fm_composer_pi_verdict admitted blocked alongside idle and done, so the shared classifier reported an affirmatively empty composer for exactly the pane where typing is unsafe. Every "is it safe to type here?" consumer reads that verdict and proceeds only on an affirmative empty, so both are told yes on a parked prompt: the away-mode injection guard in bin/fm-supervise-daemon.sh, and fm-send's pre-type refusal. The keys then answer the menu instead of composing a message - the highlighted default is selected, the text is discarded, and the record attributes a decision to a human who never made it. blocked now defers to unknown, which every consumer already treats as fail-closed. idle and done still prove an empty composer, so ordinary steering is unchanged, and Cursor is unaffected because its always-blocked panes never reach this pi-only branch. Regression coverage lands first at both levels: the verdict owner (a blocked pi defers) and the herdr adapter (a parked pi prompt is not an empty composer). --- bin/fm-composer-lib.sh | 10 +++++++--- docs/herdr-backend.md | 3 ++- tests/fm-backend-herdr.test.sh | 23 +++++++++++++++++++++++ tests/fm-composer-lib.test.sh | 9 +++++++-- 4 files changed, 39 insertions(+), 6 deletions(-) diff --git a/bin/fm-composer-lib.sh b/bin/fm-composer-lib.sh index cb03c21d4ce..07b3b02fffb 100644 --- a/bin/fm-composer-lib.sh +++ b/bin/fm-composer-lib.sh @@ -63,7 +63,7 @@ # 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 +# identity reporting an idle/done 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. @@ -1379,7 +1379,11 @@ _fm_composer_classify_bare_pi_overlap() { # local screen=$1 styled=$2 has_identity=$3 identity=$4 agent agent_status state if [ "$has_identity" != 1 ]; then @@ -1406,7 +1410,7 @@ _fm_composer_pi_verdict() { # return 0 fi case "$agent_status" in - idle|done|blocked) printf 'empty' ;; + idle|done) printf 'empty' ;; *) printf 'unknown' ;; esac } diff --git a/docs/herdr-backend.md b/docs/herdr-backend.md index 029726f8bef..a8ee9556774 100644 --- a/docs/herdr-backend.md +++ b/docs/herdr-backend.md @@ -238,7 +238,8 @@ 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 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. +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 or done. +A blocked Pi is parked on an interactive prompt, so its blank composer region is a menu's and not a free composer's; that state defers instead of proving emptiness. 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. diff --git a/tests/fm-backend-herdr.test.sh b/tests/fm-backend-herdr.test.sh index dc1be58f9c5..23be2bd1a7d 100755 --- a/tests/fm-backend-herdr.test.sh +++ b/tests/fm-backend-herdr.test.sh @@ -3083,6 +3083,28 @@ test_composer_state_pi_separator_idle_is_empty() { pass "fm_backend_herdr_composer_state: a native idle Pi separator composer reads empty" } +# A pi worker parked on an interactive prompt (permission dialog, question +# menu, trust dialog) reports agent_status=blocked: it is waiting on a human +# keystroke. The menu is drawn ABOVE the separator pair, so the composer region +# itself is blank and structure alone looks like a free composer. Typing there +# does not compose a message - the menu consumes the keys and Enter selects the +# highlighted default, so the text is discarded and a decision nobody made is +# recorded (issue #2797). Every "is it safe to type here?" consumer reads this +# verdict: the away-mode injection guard (bin/fm-supervise-daemon.sh) and +# fm-send's pre-type refusal both proceed ONLY on an affirmative `empty`. +test_composer_state_pi_parked_prompt_is_not_empty() { + local dir log resp fb out + dir="$TMP_ROOT/composer-pi-parked-prompt"; mkdir -p "$dir/responses"; log="$dir/log"; resp="$dir/responses"; : > "$log" + printf 'Should I keep going?\n 1. Yes, continue\n\x1b[7m 2. Stop, do not act\x1b[0m\n\x1b[0m\x1b[38;2;129;162;190m─────────────────────────────────────────────────────\x1b[0m\n\x1b[0m\x1b[7m \x1b[0m \n\x1b[0m\x1b[38;2;129;162;190m─────────────────────────────────────────────────────\x1b[0m\n' > "$resp/1.out" + printf '{"result":{"agent":{"agent":"pi","agent_status":"blocked"}}}\n' > "$resp/2.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 lab:w1:p2' "$ROOT" ) + [ "$out" != empty ] \ + || fail "a pi pane parked on a prompt must not report an affirmatively empty composer, got '$out'" + pass "fm_backend_herdr_composer_state: a blocked pi pane parked on a prompt is not an empty composer" +} + test_composer_state_pi_separator_real_text_is_pending() { local dir log resp fb out dir="$TMP_ROOT/composer-pi-separated-pending"; mkdir -p "$dir/responses"; log="$dir/log"; resp="$dir/responses"; : > "$log" @@ -4520,6 +4542,7 @@ test_composer_state_real_text_is_pending test_composer_state_popup_placeholder_fill_is_pending test_composer_state_unknown_on_capture_failure test_composer_state_unknown_when_no_composer_row_found +test_composer_state_pi_parked_prompt_is_not_empty test_composer_state_pi_separator_idle_is_empty test_composer_state_pi_separator_real_text_is_pending test_composer_state_pi_incomplete_separator_below_stale_generic_is_unknown diff --git a/tests/fm-composer-lib.test.sh b/tests/fm-composer-lib.test.sh index e99c55ceb43..2d61ffda865 100755 --- a/tests/fm-composer-lib.test.sh +++ b/tests/fm-composer-lib.test.sh @@ -291,11 +291,12 @@ test_matrix_herdr_halfblock_rule_bounds_bare_wrap() { test_matrix_pi_separated_needs_identity() { # Real idle pi: a blank row between two solid rules. The blank row alone is # exactly what the strict rule refuses; only structure PLUS a live - # idle/done/blocked pi identity proves the composer (herdr's rule, now + # idle/done pi identity proves the composer (herdr's rule, now # fleet-wide; tmux supplies identity from its foreground-process probe). - local screen typed pi_idle pi_working none + local screen typed pi_idle pi_working pi_blocked none screen=$'transcript\n────────────────────────\n\n────────────────────────\n footer' pi_idle=$(printf 'pi\tidle'); pi_working=$(printf 'pi\tworking'); none=$(printf 'zsh\t') + pi_blocked=$(printf 'pi\tblocked') assert_screen "pi idle with identity" empty "$CAPS_STYLED" "$screen" '' "$pi_idle" assert_screen "pi idle on tmux with identity" empty "$CAPS_TMUX" "$screen" 2 "$pi_idle" assert_screen "pi idle on zellij" unknown "$CAPS_STYLED_NOID" "$screen" @@ -306,6 +307,10 @@ test_matrix_pi_separated_needs_identity() { assert_screen "pi pair without identity capability" unknown "$CAPS_PLAIN" "$screen" # A working pi cannot authorize injection into the blank region. assert_screen "working pi defers" unknown "$CAPS_STYLED" "$screen" '' "$pi_working" + # A pi parked on an interactive prompt reports `blocked`: it is waiting on a + # human keystroke, so the blank region is a menu's, not a free composer's. + # Typing there answers the prompt and the text is discarded (issue #2797). + assert_screen "blocked pi defers" unknown "$CAPS_STYLED" "$screen" '' "$pi_blocked" # The audit's live counterexample: a plain shell running sleep, cursor # parked on a blank line between two rules, NO pi process. The permissive # rule read this `empty`; identity+structure refuses it.