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
9 changes: 9 additions & 0 deletions bin/backends/herdr.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3487,7 +3487,12 @@ fm_backend_herdr_send_text_submit() { # <target> <text> <retries> <enter-sleep>
esac
# Native stayed idle. Composer empty is positive delivery (a landed
# Claude turn that never flipped agent_status). Proven pending retries.
# A picker that classifies pending must not receive that retry.
verdict=$(fm_backend_herdr_composer_state "$target")
if fm_composer_blocking_dialog_noted >/dev/null; then
printf 'unknown'
return 0
fi
case "$verdict" in
empty) printf 'empty'; return 0 ;;
pending|pending-unproven) ;;
Expand All @@ -3496,6 +3501,10 @@ fm_backend_herdr_send_text_submit() { # <target> <text> <retries> <enter-sleep>
else
sleep "$sleep_s"
verdict=$(fm_backend_herdr_composer_state "$target")
if fm_composer_blocking_dialog_noted >/dev/null; then
printf 'unknown'
return 0
fi
if [ "$verdict" = pending ] && [ "$raw_status" != working ] \
&& [ "$footer_baseline" = idle ] \
&& [ "$(fm_backend_herdr_rendered_busy_state "$target")" = busy ]; then
Expand Down
39 changes: 32 additions & 7 deletions bin/fm-backend.sh
Original file line number Diff line number Diff line change
Expand Up @@ -811,18 +811,43 @@ fm_backend_send_key() { # <backend> <target> <key> [expected-label]
# fm_backend_send_text_submit: type text once, then submit and verify,
# retrying only the submission (never retyping). Echoes the backend's
# proof-carrying verdict; callers require exact empty for confirmed delivery.
# A pane that already shows the recognised dialog is refused before any
# adapter types, so that submit neither types the text nor sends Enter.
fm_backend_send_text_submit() { # <backend> <target> <text> <retries> <enter-sleep> <settle> [expected-label]
local backend=$1
local backend=$1 rc=0 target label dialog
shift
target=$1
label=${6:-}
fm_backend_source "$backend" || return 1
# Every Enter loop below reads the dialog sink, so it must exist before
# any adapter types: a sink that fails here leaves the composer untouched.
fm_composer_dialog_sink_prepare || {
echo "error: the dialog check for a $backend submit could not be recorded" >&2
return 1
}
# One composer read after the sink exists and before the adapter types.
# The classify writes the sink; a named dialog means the next Enter would
# answer it.
if [ -n "$label" ]; then
fm_backend_composer_state "$backend" "$target" "$label" >/dev/null || true
else
fm_backend_composer_state "$backend" "$target" >/dev/null || true
fi
if dialog=$(fm_composer_blocking_dialog_noted); then
fm_composer_dialog_sink_release
echo "error: blocked on a prompt: $dialog" >&2
return 1
fi
case "$backend" in
tmux) fm_backend_tmux_send_text_submit "$@" ;;
herdr) fm_backend_herdr_send_text_submit "$@" ;;
zellij) fm_backend_zellij_send_text_submit "$@" ;;
orca) fm_backend_orca_send_text_submit "$@" ;;
cmux) fm_backend_cmux_send_text_submit "$@" ;;
*) echo "error: no send-text implementation for backend '$backend'" >&2; return 1 ;;
tmux) fm_backend_tmux_send_text_submit "$@" || rc=$? ;;
herdr) fm_backend_herdr_send_text_submit "$@" || rc=$? ;;
zellij) fm_backend_zellij_send_text_submit "$@" || rc=$? ;;
orca) fm_backend_orca_send_text_submit "$@" || rc=$? ;;
cmux) fm_backend_cmux_send_text_submit "$@" || rc=$? ;;
*) echo "error: no send-text implementation for backend '$backend'" >&2; rc=1 ;;
esac
fm_composer_dialog_sink_release
return "$rc"
}

# fm_backend_kill: remove the task's session endpoint. An already-gone target
Expand Down
76 changes: 76 additions & 0 deletions bin/fm-composer-lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -1671,9 +1671,80 @@ EOF
printf '%s\n' "$joined" | LC_ALL=C awk '{$1=$1; printf "%s", $0}'
}

# fm_composer_blocking_dialog: name a screen whose next Enter would answer it.
# Prints the name and returns 0 only for the recorded structure of one dialog:
# the heading on its own line, then its selected row alone on a row, with the
# recorded footer as the last non-blank row. A heading buried in a sentence,
# or a last line that only starts with the same words, is not that dialog.
# The strings alone are not enough, because a diff, a note, or a test fixture
# on the pane can quote all of them above a normal composer. A miss returns 1
# and prints nothing.
# Recorded 2026-10-05 on Claude Code 2.1.289: /exit while a background shell
# is still running opens this picker, and its selected row is Exit and stop tasks.
fm_composer_blocking_dialog() { # <screen> -> dialog name
local screen=${1-}
[ -n "$screen" ] || return 1
if printf '%s\n' "$screen" | fm_composer_strip_ansi | LC_ALL=C awk '
/^[ \t]*Background work is running[ \t\r]*$/ { heading = 1 }
heading && /^[ \t]*❯ 1\. Exit and stop tasks[ \t\r]*$/ { selected = 1 }
/[^ \t\r]/ { last = $0 }
END { exit !(selected && last ~ /^[ \t]*Enter to confirm · Esc to cancel[ \t\r]*$/) }
'; then
printf '%s' 'Claude background-task exit picker'
return 0
fi
return 1
}

# A command substitution drops a shell variable, and every composer read runs
# inside one. The name is therefore written to FM_COMPOSER_DIALOG_SINK when
# that path is set. The classifier verdict is unchanged. When the sink is
# unset the name would be discarded, so the match is skipped.
fm_composer_note_blocking_dialog() { # <screen>
local name=
[ -n "${FM_COMPOSER_DIALOG_SINK:-}" ] || return 1
if name=$(fm_composer_blocking_dialog "$1"); then
printf '%s' "$name" > "$FM_COMPOSER_DIALOG_SINK" || return 1
return 0
fi
: > "$FM_COMPOSER_DIALOG_SINK" || return 1
return 1
}

# fm_composer_blocking_dialog_noted: print the name the latest classify wrote
# to the sink. Returns 1 when the sink is unset or empty.
fm_composer_blocking_dialog_noted() {
[ -n "${FM_COMPOSER_DIALOG_SINK:-}" ] || return 1
[ -s "$FM_COMPOSER_DIALOG_SINK" ] || return 1
cat "$FM_COMPOSER_DIALOG_SINK"
}

# Empty the sink, creating it when the caller has not. Sets
# FM_COMPOSER_DIALOG_OWNED=1 only for a sink this call created, so a caller
# that shares the path can still read the name after the release.
fm_composer_dialog_sink_prepare() {
FM_COMPOSER_DIALOG_OWNED=0
if [ -z "${FM_COMPOSER_DIALOG_SINK:-}" ]; then
FM_COMPOSER_DIALOG_SINK=$(mktemp "${TMPDIR:-/tmp}/fm-composer-dialog.XXXXXX") || return 1
FM_COMPOSER_DIALOG_OWNED=1
return 0
fi
: > "$FM_COMPOSER_DIALOG_SINK"
}

fm_composer_dialog_sink_release() {
if [ "${FM_COMPOSER_DIALOG_OWNED:-}" = 1 ]; then
rm -f "$FM_COMPOSER_DIALOG_SINK"
FM_COMPOSER_DIALOG_SINK=
FM_COMPOSER_DIALOG_OWNED=0
fi
}

fm_composer_classify_screen() { # <caps> <screen> [cursor_row] [identity]
local caps=$1 screen=$2 cy=${3:-} identity=${4:-}
local styled=0 cursor=0 has_identity=0 kv plain
# Note the dialog before any early return so a pending picker is still named.
fm_composer_note_blocking_dialog "$screen" || true
while IFS= read -r kv; do
case "$kv" in
styled=1) styled=1 ;;
Expand Down Expand Up @@ -1795,6 +1866,11 @@ fm_composer_submit_retry_core() { # <send-key-fn> <state-fn> <target> <retries>
"$send_key_fn" "$target" Enter "$expected_label" || true
sleep "$sleep_s"
state=$("$state_fn" "$target" "$expected_label")
# The first Enter can open a picker. A later Enter would confirm it.
if fm_composer_blocking_dialog_noted >/dev/null; then
printf 'unknown'
return 0
fi
case "$state" in
pending|pending-unproven) ;;
*) printf '%s' "$state"; return 0 ;;
Expand Down
44 changes: 43 additions & 1 deletion bin/fm-control.sh
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,11 @@ control_cleanup() {
&& declare -F relaunch_rollback >/dev/null 2>&1; then
relaunch_rollback || true
fi
# Remove the dialog file while the lock is still held: once it is released,
# the next lifecycle command for this task writes the same path.
if [ -n "${FM_COMPOSER_DIALOG_SINK:-}" ]; then
rm -f "$FM_COMPOSER_DIALOG_SINK"
fi
if [ "$CONTROL_LOCK_HELD" = 1 ]; then
CONTROL_LOCK_HELD=0
fm_lock_release "$CONTROL_LOCK" || true
Expand Down Expand Up @@ -313,6 +318,11 @@ trap control_cleanup EXIT
fm_lock_try_acquire "$CONTROL_LOCK" \
|| die "another lifecycle action is already running for task $ID"
CONTROL_LOCK_HELD=1
# do_exit runs in a command substitution. That subshell does not run this
# EXIT trap, so the parent has to hold the path the trap removes. Set it
# only once the lock is held: a process that loses the lock runs the same
# trap, and would remove the file the lock holder is reading.
FM_COMPOSER_DIALOG_SINK=$STATE/$ID.composer-dialog
Comment thread
greptile-apps[bot] marked this conversation as resolved.
META="$STATE/$ID.meta"
if [ ! -f "$META" ]; then
case "$RAW_ID" in
Expand Down Expand Up @@ -390,6 +400,13 @@ require_state_verified_backend() { # <verb>
die "task $ID runs on the $BACKEND backend, which has no recovery-grade agent-state classifier, so '$1' cannot prove the agent actually stopped; refusing rather than reporting an unproven transition as done"
}

# refuse_blocking_prompt: the screen is a dialog a confirming Enter would
# answer. Name it and stop. Do not type Escape or an option: both dismiss
# or choose.
refuse_blocking_prompt() { # <dialog-name>
die "task $ID is blocked on a prompt: $1. Refusing to type Enter into it."
}

# rendered_matches <ere>: whether any row of the visible viewport matches.
# An unreadable viewport is a no, so every caller treats it as missing proof.
rendered_matches() { # <ere>
Expand Down Expand Up @@ -557,7 +574,7 @@ retire_busy_incarnation() {
# do_exit: stop the running agent, preserving endpoint and worktree. Prints
# `already-stopped`, `endpoint-gone`, or `stopped`.
do_exit() {
local state cmd hazard verdict composer_state cancel absence interrupt_result=not-needed
local state cmd hazard verdict composer_state cancel absence interrupt_result=not-needed dialog
require_state_verified_backend exit
state=$(agent_state)
case "$state" in
Expand Down Expand Up @@ -624,8 +641,16 @@ do_exit() {
if [ -n "$hazard" ] && rendered_matches "$hazard"; then
die "task $ID shows the $HARNESS revert picker, where typed text becomes a search and Enter reverts file changes; refusing to type the $cmd exit command. Close it with $(fm_control_interrupt_key "$HARNESS"), never Enter, then retry '$VERB'"
fi
: > "$FM_COMPOSER_DIALOG_SINK" \
|| die "task $ID's dialog check could not be recorded"
composer_state=$(fm_backend_composer_state "$BACKEND" "$T" "$LABEL" 2>/dev/null) \
|| composer_state=unknown
# The classify that filled the sink ran in a subshell, so read the file
# rather than a function that subshell sourced.
if [ -s "${FM_COMPOSER_DIALOG_SINK:-}" ]; then
dialog=$(cat "$FM_COMPOSER_DIALOG_SINK")
refuse_blocking_prompt "$dialog"
fi
case "$composer_state" in
empty) ;;
pending)
Expand All @@ -645,7 +670,24 @@ do_exit() {
|| die "the exit command could not be sent to task $ID on $BACKEND"
[ "$verdict" != send-failed ] \
|| die "the exit command could not be sent to task $ID on $BACKEND"
# The submitting Enter can open the picker. The agent is still alive, and
# another Enter would confirm the selected row. A dead agent may leave the
# same text behind; that is not a prompt still waiting.
if [ -s "${FM_COMPOSER_DIALOG_SINK:-}" ]; then
dialog=$(cat "$FM_COMPOSER_DIALOG_SINK")
if [ "$(agent_state)" != dead ]; then
refuse_blocking_prompt "$dialog"
fi
fi
state=$(wait_agent_state "$EXIT_WAIT" dead) || {
# A submit can return before any read sees the picker: a native busy
# verdict needs no composer read, and a cleared composer can be read
# before the picker renders. Read the screen once more here.
: > "$FM_COMPOSER_DIALOG_SINK" || true
fm_backend_composer_state "$BACKEND" "$T" "$LABEL" >/dev/null 2>&1 || true
if [ -s "$FM_COMPOSER_DIALOG_SINK" ]; then
refuse_blocking_prompt "$(cat "$FM_COMPOSER_DIALOG_SINK")"
fi
die "exit-delivered $ID interrupt=$interrupt_result exit-command=delivered agent-state=$state exit=unconfirmed; the agent did not stop within ${EXIT_WAIT}s"
}
# The incarnation is over: retire its busy wiring so no stale record or
Expand Down
5 changes: 5 additions & 0 deletions bin/fm-tmux-lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,11 @@ fm_tmux_submit_enter_core() { # <target> <retries> <enter-sleep> [baseline-idle
tmux send-keys -t "$target" Enter 2>/dev/null || true
sleep "$sleep_s"
state=$(fm_tmux_composer_state "$target")
# The first Enter can open a picker. A later Enter would confirm it.
if fm_composer_blocking_dialog_noted >/dev/null; then
printf 'unknown'
return 0
fi
case "$state" in
pending|pending-unproven) ;;
unknown)
Expand Down
3 changes: 3 additions & 0 deletions docs/agent-control.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ muse is the one verified adapter that restores the cancelled prompt back into it
The clear is refused before anything is sent when the recorded backend cannot deliver it.

`exit` reads the composer's state before typing the exit command and requires the exact `empty` verdict; a `pending` verdict refuses by naming the pending text, and any other verdict (`unknown`, `pending-unproven`, or an unreadable read) refuses as not proven empty, matching the fail-safe contract every other consumer that can overwrite composer input follows.
`exit` also refuses, naming the dialog as `blocked on a prompt`, when the screen shows a recognised dialog that a further Enter would answer, whether the dialog was open before the exit command was typed or the submitting Enter opened it; it sends no Escape and chooses no option, so closing the dialog is left to the operator.
A stopped agent whose pane still shows the dialog text is not refused.
[`fm_composer_blocking_dialog`](../bin/fm-composer-lib.sh) owns the recognised set, which today is only Claude's background-task exit picker; [its verification record](verification/runtime-backends.md#claude-background-task-exit-picker) lists the dialogs that are not covered.

**Teardown and discard are not verbs and will not become verbs.**
`exit` stops an agent and preserves everything else.
Expand Down
18 changes: 18 additions & 0 deletions docs/verification/runtime-backends.md
Original file line number Diff line number Diff line change
Expand Up @@ -1264,6 +1264,24 @@ FM_HERDR_SUBMIT_CONFIRM_LIVE=1 tests/fm-herdr-submit-confirm-live-e2e.test.sh
ok - live Herdr submit confirm: Claude Code (2.1.283 (Claude Code)) on herdr 0.9.0 proves and submits a typed /exit behind its command popup
```

### Claude background-task exit picker

Measured 2026-10-05 against Claude Code 2.1.289 in an isolated tmux session.
The Herdr lab was not running, so the Herdr path is covered by the existing fakes.
Typing `/exit` while a background shell is still running opens a picker whose selected row is "Exit and stop tasks" and whose footer is "Enter to confirm · Esc to cancel".
That screen still classifies as pending, the same verdict as unsubmitted composer text.
A second Enter would confirm the selected row.
The picker is recognised by its recorded structure only: the heading on its own line, then the selected row alone on its row, with `Enter to confirm · Esc to cancel` as the last non-blank row.
The same strings quoted above a normal composer, as a diff, this note, or a test fixture shows them, are not a picker.
Submit retries now stop after the Enter that opened the picker and report unknown.
A typed submit to a pane that already shows the picker types nothing and sends no Enter.
Exit reports that the worker is blocked on the Claude background-task exit picker and does not type another Enter.
A submit can return before any read sees the picker, so exit reads the screen once more when its wait for the agent to stop times out, and names the picker there too.
Exit does not report a stopped agent whose pane still shows the picker text as blocked on a prompt.
The watcher does not read the picker: a pane parked on it keeps the ordinary stale triage.
Comment thread
greptile-apps[bot] marked this conversation as resolved.
No recorded screen was available for a model-downgrade confirmation, an MCP approval, or a Claude exit confirmation other than this picker, so those dialogs are not covered.
Refusing an Enter that would confirm a dialog restores an existing safety path, so it is not gated behind a flag.

### Prune and respawn

The real label-collision reproduction is owned by:
Expand Down
Loading
Loading