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 .agents/skills/harness-adapters/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,10 @@ That scope matters because, unlike `/`, a leading `$` commonly starts ordinary t
An explicit `session:window` target has no meta, so its harness is unknown and treated as non-codex (the safe fast-path default).
This is why the validation trigger (`$no-mistakes`) to a codex crew now lands on the first Enter instead of biting the popup.

When Codex is inside a tool call, Enter can accept a steer into its visible `Messages to be submitted after next tool call` queue while leaving the queued row at the cursor.
For a meta-confirmed Codex target, `fm-send` treats an increase in that queue's visible arrow-prefixed row count as a successful acknowledgement and stops retrying Enter; an unchanged queue plus pending composer text remains a genuine failure.
This exception is scoped to Codex so every other harness retains the normal cleared-composer verification contract.

Directory trust dialog on first run per repo root: "Do you trust the contents of this directory?"
Accept with Enter.
The decision persists for the repo, so later worktrees of the same project skip it.
Expand Down
1 change: 1 addition & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ tests/fm-watch-triage.test.sh # always-on watcher triage: benign abs
tests/fm-daemon.test.sh # sub-supervisor classifier, /afk presence-gating, max-defer, composer, and fm-send submit tests
tests/fm-send-settle.test.sh # fm-send post-submit settle pause, tuning, disable, and --key bypass tests
tests/fm-send-popup-settle.test.sh # fm-send pre-Enter popup-settle selection for slash commands and codex $skill invocations
tests/fm-send-codex-verification.test.sh # Codex follow-up queue submit acknowledgement and non-Codex isolation tests
tests/fm-send-secondmate-marker.test.sh # fm-send from-firstmate marker for kind=secondmate targets: marked vs crewmate/explicit/--key, and the exact marker byte sequence
tests/fm-wake-daemon-lifecycle-e2e.test.sh # watcher + daemon lifecycle e2e: restart catch-up, batching, dedupe, stale-pane routing, and digest injection
tests/fm-composer-ghost.test.sh # dim-ghost stripping, ghost-only composer detection, and escape-free peek tests
Expand Down
6 changes: 3 additions & 3 deletions bin/backends/tmux.sh
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,9 @@ fm_backend_tmux_send_key() { # <target> <key>
}

# fm_backend_tmux_send_text_submit: type <text> into <target> once, then
# submit with Enter, retried (Enter only, never retyped) until the composer
# clears. Re-exports fm_tmux_submit_core (bin/fm-tmux-lib.sh) verbatim; see
# that file for the composer-verification contract and echoed verdicts.
# submit with Enter, retried (Enter only, never retyped) until the shared
# verifier accepts it. Re-exports fm_tmux_submit_core (bin/fm-tmux-lib.sh)
# verbatim; see that file for the composer-verification contract and verdicts.
fm_backend_tmux_send_text_submit() { # <target> <text> <retries> <enter-sleep> <settle>
fm_tmux_submit_core "$@"
}
Expand Down
19 changes: 14 additions & 5 deletions bin/fm-send.sh
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,10 @@
#
# Text submission is verified: the line is typed ONCE, then Enter is sent and
# retried (Enter only, never retyped) until the target backend reports a
# submitted/cleared composer or an inconclusive send. If a swallowed Enter is
# positively confirmed (the text is still sitting in the composer after all
# retries), fm-send exits NON-ZERO so the caller knows the steer did not land
# submitted/cleared composer, an opt-in Codex follow-up queue acknowledgement,
# or an inconclusive send. If a swallowed Enter is positively confirmed (the
# text is still sitting in the composer after all retries without a new Codex
# queue row), fm-send exits NON-ZERO so the caller knows the steer did not land
# instead of silently leaving an unsubmitted instruction.
# Submission dispatches through the target's recorded backend; the tmux adapter
# shares its composer/submit core with the away-mode daemon via bin/fm-tmux-lib.sh.
Expand Down Expand Up @@ -104,7 +105,15 @@ else
sleep_s=${FM_SEND_SLEEP:-0.4}
# Type once, submit, verify. Lenient: only a positively-confirmed swallow
# (text still in the composer) is an error; an unreadable pane is assumed sent.
verdict=$(fm_backend_send_text_submit "$TARGET_BACKEND" "$T" "$MARK_PREFIX$*" "$retries" "$sleep_s" "$settle" "$EXPECTED_LABEL")
# Codex accepts a steer during an active tool call into its visible follow-up
# queue while leaving the queued row at the cursor. The tmux verifier would
# otherwise call that row pending and retry Enter. Scope queue recognition to
# a meta-confirmed Codex target; other harnesses keep the existing contract.
if [ "$TARGET_HARNESS" = codex ]; then
verdict=$(FM_TMUX_CODEX_QUEUE_VERIFY=1 fm_backend_send_text_submit "$TARGET_BACKEND" "$T" "$MARK_PREFIX$*" "$retries" "$sleep_s" "$settle" "$EXPECTED_LABEL")
else
verdict=$(fm_backend_send_text_submit "$TARGET_BACKEND" "$T" "$MARK_PREFIX$*" "$retries" "$sleep_s" "$settle" "$EXPECTED_LABEL")
fi
case "$verdict" in
pending)
echo "error: text not submitted to $T (Enter swallowed; text left in composer)" >&2
Expand All @@ -115,7 +124,7 @@ else
exit 1
;;
esac
# Submit landed (verdict was not pending/send-failed). The cleared composer only
# Submit landed (verdict was not pending/send-failed). Its acknowledgement only
# proves the text was submitted; the harness still needs a beat to spin up the
# turn before its busy footer shows. Pause so an immediate peek catches the
# crewmate actually working instead of the stale idle pane. FM_SEND_SETTLE=0
Expand Down
48 changes: 40 additions & 8 deletions bin/fm-tmux-lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@
# as "pending input" and the away-mode daemon deferred 100% of escalations for
# 9.5 hours with no escape. The detector below strips the box borders before
# deciding, so a bordered-but-empty composer is correctly seen as empty. The same
# corrected detector backs the submit acknowledgement (a submit "landed" iff the
# composer is empty afterward), fixing the parallel false "Enter swallowed".
# corrected detector backs the normal submit acknowledgement (a submit "landed"
# when the composer is empty afterward), fixing the parallel false "Enter
# swallowed". An opt-in Codex path also accepts visible follow-up queue growth.
#
# Ghost text (incident composer-robust): claude renders a predicted-next-prompt
# "suggestion" as dim/faint text inside an otherwise-empty composer. A plain
Expand Down Expand Up @@ -164,30 +165,61 @@ fm_pane_is_busy() { # <target>
| grep -qiE "${FM_BUSY_REGEX:-$FM_TMUX_BUSY_REGEX_DEFAULT}"
}

# fm_tmux_codex_queue_count: count the visible Codex follow-up rows under its
# "Messages to be submitted after next tool call" heading. The heading and the
# leading arrow on each queued row are Codex TUI-owned acknowledgement signals.
# A capture failure is inconclusive and prints nothing.
fm_tmux_codex_queue_count() { # <target> -> non-negative integer or empty
local target=$1 pane
pane=$(tmux capture-pane -p -J -t "$target" -S -80 2>/dev/null) || return 0
printf '%s\n' "$pane" | awk '
/Messages to be submitted after next tool call/ { in_queue = 1; count = 0; next }
in_queue && index($0, "↳") { count++ }
END { print count + 0 }
'
}

# fm_tmux_submit_core: type <text> into <target> ONCE, then submit with Enter,
# verifying the composer cleared. Retries Enter ONLY — never retypes, because a
# verifying the composer cleared or an enabled Codex follow-up queue grew.
# 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
# pick their own success policy:
# - 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),
# so an unreadable pane never turns a normal steer into a false error.
fm_tmux_submit_enter_core() { # <target> <retries> <enter-sleep>
local target=$1 retries=$2 sleep_s=$3 i=0 state
# accepts "queued" for a meta-confirmed Codex target, and treats an unreadable
# pane as inconclusive so it never turns a normal steer into a false error.
fm_tmux_submit_enter_core() { # <target> <retries> <enter-sleep> [codex-queue-count-before]
local target=$1 retries=$2 sleep_s=$3 queue_before=${4:-} i=0 state queue_after
while :; do
tmux send-keys -t "$target" Enter 2>/dev/null || true
sleep "$sleep_s"
state=$(fm_tmux_composer_state "$target")
[ "$state" = pending ] || { printf '%s' "$state"; return 0; }
if [ -n "$queue_before" ]; then
queue_after=$(fm_tmux_codex_queue_count "$target")
case "$queue_after" in
''|*[!0-9]*) : ;;
*)
if [ "$queue_after" -gt "$queue_before" ]; then
printf 'queued'
return 0
fi
;;
esac
fi
i=$((i + 1))
[ "$i" -lt "$retries" ] || { printf 'pending'; return 0; }
done
}

fm_tmux_submit_core() { # <target> <text> <retries> <enter-sleep> <settle>
local target=$1 text=$2 retries=$3 sleep_s=$4 settle=$5
local target=$1 text=$2 retries=$3 sleep_s=$4 settle=$5 queue_before=""
if [ "${FM_TMUX_CODEX_QUEUE_VERIFY:-}" = 1 ]; then
queue_before=$(fm_tmux_codex_queue_count "$target")
fi
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"
fm_tmux_submit_enter_core "$target" "$retries" "$sleep_s" "$queue_before"
}
116 changes: 116 additions & 0 deletions tests/fm-send-codex-verification.test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
#!/usr/bin/env bash
# Codex-specific submit verification for fm-send.
#
# During an active tool call Codex can acknowledge Enter by moving the steer to
# its visible follow-up queue while the queued row remains at the cursor.
# These tests keep that accepted state distinct from both a cleared composer and
# a genuinely swallowed Enter, and pin the exception to Codex targets only.
set -u

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

SEND="$ROOT/bin/fm-send.sh"
TMP_ROOT=$(fm_test_tmproot fm-send-codex-verification)

make_stubs() { # <dir>
local dir=$1 fb="$1/fakebin"
mkdir -p "$fb"
cat > "$fb/tmux" <<'SH'
#!/usr/bin/env bash
set -u
state="$FM_FAKE_STATE"
enters="$FM_FAKE_ENTERS"
case "${1:-}" in
send-keys)
case " $* " in
*' -l '*) printf 'typed\n' > "$state" ;;
*)
printf 'enter\n' >> "$enters"
case "$FM_FAKE_MODE" in
landed) printf 'landed\n' > "$state" ;;
queued) printf 'queued\n' > "$state" ;;
failed) : ;;
esac
;;
esac
exit 0
;;
display-message)
for arg in "$@"; do
case "$arg" in *cursor_y*) printf '0\n'; exit 0 ;; esac
done
printf 'fakepane\n'
exit 0
;;
capture-pane)
current=$(cat "$state" 2>/dev/null || printf 'idle')
case " $* " in
*' -J '*)
case "$FM_FAKE_MODE:$current" in
queued:queued)
printf 'Messages to be submitted after next tool call\n ↳ fix verification\n'
;;
stale-queue:*)
printf 'Messages to be submitted after next tool call\n ↳ older steer\n'
;;
*) printf 'ordinary pane content\n' ;;
esac
;;
*)
case "$current" in
landed|idle) printf '│ > │\n' ;;
typed|queued) printf '│ > fix verification │\n' ;;
esac
;;
esac
exit 0
;;
list-windows) exit 0 ;;
esac
exit 0
SH
chmod +x "$fb/tmux"
cat > "$fb/sleep" <<'SH'
#!/usr/bin/env bash
exit 0
SH
chmod +x "$fb/sleep"
}

run_case() { # <name> <harness> <mode> <expected-rc> <expected-enters>
local name=$1 harness=$2 mode=$3 expected_rc=$4 expected_enters=$5
local dir fb state enters rc actual_enters
dir="$TMP_ROOT/$name"
fb="$dir/fakebin"
state="$dir/state"
enters="$dir/enters"
mkdir -p "$dir/home/state"
make_stubs "$dir"
printf 'idle\n' > "$state"
: > "$enters"
fm_write_meta "$dir/home/state/task.meta" "window=sess:win" "harness=$harness"
env PATH="$fb:$PATH" FM_ROOT_OVERRIDE="$dir/home" FM_HOME="$dir/home" \
FM_FAKE_STATE="$state" FM_FAKE_ENTERS="$enters" FM_FAKE_MODE="$mode" \
FM_SEND_SETTLE=0 "$SEND" fm-task 'fix verification' >/dev/null 2>&1
rc=$?
expect_code "$expected_rc" "$rc" "$name: unexpected fm-send exit code"
actual_enters=$(wc -l < "$enters" | tr -d ' ')
[ "$actual_enters" -eq "$expected_enters" ] \
|| fail "$name: expected $expected_enters Enter(s), got $actual_enters"
}

run_case landed codex landed 0 1
pass "fm-send Codex verification: a cleared composer is accepted as landed"

run_case queued codex queued 0 1
pass "fm-send Codex verification: a newly visible follow-up queue row is accepted without retry"

run_case genuine-failure codex failed 1 3
pass "fm-send Codex verification: unchanged queue plus pending text remains a failure"

run_case stale-queue-failure codex stale-queue 1 3
pass "fm-send Codex verification: a pre-existing unchanged queue does not mask failure"

run_case other-harness claude queued 1 3
pass "fm-send Codex verification: queue recognition does not weaken other harnesses"