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
4 changes: 2 additions & 2 deletions bin/backends/cmux.sh
Original file line number Diff line number Diff line change
Expand Up @@ -504,8 +504,8 @@ fm_backend_cmux_send_key() { # <target> <key> [expected-label]
# fm_backend_cmux_send_text_line: send one line of TEXT then submit. cmux has
# no single-call atomic "run and submit" primitive (like herdr's `pane run`),
# so this composes send (literal) + send-key enter, exactly like zellij's
# equivalent - used for the fixed spawn-time commands (treehouse get, the
# GOTMPDIR export).
# equivalent. fm-spawn uses this primitive for setup and the launch delivery
# protocol owned by bin/fm-spawn.sh's header.
fm_backend_cmux_send_text_line() { # <target> <text> [expected-label]
fm_backend_cmux_send_literal "$1" "$2" "${3:-}" || return 1
fm_backend_cmux_send_key "$1" Enter "${3:-}"
Expand Down
6 changes: 3 additions & 3 deletions bin/backends/herdr.sh
Original file line number Diff line number Diff line change
Expand Up @@ -1676,9 +1676,9 @@ fm_backend_herdr_current_path() { # <target>
}

# fm_backend_herdr_send_text_line: send one line of TEXT then submit,
# ATOMICALLY - mirrors tmux's `send-keys -t T text Enter`. Used for the fixed
# spawn-time commands (treehouse get, the GOTMPDIR export). `pane run` types
# the command and submits it in one call (verified).
# ATOMICALLY - mirrors tmux's `send-keys -t T text Enter`. fm-spawn uses this
# primitive for setup and the launch delivery protocol owned by bin/fm-spawn.sh's
# header. `pane run` types and submits in one call (verified).
fm_backend_herdr_send_text_line() { # <target> <text>
fm_backend_herdr_target_ready "$1" || return 1
fm_backend_herdr_cli "$FM_BACKEND_HERDR_SESSION" pane run "$FM_BACKEND_HERDR_PANE" "$2" >/dev/null 2>&1
Expand Down
9 changes: 4 additions & 5 deletions bin/backends/tmux.sh
Original file line number Diff line number Diff line change
Expand Up @@ -102,16 +102,15 @@ fm_backend_tmux_current_path() { # <target>
}

# fm_backend_tmux_send_text_line: send one line of TEXT then Enter, with no
# composer verification - used for the fixed spawn-time commands
# (`treehouse get`, the GOTMPDIR export) that already ran this exact sequence
# inline in fm-spawn.sh. Mirrors `tmux send-keys -t "$T" "<text>" Enter`.
# composer verification. fm-spawn uses this primitive for setup and the launch
# delivery protocol owned by bin/fm-spawn.sh's header.
# Mirrors `tmux send-keys -t "$T" "<text>" Enter`.
fm_backend_tmux_send_text_line() { # <target> <text>
tmux send-keys -t "$1" "$2" Enter
}

# fm_backend_tmux_send_literal: send TEXT as literal bytes with no
# submission - the caller sends Enter separately (fm-spawn.sh's launch-command
# send pauses between the literal send and Enter for the harness to settle).
# submission - the caller sends Enter separately.
# Mirrors `tmux send-keys -t "$T" -l "<text>"`.
fm_backend_tmux_send_literal() { # <target> <text>
tmux send-keys -t "$1" -l "$2"
Expand Down
11 changes: 6 additions & 5 deletions bin/backends/zellij.sh
Original file line number Diff line number Diff line change
Expand Up @@ -462,11 +462,12 @@ fm_backend_zellij_send_key() { # <target> <key> [expected-label]

# fm_backend_zellij_send_text_line: send one line of TEXT then submit,
# ATOMICALLY - mirrors tmux's `send-keys -t T text Enter` / herdr's `pane
# run`. Used for the fixed spawn-time commands (treehouse get, the GOTMPDIR
# export). Zellij has no single-call atomic "run and submit" action, so this
# composes paste (literal) + send-keys Enter, exactly like send_literal +
# send_key are composed elsewhere - the two-step form is the ONLY form for
# this adapter, unlike tmux/herdr which have a genuinely atomic primitive.
# run`. fm-spawn uses this primitive for setup and the launch delivery protocol
# owned by bin/fm-spawn.sh's header. Zellij has no single-call atomic "run and
# submit" action, so this composes paste (literal) + send-keys Enter, exactly
# like send_literal + send_key are composed elsewhere - the two-step form is
# the ONLY form for this adapter, unlike tmux/herdr which have a genuinely
# atomic primitive.
fm_backend_zellij_send_text_line() { # <target> <text> [expected-label]
fm_backend_zellij_send_literal "$1" "$2" "${3:-}" || return 1
fm_backend_zellij_send_key "$1" Enter "${3:-}"
Expand Down
102 changes: 96 additions & 6 deletions bin/fm-spawn.sh
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,14 @@
# whitespace is treated as a RAW launch command - the escape hatch for verifying
# new adapters. pi-signed launches that exact executable name from PATH and
# refuses before endpoint creation when it is unavailable; it never falls back to pi.
# Before any harness command runs, launch delivery waits for an executed shell
# round-trip, stages the command through bounded submitted assignments using the
# selected backend's send/capture primitives, and verifies the complete staged
# bytes before evaluation. A failed check clears the shell line and retries a
# bounded number of times; exhaustion records a failed task status and exits
# nonzero rather than reporting a spawn. This launch-delivery contract does not
# alter the backend adapters' text-submit verification contracts; its regression
# coverage lives in tests/fm-spawn-launch-delivery.test.sh.
# config/secondmate-harness may also carry an optional model and effort as extra
# whitespace-separated tokens ("<harness> [<model>] [<effort>]"). For a
# --secondmate spawn, those tokens apply only when this spawn also resolves its
Expand Down Expand Up @@ -1236,6 +1244,85 @@ spawn_send_key() { # <target> <key>
esac
}

spawn_capture() { # <target>
fm_backend_capture "$BACKEND" "$1" 160 "$W" 2>/dev/null || true
}

spawn_capture_has_line() { # <capture> <exact-line>
printf '%s\n' "$1" | grep -Fqx "$2"
}

spawn_wait_for_marker() { # <target> <marker>
local target=$1 marker=$2 pane i=0 max=${FM_SPAWN_LAUNCH_VERIFY_POLLS:-40}
case "$max" in ''|*[!0-9]*) max=40 ;; esac
[ "$max" -gt 0 ] || max=40
while [ "$i" -lt "$max" ]; do
pane=$(spawn_capture "$target")
spawn_capture_has_line "$pane" "$marker" && return 0
i=$((i + 1))
[ "$i" -ge "$max" ] || sleep "${FM_SPAWN_LAUNCH_POLL_INTERVAL:-0.05}"
done
return 1
}

spawn_wait_for_shell_ready() { # <target> <token>
local target=$1 token=$2
local marker="__FM_SPAWN_READY_$token"
# The full marker is intentionally absent from the typed command, so seeing
# an exact marker line proves the shell executed the probe rather than merely
# echoing bytes that arrived before its line editor was ready.
spawn_send_text_line "$target" "printf '%s%s\\n' '__FM_SPAWN_READY_' '$token'" \
|| return 1
spawn_wait_for_marker "$target" "$marker"
}

spawn_stage_launch() { # <target> <launch> <token>
local target=$1 launch=$2 token=$3 chunk_size=${FM_SPAWN_LAUNCH_CHUNK_BYTES:-160}
local delay=${FM_SPAWN_LAUNCH_CHUNK_DELAY:-0.04} offset=0 chunk quoted expected marker check
case "$chunk_size" in ''|*[!0-9]*) chunk_size=160 ;; esac
[ "$chunk_size" -gt 0 ] || chunk_size=160
expected=$(printf '%s' "$launch" | cksum) || return 1
marker="__FM_SPAWN_LAUNCH_OK_$token"

# C-c clears an abandoned line from a prior failed attempt before short,
# independently submitted assignments rebuild the launch exactly in the
# target shell. This avoids macOS's pre-ZLE canonical-input ceiling while
# retaining the backend adapters' own literal/key submission contracts.
spawn_send_key "$target" C-c || true
spawn_send_text_line "$target" "FM_SPAWN_LAUNCH=''" || return 1
while [ "$offset" -lt "${#launch}" ]; do
chunk=${launch:offset:chunk_size}
quoted=$(shell_quote "$chunk")
spawn_send_text_line "$target" "FM_SPAWN_LAUNCH=\"\${FM_SPAWN_LAUNCH}\"$quoted" || return 1
offset=$((offset + chunk_size))
sleep "$delay"
done
check="if [ \"\$(printf %s \"\$FM_SPAWN_LAUNCH\" | cksum)\" = $(shell_quote "$expected") ]; then printf '%s%s\\n' '__FM_SPAWN_LAUNCH_OK_' '$token'; else printf '%s%s\\n' '__FM_SPAWN_LAUNCH_BAD_' '$token'; fi"
spawn_send_text_line "$target" "$check" || return 1
spawn_wait_for_marker "$target" "$marker"
}

spawn_deliver_launch() { # <target> <launch>
local target=$1 launch=$2 retries=${FM_SPAWN_LAUNCH_DELIVERY_RETRIES:-3}
local token token_sum attempt=1
case "$retries" in ''|*[!0-9]*) retries=3 ;; esac
[ "$retries" -gt 0 ] || retries=3
while [ "$attempt" -le "$retries" ]; do
token_sum=$(printf '%s' "$RANDOM:$attempt" | cksum) || return 1
token_sum=${token_sum%% *}
printf -v token '%010u' "$token_sum"
if spawn_wait_for_shell_ready "$target" "$token" \
&& spawn_stage_launch "$target" "$launch" "$token"; then
# shellcheck disable=SC2016 # Expand the staged launch in the target shell.
spawn_send_text_line "$target" 'eval "$FM_SPAWN_LAUNCH"' || return 1
return 0
fi
spawn_send_key "$target" C-c || true
attempt=$((attempt + 1))
done
return 1
}

kimi_capture() {
fm_backend_capture "$BACKEND" "$T" 120 "$W" 2>/dev/null || true
}
Expand Down Expand Up @@ -1555,17 +1642,20 @@ if [ "$KIND" = secondmate ]; then
LAUNCH="FM_ROOT_OVERRIDE= FM_STATE_OVERRIDE= FM_DATA_OVERRIDE= FM_PROJECTS_OVERRIDE= FM_CONFIG_OVERRIDE= FM_HOME=$sq_home $LAUNCH"
fi
# Export GOTMPDIR into the crewmate's pane shell so the agent and every child
# process (go build, go test, ...) inherit it. Sent before the launch command so
# the env is set when the agent starts; the brief sleep lets the export land.
# process (go build, go test, ...) inherit it. The verified delivery routine
# below then waits for that shell to round-trip a probe before staging the full
# launch in bounded, independently submitted assignments.
spawn_send_text_line "$T" "export GOTMPDIR=$TASK_TMP/gotmp"
sleep 0.3
spawn_send_literal "$T" "$LAUNCH"
sleep 0.3
if ! spawn_deliver_launch "$T" "$LAUNCH"; then
printf 'failed: launch command delivery could not be verified after %s attempts\n' \
"${FM_SPAWN_LAUNCH_DELIVERY_RETRIES:-3}" >> "$STATE/$ID.status"
echo "error: launch command delivery could not be verified after ${FM_SPAWN_LAUNCH_DELIVERY_RETRIES:-3} attempts; inspect window $T" >&2
exit 1
fi
if [ "${HERDR_PROJECTED:-0}" -eq 1 ]; then
HERDR_PROJECTION_ABORT_CLEANUP=0
spawn_herdr_presentation_order_lock_release
fi
spawn_send_key "$T" Enter
if [ "$HARNESS" = kimi ]; then
if ! kimi_wait_for_ready; then
kimi_spawn_fail "kimi did not show a verified ready signal before brief delivery"
Expand Down
2 changes: 1 addition & 1 deletion docs/herdr-backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ Every Herdr invocation goes through `fm_backend_herdr_cli`, which sets the envir
An environment variable alone is not reliable when another Herdr server is running.

Literal text and Enter are separate operations for ordinary steers.
Spawn-time fixed commands may use Herdr's atomic run primitive.
Spawn setup and the verified launch-delivery protocol use Herdr's atomic run primitive, while [`bin/fm-spawn.sh`](../bin/fm-spawn.sh) owns the protocol.
Enter, Escape, and Ctrl-C are supported.
Slash and dollar-prefixed input uses the shared harness-aware settle before the first Enter so a completion popup cannot consume it.
Text is typed once; only Enter is retried.
Expand Down
2 changes: 1 addition & 1 deletion tests/fm-backend-herdr-smoke.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,7 @@ case "$out" in
esac
pass "real herdr: send_text_line runs a command atomically (pane run) and its output is capturable"

# --- send_literal + send_key(Enter), the two-step launch-command form -------
# --- send_literal + send_key(Enter), the two-step primitive composition -----

fm_backend_herdr_send_literal "$TARGET" 'echo literal-then-key-captain' \
|| fail "send_literal failed"
Expand Down
48 changes: 46 additions & 2 deletions tests/fm-backend-orca.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,45 @@ if [ "${1:-}" = status ] && [ "${FM_ORCA_STATUS_RESPONSE:-ready}" != sequence ];
printf '{"ok":true,"result":{"runtime":{"reachable":true,"state":"ready"}}}\n'
exit 0
fi
if [ -n "${FM_ORCA_SPAWN_SCREEN:-}" ] && [ "${1:-} ${2:-}" = "terminal send" ]; then
text=
prev=
for arg in "$@"; do
if [ "$prev" = --text ]; then
text=$arg
break
fi
prev=$arg
done
staged="$FM_ORCA_SPAWN_SCREEN.staged"
evaluated="$FM_ORCA_SPAWN_SCREEN.evaluated"
case "$text" in
*"__FM_SPAWN_READY_"*)
token=$(printf '%s\n' "$text" | sed -n "s/.*'__FM_SPAWN_READY_' '\([^']*\)'.*/\1/p")
[ -z "$token" ] || printf '__FM_SPAWN_READY_%s\n' "$token" > "$FM_ORCA_SPAWN_SCREEN"
;;
"FM_SPAWN_LAUNCH=''" )
: > "$staged"
;;
FM_SPAWN_LAUNCH=*)
rebuilt=$(FM_SPAWN_LAUNCH="$(cat "$staged")" bash -c "$text; printf '%s' \"\$FM_SPAWN_LAUNCH\"")
printf '%s' "$rebuilt" > "$staged"
;;
*"__FM_SPAWN_LAUNCH_OK_"*)
FM_SPAWN_LAUNCH="$(cat "$staged")" bash -c "$text" > "$FM_ORCA_SPAWN_SCREEN"
;;
'eval "$FM_SPAWN_LAUNCH"')
cat "$staged" > "$evaluated"
;;
esac
printf '{"ok":true,"result":{"send":{"accepted":true}}}\n'
exit 0
fi
if [ -n "${FM_ORCA_SPAWN_SCREEN:-}" ] && [ "${1:-} ${2:-}" = "terminal read" ]; then
marker=$(tail -n 1 "$FM_ORCA_SPAWN_SCREEN" 2>/dev/null || true)
printf '{"ok":true,"result":{"terminal":{"tail":["%s"]}}}\n' "$marker"
exit 0
fi
n=$next
echo "$n" > "$COUNT_FILE"
if [ -f "$RESP/$n.exit" ]; then
Expand Down Expand Up @@ -486,7 +525,11 @@ test_spawn_writes_orca_metadata_and_launches_harness() {
printf '1\n' > "$RESP/1.exit"
printf '{"ok":true,"result":{"repo":{"id":"repo-spawn"}}}\n' > "$RESP/2.out"
printf '{"ok":true,"result":{"worktree":{"id":"wt-spawn","path":"%s"},"terminal":{"handle":"term-spawn"}}}\n' "$wt" > "$RESP/3.out"
: > "$CASE_DIR/spawn-screen"
: > "$CASE_DIR/spawn-screen.staged"
: > "$CASE_DIR/spawn-screen.evaluated"
out=$( PATH="$FB:$PATH" FM_ORCA_LOG="$LOG" FM_ORCA_RESPONSES="$RESP" \
FM_ORCA_SPAWN_SCREEN="$CASE_DIR/spawn-screen" \
FM_ROOT_OVERRIDE="$ROOT" FM_STATE_OVERRIDE="$state" FM_DATA_OVERRIDE="$data" FM_CONFIG_OVERRIDE="$config" \
FM_PROJECTS_OVERRIDE="$TMP_ROOT/unused-projects" FM_SPAWN_NO_GUARD=1 \
"$ROOT/bin/fm-spawn.sh" "$id" "$proj" claude --backend orca 2>&1 )
Expand All @@ -502,8 +545,9 @@ test_spawn_writes_orca_metadata_and_launches_harness() {
"spawn should reuse the implicit terminal returned by Orca worktree creation"
assert_contains "$(cat "$log")" $'orca\x1f''terminal'$'\x1f''send'$'\x1f''--terminal'$'\x1f''term-spawn'$'\x1f''--text'$'\x1f''export GOTMPDIR=/tmp/fm-orcaspawnz1/gotmp'$'\x1f''--enter'$'\x1f''--json' \
"spawn did not export GOTMPDIR through the Orca terminal"
assert_contains "$(cat "$log")" "CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false claude --dangerously-skip-permissions" \
"spawn did not send the selected harness launch command through Orca"
assert_contains "$(cat "$CASE_DIR/spawn-screen.evaluated")" \
"CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false claude --dangerously-skip-permissions" \
"spawn did not verify and evaluate the selected harness launch command through Orca"
rm -rf "/tmp/fm-$id"
pass "fm-spawn.sh --backend orca: reuses implicit terminal, records metadata, launches harness"
}
Expand Down
3 changes: 1 addition & 2 deletions tests/fm-backend-tmux-smoke.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -105,8 +105,7 @@ case "$out" in
esac
pass "real tmux: fm_backend_tmux_send_text_line sends literal text and submits with Enter"

# --- send_literal + send_key(Enter), the two-step form fm-spawn.sh uses for the
# harness launch command (literal send, settle, then a separate Enter) --------
# --- send_literal + send_key(Enter), the two-step primitive composition ------

fm_backend_tmux_send_literal "$TARGET" "printf 'literal-then-key-%s\\n' captain" \
|| fail "fm_backend_tmux_send_literal failed"
Expand Down
30 changes: 30 additions & 0 deletions tests/fm-backend.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -797,11 +797,26 @@ make_spawn_fakebin() { # <dir> <fake-worktree-path> -> echoes fakebin dir
#!/usr/bin/env bash
set -u
{ printf 'tmux'; for a in "\$@"; do printf '\\x1f%s' "\$a"; done; printf '\\n'; } >> "\${FM_TMUX_LOG:?}"
screen="\${FM_TMUX_LOG:?}.spawn-screen"
case "\${1:-}" in
display-message)
for a in "\$@"; do case "\$a" in *pane_current_path*) printf '%s\\n' "$wt"; exit 0 ;; esac; done
printf 'firstmate\\n'; exit 0 ;;
list-windows) exit 0 ;;
capture-pane) cat "\$screen" 2>/dev/null || true; exit 0 ;;
send-keys)
text=\${4:-}
case "\$text" in
*"__FM_SPAWN_READY_"*)
token=\$(printf '%s\\n' "\$text" | sed -n "s/.*'__FM_SPAWN_READY_' '\\([^']*\\)'.*/\\1/p")
[ -z "\$token" ] || printf '__FM_SPAWN_READY_%s\\n' "\$token" > "\$screen"
;;
*"__FM_SPAWN_LAUNCH_OK_"*)
token=\$(printf '%s\\n' "\$text" | sed -n "s/.*'__FM_SPAWN_LAUNCH_OK_' '\\([^']*\\)'.*/\\1/p")
[ -z "\$token" ] || printf '__FM_SPAWN_LAUNCH_OK_%s\\n' "\$token" > "\$screen"
;;
esac
exit 0 ;;
esac
exit 0
SH
Expand Down Expand Up @@ -859,6 +874,7 @@ make_spawn_symlink_fakebin() { # <dir> <initial-project-path> <worktree-path> -
#!/usr/bin/env bash
set -u
{ printf 'tmux'; for a in "\$@"; do printf '\\x1f%s' "\$a"; done; printf '\\n'; } >> "\${FM_TMUX_LOG:?}"
screen="\${FM_TMUX_LOG:?}.spawn-screen"
case "\${1:-}" in
display-message)
for a in "\$@"; do case "\$a" in *pane_current_path*)
Expand All @@ -872,6 +888,20 @@ case "\${1:-}" in
;; esac; done
printf 'firstmate\\n'; exit 0 ;;
list-windows) exit 0 ;;
capture-pane) cat "\$screen" 2>/dev/null || true; exit 0 ;;
send-keys)
text=\${4:-}
case "\$text" in
*"__FM_SPAWN_READY_"*)
token=\$(printf '%s\\n' "\$text" | sed -n "s/.*'__FM_SPAWN_READY_' '\\([^']*\\)'.*/\\1/p")
[ -z "\$token" ] || printf '__FM_SPAWN_READY_%s\\n' "\$token" > "\$screen"
;;
*"__FM_SPAWN_LAUNCH_OK_"*)
token=\$(printf '%s\\n' "\$text" | sed -n "s/.*'__FM_SPAWN_LAUNCH_OK_' '\\([^']*\\)'.*/\\1/p")
[ -z "\$token" ] || printf '__FM_SPAWN_LAUNCH_OK_%s\\n' "\$token" > "\$screen"
;;
esac
exit 0 ;;
esac
exit 0
SH
Expand Down
Loading
Loading