From 2c93585b23377ed36c47366a3679b40fdfc42266 Mon Sep 17 00:00:00 2001 From: Shane Bracewell Date: Sun, 26 Jul 2026 10:34:13 -0400 Subject: [PATCH 01/44] refactor(bin): give launch knowledge one owner in fm-launch-lib.sh A fleet launcher will soon open PRIMARY firstmate sessions alongside the crewmate sessions fm-spawn.sh opens, so both need the same verified launch commands. Today that knowledge lives only inside bin/fm-spawn.sh, and the drift a second copy causes is not hypothetical: a downstream registry hand-copied claude's command as `claude --dangerously-skip-permissions`, dropping CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false - the ghost-text suppression that keeps firstmate from reading predicted-prompt text as real typed input when it captures a pane. Extract launch_template, model_flag_for_harness, and effort_flag_for_harness (plus the shell_quote both flag resolvers depend on) into a new sourced bin/fm-launch-lib.sh, and have fm-spawn.sh source it. Every crewmate, scout, and secondmate template is byte-identical to before, so spawn behavior is unchanged on all six verified adapters. launch_template also gains a `primary` kind for the launcher. A primary session has no task, no worktree, no brief, and no status file, so it launches bare and is greeted by the session-start adapters already installed in the home; each primary template keeps its adapter's verified autonomy flag and claude's ghost-text prefix. An unrecognized kind still resolves to the crewmate shape, and an unverified adapter still returns non-zero for every kind. tests/fm-launch-lib.test.sh pins both arms directly, including a proof that fm-spawn.sh redefines none of the functions and that no other script under bin/ hand-writes a launch command. Existing suites that read the template bytes now read them from their new owner. --- .agents/skills/harness-adapters/SKILL.md | 5 +- bin/fm-launch-lib.sh | 169 +++++++++++++++ bin/fm-spawn.sh | 122 ++--------- bin/fm-test-run.sh | 2 +- tests/fm-launch-lib.test.sh | 259 +++++++++++++++++++++++ 5 files changed, 444 insertions(+), 113 deletions(-) create mode 100644 bin/fm-launch-lib.sh create mode 100755 tests/fm-launch-lib.test.sh diff --git a/.agents/skills/harness-adapters/SKILL.md b/.agents/skills/harness-adapters/SKILL.md index 093b3f689c7..b0de89397b7 100644 --- a/.agents/skills/harness-adapters/SKILL.md +++ b/.agents/skills/harness-adapters/SKILL.md @@ -26,7 +26,8 @@ If `config/crew-harness` is unset or `default`, there is no concrete value to in Inheritance also copies the literal `config/crew-dispatch.json` file, so secondmates apply the same best-fit profile rules for their own crewmates. Each adapter splits into mechanics and knowledge. -The per-task mechanics, including launch command, autonomy flag, and any enabled crewmate turn-end hook, live in `bin/fm-spawn.sh`. +The per-task mechanics, including the autonomy flag and any enabled crewmate turn-end hook, live in `bin/fm-spawn.sh`. +`bin/fm-launch-lib.sh` is the single owner of every verified launch command, for crewmate, scout, secondmate, and primary sessions alike; never hand-write one. The primary-session "no turn ends blind" guard contract and harness hook installation paths live in `docs/turnend-guard.md`. The primary-session watcher wake protocols are rendered from `docs/supervision-protocols/` by `bin/fm-supervision-instructions.sh`. The supervision knowledge lives here: busy state, exit command, interrupt, dialogs, resume behavior, skill invocation, and quirks. @@ -184,7 +185,7 @@ If such a dialog is showing, accept it from an active firstmate session using `F Claude renders a predicted-next-prompt suggestion as dim/faint text inside an otherwise-empty composer after a turn completes. A plain `tmux capture-pane` cannot tell that ghost text apart from typed text. -Firstmate launches every claude crewmate and secondmate with `CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false`, scoped to firstmate-launched agents through `bin/fm-spawn.sh`, so it never touches the captain's global config. +Firstmate launches every claude crewmate and secondmate with `CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false`, scoped to firstmate-launched agents through the launch templates in `bin/fm-launch-lib.sh`, so it never touches the captain's global config. The CLI's `--prompt-suggestions` flag is print/SDK-mode only and does not suppress the interactive composer ghost text, verified empirically on v2.1.186. As defense in depth for any pane that flag cannot reach, including the captain's own firstmate composer that away-mode reads, the shared `fm_composer_strip_ghost` extractor in `bin/fm-composer-lib.sh` removes dim/faint SGR 2 ghost runs before pending-input classification on both ANSI-capable readers (tmux and herdr). Its broader dark-TRUECOLOR placeholder handling and dark-theme tradeoff are documented in `docs/herdr-backend.md` "Composer and injection safety", with active captures in `docs/verification/runtime-backends.md`. diff --git a/bin/fm-launch-lib.sh b/bin/fm-launch-lib.sh new file mode 100644 index 00000000000..47d5d118af7 --- /dev/null +++ b/bin/fm-launch-lib.sh @@ -0,0 +1,169 @@ +#!/usr/bin/env bash +# fm-launch-lib.sh - the single owner of firstmate's verified launch commands. +# +# Every firstmate-launched agent session composes its command from exactly these +# three functions. There is no second copy anywhere, and a caller must never +# hand-write a launch string: the drift that causes is not hypothetical. A +# downstream registry once hand-copied claude's command as +# `claude --dangerously-skip-permissions`, dropping the ghost-text suppression +# variable documented in launch_template() below - the exact omission that makes +# firstmate read predicted-prompt text as real typed input when it captures a +# pane. One owner, or that happens again. +# +# Sourced by bin/fm-spawn.sh (crewmate, scout, and secondmate sessions). +# +# launch_template [] the verified launch command, with +# placeholders the caller substitutes +# model_flag_for_harness resolved --model flag, or empty +# effort_flag_for_harness resolved effort flag, or empty +# +# The knowledge half of each adapter (busy-state source, exit command, dialogs, +# quirks) lives in the harness-adapters skill, not here. +# +# shell_quote lives here because both flag resolvers depend on it; sourcing this +# library is what makes it available to bin/fm-spawn.sh. + +shell_quote() { + printf "'" + printf '%s' "$1" | sed "s/'/'\\\\''/g" + printf "'" +} + +# The verified launch command per adapter, as a template. Returns 1 for a +# harness with no verified adapter - that non-zero return is the unverified- +# adapter guard every caller relies on, so never add a permissive default arm. +# +# kind selects the session shape: +# ship|scout a crewmate working one task in an isolated worktree +# secondmate a firstmate PRIMARY launched in a provisioned secondmate home +# primary a firstmate PRIMARY launched in this home by the fleet launcher +# +# ship, scout, and secondmate all receive a launch brief, so their templates end +# in the encoded brief argument. A primary has no task, no worktree, no brief, +# and no status file, so it launches bare and is greeted by the session-start +# adapters already installed in the home (for pi and opencode those are the +# project-local extensions the harness auto-discovers once trusted, which is why +# a primary needs no explicit extension flag). A primary template therefore ends +# at its flag placeholders, and an unset flag leaves one trailing space; that is +# cosmetic in a shell command, and consumers may trim it. +# +# Placeholders the caller substitutes before launch are documented in +# bin/fm-spawn.sh's header. +launch_template() { + local harness=$1 kind=${2:-ship} + # shellcheck disable=SC2016 # single quotes are deliberate: $(cat ...) expands in the crewmate pane, not here + case "$kind" in + primary) + case "$harness" in + claude) printf '%s' 'CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false claude --dangerously-skip-permissions __MODELFLAG____EFFORTFLAG__' ;; + codex) printf '%s' 'codex __MODELFLAG____EFFORTFLAG__--dangerously-bypass-approvals-and-sandbox' ;; + opencode) printf '%s' 'OPENCODE_CONFIG_CONTENT='\''{"permission":{"*":"allow"}}'\'' opencode __MODELFLAG__' ;; + pi|pi-signed) printf '%s%s' "FM_PI_HARNESS=$harness $harness" ' __MODELFLAG____EFFORTFLAG__' ;; + grok) printf '%s' 'grok --always-approve __MODELFLAG____EFFORTFLAG__' ;; + # Kimi Code rejects a positional prompt, so its crewmate template + # already launches bare; the primary shape is identical. + kimi) printf '%s' '__KIMIBIN__ __MODELFLAG__--auto' ;; + *) return 1 ;; + esac + return 0 + ;; + esac + # shellcheck disable=SC2016 # single quotes are deliberate: $(cat ...) expands in the crewmate pane, not here + case "$harness" in + # CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false disables claude's interactive + # predicted-next-prompt ghost text, which renders as dim/faint text inside an + # otherwise-empty composer and would otherwise read like real typed input when + # firstmate captures the pane (see the harness-adapters skill). It is a per-launch env + # prefix scoped to this firstmate-launched agent; it never touches the captain's + # global config. The CLI's --prompt-suggestions flag is print/SDK-mode only and + # 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__"$(__OPINPUT__ encode launch-brief < __BRIEF__)"' ;; + codex) + if [ "$kind" = secondmate ]; then + printf '%s' 'codex __MODELFLAG____EFFORTFLAG__--dangerously-bypass-approvals-and-sandbox "$(__OPINPUT__ encode launch-brief < __BRIEF__)"' + else + printf '%s' 'codex __MODELFLAG____EFFORTFLAG__--dangerously-bypass-approvals-and-sandbox -c "notify=[\"bash\",\"-c\",\"touch __TURNEND__\"]" "$(__OPINPUT__ encode launch-brief < __BRIEF__)"' + fi + ;; + opencode) printf '%s' 'OPENCODE_CONFIG_CONTENT='\''{"permission":{"*":"allow"}}'\'' opencode __MODELFLAG__--prompt "$(__OPINPUT__ encode launch-brief < __BRIEF__)"' ;; + # pi-signed is a distinct executable identity that shares pi's verified flag + # surface, never an alias: the selected $harness is both the invoked binary + # and the FM_PI_HARNESS identity marker, so a signed primary's environment + # cannot relabel a plain Pi worker (or vice versa). The marker is part of + # the verified command, so it lives here, not in any caller. + pi|pi-signed) + if [ "$kind" = secondmate ]; then + printf '%s%s' "FM_PI_HARNESS=$harness $harness" ' __MODELFLAG____EFFORTFLAG__-e __PITURNEND__ -e __PIWATCH__ "$(__OPINPUT__ encode launch-brief < __BRIEF__)"' + else + printf '%s%s' "FM_PI_HARNESS=$harness $harness" ' __MODELFLAG____EFFORTFLAG__-e __PIEXT__ "$(__OPINPUT__ encode launch-brief < __BRIEF__)"' + fi + ;; + # grok (Grok Build TUI): a positional prompt starts the supervised interactive + # session. --always-approve auto-approves every tool execution (verified: the + # crewmate runs fully autonomously, no permission gate), which an unattended + # crewmate needs; it is the targeted equivalent of claude's + # --dangerously-skip-permissions. grok's turn-end signal does NOT ride the + # launch command - it is a Stop-event hook installed by fm-spawn.sh (global hook + + # per-task pointer), so the template is identical for ship/scout/secondmate. + grok) printf '%s' 'grok --always-approve __MODELFLAG____EFFORTFLAG__"$(__OPINPUT__ encode launch-brief < __BRIEF__)"' ;; + # Kimi Code rejects a positional prompt, so it launches bare and receives + # only an absolute brief pointer after fm-spawn.sh's TUI readiness gate. + # Its turn-end signal is a globally configured Stop hook plus a guarded + # per-task worktree token, so no launch placeholder belongs here. + kimi) printf '%s' '__KIMIBIN__ __MODELFLAG__--auto' ;; + *) return 1 ;; + esac +} + +model_flag_for_harness() { + local harness=$1 model=$2 + [ -n "$model" ] && [ "$model" != default ] || return 0 + case "$harness" in + claude|codex|opencode|pi|pi-signed|grok|kimi) + printf -- '--model %s ' "$(shell_quote "$model")" + ;; + esac +} + +effort_flag_for_harness() { + local harness=$1 effort=$2 + [ -n "$effort" ] && [ "$effort" != default ] || return 0 + case "$harness" in + claude) + case "$effort" in + low|medium|high|xhigh|max) printf -- '--effort %s ' "$(shell_quote "$effort")" ;; + esac + ;; + codex) + # The installed codex config schema uses model_reasoning_effort, and the + # bundled model catalog advertises low|medium|high|xhigh. Omit max rather + # than passing an unsupported value. + case "$effort" in + low|medium|high|xhigh) printf -- '-c %s ' "$(shell_quote "model_reasoning_effort=\"$effort\"")" ;; + esac + ;; + grok) + # grok exposes both --effort and --reasoning-effort; firstmate's profile + # axis is the reasoning knob. As of grok 0.2.99, --reasoning-effort accepts + # only low|medium|high and rejects both xhigh and max, so omit those rather + # than passing a known-bad value. + case "$effort" in + low|medium|high) printf -- '--reasoning-effort %s ' "$(shell_quote "$effort")" ;; + esac + ;; + pi|pi-signed) + # Pi 0.80.6 accepts the full shared effort vocabulary, including max, through + # its --thinking flag. + case "$effort" in + low|medium|high|xhigh|max) printf -- '--thinking %s ' "$(shell_quote "$effort")" ;; + esac + ;; + # opencode's interactive `opencode --prompt` launch has a verified --model + # flag but no verified effort flag. Its `opencode run --variant` flag belongs + # to a different, non-interactive launch mode, so fm-spawn does not pass it. + # kimi likewise has no reasoning-effort flag; the requested axis stays in + # task metadata but never reaches the launch command. + esac +} diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index ec485fd2415..9a71ae723e8 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -102,7 +102,8 @@ # and scout batches. The loop lives here, in bash, so callers never hand-write a # multi-task shell loop (the tool shell is zsh, which does not word-split unquoted # $vars and silently breaks ad-hoc `for ... in $pairs` loops). -# Launch templates live in launch_template() below; placeholders replaced before launch: +# Launch templates live in launch_template() in bin/fm-launch-lib.sh, the single owner +# of every firstmate launch command; placeholders replaced before launch: # __BRIEF__ absolute path to data//brief.md # __TURNEND__ absolute path to state/.turn-ended (for harnesses whose # turn-end signal rides the launch command, e.g. codex -c notify=[...]) @@ -161,6 +162,8 @@ DATA="${FM_DATA_OVERRIDE:-$FM_HOME/data}" PROJECTS="${FM_PROJECTS_OVERRIDE:-$FM_HOME/projects}" CONFIG="${FM_CONFIG_OVERRIDE:-$FM_HOME/config}" SUB_HOME_MARKER=".fm-secondmate-home" +# shellcheck source=bin/fm-launch-lib.sh +. "$SCRIPT_DIR/fm-launch-lib.sh" # shellcheck source=bin/fm-ff-lib.sh . "$SCRIPT_DIR/fm-ff-lib.sh" # shellcheck source=bin/fm-wake-lib.sh @@ -445,54 +448,6 @@ else fi [ -z "$HARNESS_ARG" ] || ARG3=$HARNESS_ARG -# The verified launch command per adapter. The knowledge half of each adapter -# (busy-state source, exit command, dialogs, quirks) lives in the harness-adapters skill. -launch_template() { - local harness=$1 kind=${2:-ship} - # shellcheck disable=SC2016 # single quotes are deliberate: $(cat ...) expands in the crewmate pane, not here - case "$harness" in - # CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false disables claude's interactive - # predicted-next-prompt ghost text, which renders as dim/faint text inside an - # otherwise-empty composer and would otherwise read like real typed input when - # firstmate captures the pane (see the harness-adapters skill). It is a per-launch env - # prefix scoped to this firstmate-launched agent; it never touches the captain's - # global config. The CLI's --prompt-suggestions flag is print/SDK-mode only and - # 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__"$(__OPINPUT__ encode launch-brief < __BRIEF__)"' ;; - codex) - if [ "$kind" = secondmate ]; then - printf '%s' 'codex __MODELFLAG____EFFORTFLAG__--dangerously-bypass-approvals-and-sandbox "$(__OPINPUT__ encode launch-brief < __BRIEF__)"' - else - printf '%s' 'codex __MODELFLAG____EFFORTFLAG__--dangerously-bypass-approvals-and-sandbox -c "notify=[\"bash\",\"-c\",\"touch __TURNEND__\"]" "$(__OPINPUT__ encode launch-brief < __BRIEF__)"' - fi - ;; - opencode) printf '%s' 'OPENCODE_CONFIG_CONTENT='\''{"permission":{"*":"allow"}}'\'' opencode __MODELFLAG__--prompt "$(__OPINPUT__ encode launch-brief < __BRIEF__)"' ;; - pi|pi-signed) - if [ "$kind" = secondmate ]; then - printf '%s%s' "$harness" ' __MODELFLAG____EFFORTFLAG__-e __PITURNEND__ -e __PIWATCH__ "$(__OPINPUT__ encode launch-brief < __BRIEF__)"' - else - printf '%s%s' "$harness" ' __MODELFLAG____EFFORTFLAG__-e __PIEXT__ "$(__OPINPUT__ encode launch-brief < __BRIEF__)"' - fi - ;; - # grok (Grok Build TUI): a positional prompt starts the supervised interactive - # session. --always-approve auto-approves every tool execution (verified: the - # crewmate runs fully autonomously, no permission gate), which an unattended - # crewmate needs; it is the targeted equivalent of claude's - # --dangerously-skip-permissions. grok's turn-end signal does NOT ride the - # launch command - it is a Stop-event hook installed below (global hook + - # per-task pointer), so the template is identical for ship/scout/secondmate. - grok) printf '%s' 'grok --always-approve __MODELFLAG____EFFORTFLAG__"$(__OPINPUT__ encode launch-brief < __BRIEF__)"' ;; - # Kimi Code rejects a positional prompt, so it launches bare and receives - # only an absolute brief pointer after the TUI readiness gate below. - # Its turn-end signal is a globally configured Stop hook plus a guarded - # per-task worktree token, so no launch placeholder belongs here. - kimi) printf '%s' '__KIMIBIN__ __MODELFLAG__--auto' ;; - *) return 1 ;; - esac -} - case "$ARG3" in *' '*) # raw launch command (unverified-adapter escape hatch) LAUNCH=$ARG3 @@ -500,6 +455,14 @@ case "$ARG3" in for word in $LAUNCH; do case "$word" in [A-Za-z_]*=*) continue ;; *) HARNESS=$(basename "$word"); break ;; esac done + # The verified pi/pi-signed templates carry the FM_PI_HARNESS identity + # marker inside launch_template (bin/fm-launch-lib.sh). A raw command + # bypasses the library, so re-apply the marker here: even a raw Pi-family + # launch must declare its identity, or a signed primary's environment + # could relabel a plain Pi worker (docs/configuration.md). + case "$HARNESS" in + pi|pi-signed) LAUNCH="FM_PI_HARNESS=$HARNESS $LAUNCH" ;; + esac ;; '') # No explicit harness: resolve from config. A secondmate AGENT launches on the @@ -529,10 +492,6 @@ case "$ARG3" in ;; esac -case "$HARNESS" in - pi|pi-signed) LAUNCH="FM_PI_HARNESS=$HARNESS $LAUNCH" ;; -esac - # pi-signed is an explicitly selected executable identity, not an alias that may # silently fall back to pi. Resolve it from PATH before creating an endpoint and # retain the literal name in the launch command and task metadata. @@ -578,12 +537,6 @@ secondmate_registry_value() { printf '%s\n' "$value" } -shell_quote() { - printf "'" - printf '%s' "$1" | sed "s/'/'\\\\''/g" - printf "'" -} - resolve_kimi_binary() { local candidate dir fallback candidate=$(command -v kimi 2>/dev/null || true) @@ -608,57 +561,6 @@ resolve_kimi_binary() { return 1 } -model_flag_for_harness() { - local harness=$1 model=$2 - [ -n "$model" ] && [ "$model" != default ] || return 0 - case "$harness" in - claude|codex|opencode|pi|pi-signed|grok|kimi) - printf -- '--model %s ' "$(shell_quote "$model")" - ;; - esac -} - -effort_flag_for_harness() { - local harness=$1 effort=$2 - [ -n "$effort" ] && [ "$effort" != default ] || return 0 - case "$harness" in - claude) - case "$effort" in - low|medium|high|xhigh|max) printf -- '--effort %s ' "$(shell_quote "$effort")" ;; - esac - ;; - codex) - # The installed codex config schema uses model_reasoning_effort, and the - # bundled model catalog advertises low|medium|high|xhigh. Omit max rather - # than passing an unsupported value. - case "$effort" in - low|medium|high|xhigh) printf -- '-c %s ' "$(shell_quote "model_reasoning_effort=\"$effort\"")" ;; - esac - ;; - grok) - # grok exposes both --effort and --reasoning-effort; firstmate's profile - # axis is the reasoning knob. As of grok 0.2.99, --reasoning-effort accepts - # only low|medium|high and rejects both xhigh and max, so omit those rather - # than passing a known-bad value. - case "$effort" in - low|medium|high) printf -- '--reasoning-effort %s ' "$(shell_quote "$effort")" ;; - esac - ;; - pi|pi-signed) - # Pi 0.80.6 accepts the full shared effort vocabulary, including max, through - # its --thinking flag. - case "$effort" in - low|medium|high|xhigh|max) printf -- '--thinking %s ' "$(shell_quote "$effort")" ;; - esac - ;; - # opencode's interactive `opencode --prompt` launch has a verified --model - # flag but no verified effort flag. Its `opencode run --variant` flag belongs - # to a different, non-interactive launch mode, so fm-spawn does not pass it. - # kimi likewise has no reasoning-effort flag; the requested axis stays in - # task metadata but never reaches the launch command. - esac -} - case "$LAUNCH" in *__KIMIBIN__*) KIMI_BIN=$(resolve_kimi_binary) || exit 1 diff --git a/bin/fm-test-run.sh b/bin/fm-test-run.sh index ba4653c4121..aae9ac9a5ee 100755 --- a/bin/fm-test-run.sh +++ b/bin/fm-test-run.sh @@ -123,7 +123,7 @@ family_for_basename() { fm-composer-ghost.test.sh|fm-composer-lib.test.sh|\ fm-crew-state.test.sh|fm-decision-hold-lifecycle.test.sh|\ fm-documentation-audiences.test.sh|fm-ensure-agents-md.test.sh|fm-grok-harness.test.sh|\ - fm-kimi-harness.test.sh|fm-herdr-lab.test.sh|fm-lint.test.sh|\ + fm-kimi-harness.test.sh|fm-herdr-lab.test.sh|fm-launch-lib.test.sh|fm-lint.test.sh|\ fm-operational-input.test.sh|fm-pi-primary-types.test.sh|\ fm-send-popup-settle.test.sh|fm-send-settle.test.sh|\ fm-subagent-pretool-check.test.sh|\ diff --git a/tests/fm-launch-lib.test.sh b/tests/fm-launch-lib.test.sh new file mode 100755 index 00000000000..dc5ab9b2269 --- /dev/null +++ b/tests/fm-launch-lib.test.sh @@ -0,0 +1,259 @@ +#!/usr/bin/env bash +# tests/fm-launch-lib.test.sh - bin/fm-launch-lib.sh, the single owner of every +# firstmate launch command. +# +# Why this file is load-bearing: a hand-copied launch command has already +# drifted once, dropping claude's CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false +# prefix - the ghost-text suppression that keeps firstmate from reading +# predicted-prompt text as real typed input when it captures a pane. So this +# suite pins two things: +# +# 1. Every crewmate/scout/secondmate template composes exactly what it did +# before the extraction out of bin/fm-spawn.sh (behavior-preserving pin). +# 2. bin/fm-spawn.sh defines none of the three functions itself, so there is +# exactly one copy to keep verified. +# +# The `primary` kind (a firstmate PRIMARY session: no task, no worktree, no +# brief, no status file) is pinned here too, so the fleet launcher inherits the +# same verified commands instead of hand-writing them. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +LAUNCH_LIB="$ROOT/bin/fm-launch-lib.sh" +SPAWN="$ROOT/bin/fm-spawn.sh" + +# shellcheck source=/dev/null +. "$LAUNCH_LIB" + +HARNESSES=(claude codex opencode pi pi-signed grok kimi) + +# assert_template +assert_template() { + local kind=$1 harness=$2 expected=$3 got + got=$(launch_template "$harness" "$kind") \ + || fail "launch_template $harness $kind returned non-zero for a verified adapter" + [ "$got" = "$expected" ] \ + || fail "launch_template $harness $kind drifted: + expected: $expected + got: $got" +} + +# --- crewmate/scout templates: byte-identical to the pre-extraction commands -- + +test_ship_and_scout_templates_are_pinned() { + local kind + # shellcheck disable=SC2016 # single quotes are deliberate: these expand in the crewmate pane + for kind in ship scout; do + assert_template "$kind" claude 'CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false claude --dangerously-skip-permissions __MODELFLAG____EFFORTFLAG__"$(__OPINPUT__ encode launch-brief < __BRIEF__)"' + assert_template "$kind" codex 'codex __MODELFLAG____EFFORTFLAG__--dangerously-bypass-approvals-and-sandbox -c "notify=[\"bash\",\"-c\",\"touch __TURNEND__\"]" "$(__OPINPUT__ encode launch-brief < __BRIEF__)"' + assert_template "$kind" opencode 'OPENCODE_CONFIG_CONTENT='\''{"permission":{"*":"allow"}}'\'' opencode __MODELFLAG__--prompt "$(__OPINPUT__ encode launch-brief < __BRIEF__)"' + assert_template "$kind" pi 'FM_PI_HARNESS=pi pi __MODELFLAG____EFFORTFLAG__-e __PIEXT__ "$(__OPINPUT__ encode launch-brief < __BRIEF__)"' + assert_template "$kind" pi-signed 'FM_PI_HARNESS=pi-signed pi-signed __MODELFLAG____EFFORTFLAG__-e __PIEXT__ "$(__OPINPUT__ encode launch-brief < __BRIEF__)"' + assert_template "$kind" grok 'grok --always-approve __MODELFLAG____EFFORTFLAG__"$(__OPINPUT__ encode launch-brief < __BRIEF__)"' + assert_template "$kind" kimi '__KIMIBIN__ __MODELFLAG__--auto' + done + pass "launch_template: ship and scout templates are unchanged for all seven verified adapters" +} + +test_ship_is_the_default_kind() { + local h + for h in "${HARNESSES[@]}"; do + [ "$(launch_template "$h")" = "$(launch_template "$h" ship)" ] \ + || fail "launch_template $h with no kind must equal the ship template" + done + pass "launch_template: an omitted kind still means ship" +} + +test_secondmate_templates_are_pinned() { + # Only codex and the pi family differ from the ship shape: codex drops the + # per-task notify hook and pi/pi-signed point at the secondmate home's own + # primary extensions. + # shellcheck disable=SC2016 # single quotes are deliberate: these expand in the agent pane + assert_template secondmate codex 'codex __MODELFLAG____EFFORTFLAG__--dangerously-bypass-approvals-and-sandbox "$(__OPINPUT__ encode launch-brief < __BRIEF__)"' + # shellcheck disable=SC2016 + assert_template secondmate pi 'FM_PI_HARNESS=pi pi __MODELFLAG____EFFORTFLAG__-e __PITURNEND__ -e __PIWATCH__ "$(__OPINPUT__ encode launch-brief < __BRIEF__)"' + # shellcheck disable=SC2016 + assert_template secondmate pi-signed 'FM_PI_HARNESS=pi-signed pi-signed __MODELFLAG____EFFORTFLAG__-e __PITURNEND__ -e __PIWATCH__ "$(__OPINPUT__ encode launch-brief < __BRIEF__)"' + local h + for h in claude opencode grok kimi; do + [ "$(launch_template "$h" secondmate)" = "$(launch_template "$h" ship)" ] \ + || fail "launch_template $h secondmate must match its ship template" + done + pass "launch_template: secondmate templates are unchanged (codex and the pi family differ, the rest match ship)" +} + +# --- the primary kind ------------------------------------------------------- + +test_primary_templates_are_pinned() { + assert_template primary claude 'CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false claude --dangerously-skip-permissions __MODELFLAG____EFFORTFLAG__' + assert_template primary codex 'codex __MODELFLAG____EFFORTFLAG__--dangerously-bypass-approvals-and-sandbox' + assert_template primary opencode 'OPENCODE_CONFIG_CONTENT='\''{"permission":{"*":"allow"}}'\'' opencode __MODELFLAG__' + assert_template primary pi 'FM_PI_HARNESS=pi pi __MODELFLAG____EFFORTFLAG__' + assert_template primary pi-signed 'FM_PI_HARNESS=pi-signed pi-signed __MODELFLAG____EFFORTFLAG__' + assert_template primary grok 'grok --always-approve __MODELFLAG____EFFORTFLAG__' + assert_template primary kimi '__KIMIBIN__ __MODELFLAG__--auto' + pass "launch_template: every verified adapter has a primary template" +} + +test_primary_carries_no_task_scoped_placeholder() { + local h tpl + for h in "${HARNESSES[@]}"; do + tpl=$(launch_template "$h" primary) + case "$tpl" in + *__BRIEF__*|*__OPINPUT__*|*__TURNEND__*|*__PIEXT__*|*__PITURNEND__*|*__PIWATCH__*) + fail "primary template for $h carries a task-scoped placeholder, but a primary has no task or brief: $tpl" + ;; + esac + done + pass "launch_template: no primary template references a brief, turn-end token, or task extension" +} + +test_primary_keeps_the_autonomy_and_ghost_text_knowledge() { + # The exact knowledge a hand-written launcher command has already lost once. + assert_contains "$(launch_template claude primary)" 'CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false' \ + "the claude primary template must keep the ghost-text suppression prefix" + assert_contains "$(launch_template claude primary)" '--dangerously-skip-permissions' \ + "the claude primary template must keep its autonomy flag" + assert_contains "$(launch_template codex primary)" '--dangerously-bypass-approvals-and-sandbox' \ + "the codex primary template must keep its autonomy flag" + assert_contains "$(launch_template grok primary)" '--always-approve' \ + "the grok primary template must keep its autonomy flag" + assert_contains "$(launch_template opencode primary)" '"permission":{"*":"allow"}' \ + "the opencode primary template must keep its permission config" + pass "launch_template: primary templates keep each adapter's verified autonomy and ghost-text knowledge" +} + +test_primary_and_ship_share_a_model_and_effort_surface() { + local h + for h in "${HARNESSES[@]}"; do + assert_contains "$(launch_template "$h" primary)" '__MODELFLAG__' \ + "the $h primary template must accept the shared model flag" + case "$(launch_template "$h" ship)" in + *__EFFORTFLAG__*) + assert_contains "$(launch_template "$h" primary)" '__EFFORTFLAG__' \ + "the $h primary template must accept the effort flag its ship template accepts" + ;; + esac + done + pass "launch_template: primary templates expose the same model/effort placeholders as their ship templates" +} + +# --- the unverified-adapter guard ------------------------------------------- + +test_unknown_harness_returns_non_zero_for_every_kind() { + local kind + for kind in ship scout secondmate primary; do + launch_template not-a-harness "$kind" >/dev/null 2>&1 \ + && fail "launch_template must refuse an unverified adapter for kind=$kind" + done + pass "launch_template: an unverified adapter returns non-zero for every kind, including primary" +} + +test_unknown_kind_falls_back_to_the_crewmate_shape() { + # fm-spawn passes only ship|scout|secondmate; anything else must not silently + # produce a briefless primary command. + [ "$(launch_template claude bogus-kind)" = "$(launch_template claude ship)" ] \ + || fail "an unrecognized kind must keep the crewmate shape, never fall through to primary" + pass "launch_template: an unrecognized kind keeps the crewmate shape" +} + +# --- flag resolution -------------------------------------------------------- + +test_model_flag_covers_every_verified_adapter() { + local h + for h in "${HARNESSES[@]}"; do + [ "$(model_flag_for_harness "$h" opus)" = "--model 'opus' " ] \ + || fail "model_flag_for_harness $h opus drifted: $(model_flag_for_harness "$h" opus)" + done + [ -z "$(model_flag_for_harness not-a-harness opus)" ] \ + || fail "model_flag_for_harness must emit nothing for an unverified adapter" + pass "model_flag_for_harness: every verified adapter takes --model, quoted" +} + +test_model_flag_is_empty_when_unset_or_default() { + local h v + for h in "${HARNESSES[@]}"; do + for v in '' default; do + [ -z "$(model_flag_for_harness "$h" "$v")" ] \ + || fail "model_flag_for_harness $h '$v' must emit nothing" + done + done + pass "model_flag_for_harness: an unset or 'default' model emits no flag" +} + +test_effort_flag_per_harness_vocabulary() { + # Each adapter's verified effort flag and the exact vocabulary it accepts; + # values outside that vocabulary are omitted rather than passed through. + [ "$(effort_flag_for_harness claude xhigh)" = "--effort 'xhigh' " ] || fail "claude effort flag drifted" + [ "$(effort_flag_for_harness claude max)" = "--effort 'max' " ] || fail "claude must accept max" + [ "$(effort_flag_for_harness codex xhigh)" = "-c 'model_reasoning_effort=\"xhigh\"' " ] || fail "codex effort flag drifted" + [ -z "$(effort_flag_for_harness codex max)" ] || fail "codex must omit max, not pass an unsupported value" + [ "$(effort_flag_for_harness grok high)" = "--reasoning-effort 'high' " ] || fail "grok effort flag drifted" + [ -z "$(effort_flag_for_harness grok xhigh)" ] || fail "grok must omit xhigh" + [ -z "$(effort_flag_for_harness grok max)" ] || fail "grok must omit max" + [ "$(effort_flag_for_harness pi max)" = "--thinking 'max' " ] || fail "pi effort flag drifted" + [ "$(effort_flag_for_harness pi-signed max)" = "--thinking 'max' " ] || fail "pi-signed must share pi's effort flag and vocabulary" + [ -z "$(effort_flag_for_harness opencode high)" ] || fail "opencode has no verified effort flag" + [ -z "$(effort_flag_for_harness kimi high)" ] || fail "kimi has no verified effort flag" + pass "effort_flag_for_harness: each adapter's verified flag and vocabulary are unchanged" +} + +test_effort_flag_is_empty_when_unset_or_default() { + local h v + for h in "${HARNESSES[@]}"; do + for v in '' default; do + [ -z "$(effort_flag_for_harness "$h" "$v")" ] \ + || fail "effort_flag_for_harness $h '$v' must emit nothing" + done + done + pass "effort_flag_for_harness: an unset or 'default' effort emits no flag" +} + +test_flags_are_shell_quoted() { + # The composed command is evaluated by a shell in the target pane, so a value + # carrying a quote must not be able to break out of it. + [ "$(model_flag_for_harness claude "o'pus")" = "--model 'o'\\''pus' " ] \ + || fail "model_flag_for_harness must shell-quote a value containing a single quote" + pass "model_flag_for_harness: values are shell-quoted against injection" +} + +# --- one owner -------------------------------------------------------------- + +test_fm_spawn_defines_none_of_the_three_functions() { + local fn + for fn in launch_template model_flag_for_harness effort_flag_for_harness shell_quote; do + grep -qE "^$fn\(\) \{" "$SPAWN" \ + && fail "bin/fm-spawn.sh defines $fn again; bin/fm-launch-lib.sh is the single owner" + done + # shellcheck disable=SC2016 # matching fm-spawn.sh's literal source line, not expanding it + grep -Fq '. "$SCRIPT_DIR/fm-launch-lib.sh"' "$SPAWN" \ + || fail "bin/fm-spawn.sh must source bin/fm-launch-lib.sh" + pass "one owner: bin/fm-spawn.sh sources the library and redefines nothing" +} + +test_no_other_tracked_script_hand_writes_a_launch_command() { + local matches + matches=$(git -C "$ROOT" grep -lF -- '--dangerously-skip-permissions' -- bin | grep -v '^bin/fm-launch-lib.sh$' || true) + [ -z "$matches" ] \ + || fail "a launch command is hand-written outside bin/fm-launch-lib.sh: $matches" + pass "one owner: no other script under bin/ hand-writes a harness launch command" +} + +test_ship_and_scout_templates_are_pinned +test_ship_is_the_default_kind +test_secondmate_templates_are_pinned +test_primary_templates_are_pinned +test_primary_carries_no_task_scoped_placeholder +test_primary_keeps_the_autonomy_and_ghost_text_knowledge +test_primary_and_ship_share_a_model_and_effort_surface +test_unknown_harness_returns_non_zero_for_every_kind +test_unknown_kind_falls_back_to_the_crewmate_shape +test_model_flag_covers_every_verified_adapter +test_model_flag_is_empty_when_unset_or_default +test_effort_flag_per_harness_vocabulary +test_effort_flag_is_empty_when_unset_or_default +test_flags_are_shell_quoted +test_fm_spawn_defines_none_of_the_three_functions +test_no_other_tracked_script_hand_writes_a_launch_command From 6e65a07f0fd36b1571166789739908d98d3b6944 Mon Sep 17 00:00:00 2001 From: Shane Bracewell Date: Sun, 26 Jul 2026 10:44:16 -0400 Subject: [PATCH 02/44] no-mistakes(review): map fm-launch-lib.sh into test selection, fixture, and docs --- bin/fm-test-run.sh | 2 +- docs/scripts.md | 1 + tests/fm-backend.test.sh | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/bin/fm-test-run.sh b/bin/fm-test-run.sh index aae9ac9a5ee..e723d955106 100755 --- a/bin/fm-test-run.sh +++ b/bin/fm-test-run.sh @@ -670,7 +670,7 @@ families_for_changed_path() { bin/fm-x-*|bin/fm-check*) printf '%s\n' pr-forge ;; - bin/fm-spawn.sh|bin/fm-send.sh|bin/fm-harness.sh|\ + bin/fm-spawn.sh|bin/fm-launch-lib.sh|bin/fm-send.sh|bin/fm-harness.sh|\ bin/fm-peek.sh|bin/fm-composer*) printf '%s\n' backend-dispatch printf '%s\n' pure-contract-unit diff --git a/docs/scripts.md b/docs/scripts.md index 17e0e532cb8..d4cfc5b6a32 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -39,6 +39,7 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize | `fm-supervision-instructions.sh` | Render the session-start primary-harness supervision block or the one-line repair instruction | | `fm-home-seed.sh` | Transactionally provision a secondmate home and maintain `data/secondmates.md` | | `fm-spawn.sh` | Spawn crewmates, scouts, `id=repo` batches, and secondmates on the resolved harness and runtime backend | +| `fm-launch-lib.sh` | Single owner of every verified harness launch command for crewmate, scout, secondmate, and primary sessions | | `fm-backend.sh` | Runtime-backend selection, meta helpers, selector resolution, and operation dispatch | | `fm-backend-hometag-lib.sh` | Shared per-installation home-tag derivation for zellij tab and cmux workspace titles | | `fm-composer-lib.sh` | Single fleet-wide owner of composer-content classification for all backends | diff --git a/tests/fm-backend.test.sh b/tests/fm-backend.test.sh index 7f7608dc447..34ef2f3793f 100755 --- a/tests/fm-backend.test.sh +++ b/tests/fm-backend.test.sh @@ -141,7 +141,7 @@ resolve_permissive_tmux_kill_ref() { # hence the dispatcher is a copied sibling, while the tmux adapter is extracted # from BASE_REF so conformance tests retain the exact historical behavior even # when this branch changes tmux dispatch semantics. -OLD_BIN_UNCHANGED_SIBLINGS="fm-gate-refuse-lib.sh fm-guard.sh fm-lock-lib.sh fm-tasks-axi-lib.sh fm-pr-lib.sh fm-tangle-lib.sh fm-tmux-lib.sh fm-composer-lib.sh fm-wake-lib.sh fm-classify-lib.sh fm-supervision-lib.sh fm-ff-lib.sh fm-config-inherit-lib.sh fm-project-mode.sh fm-harness.sh fm-crew-state.sh fm-decision-hold.sh fm-backend.sh fm-operational-input.sh fm-public-followup-lib.sh fm-x-lib.sh" +OLD_BIN_UNCHANGED_SIBLINGS="fm-gate-refuse-lib.sh fm-guard.sh fm-lock-lib.sh fm-tasks-axi-lib.sh fm-pr-lib.sh fm-tangle-lib.sh fm-tmux-lib.sh fm-composer-lib.sh fm-launch-lib.sh fm-wake-lib.sh fm-classify-lib.sh fm-supervision-lib.sh fm-ff-lib.sh fm-config-inherit-lib.sh fm-project-mode.sh fm-harness.sh fm-crew-state.sh fm-decision-hold.sh fm-backend.sh fm-operational-input.sh fm-public-followup-lib.sh fm-x-lib.sh" # A pull-request merge may add a new main-only dependency that the branch's older baseline does not have yet. OLD_BIN_OPTIONAL_SIBLINGS="fm-pending-reply-lib.sh" OLD_BIN_REFACTORED="fm-send.sh fm-peek.sh fm-watch.sh fm-spawn.sh fm-teardown.sh fm-marker-lib.sh" From 7fd8e553fbd5b6b783795f089989de9c3df7f496 Mon Sep 17 00:00:00 2001 From: Shane Bracewell Date: Sun, 26 Jul 2026 10:59:33 -0400 Subject: [PATCH 03/44] no-mistakes(document): point launch-command docs at new fm-launch-lib.sh owner --- .agents/skills/harness-adapters/SKILL.md | 2 +- CONTRIBUTING.md | 2 +- docs/configuration.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.agents/skills/harness-adapters/SKILL.md b/.agents/skills/harness-adapters/SKILL.md index b0de89397b7..34a5aab03fe 100644 --- a/.agents/skills/harness-adapters/SKILL.md +++ b/.agents/skills/harness-adapters/SKILL.md @@ -36,7 +36,7 @@ Each adapter's `Busy state` row names only which semantic source that harness us Never dispatch a crewmate or secondmate on an unverified adapter. If `config/crew-harness` or `config/secondmate-harness` names an unverified adapter, tell the captain under `AGENTS.md` section 9 that the requested worker runtime is not verified yet, use firstmate's own verified runtime for current work, and ask only whether to verify the requested runtime before future use. Do not pause current work for that future-verification choice, and never launch an unverified adapter. -If the captain asks for a new harness, propose verifying it first: spawn a trivial supervised task using `fm-spawn`'s raw-launch-command escape hatch, confirm every fact empirically, then record the mechanics in `fm-spawn`, its semantic busy source and trust gate in `bin/fm-busy-lib.sh`, any needed `FM_COMPOSER_IDLE_RE` empty-composer override plus any novel bare agent prompt glyph in `bin/fm-composer-lib.sh`'s shared composer classifier (the one fleet-wide owner of the empty/dead-shell/pending decision, so a new harness's own idle composer is not misread as a dead shell), the tmux agent-process liveness classification in `bin/backends/tmux.sh` when the harness can launch a secondmate, and the verified knowledge here. +If the captain asks for a new harness, propose verifying it first: spawn a trivial supervised task using `fm-spawn`'s raw-launch-command escape hatch, confirm every fact empirically, then record the verified launch command in `bin/fm-launch-lib.sh` and the remaining per-task mechanics in `fm-spawn`, its semantic busy source and trust gate in `bin/fm-busy-lib.sh`, any needed `FM_COMPOSER_IDLE_RE` empty-composer override plus any novel bare agent prompt glyph in `bin/fm-composer-lib.sh`'s shared composer classifier (the one fleet-wide owner of the empty/dead-shell/pending decision, so a new harness's own idle composer is not misread as a dead shell), the tmux agent-process liveness classification in `bin/backends/tmux.sh` when the harness can launch a secondmate, and the verified knowledge here. ## Detection diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index effd31a8912..598e3d53f83 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -47,7 +47,7 @@ See the [no-mistakes quick start](https://kunchenguid.github.io/no-mistakes/star Test scripts and helpers in `tests/` are plain bash too. `bin/fm-lint.sh` must pass: it is the single owner of the lint definition (the shellcheck file set, config, and pinned shellcheck version), and both CI and the no-mistakes pre-push gate run it, so local and CI can never diverge. It pins one exact shellcheck version and refuses to run under any other; print it with `bin/fm-lint.sh --required-version` and install that build locally. -- Changes to harness adapters (detection in `bin/fm-harness.sh`, launch and hook mechanics in `bin/fm-spawn.sh`, semantic busy sources and trust gates in `bin/fm-busy-lib.sh`, delivery-only rendered guards in `bin/fm-tmux-lib.sh`, cleanup in `bin/fm-teardown.sh`, and facts in `.agents/skills/harness-adapters/SKILL.md`) must be verified empirically against the real harness, never written from documentation alone. +- Changes to harness adapters (detection in `bin/fm-harness.sh`, verified launch commands in `bin/fm-launch-lib.sh`, remaining launch and hook mechanics in `bin/fm-spawn.sh`, semantic busy sources and trust gates in `bin/fm-busy-lib.sh`, delivery-only rendered guards in `bin/fm-tmux-lib.sh`, cleanup in `bin/fm-teardown.sh`, and facts in `.agents/skills/harness-adapters/SKILL.md`) must be verified empirically against the real harness, never written from documentation alone. - Changes to runtime session backends (`bin/fm-backend.sh`, `bin/backends/`, and the scripts that dispatch through them) keep current setup and limits in the relevant backend guide and active empirical evidence in [`docs/verification/runtime-backends.md`](docs/verification/runtime-backends.md). - [`docs/documentation-audiences.md`](docs/documentation-audiences.md) and its machine-consumed inventory own prose classification; run `bin/fm-doc-audience-check.sh` after documentation changes. - In Markdown, put each full sentence on its own line. diff --git a/docs/configuration.md b/docs/configuration.md index 3b91fc08a2a..fadf2c60a2b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -196,7 +196,7 @@ The full cmux home label also includes a short hash of the resolved `FM_ROOT` pa claude, codex, opencode, pi, pi-signed, grok, and kimi are empirically verified for crewmate and secondmate launches; [README requirements](../README.md#requirements) own the set supported for the primary session. New harnesses get verified through a supervised trial task before joining the set. The verified adapter knowledge - each harness's busy-state source, interrupt and exit commands, skill-invocation syntax, and per-harness quirks - lives in [`.agents/skills/harness-adapters/SKILL.md`](../.agents/skills/harness-adapters/SKILL.md). -Launch mechanics, including the verified command templates, live in [`bin/fm-spawn.sh`](../bin/fm-spawn.sh). +The verified launch command templates have one owner, [`bin/fm-launch-lib.sh`](../bin/fm-launch-lib.sh); the remaining per-task launch mechanics live in [`bin/fm-spawn.sh`](../bin/fm-spawn.sh), which sources it. Enabled primary-session turn-end guard integrations are tracked as repo-level hook files and documented in [`docs/turnend-guard.md`](turnend-guard.md). Kimi remains outside the primary turn-end guard integrations; [`docs/turnend-guard.md`](turnend-guard.md#compatibility-limits) owns its separate captain-approved crew wake hook. Primary-session watcher wake protocols are rendered at session start by [`bin/fm-supervision-instructions.sh`](../bin/fm-supervision-instructions.sh) from [`docs/supervision-protocols/`](supervision-protocols/). From e0676564c4bf3850b1192171fca91a52057d52d1 Mon Sep 17 00:00:00 2001 From: Shane Bracewell Date: Sun, 26 Jul 2026 11:36:07 -0400 Subject: [PATCH 04/44] no-mistakes(review): use verified opencode --auto primary shape and tighten launch-lib ownership --- AGENTS.md | 2 +- bin/fm-launch-lib.sh | 33 +++++++++++++++++++++++++++------ bin/fm-spawn.sh | 11 ++--------- tests/fm-launch-lib.test.sh | 36 ++++++++++++++++++++++++++++++------ 4 files changed, 60 insertions(+), 22 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e09502cb055..912885818ff 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -164,7 +164,7 @@ Load `harness-adapters` before every spawn or recovery and before trust handling The verified harnesses are `claude`, `codex`, `opencode`, `pi`, `pi-signed`, `grok`, and `kimi`; never dispatch on an unverified adapter. If static `config/crew-harness` or `config/secondmate-harness` names an unverified adapter, report it and fall back only to a verified adapter rather than launching it. -`docs/configuration.md` owns dispatch-profile and runtime-backend schemas, `bin/fm-harness.sh` owns static resolution, and `bin/fm-spawn.sh` owns launch flags and fail-closed validation. +`docs/configuration.md` owns dispatch-profile and runtime-backend schemas, `bin/fm-harness.sh` owns static resolution, and `bin/fm-launch-lib.sh` owns the verified launch commands, launch flags, and the fail-closed unverified-adapter guard that `bin/fm-spawn.sh` sources and enforces at spawn. When dispatch profiles exist, consult them at every crewmate or scout intake and pass the resolved concrete profile required by `fm-spawn`. Routing precedence is an explicit per-task captain override, then the best-fit configured rule, then the configured default, then the static crewmate harness. Firstmate alone resolves a matched profile array: run `quota-axi --json` at that intake, evaluate every configured candidate against that current output, and choose with inspectable real headroom including quota-window pace. diff --git a/bin/fm-launch-lib.sh b/bin/fm-launch-lib.sh index 47d5d118af7..6a913ca007c 100644 --- a/bin/fm-launch-lib.sh +++ b/bin/fm-launch-lib.sh @@ -43,12 +43,29 @@ shell_quote() { # and no status file, so it launches bare and is greeted by the session-start # adapters already installed in the home (for pi and opencode those are the # project-local extensions the harness auto-discovers once trusted, which is why -# a primary needs no explicit extension flag). A primary template therefore ends -# at its flag placeholders, and an unset flag leaves one trailing space; that is -# cosmetic in a shell command, and consumers may trim it. +# a primary needs no explicit extension flag). A primary template therefore +# carries only its flag placeholders plus whatever briefless-launch flag that +# adapter was verified to need, and an unset flag leaves one trailing space; that +# is cosmetic in a shell command, and consumers may trim it. # -# Placeholders the caller substitutes before launch are documented in -# bin/fm-spawn.sh's header. +# Placeholders every caller substitutes before launch: +# __MODELFLAG__ model_flag_for_harness output, or empty (see below) +# __EFFORTFLAG__ effort_flag_for_harness output, or empty (see below) +# __KIMIBIN__ shell-quoted absolute path to the resolved kimi binary +# Placeholders only a task-scoped (ship|scout|secondmate) launch substitutes: +# __BRIEF__ absolute path to data//brief.md +# __TURNEND__ absolute path to state/.turn-ended (for harnesses whose +# turn-end signal rides the launch command, e.g. codex -c notify=[...]) +# __PIEXT__ absolute path to state/.pi-ext.ts (pi turn-end extension, +# written by fm-spawn.sh; outside the worktree to avoid pi's trust gate) +# __PITURNEND__ absolute path to .pi/extensions/fm-primary-turnend-guard.ts in a pi secondmate home +# __PIWATCH__ absolute path to .pi/extensions/fm-primary-pi-watch.ts in a pi secondmate home +# __OPINPUT__ absolute path to the canonical operational-input encoder +# +# __KIMIBIN__ is resolved by bin/fm-spawn.sh alone, deliberately: the fleet +# launcher reaches Kimi through the pi harness rather than a native kimi binary, +# so there is no second caller to drift from. +# Revisit that only if a native kimi launch ever becomes a launcher entry. launch_template() { local harness=$1 kind=${2:-ship} # shellcheck disable=SC2016 # single quotes are deliberate: $(cat ...) expands in the crewmate pane, not here @@ -57,7 +74,11 @@ launch_template() { case "$harness" in claude) printf '%s' 'CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false claude --dangerously-skip-permissions __MODELFLAG____EFFORTFLAG__' ;; codex) printf '%s' 'codex __MODELFLAG____EFFORTFLAG__--dangerously-bypass-approvals-and-sandbox' ;; - opencode) printf '%s' 'OPENCODE_CONFIG_CONTENT='\''{"permission":{"*":"allow"}}'\'' opencode __MODELFLAG__' ;; + # --prompt carries the crewmate's brief, so it has no place in a briefless + # primary; --auto is the empirically verified briefless form (a primary + # opencode TUI is launched that way in + # tests/fm-opencode-primary-live-e2e.test.sh:256 and :310). + opencode) printf '%s' 'OPENCODE_CONFIG_CONTENT='\''{"permission":{"*":"allow"}}'\'' opencode __MODELFLAG__--auto' ;; pi|pi-signed) printf '%s%s' "FM_PI_HARNESS=$harness $harness" ' __MODELFLAG____EFFORTFLAG__' ;; grok) printf '%s' 'grok --always-approve __MODELFLAG____EFFORTFLAG__' ;; # Kimi Code rejects a positional prompt, so its crewmate template diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index 9a71ae723e8..181773e7de2 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -103,15 +103,8 @@ # multi-task shell loop (the tool shell is zsh, which does not word-split unquoted # $vars and silently breaks ad-hoc `for ... in $pairs` loops). # Launch templates live in launch_template() in bin/fm-launch-lib.sh, the single owner -# of every firstmate launch command; placeholders replaced before launch: -# __BRIEF__ absolute path to data//brief.md -# __TURNEND__ absolute path to state/.turn-ended (for harnesses whose -# turn-end signal rides the launch command, e.g. codex -c notify=[...]) -# __PIEXT__ absolute path to state/.pi-ext.ts (pi turn-end extension, -# written by this script; outside the worktree to avoid pi's trust gate) -# __PITURNEND__ absolute path to .pi/extensions/fm-primary-turnend-guard.ts in a pi secondmate home -# __PIWATCH__ absolute path to .pi/extensions/fm-primary-pi-watch.ts in a pi secondmate home -# __OPINPUT__ absolute path to the canonical operational-input encoder +# of every firstmate launch command; that library's header owns the placeholder +# contract this script substitutes before launch. # Verified per-harness turn-end hooks are installed automatically where enabled; some live outside the worktree. # Kimi uses one surgically installed Firstmate region in $HOME/.kimi-code/config.toml, # a firstmate-owned global hook and registry, and a gitignored per-task pointer. diff --git a/tests/fm-launch-lib.test.sh b/tests/fm-launch-lib.test.sh index dc5ab9b2269..5e3749ff540 100755 --- a/tests/fm-launch-lib.test.sh +++ b/tests/fm-launch-lib.test.sh @@ -89,7 +89,7 @@ test_secondmate_templates_are_pinned() { test_primary_templates_are_pinned() { assert_template primary claude 'CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false claude --dangerously-skip-permissions __MODELFLAG____EFFORTFLAG__' assert_template primary codex 'codex __MODELFLAG____EFFORTFLAG__--dangerously-bypass-approvals-and-sandbox' - assert_template primary opencode 'OPENCODE_CONFIG_CONTENT='\''{"permission":{"*":"allow"}}'\'' opencode __MODELFLAG__' + assert_template primary opencode 'OPENCODE_CONFIG_CONTENT='\''{"permission":{"*":"allow"}}'\'' opencode __MODELFLAG__--auto' assert_template primary pi 'FM_PI_HARNESS=pi pi __MODELFLAG____EFFORTFLAG__' assert_template primary pi-signed 'FM_PI_HARNESS=pi-signed pi-signed __MODELFLAG____EFFORTFLAG__' assert_template primary grok 'grok --always-approve __MODELFLAG____EFFORTFLAG__' @@ -122,6 +122,8 @@ test_primary_keeps_the_autonomy_and_ghost_text_knowledge() { "the grok primary template must keep its autonomy flag" assert_contains "$(launch_template opencode primary)" '"permission":{"*":"allow"}' \ "the opencode primary template must keep its permission config" + assert_contains "$(launch_template opencode primary)" '--auto' \ + "the opencode primary template must keep the verified briefless --auto form" pass "launch_template: primary templates keep each adapter's verified autonomy and ghost-text knowledge" } @@ -234,11 +236,33 @@ test_fm_spawn_defines_none_of_the_three_functions() { } test_no_other_tracked_script_hand_writes_a_launch_command() { - local matches - matches=$(git -C "$ROOT" grep -lF -- '--dangerously-skip-permissions' -- bin | grep -v '^bin/fm-launch-lib.sh$' || true) - [ -z "$matches" ] \ - || fail "a launch command is hand-written outside bin/fm-launch-lib.sh: $matches" - pass "one owner: no other script under bin/ hand-writes a harness launch command" + # One marker per verified adapter's autonomy, permission, or ghost-text + # knowledge, so a hand-copied codex, opencode, grok, or kimi command is caught + # too and not just claude's. Each pattern must still match the library itself; + # a pattern that matches nothing would pass this guard while checking nothing. + local markers=( + 'CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION' + '--dangerously-skip-permissions' + '--dangerously-bypass-approvals-and-sandbox' + 'OPENCODE_CONFIG_CONTENT=' + '--always-approve' + '--auto([^-a-z]|$)' + ) + local marker owners status matches + for marker in "${markers[@]}"; do + owners=$(git -C "$ROOT" grep -lE -e "$marker" -- bin) + status=$? + [ "$status" -le 1 ] \ + || fail "the one-owner guard could not run git grep for '$marker' (git grep exited $status)" + case $'\n'"$owners"$'\n' in + *$'\n'bin/fm-launch-lib.sh$'\n'*) ;; + *) fail "the one-owner guard's '$marker' pattern no longer matches bin/fm-launch-lib.sh, so it checks nothing" ;; + esac + matches=$(printf '%s\n' "$owners" | grep -v '^bin/fm-launch-lib.sh$' | grep -v '^$') + [ -z "$matches" ] \ + || fail "a launch command is hand-written outside bin/fm-launch-lib.sh ('$marker'): $matches" + done + pass "one owner: no other script under bin/ hand-writes any verified harness launch command" } test_ship_and_scout_templates_are_pinned From dbfd400f909322d57dd170da8f5ac605021d90b5 Mon Sep 17 00:00:00 2001 From: Shane Bracewell Date: Sun, 26 Jul 2026 11:48:59 -0400 Subject: [PATCH 05/44] no-mistakes(document): point grok adapter launch line at fm-launch-lib owner --- .agents/skills/harness-adapters/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.agents/skills/harness-adapters/SKILL.md b/.agents/skills/harness-adapters/SKILL.md index 34a5aab03fe..6f94745c479 100644 --- a/.agents/skills/harness-adapters/SKILL.md +++ b/.agents/skills/harness-adapters/SKILL.md @@ -304,7 +304,7 @@ When a secondmate is launched on Pi or pi-signed, `fm-spawn.sh --secondmate` lau ## grok (VERIFIED 2026-06-29, grok 0.2.73; slash-submit re-verified 2026-07-03 on 0.2.82; reasoning-effort ceiling re-verified 2026-07-13 on 0.2.99; exit paths re-verified 2026-07-19 on grok 0.2.103) Grok Build TUI (`grok`), a Claude-Code-compatible CLI from xAI. -Launch with a positional prompt: `grok --always-approve "$(cat )"`. +A positional prompt starts the supervised interactive session; the verified command itself lives in `bin/fm-launch-lib.sh`, never here. For Grok's supported reasoning-effort values and omission behavior, see the [launch-profile-axes table](#launch-profile-axes). | Fact | Value | From abe3c8dd745b006732e5bddeac6e26fd26d7083d Mon Sep 17 00:00:00 2001 From: Shane Bracewell Date: Sun, 26 Jul 2026 12:07:49 -0400 Subject: [PATCH 06/44] no-mistakes(review): add grok --trust, refuse kimi primary, pin every primary shape --- bin/fm-launch-lib.sh | 51 +++++++++++++++-- tests/fm-launch-lib.test.sh | 107 +++++++++++++++++++++++++++++++++--- 2 files changed, 146 insertions(+), 12 deletions(-) diff --git a/bin/fm-launch-lib.sh b/bin/fm-launch-lib.sh index 6a913ca007c..ffbc1aefb9f 100644 --- a/bin/fm-launch-lib.sh +++ b/bin/fm-launch-lib.sh @@ -66,24 +66,67 @@ shell_quote() { # launcher reaches Kimi through the pi harness rather than a native kimi binary, # so there is no second caller to drift from. # Revisit that only if a native kimi launch ever becomes a launcher entry. +# That same reasoning is why kimi has no primary arm: only bin/fm-spawn.sh can +# substitute __KIMIBIN__, and it only ever launches crewmates, so a primary kimi +# template could not be substituted by the caller that would ask for it. +# +# Every primary template below is the shape this repo empirically verified for a +# briefless PRIMARY launch, not the crewmate command with its brief argument +# subtracted; each arm cites the evidence that fixes its flags, and +# tests/fm-launch-lib.test.sh pins each one against those same citations. launch_template() { local harness=$1 kind=${2:-ship} # shellcheck disable=SC2016 # single quotes are deliberate: $(cat ...) expands in the crewmate pane, not here case "$kind" in primary) case "$harness" in + # The ghost-text suppression prefix is firstmate-required on every claude + # launch, primary included (see the crewmate arm below for why). + # --dangerously-skip-permissions carries over from the crewmate command: + # README.md:90 documents the primary launch as bare `claude`, and the only + # in-repo launch carrying the flag is the headless print-mode session at + # tests/fm-claude-stop-autoarm-live-e2e.test.sh:115, so the repo pins the + # prefix but not this flag's place in an interactive primary. claude) printf '%s' 'CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false claude --dangerously-skip-permissions __MODELFLAG____EFFORTFLAG__' ;; + # --dangerously-bypass-approvals-and-sandbox likewise carries over from the + # crewmate command; tests/fm-codex-continuity-live-e2e.test.sh:40 uses it + # under headless `codex exec`, which is not evidence of the interactive + # primary TUI shape. No in-repo primary codex TUI launch exists to pin. codex) printf '%s' 'codex __MODELFLAG____EFFORTFLAG__--dangerously-bypass-approvals-and-sandbox' ;; # --prompt carries the crewmate's brief, so it has no place in a briefless # primary; --auto is the empirically verified briefless form (a primary # opencode TUI is launched that way in # tests/fm-opencode-primary-live-e2e.test.sh:256 and :310). opencode) printf '%s' 'OPENCODE_CONFIG_CONTENT='\''{"permission":{"*":"allow"}}'\'' opencode __MODELFLAG__--auto' ;; + # Bare `pi` is the documented primary launch (README.md:102); the project + # trust prompt approved once per clone is what makes the tracked + # .pi/extensions/*.ts auto-load (README.md:106), so a primary needs no + # explicit -e flag. tests/fm-pi-primary-live-e2e.test.sh:266 adds + # --approve --no-session --no-context-files --no-extensions with explicit + # -e paths, but those are that test's isolation scaffolding - it runs + # against a throwaway clone - not the verified primary form, so they are + # deliberately not copied here. The FM_PI_HARNESS identity marker rides + # every Pi-family launch, primary included (README.md:104 documents the + # signed primary as `FM_PI_HARNESS=pi-signed pi-signed`): the selected + # $harness is both the invoked binary and the marker, so a signed + # primary's environment cannot relabel a plain Pi session. pi|pi-signed) printf '%s%s' "FM_PI_HARNESS=$harness $harness" ' __MODELFLAG____EFFORTFLAG__' ;; - grok) printf '%s' 'grok --always-approve __MODELFLAG____EFFORTFLAG__' ;; - # Kimi Code rejects a positional prompt, so its crewmate template - # already launches bare; the primary shape is identical. - kimi) printf '%s' '__KIMIBIN__ __MODELFLAG__--auto' ;; + # --trust is supervision-safety knowledge, not one-time setup trivia: + # without folder trust the primary turn-end guard FAILS OPEN + # (.agents/skills/harness-adapters/SKILL.md:345), and because trust is + # granted once per clone a fresh clone is exactly when its absence bites + # (README.md:105, docs/turnend-guard.md:63). The empirical primary launch + # is tests/fm-grok-continuity-live-e2e.test.sh:76, + # `grok --trust --always-approve --reasoning-effort low`, where + # --reasoning-effort is what __EFFORTFLAG__ resolves to; README.md:96 + # documents the same `grok --trust`. + grok) printf '%s' 'grok --trust --always-approve __MODELFLAG____EFFORTFLAG__' ;; + # kimi refuses rather than emitting an unsubstitutable command: README.md:61 + # lists only Claude Code, Grok, Pi, pi-signed, Codex, and OpenCode as verified + # primary harnesses (docs/configuration.md:177 defers that narrower set to README), + # and only bin/fm-spawn.sh can resolve __KIMIBIN__ (see the header above). + # A non-zero return is the same refusal an unverified adapter gets. + kimi) return 1 ;; *) return 1 ;; esac return 0 diff --git a/tests/fm-launch-lib.test.sh b/tests/fm-launch-lib.test.sh index 5e3749ff540..71c76eb55cc 100755 --- a/tests/fm-launch-lib.test.sh +++ b/tests/fm-launch-lib.test.sh @@ -28,6 +28,9 @@ SPAWN="$ROOT/bin/fm-spawn.sh" . "$LAUNCH_LIB" HARNESSES=(claude codex opencode pi pi-signed grok kimi) +# The harnesses README.md:61 lists as verified for a PRIMARY session. kimi is +# deliberately absent, so launch_template refuses it for kind=primary. +PRIMARY_HARNESSES=(claude codex opencode pi pi-signed grok) # assert_template assert_template() { @@ -86,20 +89,92 @@ test_secondmate_templates_are_pinned() { # --- the primary kind ------------------------------------------------------- -test_primary_templates_are_pinned() { +# Each primary shape gets its own test naming the evidence that fixes its flags, +# so an edit that drops a load-bearing flag fails here rather than reaching a +# reviewer. A primary template is NOT the crewmate command minus its brief; it is +# whatever this repo empirically verified for a briefless PRIMARY launch. + +test_primary_claude_template_is_pinned() { + # CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false is the ghost-text suppression a + # hand-copied command already dropped once. README.md:90 documents the primary + # launch as bare `claude`; the only in-repo launch carrying + # --dangerously-skip-permissions is the headless print-mode session at + # tests/fm-claude-stop-autoarm-live-e2e.test.sh:115, so this pin records the + # flag's presence rather than claiming an interactive primary verified it. assert_template primary claude 'CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false claude --dangerously-skip-permissions __MODELFLAG____EFFORTFLAG__' + pass "launch_template: the claude primary template is pinned" +} + +test_primary_codex_template_is_pinned() { + # tests/fm-codex-continuity-live-e2e.test.sh:40 runs codex headlessly via + # `codex exec`, which is not evidence of the interactive primary TUI shape; + # --dangerously-bypass-approvals-and-sandbox carries over from the crewmate + # command and this pin holds it steady until a primary TUI launch verifies it. assert_template primary codex 'codex __MODELFLAG____EFFORTFLAG__--dangerously-bypass-approvals-and-sandbox' + pass "launch_template: the codex primary template is pinned" +} + +test_primary_opencode_template_is_pinned() { + # tests/fm-opencode-primary-live-e2e.test.sh:256 and :310 both launch a primary + # opencode TUI as OPENCODE_CONFIG_CONTENT='{"permission":{"*":"allow"}}' with + # `opencode --auto`. --prompt belongs to the crewmate, which has a brief to pass. assert_template primary opencode 'OPENCODE_CONFIG_CONTENT='\''{"permission":{"*":"allow"}}'\'' opencode __MODELFLAG__--auto' + pass "launch_template: the opencode primary template is pinned" +} + +test_primary_pi_template_is_pinned() { + # README.md:102 documents the primary launch as bare `pi`, and README.md:106 + # records that the once-per-clone project trust prompt is what auto-loads the + # tracked .pi/extensions/*.ts. tests/fm-pi-primary-live-e2e.test.sh:266 adds + # --approve --no-session --no-context-files --no-extensions plus explicit -e + # paths, but that is the test's own isolation scaffolding against a throwaway + # clone, not the verified primary form, so it must not leak into this template. + # The FM_PI_HARNESS identity marker rides every Pi-family launch, primary + # included: README.md:104 documents the signed primary launch as + # `FM_PI_HARNESS=pi-signed pi-signed`, and pi-signed is a distinct executable + # identity sharing pi's verified flag surface, never an alias. assert_template primary pi 'FM_PI_HARNESS=pi pi __MODELFLAG____EFFORTFLAG__' assert_template primary pi-signed 'FM_PI_HARNESS=pi-signed pi-signed __MODELFLAG____EFFORTFLAG__' - assert_template primary grok 'grok --always-approve __MODELFLAG____EFFORTFLAG__' - assert_template primary kimi '__KIMIBIN__ __MODELFLAG__--auto' - pass "launch_template: every verified adapter has a primary template" + pass "launch_template: the pi and pi-signed primary templates are pinned" +} + +test_primary_grok_template_is_pinned() { + # tests/fm-grok-continuity-live-e2e.test.sh:76 launches a primary grok as + # `grok --trust --always-approve --reasoning-effort low`, where + # --reasoning-effort is what __EFFORTFLAG__ resolves to; README.md:96 documents + # `grok --trust` too. --trust is load-bearing, not setup trivia: + # .agents/skills/harness-adapters/SKILL.md:345 records that without folder trust + # the primary turn-end guard fails open, README.md:105 and + # docs/turnend-guard.md:63 say the same, and trust being granted once per clone + # means a fresh clone is exactly when dropping it bites. + assert_template primary grok 'grok --trust --always-approve __MODELFLAG____EFFORTFLAG__' + pass "launch_template: the grok primary template is pinned, --trust included" +} + +test_primary_kimi_refuses() { + # README.md:61 lists only Claude Code, Grok, Pi, pi-signed, Codex, and OpenCode + # as verified primary harnesses (docs/configuration.md:177 defers that narrower + # set to README), and __KIMIBIN__ is resolvable by bin/fm-spawn.sh alone, which never + # launches a primary. Refusing beats handing back an unsubstitutable command. + launch_template kimi primary >/dev/null 2>&1 \ + && fail "launch_template kimi primary must refuse: kimi is not a verified primary harness and only fm-spawn.sh can resolve __KIMIBIN__" + [ -z "$(launch_template kimi primary 2>/dev/null)" ] \ + || fail "launch_template kimi primary must emit nothing when it refuses" + pass "launch_template: kimi has no primary template and refuses instead of emitting __KIMIBIN__" +} + +test_primary_kimi_refusal_leaves_the_crewmate_template_intact() { + # bin/fm-spawn.sh depends on the kimi crewmate command byte-for-byte. + local kind + for kind in ship scout secondmate; do + assert_template "$kind" kimi '__KIMIBIN__ __MODELFLAG__--auto' + done + pass "launch_template: the kimi crewmate template is unaffected by the primary refusal" } test_primary_carries_no_task_scoped_placeholder() { local h tpl - for h in "${HARNESSES[@]}"; do + for h in "${PRIMARY_HARNESSES[@]}"; do tpl=$(launch_template "$h" primary) case "$tpl" in *__BRIEF__*|*__OPINPUT__*|*__TURNEND__*|*__PIEXT__*|*__PITURNEND__*|*__PIWATCH__*) @@ -120,6 +195,8 @@ test_primary_keeps_the_autonomy_and_ghost_text_knowledge() { "the codex primary template must keep its autonomy flag" assert_contains "$(launch_template grok primary)" '--always-approve' \ "the grok primary template must keep its autonomy flag" + assert_contains "$(launch_template grok primary)" '--trust' \ + "the grok primary template must keep --trust, without which the primary turn-end guard fails open" assert_contains "$(launch_template opencode primary)" '"permission":{"*":"allow"}' \ "the opencode primary template must keep its permission config" assert_contains "$(launch_template opencode primary)" '--auto' \ @@ -129,7 +206,7 @@ test_primary_keeps_the_autonomy_and_ghost_text_knowledge() { test_primary_and_ship_share_a_model_and_effort_surface() { local h - for h in "${HARNESSES[@]}"; do + for h in "${PRIMARY_HARNESSES[@]}"; do assert_contains "$(launch_template "$h" primary)" '__MODELFLAG__' \ "the $h primary template must accept the shared model flag" case "$(launch_template "$h" ship)" in @@ -240,13 +317,21 @@ test_no_other_tracked_script_hand_writes_a_launch_command() { # knowledge, so a hand-copied codex, opencode, grok, or kimi command is caught # too and not just claude's. Each pattern must still match the library itself; # a pattern that matches nothing would pass this guard while checking nothing. + # + # The two --auto markers are anchored to the binary they belong to. A bare + # --auto would also match unrelated legitimate flags - `gh pr merge --auto` in + # bin/fm-pr-*.sh is the obvious one - and fail with a misleading "a launch + # command is hand-written" message. Double-quoted so the single quote inside + # the character class stays literal; it stops the match at the template's own + # quoting so the pattern cannot run past the end of a launch string. local markers=( 'CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION' '--dangerously-skip-permissions' '--dangerously-bypass-approvals-and-sandbox' 'OPENCODE_CONFIG_CONTENT=' '--always-approve' - '--auto([^-a-z]|$)' + "opencode [^']*--auto([^-a-z]|$)" + "(kimi|__KIMIBIN__)[^']*--auto([^-a-z]|$)" ) local marker owners status matches for marker in "${markers[@]}"; do @@ -268,7 +353,13 @@ test_no_other_tracked_script_hand_writes_a_launch_command() { test_ship_and_scout_templates_are_pinned test_ship_is_the_default_kind test_secondmate_templates_are_pinned -test_primary_templates_are_pinned +test_primary_claude_template_is_pinned +test_primary_codex_template_is_pinned +test_primary_opencode_template_is_pinned +test_primary_pi_template_is_pinned +test_primary_grok_template_is_pinned +test_primary_kimi_refuses +test_primary_kimi_refusal_leaves_the_crewmate_template_intact test_primary_carries_no_task_scoped_placeholder test_primary_keeps_the_autonomy_and_ghost_text_knowledge test_primary_and_ship_share_a_model_and_effort_surface From bf8724606ad0b79d3799f1df07b9918bfa596d1a Mon Sep 17 00:00:00 2001 From: Shane Bracewell Date: Sun, 26 Jul 2026 12:16:47 -0400 Subject: [PATCH 07/44] no-mistakes(review): record primary autonomy evidence and consumer obligation --- bin/fm-launch-lib.sh | 71 ++++++++++++++++++++++++++++++++++---------- 1 file changed, 55 insertions(+), 16 deletions(-) diff --git a/bin/fm-launch-lib.sh b/bin/fm-launch-lib.sh index ffbc1aefb9f..d50a4c3d6f1 100644 --- a/bin/fm-launch-lib.sh +++ b/bin/fm-launch-lib.sh @@ -29,9 +29,16 @@ shell_quote() { printf "'" } -# The verified launch command per adapter, as a template. Returns 1 for a -# harness with no verified adapter - that non-zero return is the unverified- -# adapter guard every caller relies on, so never add a permissive default arm. +# The verified launch command per adapter, as a template. A non-zero return has +# two distinct causes, and a caller reporting the refusal must tell them apart: +# 1. the harness has no verified adapter at all - the unverified-adapter guard +# every caller relies on, whose remedy is a raw launch command; +# 2. the harness is verified but this kind is deliberately unsupported for it - +# today only kimi with kind=primary, whose remedy is a different harness, +# NOT the raw-launch escape hatch. +# Never add a permissive default arm to either case. bin/fm-spawn.sh:437 and :441 +# name only cause 1 because fm-spawn never passes kind=primary; a consumer that +# does pass it owes the user the cause-2 wording. # # kind selects the session shape: # ship|scout a crewmate working one task in an isolated worktree @@ -70,10 +77,25 @@ shell_quote() { # substitute __KIMIBIN__, and it only ever launches crewmates, so a primary kimi # template could not be substituted by the caller that would ask for it. # -# Every primary template below is the shape this repo empirically verified for a -# briefless PRIMARY launch, not the crewmate command with its brief argument -# subtracted; each arm cites the evidence that fixes its flags, and -# tests/fm-launch-lib.test.sh pins each one against those same citations. +# No primary template below is the crewmate command with its brief argument +# subtracted; each arm cites the specific in-repo evidence that fixes its flags, +# and tests/fm-launch-lib.test.sh pins each one against those same citations. +# The evidence is not uniform, and each arm says which kind it rests on: opencode +# and grok are pinned to an empirical briefless PRIMARY launch in a live e2e test, +# pi to the documented bare launch, and claude and codex to the secondmate +# precedent - this file's own `secondmate` kind is a firstmate PRIMARY (see the +# kind list above), and its shipped crewmate templates - the claude arm and the +# codex `kind = secondmate` arm in the second case block below - launch that +# interactive primary with exactly the autonomy flags those two arms carry. +# +# CONSUMER OBLIGATION (binding, not advisory): the claude and codex primary +# templates launch a session that runs with NO permission prompts, and the +# opencode primary template allows every permission outright. A consumer that +# composes a primary launch from this library MUST surface that fact to the +# captain at launch time - one short line at launch or in the menu row is enough. +# The captain is entitled to know the posture of the session their front door +# starts; this obligation lives here so every consumer inherits it from the one +# owner instead of rediscovering it. launch_template() { local harness=$1 kind=${2:-ship} # shellcheck disable=SC2016 # single quotes are deliberate: $(cat ...) expands in the crewmate pane, not here @@ -82,16 +104,33 @@ launch_template() { case "$harness" in # The ghost-text suppression prefix is firstmate-required on every claude # launch, primary included (see the crewmate arm below for why). - # --dangerously-skip-permissions carries over from the crewmate command: - # README.md:90 documents the primary launch as bare `claude`, and the only - # in-repo launch carrying the flag is the headless print-mode session at - # tests/fm-claude-stop-autoarm-live-e2e.test.sh:115, so the repo pins the - # prefix but not this flag's place in an interactive primary. + # --dangerously-skip-permissions is settled knowledge, decided and recorded + # rather than inherited by accident. The evidence is the secondmate + # precedent: the claude arm in the crewmate case block below serves every + # crewmate kind INCLUDING secondmate, and this `secondmate` kind is itself a + # firstmate PRIMARY launched in a provisioned home, so the repo already + # ships an interactive firstmate primary carrying this flag. The captain's + # own attended session already runs as `claude --dangerously-skip-permissions`, + # so keeping it preserves the status quo instead of creating new exposure, + # and dropping it would break the supervision contract: a firstmate stalled + # on a permission prompt cannot run bin/fm-wake-drain.sh to drain its wake + # queue or bin/fm-watch-arm.sh to arm its own watcher. Note that README.md:90 + # documents the primary launch as bare `claude` and the only in-repo claude + # launch carrying the flag directly is the headless print-mode session at + # tests/fm-claude-stop-autoarm-live-e2e.test.sh:115; the secondmate + # precedent, not those, is what fixes this arm. See the CONSUMER OBLIGATION + # in the header: a consumer must tell the captain this session has no + # permission prompts. claude) printf '%s' 'CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false claude --dangerously-skip-permissions __MODELFLAG____EFFORTFLAG__' ;; - # --dangerously-bypass-approvals-and-sandbox likewise carries over from the - # crewmate command; tests/fm-codex-continuity-live-e2e.test.sh:40 uses it - # under headless `codex exec`, which is not evidence of the interactive - # primary TUI shape. No in-repo primary codex TUI launch exists to pin. + # --dangerously-bypass-approvals-and-sandbox rests on the same secondmate + # precedent: the codex `kind = secondmate` arm in the crewmate case block + # below is the SECONDMATE template, and it launches an + # interactive firstmate primary with exactly this flag. The same status-quo + # and supervision-contract reasoning as the claude arm applies. It is worth + # recording what is NOT the evidence here: + # tests/fm-codex-continuity-live-e2e.test.sh:40 runs `codex exec`, headless, + # so it says nothing about the interactive primary TUI shape. The header's + # CONSUMER OBLIGATION covers this arm too. codex) printf '%s' 'codex __MODELFLAG____EFFORTFLAG__--dangerously-bypass-approvals-and-sandbox' ;; # --prompt carries the crewmate's brief, so it has no place in a briefless # primary; --auto is the empirically verified briefless form (a primary From 115467757eded52524de1877cb6ece9967b03570 Mon Sep 17 00:00:00 2001 From: Shane Bracewell Date: Sun, 26 Jul 2026 12:19:56 -0400 Subject: [PATCH 08/44] no-mistakes(review): bind consumer obligation to any bypass flag, add grok --- bin/fm-launch-lib.sh | 58 ++++++++++++++++++++++++++++++++------------ 1 file changed, 42 insertions(+), 16 deletions(-) diff --git a/bin/fm-launch-lib.sh b/bin/fm-launch-lib.sh index d50a4c3d6f1..cb193734db1 100644 --- a/bin/fm-launch-lib.sh +++ b/bin/fm-launch-lib.sh @@ -36,7 +36,7 @@ shell_quote() { # 2. the harness is verified but this kind is deliberately unsupported for it - # today only kimi with kind=primary, whose remedy is a different harness, # NOT the raw-launch escape hatch. -# Never add a permissive default arm to either case. bin/fm-spawn.sh:437 and :441 +# Never add a permissive default arm to either case. bin/fm-spawn.sh:446 and :450 # name only cause 1 because fm-spawn never passes kind=primary; a consumer that # does pass it owes the user the cause-2 wording. # @@ -88,14 +88,32 @@ shell_quote() { # codex `kind = secondmate` arm in the second case block below - launch that # interactive primary with exactly the autonomy flags those two arms carry. # -# CONSUMER OBLIGATION (binding, not advisory): the claude and codex primary -# templates launch a session that runs with NO permission prompts, and the -# opencode primary template allows every permission outright. A consumer that -# composes a primary launch from this library MUST surface that fact to the -# captain at launch time - one short line at launch or in the menu row is enough. -# The captain is entitled to know the posture of the session their front door -# starts; this obligation lives here so every consumer inherits it from the one -# owner instead of rediscovering it. +# CONSUMER OBLIGATION (binding, not advisory). The rule, which governs whatever +# the templates below happen to say: whenever a primary template carries a +# permission-bypass flag - anything that auto-approves, skips, or pre-allows the +# permission prompts an interactive session would otherwise raise - a consumer +# composing that launch MUST surface it to the captain at launch time. One short +# line at launch or in the menu row is enough. The captain is entitled to know +# the posture of the session their front door starts. The rule binds by flag, not +# by the roster below, so an adapter that gains a bypass flag later is covered the +# day it gains it, and a consumer cannot satisfy the letter of this note while +# silently shipping a no-prompt session. +# +# As of this commit the rule catches four of the six primary templates, each by a +# different mechanism: +# claude --dangerously-skip-permissions +# codex --dangerously-bypass-approvals-and-sandbox +# opencode OPENCODE_CONFIG_CONTENT='{"permission":{"*":"allow"}}', which +# pre-allows every permission before the TUI starts +# grok --always-approve, which .agents/skills/harness-adapters/SKILL.md:304 +# records as auto-approving every tool execution, verified to run +# fully unattended and equivalent to --permission-mode bypassPermissions +# The pi family (pi and pi-signed, which share one arm) is the sole exception, +# and its absence here is deliberate rather than an oversight: its primary +# template is the bare selected binary with only the FM_PI_HARNESS identity +# marker, which changes no permission posture, so a pi primary still prompts and +# there is nothing for a consumer to disclose. If that ever changes, the rule +# above already covers it. launch_template() { local harness=$1 kind=${2:-ship} # shellcheck disable=SC2016 # single quotes are deliberate: $(cat ...) expands in the crewmate pane, not here @@ -135,7 +153,9 @@ launch_template() { # --prompt carries the crewmate's brief, so it has no place in a briefless # primary; --auto is the empirically verified briefless form (a primary # opencode TUI is launched that way in - # tests/fm-opencode-primary-live-e2e.test.sh:256 and :310). + # tests/fm-opencode-primary-live-e2e.test.sh:256 and :310). The + # OPENCODE_CONFIG_CONTENT JSON pre-allows every permission, so the header's + # CONSUMER OBLIGATION covers this arm too. opencode) printf '%s' 'OPENCODE_CONFIG_CONTENT='\''{"permission":{"*":"allow"}}'\'' opencode __MODELFLAG__--auto' ;; # Bare `pi` is the documented primary launch (README.md:102); the project # trust prompt approved once per clone is what makes the tracked @@ -144,11 +164,14 @@ launch_template() { # --approve --no-session --no-context-files --no-extensions with explicit # -e paths, but those are that test's isolation scaffolding - it runs # against a throwaway clone - not the verified primary form, so they are - # deliberately not copied here. The FM_PI_HARNESS identity marker rides - # every Pi-family launch, primary included (README.md:104 documents the - # signed primary as `FM_PI_HARNESS=pi-signed pi-signed`): the selected - # $harness is both the invoked binary and the marker, so a signed - # primary's environment cannot relabel a plain Pi session. + # deliberately not copied here. --approve is part of that scaffolding, and + # leaving it out is why the pi family holds the one primary template the + # header's CONSUMER OBLIGATION does not catch: a pi primary still prompts. + # The FM_PI_HARNESS identity marker rides every Pi-family launch, primary + # included (README.md:104 documents the signed primary as + # `FM_PI_HARNESS=pi-signed pi-signed`): the selected $harness is both the + # invoked binary and the marker, so a signed primary's environment cannot + # relabel a plain Pi session. pi|pi-signed) printf '%s%s' "FM_PI_HARNESS=$harness $harness" ' __MODELFLAG____EFFORTFLAG__' ;; # --trust is supervision-safety knowledge, not one-time setup trivia: # without folder trust the primary turn-end guard FAILS OPEN @@ -158,7 +181,10 @@ launch_template() { # is tests/fm-grok-continuity-live-e2e.test.sh:76, # `grok --trust --always-approve --reasoning-effort low`, where # --reasoning-effort is what __EFFORTFLAG__ resolves to; README.md:96 - # documents the same `grok --trust`. + # documents the same `grok --trust`. --always-approve auto-approves every + # tool execution (.agents/skills/harness-adapters/SKILL.md:304), so the + # header's CONSUMER OBLIGATION covers this arm: a consumer must tell the + # captain this session has no permission prompts. grok) printf '%s' 'grok --trust --always-approve __MODELFLAG____EFFORTFLAG__' ;; # kimi refuses rather than emitting an unsubstitutable command: README.md:61 # lists only Claude Code, Grok, Pi, pi-signed, Codex, and OpenCode as verified From 0f428558813636103060a2f85d287035362a8693 Mon Sep 17 00:00:00 2001 From: Shane Bracewell Date: Sun, 26 Jul 2026 12:23:26 -0400 Subject: [PATCH 09/44] no-mistakes(review): bind consumer obligation to every primary, fix pi rationale --- bin/fm-launch-lib.sh | 44 ++++++++++++++++++++++++-------------------- 1 file changed, 24 insertions(+), 20 deletions(-) diff --git a/bin/fm-launch-lib.sh b/bin/fm-launch-lib.sh index cb193734db1..f66949814dd 100644 --- a/bin/fm-launch-lib.sh +++ b/bin/fm-launch-lib.sh @@ -89,18 +89,16 @@ shell_quote() { # interactive primary with exactly the autonomy flags those two arms carry. # # CONSUMER OBLIGATION (binding, not advisory). The rule, which governs whatever -# the templates below happen to say: whenever a primary template carries a -# permission-bypass flag - anything that auto-approves, skips, or pre-allows the -# permission prompts an interactive session would otherwise raise - a consumer -# composing that launch MUST surface it to the captain at launch time. One short -# line at launch or in the menu row is enough. The captain is entitled to know -# the posture of the session their front door starts. The rule binds by flag, not -# by the roster below, so an adapter that gains a bypass flag later is covered the -# day it gains it, and a consumer cannot satisfy the letter of this note while -# silently shipping a no-prompt session. +# the templates below happen to say: EVERY primary template here starts a session +# that runs without permission prompts, and a consumer composing a primary launch +# MUST surface that to the captain at launch time. One short line at launch or in +# the menu row is enough. The captain is entitled to know the posture of the +# session their front door starts. There are no exempt adapters. The rule binds on +# the posture, not on the presence of a particular flag, so a consumer cannot +# satisfy the letter of this note while silently shipping a no-prompt session. # -# As of this commit the rule catches four of the six primary templates, each by a -# different mechanism: +# All six reach that posture, four by an explicit bypass and the pi family +# structurally: # claude --dangerously-skip-permissions # codex --dangerously-bypass-approvals-and-sandbox # opencode OPENCODE_CONFIG_CONTENT='{"permission":{"*":"allow"}}', which @@ -108,12 +106,15 @@ shell_quote() { # grok --always-approve, which .agents/skills/harness-adapters/SKILL.md:304 # records as auto-approving every tool execution, verified to run # fully unattended and equivalent to --permission-mode bypassPermissions -# The pi family (pi and pi-signed, which share one arm) is the sole exception, -# and its absence here is deliberate rather than an oversight: its primary -# template is the bare selected binary with only the FM_PI_HARNESS identity -# marker, which changes no permission posture, so a pi primary still prompts and -# there is nothing for a consumer to disclose. If that ever changes, the rule -# above already covers it. +# pi (and pi-signed, which shares its arm) no flag, because none exists +# to pass: SKILL.md:272 records that pi has no permission system at +# all, so a pi session is autonomous by construction. Its template - +# the bare selected binary plus the FM_PI_HARNESS identity marker, +# which changes no permission posture - is complete, NOT missing an +# autonomy flag its siblings carry - do not add one. +# Pi's first-run project trust dialog (SKILL.md:276-278) is folder trust, not a +# permission prompt, and is a separate concern that neither satisfies nor softens +# this obligation. launch_template() { local harness=$1 kind=${2:-ship} # shellcheck disable=SC2016 # single quotes are deliberate: $(cat ...) expands in the crewmate pane, not here @@ -164,9 +165,12 @@ launch_template() { # --approve --no-session --no-context-files --no-extensions with explicit # -e paths, but those are that test's isolation scaffolding - it runs # against a throwaway clone - not the verified primary form, so they are - # deliberately not copied here. --approve is part of that scaffolding, and - # leaving it out is why the pi family holds the one primary template the - # header's CONSUMER OBLIGATION does not catch: a pi primary still prompts. + # deliberately not copied here. This template carries no autonomy flag + # because pi has none to carry: SKILL.md:272 records that pi has no + # permission system, so the session is autonomous by construction. The + # header's CONSUMER OBLIGATION therefore covers this arm like every other - + # a pi primary runs without permission prompts too, it just gets there + # structurally rather than by a bypass flag. Nothing is missing here. # The FM_PI_HARNESS identity marker rides every Pi-family launch, primary # included (README.md:104 documents the signed primary as # `FM_PI_HARNESS=pi-signed pi-signed`): the selected $harness is both the From a08388617be1c4cfc19492c3f261c275aad8ba43 Mon Sep 17 00:00:00 2001 From: Shane Bracewell Date: Sun, 26 Jul 2026 12:34:08 -0400 Subject: [PATCH 10/44] no-mistakes(document): point harness-adapters launch facts at fm-launch-lib owner --- .agents/skills/harness-adapters/SKILL.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.agents/skills/harness-adapters/SKILL.md b/.agents/skills/harness-adapters/SKILL.md index 6f94745c479..6e0bc5ec62b 100644 --- a/.agents/skills/harness-adapters/SKILL.md +++ b/.agents/skills/harness-adapters/SKILL.md @@ -26,8 +26,8 @@ If `config/crew-harness` is unset or `default`, there is no concrete value to in Inheritance also copies the literal `config/crew-dispatch.json` file, so secondmates apply the same best-fit profile rules for their own crewmates. Each adapter splits into mechanics and knowledge. -The per-task mechanics, including the autonomy flag and any enabled crewmate turn-end hook, live in `bin/fm-spawn.sh`. -`bin/fm-launch-lib.sh` is the single owner of every verified launch command, for crewmate, scout, secondmate, and primary sessions alike; never hand-write one. +The per-task mechanics, including any enabled crewmate turn-end hook, live in `bin/fm-spawn.sh`. +`bin/fm-launch-lib.sh` is the single owner of every verified launch command and the autonomy, model, and effort flags it carries, for crewmate, scout, secondmate, and primary sessions alike; never hand-write one. The primary-session "no turn ends blind" guard contract and harness hook installation paths live in `docs/turnend-guard.md`. The primary-session watcher wake protocols are rendered from `docs/supervision-protocols/` by `bin/fm-supervision-instructions.sh`. The supervision knowledge lives here: busy state, exit command, interrupt, dialogs, resume behavior, skill invocation, and quirks. @@ -126,7 +126,7 @@ The supported launch-profile flags below are verified locally; each row records | codex | `--model ` | `-c 'model_reasoning_effort=""'` | Verified on codex-cli 0.142.1. The installed binary schema contains `model_reasoning_effort`, the active config uses it, and the bundled model catalog advertises only low/medium/high/xhigh. `max` is omitted. | | grok | `--model ` | `--reasoning-effort ` | Verified on grok 0.2.99 (2026-07-13). `--effort` is an alias, but firstmate's profile axis is reasoning effort. As of 0.2.99 the ceiling is `high`; both `xhigh` and `max` are rejected with `use one of: high, medium, low`, so firstmate omits them. | | pi / pi-signed | `--model ` | `--thinking ` | Verified 2026-07-27 on Pi and pi-signed 0.82.0. Both expose the same accepted thinking levels and completed the same model-qualified max-thinking smoke. | -| opencode | `--model ` | none for firstmate's interactive launch | Verified on opencode 1.17.6. `opencode run` has `--variant`, but firstmate launches the interactive `opencode --prompt` path, which has no verified effort flag. | +| opencode | `--model ` | none for firstmate's interactive launch | Verified on opencode 1.17.6. `opencode run` has `--variant`, but firstmate launches opencode's interactive TUI, which has no verified effort flag. | | kimi | `--model ` | none | Verified 2026-07-25 on Kimi Code CLI 0.29.1. | The concrete `harness` field owns adapter identity independently of the model provider: `harness=pi` with `model=xai/grok-*` is Pi using xAI, not `harness=grok`, and does not require Grok CLI login; `harness=grok` remains the standalone Grok Build CLI adapter. @@ -283,7 +283,7 @@ The observed signed process tree is an exact `pi-signed` wrapper parent with the The installed plain `pi` command also execs that signed launcher, so `FM_PI_HARNESS=pi-signed` is the authoritative selection marker and shared unmarked ancestry remains `pi`. Firstmate sets `FM_PI_HARNESS` explicitly for both worker launch identities, and a signed primary uses the README launch command to establish the same boundary. Keep the brief as one positional argument. -Multiple positional args become separate queued messages; `fm-spawn`'s template already does this correctly. +Multiple positional args become separate queued messages; the verified pi template in `bin/fm-launch-lib.sh` already does this correctly. Project trust dialog can appear on the first pi run in any not-yet-trusted directory, observed even on clean worktrees. Accept with Enter. From cca9d3ac16be04545f0aec74747e2cfe32085728 Mon Sep 17 00:00:00 2001 From: Shane Bracewell Date: Tue, 28 Jul 2026 22:21:18 -0400 Subject: [PATCH 11/44] feat(bin): add the fleet launcher menu, Herdr gate, and reattach guard bin/fm-launch.sh is the captain's front door: it renders a five-entry harness menu, starts one firstmate primary session in this home, and attaches to it. The menu is derived and probed, never declared. An entry is available only when its harness binary resolves on PATH, or - for a Pi-routed entry - when the provider named in its model appears in pi's local auth record. Unavailable entries stay visible and dim, each with one actionable line, so the menu never changes shape under the captain's muscle memory. Both probes are local file reads, so the menu touches no network and executes no binary at all. Menu entries carry no launch command. They name a harness plus an optional model and effort, and the command is resolved through bin/fm-launch-lib.sh at launch time - the single owner a downstream registry has already drifted from once by hand-copying a launch string and dropping claude's ghost-text suppression prefix. The launcher states on every render, before the choice, that the session it starts runs without permission prompts. That discharges the consumer obligation bin/fm-launch-lib.sh's header binds on every consumer of a primary template. Herdr is mandatory with no silent fallback to a bare shell, and the gate runs after selection so no socket round trip sits on the critical path. Before creating anything the launcher looks for a primary already running in this home and offers to reattach, so two sessions can never contend for one home's session lock. Selection is one keypress. A human who mistypes gets a redrawn prompt; a scripted caller keeps the refuse-don't-reprompt behavior, and a blank line or EOF refuses rather than launching whatever the default happens to be - taking the default there once started an unattended session nobody chose. Presets live in gitignored config/launch-presets.json and the built-in five need no configuration. They are deliberately not inherited into secondmate homes: a secondmate is provisioned and launched by the primary through bin/fm-spawn.sh, never through this front door, so there would be no consumer for an inherited menu. The Windows entry point and WSL bridge are out of scope here and land separately. --- README.md | 8 + bin/fm-launch.sh | 684 ++++++++++++++++++++++++++++++ bin/fm-test-run.sh | 2 +- docs/configuration.md | 9 + docs/documentation-audiences.json | 5 + docs/launcher.md | 99 +++++ docs/scripts.md | 1 + tests/fm-launch.test.sh | 635 +++++++++++++++++++++++++++ 8 files changed, 1442 insertions(+), 1 deletion(-) create mode 100755 bin/fm-launch.sh create mode 100644 docs/launcher.md create mode 100755 tests/fm-launch.test.sh diff --git a/README.md b/README.md index ac54cf70259..41fe122cfef 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,13 @@ The hidden operational inputs remain ordinary user-role messages with unchanged The preference persists for the effective Firstmate home, and toggling it off restores ordinary rendering. [Calm's current behavior and supported limits](docs/calm.md) are separate from its [version-scoped maintainer evidence](docs/calm-mode-feasibility.md). +### Optional: launch from a menu + +If you run the Herdr backend, `bin/fm-launch.sh` offers the same launch as a short menu instead of a remembered command, starts the session in this home's Herdr workspace, and attaches to it. +It shows every configured harness with honest availability, remembers your last choice, and refuses rather than starting a second session in a home that already has one. +Herdr is required for this path; launching a harness directly, as above, stays fully supported on every backend. +See [docs/launcher.md](docs/launcher.md). + ### Talk to it ```sh @@ -198,6 +205,7 @@ Firstmate's skills live in two separate places with different audiences: - [docs/architecture.md](docs/architecture.md) - maintainer architecture for the crew, supervision, worktrees, secondmates, and project modes. - [docs/configuration.md](docs/configuration.md) - environment variables, `FM_HOME`, runtime backend selection, optional X mode, the files you set, and harness support. +- [docs/launcher.md](docs/launcher.md) - the optional harness menu that starts and attaches one primary session in a home. - [docs/calm.md](docs/calm.md) - current Pi `/calm` behavior and supported presentation limits. - [docs/wedge-alarm.md](docs/wedge-alarm.md) - configure the active alert for an away-mode escalation delivery that gets stuck. - [docs/tmux-backend.md](docs/tmux-backend.md) - current setup and limits for the tmux reference backend. diff --git a/bin/fm-launch.sh b/bin/fm-launch.sh new file mode 100755 index 00000000000..012becadcff --- /dev/null +++ b/bin/fm-launch.sh @@ -0,0 +1,684 @@ +#!/usr/bin/env bash +# fm-launch.sh - the captain's front door: pick a harness, start ONE firstmate +# primary session in this home, and attach to it. +# +# Usage: +# bin/fm-launch.sh render the menu, select, launch, attach +# bin/fm-launch.sh --print-menu render the menu and exit 0 (no side effects) +# bin/fm-launch.sh --verbose add the launch mechanics on stderr +# bin/fm-launch.sh --help +# +# This launches a PRIMARY (a firstmate session: no task, no worktree, no brief), +# never a crewmate. bin/fm-spawn.sh remains the only way to start a crewmate, +# scout, or secondmate. Both compose their command from bin/fm-launch-lib.sh, so +# there is exactly one copy of every verified launch command. +# +# THE CONSUMER OBLIGATION, DISCHARGED HERE. bin/fm-launch-lib.sh's header binds +# every consumer that composes a `primary` launch to tell the captain, at launch +# time, that the session runs WITHOUT permission prompts. This launcher +# discharges that with FM_LAUNCH_AUTONOMY_NOTICE below, printed in the menu +# header on every render - before the choice, not after it, so the captain knows +# the posture of the session while they are still choosing it. The obligation +# binds on the POSTURE, not on any particular flag, so this line must survive +# even if every template's flags change. Do not make it conditional, do not move +# it behind --verbose, and do not drop it because one adapter reaches the posture +# structurally rather than through a bypass flag. +# +# WHAT THIS SCRIPT DOES NOT DO, deliberately: +# - No network, ever, before the menu. Both availability probes are local file +# reads (`command -v`, and one read of pi's auth record), which is what holds +# the sub-150ms first-paint target. No catalog fetch, no quota probe, no +# `pi --list-models` (a subprocess plus JSON parse, ~1s when measured). +# - No `herdr status` before the menu. The Herdr gate is needed to LAUNCH, not +# to CHOOSE, so it runs after selection and keeps a socket round trip off the +# critical path. +# - No side effects before selection: `q` and Ctrl-C are always clean. +# - No bin/fm-guard.sh call. That guard reports supervision mechanics to a +# RUNNING firstmate; this runs before one exists, and its warnings are +# exactly the mechanics the front door keeps off the captain's screen. +# +# Files, all local to the effective FM_HOME: +# config/launch-presets.json optional menu presets; absent means the built-in +# five below. Schema: docs/configuration.md. +# state/.launch-last the last-used entry id, written atomically +# (temp + mv) so a killed launcher can never +# corrupt the Enter default. +# +# Test seams (documented so the suite does not reach into internals): +# FM_LAUNCH_PRESETS override the presets path +# FM_LAUNCH_PI_AUTH override pi's auth record path +# FM_LAUNCH_NO_ATTACH=1 do everything except the final attach +# FM_LAUNCH_READY_ATTEMPTS bounded shell-prompt poll attempts (default 10) +# FM_LAUNCH_READY_SLEEP seconds between those attempts (default 0.3) +set -eu + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" +FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" +STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" +CONFIG="${FM_CONFIG_OVERRIDE:-$FM_HOME/config}" + +PRESETS="${FM_LAUNCH_PRESETS:-$CONFIG/launch-presets.json}" +PI_AUTH="${FM_LAUNCH_PI_AUTH:-$HOME/.pi/agent/auth.json}" +LAST_USED="$STATE/.launch-last" + +# The herdr TAB label for this home's one primary session. The WORKSPACE label +# is already per-home (fm_backend_herdr_workspace_label: "firstmate", or +# "2ndmate-"), so this constant is unambiguous inside it and can never +# collide with a crewmate tab, which is always labeled fm-. +PRIMARY_TAB_LABEL="firstmate" + +# See THE CONSUMER OBLIGATION above before changing or moving this line. +FM_LAUNCH_AUTONOMY_NOTICE="Sessions start without permission prompts." + +VERBOSE=0 +PRINT_MENU=0 + +usage() { + cat <<'USAGE' +fm-launch.sh - the captain's front door: pick a harness, start ONE firstmate +primary session in this home, and attach to it. + +Usage: + bin/fm-launch.sh render the menu, select, launch, attach + bin/fm-launch.sh --print-menu render the menu and exit 0 (no side effects) + bin/fm-launch.sh --verbose add the launch mechanics on stderr + bin/fm-launch.sh --help + +Every session this starts runs WITHOUT permission prompts; the menu says so +before you choose. Menu presets and setup: docs/launcher.md. +USAGE +} + +while [ $# -gt 0 ]; do + case "$1" in + --verbose|-v) VERBOSE=1 ;; + --print-menu) PRINT_MENU=1 ;; + -h|--help) usage; exit 0 ;; + *) echo "fm-launch.sh: unknown option '$1' (try --help)" >&2; exit 2 ;; + esac + shift +done + +vlog() { [ "$VERBOSE" -eq 1 ] && printf 'fm-launch: %s\n' "$*" >&2 || true; } + +# --- presentation ----------------------------------------------------------- +# +# Every refusal is at most a few lines: what happened, and the one thing that +# fixes it. Never a stack trace, never a wall of diagnostics - --verbose exists +# for when the captain wants one. + +TTY=0 +[ -t 0 ] && [ -t 1 ] && TTY=1 +DIM=""; RESET="" +if [ "$TTY" -eq 1 ] && [ -z "${NO_COLOR:-}" ]; then + DIM=$'\033[2m'; RESET=$'\033[0m' +fi + +# refuse ...: print an indented refusal and exit non-zero. +refuse() { + local line + printf '\n' + for line in "$@"; do printf ' %s\n' "$line"; done + exit 1 +} + +# refuse_at_door ...: a refusal reached before the menu was ever drawn, so +# it carries the header the menu would have carried. +refuse_at_door() { + printf '\n Firstmate\n' + refuse "$@" +} + +# --- presets ---------------------------------------------------------------- +# +# An entry is a record of five fields - id, label, harness, model, effort - +# joined by the ASCII unit separator. NOT by a tab: a tab is IFS whitespace, so +# splitting on one silently COLLAPSES empty fields, and an entry missing its id +# would arrive here looking like a well-formed entry with everything shifted one +# place left. The unit separator is not IFS whitespace, so an empty field stays +# an empty field and the validation below can see it. An entry carries NO +# command. The command comes from launch_template() at launch time, +# which is the whole point: a hand-written launch string has already drifted once +# in a downstream registry (see bin/fm-launch-lib.sh's header). +# +# "default" for model or effort means "let the adapter choose", exactly as +# model_flag_for_harness/effort_flag_for_harness already define it. +# +# The built-in five ship with model="default" on the NATIVE entries on purpose: +# this repo is a shared template, and a pinned model id rots for every home whose +# account cannot reach it. The two Pi-routed entries DO pin a provider-qualified +# model because that string is what selects the provider at all - without it the +# entry is not "Grok via pi", it is just pi. +FM_LAUNCH_US=$'\037' +FM_LAUNCH_BUILTIN_PRESETS=( + $'claude\037Claude\037claude\037default\037high' + $'chatgpt-sol\037ChatGPT Sol\037pi\037openai-codex/gpt-5.6-sol\037high' + $'grok\037Grok\037pi\037xai/grok-4\037high' + $'codex\037Codex\037codex\037default\037high' + $'opencode\037OpenCode\037opencode\037default\037default' +) + +ENTRIES=() + +load_presets() { + local line + if [ ! -f "$PRESETS" ]; then + ENTRIES=("${FM_LAUNCH_BUILTIN_PRESETS[@]}") + return 0 + fi + command -v jq >/dev/null 2>&1 \ + || refuse_at_door "Cannot read the menu presets at $PRESETS." \ + "Install jq, or remove that file to use the built-in menu." + local parsed + parsed=$(jq -r ' + if (.entries | type) != "array" then error("entries must be an array") else . end + | .entries[] + | [(.id // ""), (.label // ""), (.harness // ""), (.model // "default"), (.effort // "default")] + | map(tostring) + | join("\u001f")' "$PRESETS" 2>/dev/null) \ + || refuse_at_door "The menu presets at $PRESETS are not valid." \ + "Fix the file, or remove it to use the built-in menu." + while IFS= read -r line; do + [ -n "$line" ] || continue + # jq renders a missing key as an empty field, so an entry short of its three + # required keys arrives here looking merely blank. Refuse it rather than + # rendering an unnamed row that cannot launch. + entry_split "$line" + [ -n "$E_ID" ] && [ -n "$E_LABEL" ] && [ -n "$E_HARNESS" ] \ + || refuse_at_door "An entry in $PRESETS is missing its id, label, or harness." \ + "Give every entry all three, or remove the file to use the built-in menu." + ENTRIES+=("$line") + done <: unpack one record into E_ID/E_LABEL/E_HARNESS/E_MODEL/ +# E_EFFORT. +# +# Every hot-path helper in this file communicates through globals rather than +# stdout, and none of them may be called through $(...). That is a speed +# contract, not a style preference: first paint is budgeted under 150 ms, a fork +# costs 2-4 ms, and the menu touches these helpers roughly seven times per entry. +# Routing them through command substitution measured 152 ms against a 2 ms bash +# floor - the forks WERE the entire budget. +entry_split() { # + local rec=$1 IFS=$FM_LAUNCH_US + # shellcheck disable=SC2086 # deliberate word split on the unit separator via IFS + set -- $rec + E_ID=${1:-}; E_LABEL=${2:-}; E_HARNESS=${3:-}; E_MODEL=${4:-}; E_EFFORT=${5:-} +} + +# --- availability probe ----------------------------------------------------- +# +# Probed, never declared. An entry is available only when it can actually start +# today, and an entry that cannot stays VISIBLE and dim rather than disappearing, +# so the menu never changes shape under the captain's muscle memory. +# +# Two local reads, no network: +# native - the harness binary resolves on PATH. This launcher is the front +# door, so its PATH is the captain's login PATH. +# pi-routed - `pi` resolves AND the entry's provider (the part of the model +# before "/") appears as a key in pi's auth record. +# +# pi's auth record is a flat JSON object keyed by provider id, whose values are +# OAuth objects with fixed field names (type, access, refresh, expires, +# accountId). Matching "" followed by a colon therefore identifies a +# top-level provider key and cannot collide with a nested field name. Doing it +# with a shell match rather than a jq subprocess is what keeps first paint fast; +# an absent or unreadable record simply means "no providers", which renders the +# entry unavailable with its sign-in line - the honest, fail-closed direction. +# harness_on_path : memoized `command -v`, because a PATH MISS is the +# single most expensive thing the menu does and presets may name one harness +# more than once (the two Pi-routed entries both need `pi`). +# +# Measured on a WSL home whose PATH carries 29 Windows mounts among 40 entries: +# a HIT costs under 2 ms because the lookup stops at the first match, while a +# MISS scans every remaining directory and costs about 55 ms - the built-in +# menu's two uninstalled native entries alone account for ~113 ms of a ~125 ms +# render. Dropping the Windows mounts from PATH and changing nothing else takes +# the same render to ~7 ms, so the sub-150 ms first-paint target is held by +# construction and what remains is 9p directory access, not this file's work. +# Nothing in this file can shorten that without lying about what is installed, +# so the ENFORCED ceiling in tests/fm-launch.test.sh is 500 ms. +HARNESS_PATH_MEMO="" + +harness_on_path() { # + local h=$1 hit + case "$HARNESS_PATH_MEMO" in + *":$h=1:"*) return 0 ;; + *":$h=0:"*) return 1 ;; + esac + if command -v "$h" >/dev/null 2>&1; then hit=1; else hit=0; fi + HARNESS_PATH_MEMO="$HARNESS_PATH_MEMO:$h=$hit:" + [ "$hit" -eq 1 ] +} + +PI_AUTH_BLOB="" +PI_AUTH_READ=0 + +pi_auth_load() { + [ "$PI_AUTH_READ" -eq 0 ] || return 0 + PI_AUTH_READ=1 + PI_AUTH_BLOB="" + # $( + local provider=$1 re + pi_auth_load + [ -n "$PI_AUTH_BLOB" ] || return 1 + # Held in a variable so the provider name interpolates while the rest stays + # regex: bash 3.2 treats a quoted pattern operand as a literal string. + re="\"$provider\"[[:space:]]*:" + [[ $PI_AUTH_BLOB =~ $re ]] +} + +# pi_provider_of : sets E_PROVIDER to the provider a pi model string +# selects, or empty when the model carries none (pi then uses its own configured +# default provider). +E_PROVIDER="" +pi_provider_of() { # + local model=$1 + E_PROVIDER="" + case "$model" in + default|'') return 0 ;; + */*) E_PROVIDER=${model%%/*} ;; + esac +} + +# probe_entry : sets P_STATUS (ok|unavailable) and P_NOTE - the ONE +# actionable line for an unavailable entry, or the route hint ("via pi") for an +# available Pi-routed one. Requires entry_split to have run for this record. +# +# The two refusal causes launch_template() distinguishes are BOTH reported here, +# with the wording each one owes the captain (bin/fm-launch-lib.sh header): +# a harness with no verified adapter at all is a preset error, while a verified +# harness that simply has no primary shape is fixed by choosing another entry - +# never by reaching for a raw launch command. Deriving cause 2 as "works as a +# crewmate, refuses as a primary" keeps this free of a hardcoded harness name. +P_STATUS="" +P_NOTE="" +probe_entry() { + local route="" + P_STATUS=unavailable + + if ! launch_template "$E_HARNESS" primary >/dev/null 2>&1; then + if launch_template "$E_HARNESS" ship >/dev/null 2>&1; then + P_NOTE="no primary session - choose another entry" + else + P_NOTE="unknown runtime $E_HARNESS - fix the menu presets" + fi + return 0 + fi + + E_PROVIDER="" + [ "$E_HARNESS" = pi ] && { route="via pi"; pi_provider_of "$E_MODEL"; } + + if ! harness_on_path "$E_HARNESS"; then + P_NOTE="not installed - install $E_HARNESS" + return 0 + fi + + if [ -n "$E_PROVIDER" ] && ! pi_provider_authed "$E_PROVIDER"; then + P_NOTE="$route - sign in: pi /login" + return 0 + fi + + P_STATUS=ok + P_NOTE=$route +} + +# --- menu render ------------------------------------------------------------ + +# entry_detail: sets E_DETAIL, the model/effort column, from the current +# entry_split fields. Renders only what is actually pinned - the literal word +# "default" is noise, not information - and shows a plain dash when the adapter +# decides everything. +E_DETAIL="" +entry_detail() { + E_DETAIL="" + [ "$E_MODEL" = default ] || E_DETAIL=${E_MODEL##*/} + if [ "$E_EFFORT" != default ]; then + [ -n "$E_DETAIL" ] && E_DETAIL="$E_DETAIL · $E_EFFORT" || E_DETAIL=$E_EFFORT + fi + [ -n "$E_DETAIL" ] || E_DETAIL="-" +} + +# pad : set to left-aligned to COLUMNS. +# printf's %-Ns pads by BYTES, which misaligns every row whose detail carries a +# multi-byte separator, so the width is computed from bash's character count. +pad() { # + local fill="" short=$(( $3 - ${#2} )) + [ "$short" -gt 0 ] && printf -v fill '%*s' "$short" '' + printf -v "$1" '%s%s' "$2" "$fill" +} + +STATUSES=() +NOTES=() + +probe_all() { + local rec + STATUSES=(); NOTES=() + for rec in "${ENTRIES[@]}"; do + entry_split "$rec" + probe_entry + STATUSES+=("$P_STATUS") + NOTES+=("$P_NOTE") + done +} + +# default_index: the entry Enter takes. The last-used entry when it is still +# available, otherwise the first available entry, otherwise none (0). A stale or +# now-unavailable last-used entry must never become an Enter that cannot launch. +DEFAULT_INDEX=0 +DEFAULT_MARK="← default" + +resolve_default() { + local last="" i + DEFAULT_INDEX=0 + DEFAULT_MARK="← default" + if [ -r "$LAST_USED" ]; then + IFS=$' \t\n' read -r last < "$LAST_USED" || last="" + fi + if [ -n "$last" ]; then + for i in "${!ENTRIES[@]}"; do + entry_split "${ENTRIES[$i]}" + if [ "$E_ID" = "$last" ] && [ "${STATUSES[$i]}" = ok ]; then + DEFAULT_INDEX=$((i + 1)) + DEFAULT_MARK="← last" + return 0 + fi + done + fi + for i in "${!ENTRIES[@]}"; do + if [ "${STATUSES[$i]}" = ok ]; then + DEFAULT_INDEX=$((i + 1)) + return 0 + fi + done +} + +render_menu() { + local i n note row padded_label padded_detail default_label="" + printf '\n Firstmate\n' + printf ' %s\n\n' "$FM_LAUNCH_AUTONOMY_NOTICE" + for i in "${!ENTRIES[@]}"; do + n=$((i + 1)) + entry_split "${ENTRIES[$i]}" + entry_detail + [ "$n" -eq "$DEFAULT_INDEX" ] && default_label=$E_LABEL + note=${NOTES[$i]} + [ "$n" -eq "$DEFAULT_INDEX" ] && note=${note:+"$note "}$DEFAULT_MARK + pad padded_label "$E_LABEL" 18 + pad padded_detail "$E_DETAIL" 22 + row=" $n $padded_label $padded_detail $note" + # Trailing spaces are noise in a captured menu; strip them. + row=${row%"${row##*[![:space:]]}"} + if [ "${STATUSES[$i]}" = ok ]; then + printf '%s\n' "$row" + else + printf '%s%s%s\n' "$DIM" "$row" "$RESET" + fi + done + if [ "$DEFAULT_INDEX" -gt 0 ]; then + printf '\n ⏎ %s 1-%d select q quit\n' "$default_label" "${#ENTRIES[@]}" + else + printf '\n nothing is available yet 1-%d select q quit\n' "${#ENTRIES[@]}" + fi +} + +# --- selection -------------------------------------------------------------- +# +# One keypress selects AND launches; Enter takes the default; q quits clean. +# +# An invalid key redraws the prompt with an inline "?" and waits. That is a +# deliberate divergence from firstmate's refuse-don't-reprompt law, and only for +# a TTY: that law is right for a SCRIPTED selection, where a wrong value must +# fail loudly rather than be guessed, and wrong for a human who mistyped a key at +# their own front door. Determinism is preserved either way - the input alphabet +# is closed and the mapping is total; only the response to invalid input differs. +# Non-TTY stdin keeps the original behavior exactly: one line, no reprompt. + +SELECTED=0 + +prompt_line() { # + printf '\r\033[K› %s' "$1" +} + +select_entry_tty() { + local key n status + while :; do + prompt_line "" + IFS= read -rsn1 key || { printf '\n'; exit 1; } + case "$key" in + q|Q) printf '\r\033[K'; exit 0 ;; + '') n=$DEFAULT_INDEX ;; + [0-9]) n=$key ;; + *) prompt_line "?"; continue ;; + esac + if [ "$n" -lt 1 ] || [ "$n" -gt "${#ENTRIES[@]}" ]; then + prompt_line "?" + continue + fi + status=${STATUSES[$((n - 1))]} + if [ "$status" != ok ]; then + prompt_line "$n ${NOTES[$((n - 1))]}" + continue + fi + printf '\r\033[K› %s\n' "$n" + SELECTED=$n + return 0 + done +} + +select_entry_pipe() { + local line n + IFS=$' \t\n' read -r line || line="" + case "$line" in + q|Q) exit 0 ;; + # EOF and a blank line REFUSE here rather than taking the default. Enter + # taking the default is a convenience for a human at a keyboard; for a + # scripted caller the same silence would mean `fm-launch.sh < /dev/null` + # starts a real unattended session nobody chose. A scripted selection must + # be explicit - that is the refuse-don't-reprompt law this path preserves. + '') refuse "No selection was made." "Choose 1-${#ENTRIES[@]}, or q to quit." ;; + [0-9]|[0-9][0-9]) n=$line ;; + *) refuse "'$line' is not one of the menu choices." "Choose 1-${#ENTRIES[@]}, or q to quit." ;; + esac + if [ "$n" -lt 1 ] || [ "$n" -gt "${#ENTRIES[@]}" ]; then + refuse "There is no menu entry $n." "Choose 1-${#ENTRIES[@]}, or q to quit." + fi + if [ "${STATUSES[$((n - 1))]}" != ok ]; then + entry_split "${ENTRIES[$((n - 1))]}" + refuse "$E_LABEL is not available: ${NOTES[$((n - 1))]}." + fi + printf '› %s\n' "$n" + SELECTED=$n +} + +# --- launch ----------------------------------------------------------------- + +# remember_choice : atomic temp + mv, so a launcher killed mid-write leaves +# the previous default intact rather than a truncated file. +remember_choice() { # + local id=$1 tmp + mkdir -p "$STATE" 2>/dev/null || return 0 + tmp="$LAST_USED.tmp.$$" + printf '%s\n' "$id" > "$tmp" 2>/dev/null || return 0 + mv -f "$tmp" "$LAST_USED" 2>/dev/null || rm -f "$tmp" 2>/dev/null || true +} + +# herdr_gate: Herdr is MANDATORY and there is no silent fallback to a bare +# shell. Absence is this launcher's own condition and gets the front door's +# wording; every other verdict belongs to fm_backend_herdr_version_check, whose +# message carries the protocol numbers and is relayed rather than restated. +herdr_gate() { + local out + command -v herdr >/dev/null 2>&1 \ + || refuse "Herdr is required and was not found." \ + "Install it (https://herdr.dev), then relaunch." + if ! out=$(fm_backend_herdr_version_check 2>&1); then + refuse "Herdr is required and this one cannot be used." "${out#error: }" + fi +} + +# live_primary_pane: the pane of a primary already running in this home, or +# empty. Read-only, and skipped entirely when no herdr server is up - if nothing +# is running, nothing can be reattached to. Reattach is checked BEFORE any create +# so a second launch can never leave two primaries contending for this home's +# session lock. +live_primary_pane() { # + local session=$1 running wsid tab pane + running=$(fm_backend_herdr_cli "$session" status --json 2>/dev/null | jq -r '.server.running // false' 2>/dev/null) + [ "$running" = true ] || return 0 + wsid=$(fm_backend_herdr_workspace_find "$session") + [ -n "$wsid" ] || return 0 + tab=$(fm_backend_herdr_cli "$session" tab list --workspace "$wsid" 2>/dev/null \ + | jq -r --arg want "$PRIMARY_TAB_LABEL" '.result.tabs[]? | select(.label == $want) | .tab_id' 2>/dev/null | head -1) + [ -n "$tab" ] || return 0 + pane=$(fm_backend_herdr_pane_for_tab "$session" "$wsid" "$tab") + [ -n "$pane" ] || return 0 + [ "$(fm_backend_herdr_pane_agent_state "$session" "$pane")" = live ] || return 0 + printf '%s' "$pane" +} + +attach() { # + local session=$1 + if [ -n "${FM_LAUNCH_NO_ATTACH:-}" ]; then + vlog "attach suppressed by FM_LAUNCH_NO_ATTACH" + return 0 + fi + exec herdr session attach "$session" +} + +# wait_for_prompt : a bounded deadline poll for the pane's shell prompt, +# never a bare sleep. It gates on the SHELL, not on the agent, so the attach +# lands well before the model's first token. Exhaustion refuses with the attempt +# count and creates nothing further; it never proceeds hopefully. +wait_for_prompt() { # + local target=$1 attempts=${FM_LAUNCH_READY_ATTEMPTS:-10} nap=${FM_LAUNCH_READY_SLEEP:-0.3} i out + for i in $(seq 1 "$attempts"); do + out=$(fm_backend_herdr_capture "$target" 5 2>/dev/null) || out="" + if [ -n "$(printf '%s' "$out" | tr -d '[:space:]')" ]; then + vlog "shell prompt ready after attempt $i" + return 0 + fi + sleep "$nap" + done + return 1 +} + +launch_entry() { # + local rec=$1 session container seeded ids tab pane + local template modelflag effortflag launch target + entry_split "$rec" + entry_detail + + printf ' %s · %s\n' "$E_LABEL" "$E_DETAIL" + + # shellcheck source=bin/fm-backend.sh + . "$SCRIPT_DIR/fm-backend.sh" + fm_backend_source herdr || refuse "The Herdr runtime could not be loaded." "Reinstall firstmate, then relaunch." + + herdr_gate + session=$(fm_backend_herdr_session) + + pane=$(live_primary_pane "$session") + if [ -n "$pane" ]; then + if [ "$TTY" -eq 1 ]; then + printf '\n A firstmate session is already running here.\n' + printf ' r reattach q quit ' + local key + IFS= read -rsn1 key || key=q + printf '\n' + case "$key" in + r|R|'') attach "$session"; return 0 ;; + *) exit 0 ;; + esac + fi + refuse "A firstmate session is already running here." \ + "Attach to it, or close it before starting another." + fi + + template=$(launch_template "$E_HARNESS" primary) \ + || refuse "$E_LABEL cannot start a firstmate session." "Choose another entry." + modelflag=$(model_flag_for_harness "$E_HARNESS" "$E_MODEL") + effortflag=$(effort_flag_for_harness "$E_HARNESS" "$E_EFFORT") + launch=${template//__MODELFLAG__/$modelflag} + launch=${launch//__EFFORTFLAG__/$effortflag} + # An unset flag placeholder leaves one trailing space (bin/fm-launch-lib.sh + # header); cosmetic in a shell command, trimmed here. + launch=${launch%"${launch##*[![:space:]]}"} + # The pane's shell is a child of the herdr SERVER, which this launcher may + # itself have started - so it can inherit this process's overrides. Clear them + # and pin FM_HOME explicitly, the same shape bin/fm-spawn.sh uses to launch a + # firstmate primary in a secondmate home. + launch="FM_ROOT_OVERRIDE= FM_STATE_OVERRIDE= FM_DATA_OVERRIDE= FM_PROJECTS_OVERRIDE= FM_CONFIG_OVERRIDE= FM_HOME=$(shell_quote "$FM_HOME") $launch" + vlog "launch: $launch" + + fm_backend_herdr_server_ensure "$session" \ + || refuse "The Herdr server did not start." "Retry, or run with --verbose." + container=$(fm_backend_herdr_container_ensure "$FM_HOME") \ + || refuse "The Herdr workspace for this home could not be prepared." "Retry, or run with --verbose." + seeded=${container#*$'\t'} + container=${container%%$'\t'*} + ids=$(fm_backend_herdr_create_task "$container" "$PRIMARY_TAB_LABEL" "$FM_HOME" "$seeded") \ + || refuse "The firstmate session could not be created." "Retry, or run with --verbose." + tab=${ids%% *} + pane=${ids##* } + target="$session:$pane" + vlog "workspace=$container tab=$tab pane=$pane" + + wait_for_prompt "$target" \ + || refuse "The session did not start within ${FM_LAUNCH_READY_ATTEMPTS:-10} attempts." \ + "Retry, or run with --verbose." + + # One line, one round trip: `clear` wipes both the echoed command itself and + # any shell banner in the fresh pane, and the agent starts in its place - so + # the first thing on screen after the attach repaint is firstmate's greeting. + fm_backend_herdr_send_literal "$target" "clear && $launch" \ + || refuse "The session could not be started in its pane." "Retry, or run with --verbose." + fm_backend_herdr_send_key "$target" Enter \ + || refuse "The session could not be started in its pane." "Retry, or run with --verbose." + + remember_choice "$E_ID" + fm_backend_herdr_cli "$session" tab focus "$tab" >/dev/null 2>&1 || true + attach "$session" +} + +# --- main ------------------------------------------------------------------- + +[ -d "$FM_HOME" ] \ + || refuse_at_door "No firstmate home at $FM_HOME." "Set FM_HOME, or finish setup, then relaunch." + +# shellcheck source=bin/fm-launch-lib.sh +. "$SCRIPT_DIR/fm-launch-lib.sh" + +load_presets +probe_all +resolve_default +render_menu + +[ "$PRINT_MENU" -eq 1 ] && exit 0 + +if [ "$TTY" -eq 1 ]; then + select_entry_tty +else + select_entry_pipe +fi + +launch_entry "${ENTRIES[$((SELECTED - 1))]}" diff --git a/bin/fm-test-run.sh b/bin/fm-test-run.sh index e723d955106..3d956f627ea 100755 --- a/bin/fm-test-run.sh +++ b/bin/fm-test-run.sh @@ -163,7 +163,7 @@ family_for_basename() { printf '%s\n' live-harness-optin ;; fm-backend-herdr.test.sh|fm-backend-tmux-smoke.test.sh|fm-backend.test.sh|\ - fm-herdr-session-cleanup.test.sh|fm-send-strict.test.sh|fm-spawn-batch.test.sh|\ + fm-herdr-session-cleanup.test.sh|fm-launch.test.sh|fm-send-strict.test.sh|fm-spawn-batch.test.sh|\ fm-spawn-dispatch-profile.test.sh|fm-spawn-worktree-settle.test.sh|\ fm-teardown-endpoint-safety.test.sh) printf '%s\n' backend-dispatch diff --git a/docs/configuration.md b/docs/configuration.md index fadf2c60a2b..87de85256a9 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -31,6 +31,15 @@ The `/calm` command replaces the file atomically before changing live presentati The extension reloads this preference on every Pi `session_start`, including startup, new, resume, fork, and reload reasons. This preference is local to each Firstmate home and is not part of secondmate inherited configuration. +## Fleet launcher menu (config/launch-presets.json / state/.launch-last) + +`bin/fm-launch.sh` reads its menu from gitignored `config/launch-presets.json` under the effective Firstmate home, and falls back to a built-in five-entry menu when that file is absent, so a fresh home needs no configuration. +Each entry is an object with `id`, `label`, and `harness`, plus optional `model` and `effort` that both default to `default`; an entry never carries a launch command, because `bin/fm-launch-lib.sh` remains the single owner of every verified launch command. +A present but malformed file refuses at the door rather than silently falling back to the built-in menu, and an entry naming an adapter with no verified primary shape renders unavailable rather than launching. +The launcher records the last successfully launched entry id in `state/.launch-last`, written atomically so an interrupted launcher cannot corrupt the default. +Neither file is part of secondmate inherited configuration: a secondmate home is provisioned and launched by the primary through `bin/fm-spawn.sh`, never through this front door, so there is no consumer for an inherited menu there. +[docs/launcher.md](launcher.md) is the operator-facing owner of launcher behavior and setup. + ## Backlog backend (.tasks.toml / config/backlog-backend) The tracked `.tasks.toml` pins the default `tasks-axi` markdown backend to `data/backlog.md`, with `done_keep = 10` and an archive at `data/done-archive.md`. diff --git a/docs/documentation-audiences.json b/docs/documentation-audiences.json index f6075ab0852..9e3648a85d4 100644 --- a/docs/documentation-audiences.json +++ b/docs/documentation-audiences.json @@ -24,6 +24,7 @@ "operator-example" ], "readmeSetupTargets": [ + "docs/launcher.md", "docs/configuration.md", "docs/wedge-alarm.md", "docs/tmux-backend.md", @@ -255,6 +256,10 @@ "path": "docs/herdr-backend.md", "audience": "operator-current" }, + { + "path": "docs/launcher.md", + "audience": "operator-current" + }, { "path": "docs/orca-backend.md", "audience": "operator-current" diff --git a/docs/launcher.md b/docs/launcher.md new file mode 100644 index 00000000000..6a7424d4d0b --- /dev/null +++ b/docs/launcher.md @@ -0,0 +1,99 @@ +# Fleet launcher + +`bin/fm-launch.sh` is the captain's front door. +It renders a short harness menu, starts one firstmate primary session in this home, and attaches to it. + +```sh +bin/fm-launch.sh +``` + +``` + Firstmate + Sessions start without permission prompts. + + 1 Claude high ← last + 2 ChatGPT Sol gpt-5.6-sol · high via pi + 3 Grok grok-4 · high via pi - sign in: pi /login + 4 Codex high not installed - install codex + 5 OpenCode - not installed - install opencode + + ⏎ Claude 1-5 select q quit +``` + +Press `1`-`5` to select and launch in one keypress, `Enter` to take the marked entry, or `q` to quit. +Quitting creates nothing. +A mistyped key redraws the prompt and waits rather than abandoning the launch. + +The launcher starts a PRIMARY session only. +Crewmates, scouts, and secondmates continue to come from `bin/fm-spawn.sh`; both compose their commands from the single owner in `bin/fm-launch-lib.sh`. + +## Sessions start without permission prompts + +Every harness the menu can start runs its session without permission prompts, four through an explicit bypass flag and one because that harness has no permission system at all. +The launcher states this on every render, before you choose, because you are entitled to know the posture of the session your front door starts. +`bin/fm-launch-lib.sh` records that obligation and which flag each adapter uses. + +## Availability is probed, not declared + +An entry is shown as available only when it could actually start right now. +Unavailable entries stay visible and dimmed, each carrying the one thing that would fix it, so the menu never changes shape and the numbering stays stable. + +Two local checks decide it, and neither touches the network: + +- A native entry is available when its harness binary resolves on `PATH`. +- A Pi-routed entry is available when `pi` resolves and the provider named in its model (the part before `/`) appears in `~/.pi/agent/auth.json`. + +Because both checks are local file reads, the menu paints in well under a tenth of a second. +The one thing that slows it is a `PATH` whose misses cross a slow filesystem, such as the Windows mounts a WSL shell inherits; `bin/fm-launch.sh` records the measured numbers. + +## Menu presets + +The built-in menu needs no configuration. +To change it, create `config/launch-presets.json` in your firstmate home; it is local and gitignored like every other `config/` entry, and it replaces the built-in menu entirely. + +```json +{ + "entries": [ + { "id": "claude", "label": "Claude", "harness": "claude", "model": "claude-opus-5", "effort": "high" }, + { "id": "chatgpt-sol", "label": "ChatGPT Sol", "harness": "pi", "model": "openai-codex/gpt-5.6-sol", "effort": "high" } + ] +} +``` + +An entry carries no command. +It names a verified harness plus an optional model and effort, and the launcher resolves the actual command through `bin/fm-launch-lib.sh` at launch time. +That is deliberate: a hand-written launch string has already drifted once in a downstream tool, dropping a flag firstmate depends on. + +`id` is what the launcher remembers as your last choice, `label` is what the menu shows, and `harness` must be one of the verified adapters that supports a primary session. +Omit `model` or `effort`, or set either to `"default"`, to let the harness choose. +A malformed file refuses at the door rather than silently falling back to the built-in menu. + +To reach a harness you have not installed, route it through Pi: set `"harness": "pi"` and a provider-qualified model such as `"xai/grok-4"`, then sign in once with `/login` inside Pi. + +Your last successful choice is remembered in `state/.launch-last` and becomes the `Enter` default. +If that entry later stops being available, `Enter` falls back to the first available one instead of offering a choice that cannot start. + +## Herdr is required + +The launcher creates the session as a tab in this home's Herdr workspace, so Herdr must be installed and recent enough. +It refuses with one actionable line if Herdr is missing or its protocol is older than firstmate's verified minimum, and it never falls back to a bare shell. +The Herdr check runs after you choose, not before the menu, so choosing stays instant. + +See [docs/herdr-backend.md](herdr-backend.md) for Herdr setup. + +## One primary per home + +Before creating anything, the launcher looks for a primary already running in this home. +If it finds one, it offers to reattach instead of starting a second, so two sessions can never contend for the same home's session lock. +A leftover tab whose agent is gone is not a running session and is replaced normally. + +## When something goes wrong + +Every refusal is one or two lines: what happened, and the one thing that fixes it. +Run with `--verbose` for the underlying mechanics, and `--print-menu` to render the menu without launching anything. + +## Maintaining this file + +This file is the operator-facing owner of launcher setup and behavior. +Keep it to current behavior, supported limits, and the files an operator sets. +Exact flags, mechanics, and rationale belong in `bin/fm-launch.sh`'s own header and `--help`; the launch commands themselves belong to `bin/fm-launch-lib.sh`; the preset and state file locations are listed in [docs/configuration.md](configuration.md). diff --git a/docs/scripts.md b/docs/scripts.md index d4cfc5b6a32..5f48b5edb03 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -40,6 +40,7 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize | `fm-home-seed.sh` | Transactionally provision a secondmate home and maintain `data/secondmates.md` | | `fm-spawn.sh` | Spawn crewmates, scouts, `id=repo` batches, and secondmates on the resolved harness and runtime backend | | `fm-launch-lib.sh` | Single owner of every verified harness launch command for crewmate, scout, secondmate, and primary sessions | +| `fm-launch.sh` | The captain's front door: probe the harness menu, then start and attach to one primary session in this home (docs/launcher.md) | | `fm-backend.sh` | Runtime-backend selection, meta helpers, selector resolution, and operation dispatch | | `fm-backend-hometag-lib.sh` | Shared per-installation home-tag derivation for zellij tab and cmux workspace titles | | `fm-composer-lib.sh` | Single fleet-wide owner of composer-content classification for all backends | diff --git a/tests/fm-launch.test.sh b/tests/fm-launch.test.sh new file mode 100755 index 00000000000..60b6647551b --- /dev/null +++ b/tests/fm-launch.test.sh @@ -0,0 +1,635 @@ +#!/usr/bin/env bash +# tests/fm-launch.test.sh - bin/fm-launch.sh, the captain's front door. +# +# The launcher is the one place in firstmate a human, not an agent, starts a +# session, so this suite pins the properties that make that safe and honest: +# +# 1. Availability is PROBED, never declared. An entry renders available only +# when it could actually start right now, and an entry that could not stays +# VISIBLE and unselectable with one actionable line, so the menu never +# changes shape under the captain's muscle memory. +# 2. The menu touches NO network and executes no harness binary. Both probes +# are local reads; that is what holds the first-paint budget. +# 3. The autonomy notice is present on every render. bin/fm-launch-lib.sh's +# header binds every consumer of a `primary` template to tell the captain +# the session runs without permission prompts, and this launcher is that +# consumer. +# 4. Herdr is mandatory, with no silent fallback to a bare shell. +# 5. A second launch can never produce two primaries in one home. +# 6. Nothing is created before a selection, and a scripted caller must select +# EXPLICITLY - a blank line or EOF refuses rather than launching whatever +# the default happens to be. +# +# Property 6 is here because it was a real defect, not a hypothetical: the first +# draft took the default on EOF, and one `printf '\n' | fm-launch.sh` against a +# live machine started an unattended Claude primary in the captain's own herdr +# workspace. The fake below exists so this file can never repeat that. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +LAUNCH="$ROOT/bin/fm-launch.sh" +TMP_ROOT=$(fm_test_tmproot fm-launch) + +# --- fakes ------------------------------------------------------------------ + +# make_launch_fakebin : a fakebin holding a STATEFUL `herdr` plus logging +# stubs for every harness binary and every network tool. Two jobs: +# +# - model enough of herdr for a full select -> gate -> create -> send -> attach +# run (the canned-response fake in tests/fm-backend-herdr.test.sh cannot +# carry state across calls, and models neither `pane read` nor `session +# attach`, which this path needs); +# - PROVE the menu is inert. Every stub appends to $FM_LAUNCH_EXEC_LOG when it +# is EXECUTED, so a menu render that shells out to a harness or the network +# is a visible failure rather than a slow test. +# +# `command -v` never executes a stub, so an inert menu leaves the log empty even +# though every harness resolves on PATH. +make_launch_fakebin() { # -> echoes fakebin dir + local dir=$1 fb="$1/fakebin" tool + mkdir -p "$fb" + printf '{"next":1,"workspaces":[],"tabs":[],"agent_status":{},"server":"true","prompt":"$ "}\n' > "$dir/state.json" + + for tool in claude codex opencode pi grok kimi curl wget nc ping; do + cat > "$fb/$tool" <<'SH' +#!/usr/bin/env bash +printf '%s\n' "$(basename "$0") $*" >> "${FM_LAUNCH_EXEC_LOG:-/dev/null}" +exit 0 +SH + chmod +x "$fb/$tool" + done + + cat > "$fb/herdr" <<'SH' +#!/usr/bin/env bash +set -u +STATE="${FM_FAKE_HERDR_STATE:?}" +printf 'herdr %s\n' "$*" >> "${FM_LAUNCH_EXEC_LOG:-/dev/null}" +printf 'herdr %s\n' "$*" >> "${FM_HERDR_LOG:-/dev/null}" + +jq_state() { jq "$@" "$STATE"; } +save() { local tmp="$STATE.tmp.$$"; cat > "$tmp" && mv "$tmp" "$STATE"; } + +cmd=${1:-}; sub=${2:-} +ws=""; label="" +args=("$@") +for ((i=0; i<${#args[@]}; i++)); do + case "${args[$i]}" in + --workspace) ws=${args[$((i+1))]:-} ;; + --label) label=${args[$((i+1))]:-} ;; + esac +done + +case "$cmd $sub" in + "status --json") + printf '{"client":{"version":"0.7.1","protocol":%s},"server":{"running":%s}}\n' \ + "${FM_FAKE_HERDR_PROTOCOL:-14}" "$(jq_state -r '.server')" + ;; + "server ") + jq_state '.server = "true"' | save + ;; + "workspace list") + jq_state '{result:{workspaces:.workspaces}}' + ;; + "workspace create") + n=$(jq_state -r '.next'); wsid="w$n"; dn=$((n + 1)) + jq_state --arg wsid "$wsid" --arg wlabel "$label" \ + --arg tabid "$wsid:t$dn" --arg paneid "$wsid:p$dn" \ + '.workspaces += [{workspace_id:$wsid, label:$wlabel}] + | .tabs += [{tab_id:$tabid, label:"1", workspace_id:$wsid, pane_id:$paneid}] + | .next = (.next + 2)' | save + printf '{"result":{"workspace":{"workspace_id":"%s","label":"%s"},"tab":{"tab_id":"%s"},"root_pane":{"pane_id":"%s"}}}\n' \ + "$wsid" "$label" "$wsid:t$dn" "$wsid:p$dn" + ;; + "tab list") + jq_state --arg w "$ws" '{result:{tabs:[.tabs[]|select(.workspace_id==$w)]}}' + ;; + "tab create") + n=$(jq_state -r '.next'); tabid="$ws:t$n"; paneid="$ws:p$n" + jq_state --arg w "$ws" --arg wlabel "$label" --arg tabid "$tabid" --arg paneid "$paneid" \ + '.tabs += [{tab_id:$tabid, label:$wlabel, workspace_id:$w, pane_id:$paneid}] + | .next = (.next + 1)' | save + printf '{"result":{"tab":{"tab_id":"%s"},"root_pane":{"pane_id":"%s"}}}\n' "$tabid" "$paneid" + ;; + "pane list") + jq_state --arg w "$ws" '{result:{panes:[.tabs[]|select(.workspace_id==$w)|{pane_id:.pane_id, tab_id:.tab_id}]}}' + ;; + "pane get") + pane=${3:-} + if [ "$(jq_state -r --arg p "$pane" '[.tabs[]|select(.pane_id==$p)]|length')" = 0 ]; then + printf '{"error":{"code":"pane_not_found","message":"%s"}}\n' "$pane" >&2 + else + printf '{"result":{"pane":{"pane_id":"%s"}}}\n' "$pane" + fi + ;; + "pane read") + jq_state -r '.prompt' + ;; + "pane close") + jq_state --arg p "${3:-}" '.tabs |= [.[]|select(.pane_id != $p)]' | save + ;; + "tab close") + jq_state --arg t "${3:-}" '.tabs |= [.[]|select(.tab_id != $t)]' | save + ;; + "agent get") + pane=${3:-} + status=$(jq_state -r --arg p "$pane" '.agent_status[$p] // empty') + if [ -n "$status" ]; then + printf '{"result":{"agent":{"agent_status":"%s"}}}\n' "$status" + else + printf '{"error":{"code":"agent_not_found","message":"%s"}}\n' "$pane" >&2 + fi + ;; + *) : ;; +esac +exit 0 +SH + chmod +x "$fb/herdr" + printf '%s\n' "$fb" +} + +# launch_case : a private FM_HOME plus a fakebin, both under TMP_ROOT. +# Echoes "\n\n\n". +launch_case() { # + local dir="$TMP_ROOT/$1" fb + mkdir -p "$dir/home/state" "$dir/home/config" + fb=$(make_launch_fakebin "$dir") + : > "$dir/exec.log" + printf '%s\n%s\n%s\n%s\n' "$dir/home" "$fb" "$dir/state.json" "$dir/exec.log" +} + +# run_launch [env=val ...] +# Runs the launcher with a PATH containing ONLY the fakebin and the system dirs +# jq/git live in, so no real harness or herdr can ever be reached. +run_launch() { + local home=$1 fb=$2 state=$3 execlog=$4 input=$5 + shift 5 + printf '%s' "$input" | env \ + PATH="$fb:/usr/bin:/bin" \ + FM_HOME="$home" \ + FM_LAUNCH_PI_AUTH="$home/pi-auth.json" \ + FM_FAKE_HERDR_STATE="$state" \ + FM_LAUNCH_EXEC_LOG="$execlog" \ + FM_LAUNCH_READY_ATTEMPTS=3 \ + FM_LAUNCH_READY_SLEEP=0.01 \ + FM_LAUNCH_NO_ATTACH=1 \ + "$@" \ + bash "$LAUNCH" 2>&1 +} + +# render_menu_out [env=val ...]: the menu only, never a launch. +render_menu_out() { + local home=$1 fb=$2 + shift 2 + printf '' | env \ + PATH="$fb:/usr/bin:/bin" \ + FM_HOME="$home" \ + FM_LAUNCH_PI_AUTH="$home/pi-auth.json" \ + "$@" \ + bash "$LAUNCH" --print-menu 2>&1 +} + +# menu_row