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
54 changes: 39 additions & 15 deletions bin/backends/cmux.sh
Original file line number Diff line number Diff line change
Expand Up @@ -535,38 +535,62 @@ fm_backend_cmux_capture() { # <target> <lines> [expected-label]
# 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. The cmux classifier intentionally remains
# border-row based: locate the
# composer row as the only captured line whose TRIMMED content both STARTS and
# ENDS with the same border glyph (│, ┃, or a plain ASCII |), scanning forward
# and keeping the LAST match so an earlier border-shaped line (scrollback, a
# popup) never outranks the real bottom-anchored composer row.
# `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() { # <trimmed-line>
local remaining=$1
remaining=${remaining//─/}
remaining=${remaining//[[:space:]]/}
[ -n "$1" ] && [ -z "$remaining" ]
}

fm_backend_cmux_composer_state() { # <target> [expected-label] -> empty|pending|unknown
local target=$1 expected_label=${2:-} cap line trimmed stripped="" found=0
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
'│'*'│'|'┃'*'┃'|'|'*'|') : ;;
*) continue ;;
'│'*'│'|'┃'*'┃'|'|'*'|')
stripped=$trimmed
bordered_index=$((${#rows[@]} - 1))
;;
esac
stripped=$trimmed
found=1
done < <(printf '%s\n' "$cap")
[ "$found" -eq 1 ] || { printf 'unknown'; return 0; }
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 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.
# A bordered row is a genuine composer box.
fm_composer_classify_content 1 "$stripped" "$FM_BACKEND_CMUX_IDLE_RE"
}

Expand Down
1 change: 1 addition & 0 deletions bin/fm-test-run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@ family_for_basename() {
printf '%s\n' session-bootstrap
;;
fm-afk-pi-herdr-return-e2e.test.sh|\
fm-cmux-claude-composer-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|\
Expand Down
5 changes: 3 additions & 2 deletions docs/cmux-backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,8 +92,9 @@ 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 and delegates the content decision to `bin/fm-composer-lib.sh`.
A bare shell prompt is `unknown`, and a slash-popup placeholder remains `pending`, so only Enter is retried and text is never retyped.
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.
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.

Expand Down
14 changes: 14 additions & 0 deletions docs/verification/runtime-backends.md
Original file line number Diff line number Diff line change
Expand Up @@ -674,6 +674,20 @@ tests/fm-backend-cmux-smoke.test.sh

The real smoke proves socket access, fresh readiness, current-path probing, send and keys, bounded capture, title identity, and guarded exact cleanup.

### Claude composer confirmation

The borderless Claude composer confirmation was verified on 2026-08-09 with cmux 0.64.22 build 102 and Claude Code 2.1.226 on macOS aarch64.
An isolated real Claude worker rendered a bare `❯` plus U+00A0 row between horizontal rules.
The cmux classifier returned `empty`, and one `fm-send.sh --resolve-key <key> ALBATROSS` command appended the matching `resolved` event before the worker reported completion.
The terminal capture contained exactly one submitted `❯ ALBATROSS` row.
Refresh this harness-dependent proof with an isolated cmux Claude worker before accepting a Claude or cmux upgrade:

```sh
FM_CMUX_CLAUDE_COMPOSER_LIVE=1 bin/fm-test-run.sh tests/fm-cmux-claude-composer-live-e2e.test.sh
```

The portable classifier regression is `tests/fm-backend-cmux.test.sh`.

## Codex App host tools

A reusable Desktop host-tool smoke ran on 2026-07-06 against Codex Desktop bundle version 26.623.101652, build 4674, bundle id `com.openai.codex`.
Expand Down
52 changes: 52 additions & 0 deletions tests/fm-backend-cmux.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -726,6 +726,54 @@ test_composer_state_bare_prompt_is_empty() {
pass "fm_backend_cmux_composer_state: a bare '❯' composer row reads empty"
}

test_composer_state_borderless_claude_prompt_is_empty() {
local dir fb out
dir="$TMP_ROOT/composer-borderless-claude"; mkdir -p "$dir/responses"
cmux_panes_response "$dir" 1 "bbbbbbbb-1111-1111-1111-111111111111"
cmux_read_screen_response "$dir" 2 $'────────────────────────\n❯\n────────────────────────\nHaiku 4.5'
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" = empty ] || fail "a borderless Claude '❯' row bounded by horizontal rules should read empty, got '$out'"
pass "fm_backend_cmux_composer_state: a borderless Claude '❯' composer row reads empty"
}

test_composer_state_borderless_claude_prompt_outranks_stale_bordered_row() {
local dir fb out
dir="$TMP_ROOT/composer-borderless-claude-after-bordered"; mkdir -p "$dir/responses"
cmux_panes_response "$dir" 1 "bbbbbbbb-1111-1111-1111-111111111111"
cmux_read_screen_response "$dir" 2 $'│ ❯ stale input │\n────────────────────────\n❯\n────────────────────────\nHaiku 4.5'
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" = empty ] || fail "a current borderless Claude row should outrank stale bordered scrollback, got '$out'"
pass "fm_backend_cmux_composer_state: a borderless Claude row outranks stale bordered scrollback"
}

test_composer_state_borderless_claude_nbsp_prompt_is_empty() {
local dir fb out
dir="$TMP_ROOT/composer-borderless-claude-nbsp"; mkdir -p "$dir/responses"
cmux_panes_response "$dir" 1 "bbbbbbbb-1111-1111-1111-111111111111"
cmux_read_screen_response "$dir" 2 $'────────────────────────\n❯\302\240\n────────────────────────\nHaiku 4.5'
fb=$(make_cmux_fakebin "$dir")
out=$( LC_ALL=C 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" = empty ] || fail "a borderless Claude '❯'+NBSP row bounded by horizontal rules should read empty under LC_ALL=C, got '$out'"
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() {
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"
cmux_read_screen_response "$dir" 2 $'────────────────────────\n❯ retain this message\n────────────────────────\nHaiku 4.5'
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"
}

test_composer_state_ghost_placeholder_is_empty() {
local dir fb out
dir="$TMP_ROOT/composer-ghost"; mkdir -p "$dir/responses"
Expand Down Expand Up @@ -1087,6 +1135,10 @@ test_send_text_line_clears_partial_input_when_enter_fails
test_send_text_line_reports_unsafe_input_when_cleanup_fails
test_current_path_probes_with_marker
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_ghost_placeholder_is_empty
test_composer_state_real_text_is_pending
test_composer_state_popup_placeholder_fill_is_pending
Expand Down
110 changes: 110 additions & 0 deletions tests/fm-cmux-claude-composer-live-e2e.test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
#!/usr/bin/env bash
# Real Claude Code plus cmux submit-confirmation drift guard.
# Run explicitly with FM_CMUX_CLAUDE_COMPOSER_LIVE=1; it creates and cleans up
# only one exact fm-test- workspace through the normal scout lifecycle.
set -u

ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TASK="fm-test-cmux-claude-composer-$$"
LAB=
SPAWNED=0

fail() { printf 'not ok - %s\n' "$1" >&2; exit 1; }
pass() { printf 'ok - %s\n' "$1"; }

cleanup() {
[ "$SPAWNED" -eq 0 ] || {
mkdir -p "$LAB/data/$TASK"
: > "$LAB/data/$TASK/report.md"
if grep -q '^needs-decision \[key=probe-decision\]' "$LAB/state/$TASK.status" 2>/dev/null \
&& ! grep -q '^resolved \[key=probe-decision\]' "$LAB/state/$TASK.status" 2>/dev/null; then
printf '%s\n' 'resolved [key=probe-decision]: live guard cleanup' >> "$LAB/state/$TASK.status"
fi
FM_HOME="$LAB" "$ROOT/bin/fm-decision-hold.sh" complete "$TASK" --none >/dev/null 2>&1 || true
FM_HOME="$LAB" "$ROOT/bin/fm-teardown.sh" "$TASK" >/dev/null 2>&1 || true
}
[ -z "$LAB" ] || rm -rf -- "$LAB"
}

if [ "${FM_CMUX_CLAUDE_COMPOSER_LIVE:-0}" != 1 ]; then
echo "skip: set FM_CMUX_CLAUDE_COMPOSER_LIVE=1 to run the real cmux Claude composer drift guard"
exit 0
fi

command -v claude >/dev/null 2>&1 || fail "FM_CMUX_CLAUDE_COMPOSER_LIVE=1 but Claude Code is not installed"
command -v cmux >/dev/null 2>&1 || fail "FM_CMUX_CLAUDE_COMPOSER_LIVE=1 but cmux is not installed"
command -v jq >/dev/null 2>&1 || fail "FM_CMUX_CLAUDE_COMPOSER_LIVE=1 but jq is not installed"
command -v treehouse >/dev/null 2>&1 || fail "FM_CMUX_CLAUDE_COMPOSER_LIVE=1 but treehouse is not installed"
command -v python3 >/dev/null 2>&1 || fail "FM_CMUX_CLAUDE_COMPOSER_LIVE=1 but python3 is not installed"
cmux ping >/dev/null 2>&1 || fail "FM_CMUX_CLAUDE_COMPOSER_LIVE=1 but the cmux socket is unavailable"

LAB=$(mktemp -d "${TMPDIR:-/tmp}/fm-cmux-claude-composer.XXXXXX") || fail "could not create an isolated cmux Claude lab"
trap cleanup EXIT
mkdir -p "$LAB/config" "$LAB/data/$TASK" "$LAB/projects/comms" "$LAB/state"
printf 'cmux\n' > "$LAB/config/backend"

git -C "$LAB/projects/comms" init -q -b main || fail "could not initialize the isolated probe repository"
git -C "$LAB/projects/comms" config user.email 'cmux-composer-test@example.invalid'
git -C "$LAB/projects/comms" config user.name 'cmux composer test'
printf 'cmux Claude composer probe\n' > "$LAB/projects/comms/README.md"
git -C "$LAB/projects/comms" add README.md
git -C "$LAB/projects/comms" commit -qm 'fixture: initialize cmux Claude composer probe'

STATUS="$LAB/state/$TASK.status"
FM_HOME="$LAB" "$ROOT/bin/fm-brief.sh" "$TASK" comms --scout || fail "could not scaffold the Claude probe brief"
python3 - "$LAB/data/$TASK/brief.md" "$STATUS" <<'PY'
from pathlib import Path
import sys

brief = Path(sys.argv[1])
status = sys.argv[2]
brief.write_text(brief.read_text().replace("{TASK}", f'''Run a cmux communication probe.

Immediately append `working: cmux composer probe ready` to `{status}`.
Then append exactly `needs-decision [key=probe-decision]: awaiting codeword` to that file and stop to wait for a firstmate message.
When you receive a firstmate message containing `ALBATROSS`, append `done: received ALBATROSS` to that status file and stop.
Do not change project files or make a commit.'''))
PY

FM_HOME="$LAB" "$ROOT/bin/fm-spawn.sh" "$TASK" "$LAB/projects/comms" --scout --harness claude --model haiku --backend cmux \
|| fail "could not launch the real Claude cmux probe"
SPAWNED=1

# shellcheck source=bin/fm-backend.sh
FM_HOME="$LAB"
export FM_HOME
. "$ROOT/bin/fm-backend.sh"
fm_backend_source cmux || fail "could not source the cmux adapter"
TARGET=$(awk -F= '/^window=/{print $2}' "$LAB/state/$TASK.meta")
[ -n "$TARGET" ] || fail "the cmux probe did not record its endpoint"

for _ in $(seq 1 45); do
CAPTURE=$(fm_backend_cmux_capture "$TARGET" 200 "$TASK" 2>/dev/null || true)
case "$CAPTURE" in
*'Yes, I trust this folder'*) FM_HOME="$LAB" "$ROOT/bin/fm-send.sh" "$TASK" --key Enter || fail "could not accept Claude's folder-trust prompt" ;;
esac
grep -q '^needs-decision \[key=probe-decision\]' "$STATUS" 2>/dev/null && break
sleep 2
done
grep -q '^needs-decision \[key=probe-decision\]' "$STATUS" 2>/dev/null \
|| fail "Claude $(claude --version) did not reach the communication decision"

COMPOSER=$(fm_backend_cmux_composer_state "$TARGET" "$TASK")
[ "$COMPOSER" = empty ] || fail "cmux classified the real Claude $(claude --version) idle composer as '$COMPOSER'"
pass "cmux classifies the real Claude borderless composer as empty"

FM_SEND_SETTLE=0 FM_HOME="$LAB" "$ROOT/bin/fm-send.sh" "$TASK" --resolve-key probe-decision ALBATROSS \
|| fail "cmux did not confirm the real Claude steer"
for _ in $(seq 1 30); do
grep -q '^done: received ALBATROSS' "$STATUS" 2>/dev/null && break
sleep 2
done
grep -q '^resolved \[key=probe-decision\]: answered: ALBATROSS' "$STATUS" \
|| fail "confirmed cmux delivery did not close the keyed decision"
grep -q '^done: received ALBATROSS' "$STATUS" \
|| fail "the real Claude worker did not complete after the confirmed steer"

CAPTURE=$(fm_backend_cmux_capture "$TARGET" 200 "$TASK")
COUNT=$(printf '%s\n' "$CAPTURE" | grep -cF '❯ ALBATROSS' || true)
[ "$COUNT" -eq 1 ] || fail "expected exactly one submitted ALBATROSS steer, found $COUNT"
pass "cmux confirms one steer, closes the keyed decision, and leaves no duplicate"
Loading