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
18 changes: 15 additions & 3 deletions bin/backends/tmux.sh
Original file line number Diff line number Diff line change
Expand Up @@ -108,13 +108,25 @@ fm_backend_tmux_container_ensure() {
# treehouse cd's into the worktree, which would break name-based targeting.
# The returned window id lets callers target the window even if its name is ever
# lost, so worktree discovery cannot fall back to the active client's window.
fm_backend_tmux_create_task() { # <session> <window-name> <proj-abs> -> prints window id
local ses=$1 wname=$2 proj_abs=$3 wid
#
# The optional <fm-home> seeds FM_HOME into the new window's environment with
# `new-window -e`, which tmux applies BEFORE the window's shell starts. That
# ordering is the whole point of passing it here rather than leaving it to the
# launch command: a login/interactive profile that derives per-vessel values
# from FM_HOME runs once, at shell start, and nothing re-derives them
# afterwards, so a home announced later is a home announced too late. Callers
# that do not care may omit it, and the flag is then not passed at all.
fm_backend_tmux_create_task() { # <session> <window-name> <proj-abs> [<fm-home>] -> prints window id
local ses=$1 wname=$2 proj_abs=$3 fm_home=${4:-} wid
if tmux list-windows -t "$ses" -F '#{window_name}' | grep -qx "$wname"; then
echo "error: window $ses:$wname already exists" >&2
return 1
fi
wid=$(tmux new-window -dP -F '#{window_id}' -t "$ses:" -n "$wname" -c "$proj_abs") || return 1
if [ -n "$fm_home" ]; then
wid=$(tmux new-window -dP -F '#{window_id}' -t "$ses:" -n "$wname" -c "$proj_abs" -e "FM_HOME=$fm_home") || return 1
else
wid=$(tmux new-window -dP -F '#{window_id}' -t "$ses:" -n "$wname" -c "$proj_abs") || return 1
fi
tmux set-window-option -t "$wid" automatic-rename off 2>/dev/null || true
tmux set-window-option -t "$wid" allow-rename off 2>/dev/null || true
printf '%s\n' "$wid"
Expand Down
30 changes: 25 additions & 5 deletions bin/fm-spawn.sh
Original file line number Diff line number Diff line change
Expand Up @@ -851,6 +851,18 @@ fi
# (docs/herdr-backend.md "Known gaps").
PROJ_ABS_REAL=$(cd "$PROJ_ABS" 2>/dev/null && pwd -P) || PROJ_ABS_REAL="$PROJ_ABS"

# The firstmate home this task belongs to: this process's own for a crewmate or
# scout, and PROJ_ABS for a --secondmate spawn, which is the primary launching a
# DIFFERENT home. Resolved here, before the task surface is created, because two
# later steps need it and one of them cannot wait: an operator shell profile that
# derives per-vessel values from FM_HOME runs when the task's shell STARTS, so a
# home named only on the launch command arrives too late to change what that
# profile computed. Backends that can seed a task's environment before its shell
# starts are given it here; the launch command carries it as well, which is what
# every other backend has.
LAUNCH_HOME=$FM_HOME
[ "$KIND" != secondmate ] || LAUNCH_HOME=$PROJ_ABS

real_path_or_raw() { # <path>
local path=$1 real
if real=$(cd "$path" 2>/dev/null && pwd -P); then
Expand Down Expand Up @@ -897,7 +909,7 @@ case "$BACKEND" in
# treehouse cd's into the worktree. WT_TARGET carries that stable id for the
# rename-critical worktree-detection steps below; the persisted window= handle
# stays $T (the name form), which is safe now that rename is disabled.
WID=$(fm_backend_tmux_create_task "$SES" "$W" "$PROJ_ABS") || exit 1
WID=$(fm_backend_tmux_create_task "$SES" "$W" "$PROJ_ABS" "$LAUNCH_HOME") || exit 1
WT_TARGET="$WID"
;;
herdr)
Expand Down Expand Up @@ -1332,9 +1344,7 @@ sq_piext=$(shell_quote "$STATE/$ID.pi-ext.ts")
sq_piturnend=$(shell_quote "$PROJ_ABS/.pi/extensions/fm-primary-turnend-guard.ts")
sq_piwatch=$(shell_quote "$PROJ_ABS/.pi/extensions/fm-primary-pi-watch.ts")
sq_opinput=$(shell_quote "$FM_ROOT/bin/fm-operational-input.sh")
AXI_LAUNCH_HOME=$FM_HOME
[ "$KIND" != secondmate ] || AXI_LAUNCH_HOME=$PROJ_ABS
sq_axi_bin=$(shell_quote "$(fm_axi_bin_dir "$AXI_LAUNCH_HOME")")
sq_axi_bin=$(shell_quote "$(fm_axi_bin_dir "$LAUNCH_HOME")")
PIBRIEFENV=
[ "$HARNESS" != pi ] || PIBRIEFENV="FM_FIRSTMATE_PI_LAUNCH_BRIEF=$sq_brief"
LAUNCH=${LAUNCH//__MODELFLAG__/$MODELFLAG}
Expand All @@ -1348,9 +1358,19 @@ LAUNCH=${LAUNCH//__PITURNEND__/$sq_piturnend}
LAUNCH=${LAUNCH//__PIWATCH__/$sq_piwatch}
LAUNCH=${LAUNCH//__OPINPUT__/$sq_opinput}
LAUNCH=${LAUNCH//__PIBRIEFENV__/$PIBRIEFENV}
# Every firstmate-launched agent names the home that launched it, not only a
# secondmate, so the agent process and everything it runs resolve this home's
# state, findings surface, and fm-send targets rather than falling back to some
# other home. This reaches the agent, not the shell that started before it: a
# profile-derived value is already fixed by then, which is why LAUNCH_HOME is
# also seeded into the task's environment before its shell starts wherever the
# backend can. Only a secondmate additionally clears the operational overrides,
# because those name the primary's own directories.
sq_home=$(shell_quote "$LAUNCH_HOME")
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"
else
LAUNCH="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
Expand Down
2 changes: 2 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,8 @@ When it is unset, most scripts use the repo root as the home; when it is set, sc
When `FM_HOME` is unset, it also behaves as the old whole-root override.
`bin/fm-send.sh` is intentionally stricter than that general fallback: it requires `FM_HOME` to be set before resolving a target, so operator steers cannot silently resolve against the wrong home.
`FM_STATE_OVERRIDE`, `FM_DATA_OVERRIDE`, `FM_PROJECTS_OVERRIDE`, and `FM_CONFIG_OVERRIDE` override individual operational directories for tests and specialized harness setup.
Every task `bin/fm-spawn.sh` launches is told its home explicitly: a crewmate or scout inherits the launching process's own `FM_HOME`, and a `--secondmate` spawn is given the secondmate's own home, so the worker's `fm-send.sh` targets, findings surface, and state resolve against the home that launched it rather than falling back to a repo root or to whatever an operator shell profile defaults to when `FM_HOME` is unset.
That home reaches the agent through the launch command on every backend, and on tmux it is additionally seeded into the task window before its shell starts, which is the only backend offering that seam; [`docs/tmux-backend.md`](tmux-backend.md) owns the measurement, the tmux 3.0 floor it needs, and the limit on the other backends.
For the herdr backend, `FM_HOME` also determines the workspace label used by the adapter.
For the zellij backend, `FM_HOME` does not split containers, but it determines the readable home prefix embedded in visible tab titles; use `FM_ZELLIJ_SESSION` when a separate zellij session is needed.
The full zellij home label also includes a short hash of the resolved `FM_ROOT` path.
Expand Down
33 changes: 33 additions & 0 deletions docs/tmux-backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,39 @@ A comment next to one caller is not enforcement, which is why the rule is now a
`bin/fm-supervise-daemon.sh`'s away-mode status-line flash calls `display-message` without `-p` to display a message rather than to produce a verdict.
Neither was measured to answer wrongly during this work, so neither was changed on assumption.

## A task window is created already knowing its home (`new-window -e`)

`fm_backend_tmux_create_task` takes an optional home and, when given one, passes `-e FM_HOME=<home>` to `new-window`, which tmux applies to the new window's process before its shell starts.
`bin/fm-spawn.sh` passes the home the task belongs to: its own for a crewmate or scout, and the secondmate's home for a `--secondmate` spawn.

The ordering is the entire point, and it is not redundant with the `FM_HOME=` prefix the launch command already carries.
A shell profile that derives per-vessel values from `FM_HOME` runs once, when the task's shell starts, and nothing re-derives them afterwards.
Firstmate sends the launch command *after* that shell exists, so a home carried only there reaches the agent process but arrives too late to change anything the profile already computed - and every later non-interactive shell inherits the profile's stale answer rather than recomputing it.

Measured 2026-08-08 on tmux 3.6 (Linux 6.18.33.2-microsoft-standard-WSL2), against a host profile that derives a per-vessel value from `FM_HOME` and defaults to a different home when it is unset:

```
$ tmux -L fmprobe new-window -d -P -F '#{window_id}' -t p: -n probe \
-e FM_HOME=/home/captain/firstmate-upstream
@1
$ tmux -L fmprobe send-keys -t p:probe 'printf "PROBE seat=%s fm=%s\n" "$BRIDGE_VESSEL" "$FM_HOME" > /tmp/fmprobe.out' Enter
$ cat /tmp/fmprobe.out
PROBE seat=tugboat fm=/home/captain/firstmate-upstream
```

The same day, the negative control that shows why the launch-command prefix alone cannot do this: a shell that starts without `FM_HOME` and is given it afterwards keeps the value its profile already computed, because the profile does not run again.

```
$ FM_HOME=/home/captain/firstmate-upstream bash -c 'echo $BRIDGE_VESSEL'
sc1
$ env -u BRIDGE_VESSEL FM_HOME=/home/captain/firstmate-upstream bash -c 'echo ${BRIDGE_VESSEL:-<unset>}'
<unset>
```

`-e` on `new-window` requires tmux 3.0 or newer.
This is the only backend with such a seam: herdr, zellij, Orca, and cmux create a task through CLIs that accept a label and a working directory and no environment, so on those a worker learns its home from the launch command only, and a profile-derived value in the task's shell is still computed before firstmate can influence it.
Firstmate names only `FM_HOME` here, never any variable a profile derives from it.

## Agent liveness probe

`fm_backend_target_exists` (`bin/fm-backend.sh`) only checks that a window's pane still exists.
Expand Down
79 changes: 73 additions & 6 deletions tests/fm-spawn-dispatch-profile.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,13 @@ case "${1:-}" in
for a in "$@"; do case "$a" in *pane_id*) printf '%%1\n'; exit 0 ;; esac; done
printf 'firstmate\n'; exit 0 ;;
list-windows) exit 0 ;;
has-session|new-session|new-window|kill-window) exit 0 ;;
new-window)
if [ -n "${FM_FAKE_LAUNCH_LOG:-}" ]; then
printf '%s\n' "$*" >> "$FM_FAKE_LAUNCH_LOG.newwindow"
fi
exit 0
;;
has-session|new-session|kill-window) exit 0 ;;
send-keys)
if [ -n "${FM_FAKE_LAUNCH_LOG:-}" ]; then
prev=
Expand Down Expand Up @@ -126,7 +132,7 @@ test_no_profile_keeps_claude_profile_defaults() {
assert_meta_profile "$HOME_DIR/state/$id.meta" claude default default

launch=$(cat "$LAUNCH_LOG")
expected="CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false claude --dangerously-skip-permissions --settings '$WT_DIR/.claude/settings.fm-task.json' \"\$('${ROOT}/bin/fm-operational-input.sh' encode launch-brief < '$HOME_DIR/data/$id/brief.md')\""
expected="FM_HOME='$HOME_DIR' CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false claude --dangerously-skip-permissions --settings '$WT_DIR/.claude/settings.fm-task.json' \"\$('${ROOT}/bin/fm-operational-input.sh' encode launch-brief < '$HOME_DIR/data/$id/brief.md')\""
[ "$launch" = "$expected" ] || fail "no-profile claude launch did not use the canonical launch kind"$'\n'"expected: $expected"$'\n'"actual: $launch"
assert_grep "export PATH='$HOME_DIR/.local/axi/bin':\$PATH" "$LAUNCH_LOG.path" \
"ordinary crew did not receive the owning vessel's AXI bin first"
Expand Down Expand Up @@ -191,6 +197,64 @@ test_secondmate_claude_launch_omits_the_task_overlay() {
pass "a secondmate claude launch omits --settings because no per-task overlay is written"
}

# An ordinary crewmate or scout is launched by a home too, and it must be told
# which one - not only a secondmate. Two distinct places carry it, and the second
# is not a belt-and-braces copy of the first:
#
# 1. the task surface's environment, seeded when the window is CREATED. An
# operator shell profile that derives per-vessel values from FM_HOME runs
# once, when that shell starts, and nothing re-derives them afterwards. A
# home announced after the shell exists is a home announced too late, and
# the profile's own fallback - on a machine with two firstmate homes under
# one OS account, another vessel entirely - is what the worker then acts as.
# 2. the launch command, which is what reaches the agent process on every
# backend, including those whose task-creation call has no environment seam.
#
# The operational overrides stay uncleared: only a secondmate spawn needs that,
# because only there do they name a different home's directories.
test_ordinary_crew_launch_names_its_own_home() {
local rec id out status launch
id=crew-home-z20
rec=$(make_spawn_case crew-home claude "$id")
read_case_record "$rec"

out=$(run_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$LAUNCH_LOG" "$id" "$PROJ_DIR")
status=$?
expect_code 0 "$status" "ordinary crew spawn should succeed"
assert_grep "FM_HOME=$HOME_DIR" "$LAUNCH_LOG.newwindow" \
"the crew window was created without its own home in the environment its shell starts with"
launch=$(cat "$LAUNCH_LOG")
case "$launch" in
"FM_HOME='$HOME_DIR' "*) ;;
*) fail "ordinary crew launch did not name its own firstmate home"$'\n'"actual: $launch" ;;
esac
assert_not_contains "$launch" "FM_STATE_OVERRIDE=" \
"ordinary crew launch cleared the operational overrides a secondmate spawn owns"
pass "an ordinary crew launch names the home that spawned it, before its shell starts and on the launch command"
}

# The same seeding for a secondmate names the SECONDMATE's home, never the
# primary's: it is the primary's own fm-spawn process creating a window for a
# different home, so the process environment it would otherwise pass down is the
# wrong answer at exactly the moment the new shell reads it.
test_secondmate_window_is_created_in_its_own_home() {
local rec id sm out status
id=secondmate-window-home-z21
rec=$(make_spawn_case secondmate-window-home claude "$id")
read_case_record "$rec"
sm="$CASE_DIR/secondmate-home"
make_seeded_secondmate_home "$sm" "$id"

out=$(run_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$LAUNCH_LOG" "$id" "$sm" --secondmate)
status=$?
expect_code 0 "$status" "secondmate spawn should succeed"
assert_grep "FM_HOME=$sm" "$LAUNCH_LOG.newwindow" \
"the secondmate window was not created in the secondmate's own home"
assert_no_grep "FM_HOME=$HOME_DIR" "$LAUNCH_LOG.newwindow" \
"the secondmate window was created carrying the primary's home"
pass "a secondmate window is created in the secondmate's own home, not the primary's"
}

test_active_dispatch_profile_requires_explicit_harness_for_ship() {
local rec id out status
id=profile-required-ship-z11
Expand Down Expand Up @@ -274,7 +338,8 @@ test_active_dispatch_profile_allows_raw_launch_command() {
assert_contains "$out" "spawned $id harness=custom-agent" "spawn did not report raw command harness"
assert_meta_profile "$HOME_DIR/state/$id.meta" custom-agent default default
launch=$(cat "$LAUNCH_LOG")
[ "$launch" = "custom-agent --flag" ] || fail "raw launch command changed"$'\n'"actual: $launch"
[ "$launch" = "FM_HOME='$HOME_DIR' custom-agent --flag" ] \
|| fail "raw launch command changed"$'\n'"actual: $launch"
pass "active crew-dispatch profile allows the raw launch-command escape hatch"
}

Expand All @@ -291,7 +356,7 @@ test_raw_claude_launch_loads_the_task_overlay() {
expect_code 0 "$status" "raw claude launch command should succeed"
[ -f "$overlay" ] || fail "raw claude launch did not write the per-task settings overlay"
launch=$(cat "$LAUNCH_LOG")
[ "$launch" = "FOO=1 claude --settings '$overlay' --dangerously-skip-permissions" ] \
[ "$launch" = "FM_HOME='$HOME_DIR' FOO=1 claude --settings '$overlay' --dangerously-skip-permissions" ] \
|| fail "raw claude launch did not load the per-task settings overlay"$'\n'"actual: $launch"
pass "a raw claude launch command loads the per-task hook overlay it is given"
}
Expand All @@ -311,7 +376,7 @@ test_raw_claude_launch_with_own_settings_writes_no_overlay() {
assert_contains "$out" "turn-end hook was NOT installed" \
"spawn did not warn that the turn-end hook is unarmed"
launch=$(cat "$LAUNCH_LOG")
[ "$launch" = "claude --settings /tmp/captain-settings.json" ] \
[ "$launch" = "FM_HOME='$HOME_DIR' claude --settings /tmp/captain-settings.json" ] \
|| fail "raw launch command changed"$'\n'"actual: $launch"
pass "a raw claude command carrying its own --settings warns instead of writing a dead hook"
}
Expand All @@ -331,7 +396,7 @@ test_raw_claude_shaped_wrapper_gets_no_settings_flag() {
assert_contains "$out" "turn-end hook was NOT installed" \
"spawn did not warn that the wrapper's turn-end hook is unarmed"
launch=$(cat "$LAUNCH_LOG")
[ "$launch" = "claude-yolo --dangerously-skip-permissions" ] \
[ "$launch" = "FM_HOME='$HOME_DIR' claude-yolo --dangerously-skip-permissions" ] \
|| fail "spawn spliced flags into a claude-shaped wrapper command"$'\n'"actual: $launch"
pass "a claude-shaped wrapper keeps its own argv and degrades with a warning"
}
Expand Down Expand Up @@ -556,6 +621,8 @@ test_active_dispatch_profile_does_not_block_secondmate_launch() {
test_no_profile_keeps_claude_profile_defaults
test_claude_hook_preserves_repo_local_settings
test_secondmate_claude_launch_omits_the_task_overlay
test_ordinary_crew_launch_names_its_own_home
test_secondmate_window_is_created_in_its_own_home
test_active_dispatch_profile_requires_explicit_harness_for_ship
test_active_dispatch_profile_requires_explicit_harness_for_scout
test_active_dispatch_profile_allows_explicit_harness
Expand Down
Loading