diff --git a/bin/backends/tmux.sh b/bin/backends/tmux.sh index 31c259fa54..2ff0f1ddb9 100644 --- a/bin/backends/tmux.sh +++ b/bin/backends/tmux.sh @@ -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() { # -> prints window id - local ses=$1 wname=$2 proj_abs=$3 wid +# +# The optional 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() { # [] -> 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" diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index 06f32c53c3..e09ae7c846 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -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() { # local path=$1 real if real=$(cd "$path" 2>/dev/null && pwd -P); then @@ -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) @@ -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} @@ -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 diff --git a/docs/configuration.md b/docs/configuration.md index 6d3da0a095..d5a60e7b3e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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. diff --git a/docs/tmux-backend.md b/docs/tmux-backend.md index 45d62417c8..72a7b14207 100644 --- a/docs/tmux-backend.md +++ b/docs/tmux-backend.md @@ -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=` 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:-}' + +``` + +`-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. diff --git a/tests/fm-spawn-dispatch-profile.test.sh b/tests/fm-spawn-dispatch-profile.test.sh index 70f970868b..effa1afb7a 100755 --- a/tests/fm-spawn-dispatch-profile.test.sh +++ b/tests/fm-spawn-dispatch-profile.test.sh @@ -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= @@ -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" @@ -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 @@ -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" } @@ -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" } @@ -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" } @@ -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" } @@ -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