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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions bin/fm-send.sh
Original file line number Diff line number Diff line change
Expand Up @@ -65,5 +65,9 @@ else
echo "error: text not sent to $T (tmux send-keys failed)" >&2
exit 1
;;
busy)
echo "error: text not sent to $T (target pane busy)" >&2
exit 1
;;
esac
fi
30 changes: 27 additions & 3 deletions bin/fm-spawn.sh
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}"
DATA="${FM_DATA_OVERRIDE:-$FM_HOME/data}"
PROJECTS="${FM_PROJECTS_OVERRIDE:-$FM_HOME/projects}"
SUB_HOME_MARKER=".fm-secondmate-home"
# shellcheck source=bin/fm-tmux-lib.sh
. "$SCRIPT_DIR/fm-tmux-lib.sh"
# Skip the watcher guard when re-exec'd for one pair of a batch (FM_SPAWN_NO_GUARD is
# set by the batch loop below), so the guard runs once for the batch, not once per pair.
[ -n "${FM_SPAWN_NO_GUARD:-}" ] || "$FM_ROOT/bin/fm-guard.sh" || true
Expand Down Expand Up @@ -344,6 +346,16 @@ if [ "$KIND" != secondmate ]; then
echo "error: treehouse get did not enter a worktree within 60s; inspect window $T" >&2
exit 1
fi
# cwd can flip before the new shell has drawn a prompt. Wait briefly for a
# readable idle/busy line before typing the agent launch, otherwise the launch
# text can be swallowed during the treehouse handoff while spawn still reports
# success.
for _ in $(seq 1 20); do
state=$(fm_tmux_composer_state "$T")
[ "$state" = empty ] && break
fm_pane_is_busy "$T" && break
sleep 0.2
done
fi

# Per-harness turn-end hook: a file that touches state/<id>.turn-ended when the
Expand Down Expand Up @@ -439,8 +451,20 @@ if [ "$KIND" = secondmate ]; then
sq_home=$(shell_quote "$PROJ_ABS")
LAUNCH="FM_ROOT_OVERRIDE= FM_STATE_OVERRIDE= FM_DATA_OVERRIDE= FM_PROJECTS_OVERRIDE= FM_CONFIG_OVERRIDE= FM_HOME=$sq_home $LAUNCH"
fi
tmux send-keys -t "$T" -l "$LAUNCH"
sleep 0.3
tmux send-keys -t "$T" Enter
verdict=$(fm_tmux_submit_core "$T" "$LAUNCH" 3 0.4 0.3)
case "$verdict" in
pending)
echo "error: launch command was not submitted to $T (Enter swallowed; launch text left in composer)" >&2
exit 1
;;
send-failed)
echo "error: launch command was not sent to $T (tmux send-keys failed)" >&2
exit 1
;;
busy)
echo "error: launch command was not sent to $T (target pane busy)" >&2
exit 1
;;
esac

echo "spawned $ID harness=$HARNESS kind=$KIND mode=$MODE yolo=$YOLO window=$T worktree=$WT"
11 changes: 8 additions & 3 deletions bin/fm-tmux-lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -136,9 +136,11 @@ fm_tmux_composer_state() { # <target> -> empty|pending|unknown
&& printf '%s' "$stripped" | grep -qiE "$FM_COMPOSER_IDLE_RE"; then
printf 'empty'; return 0
fi
# Just a bare prompt glyph = empty composer (idle).
# Just a bare prompt glyph = empty composer (idle). Codex uses `›` (U+203A);
# missing it caused post-submit idle panes to look pending and report a false
# "Enter swallowed" after the dim suggestion text was stripped.
case "$stripped" in
'>'|'❯'|'$'|'%'|'#') printf 'empty'; return 0 ;;
'>'|'❯'|'›'|'$'|'%'|'#') printf 'empty'; return 0 ;;
esac
# A busy footer landing on the cursor line is not pending input.
if printf '%s' "$stripped" | grep -qiE "${FM_BUSY_REGEX:-$FM_TMUX_BUSY_REGEX_DEFAULT}"; then
Expand Down Expand Up @@ -166,8 +168,10 @@ fm_pane_is_busy() { # <target>
# fm_tmux_submit_core: type <text> into <target> ONCE, then submit with Enter,
# verifying the composer cleared. Retries Enter ONLY — never retypes, because a
# swallowed Enter leaves our text in the composer and retyping would duplicate
# it. Echoes the final verdict on stdout (empty|pending|unknown|send-failed) so callers can
# it. Echoes the final verdict on stdout (empty|pending|unknown|busy|send-failed) so callers can
# pick their own success policy:
# - busy means nothing was typed; steering a pane mid-turn queues text into the
# next tool-call composer and creates a false swallowed-Enter diagnosis.
# - the daemon clears its buffer only on "empty" (strict: an unknown pane must
# not be mistaken for a delivered escalation).
# - fm-send fails only on "pending" (lenient: a positively-confirmed swallow),
Expand All @@ -186,6 +190,7 @@ fm_tmux_submit_enter_core() { # <target> <retries> <enter-sleep>

fm_tmux_submit_core() { # <target> <text> <retries> <enter-sleep> <settle>
local target=$1 text=$2 retries=$3 sleep_s=$4 settle=$5
fm_pane_is_busy "$target" && { printf 'busy'; return 0; }
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"
Expand Down
17 changes: 17 additions & 0 deletions tests/fm-composer-ghost.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,22 @@ test_dim_ghost_only_composer_is_not_pending() {
pass "fm_pane_input_pending: a dim ghost-only composer is NOT pending"
}

test_codex_bold_prompt_with_dim_suggestion_is_not_pending() {
local dir fb capture
dir="$TMP_ROOT/codex-bold-ghost"; mkdir -p "$dir"
fb=$(make_fake_tmux "$dir")
capture="$dir/styled.txt"
# Codex idle composer: normal/bold prompt glyph plus a dim suggestion. The
# root cause of the false "Enter swallowed" was that ghost stripping left the
# prompt glyph `›`, but bare-prompt detection did not know that glyph.
printf '\033[1m\xe2\x80\xba\033[0m \033[2mExplain this codebase\033[0m\n' > "$capture"
if PATH="$fb:$PATH" FM_FAKE_STYLED="$capture" FM_FAKE_CY=0 \
fm_pane_input_pending "fakepane"; then
fail "codex bold prompt plus dim suggestion falsely read as pending"
fi
pass "fm_pane_input_pending: codex bold › plus dim suggestion is NOT pending"
}

test_dim_ghost_inside_bordered_composer_is_not_pending() {
local dir fb capture
dir="$TMP_ROOT/ghost-bordered"; mkdir -p "$dir"
Expand Down Expand Up @@ -220,6 +236,7 @@ test_strip_ghost_drops_dim_keeps_normal
test_strip_ghost_handles_combined_and_boundary_codes
test_strip_ghost_keeps_colored_text_with_2_payloads
test_dim_ghost_only_composer_is_not_pending
test_codex_bold_prompt_with_dim_suggestion_is_not_pending
test_dim_ghost_inside_bordered_composer_is_not_pending
test_normal_text_still_pending
test_colored_text_with_2_payload_still_pending
Expand Down
188 changes: 188 additions & 0 deletions tests/fm-spawn-send-handshake.test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
#!/usr/bin/env bash
# Regression coverage for launch/steer submission handshakes.
set -u

# shellcheck source=tests/lib.sh
. "$(dirname "${BASH_SOURCE[0]}")/lib.sh"

SPAWN="$ROOT/bin/fm-spawn.sh"
SEND="$ROOT/bin/fm-send.sh"

TMP_ROOT=$(fm_test_tmproot fm-spawn-send-handshake)

make_submit_fake_tmux() {
local dir=$1 fakebin
fakebin=$(fm_fakebin "$dir")
cat > "$fakebin/tmux" <<'SH'
#!/usr/bin/env bash
set -u
log=${FM_FAKE_TMUX_LOG:?}
composer=${FM_FAKE_COMPOSER:?}
case "${1:-}" in
display-message)
for a in "$@"; do
case "$a" in
*cursor_y*) printf '0\n'; exit 0 ;;
*pane_current_path*) printf '%s\n' "${FM_FAKE_PANE_PATH:-$PWD}"; exit 0 ;;
'#S') printf 'firstmate\n'; exit 0 ;;
esac
done
printf 'firstmate\n'; exit 0 ;;
capture-pane)
if [ "${*: -1}" = "-40" ] || [ -n "${FM_FAKE_BUSY_TAIL:-}" ]; then
printf '%s\n' "${FM_FAKE_BUSY_TAIL:-}"
exit 0
fi
cat "$composer"
exit 0 ;;
send-keys)
printf '%s\n' "$*" >> "$log"
shift
while [ $# -gt 0 ]; do
case "$1" in
-t) shift 2 ;;
-l) shift; printf '%s\n' "$1" > "$composer"; shift ;;
Enter)
if [ -n "${FM_FAKE_BUSY_AFTER_ENTER:-}" ]; then
printf 'Working...\n' > "$composer"
else
printf '\033[1m\xe2\x80\xba\033[0m \033[2mExplain this codebase\033[0m\n' > "$composer"
fi
shift ;;
*) shift ;;
esac
done
exit 0 ;;
list-windows)
[ -n "${FM_FAKE_TMUX_WINDOW:-}" ] && printf '%s\n' "$FM_FAKE_TMUX_WINDOW"
exit 0 ;;
esac
exit 1
SH
chmod +x "$fakebin/tmux"
printf '%s\n' "$fakebin"
}

make_spawn_fake_tmux() {
local dir=$1 fakebin
fakebin=$(fm_fakebin "$dir")
cat > "$fakebin/tmux" <<'SH'
#!/usr/bin/env bash
set -u
log=${FM_FAKE_TMUX_LOG:?}
composer=${FM_FAKE_COMPOSER:?}
case "${1:-}" in
has-session|new-session|new-window)
printf '%s\n' "$*" >> "$log"
exit 0 ;;
list-windows)
exit 0 ;;
display-message)
for a in "$@"; do
case "$a" in
*cursor_y*) printf '0\n'; exit 0 ;;
*pane_current_path*) printf '%s\n' "${FM_FAKE_WORKTREE:?}"; exit 0 ;;
esac
done
printf 'firstmate\n'; exit 0 ;;
capture-pane)
cat "$composer"
exit 0 ;;
send-keys)
printf '%s\n' "$*" >> "$log"
shift
while [ $# -gt 0 ]; do
case "$1" in
-t) shift 2 ;;
-l) shift; printf '%s\n' "$1" > "$composer"; touch "${FM_FAKE_LAUNCH_TYPED:-/dev/null}"; shift ;;
Enter)
if [ -n "${FM_FAKE_LAUNCH_TYPED:-}" ] && [ -e "$FM_FAKE_LAUNCH_TYPED" ] && [ -z "${FM_FAKE_KEEP_LAUNCH_PENDING:-}" ]; then
printf 'Working...\n' > "$composer"
fi
shift ;;
*) shift ;;
esac
done
exit 0 ;;
esac
exit 1
SH
cat > "$fakebin/treehouse" <<'SH'
#!/usr/bin/env bash
exit 0
SH
chmod +x "$fakebin/tmux" "$fakebin/treehouse"
printf '%s\n' "$fakebin"
}

test_fm_send_accepts_codex_idle_prompt_after_submit() {
local dir home fakebin log composer err
dir="$TMP_ROOT/send-codex-idle"; mkdir -p "$dir"
home="$dir/home"; mkdir -p "$home/state"
log="$dir/tmux.log"; composer="$dir/composer"; err="$dir/send.err"
printf '\033[1m\xe2\x80\xba\033[0m \033[2mExplain this codebase\033[0m\n' > "$composer"
fakebin=$(make_submit_fake_tmux "$dir")
PATH="$fakebin:$PATH" FM_HOME="$home" FM_FAKE_TMUX_LOG="$log" FM_FAKE_COMPOSER="$composer" \
FM_SEND_SLEEP=0.01 "$SEND" sess:win 'route this work' >/dev/null 2>"$err" \
|| fail "fm-send reported a false swallowed Enter on codex idle prompt: $(cat "$err")"
pass "fm-send accepts codex idle/ghost prompt as submitted"
}

test_fm_send_refuses_busy_pane_without_typing() {
local dir home fakebin log composer err
dir="$TMP_ROOT/send-busy"; mkdir -p "$dir"
home="$dir/home"; mkdir -p "$home/state"
log="$dir/tmux.log"; composer="$dir/composer"; err="$dir/send.err"
: > "$log"
printf '\033[1m\xe2\x80\xba\033[0m\n' > "$composer"
fakebin=$(make_submit_fake_tmux "$dir")
if PATH="$fakebin:$PATH" FM_HOME="$home" FM_FAKE_TMUX_LOG="$log" FM_FAKE_COMPOSER="$composer" \
FM_FAKE_BUSY_TAIL="esc to interrupt" FM_SEND_SLEEP=0.01 \
"$SEND" sess:win 'do not queue this while busy' >/dev/null 2>"$err"; then
fail "fm-send exited zero for a busy pane"
fi
assert_contains "$(cat "$err")" "target pane busy" "fm-send did not explain busy target refusal"
assert_not_contains "$(cat "$log")" "send-keys -t sess:win -l" "fm-send typed steering text into a busy pane"
pass "fm-send refuses a busy pane without typing into the composer"
}

test_fm_spawn_refuses_success_when_launch_text_remains_pending() {
local dir home project worktree fakebin log composer err out status
dir="$TMP_ROOT/spawn-pending"; home="$dir/home"; project="$home/projects/alpha"; worktree="$dir/worktree"
mkdir -p "$home/data/spawn-pending" "$home/state" "$project" "$worktree"
fm_git_init_commit "$worktree"
printf 'brief\n' > "$home/data/spawn-pending/brief.md"
log="$dir/tmux.log"; composer="$dir/composer"; err="$dir/spawn.err"; out="$dir/spawn.out"
: > "$composer"
fakebin=$(make_spawn_fake_tmux "$dir")
PATH="$fakebin:$PATH" FM_HOME="$home" FM_FAKE_TMUX_LOG="$log" FM_FAKE_COMPOSER="$composer" \
FM_FAKE_WORKTREE="$worktree" FM_FAKE_LAUNCH_TYPED="$dir/launch-typed" FM_FAKE_KEEP_LAUNCH_PENDING=1 \
"$SPAWN" spawn-pending projects/alpha codex >"$out" 2>"$err"
status=$?
[ "$status" -ne 0 ] || fail "fm-spawn printed success while launch text was still pending"
assert_not_contains "$(cat "$out")" "spawned spawn-pending" "fm-spawn reported spawned despite pending launch"
assert_contains "$(cat "$err")" "launch command was not submitted" "fm-spawn did not explain pending launch text"
pass "fm-spawn fails instead of reporting success when launch submission stays pending"
}

test_fm_spawn_reports_success_when_launch_submits_and_pane_busy() {
local dir home project worktree fakebin log composer err out
dir="$TMP_ROOT/spawn-busy"; home="$dir/home"; project="$home/projects/alpha"; worktree="$dir/worktree"
mkdir -p "$home/data/spawn-busy" "$home/state" "$project" "$worktree"
fm_git_init_commit "$worktree"
printf 'brief\n' > "$home/data/spawn-busy/brief.md"
log="$dir/tmux.log"; composer="$dir/composer"; err="$dir/spawn.err"; out="$dir/spawn.out"
: > "$composer"
fakebin=$(make_spawn_fake_tmux "$dir")
PATH="$fakebin:$PATH" FM_HOME="$home" FM_FAKE_TMUX_LOG="$log" FM_FAKE_COMPOSER="$composer" \
FM_FAKE_WORKTREE="$worktree" FM_FAKE_LAUNCH_TYPED="$dir/launch-typed" \
"$SPAWN" spawn-busy projects/alpha codex >"$out" 2>"$err" \
|| fail "fm-spawn failed after launch submission made pane busy: $(cat "$err")"
assert_contains "$(cat "$out")" "spawned spawn-busy" "fm-spawn did not report success after busy launch"
pass "fm-spawn reports success after launch command submits and pane is busy"
}

test_fm_send_accepts_codex_idle_prompt_after_submit
test_fm_send_refuses_busy_pane_without_typing
test_fm_spawn_refuses_success_when_launch_text_remains_pending
test_fm_spawn_reports_success_when_launch_submits_and_pane_busy