diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..5319436 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,15 @@ +# Cross-platform line endings. +# firstmate runs on macOS, Linux, and Windows (Git Bash + WezTerm). Shell +# scripts and config MUST use LF or they break on macOS/Linux/CI (a CRLF in a +# `#!/usr/bin/env bash` line is a hard error). Normalize text to LF on checkout +# regardless of a contributor's core.autocrlf setting. Tracked symlinks +# (CLAUDE.md, .claude/skills) are mode 120000 and are not affected by these +# text/eol attributes. +* text=auto eol=lf + +# Binary assets must never be normalized. +*.jpg binary +*.jpeg binary +*.png binary +*.gif binary +*.ico binary diff --git a/AGENTS.md b/AGENTS.md index 2578041..8b69b25 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -68,7 +68,7 @@ README.md public overview and development notes .tasks.toml tracked tasks-axi markdown backend config; drives backlog mutations when a compatible tasks-axi is on PATH (section 10), otherwise inert .agents/skills/ shared skills, committed .claude/skills symlink to .agents/skills for claude compatibility -bin/ helper scripts, committed; read each script's header before first use +bin/ helper scripts, committed, including fm-mux.sh for the terminal-multiplexer abstraction (tmux on macOS/Linux, wezterm on Windows) and fm-proc.sh for portable process/ancestry introspection (incl. Git Bash/MSYS where `ps -o` is unavailable); read each script's header before first use config/crew-harness crewmate harness override; LOCAL, gitignored; absent or "default" = same as firstmate data/ personal fleet records; LOCAL, gitignored as a whole backlog.md task queue, dependencies, history @@ -139,6 +139,12 @@ If the captain names a different crewmate harness at bootstrap or later, write i ## 4. Harness adapters +The crew lives in terminal-multiplexer windows. +All multiplexer interaction (create window, send keys, capture pane, read cwd, kill) goes through `bin/fm-mux.sh`, which has two backends: `tmux` on macOS/Linux and `wezterm` on Windows (tmux has no Windows port, so firstmate drives WezTerm's CLI and crewmates appear as WezTerm tabs). +The backend auto-selects (prefer `$TMUX`, then a tmux binary, then WezTerm); `FM_MUX` forces it. +Never call `tmux` directly from a script; call the `fm_mux_*` verbs so both platforms work. +Window handles are opaque: `session:window` for tmux, `wezterm:` for wezterm, stored verbatim in `state/.meta`. + Crewmates default to the same harness you are running on. The captain may override this at any time, typically at bootstrap: record the choice in `config/crew-harness` (a single adapter name; absent or `default` means mirror your own harness). The recorded harness is used for every dispatch until changed; a per-task instruction from the captain ("run this one on codex") overrides it for that dispatch only. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 17d306c..c73f1e1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -42,6 +42,11 @@ See the [no-mistakes quick start](https://kunchenguid.github.io/no-mistakes/star Each starts with a usage header comment; keep it accurate when you change behavior. Test scripts and helpers in `tests/` are plain bash too. `shellcheck bin/*.sh tests/*.sh` must pass, and CI enforces it. +- firstmate runs on macOS, Linux, and Windows (Git Bash + WezTerm). + All terminal-multiplexer interaction goes through `bin/fm-mux.sh` (a `tmux` backend and a `wezterm` backend); never call `tmux` directly from another script. + Process/ancestry introspection goes through `bin/fm-proc.sh`, which works on MSYS where `ps -o` is unavailable. + Pin a backend in tests with `FM_MUX=tmux` (or `wezterm`). +- All tracked text, especially shell scripts, must use LF line endings (`.gitattributes` enforces this); a CRLF in a `#!/usr/bin/env bash` line breaks the script on macOS/Linux/CI. - Changes to harness adapters (launch templates in `bin/fm-spawn.sh`, facts in `.agents/skills/harness-adapters/SKILL.md`) must be verified empirically against the real harness, never written from documentation alone. - In Markdown, put each full sentence on its own line. @@ -59,6 +64,7 @@ Check and test the toolbelt before pushing: bash -n bin/*.sh # syntax-check the toolbelt shellcheck bin/*.sh tests/*.sh # lint the toolbelt and behavior tests; CI enforces this for test_script in tests/*.test.sh; do "$test_script"; done # behavior tests, matching CI +tests/fm-mux.test.sh # multiplexer abstraction: tmux + wezterm backends (fakes; no real multiplexer needed) tests/fm-wake-queue.test.sh # durable wake queue losslessness, catch-up, double-drain, and duplicate-collapse tests tests/fm-watcher-lock.test.sh # watcher singleton, lock-race, watch-arm liveness, and guard-warning tests tests/fm-daemon.test.sh # sub-supervisor classifier, /afk presence-gating, max-defer, composer, and fm-send submit tests diff --git a/README.md b/README.md index 71b6d58..073cc20 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@

firstmate

Platform ahoy! look at my github project xyz, then fix the flaky login test and add dark mode # firstmate checks its toolchain (asking your consent before installing anything), -# clones the project under projects/, and spawns two crewmates in tmux windows -# fm-fix-login-k3 and fm-dark-mode-p7. +# clones the project under projects/, and spawns two crewmates in multiplexer +# windows fm-fix-login-k3 and fm-dark-mode-p7. # Minutes later: PR ready for review, captain: https://github.com/you/xyz/pull/42 @@ -78,8 +78,8 @@ Then just talk: > alright merge it ``` -Run it inside tmux for the best experience: launching your harness from inside tmux puts every crewmate window in your own session, where you can watch the crew work in real time or type into any window to intervene. -Outside tmux, crewmates land in a detached `firstmate` session you can attach to. +Run it inside your multiplexer for the best experience - tmux on macOS/Linux, or WezTerm on Windows (tmux has no Windows port, so firstmate drives WezTerm's CLI there): launching your harness from inside it puts every crewmate window in your own session, where you can watch the crew work in real time or type into any window to intervene. +Outside it, crewmates land in a detached `firstmate` session you can attach to. ## How It Works @@ -92,10 +92,10 @@ Outside tmux, crewmates land in a detached `firstmate` session you can attach to │ reads projects/ + firstmate routes │ │ writes guarded backlog/briefs/state │ └──┬──────────────┬───────────────┬───┘ - │ tmux send-keys / status files │ + │ fm-mux send / status files │ ▼ ▼ ▼ ┌────────┐ ┌────────┐ ┌────────┐ - │fm-task1│ │fm-task2│ ... │fm-taskN│ tmux windows you can watch + │fm-task1│ │fm-task2│ ... │fm-taskN│ multiplexer windows/tabs you watch │crewmate│ │crewmate│ │crewmate│ one autonomous agent each └───┬────┘ └───┬────┘ └───┬────┘ ▼ ▼ ▼ diff --git a/bin/fm-bootstrap.sh b/bin/fm-bootstrap.sh index 36b8696..b7f5097 100755 --- a/bin/fm-bootstrap.sh +++ b/bin/fm-bootstrap.sh @@ -119,7 +119,9 @@ secondmate_sync() { install_cmd() { case "$1" in - tmux|node|gh) echo "brew install $1 # or the platform's package manager" ;; + node|gh) echo "brew install $1 # or the platform's package manager" ;; + tmux) echo "brew install tmux # or the platform's package manager" ;; + wezterm) echo "winget install wez.wezterm # or https://wezfurlong.org/wezterm/install/windows.html" ;; treehouse) echo "curl -fsSL https://kunchenguid.github.io/treehouse/install.sh | sh" ;; no-mistakes) echo "curl -fsSL https://raw.githubusercontent.com/kunchenguid/no-mistakes/main/docs/install.sh | sh" ;; gh-axi|chrome-devtools-axi|lavish-axi) echo "npm install -g $1 && $1 setup hooks" ;; @@ -127,7 +129,13 @@ install_cmd() { esac } -TOOLS="tmux node gh treehouse no-mistakes gh-axi chrome-devtools-axi lavish-axi" +# A terminal multiplexer is required to run the crew in watchable windows. +# tmux on macOS/Linux; WezTerm's multiplexer CLI on Windows (no tmux port). +mux_present() { + command -v tmux >/dev/null 2>&1 || command -v "${FM_WEZTERM:-wezterm}" >/dev/null 2>&1 +} + +TOOLS="node gh treehouse no-mistakes gh-axi chrome-devtools-axi lavish-axi" treehouse_supports_lease() { treehouse get --help 2>&1 | grep -Eq '(^|[^[:alnum:]_-])--lease([^[:alnum:]_-]|$)' @@ -151,6 +159,15 @@ done if command -v treehouse >/dev/null 2>&1 && ! treehouse_supports_lease; then echo "MISSING: treehouse (install: $(install_cmd treehouse))" fi +if ! mux_present; then + # Emit a token install_cmd / `fm-bootstrap.sh install ` understands, + # chosen per platform: wezterm on Windows (no tmux port), tmux elsewhere. + case "$(uname -s 2>/dev/null)" in + MSYS*|MINGW*|CYGWIN*) mux_tool=wezterm ;; + *) mux_tool=tmux ;; + esac + echo "MISSING: $mux_tool (install: $(install_cmd "$mux_tool"))" +fi gh auth status >/dev/null 2>&1 || echo "NEEDS_GH_AUTH" # Worktree-tangle check: the firstmate primary checkout (FM_ROOT) must sit on its # default branch, not a feature branch (see fm-tangle-lib.sh). Scoped to the diff --git a/bin/fm-harness.sh b/bin/fm-harness.sh index 703c9a6..4eb65d5 100755 --- a/bin/fm-harness.sh +++ b/bin/fm-harness.sh @@ -12,34 +12,36 @@ FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" CONFIG="${FM_CONFIG_OVERRIDE:-$FM_HOME/config}" +# shellcheck source=bin/fm-proc.sh +. "$SCRIPT_DIR/fm-proc.sh" + detect_own() { - # Layer 1: environment markers for verified harnesses. + # Layer 1: environment markers for verified harnesses (OS-independent). [ "${CLAUDECODE:-}" = "1" ] && { echo claude; return; } [ "${PI_CODING_AGENT:-}" = "true" ] && { echo pi; return; } - # Layer 2: walk the parent chain and match the command name. - local pid=$$ comm args - for _ in 1 2 3 4 5 6 7 8; do - comm=$(ps -o comm= -p "$pid" 2>/dev/null) || break - case "$(basename "$comm")" in + # Layer 2: walk the ancestry and match the command name (or, for a bare + # interpreter like node/python, the harness name in its argv). fm_proc_ancestry + # walks the Windows process tree on MSYS, the OS process tree elsewhere. + local pid comm args base + while IFS="$(printf '\t')" read -r pid comm args; do + [ -n "$comm" ] || continue + base=$(basename "$comm") + case "$base" in *claude*) echo claude; return ;; *codex*) echo codex; return ;; *opencode*) echo opencode; return ;; - pi) echo pi; return ;; + pi|pi.exe) echo pi; return ;; node*|python*) - # Bare interpreter: match the harness name in its script path. - args=$(ps -o args= -p "$pid" 2>/dev/null) case "$args" in *claude*) echo claude; return ;; *codex*) echo codex; return ;; *opencode*) echo opencode; return ;; - *" pi "*|*/pi) echo pi; return ;; + *" pi "*|*/pi|*\\pi|*\\pi.exe) echo pi; return ;; esac ;; esac - pid=$(ps -o ppid= -p "$pid" 2>/dev/null | tr -d ' ') - if [ -z "$pid" ] || [ "$pid" -le 1 ]; then - break - fi - done + done </dev/null) || return 1 - args=$(ps -o args= -p "$pid" 2>/dev/null) - if printf '%s' "$(basename "$comm")" | grep -qE "$HARNESS_RE"; then + local pid comm args base + while IFS="$(printf '\t')" read -r pid comm args; do + [ -n "$pid" ] || continue + base=$(basename "$comm") + if printf '%s' "$base" | grep -qE "$HARNESS_RE"; then echo "$pid"; return 0 fi - # Bare interpreter (e.g. node): match the harness name in its script path. - case "$comm" in + # Bare interpreter (e.g. node): match the harness name in its argv. + case "$base" in *node*|*python*) printf '%s' "$args" | grep -qE "$HARNESS_RE" && { echo "$pid"; return 0; } ;; esac - pid=$(ps -o ppid= -p "$pid" 2>/dev/null | tr -d ' ') - [ -n "$pid" ] && [ "$pid" -gt 1 ] || return 1 - done + done </dev/null || return 1 - comm=$(ps -o comm= -p "$pid" 2>/dev/null) || return 1 - printf '%s' "$(basename "$comm") $(ps -o args= -p "$pid" 2>/dev/null)" | grep -qE "$HARNESS_RE" + local pid=$1 info comm args + fm_proc_alive "$pid" || return 1 + info=$(fm_proc_info "$pid") || return 1 + comm=$(printf '%s' "$info" | cut -f1) + args=$(printf '%s' "$info" | cut -f2-) + printf '%s %s' "$(basename "$comm")" "$args" | grep -qE "$HARNESS_RE" } if [ "${1:-}" = "status" ]; then diff --git a/bin/fm-mux.sh b/bin/fm-mux.sh new file mode 100644 index 0000000..d31e724 --- /dev/null +++ b/bin/fm-mux.sh @@ -0,0 +1,385 @@ +#!/usr/bin/env bash +# Terminal-multiplexer abstraction for firstmate. +# +# firstmate's crew lives in multiplexer windows. tmux has no Windows port, so +# this library hides the multiplexer behind a small verb set with two backends: +# tmux - macOS / Linux (and anywhere tmux is installed). The tmux backend +# issues exactly the tmux commands the scripts used before this +# library existed, so existing behavior and the test shims are +# unchanged. +# wezterm - native Windows (and anywhere WezTerm's multiplexer CLI is the +# chosen backend). Crewmates appear as WezTerm tabs you can watch. +# +# SOURCE it (`. fm-mux.sh`) to call the fm_mux_* functions, or run it as a CLI +# (`fm-mux.sh [args...]`) for ad-hoc use and tests. +# +# WINDOW HANDLE. Every verb that targets a window takes an opaque handle and +# every creator prints one; callers store it verbatim (e.g. in state/.meta +# `window=`). The two backends use different handle shapes, distinguished by a +# `wezterm:` prefix so a single helper can dispatch: +# tmux ":" e.g. firstmate:fm-fix-k3 (legacy shape) +# wezterm "wezterm:" e.g. wezterm:14 +# +# BACKEND SELECTION (fm_mux_backend, cached in FM_MUX_BACKEND): +# 1. $FM_MUX explicit override (tmux|wezterm) +# 2. $TMUX set -> tmux (we are running inside tmux) +# 3. tmux on PATH -> tmux (prefer tmux where present; keeps the test +# shims and macOS/Linux default intact, and +# keeps wezterm-on-mac users on tmux unless +# they set FM_MUX=wezterm) +# 4. WEZTERM_PANE + wezterm on PATH -> wezterm +# 5. wezterm on PATH -> wezterm +# 6. none (no multiplexer; verbs fail loudly) + +_FM_WEZ="${FM_WEZTERM:-wezterm}" + +fm_mux_backend() { + if [ -n "${FM_MUX_BACKEND:-}" ]; then printf '%s' "$FM_MUX_BACKEND"; return 0; fi + local b + if [ -n "${FM_MUX:-}" ]; then + b=$FM_MUX + elif [ -n "${TMUX:-}" ]; then + b=tmux + elif command -v tmux >/dev/null 2>&1; then + b=tmux + elif command -v "$_FM_WEZ" >/dev/null 2>&1; then + b=wezterm + else + b=none + fi + FM_MUX_BACKEND=$b + printf '%s' "$b" +} + +_fm_mux_no_backend() { + echo "error: no terminal multiplexer available (need tmux or wezterm); set FM_MUX" >&2 + return 1 +} + +# --- wezterm helpers -------------------------------------------------------- + +# Strip the "wezterm:" prefix from a handle to get the bare pane id. +_fm_wez_pane() { printf '%s' "${1#wezterm:}"; } + +# Print one TAB-separated record per pane: pane_idtitlecwdcursor_xcursor_y +# Parsed from `wezterm cli list --format json` with perl (always present under +# Git Bash) so we avoid a jq dependency. wezterm emits one flat object per pane +# with a single nested "size" object whose keys never collide with the fields +# below, so a line-oriented parser is reliable. +_fm_wez_list() { + "$_FM_WEZ" cli list --format json 2>/dev/null | perl -ne ' + sub flush { print "$p\t$ti\t$cw\t$cx\t$cy\n" if defined $p; } + if (/"pane_id":\s*(\d+)/) { flush(); $p=$1; $ti=""; $cw=""; $cx=""; $cy=""; } + elsif (/"title":\s*"(.*?)"/) { $ti=$1; } + elsif (/"cwd":\s*"(.*?)"/) { $cw=$1; } + elsif (/"cursor_x":\s*(\d+)/) { $cx=$1; } + elsif (/"cursor_y":\s*(\d+)/) { $cy=$1; } + END { flush(); } + ' +} + +# Print field N (1=pane_id 2=title 3=cwd 4=cursor_x 5=cursor_y) for a pane id. +_fm_wez_field() { + local pane=$1 field=$2 + _fm_wez_list | awk -F '\t' -v p="$pane" -v f="$field" '$1==p {print $f; exit}' +} + +# Convert a wezterm file:// cwd URI to the same path shape `pwd` prints, so +# callers can compare it against an MSYS/absolute path. URL-decodes, then routes +# a Windows path through cygpath -u (Git Bash) to yield e.g. /c/projects/foo. +_fm_wez_uri_to_path() { + local uri=$1 p + case "$uri" in + file://*) p=${uri#file://} ;; # file:///C:/x -> /C:/x (empty host) + *) printf '%s' "$uri"; return 0 ;; + esac + if command -v perl >/dev/null 2>&1; then + p=$(printf '%s' "$p" | perl -pe 's/%([0-9A-Fa-f]{2})/chr(hex($1))/ge') + fi + if command -v cygpath >/dev/null 2>&1; then + case "$p" in /[A-Za-z]:/*) p=${p#/} ;; esac # /C:/x -> C:/x for cygpath + p=$(cygpath -u "$p" 2>/dev/null || printf '%s' "$p") + fi + # wezterm's cwd URI keeps a trailing slash (file:///C:/x/); `pwd` does not, so + # drop one trailing slash (but never reduce the root "/" to empty) to make the + # value comparable to a known path. + case "$p" in + /) ;; + */) p=${p%/} ;; + esac + printf '%s' "$p" +} + +# Translate a tmux-style key name to the bytes wezterm should inject. +_fm_wez_key_bytes() { + case "$1" in + Enter) printf '\r' ;; + Escape) printf '\033' ;; + C-c) printf '\003' ;; + C-d) printf '\004' ;; + C-z) printf '\032' ;; + Space) printf ' ' ;; + Tab) printf '\t' ;; + BSpace) printf '\177' ;; + *) printf '%s' "$1" ;; # last resort: inject the literal name + esac +} + +# Send raw text to a wezterm pane's input via stdin (preserves quotes, $(), +# and other metacharacters that argv conversion across the MSYS->Windows +# boundary would otherwise mangle). +_fm_wez_send() { + local pane=$1 text=$2 + printf '%s' "$text" | "$_FM_WEZ" cli send-text --pane-id "$pane" --no-paste +} + +# --- verbs ------------------------------------------------------------------ + +# Print the opaque session token to create windows in. For tmux this is the +# current session when inside tmux, otherwise a dedicated detached `firstmate` +# session (created if absent). wezterm has no separate session object - new +# tabs land in the active window - so it returns a constant sentinel. +fm_mux_session() { + case "$(fm_mux_backend)" in + tmux) + if [ -n "${TMUX:-}" ]; then + tmux display-message -p '#S' + else + tmux has-session -t firstmate 2>/dev/null || tmux new-session -d -s firstmate + printf '%s' firstmate + fi + ;; + wezterm) printf '%s' wezterm ;; + *) _fm_mux_no_backend ;; + esac +} + +# Does a window named already exist in ? Exit 0 if yes. +# wezterm cannot be queried by window name (set-tab-title is not reflected in +# `cli list`, and input injection cannot set a queryable pane title), so it +# always reports "not found"; firstmate guarantees unique task ids, and every +# live task is tracked by its handle in state/.meta. +fm_mux_window_exists() { + local ses=$1 name=$2 + case "$(fm_mux_backend)" in + tmux) tmux list-windows -t "$ses" -F '#{window_name}' 2>/dev/null | grep -qx "$name" ;; + wezterm) return 1 ;; + *) _fm_mux_no_backend ;; + esac +} + +# Create a window/tab named running an interactive shell in . +# Prints the handle to store. The shell is the caller's default shell under +# tmux; under wezterm it is an interactive login Git Bash, so the bash-syntax +# launch command firstmate sends next is understood on Windows. +fm_mux_new_window() { + local ses=$1 name=$2 cwd=$3 + case "$(fm_mux_backend)" in + tmux) + tmux new-window -d -t "$ses" -n "$name" -c "$cwd" + printf '%s:%s' "$ses" "$name" + ;; + wezterm) + local wincwd pane shell + wincwd=$cwd + if command -v cygpath >/dev/null 2>&1; then + wincwd=$(cygpath -w "$cwd" 2>/dev/null || printf '%s' "$cwd") + fi + # wezterm is a native Windows process; an MSYS shell path like + # /usr/bin/bash is not resolvable to it. Convert an auto-detected MSYS + # path with cygpath -w (as we do for cwd); leave an explicit + # FM_MUX_WEZTERM_SHELL override untouched (the user owns that value). + if [ -n "${FM_MUX_WEZTERM_SHELL:-}" ]; then + shell=$FM_MUX_WEZTERM_SHELL + else + shell=$(command -v bash 2>/dev/null || printf '%s' bash) + case "$shell" in + /*) command -v cygpath >/dev/null 2>&1 && shell=$(cygpath -w "$shell" 2>/dev/null || printf '%s' "$shell") ;; + esac + fi + pane=$("$_FM_WEZ" cli spawn --cwd "$wincwd" -- "$shell" --login -i) || return 1 + [ -n "$pane" ] || return 1 + "$_FM_WEZ" cli set-tab-title --pane-id "$pane" "$name" >/dev/null 2>&1 || true + printf 'wezterm:%s' "$pane" + ;; + *) _fm_mux_no_backend ;; + esac +} + +# Send literal text to a window's input (no trailing Enter). +fm_mux_send_text() { + local handle=$1 text=$2 + case "$(fm_mux_backend)" in + tmux) tmux send-keys -t "$handle" -l "$text" ;; + wezterm) _fm_wez_send "$(_fm_wez_pane "$handle")" "$text" ;; + *) _fm_mux_no_backend ;; + esac +} + +# Submit the current input line (press Enter). +fm_mux_send_enter() { + local handle=$1 + case "$(fm_mux_backend)" in + tmux) tmux send-keys -t "$handle" Enter ;; + wezterm) _fm_wez_send "$(_fm_wez_pane "$handle")" "$(printf '\r')" ;; + *) _fm_mux_no_backend ;; + esac +} + +# Send a named special key (Enter, Escape, C-c, ...) to a window. +fm_mux_send_key() { + local handle=$1 key=$2 + case "$(fm_mux_backend)" in + tmux) tmux send-keys -t "$handle" "$key" ;; + wezterm) _fm_wez_send "$(_fm_wez_pane "$handle")" "$(_fm_wez_key_bytes "$key")" ;; + *) _fm_mux_no_backend ;; + esac +} + +# Print the window's current working directory (pane_current_path), normalized +# to the local `pwd` shape so callers can compare it against a known path. +fm_mux_pane_path() { + local handle=$1 + case "$(fm_mux_backend)" in + tmux) tmux display-message -p -t "$handle" '#{pane_current_path}' 2>/dev/null ;; + wezterm) + local cw + cw=$(_fm_wez_field "$(_fm_wez_pane "$handle")" 3) || return 1 + [ -n "$cw" ] || return 1 + _fm_wez_uri_to_path "$cw" + ;; + *) _fm_mux_no_backend ;; + esac +} + +# Print the last lines of a window's pane (default 40). +fm_mux_capture() { + local handle=$1 lines=${2:-40} + case "$(fm_mux_backend)" in + tmux) tmux capture-pane -p -t "$handle" -S -"$lines" ;; + wezterm) + # --start-line -N reaches N lines back into scrollback (negative numbers go + # backwards from the top of the viewport), matching tmux's `capture-pane -S + # -N` history access - so a deep peek returns real history, not just the + # visible viewport. get-text still includes the blank rows below the content + # (tmux's -S -N does not), so trim trailing blank lines before tailing so + # "last N lines" means N lines of actual content, matching the tmux backend + # - important for stable staleness hashing in fm-watch. Capture to a variable + # first so a dead pane (get-text non-zero) propagates as a non-zero return, + # like capture-pane on a missing tmux pane. + local out + out=$("$_FM_WEZ" cli get-text --pane-id "$(_fm_wez_pane "$handle")" --start-line "-$lines" 2>/dev/null) || return 1 + printf '%s\n' "$out" \ + | awk '{ a[NR]=$0 } END { n=NR; while (n>0 && a[n] ~ /^[[:space:]]*$/) n--; for (i=1;i<=n;i++) print a[i] }' \ + | tail -n "$lines" + ;; + *) _fm_mux_no_backend ;; + esac +} + +# Print the full visible pane (no trailing-blank trim) so the row at index +# cursor_y lines up with this output - the sub-supervisor's pane_input_pending +# reads the cursor line by number. Distinct from fm_mux_capture, which trims and +# tails for "last N lines of content". +fm_mux_capture_visible() { + local handle=$1 + case "$(fm_mux_backend)" in + tmux) tmux capture-pane -p -t "$handle" ;; + wezterm) "$_FM_WEZ" cli get-text --pane-id "$(_fm_wez_pane "$handle")" 2>/dev/null || return 1 ;; + *) _fm_mux_no_backend ;; + esac +} + +# Print the cursor's 0-indexed row within the visible pane (for input-pending +# detection in the sub-supervisor). +fm_mux_cursor_y() { + local handle=$1 + case "$(fm_mux_backend)" in + tmux) tmux display-message -p -t "$handle" '#{cursor_y}' 2>/dev/null ;; + wezterm) _fm_wez_field "$(_fm_wez_pane "$handle")" 5 ;; + *) _fm_mux_no_backend ;; + esac +} + +# Exit 0 if the window's pane still exists. +fm_mux_pane_alive() { + local handle=$1 + case "$(fm_mux_backend)" in + tmux) tmux display-message -p -t "$handle" '#{pane_id}' >/dev/null 2>&1 ;; + wezterm) + local pane found + pane=$(_fm_wez_pane "$handle") + found=$(_fm_wez_field "$pane" 1) + [ "$found" = "$pane" ] + ;; + *) _fm_mux_no_backend ;; + esac +} + +# Kill a window (and its pane). +fm_mux_kill_window() { + local handle=$1 + case "$(fm_mux_backend)" in + tmux) tmux kill-window -t "$handle" 2>/dev/null || true ;; + wezterm) "$_FM_WEZ" cli kill-pane --pane-id "$(_fm_wez_pane "$handle")" >/dev/null 2>&1 || true ;; + *) _fm_mux_no_backend ;; + esac +} + +# Resolve a bare window name to a handle (the rare path where a caller targets a +# window not tracked by this home's meta). tmux searches all sessions; wezterm +# cannot match by name (see fm_mux_window_exists) and fails - use fm- (which +# resolves through meta) or pass the handle directly. +fm_mux_find_window() { + local name=$1 + case "$(fm_mux_backend)" in + tmux) tmux list-windows -a -F '#{session_name}:#{window_name}' 2>/dev/null | grep -m1 ":$name\$" ;; + wezterm) + echo "error: wezterm backend cannot resolve a window by bare name; use fm- or pass the wezterm: handle" >&2 + return 1 + ;; + *) _fm_mux_no_backend ;; + esac +} + +# Print the handle of the pane firstmate itself is running in (the sub-supervisor +# injects escalations here). Honors FM_SUPERVISOR_TARGET, then the in-pane env +# var each multiplexer exports, then a backend fallback. +fm_mux_resolve_supervisor() { + if [ -n "${FM_SUPERVISOR_TARGET:-}" ]; then printf '%s' "$FM_SUPERVISOR_TARGET"; return 0; fi + case "$(fm_mux_backend)" in + tmux) + if [ -n "${TMUX_PANE:-}" ]; then printf '%s' "$TMUX_PANE"; else printf '%s' "${FM_SUPERVISOR_TARGET_DEFAULT:-firstmate:0}"; return 1; fi + ;; + wezterm) + if [ -n "${WEZTERM_PANE:-}" ]; then printf 'wezterm:%s' "$WEZTERM_PANE"; else return 1; fi + ;; + *) _fm_mux_no_backend ;; + esac +} + +# CLI dispatcher (only when executed, not sourced) so verbs are usable by hand +# and from tests: fm-mux.sh [args...] +if [ "${BASH_SOURCE[0]}" = "${0}" ]; then + verb=${1:-} + [ -n "$verb" ] || { echo "usage: fm-mux.sh [args...]" >&2; exit 2; } + shift || true + case "$verb" in + backend) fm_mux_backend; echo ;; + session) fm_mux_session ;; + window-exists) fm_mux_window_exists "$@" ;; + new-window) fm_mux_new_window "$@" ;; + send-text) fm_mux_send_text "$@" ;; + send-enter) fm_mux_send_enter "$@" ;; + send-key) fm_mux_send_key "$@" ;; + pane-path) fm_mux_pane_path "$@" ;; + capture) fm_mux_capture "$@" ;; + capture-visible) fm_mux_capture_visible "$@" ;; + cursor-y) fm_mux_cursor_y "$@" ;; + pane-alive) fm_mux_pane_alive "$@" ;; + kill-window) fm_mux_kill_window "$@" ;; + find-window) fm_mux_find_window "$@" ;; + resolve-supervisor) fm_mux_resolve_supervisor "$@" ;; + *) echo "error: unknown verb $verb" >&2; exit 2 ;; + esac +fi diff --git a/bin/fm-peek.sh b/bin/fm-peek.sh index fc1e320..08b0722 100755 --- a/bin/fm-peek.sh +++ b/bin/fm-peek.sh @@ -11,6 +11,8 @@ FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" "$SCRIPT_DIR/fm-guard.sh" || true +# shellcheck source=bin/fm-mux.sh +. "$SCRIPT_DIR/fm-mux.sh" resolve() { case "$1" in @@ -25,11 +27,10 @@ resolve() { [ -n "$window" ] || { echo "error: no window recorded in $meta" >&2; exit 1; } echo "$window" ;; - *) tmux list-windows -a -F '#{session_name}:#{window_name}' | grep -m1 ":$1\$" \ - || { echo "error: no window named $1" >&2; exit 1; } ;; + *) fm_mux_find_window "$1" || { echo "error: no window named $1" >&2; exit 1; } ;; esac } T=$(resolve "$1") N=${2:-40} -tmux capture-pane -p -t "$T" -S -"$N" +fm_mux_capture "$T" "$N" diff --git a/bin/fm-proc.sh b/bin/fm-proc.sh new file mode 100644 index 0000000..d4e7421 --- /dev/null +++ b/bin/fm-proc.sh @@ -0,0 +1,131 @@ +#!/usr/bin/env bash +# Portable process introspection for Linux, macOS, and Git Bash / MSYS on Windows. +# +# Two MSYS realities break the Unix approach used elsewhere: +# 1. MSYS `ps` does NOT support `ps -o -p ` (only -p/-f/-l), so the +# `ps -o comm=/ppid=/args=` calls return "unknown option -- o". +# 2. MSYS `ps` PPID stops at the MSYS boundary: a bash launched directly by a +# native Windows process shows PPID 1, so Unix ancestry walking can never +# reach the native harness (claude/codex/...) that started it. +# On MSYS we therefore walk the *Windows* process tree with a Win32_Process CIM +# query via PowerShell, invoked as a temp `-File` script (the `-Command -` stdin +# form silently mis-parses multi-line `for` blocks). +# +# Sourced by fm-harness.sh and fm-lock.sh. Emits TAB-separated records. + +fm_proc_is_windows() { + case "$(uname -s 2>/dev/null)" in + MSYS*|MINGW*|CYGWIN*) return 0 ;; + *) return 1 ;; + esac +} + +_fm_proc_pwsh() { + command -v powershell.exe 2>/dev/null \ + || command -v powershell 2>/dev/null \ + || command -v pwsh.exe 2>/dev/null \ + || command -v pwsh 2>/dev/null +} + +# Windows PID of an MSYS pid (default $$). MSYS `ps` long columns are positional +# and numeric through WINPID: PID(1) PPID(2) PGID(3) WINPID(4) ... +_fm_proc_winpid() { + local mpid=${1:-$$} + ps -p "$mpid" 2>/dev/null | awk 'NR==2 {print $4}' +} + +# Run a PowerShell script (read from stdin) via a temp -File, forwarding any +# args to the script. -File reliably executes the whole script, unlike the +# -Command - stdin form. Returns the script's exit status. +_fm_proc_run_ps() { + local ps tmpdir tmp winpath rc + ps=$(_fm_proc_pwsh) || return 1 + tmpdir=$(mktemp -d 2>/dev/null) || { + tmpdir="${TMPDIR:-/tmp}/fm-proc-$$-$RANDOM" + (umask 077 && mkdir "$tmpdir") || return 1 + } + tmp="$tmpdir/script.ps1" + cat > "$tmp" + winpath="$tmp" + command -v cygpath >/dev/null 2>&1 && winpath=$(cygpath -w "$tmp" 2>/dev/null || printf '%s' "$tmp") + "$ps" -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "$winpath" "$@" + rc=$? + rm -rf "$tmpdir" 2>/dev/null || true + return "$rc" +} + +# Print the ancestry chain from (default current) upward, one per line: +# \t\t +# Pids are Windows PIDs on MSYS, OS pids elsewhere. Bounded depth; stops at the +# process tree root. +fm_proc_ancestry() { + local start=${1:-$$} + if fm_proc_is_windows; then + local win + win=$(_fm_proc_winpid "$start") + case "$win" in ''|*[!0-9]*) return 1 ;; esac + _fm_proc_run_ps "$win" <<'PSEOF' +param([int]$Start) +$cur = $Start +$map = @{} +Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | ForEach-Object { $map[[int]$_.ProcessId] = $_ } +for ($i = 0; $i -lt 16 -and $cur -gt 0; $i++) { + $p = $map[[int]$cur] + if (-not $p) { break } + $cmd = $p.CommandLine + if ($null -eq $cmd) { $cmd = "" } + $cmd = $cmd -replace "[`t`r`n]", " " + "{0}`t{1}`t{2}" -f $p.ProcessId, $p.Name, $cmd + $cur = [int]$p.ParentProcessId +} +PSEOF + else + local pid=$start comm args n=0 + while [ "$n" -lt 12 ]; do + n=$((n + 1)) + comm=$(ps -o comm= -p "$pid" 2>/dev/null) || break + args=$(ps -o args= -p "$pid" 2>/dev/null) + printf '%s\t%s\t%s\n' "$pid" "$comm" "$args" + pid=$(ps -o ppid= -p "$pid" 2>/dev/null | tr -d ' ') + if [ -z "$pid" ] || [ "$pid" -le 1 ]; then break; fi + done + fi +} + +# Print "\t" for a specific pid (Windows pid on MSYS), or fail. +fm_proc_info() { + local pid=$1 + case "$pid" in ''|*[!0-9]*) return 1 ;; esac + if fm_proc_is_windows; then + _fm_proc_run_ps "$pid" <<'PSEOF' +param([int]$Id) +$p = Get-CimInstance Win32_Process -Filter "ProcessId=$Id" -ErrorAction SilentlyContinue +if ($p) { + $cmd = $p.CommandLine + if ($null -eq $cmd) { $cmd = "" } + $cmd = $cmd -replace "[`t`r`n]", " " + "{0}`t{1}" -f $p.Name, $cmd +} +PSEOF + else + local comm args + comm=$(ps -o comm= -p "$pid" 2>/dev/null) || return 1 + args=$(ps -o args= -p "$pid" 2>/dev/null) + printf '%s\t%s\n' "$comm" "$args" + fi +} + +# Is a live process? (Windows pid on MSYS, OS pid elsewhere.) +fm_proc_alive() { + local pid=$1 + case "$pid" in ''|*[!0-9]*) return 1 ;; esac + if fm_proc_is_windows; then + _fm_proc_pwsh >/dev/null 2>&1 || return 0 # cannot check -> assume alive (conservative) + _fm_proc_run_ps "$pid" <<'PSEOF' +param([int]$Id) +if (Get-CimInstance Win32_Process -Filter "ProcessId=$Id" -ErrorAction SilentlyContinue) { exit 0 } else { exit 1 } +PSEOF + else + kill -0 "$pid" 2>/dev/null + fi +} diff --git a/bin/fm-send.sh b/bin/fm-send.sh index dd8e889..f41f8e3 100755 --- a/bin/fm-send.sh +++ b/bin/fm-send.sh @@ -38,6 +38,8 @@ STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" . "$SCRIPT_DIR/fm-marker-lib.sh" "$SCRIPT_DIR/fm-guard.sh" || true +# shellcheck source=bin/fm-mux.sh +. "$SCRIPT_DIR/fm-mux.sh" resolve() { case "$1" in @@ -52,8 +54,7 @@ resolve() { [ -n "$window" ] || { echo "error: no window recorded in $meta" >&2; exit 1; } echo "$window" ;; - *) tmux list-windows -a -F '#{session_name}:#{window_name}' | grep -m1 ":$1\$" \ - || { echo "error: no window named $1" >&2; exit 1; } ;; + *) fm_mux_find_window "$1" || { echo "error: no window named $1" >&2; exit 1; } ;; esac } @@ -77,30 +78,39 @@ case "$RAW_TARGET" in esac if [ "${1:-}" = "--key" ]; then - tmux send-keys -t "$T" "$2" + fm_mux_send_key "$T" "$2" else # Slash commands open a completion popup in some TUIs (verified on codex); # submitting too fast selects nothing. Give popups time to settle. case "$*" in /*) settle=1.2 ;; *) settle=0.3 ;; esac - retries=${FM_SEND_RETRIES:-3} - sleep_s=${FM_SEND_SLEEP:-0.4} - # Type once, submit, verify. Lenient: only a positively-confirmed swallow - # (text still in the composer) is an error; an unreadable pane is assumed sent. - verdict=$(fm_tmux_submit_core "$T" "$MARK_PREFIX$*" "$retries" "$sleep_s" "$settle") - case "$verdict" in - pending) - echo "error: text not submitted to $T (Enter swallowed; text left in composer)" >&2 - exit 1 - ;; - send-failed) - echo "error: text not sent to $T (tmux send-keys failed)" >&2 - exit 1 - ;; - esac - # Submit landed (verdict was not pending/send-failed). The cleared composer only - # proves the text was submitted; the harness still needs a beat to spin up the - # turn before its busy footer shows. Pause so an immediate peek catches the - # crewmate actually working instead of the stale idle pane. FM_SEND_SETTLE=0 - # disables it. Scoped to this path only, never the shared submit core. + if [ "$(fm_mux_backend)" = tmux ]; then + retries=${FM_SEND_RETRIES:-3} + sleep_s=${FM_SEND_SLEEP:-0.4} + # Type once, submit, verify. Lenient: only a positively-confirmed swallow + # (text still in the composer) is an error; an unreadable pane is assumed sent. + verdict=$(fm_tmux_submit_core "$T" "$MARK_PREFIX$*" "$retries" "$sleep_s" "$settle") + case "$verdict" in + pending) + echo "error: text not submitted to $T (Enter swallowed; text left in composer)" >&2 + exit 1 + ;; + send-failed) + echo "error: text not sent to $T (tmux send-keys failed)" >&2 + exit 1 + ;; + esac + else + # Non-tmux backends (e.g. wezterm) have no verified tmux submit core, so use the + # multiplexer abstraction's type-then-Enter. The from-firstmate marker still + # applies, and the popup-settle pause is honored before Enter. + fm_mux_send_text "$T" "$MARK_PREFIX$*" + sleep "$settle" + fm_mux_send_enter "$T" + fi + # Submit landed. The cleared composer only proves the text was submitted; the + # harness still needs a beat to spin up the turn before its busy footer shows. + # Pause so an immediate peek catches the crewmate actually working instead of the + # stale idle pane. FM_SEND_SETTLE=0 disables it. Scoped to this path only, never + # the shared submit core. [ "${FM_SEND_SETTLE:-1}" = 0 ] || sleep "${FM_SEND_SETTLE:-1}" fi diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index 38747d5..0ca9e4f 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -338,32 +338,71 @@ else fi [ -f "$BRIEF" ] || { echo "error: no brief at $BRIEF" >&2; exit 1; } -# Same session when firstmate already runs inside tmux; dedicated session otherwise. -if [ -n "${TMUX:-}" ]; then - SES=$(tmux display-message -p '#S') -else - tmux has-session -t firstmate 2>/dev/null || tmux new-session -d -s firstmate - SES=firstmate -fi +# Source the multiplexer abstraction (tmux on macOS/Linux, wezterm on Windows). +. "$FM_ROOT/bin/fm-mux.sh" + +# Same session when firstmate already runs inside the multiplexer; a dedicated +# session otherwise (tmux). wezterm has no separate session object - new tabs +# land in the active window - so fm_mux_session returns a sentinel there. +SES=$(fm_mux_session) W="fm-$ID" -T="$SES:$W" -if tmux list-windows -t "$SES" -F '#{window_name}' | grep -qx "$W"; then - echo "error: window $T already exists" >&2 +if fm_mux_window_exists "$SES" "$W"; then + echo "error: window $SES:$W already exists" >&2 exit 1 fi -tmux new-window -d -t "$SES" -n "$W" -c "$PROJ_ABS" +# Duplicate-spawn guard for backends that cannot query a window by name (wezterm: +# fm_mux_window_exists is a structural no-op there). For a non-secondmate task, +# if this id already has a meta whose recorded window is still alive, refuse +# rather than create a second tab and overwrite the meta - which would orphan the +# prior tab+worktree and drop it out of teardown tracking. Secondmate respawn is a +# deliberate recovery path (it reads home= from the existing meta above), so it is +# exempt. This complements the fm_mux_window_exists check, which covers tmux. +if [ "$KIND" != secondmate ] && [ -f "$STATE/$ID.meta" ]; then + prev_window=$(grep '^window=' "$STATE/$ID.meta" | cut -d= -f2- || true) + if [ -n "$prev_window" ] && fm_mux_pane_alive "$prev_window" 2>/dev/null; then + echo "error: task $ID already in flight (window $prev_window); tear it down first" >&2 + exit 1 + fi +fi + +T=$(fm_mux_new_window "$SES" "$W" "$PROJ_ABS") || { echo "error: failed to create window $W in $SES" >&2; exit 1; } if [ "$KIND" != secondmate ]; then - tmux send-keys -t "$T" 'treehouse get' Enter + fm_mux_send_text "$T" 'treehouse get' + fm_mux_send_enter "$T" - # Wait for the treehouse subshell: the pane's cwd moves from the project to the worktree. - for _ in $(seq 1 60); do - p=$(tmux display-message -p -t "$T" '#{pane_current_path}' 2>/dev/null || true) + # Detect the treehouse worktree the subshell entered. + # Primary signal: the pane's reported cwd moves off the project dir (fast). + # On the wezterm backend this depends on the shell emitting OSC 7, which Git + # Bash may not do, so once the subshell is up we ALSO print its $PWD into the + # pane behind a unique marker and read it back from the pane text - a fallback + # that needs no OSC 7. The fallback is wezterm-only so the verified tmux path + # (native cwd tracking) stays byte-identical. Whichever resolves first to a + # path different from the project dir wins. + BACKEND=$(fm_mux_backend) + WT_PROBE="__FM_WT_${ID}__" + WT_GRACE=${FM_WORKTREE_PROBE_GRACE:-8} + for i in $(seq 1 60); do + p=$(fm_mux_pane_path "$T" 2>/dev/null || true) if [ -n "$p" ] && [ "$p" != "$PROJ_ABS" ]; then WT="$p" break fi + if [ "$BACKEND" = wezterm ] && [ "$i" -ge "$WT_GRACE" ]; then + # Re-emit periodically: a probe sent before treehouse finished setting up + # runs in the project shell (path == project dir, ignored below); once the + # worktree subshell is active a probe prints the worktree path. + if [ $(( i % 3 )) -eq 0 ]; then + fm_mux_send_text "$T" "printf '%s%s\\n' '$WT_PROBE=' \"\$PWD\"" + fm_mux_send_enter "$T" + fi + cand=$(fm_mux_capture "$T" 80 2>/dev/null | sed -n "s/^$WT_PROBE=//p" | tail -1 || true) + if [ -n "$cand" ] && [ "$cand" != "$PROJ_ABS" ]; then + WT="$cand" + break + fi + fi sleep 1 done if [ -z "$WT" ]; then @@ -490,8 +529,8 @@ if [ "$KIND" = secondmate ]; then sq_home=$(shell_quote "$PROJ_ABS") LAUNCH="FM_ROOT_OVERRIDE= FM_STATE_OVERRIDE= FM_DATA_OVERRIDE= FM_PROJECTS_OVERRIDE= FM_CONFIG_OVERRIDE= FM_HOME=$sq_home $LAUNCH" fi -tmux send-keys -t "$T" -l "$LAUNCH" +fm_mux_send_text "$T" "$LAUNCH" sleep 0.3 -tmux send-keys -t "$T" Enter +fm_mux_send_enter "$T" echo "spawned $ID harness=$HARNESS kind=$KIND mode=$MODE yolo=$YOLO window=$T worktree=$WT" diff --git a/bin/fm-supervise-daemon.sh b/bin/fm-supervise-daemon.sh index 09e5ab0..b246282 100755 --- a/bin/fm-supervise-daemon.sh +++ b/bin/fm-supervise-daemon.sh @@ -55,9 +55,10 @@ # Usage: fm-supervise-daemon.sh # Long-lived background loop. Normally started by the /afk skill, which # sets state/.afk first. Env knobs: -# FM_SUPERVISOR_TARGET supervisor tmux target (override; otherwise -# auto-discovered from TMUX_PANE, then -# firstmate:0 fallback) +# FM_SUPERVISOR_TARGET supervisor pane (tmux target, or +# wezterm:); override, otherwise +# auto-discovered from TMUX_PANE / WEZTERM_PANE, +# then firstmate:0 fallback # FM_INJECT_SKIP |-prefixes force-self-handle bypassing # classification (default "heartbeat"); empty # disables. Use sparingly: it overrides the @@ -101,6 +102,8 @@ set -u FM_DAEMON_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$FM_DAEMON_DIR/.." && pwd)}" FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" +# shellcheck source=bin/fm-mux.sh +. "$FM_DAEMON_DIR/fm-mux.sh" # Shared tmux pane primitives (busy/composer detection + verify-retry submit). # Sourced at top level so BOTH the executed daemon and the unit tests (which @@ -234,10 +237,11 @@ _collapse_newlines() { # # Auto-discover the supervisor pane at startup. Priority: # 1. FM_SUPERVISOR_TARGET env (explicit override) — caller passes it in. -# 2. $TMUX_PANE — tmux sets this in every pane's environment; inherited by -# the daemon when the /afk skill launches it from firstmate's own pane. -# 3. firstmate:0 — legacy fallback (may not resolve if the session is named -# differently). The caller logs a warning in that case. +# 2. $TMUX_PANE / $WEZTERM_PANE — the multiplexer sets one of these in every +# pane's environment; inherited by the daemon when /afk launches it from +# firstmate's own pane. WEZTERM_PANE becomes a wezterm: handle. +# 3. firstmate:0 — legacy tmux fallback (may not resolve if the session is +# named differently). The caller logs a warning in that case. # Returns the resolved target on stdout; returns 1 if only the fallback is left # AND the fallback does not resolve to a live pane. discover_supervisor_target() { @@ -249,6 +253,10 @@ discover_supervisor_target() { printf '%s' "$TMUX_PANE" return 0 fi + if [ -n "${WEZTERM_PANE:-}" ]; then + printf 'wezterm:%s' "$WEZTERM_PANE" + return 0 + fi printf '%s' "$FM_SUPERVISOR_TARGET_DEFAULT" return 1 } @@ -400,18 +408,42 @@ mark_escalated_seen() { # esac } -# Busy + composer-empty detection are the shared primitives in fm-tmux-lib.sh -# (one source of truth with fm-send.sh). These thin wrappers keep the daemon's -# call sites and the unit tests stable. +# Busy + composer-empty detection. On tmux, use the shared ghost-text- and +# border-robust primitives from fm-tmux-lib.sh (one source of truth with +# fm-send.sh), so a ghost-only or idle bordered claude composer ("│ > … │") reads +# as empty, not pending (incidents afk-invx-i5 and composer-robust). On other +# backends (wezterm) the tmux-specific captures do not apply, so fall back to the +# multiplexer abstraction's capture-based detection. # # pane_input_pending returns 0 (pending) when the cursor line holds real # unsubmitted text - a human's half-typed line (the return race) or a previous -# injection whose Enter was swallowed. The detector drops dim/faint ghost text and -# strips the harness's composer box borders, so a ghost-only or idle bordered -# claude composer ("│ > … │") is correctly read as empty, not pending (incidents -# afk-invx-i5 and composer-robust). -pane_is_busy() { fm_pane_is_busy "$@"; } # -pane_input_pending() { fm_pane_input_pending "$@"; } # +# injection whose Enter was swallowed. +pane_is_busy() { # + if [ "$(fm_mux_backend)" = tmux ]; then fm_pane_is_busy "$@"; return; fi + local win=$1 tail40 + tail40=$(fm_mux_capture "$win" 40 2>/dev/null) || return 1 + printf '%s' "$tail40" | grep -v '^[[:space:]]*$' | tail -6 \ + | grep -qiE "${FM_BUSY_REGEX:-$FM_TMUX_BUSY_REGEX_DEFAULT}" +} + +pane_input_pending() { # + if [ "$(fm_mux_backend)" = tmux ]; then fm_pane_input_pending "$@"; return; fi + local target=$1 cy pane_out line idle_re + idle_re='^[[:space:]]*(\$|>|❯|%|#)[[:space:]]*$|esc (to )?interrupt|Working\.\.\.' + # Get the cursor's Y position (0-indexed from the top of the visible pane). + cy=$(fm_mux_cursor_y "$target" 2>/dev/null) || return 1 + case "$cy" in ''|*[!0-9]*) return 1 ;; esac + # Capture the full visible pane and extract the cursor line (sed is 1-indexed). + pane_out=$(fm_mux_capture_visible "$target" 2>/dev/null) || return 1 + line=$(printf '%s\n' "$pane_out" | sed -n "$((cy + 1))p") + # Strip trailing whitespace (the cursor position is not "content"). + line="${line%"${line##*[![:space:]]}"}" + # Blank line = empty composer = not pending. + [ -n "$line" ] || return 1 + # A recognized idle pattern (bare prompt, busy footer) = empty composer. + printf '%s' "$line" | grep -qiE "${FM_COMPOSER_IDLE_RE:-$idle_re}" && return 1 + return 0 +} escalate_add() { # local state=$1 item=$2 buf @@ -558,9 +590,26 @@ housekeeping() { # # Find a live fm-* window whose task id matches the given marker key. window_for_task() { # local key=$1 w t - for w in $(tmux list-windows -a -F '#{session_name}:#{window_name}' 2>/dev/null | grep ':fm-' || true); do - t=$(window_to_task "$w") - [ "$(_stale_key "$t")" = "$key" ] && { printf '%s' "$w"; return 0; } + if [ "$(fm_mux_backend)" = tmux ]; then + for w in $(tmux list-windows -a -F '#{session_name}:#{window_name}' 2>/dev/null | grep ':fm-' || true); do + t=$(window_to_task "$w") + [ "$(_stale_key "$t")" = "$key" ] && { printf '%s' "$w"; return 0; } + done + return 1 + fi + # Non-tmux (wezterm): the multiplexer cannot be queried by window name, so map + # the stale key back through this home's task meta, which records the handle. + local meta task st + st=$(_state_root) + for meta in "$st"/*.meta; do + [ -e "$meta" ] || continue + task=$(basename "$meta" .meta) + [ "$(_stale_key "$task")" = "$key" ] || continue + w=$(grep '^window=' "$meta" | cut -d= -f2- || true) + [ -n "$w" ] || continue + fm_mux_pane_alive "$w" 2>/dev/null || return 1 + printf '%s' "$w" + return 0 done return 1 } @@ -585,7 +634,7 @@ window_for_task() { # # line, or a previous injection's unsent text), defer entirely - injecting # would merge with the human's text. inject_msg() { # [state] - local msg=$1 state target retries sleep_s verdict + local msg=$1 state target retries sleep_s verdict i state="${2:-$(_state_root)}" # (1) Presence-gate: inject ONLY when afk is active. When afk is off, the # daemon self-handles and stays quiet; firstmate drives the base one-shot @@ -598,7 +647,7 @@ inject_msg() { # [state] msg=$(_collapse_newlines "$msg") msg="${FM_INJECT_MARK}${msg}" target="${FM_SUPERVISOR_TARGET:-$FM_SUPERVISOR_TARGET_DEFAULT}" - tmux display-message -p -t "$target" '#{pane_id}' >/dev/null 2>&1 || return 1 + fm_mux_pane_alive "$target" 2>/dev/null || return 1 # (3) Busy-guard: never inject into an in-use pane. Two checks: # a) pane_is_busy: the harness shows a busy footer (agent mid-turn). # b) pane_input_pending: the cursor line has real unsubmitted text after @@ -618,11 +667,32 @@ inject_msg() { # [state] # count as delivered, so the buffer is preserved (strict) rather than cleared. retries=${FM_INJECT_CONFIRM_RETRIES:-$INJECT_CONFIRM_RETRIES_DEFAULT} sleep_s=${FM_INJECT_CONFIRM_SLEEP:-$INJECT_CONFIRM_SLEEP_DEFAULT} - verdict=$(fm_tmux_submit_core "$target" "$msg" "$retries" "$sleep_s" "$sleep_s") - if [ "$verdict" = empty ]; then - return 0 # Composer cleared → submit confirmed. + if [ "$(fm_mux_backend)" = tmux ]; then + verdict=$(fm_tmux_submit_core "$target" "$msg" "$retries" "$sleep_s" "$sleep_s") + if [ "$verdict" = empty ]; then + return 0 # Composer cleared → submit confirmed. + fi + log "inject failed: submit unconfirmed after $retries retries (verdict=$verdict, text may be in composer)" + else + # Non-tmux backends (e.g. wezterm): type once, then retry Enter only (never + # retype) until the composer clears, mirroring the verified tmux core. + if ! fm_mux_send_text "$target" "$msg" 2>/dev/null; then + log "inject failed: send-keys -l returned non-zero" + return 1 + fi + sleep "$sleep_s" + i=0 + while [ "$i" -lt "$retries" ]; do + i=$((i + 1)) + fm_mux_send_enter "$target" 2>/dev/null || true + sleep "$sleep_s" + if ! pane_input_pending "$target"; then + return 0 # Composer cleared → submit succeeded. + fi + # Enter was swallowed (text still in composer). Retry Enter, not retype. + done + log "inject failed: Enter swallowed after $retries retries (text in composer)" fi - log "inject failed: submit unconfirmed after $retries retries (verdict=$verdict, text may be in composer)" return 1 } @@ -766,8 +836,8 @@ fm_super_main() { local TARGET="$FM_SUPERVISOR_TARGET" # --- validate supervisor target at startup (a missing target is a typo) --- - if ! tmux display-message -p -t "$TARGET" '#{pane_id}' >/dev/null 2>&1; then - echo "error: supervisor target '$TARGET' does not resolve to a tmux pane; set FM_SUPERVISOR_TARGET" >&2 + if ! fm_mux_pane_alive "$TARGET" 2>/dev/null; then + echo "error: supervisor target '$TARGET' does not resolve to a live pane; set FM_SUPERVISOR_TARGET" >&2 log "startup failed: target '$TARGET' not found" fm_lock_release "$LOCK" 2>/dev/null || true rm -f "$PIDFILE" 2>/dev/null || true @@ -832,7 +902,7 @@ fm_super_main() { # has nowhere to go, and firstmate itself is the consumer of escalations. # Catch-up signals persist in state/*.status and flow on the next run, so # this delays rather than loses work. - if ! tmux display-message -p -t "$TARGET" '#{pane_id}' >/dev/null 2>&1; then + if ! fm_mux_pane_alive "$TARGET" 2>/dev/null; then log "warn: supervisor target '$TARGET' gone; backing off ${INJECT_FAIL_SLEEP}s, will retry" # Flush is pointless with no pane; preserve any buffered escalations. sleep "$INJECT_FAIL_SLEEP" diff --git a/bin/fm-teardown.sh b/bin/fm-teardown.sh index ddd0a6d..1ef32ca 100755 --- a/bin/fm-teardown.sh +++ b/bin/fm-teardown.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Tear down a finished task: return the treehouse worktree or retire a -# secondmate home, kill the tmux window, clear volatile state, refresh/prune +# secondmate home, kill the multiplexer window, clear volatile state, refresh/prune # the project's clone for PR-based ship tasks, then print a backlog-refresh # reminder. # REFUSES if the worktree holds work not on any remote, because treehouse return @@ -35,6 +35,8 @@ SUB_HOME_MARKER=".fm-secondmate-home" # shellcheck source=bin/fm-tasks-axi-lib.sh . "$SCRIPT_DIR/fm-tasks-axi-lib.sh" "$FM_ROOT/bin/fm-guard.sh" || true +# shellcheck source=bin/fm-mux.sh +. "$FM_ROOT/bin/fm-mux.sh" ID=$1 FORCE=${2:-} @@ -361,7 +363,7 @@ cleanup_firstmate_home_children() { child_kind=$(meta_value "$child_meta" kind) [ -n "$child_kind" ] || child_kind=ship if [ -n "$child_t" ]; then - tmux kill-window -t "$child_t" 2>/dev/null || true + fm_mux_kill_window "$child_t" fi if [ "$child_kind" = secondmate ]; then child_home=$(meta_value "$child_meta" home) @@ -473,7 +475,7 @@ if [ -d "$WT" ] && [ "$KIND" != secondmate ]; then ( cd "$PROJ" && treehouse return --force "$WT" ) fi -tmux kill-window -t "$T" 2>/dev/null || true +fm_mux_kill_window "$T" if [ "$KIND" = secondmate ]; then [ -n "$HOME_PATH" ] || HOME_PATH=$WT remove_firstmate_home "$HOME_PATH" "secondmate home" "$ID" diff --git a/bin/fm-watch.sh b/bin/fm-watch.sh index bd03bec..4426c1a 100755 --- a/bin/fm-watch.sh +++ b/bin/fm-watch.sh @@ -19,6 +19,8 @@ mkdir -p "$STATE" # shellcheck source=bin/fm-wake-lib.sh . "$SCRIPT_DIR/fm-wake-lib.sh" +# shellcheck source=bin/fm-mux.sh +. "$SCRIPT_DIR/fm-mux.sh" WATCH_LOCK="$STATE/.watch.lock" WATCH_PATH="$SCRIPT_DIR/fm-watch.sh" @@ -240,7 +242,7 @@ EOF # A secondmate idling on its own watcher is healthy. Its parent supervises # it through status writes and heartbeats, not pane-idle staleness. [ "$(window_kind "$w")" = secondmate ] && continue - tail40=$(tmux capture-pane -p -t "$w" -S -40 2>/dev/null) || continue + tail40=$(fm_mux_capture "$w" 40 2>/dev/null) || continue h=$(printf '%s' "$tail40" | hash_pane) key=$(printf '%s' "$w" | tr ':/.' '___') hf="$STATE/.hash-$key" diff --git a/docs/architecture.md b/docs/architecture.md index 0a891f3..0945c8c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -6,6 +6,14 @@ The [README](../README.md) carries the high-level diagram and a short synopsis. This document expands every part of it. firstmate's full operating manual for the orchestrator agent itself is [`AGENTS.md`](../AGENTS.md); this is the human-facing companion. +## Multiplexer backends (tmux and WezTerm) + +The crew lives in terminal-multiplexer windows, and every multiplexer interaction - create a window, send keys, capture a pane, read its cwd, kill it - goes through [`bin/fm-mux.sh`](../bin/fm-mux.sh) rather than calling a multiplexer directly. +It has two backends: `tmux` on macOS/Linux, and `wezterm` on Windows, where firstmate runs under Git Bash and drives WezTerm's CLI because tmux has no Windows port. +The backend auto-selects (prefer `$TMUX`, then a tmux binary, then WezTerm) and `FM_MUX` forces it; window handles stay opaque (`session:window` for tmux, `wezterm:` for wezterm) and are recorded verbatim in `state/.meta`. +Pane-level helpers that need the verified tmux composer/submit logic (`bin/fm-tmux-lib.sh`) run on tmux; on wezterm `fm-send.sh` and the sub-supervisor fall back to the multiplexer abstraction's type-then-Enter, selected by the same backend check. +Process and ancestry introspection goes through [`bin/fm-proc.sh`](../bin/fm-proc.sh), which also works on MSYS where `ps -o` is unavailable. + ## Event-driven supervision A zero-token bash watcher (`bin/fm-watch.sh`) sleeps on the fleet and wakes the first mate only when a crewmate reports, stalls, a PR merges, or an internal heartbeat review is due. @@ -42,7 +50,7 @@ Ship tasks change projects and ship by project mode (`no-mistakes`, `direct-PR`, ## Optional secondmates `data/secondmates.md` records persistent domain supervisors with natural-language scopes, project clone lists, and home paths. -`fm-home-seed.sh` provisions the isolated home, clones the listed PR-based projects into it, initializes newly cloned `no-mistakes` projects, copies the charter to `data/charter.md`, and `fm-spawn.sh --secondmate` launches it through the same tmux and status-file path as any direct report. +`fm-home-seed.sh` provisions the isolated home, clones the listed PR-based projects into it, initializes newly cloned `no-mistakes` projects, copies the charter to `data/charter.md`, and `fm-spawn.sh --secondmate` launches it through the same multiplexer and status-file path as any direct report. When seeded with `-`, the home is a durable treehouse lease under the secondmate id, so it survives with no live process and is not recycled by later `treehouse get` or pruning. Retirement or seed rollback returns the leased home; normal restart/recovery keeps it leased. If returning the lease fails during teardown, firstmate leaves the route and home intact instead of hiding a still-held lease. diff --git a/docs/configuration.md b/docs/configuration.md index 5112407..061869d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -43,6 +43,14 @@ claude, codex, opencode, and pi are all empirically verified; new harnesses get The verified adapter knowledge - busy signatures, 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). +## Multiplexer backend (FM_MUX) + +The crew lives in terminal-multiplexer windows, and all multiplexer interaction goes through [`bin/fm-mux.sh`](../bin/fm-mux.sh). +It has two backends: `tmux` on macOS/Linux and `wezterm` on Windows, where firstmate runs under Git Bash and drives WezTerm's CLI because tmux has no Windows port. +The backend auto-selects (prefer `$TMUX`, then a tmux binary, then WezTerm); set `FM_MUX=tmux` or `FM_MUX=wezterm` to force it, and `FM_WEZTERM` to point at a non-default WezTerm CLI binary. +Window handles are opaque - `session:window` for tmux, `wezterm:` for wezterm - and stored verbatim in `state/.meta`. +Process and ancestry introspection goes through [`bin/fm-proc.sh`](../bin/fm-proc.sh), which also works on MSYS where `ps -o` is unavailable. + ## Toolchain On first launch the first mate detects what its required toolchain is missing or too old (tmux, node, gh, treehouse with durable lease support, no-mistakes, gh-axi, chrome-devtools-axi, lavish-axi), lists it with the exact install commands, and installs only after you say go. @@ -62,6 +70,8 @@ FM_STATE_OVERRIDE= # alternate state dir, mainly for tests FM_DATA_OVERRIDE= # alternate data dir, mainly for tests FM_PROJECTS_OVERRIDE= # alternate projects dir, mainly for tests FM_CONFIG_OVERRIDE= # alternate config dir, mainly for tests +FM_MUX= # force the multiplexer backend (tmux|wezterm); unset = auto-detect +FM_WEZTERM=wezterm # wezterm CLI binary used by the wezterm backend (Windows) FM_POLL=15 # seconds between watcher cycles FM_HEARTBEAT=600 # base seconds between fleet reviews; backs off exponentially while idle FM_HEARTBEAT_MAX=7200 # heartbeat backoff cap @@ -80,7 +90,7 @@ FM_SEND_RETRIES=3 # fm-send Enter-retry attempts after typing the line onc FM_SEND_SLEEP=0.4 # seconds between fm-send submit checks FM_SEND_SETTLE=1 # seconds fm-send waits after a successful text submit; 0 disables # sub-supervisor (bin/fm-supervise-daemon.sh); presence-gated via /afk -FM_SUPERVISOR_TARGET=firstmate:0 # supervisor tmux target (override; auto-discovers from $TMUX_PANE) +FM_SUPERVISOR_TARGET=firstmate:0 # supervisor pane (tmux target, or wezterm:); auto-discovers from $TMUX_PANE / $WEZTERM_PANE FM_INJECT_SKIP=heartbeat # |-prefixes force-self-handled bypassing classification; empty disables FM_CAPTAIN_RE='done:|needs-decision:|blocked:|failed:|PR ready|checks green|ready in branch|merged' # status regex that escalates daemon signal/stale/scan output FM_STALE_ESCALATE_SECS=240 # idle seconds before a stale pane escalates as a possible wedge diff --git a/docs/scripts.md b/docs/scripts.md index 5aa092a..ec915bf 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -28,6 +28,8 @@ Each file also starts with a short header comment. | `fm-wake-lib.sh` | Shared durable wake queue and portable lock helpers sourced by the watcher, drain, arm, guard, and daemon | | `fm-send.sh` | Send one verified literal line (or `--key Escape`) to a direct-report window; exits non-zero on confirmed swallowed Enter; bare `kind=secondmate` targets are marked as from-firstmate; text sends pause `FM_SEND_SETTLE` seconds after success | | `fm-tmux-lib.sh` | Shared tmux pane primitives for busy detection, dim-ghost-aware and border-aware composer detection, and verified submit retry | +| `fm-mux.sh` | Terminal-multiplexer abstraction: one verb set over a `tmux` backend (macOS/Linux) and a `wezterm` backend (Windows) | +| `fm-proc.sh` | Portable process/ancestry introspection for Linux, macOS, and Git Bash/MSYS, where `ps -o` is unavailable | | `fm-peek.sh` | Print a bounded tail of a crewmate pane | | `fm-pr-check.sh` | Record a PR-ready task and arm the watcher's merge poll | | `fm-promote.sh` | Promote a scout task in place so it becomes a protected ship task | diff --git a/tests/fm-afk-inject-e2e.test.sh b/tests/fm-afk-inject-e2e.test.sh index 3f97917..6ac595d 100755 --- a/tests/fm-afk-inject-e2e.test.sh +++ b/tests/fm-afk-inject-e2e.test.sh @@ -24,6 +24,9 @@ # appearance — terminal line-wrapping looks like newlines but isn't. set -u +# The daemon's mux calls must go through the tmux shim to the private socket. +export FM_MUX=tmux + ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" DAEMON="$ROOT/bin/fm-supervise-daemon.sh" diff --git a/tests/fm-mux.test.sh b/tests/fm-mux.test.sh new file mode 100644 index 0000000..03d82f2 --- /dev/null +++ b/tests/fm-mux.test.sh @@ -0,0 +1,176 @@ +#!/usr/bin/env bash +# Behavior tests for bin/fm-mux.sh — the terminal-multiplexer abstraction. +# Exercises BOTH backends through the fm-mux.sh CLI dispatcher with fakes on +# PATH, so it runs anywhere (Linux CI included) without a real tmux or WezTerm. +# Invocations pass env inline to a subprocess (repo convention), so no backend +# state leaks between cases. +set -u + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +MUX="$ROOT/bin/fm-mux.sh" +TMP_ROOT=$(mktemp -d "${TMPDIR:-/tmp}/fm-mux.XXXXXX") +trap 'rm -rf "$TMP_ROOT"' EXIT + +fail() { printf 'not ok - %s\n' "$1" >&2; exit 1; } +pass() { printf 'ok - %s\n' "$1"; } + +# --- fake tmux: logs every invocation verbatim, answers the read verbs -------- +make_fake_tmux() { + local fb="$1/fb" + mkdir -p "$fb" + cat > "$fb/tmux" <<'SH' +#!/usr/bin/env bash +echo "tmux $*" >> "$FB_LOG" +case "$1" in + capture-pane) printf 'line-1\nline-2\n' ;; + display-message) printf '7\n' ;; + list-windows) printf 'nope\n' ;; +esac +exit 0 +SH + chmod +x "$fb/tmux" + printf '%s\n' "$fb" +} + +# --- fake wezterm: a minimal `wezterm cli ...` surface ------------------------ +make_fake_wezterm() { + local fb="$1/fb" + mkdir -p "$fb" + cat > "$fb/wezterm" <<'SH' +#!/usr/bin/env bash +shift # drop "cli" +verb=$1; shift +echo "wezterm $verb $*" >> "${FB_LOG:-/dev/null}" +case "$verb" in + spawn) echo "${FB_SPAWN_PANE:-42}" ;; + set-tab-title) : ;; + send-text) cat >> "${FB_SENT:-/dev/null}" ;; # text arrives on stdin + get-text) printf 'top\nMARKER_OK\n\n\n' ;; # trailing blanks = viewport padding + kill-pane) : ;; + list) + cat <<'JSON' +[ + { + "pane_id": 42, + "title": "bash.exe", + "cwd": "file:///home/u/proj/", + "cursor_x": 3, + "cursor_y": 9 + } +] +JSON + ;; +esac +exit 0 +SH + chmod +x "$fb/wezterm" + printf '%s\n' "$fb" +} + +# ============================================================================ +# tmux backend — emits exactly the commands the rest of firstmate (and the +# other test shims) expect. +# ============================================================================ +test_tmux_backend_commands() { + local dir fb log + dir="$TMP_ROOT/tmux"; mkdir -p "$dir" + fb=$(make_fake_tmux "$dir") + log="$dir/log"; : > "$log" + + [ "$(PATH="$fb:$PATH" FM_MUX=tmux "$MUX" backend)" = tmux ] || fail "backend not tmux" + PATH="$fb:$PATH" FM_MUX=tmux FB_LOG="$log" "$MUX" send-text "s:fm-x" "hi there" + PATH="$fb:$PATH" FM_MUX=tmux FB_LOG="$log" "$MUX" send-enter "s:fm-x" + PATH="$fb:$PATH" FM_MUX=tmux FB_LOG="$log" "$MUX" send-key "s:fm-x" Escape + PATH="$fb:$PATH" FM_MUX=tmux FB_LOG="$log" "$MUX" capture "s:fm-x" 40 >/dev/null + PATH="$fb:$PATH" FM_MUX=tmux FB_LOG="$log" "$MUX" capture-visible "s:fm-x" >/dev/null + PATH="$fb:$PATH" FM_MUX=tmux FB_LOG="$log" "$MUX" kill-window "s:fm-x" + local h + h=$(PATH="$fb:$PATH" FM_MUX=tmux FB_LOG="$log" "$MUX" new-window s fm-y /tmp/wd) + [ "$h" = "s:fm-y" ] || fail "new_window handle wrong: $h" + + grep -qF 'tmux send-keys -t s:fm-x -l hi there' "$log" || fail "send_text cmd" + grep -qF 'tmux send-keys -t s:fm-x Enter' "$log" || fail "send_enter cmd" + grep -qF 'tmux send-keys -t s:fm-x Escape' "$log" || fail "send_key cmd" + grep -qF 'tmux capture-pane -p -t s:fm-x -S -40' "$log" || fail "capture cmd" + grep -qF 'tmux capture-pane -p -t s:fm-x' "$log" || fail "capture_visible cmd" + grep -qF 'tmux kill-window -t s:fm-x' "$log" || fail "kill cmd" + grep -qF 'tmux new-window -d -t s -n fm-y -c /tmp/wd' "$log" || fail "new_window cmd" + pass "tmux backend emits the expected tmux commands" +} + +# ============================================================================ +# wezterm backend +# ============================================================================ +test_wezterm_backend() { + local dir fb log sent + dir="$TMP_ROOT/wez"; mkdir -p "$dir" + fb=$(make_fake_wezterm "$dir") + log="$dir/log"; sent="$dir/sent"; : > "$log"; : > "$sent" + + [ "$(PATH="$fb:$PATH" FM_MUX=wezterm "$MUX" backend)" = wezterm ] || fail "backend not wezterm" + + # window-exists is always false on wezterm (cannot query by name) + if PATH="$fb:$PATH" FM_MUX=wezterm "$MUX" window-exists wezterm fm-foo; then + fail "window-exists should be false on wezterm" + fi + + local h + h=$(PATH="$fb:$PATH" FM_MUX=wezterm FB_LOG="$log" "$MUX" new-window wezterm fm-foo /tmp/wd) + [ "$h" = "wezterm:42" ] || fail "new_window handle: $h" + + # send-text / -enter / -key all funnel through send-text via stdin. Use a + # value full of shell metacharacters (no $-expansion) to prove the text is + # passed through verbatim, not mangled by argv conversion. + PATH="$fb:$PATH" FM_MUX=wezterm FB_SENT="$sent" "$MUX" send-text "wezterm:42" 'echo "a|b;c&d"' + grep -qF 'echo "a|b;c&d"' "$sent" || fail "send_text content not piped via stdin" + + # capture trims trailing blanks then tails (MARKER_OK is the last content line) + local out + out=$(PATH="$fb:$PATH" FM_MUX=wezterm "$MUX" capture "wezterm:42" 1) + [ "$out" = "MARKER_OK" ] || fail "capture trailing-blank trim/tail wrong: [$out]" + + # capture-visible keeps the raw viewport (trailing blank rows intact) + local vis + vis=$(PATH="$fb:$PATH" FM_MUX=wezterm "$MUX" capture-visible "wezterm:42" | wc -l | tr -d ' ') + [ "$vis" -ge 4 ] || fail "capture-visible should keep blank rows: $vis" + + # pane-path: file:///home/u/proj/ -> /home/u/proj (no cygpath on CI) + local p + p=$(PATH="$fb:$PATH" FM_MUX=wezterm "$MUX" pane-path "wezterm:42") + [ "$p" = "/home/u/proj" ] || fail "pane-path normalization wrong: [$p]" + + # cursor-y from the list JSON + [ "$(PATH="$fb:$PATH" FM_MUX=wezterm "$MUX" cursor-y "wezterm:42")" = "9" ] || fail "cursor-y wrong" + + # pane-alive: 42 present, 99 absent + PATH="$fb:$PATH" FM_MUX=wezterm "$MUX" pane-alive "wezterm:42" || fail "pane-alive should be true for 42" + if PATH="$fb:$PATH" FM_MUX=wezterm "$MUX" pane-alive "wezterm:99"; then fail "pane-alive should be false for 99"; fi + + PATH="$fb:$PATH" FM_MUX=wezterm FB_LOG="$log" "$MUX" kill-window "wezterm:42" + grep -qF 'wezterm kill-pane' "$log" || fail "kill_window cmd" + + # find-window is unsupported on wezterm + if PATH="$fb:$PATH" FM_MUX=wezterm "$MUX" find-window fm-foo 2>/dev/null; then + fail "find-window should fail on wezterm" + fi + + # resolve-supervisor maps WEZTERM_PANE to a handle + [ "$(PATH="$fb:$PATH" FM_MUX=wezterm WEZTERM_PANE=11 "$MUX" resolve-supervisor)" = "wezterm:11" ] \ + || fail "resolve-supervisor should map WEZTERM_PANE" + + pass "wezterm backend spawns, sends, captures, and normalizes cwd" +} + +# ============================================================================ +# backend selection precedence +# ============================================================================ +test_backend_precedence() { + [ "$(FM_MUX=wezterm "$MUX" backend)" = wezterm ] || fail "FM_MUX override ignored" + [ "$(TMUX=/tmp/x "$MUX" backend)" = tmux ] || fail "\$TMUX should select tmux" + pass "backend selection honors FM_MUX and \$TMUX" +} + +test_tmux_backend_commands +test_wezterm_backend +test_backend_precedence +echo "all fm-mux tests passed" diff --git a/tests/fm-secondmate-safety.test.sh b/tests/fm-secondmate-safety.test.sh index 905b0c8..32d956d 100755 --- a/tests/fm-secondmate-safety.test.sh +++ b/tests/fm-secondmate-safety.test.sh @@ -10,6 +10,10 @@ set -u # shellcheck source=tests/secondmate-helpers.sh . "$(dirname "${BASH_SOURCE[0]}")/secondmate-helpers.sh" +# Pin the multiplexer backend so the fake `tmux` shim is always used (a Windows +# dev box inside WezTerm would otherwise auto-select the wezterm backend). +export FM_MUX=tmux + TMP_ROOT=$(fm_test_tmproot fm-secondmate-safety) diff --git a/tests/fm-spawn-batch.test.sh b/tests/fm-spawn-batch.test.sh index e18ab25..3d3fe58 100755 --- a/tests/fm-spawn-batch.test.sh +++ b/tests/fm-spawn-batch.test.sh @@ -12,6 +12,10 @@ set -u # shellcheck source=tests/lib.sh . "$(dirname "${BASH_SOURCE[0]}")/lib.sh" +# Pin the multiplexer backend so detection is deterministic on any host (a Windows +# dev box inside WezTerm would otherwise auto-select the wezterm backend). +export FM_MUX=tmux + SPAWN="$ROOT/bin/fm-spawn.sh" TMP_ROOT=$(fm_test_tmproot fm-spawn-batch) diff --git a/tests/fm-wake-queue.test.sh b/tests/fm-wake-queue.test.sh index 34c5ee8..b5dfebf 100755 --- a/tests/fm-wake-queue.test.sh +++ b/tests/fm-wake-queue.test.sh @@ -9,6 +9,11 @@ set -u # shellcheck source=tests/wake-helpers.sh . "$(dirname "${BASH_SOURCE[0]}")/wake-helpers.sh" +# Pin the multiplexer backend so the fake `tmux` shim is always used, regardless +# of the host (e.g. a Windows dev box running inside WezTerm would otherwise +# auto-select the wezterm backend). +export FM_MUX=tmux + WATCH="$ROOT/bin/fm-watch.sh" DRAIN="$ROOT/bin/fm-wake-drain.sh"