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
32 changes: 30 additions & 2 deletions bin/fm-spawn.sh
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@
# $vars and silently breaks ad-hoc `for ... in $pairs` loops).
# Launch templates live in launch_template() below; placeholders replaced before launch:
# __BRIEF__ absolute path to data/<task-id>/brief.md
# __CLAUDECONFIGDIR__ optional `CLAUDE_CONFIG_DIR=<dir> ` prefix from config/crew-config-dir
# (claude only; absent knob = bare `claude`, default config dir; see #599)
# __TURNEND__ absolute path to state/<task-id>.turn-ended (for harnesses whose
# turn-end signal rides the launch command, e.g. codex -c notify=[...])
# __PIEXT__ absolute path to state/<task-id>.pi-ext.ts (pi turn-end extension,
Expand All @@ -84,7 +86,7 @@ set -eu
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"

usage() {
sed -n '2,78p' "$0" | sed 's/^# \{0,1\}//'
sed -n '2,80p' "$0" | sed 's/^# \{0,1\}//'
}

case "${1:-}" in
Expand Down Expand Up @@ -321,7 +323,11 @@ launch_template() {
# does NOT suppress the interactive ghost text (verified empirically), so the env
# var is the correct control. The dim-aware composer reader in fm-tmux-lib.sh is
# the defense-in-depth backstop for any pane this flag cannot reach.
claude) printf '%s' 'CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false claude --dangerously-skip-permissions __MODELFLAG____EFFORTFLAG__"$(cat __BRIEF__)"' ;;
# __CLAUDECONFIGDIR__ expands to a `CLAUDE_CONFIG_DIR=<dir> ` env prefix when
# config/crew-config-dir names an authenticated config dir, or to nothing when
# the knob is absent (bare `claude`, default config dir). See claude_config_dir_prefix
# below and #599 (hands-free spawn on a multi-account machine).
claude) printf '%s' 'CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false __CLAUDECONFIGDIR__claude --dangerously-skip-permissions __MODELFLAG____EFFORTFLAG__"$(cat __BRIEF__)"' ;;
codex)
if [ "$kind" = secondmate ]; then
printf '%s' 'codex __MODELFLAG____EFFORTFLAG__--dangerously-bypass-approvals-and-sandbox "$(cat __BRIEF__)"'
Expand Down Expand Up @@ -477,6 +483,26 @@ effort_flag_for_harness() {
esac
}

# The optional CLAUDE_CONFIG_DIR launch prefix for a claude crewmate (#599).
# config/crew-config-dir is a local, gitignored file naming the config dir the
# crewmate authenticates with. On a multi-account machine the default ~/.claude
# can be empty/unauthenticated, stranding a bare-`claude` crewmate on the login
# wall; pointing it at an authenticated dir makes the spawn hands-free. The
# trimmed value becomes a `CLAUDE_CONFIG_DIR=<dir> ` env prefix scoped to this
# firstmate-launched agent, so it never mutates the captain's global config.
# Absent, empty, or a non-claude harness -> nothing, i.e. today's bare-`claude`,
# default-config-dir behavior. Only the claude template carries the placeholder.
claude_config_dir_prefix() {
local harness=$1 dir=
[ "$harness" = claude ] || return 0
[ -f "$CONFIG/crew-config-dir" ] || return 0
dir=$(head -n 1 "$CONFIG/crew-config-dir")
dir="${dir#"${dir%%[![:space:]]*}"}" # trim leading whitespace
dir="${dir%"${dir##*[![:space:]]}"}" # trim trailing whitespace
[ -n "$dir" ] || return 0
printf 'CLAUDE_CONFIG_DIR=%s ' "$(shell_quote "$dir")"
}

json_escape() {
printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g'
}
Expand Down Expand Up @@ -1038,8 +1064,10 @@ 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")
MODELFLAG=$(model_flag_for_harness "$HARNESS" "$MODEL")
EFFORTFLAG=$(effort_flag_for_harness "$HARNESS" "$EFFORT")
CLAUDECONFIGDIR=$(claude_config_dir_prefix "$HARNESS")
LAUNCH=${LAUNCH//__MODELFLAG__/$MODELFLAG}
LAUNCH=${LAUNCH//__EFFORTFLAG__/$EFFORTFLAG}
LAUNCH=${LAUNCH//__CLAUDECONFIGDIR__/$CLAUDECONFIGDIR}
LAUNCH=${LAUNCH//__BRIEF__/$sq_brief}
LAUNCH=${LAUNCH//__TURNEND__/$sq_turnend}
LAUNCH=${LAUNCH//__PIEXT__/$sq_piext}
Expand Down
8 changes: 8 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,14 @@ The primary propagates `config/crew-dispatch.json`, `config/crew-harness`, and `
For grok, `fm-spawn.sh` installs one firstmate-owned global turn-end hook under `$GROK_HOME/hooks/`, or `~/.grok/hooks/` when `GROK_HOME` is unset, and drops a per-task `.fm-grok-turnend` pointer in the worktree, with teardown removing the task token and pointer.
For Pi secondmate launches, `fm-spawn.sh` starts Pi with `-e` pointed at the secondmate home's own tracked `.pi/extensions/fm-primary-pi-watch.ts` and `.pi/extensions/fm-primary-turnend-guard.ts`, both already present from the secondmate home's git worktree.

### Claude config dir (config/crew-config-dir)

`config/crew-config-dir` is a local, gitignored file naming the Claude config dir (`CLAUDE_CONFIG_DIR`) a spawned `claude` crewmate authenticates with.
It exists for multi-account machines, where the default `~/.claude` can be empty or unauthenticated so a bare-`claude` crewmate lands on the login wall and never starts.
The first line's trimmed value becomes a per-launch `CLAUDE_CONFIG_DIR=<dir>` env prefix on the claude launch command only, scoped to that firstmate-launched agent, so it never mutates the captain's global config.
It applies to the `claude` harness only; other harnesses ignore it.
When the file is absent or empty, launch behavior is byte-identical to before: bare `claude` on its default config dir.

## Crew dispatch profiles (config/crew-dispatch.json)

`config/crew-dispatch.json` is an optional local, gitignored file containing natural-language rules that firstmate reads before dispatching a crewmate or scout.
Expand Down
128 changes: 128 additions & 0 deletions tests/fm-spawn-claude-config-dir.test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
#!/usr/bin/env bash
# Behavior tests for the claude crewmate CLAUDE_CONFIG_DIR launch knob (#599).
#
# On a multi-account machine the default ~/.claude can be empty/unauthenticated,
# so a bare-`claude` crewmate lands on the login wall and never starts. The local,
# gitignored config/crew-config-dir file names the config dir a claude crewmate
# should authenticate with; when set, fm-spawn prefixes the claude launch command
# with `CLAUDE_CONFIG_DIR=<dir> `. These tests drive a real claude ship spawn
# through fm-spawn.sh with a fake tmux that records the literal launch string, and
# pin both directions:
# 1. config/crew-config-dir set -> the launch gains the CLAUDE_CONFIG_DIR prefix.
# 2. config/crew-config-dir absent -> NO prefix, i.e. byte-identical prior
# behavior (bare `claude`, default config dir).
set -u

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

SPAWN="$ROOT/bin/fm-spawn.sh"
TMP_ROOT=$(fm_test_tmproot fm-spawn-ccd)

# A fake tmux that satisfies fm-spawn's tmux backend and records the literal
# launch string. The launch command is the only send-keys call that uses `-l`
# (fm_backend_tmux_send_literal); the `treehouse get` and GOTMPDIR sends use the
# text-line form (trailing Enter, no -l) and the final Enter uses send_key, so
# capturing the argument after `-l` isolates the launch string. The pane-path
# query returns FM_FAKE_PANE_PATH so worktree discovery resolves immediately.
make_spawn_fakebin() {
local dir=$1 fakebin
fakebin=$(fm_fakebin "$dir")
cat > "$fakebin/tmux" <<'SH'
#!/usr/bin/env bash
set -u
case "$*" in
*"#{pane_current_path}"*) printf '%s\n' "${FM_FAKE_PANE_PATH:-}"; exit 0 ;;
esac
case "${1:-}" in
display-message) printf 'firstmate\n'; exit 0 ;;
new-window) printf '@1\n'; exit 0 ;;
list-windows|has-session|new-session|set-window-option|kill-window) exit 0 ;;
send-keys)
prev=
for a in "$@"; do
[ "$prev" = "-l" ] && printf '%s' "$a" > "${FM_SEND_LOG:?}"
prev=$a
done
exit 0 ;;
esac
exit 0
SH
chmod +x "$fakebin/tmux"
fm_fake_exit0 "$fakebin" treehouse gh-axi gh
printf '%s\n' "$fakebin"
}

# Build one isolated case (home + project + worktree + fakebin), returning its
# fields. Each case gets a fresh id so state/data never collide across cases.
make_spawn_case() {
local name=$1 case_dir home proj wt fakebin id
case_dir="$TMP_ROOT/$name"
home="$case_dir/home"
proj="$case_dir/project"
wt="$case_dir/wt"
fakebin=$(make_spawn_fakebin "$case_dir/fake")
id="ccd-$name-x1"
mkdir -p "$home/data/$id" "$home/projects" "$home/state" "$home/config"
printf 'brief\n' > "$home/data/$id/brief.md"
fm_git_worktree "$proj" "$wt" "fm/$id"
touch "$home/state/.last-watcher-beat"
printf '%s\n' "$case_dir|$home|$proj|$wt|$fakebin|$id"
}

# Drive a real claude ship spawn and echo the recorded launch string.
run_claude_spawn() {
local home=$1 proj=$2 wt=$3 fakebin=$4 id=$5 send_log=$6 out
out=$(FM_ROOT_OVERRIDE='' FM_HOME="$home" \
FM_STATE_OVERRIDE="$home/state" FM_DATA_OVERRIDE="$home/data" \
FM_PROJECTS_OVERRIDE="$home/projects" FM_CONFIG_OVERRIDE="$home/config" \
FM_SPAWN_NO_GUARD=1 FM_FAKE_PANE_PATH="$wt" TMUX="fake,1,0" \
FM_SEND_LOG="$send_log" PATH="$fakebin:$PATH" \
"$SPAWN" "$id" "$proj" claude 2>&1) || { echo "SPAWN-FAILED: $out" >&2; return 1; }
cat "$send_log"
}

test_config_dir_adds_prefix() {
local rec case_dir home proj wt fakebin id send_log ccd launch
rec=$(make_spawn_case with-knob)
IFS='|' read -r case_dir home proj wt fakebin id <<EOF
$rec
EOF
ccd="$case_dir/alt-claude-home"
printf '%s\n' "$ccd" > "$home/config/crew-config-dir"
send_log="$case_dir/launch.log"

launch=$(run_claude_spawn "$home" "$proj" "$wt" "$fakebin" "$id" "$send_log") \
|| fail "claude spawn with config/crew-config-dir failed"

# The prefix must sit between the ghost-text env var and the claude verb, with
# the config dir shell-quoted (single quotes, no special chars in the path).
case "$launch" in
"CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false CLAUDE_CONFIG_DIR='$ccd' claude --dangerously-skip-permissions "*) : ;;
*) fail "launch missing CLAUDE_CONFIG_DIR prefix"$'\n'"--- launch ---"$'\n'"$launch" ;;
esac
pass "config/crew-config-dir prefixes the claude launch with CLAUDE_CONFIG_DIR"
}

test_absent_knob_is_backward_compatible() {
local rec case_dir home proj wt fakebin id send_log launch
rec=$(make_spawn_case no-knob)
IFS='|' read -r case_dir home proj wt fakebin id <<EOF
$rec
EOF
# No config/crew-config-dir file: the launch must be identical to prior behavior.
send_log="$case_dir/launch.log"

launch=$(run_claude_spawn "$home" "$proj" "$wt" "$fakebin" "$id" "$send_log") \
|| fail "claude spawn without config/crew-config-dir failed"

assert_not_contains "$launch" "CLAUDE_CONFIG_DIR" "absent knob must not add a CLAUDE_CONFIG_DIR prefix"
case "$launch" in
"CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false claude --dangerously-skip-permissions "*) : ;;
*) fail "absent knob changed the bare-claude launch prefix"$'\n'"--- launch ---"$'\n'"$launch" ;;
esac
pass "absent config/crew-config-dir keeps the bare-claude launch byte-identical"
}

test_config_dir_adds_prefix
test_absent_knob_is_backward_compatible
Loading