From d77f652f26cb9a7baaf7d9b6d7c090c1a560f8cb Mon Sep 17 00:00:00 2001 From: kamick Date: Tue, 23 Jun 2026 12:00:17 -0700 Subject: [PATCH 1/6] feat: run on Windows via terminal-multiplexer abstraction tmux has no Windows port, so route all multiplexer access through bin/fm-mux.sh (tmux + wezterm backends) and add portable process introspection in bin/fm-proc.sh (MSYS ps lacks -o). Refactor every tmux consumer, harness/lock detection, and bootstrap; pin FM_MUX=tmux in tests; add tests/fm-mux.test.sh; enforce LF via .gitattributes; update README/CONTRIBUTING/AGENTS. --- .gitattributes | 15 ++ AGENTS.md | 8 +- CONTRIBUTING.md | 5 + README.md | 38 ++-- bin/fm-bootstrap.sh | 13 +- bin/fm-harness.sh | 32 +-- bin/fm-lock.sh | 33 +-- bin/fm-mux.sh | 371 ++++++++++++++++++++++++++++++++ bin/fm-peek.sh | 7 +- bin/fm-proc.sh | 128 +++++++++++ bin/fm-send.sh | 11 +- bin/fm-spawn.sh | 30 +-- bin/fm-supervise-daemon.sh | 56 +++-- bin/fm-teardown.sh | 6 +- bin/fm-watch.sh | 4 +- tests/fm-afk-inject-e2e.test.sh | 3 + tests/fm-mux.test.sh | 176 +++++++++++++++ tests/fm-secondmate.test.sh | 4 + tests/fm-spawn-batch.test.sh | 3 + tests/fm-wake-queue.test.sh | 5 + 20 files changed, 860 insertions(+), 88 deletions(-) create mode 100644 .gitattributes create mode 100644 bin/fm-mux.sh create mode 100644 bin/fm-proc.sh create mode 100644 tests/fm-mux.test.sh 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 eb98518..d866a76 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -67,7 +67,7 @@ README.md public overview and development notes .github/workflows/ shared CI and PR enforcement, committed .agents/skills/ shared skills, committed .claude/skills symlink to .agents/skills for claude compatibility -bin/ helper scripts, committed, including fm-fleet-sync.sh for clean default-branch refreshes and gone-branch pruning; read each script's header before first use +bin/ helper scripts, committed, including fm-fleet-sync.sh for clean default-branch refreshes and gone-branch pruning, 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 @@ -127,6 +127,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 word - an adapter name below; the file is local and gitignored, so each machine keeps its own; 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 eedab10..0908dc9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -38,6 +38,11 @@ See the [no-mistakes quick start](https://kunchenguid.github.io/no-mistakes/star - Helper scripts in `bin/` are plain bash. Each starts with a usage header comment; keep it accurate when you change behavior. `shellcheck bin/*.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`, the adapter tables in `AGENTS.md`) must be verified empirically against the real harness, never written from documentation alone. - In Markdown, put each full sentence on its own line. diff --git a/README.md b/README.md index 3b1da8b..40c1d5b 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 @@ -71,7 +71,9 @@ $ claude # launch your agent harness here; AGENTS.md takes over ```sh # 1. a verified agent harness - claude, codex, opencode, or pi # 2. git + GitHub auth -# 3. tmux - the crew lives in tmux windows (firstmate offers to install it if missing) +# 3. a terminal multiplexer - tmux on macOS/Linux, or WezTerm on Windows +# (tmux has no Windows port; firstmate drives WezTerm's CLI there instead). +# firstmate offers to install one if missing. gh auth login ``` @@ -83,10 +85,11 @@ cd firstmate && claude ``` That is the whole install. -On first launch the first mate detects what its 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. +On first launch the first mate detects what its toolchain is missing or too old (a multiplexer, 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. -**Run it inside tmux for the best experience.** -firstmate works from any terminal - outside tmux, crewmates land in a detached `firstmate` session you can attach to - but launching your harness from inside tmux puts every crewmate window in your own session, one per task, where you can watch the crew work in real time or type into any window to intervene. +**Run it inside your multiplexer for the best experience.** +On macOS/Linux that means tmux; on Windows it means WezTerm. +firstmate works from any terminal - outside tmux, crewmates land in a detached `firstmate` session you can attach to - but launching your harness from inside the multiplexer puts every crewmate window (a tmux window, or a WezTerm tab on Windows) alongside your own, one per task, where you can watch the crew work in real time or type into any window to intervene. ## How It Works @@ -99,10 +102,10 @@ firstmate works from any terminal - outside tmux, crewmates land in a detached ` │ 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 └───┬────┘ └───┬────┘ └───┬────┘ ▼ ▼ ▼ @@ -136,7 +139,7 @@ firstmate works from any terminal - outside tmux, crewmates land in a detached ` - **Project memory belongs to projects** - durable project-intrinsic agent knowledge lives in each project's committed `AGENTS.md`, with `CLAUDE.md` as a symlink. Ship briefs prompt crewmates to create or update those files through the normal delivery path; `data/projects.md` stays a thin private registry. - **Local clones stay fresh** - bootstrap and PR-based teardown refresh remote-backed project clones with clean default-branch fast-forwards when the clone is on the default branch and has no local work, and prune local branches whose remote is gone and that no worktree still needs. -- **Restart-proof** - all state lives in tmux, status files, local markdown under `data/`, `data/secondmates.md`, and persistent secondmate homes. +- **Restart-proof** - all state lives in the multiplexer, status files, local markdown under `data/`, `data/secondmates.md`, and persistent secondmate homes. Kill the first mate session anytime; the next one reconciles and carries on. ## The bin/ toolbelt @@ -166,6 +169,8 @@ The first mate drives these; you rarely need to, but they work by hand too. | `fm-teardown.sh` | Return the worktree or retire/release a secondmate home; protects ship work, requires scout reports, and checks child work | | `fm-harness.sh` | Detect the running harness; resolve the effective crewmate harness | | `fm-lock.sh` | Per-home firstmate session lock | +| `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 (Linux, macOS, and Git Bash/MSYS, where `ps -o` is unavailable) | ## Configuration @@ -185,10 +190,14 @@ Set `FM_SECONDMATE_CHARTER` to seed from inline charter text when no filled char When it is unset, the repo root is the home; when it is set, scripts still run from this repo's `bin/`, but `state/`, `data/`, `config/`, and `projects/` come from `$FM_HOME`. Harness support is a table in section 4: claude, codex, opencode, and pi are all empirically verified; new harnesses get verified through a supervised trial task before joining the table. +On Windows, firstmate runs under Git Bash and drives the crew through WezTerm's multiplexer CLI instead of tmux (which has no Windows port); `bin/fm-mux.sh` auto-selects the backend, and `FM_MUX` forces it (`tmux` or `wezterm`). + Runtime tuning via environment variables (defaults shown): ```sh FM_HOME= # optional operational home; unset means this repo root +FM_MUX= # force the multiplexer backend (tmux|wezterm); unset = auto-detect +FM_WEZTERM=wezterm # wezterm CLI binary used by the wezterm backend 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 @@ -200,7 +209,7 @@ FM_FLEET_SYNC_BOOTSTRAP_TIMEOUT=20 # seconds allowed for bootstrap's best-effo FM_FLEET_PRUNE=1 # set to 0 to skip pruning local branches whose upstream is gone FM_BUSY_REGEX='esc (to )?interrupt|Working\.\.\.' # busy-pane signatures, extend per harness # 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_STALE_ESCALATE_SECS=240 # idle seconds before a stale pane escalates as a possible wedge FM_ESCALATE_BATCH_SECS=90 # buffer window for batched escalation digests; 0 = flush immediately @@ -221,6 +230,7 @@ The presence-gated sub-supervisor (`bin/fm-supervise-daemon.sh`) provides proact 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, singleton behavior, sub-supervisor classifier, and /afk presence-gating tests tests/fm-afk-inject-e2e.test.sh # private-socket end-to-end test of the afk injection path (partial-input deferral, swallowed-Enter retry) tests/fm-bootstrap.test.sh # bootstrap dependency and feature-probe tests diff --git a/bin/fm-bootstrap.sh b/bin/fm-bootstrap.sh index 9150ab8..3584c37 100755 --- a/bin/fm-bootstrap.sh +++ b/bin/fm-bootstrap.sh @@ -59,7 +59,9 @@ fleet_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" ;; @@ -67,7 +69,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:]_-]|$)' @@ -91,6 +99,7 @@ done if command -v treehouse >/dev/null 2>&1 && ! treehouse_supports_lease; then echo "MISSING: treehouse (install: $(install_cmd treehouse))" fi +mux_present || echo "MISSING: multiplexer (install: 'brew install tmux' on macOS/Linux, or install WezTerm https://wezfurlong.org/wezterm/install/windows.html on Windows)" gh auth status >/dev/null 2>&1 || echo "NEEDS_GH_AUTH" crew= [ -f "$CONFIG/crew-harness" ] && crew=$(tr -d '[:space:]' < "$CONFIG/crew-harness" || true) 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..8be1cad --- /dev/null +++ b/bin/fm-mux.sh @@ -0,0 +1,371 @@ +#!/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 + shell="${FM_MUX_WEZTERM_SHELL:-$(command -v bash || printf '%s' bash)}" + 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) + # get-text returns the whole viewport, including the blank rows below the + # content (tmux's -S -N does not). 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")" 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..24fcac1 --- /dev/null +++ b/bin/fm-proc.sh @@ -0,0 +1,128 @@ +#!/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 tmp winpath rc + ps=$(_fm_proc_pwsh) || return 1 + tmp=$(mktemp 2>/dev/null) || tmp="${TMPDIR:-/tmp}/fm-proc-$$-$RANDOM" + tmp="$tmp.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 -f "$tmp" 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 3c220dd..7e49429 100755 --- a/bin/fm-send.sh +++ b/bin/fm-send.sh @@ -12,6 +12,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 @@ -26,8 +28,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 } @@ -35,11 +36,11 @@ T=$(resolve "$1") shift if [ "${1:-}" = "--key" ]; then - tmux send-keys -t "$T" "$2" + fm_mux_send_key "$T" "$2" else - tmux send-keys -t "$T" -l "$*" + fm_mux_send_text "$T" "$*" # Slash commands open a completion popup in some TUIs (verified on codex); # submitting too fast selects nothing. Give popups time to settle. case "$*" in /*) sleep 1.2 ;; *) sleep 0.3 ;; esac - tmux send-keys -t "$T" Enter + fm_mux_send_enter "$T" fi diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index df76cd6..cee43b4 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -303,28 +303,28 @@ 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" +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) + p=$(fm_mux_pane_path "$T" 2>/dev/null || true) if [ -n "$p" ] && [ "$p" != "$PROJ_ABS" ]; then WT="$p" break @@ -430,8 +430,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 e736691..04cb9e7 100755 --- a/bin/fm-supervise-daemon.sh +++ b/bin/fm-supervise-daemon.sh @@ -88,6 +88,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" # --- tunables --------------------------------------------------------------- FM_SUPERVISOR_TARGET_DEFAULT="firstmate:0" @@ -218,10 +220,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() { @@ -233,6 +236,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 } @@ -387,7 +394,7 @@ mark_escalated_seen() { # # 0 if the pane is currently showing a busy signature (crewmate resumed/working). pane_is_busy() { # local win=$1 tail40 - tail40=$(tmux capture-pane -p -t "$win" -S -40 2>/dev/null) || return 1 + tail40=$(fm_mux_capture "$win" 40 2>/dev/null) || return 1 printf '%s' "$tail40" | grep -v '^[[:space:]]*$' | tail -6 \ | grep -qiE "${FM_BUSY_REGEX:-$BUSY_REGEX_DEFAULT}" } @@ -405,10 +412,10 @@ pane_is_busy() { # pane_input_pending() { # local target=$1 cy pane_out line # Get the cursor's Y position (0-indexed from the top of the visible pane). - cy=$(tmux display-message -p -t "$target" '#{cursor_y}' 2>/dev/null) || return 1 + 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=$(tmux capture-pane -p -t "$target" 2>/dev/null) || return 1 + 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:]]}"}" @@ -518,9 +525,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 } @@ -556,7 +580,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 non-empty content (a human's @@ -576,7 +600,7 @@ inject_msg() { # [state] # consumed); submit failure = the composer still has our text (Enter swallowed). retries=${FM_INJECT_CONFIRM_RETRIES:-$INJECT_CONFIRM_RETRIES_DEFAULT} sleep_s=${FM_INJECT_CONFIRM_SLEEP:-$INJECT_CONFIRM_SLEEP_DEFAULT} - if ! tmux send-keys -t "$target" -l "$msg" 2>/dev/null; then + if ! fm_mux_send_text "$target" "$msg" 2>/dev/null; then log "inject failed: send-keys -l returned non-zero" return 1 fi @@ -584,7 +608,7 @@ inject_msg() { # [state] i=0 while [ "$i" -lt "$retries" ]; do i=$((i + 1)) - tmux send-keys -t "$target" Enter 2>/dev/null || true + fm_mux_send_enter "$target" 2>/dev/null || true sleep "$sleep_s" if ! pane_input_pending "$target"; then return 0 # Composer cleared → submit succeeded. @@ -735,8 +759,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 @@ -801,7 +825,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 69885f3..9504ff4 100755 --- a/bin/fm-teardown.sh +++ b/bin/fm-teardown.sh @@ -33,6 +33,8 @@ DATA="${FM_DATA_OVERRIDE:-$FM_HOME/data}" SECONDMATE_REG="$DATA/secondmates.md" SUB_HOME_MARKER=".fm-secondmate-home" "$FM_ROOT/bin/fm-guard.sh" || true +# shellcheck source=bin/fm-mux.sh +. "$FM_ROOT/bin/fm-mux.sh" ID=$1 FORCE=${2:-} @@ -328,7 +330,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) @@ -440,7 +442,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 daa4356..5a4b750 100755 --- a/bin/fm-watch.sh +++ b/bin/fm-watch.sh @@ -18,6 +18,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" WATCHER_STALE_GRACE=${FM_WATCHER_STALE_GRACE:-${FM_GUARD_GRACE:-300}} @@ -221,7 +223,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/tests/fm-afk-inject-e2e.test.sh b/tests/fm-afk-inject-e2e.test.sh index 965db80..ab51113 100755 --- a/tests/fm-afk-inject-e2e.test.sh +++ b/tests/fm-afk-inject-e2e.test.sh @@ -21,6 +21,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.test.sh b/tests/fm-secondmate.test.sh index ea01991..956b7b2 100755 --- a/tests/fm-secondmate.test.sh +++ b/tests/fm-secondmate.test.sh @@ -2,6 +2,10 @@ # Behavior tests for secondmate home routing and lifecycle reuse. set -u +# 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 + ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" TMP_ROOT= diff --git a/tests/fm-spawn-batch.test.sh b/tests/fm-spawn-batch.test.sh index 7f36a1a..ccc5b54 100755 --- a/tests/fm-spawn-batch.test.sh +++ b/tests/fm-spawn-batch.test.sh @@ -5,6 +5,9 @@ # windows or worktrees. FM_SPAWN_NO_GUARD=1 keeps them off the live watcher guard / state. set -u +# Pin the multiplexer backend so detection is deterministic on any host. +export FM_MUX=tmux + ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" SPAWN="$ROOT/bin/fm-spawn.sh" TMP_ROOT=$(mktemp -d "${TMPDIR:-/tmp}/fm-spawn-batch.XXXXXX") diff --git a/tests/fm-wake-queue.test.sh b/tests/fm-wake-queue.test.sh index 302cd37..1631fd2 100755 --- a/tests/fm-wake-queue.test.sh +++ b/tests/fm-wake-queue.test.sh @@ -1,6 +1,11 @@ #!/usr/bin/env bash set -u +# 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 + ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" WATCH="$ROOT/bin/fm-watch.sh" DRAIN="$ROOT/bin/fm-wake-drain.sh" From 5adf65a384d6066e7e170fe6568f100f6e6e68ca Mon Sep 17 00:00:00 2001 From: kamick Date: Tue, 23 Jun 2026 17:02:22 -0700 Subject: [PATCH 2/6] fix(windows): address review findings on the multiplexer port - fm-spawn: add an OSC-7-independent fallback to worktree detection (sentinel $PWD printed into the pane, read back from pane text), gated to the wezterm backend so the tmux path stays byte-identical. - fm-mux: cygpath -w the auto-detected MSYS bash path before passing it to the native wezterm cli spawn (a Windows process cannot resolve /usr/bin/bash). - fm-lock: align HARNESS_RE with fm-harness.sh so it matches the Windows process name pi.exe (and pi with args), not only a bare ^pi$. - fm-bootstrap: emit a per-platform installable token (wezterm on Windows, tmux elsewhere) instead of the unknown MISSING: multiplexer token. --- bin/fm-bootstrap.sh | 10 +++++++++- bin/fm-lock.sh | 5 ++++- bin/fm-mux.sh | 13 ++++++++++++- bin/fm-spawn.sh | 28 ++++++++++++++++++++++++++-- 4 files changed, 51 insertions(+), 5 deletions(-) diff --git a/bin/fm-bootstrap.sh b/bin/fm-bootstrap.sh index 3584c37..d4d2de9 100755 --- a/bin/fm-bootstrap.sh +++ b/bin/fm-bootstrap.sh @@ -99,7 +99,15 @@ done if command -v treehouse >/dev/null 2>&1 && ! treehouse_supports_lease; then echo "MISSING: treehouse (install: $(install_cmd treehouse))" fi -mux_present || echo "MISSING: multiplexer (install: 'brew install tmux' on macOS/Linux, or install WezTerm https://wezfurlong.org/wezterm/install/windows.html on Windows)" +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" crew= [ -f "$CONFIG/crew-harness" ] && crew=$(tr -d '[:space:]' < "$CONFIG/crew-harness" || true) diff --git a/bin/fm-lock.sh b/bin/fm-lock.sh index e0f798c..9cfbc59 100755 --- a/bin/fm-lock.sh +++ b/bin/fm-lock.sh @@ -18,7 +18,10 @@ mkdir -p "$STATE" . "$SCRIPT_DIR/fm-proc.sh" # Known harness command names; extend when a new adapter is verified. -HARNESS_RE='claude|codex|opencode|^pi$' +# pi is anchored (so it does not match substrings like "api"/"raspi") but must +# also accept the Windows process name pi.exe, matching fm-harness.sh - as a bare +# basename and as "basename args" (the form holder_alive greps). +HARNESS_RE='claude|codex|opencode|(^|[\\/])pi(\.exe)?($|[[:space:]])' harness_pid() { local pid comm args base diff --git a/bin/fm-mux.sh b/bin/fm-mux.sh index 8be1cad..a293a7d 100644 --- a/bin/fm-mux.sh +++ b/bin/fm-mux.sh @@ -185,7 +185,18 @@ fm_mux_new_window() { if command -v cygpath >/dev/null 2>&1; then wincwd=$(cygpath -w "$cwd" 2>/dev/null || printf '%s' "$cwd") fi - shell="${FM_MUX_WEZTERM_SHELL:-$(command -v bash || printf '%s' bash)}" + # 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 diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index cee43b4..b7d4dda 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -322,13 +322,37 @@ if [ "$KIND" != secondmate ]; then 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 + # 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 From ea68cab6d752275297b65f2fa7e93f131cb14530 Mon Sep 17 00:00:00 2001 From: kamick Date: Tue, 23 Jun 2026 17:29:30 -0700 Subject: [PATCH 3/6] no-mistakes(review): restore wezterm duplicate-spawn guard and scrollback capture --- bin/fm-mux.sh | 17 ++++++++++------- bin/fm-spawn.sh | 15 +++++++++++++++ 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/bin/fm-mux.sh b/bin/fm-mux.sh index a293a7d..d31e724 100644 --- a/bin/fm-mux.sh +++ b/bin/fm-mux.sh @@ -258,14 +258,17 @@ fm_mux_capture() { case "$(fm_mux_backend)" in tmux) tmux capture-pane -p -t "$handle" -S -"$lines" ;; wezterm) - # get-text returns the whole viewport, including the blank rows below the - # content (tmux's -S -N does not). 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. + # --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")" 2>/dev/null) || return 1 + 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" diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index b7d4dda..d60f6e5 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -317,6 +317,21 @@ if fm_mux_window_exists "$SES" "$W"; then exit 1 fi +# 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 fm_mux_send_text "$T" 'treehouse get' From 33db532fc4d375616c4ad244be4b5388e793bc89 Mon Sep 17 00:00:00 2001 From: kamick Date: Thu, 25 Jun 2026 12:11:15 -0700 Subject: [PATCH 4/6] chore: trigger no-mistakes validation From e17547032ee47ee788d95c32de5ce5ec1e4741c6 Mon Sep 17 00:00:00 2001 From: kamick Date: Thu, 25 Jun 2026 12:24:52 -0700 Subject: [PATCH 5/6] no-mistakes(review): fix temp-dir leak in fm-proc PowerShell helper --- bin/fm-proc.sh | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/bin/fm-proc.sh b/bin/fm-proc.sh index 24fcac1..d4e7421 100644 --- a/bin/fm-proc.sh +++ b/bin/fm-proc.sh @@ -38,16 +38,19 @@ _fm_proc_winpid() { # 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 tmp winpath rc + local ps tmpdir tmp winpath rc ps=$(_fm_proc_pwsh) || return 1 - tmp=$(mktemp 2>/dev/null) || tmp="${TMPDIR:-/tmp}/fm-proc-$$-$RANDOM" - tmp="$tmp.ps1" + 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 -f "$tmp" 2>/dev/null || true + rm -rf "$tmpdir" 2>/dev/null || true return "$rc" } From 65977e70d91580f285249d787139e5c7c06ea1dc Mon Sep 17 00:00:00 2001 From: kamick Date: Thu, 25 Jun 2026 13:06:17 -0700 Subject: [PATCH 6/6] no-mistakes(document): sync docs/comments with multiplexer (wezterm) backend changes --- AGENTS.md | 2 +- README.md | 2 +- bin/fm-supervise-daemon.sh | 7 ++++--- bin/fm-teardown.sh | 2 +- 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d866a76..53bd84f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -575,7 +575,7 @@ This is why fewer, cheaper firstmate turns handle the same fleet. - **Marker strip** — `strip_injection_marker` removes the sentinel prefix before classification/relay, so the digest text firstmate sees is clean. - **Portable singleton lock** — the daemon uses the repo's mkdir-based lock helper (`fm-wake-lib.sh`) instead of `flock`, which is absent on macOS. - **Dedupe across signal/stale/scan** — `classify_signal` and `classify_stale` both check the seen-status marker before escalating, so a status escalated by one path is not re-escalated by another in the same digest. -- **Auto-discovered supervisor pane** — the daemon resolves its injection target from `FM_SUPERVISOR_TARGET`, then `$TMUX_PANE` (inherited from the pane that launched it), then a `firstmate:0` fallback with a warning; the resolution source is logged at startup so a wrong-but-resolving fallback is detectable. +- **Auto-discovered supervisor pane** — the daemon resolves its injection target from `FM_SUPERVISOR_TARGET`, then `$TMUX_PANE` / `$WEZTERM_PANE` (whichever the multiplexer set in the pane that launched it; `$WEZTERM_PANE` becomes a `wezterm:` handle), then a `firstmate:0` fallback with a warning; the resolution source is logged at startup so a wrong-but-resolving fallback is detectable. **Reliability properties (must hold):** nothing is lost (the #29 queue plus `fm-wake-drain.sh` recover any missed/crashed injection); wedge detection is bounded-latency, not lossy; the catch-all scan backs up the keyword classifier; the daemon preserves single-instance portable lock, crash-loop backoff, a pane-gone guard, and a signal-trapped shutdown that flushes buffered escalations before exit. `FM_INJECT_SKIP` (default `heartbeat`) force-self-handles matching kinds, overriding classification - use sparingly. diff --git a/README.md b/README.md index 40c1d5b..78004bf 100644 --- a/README.md +++ b/README.md @@ -124,7 +124,7 @@ firstmate works from any terminal - outside tmux, crewmates land in a detached ` - **Worktrees, not branches in your checkout** - crewmates never touch your clone; treehouse pools clean worktrees so parallel tasks on one repo cannot collide. - **Two task shapes** - ship tasks change projects and ship by project mode (`no-mistakes`, `direct-PR`, or `local-only`); scout tasks investigate, plan, reproduce bugs, or audit, then leave a report at `data//report.md` and never push. - **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/bin/fm-supervise-daemon.sh b/bin/fm-supervise-daemon.sh index 04cb9e7..9e8201c 100755 --- a/bin/fm-supervise-daemon.sh +++ b/bin/fm-supervise-daemon.sh @@ -52,9 +52,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 diff --git a/bin/fm-teardown.sh b/bin/fm-teardown.sh index 9504ff4..ce1a4c1 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