From 9a90bd69c51120cd037fab32dff200d1fea745ca Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 24 Aug 2026 23:37:29 -0700 Subject: [PATCH 1/3] feat(backend): add the cmux-tui session-provider adapter cmux-tui is the headless Rust terminal multiplexer from manaflow-ai/cmux (cmux-tui/), a different product from the macOS GUI app bin/backends/cmux.sh drives. One dedicated headless session per firstmate home (fm-, server start --session --headless --state), one workspace per task with one terminal tab, target string :. Live-verified against 0.1.0: durable ids across server restarts (ids are recovery authority; names are display labels behind the exactly-one duplicate guard), write --text never auto-submits, screen read works on fresh terminals and carries the cursor (composer capability cursor=1 into the shared classifier), last-workspace close works, process show cwd plus child-pid lsof//proc fallback replaces the screen-scraped pwd probe, and mutations ride per-attempt correlation-key nonces retried once on typed retryable/indeterminate errors. Registry wiring: known/spawn lists, required tools (cmux-tui jq treehouse), runtime detection from CMUX_TUI_SOCKET (legacy CMUX_MUX_SOCKET) after tmux/herdr and before the GUI cmux markers, endpoint validation, dispatch arms (capture, keys, submit, kill, composer, busy via the native hook-fed agent record, target-exists), fm-spawn create/steer/meta arms with the --secondmate refusal, control-lib key support, and the bootstrap install hint. --- bin/backends/cmux-tui.sh | 611 +++++++++++++++++++++++++++++++++++++++ bin/fm-backend.sh | 72 ++++- bin/fm-bootstrap.sh | 1 + bin/fm-control-lib.sh | 2 +- bin/fm-spawn.sh | 27 +- 5 files changed, 709 insertions(+), 4 deletions(-) create mode 100644 bin/backends/cmux-tui.sh diff --git a/bin/backends/cmux-tui.sh b/bin/backends/cmux-tui.sh new file mode 100644 index 00000000000..b382a84b91f --- /dev/null +++ b/bin/backends/cmux-tui.sh @@ -0,0 +1,611 @@ +#!/usr/bin/env bash +# bin/backends/cmux-tui.sh - the cmux-tui session-provider adapter +# (EXPERIMENTAL). cmux-tui is the Rust terminal multiplexer in +# manaflow-ai/cmux `cmux-tui/`, NOT the macOS GUI app that +# bin/backends/cmux.sh drives; the two adapters are independent. +# +# Function prefix: fm_backend_cmuxtui_* (the backend NAME is "cmux-tui", +# but shell function names cannot carry the hyphen portably, so the +# dispatcher's cmux-tui case arms call the cmuxtui-prefixed family). +# +# cmux-tui is a session provider ONLY, exactly like tmux/herdr/zellij: the +# worktree provider stays treehouse. Sourced only through bin/fm-backend.sh's +# fm_backend_source in normal operation; the unit tests source it directly. +# +# Container shape: cmux-tui HAS a real session layer (unlike the GUI app). +# ONE dedicated headless cmux-tui session per firstmate home, named +# "fm-" (bin/fm-backend-hometag-lib.sh), started with +# `server start --session --headless --state ` against an +# adapter-owned state dir. ONE workspace per task with exactly one terminal +# tab inside it. Because the session itself is home-scoped, workspace names +# are the PLAIN caller-facing "fm-" labels - no title tag is needed for +# cross-home isolation (the tag lives in the session name instead). +# +# Target string shape: ":" - typed opaque ids +# ("ws_", "term_") with no embedded colon, so splitting on the +# FIRST colon is trivially correct (herdr/zellij/cmux convention). +# +# Empirical findings (real cmux-tui 0.1.0 @ 4471965b12, macOS aarch64, +# 2026-08-24; docs/verification/runtime-backends.md "cmux-tui" has the +# evidence log) that shaped this adapter: +# +# 1. IDs are DURABLE across server restarts: after `server stop` + +# `server start --state `, `workspace list` and +# `terminal show` return the SAME ws_/term_ ids (verified live). +# IDs are therefore the recovery authority; workspace names are display +# labels only, adopted only through the standard exactly-one duplicate +# guard when a recorded id has genuinely disappeared. +# 2. `terminal write --text` does NOT auto-submit; `keys enter` is a +# separate call - the fleet-wide literal-then-Enter contract, verified. +# 3. `screen read` works on a genuinely FRESH terminal (no GUI-cmux +# fresh-surface internal_error) and returns cursor_row/cursor_col plus +# cursor_visible, giving this adapter a cursor primitive the GUI app +# lacks (composer capability cursor=1). +# 4. Closing the LAST workspace of a session works; the session server +# keeps running with an empty tree (no GUI-cmux last-in-window sibling +# dance, no zellij ghost tab). +# 5. `process show --json` reports the top-level shell's cwd as a +# file:///path URL (strip scheme+host) and its child pids. The +# structured cwd follows a `cd` typed into an OSC7-integrated shell but +# stays FROZEN when a foreground subshell without shell integration +# (exactly what `treehouse get` opens) does its own cd - verified with +# `env -i bash --norc`. The live answer for that case is the child +# pid's OS-level cwd: `lsof -a -p -d cwd -Fn` on macOS, +# /proc//cwd on Linux. No screen-scraped pwd-marker probe needed. +# 6. NO workspace-name uniqueness enforcement (two workspaces created with +# one name, verified). The duplicate refusal below is ours, mirroring +# every other adapter. +# 7. Mutations accept --correlation-key: replaying the same key returns +# the ORIGINAL result with replayed:true - even after the created +# workspace was closed (verified), so keys must be per-attempt nonces +# used only to retry the SAME indeterminate attempt, never derived from +# stable labels. +# 8. Typed JSON errors ({code, message, retryable, details}), e.g. +# selector.not_found, validation.invalid, mutation.indeterminate. The +# mutate wrapper retries once on a retryable/indeterminate error with +# the SAME correlation key (act-then-handle, not check-then-act). +# 9. Native per-terminal agent state exists: `agent list --json` rows +# carry state working|blocked|idle|done|unknown (hook-fed via +# `agent report`/`agent hook install`), the same vocabulary as herdr's +# agent_status, mapped the same way for fm_backend_busy_state. +# 10. The per-uid socket dir is 0700 and same-user; there is no +# socketControlMode matrix and no password handshake (the GUI app's +# whole auth section does not exist here). +# +# Config isolation is load-bearing: the operator's own ~/.config/cmux/ +# cmux-tui.json may configure a machine provider that refuses +# --session/--headless startup, so EVERY invocation exports CMUX_TUI_CONFIG +# pointing at an adapter-owned config file (fm_backend_cmuxtui_config). +# +# Requires: cmux-tui (CLI; override with FM_CMUXTUI_BIN), jq (JSON parsing). +# Bootstrap detects these through fm_backend_required_tools only when +# cmux-tui is the resolved backend; this adapter also gates them again +# before spawning. + +# FM_HOME fallback: every real caller already sets FM_HOME as a global before +# sourcing fm-backend.sh (which sources this file); this exists only so this +# file's own unit tests, which source it directly, resolve sanely. Mirrors +# bin/backends/zellij.sh's identical fallback. +FM_BACKEND_CMUXTUI_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +FM_ROOT="${FM_ROOT_OVERRIDE:-${FM_ROOT:-$FM_BACKEND_CMUXTUI_ROOT}}" +FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" + +# shellcheck source=bin/fm-backend-hometag-lib.sh +. "$FM_BACKEND_CMUXTUI_ROOT/bin/fm-backend-hometag-lib.sh" + +# Shared composer classification (the fleet-wide shape catalogue and verdict +# owner; this adapter contributes only capture and capability facts). +# shellcheck source=bin/fm-composer-lib.sh +. "$FM_BACKEND_CMUXTUI_ROOT/bin/fm-composer-lib.sh" + +# Verified minimum: the version the live pass ran against +# (docs/verification/runtime-backends.md "cmux-tui"). +FM_BACKEND_CMUXTUI_MIN_MAJOR=0 +FM_BACKEND_CMUXTUI_MIN_MINOR=1 + +# fm_backend_cmuxtui_bin: resolve the cmux-tui CLI binary. FM_CMUXTUI_BIN +# overrides (test isolation and non-PATH installs); otherwise PATH. +fm_backend_cmuxtui_bin() { + if [ -n "${FM_CMUXTUI_BIN:-}" ]; then + printf '%s' "$FM_CMUXTUI_BIN" + return 0 + fi + command -v cmux-tui 2>/dev/null && return 0 + return 1 +} + +fm_backend_cmuxtui_tool_check() { + fm_backend_cmuxtui_bin >/dev/null 2>&1 || { echo "error: backend=cmux-tui selected but the 'cmux-tui' CLI was not found on PATH (set FM_CMUXTUI_BIN or install it from https://github.com/manaflow-ai/cmux)" >&2; return 1; } + command -v jq >/dev/null 2>&1 || { echo "error: backend=cmux-tui selected but 'jq' is not installed (required to parse cmux-tui's JSON output)" >&2; return 1; } + return 0 +} + +# fm_backend_cmuxtui_session: the home-scoped session name. One dedicated +# headless session per firstmate home ("fm-"), so crews are isolated +# per home AND from the operator's own interactive cmux-tui sessions +# (including the default "main" session, which this adapter never touches). +# FM_CMUXTUI_SESSION overrides for test isolation, mirroring FM_ZELLIJ_SESSION. +fm_backend_cmuxtui_session() { + if [ -n "${FM_CMUXTUI_SESSION:-}" ]; then + printf '%s' "$FM_CMUXTUI_SESSION" + return 0 + fi + printf 'fm-%s' "$(fm_backend_hometag)" +} + +# fm_backend_cmuxtui_state_dir: the adapter-owned durable state dir handed to +# `server start --state`. Durable ids (finding #1) live here, so it must be a +# per-home stable path: /cmux-tui under the home's own state root. +# FM_CMUXTUI_STATE overrides for test isolation. +fm_backend_cmuxtui_state_dir() { + if [ -n "${FM_CMUXTUI_STATE:-}" ]; then + printf '%s' "$FM_CMUXTUI_STATE" + return 0 + fi + printf '%s/cmux-tui' "${FM_STATE_OVERRIDE:-$FM_HOME/state}" +} + +# fm_backend_cmuxtui_config: the adapter-owned CMUX_TUI_CONFIG file, created +# as an empty JSON object on first use. Never the operator's own +# ~/.config/cmux/cmux-tui.json: a machine_provider configured there makes +# --session/--headless startup fail outright (see file header). +# FM_CMUXTUI_CONFIG overrides for test isolation. +fm_backend_cmuxtui_config() { + local cfg dir + cfg="${FM_CMUXTUI_CONFIG:-$(fm_backend_cmuxtui_state_dir)/cmux-tui.json}" + if [ ! -f "$cfg" ]; then + dir=$(dirname "$cfg") + mkdir -p "$dir" 2>/dev/null || true + printf '{}\n' > "$cfg" 2>/dev/null || true + fi + printf '%s' "$cfg" +} + +# fm_backend_cmuxtui_cli: run `cmux-tui --session ` +# with the adapter-owned config exported. The global --session flag routes +# every scope/action through the home's dedicated session socket, so no call +# can ever reach the operator's default "main" session. +fm_backend_cmuxtui_cli() { # + local bin + bin=$(fm_backend_cmuxtui_bin) || return 1 + CMUX_TUI_CONFIG="$(fm_backend_cmuxtui_config)" \ + "$bin" --session "$(fm_backend_cmuxtui_session)" "$@" +} + +# fm_backend_cmuxtui_version_check: refuse loudly on a missing/incompatible +# cmux-tui client. `cmux-tui --version` needs no socket (verified: prints +# "cmux (; ghostty )" with no server running). +fm_backend_cmuxtui_version_check() { + fm_backend_cmuxtui_tool_check || return 1 + local bin raw ver major rest minor + bin=$(fm_backend_cmuxtui_bin) || return 1 + raw=$("$bin" --version 2>/dev/null) || { echo "error: 'cmux-tui --version' failed; is cmux-tui installed correctly?" >&2; return 1; } + ver=$(printf '%s' "$raw" | awk '{print $2}') + case "$ver" in + ''|*[!0-9.]*) + echo "error: could not parse a cmux-tui version from '$raw'; refusing to use an unverified cmux-tui build" >&2 + return 1 + ;; + esac + major=${ver%%.*} + rest=${ver#*.} + minor=${rest%%.*} + case "$major" in ''|*[!0-9]*) major=0 ;; esac + case "$minor" in ''|*[!0-9]*) minor=0 ;; esac + if [ "$major" -lt "$FM_BACKEND_CMUXTUI_MIN_MAJOR" ] || { [ "$major" -eq "$FM_BACKEND_CMUXTUI_MIN_MAJOR" ] && [ "$minor" -lt "$FM_BACKEND_CMUXTUI_MIN_MINOR" ]; }; then + echo "error: cmux-tui $ver is older than the verified minimum $FM_BACKEND_CMUXTUI_MIN_MAJOR.$FM_BACKEND_CMUXTUI_MIN_MINOR; update cmux-tui before using backend=cmux-tui" >&2 + return 1 + fi + return 0 +} + +# fm_backend_cmuxtui_server_running: passive, READ-ONLY liveness check for the +# home's session server. `server status --session ` exits 0 only when +# that named session's socket answers; it never starts anything. +fm_backend_cmuxtui_server_running() { + local bin + bin=$(fm_backend_cmuxtui_bin) || return 1 + CMUX_TUI_CONFIG="$(fm_backend_cmuxtui_config)" \ + "$bin" server status --session "$(fm_backend_cmuxtui_session)" >/dev/null 2>&1 +} + +# fm_backend_cmuxtui_server_ensure: start the home's dedicated headless +# session if it is not already running - mirrors tmux's +# `has-session || new-session -d` and zellij's server_ensure. `server start` +# runs foreground, so it is daemonized explicitly (nohup, detached, output to +# a log inside the adapter state dir) and readiness is polled via +# `server status`. +fm_backend_cmuxtui_server_ensure() { + fm_backend_cmuxtui_server_running && return 0 + local bin state session log i + bin=$(fm_backend_cmuxtui_bin) || return 1 + state=$(fm_backend_cmuxtui_state_dir) + session=$(fm_backend_cmuxtui_session) + mkdir -p "$state" || { echo "error: cannot create cmux-tui state dir $state" >&2; return 1; } + log="$state/server.log" + ( CMUX_TUI_CONFIG="$(fm_backend_cmuxtui_config)" \ + nohup "$bin" server start --session "$session" --headless --state "$state" \ + >"$log" 2>&1 & ) || return 1 + for i in $(seq 1 40); do + fm_backend_cmuxtui_server_running && return 0 + sleep 0.25 + done + echo "error: cmux-tui session '$session' did not come up within 10s (see $log)" >&2 + return 1 +} + +# fm_backend_cmuxtui_container_ensure: the full spawn-time container-ensure +# sequence (version gate, headless session). Echoes the session name, +# mirroring zellij's container_ensure. +fm_backend_cmuxtui_container_ensure() { + local session + fm_backend_cmuxtui_version_check || return 1 + fm_backend_cmuxtui_server_ensure || return 1 + session=$(fm_backend_cmuxtui_session) + printf '%s' "$session" +} + +# fm_backend_cmuxtui_mutate: run one mutating CLI action with a fresh +# per-attempt --correlation-key nonce, retrying ONCE with the SAME key when +# the typed error says the attempt was retryable or indeterminate +# (mutation.indeterminate) - act-then-handle-typed-error, never a pre-check. +# The key is a nonce, never label-derived: a replayed key returns the +# ORIGINAL attempt's result even after that workspace was closed (verified, +# finding #7), so a stable key would resurrect stale ids on a later task +# reusing the same label. Echoes the (successful or failed) JSON response. +fm_backend_cmuxtui_mutate() { # + local key out rc + key="fm-$$-$(date +%s)-$RANDOM$RANDOM" + out=$(fm_backend_cmuxtui_cli "$@" --correlation-key "$key" --json 2>&1) + rc=$? + if [ "$rc" -ne 0 ] && printf '%s' "$out" | jq -e '(.retryable == true) or (.code == "mutation.indeterminate")' >/dev/null 2>&1; then + out=$(fm_backend_cmuxtui_cli "$@" --correlation-key "$key" --json 2>&1) + rc=$? + fi + printf '%s' "$out" + return "$rc" +} + +# fm_backend_cmuxtui_workspace_id_for_label: the live workspace id whose name +# equals