Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions bin/fm-composer-lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -1379,7 +1379,11 @@ _fm_composer_classify_bare_pi_overlap() { # <screen> <styled> <has-identity> <i
# 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. Proven input remains pending for every
# live pi state, while only an idle/done/blocked pi proves an empty composer.
# live pi state, while only an idle/done pi proves an empty composer. A blocked
# pi is parked on an interactive prompt waiting for a human keystroke: its menu
# is drawn above the separator pair, so the composer region looks free while the
# keys would answer the prompt instead of composing (issue #2797). Structure
# cannot disprove that, so a blocked pi defers rather than claiming empty.
_fm_composer_pi_verdict() { # <screen> <styled> <has_identity> <identity>
local screen=$1 styled=$2 has_identity=$3 identity=$4 agent agent_status state
if [ "$has_identity" != 1 ]; then
Expand All @@ -1406,7 +1410,7 @@ _fm_composer_pi_verdict() { # <screen> <styled> <has_identity> <identity>
return 0
fi
case "$agent_status" in
idle|done|blocked) printf 'empty' ;;
idle|done) printf 'empty' ;;
*) printf 'unknown' ;;
esac
}
3 changes: 2 additions & 1 deletion docs/herdr-backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
23 changes: 23 additions & 0 deletions tests/fm-backend-herdr.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
9 changes: 7 additions & 2 deletions tests/fm-composer-lib.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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.
Expand Down
Loading