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
77 changes: 65 additions & 12 deletions bin/backends/tmux.sh
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,26 @@
# shellcheck source=bin/fm-tmux-lib.sh
. "$FM_BACKEND_LIB_DIR/fm-tmux-lib.sh"

# fm_backend_tmux_resolve_pane: re-exported verbatim from bin/fm-tmux-lib.sh
# under this adapter's naming convention (the same pattern the submit core
# uses), so the adapter and the shared library cannot drift on what counts as
# a resolvable target. It is the gate EVERY read of a caller-supplied target in
# this file goes through; see that function for why `tmux display-message`
# cannot be trusted to refuse one.
fm_backend_tmux_resolve_pane() { # <target> -> prints pane id, or returns 1
fm_tmux_resolve_pane "$@"
}

# fm_backend_tmux_target_exists: pane-PRESENCE of <target>, and the one owner
# of that question for this backend - fm_backend_target_exists (bin/fm-backend.sh)
# and fm-crew-state.sh's pane_readable both dispatch here rather than each
# running their own raw probe, which is how two of the three copies kept using
# the unguarded display-message form after the hazard was already documented at
# the third (bin/fm-spawn.sh's worktree-discovery poll).
fm_backend_tmux_target_exists() { # <target>
fm_backend_tmux_resolve_pane "$1" >/dev/null
}

# fm_backend_tmux_resolve_bare_selector: the live-window-listing fallback for a
# selector that is neither an explicit target nor a task selector routed
# through meta - an ad hoc window name with no recorded task. Mirrors the
Expand All @@ -38,11 +58,17 @@ fm_backend_tmux_capture() { # <target> <lines>
tmux capture-pane -p -t "$1" -S -"$2"
}

# fm_backend_tmux_send_key: one named key. Mirrors fm-send.sh's --key path:
# `tmux display-message -p -t "$T" '#{pane_id}' >/dev/null`, then
# `tmux send-keys -t "$T" "$2"`.
# fm_backend_tmux_send_key: one named key. Mirrors fm-send.sh's --key path,
# whose preflight used to be `tmux display-message -p -t "$T" '#{pane_id}'
# >/dev/null` - a check that could not fail, because display-message answers
# for another window instead of refusing (fm_tmux_resolve_pane). The preflight
# now actually verifies the target before any key is sent. `tmux send-keys`
# itself was measured to refuse an unresolvable target correctly (rc=1, nothing
# delivered anywhere - docs/tmux-backend.md), so no keystroke was ever
# misdelivered by the old form; it simply reported the failure from send-keys
# rather than from the preflight that claimed to be guarding it.
fm_backend_tmux_send_key() { # <target> <key>
tmux display-message -p -t "$1" '#{pane_id}' >/dev/null
fm_backend_tmux_resolve_pane "$1" >/dev/null || return 1
tmux send-keys -t "$1" "$2"
}

Expand Down Expand Up @@ -95,10 +121,19 @@ fm_backend_tmux_create_task() { # <session> <window-name> <proj-abs> -> prints
}

# fm_backend_tmux_current_path: the live pane's current working directory, or
# empty on any tmux error. Mirrors fm-spawn.sh's worktree-discovery poll:
# `tmux display-message -p -t "$T" '#{pane_current_path}'`.
# empty on any tmux error AND on a target that does not resolve. Mirrors
# fm-spawn.sh's worktree-discovery poll.
#
# The resolve gate is what bin/fm-spawn.sh's own comment at that poll has warned
# about since it was written: an unresolvable target makes display-message read
# firstmate's OWN pane path as the crewmate's worktree and tangle a hook into
# the primary checkout. fm-spawn.sh answered that by passing the stable window
# id, which is a correct fix for that ONE caller and no protection for any
# other. The gate lives here instead, so the hazard is closed for every caller.
fm_backend_tmux_current_path() { # <target>
tmux display-message -p -t "$1" '#{pane_current_path}' 2>/dev/null
local pane
pane=$(fm_backend_tmux_resolve_pane "$1") || return 1
tmux display-message -p -t "$pane" '#{pane_current_path}' 2>/dev/null
}

# fm_backend_tmux_send_text_line: send one line of TEXT then Enter, with no
Expand Down Expand Up @@ -133,8 +168,15 @@ fm_backend_tmux_kill() { # <target>
# a persisting parent script running `sleep` as a child reports the PARENT's
# own name throughout; the value reverts to the shell's own name only once
# the foreground command actually exits). Empty on any tmux error.
#
# Gated on fm_backend_tmux_resolve_pane: an unresolvable target returns 1 with
# no output rather than the foreground command of some OTHER pane. Without that
# gate this function - and therefore fm_backend_tmux_agent_alive, its only
# consumer - answered for the supervising pane and never read the target at all.
fm_backend_tmux_current_command() { # <target>
tmux display-message -p -t "$1" '#{pane_current_command}' 2>/dev/null
local pane
pane=$(fm_backend_tmux_resolve_pane "$1") || return 1
tmux display-message -p -t "$pane" '#{pane_current_command}' 2>/dev/null
}

# fm_backend_tmux_agent_alive: CONFIDENT liveness of a live harness-agent
Expand All @@ -151,10 +193,21 @@ fm_backend_tmux_current_command() { # <target>
# unknown - anything else, INCLUDING a bare "node"/"python" interpreter
# name (pi's own launcher execs into a generic "node" process
# with no reliable way to attribute it back to pi from outside
# the pane - docs/tmux-backend.md "Known gaps"), or an unreadable
# pane. Callers must never treat unknown as a confirmed-dead
# signal (bin/fm-bootstrap.sh's secondmate-liveness sweep gates a
# respawn on `dead` only).
# the pane - docs/tmux-backend.md "Known gaps"), an unreadable
# pane, or a TARGET THAT DOES NOT RESOLVE. Callers must never treat
# unknown as a confirmed-dead signal (bin/fm-bootstrap.sh's
# secondmate-liveness sweep gates a respawn on `dead` only).
#
# An unresolvable target reports unknown, not dead, and the distinction is the
# point of the fix: `dead` here means "this pane exists and confidently holds no
# agent", which is a reading of the target. Whether the endpoint exists at all
# is fm_backend_target_exists's question, and a gone endpoint routes to the
# recovery path (AGENTS.md section 5), so nothing is lost by refusing to answer
# a liveness question about a pane that is not there. The sweep turns unknown
# into a REPORTED skip; before the gate, the same target silently produced a
# confident alive or dead verdict read off the supervising pane, which is how
# one host would never respawn a dead secondmate while another risked spawning
# a duplicate over a live one.
fm_backend_tmux_agent_alive() { # <target>
local target=$1 comm
comm=$(fm_backend_tmux_current_command "$target") || { printf 'unknown'; return 0; }
Expand Down
29 changes: 28 additions & 1 deletion bin/fm-backend.sh
Original file line number Diff line number Diff line change
Expand Up @@ -666,11 +666,38 @@ fm_backend_composer_state() { # <backend> <target> -> empty|pending|unknown
# Mirrors fm-crew-state.sh's pane_readable check; exists here as one shared
# primitive so callers that only need a fast alive/dead read (recovery
# digests, the session-start fleet digest) do not re-derive it inline.
#
# Answering-for-the-wrong-target survey (2026-08-05, incident
# fm-liveness-probe-target-fallback): the hazard is tmux-specific, and the other
# four adapters were read for the same pattern rather than assumed clean.
# tmux - WAS affected. `display-message` answers for another window instead
# of refusing, so this branch reported a non-existent window as
# present; it now dispatches through fm_backend_tmux_target_exists,
# which resolves first (docs/tmux-backend.md "Target resolution").
# herdr - not affected, and the model to copy: fm_backend_herdr_pane_agent_state
# round-trips the echoed pane_id and reports `dead` only on an explicit
# pane_not_found error code, so an unknown id can never be answered
# for by some other pane.
# zellij - not affected: fm_backend_zellij_target_ready parses the target, then
# checks the session and the pane by ENUMERATION, which is the same
# resolve-then-answer shape the tmux gate now uses.
# orca - not affected: `orca terminal read --terminal <id>` is id-addressed
# and its JSON `ok:false` is handled explicitly.
# cmux - not affected: fm_backend_cmux_target_ready parses the target and
# looks the workspace/surface up by id.
# The common property of the four unaffected backends is that they ask an API
# that errors on an unknown id; tmux was the outlier because its probe command
# substitutes a different target rather than failing.
fm_backend_target_exists() { # <backend> <target> [expected-label]
local backend=$1 target=$2 expected_label=${3:-} session pane
case "$backend" in
tmux)
tmux display-message -p -t "$target" '#{pane_id}' >/dev/null 2>&1
# Dispatched, not inlined: the raw `tmux display-message -p -t "$target"
# '#{pane_id}'` this used to run reported a NON-EXISTENT window as present,
# because display-message answers for another window instead of refusing
# and so returns 0 either way (fm_tmux_resolve_pane, bin/fm-tmux-lib.sh).
fm_backend_source tmux || return 1
fm_backend_tmux_target_exists "$target"
;;
herdr)
fm_backend_source herdr || return 1
Expand Down
11 changes: 10 additions & 1 deletion bin/fm-context-reset.sh
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}"
. "$SCRIPT_DIR/fm-wake-lib.sh"
# shellcheck source=bin/fm-supervisor-target-lib.sh
. "$SCRIPT_DIR/fm-supervisor-target-lib.sh"
# shellcheck source=bin/fm-tmux-lib.sh
. "$SCRIPT_DIR/fm-tmux-lib.sh"

LOG="$STATE/.context-reset.log"
CHECK_ONLY=0
Expand Down Expand Up @@ -206,7 +208,14 @@ BACKEND=$(discover_supervisor_backend) \

PANE=$(discover_supervisor_target) \
|| refuse "could not identify this session's own terminal pane; a reset must never be typed into a pane it cannot identify"
TARGET=$(tmux display-message -p -t "$PANE" '#{session_name}:#{window_index}.#{pane_index}' 2>/dev/null || true)
# Resolve BEFORE reading, or the refusal below cannot fire: `tmux
# display-message` answers for a different pane instead of refusing an
# unresolvable target (fm_tmux_resolve_pane, bin/fm-tmux-lib.sh), so a $PANE
# that no longer names anything would produce a real-looking TARGET belonging to
# some other window - and this script types a reset into whatever TARGET names.
RESOLVED_PANE=$(fm_tmux_resolve_pane "$PANE") \
|| refuse "this session's own terminal pane '$PANE' does not name a live pane"
TARGET=$(tmux display-message -p -t "$RESOLVED_PANE" '#{session_name}:#{window_index}.#{pane_index}' 2>/dev/null || true)
[ -n "$TARGET" ] \
|| refuse "this session's own terminal pane '$PANE' could not be resolved"

Expand Down
6 changes: 5 additions & 1 deletion bin/fm-crew-state.sh
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,11 @@ BACKEND_TARGET=$(fm_backend_target_of_meta "$META")
EXPECTED_LABEL="fm-$ID"
pane_readable() { # <target>
case "$TASK_BACKEND" in
tmux) tmux display-message -p -t "$1" '#{pane_id}' >/dev/null 2>&1 ;;
# Through fm_backend_target_exists, not a raw probe of its own: the inline
# `tmux display-message -p -t "$1" '#{pane_id}'` this replaced returned 0
# for a window that does not exist, because display-message answers for
# another window rather than refusing (fm_tmux_resolve_pane).
tmux) fm_backend_target_exists tmux "$1" ;;
*) fm_backend_capture "$TASK_BACKEND" "$1" 1 "$EXPECTED_LABEL" >/dev/null 2>&1 ;;
esac
}
Expand Down
11 changes: 7 additions & 4 deletions bin/fm-spawn.sh
Original file line number Diff line number Diff line change
Expand Up @@ -1079,10 +1079,13 @@ if [ "$KIND" != secondmate ] && [ "$BACKEND" != orca ]; then
spawn_send_text_line "$WT_TARGET" 'treehouse get'

# Wait for the treehouse subshell: the pane's cwd moves from the project to the worktree.
# Target the stable window id, not the name: if the name is ever lost (e.g. an
# automatic-rename slips through), display-message -t <bad-name> falls back to the
# active client's window, which would misread firstmate's OWN pane path as the
# worktree and tangle a hook into the primary checkout. The window id never lies.
# Target the stable window id, not the name: an automatic rename can lose the
# name, and the window id never lies. The fallback hazard that first motivated
# this - a lost name making the path read answer for firstmate's OWN pane and
# tangle a hook into the primary checkout - is now closed for every caller by
# fm_tmux_resolve_pane (bin/fm-tmux-lib.sh), the gate every tmux read of a
# caller-supplied target passes; docs/tmux-backend.md "Target resolution"
# owns that fact.
# Compare against PROJ_ABS_REAL (physical), not PROJ_ABS: a symlinked project
# prefix would otherwise make the pane's OS-level cwd read differ from
# PROJ_ABS on the very first poll, before the pane has actually moved.
Expand Down
7 changes: 4 additions & 3 deletions bin/fm-supervise-daemon.sh
Original file line number Diff line number Diff line change
Expand Up @@ -1377,9 +1377,10 @@ fm_super_main() {

# --- validate supervisor target at startup (a missing target is a typo) ---
# Dispatches through bin/fm-backend.sh instead of a raw `tmux display-message`
# probe, so a herdr supervisor pane is checked via the herdr adapter; for
# backend=tmux this runs the exact same `tmux display-message -p -t "$TARGET"
# '#{pane_id}'` call as before.
# probe, so a herdr supervisor pane is checked via the herdr adapter, and the
# tmux arm resolves the target before answering rather than accepting
# display-message's answer for some other window (docs/tmux-backend.md
# "Target resolution").
if ! fm_backend_target_exists "$BACKEND" "$TARGET"; then
echo "error: supervisor target '$TARGET' does not resolve to a $BACKEND pane; set FM_SUPERVISOR_TARGET" >&2
log "startup failed: target '$TARGET' not found (backend=$BACKEND)"
Expand Down
3 changes: 2 additions & 1 deletion bin/fm-test-run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,8 @@ family_for_basename() {
fm-operational-input.test.sh|fm-pi-primary-types.test.sh|\
fm-send-popup-settle.test.sh|fm-send-settle.test.sh|fm-stow-contract.test.sh|\
fm-subagent-pretool-check.test.sh|\
fm-supervision-instructions.test.sh|fm-tmux-submit-busy.test.sh|fm-transition-lib.test.sh|\
fm-supervision-instructions.test.sh|fm-tmux-submit-busy.test.sh|\
fm-tmux-target-resolve.test.sh|fm-transition-lib.test.sh|\
fm-test-run.test.sh|fm-test-isolation-proof.test.sh)
printf '%s\n' pure-contract-unit
;;
Expand Down
Loading
Loading