diff --git a/AGENTS.md b/AGENTS.md index 90f9e796e..56913b568 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -67,7 +67,7 @@ config/crew-harness crewmate harness override; LOCAL, gitignored; absent or "de config/crew-dispatch.json optional crewmate dispatch profiles; LOCAL, gitignored; firstmate-maintained but human-editable natural-language rules that choose a per-task harness/model/effort profile (section 4). Inherited by secondmate homes config/secondmate-harness harness the PRIMARY uses to launch SECONDMATE agents, optionally followed by a model and effort token on the same line (" [] []"; section 4); LOCAL, gitignored; absent or "default" harness falls back to config/crew-harness then firstmate's own. The primary's own setting; NOT inherited into secondmate homes (secondmates do not spawn secondmates) config/backlog-backend backlog backend override; LOCAL, gitignored; absent or "tasks-axi" = default tasks-axi backend, "manual" = force routine backlog updates to hand-editing; inherited by secondmate homes (section 10) -config/backend runtime session-provider backend override for new tasks; LOCAL, gitignored; absent = falls through to runtime auto-detection (the runtime firstmate itself is executing inside), then tmux; tmux is the verified reference backend (docs/tmux-backend.md), while herdr, zellij, orca, and cmux are experimental spawn backends (docs/herdr-backend.md, docs/zellij-backend.md, docs/orca-backend.md, docs/cmux-backend.md) - herdr and cmux can also be selected by runtime auto-detection, zellij and orca never are (always explicit), and codex-app is not accepted; see docs/codex-app-backend.md; not inherited into secondmate homes +config/backend runtime session-provider backend override for new tasks; LOCAL, gitignored; absent = falls through to runtime auto-detection (the runtime firstmate itself is executing inside), then tmux; tmux is the verified reference backend (docs/tmux-backend.md), while herdr, zellij, orca, cmux, and paseo are experimental spawn backends (docs/herdr-backend.md, docs/zellij-backend.md, docs/orca-backend.md, docs/cmux-backend.md, docs/paseo-backend.md) - herdr and cmux can also be selected by runtime auto-detection, zellij, orca, and paseo never are (always explicit), and codex-app is not accepted; see docs/codex-app-backend.md; not inherited into secondmate homes config/cmux-socket-password optional cmux control-socket password; LOCAL, gitignored; read fresh on every cmux CLI call and passed through without ever overriding an operator's own ambient CMUX_SOCKET_PASSWORD when absent (docs/cmux-backend.md "Setup") config/wedge-alarm optional away-mode wedge-alarm active-alert directives; LOCAL, gitignored; absent means auto (macOS Notification Center when available); see docs/wedge-alarm.md config/x-mode.env generated X-mode watcher cadence; LOCAL, gitignored; source before arming watcher when present diff --git a/bin/backends/paseo.sh b/bin/backends/paseo.sh new file mode 100644 index 000000000..d76cdd119 --- /dev/null +++ b/bin/backends/paseo.sh @@ -0,0 +1,283 @@ +#!/usr/bin/env bash +# bin/backends/paseo.sh - the Paseo agent-session-provider adapter (EXPERIMENTAL). +# +# Empirical basis: docs/paseo-backend.md (verified against the real installed +# CLI, Paseo v0.1.104, daemon on 127.0.0.1:6767). Read that doc before changing +# any command below; it records the exact commands run and their exact output. +# +# Paseo (paseo.sh, getpaseo/paseo) is a self-hosted TypeScript daemon that +# manages headless coding-agent sessions and exposes them in desktop/mobile/web +# clients. Unlike tmux/herdr/zellij/cmux, Paseo is NOT a terminal multiplexer: +# there is no pane to type into. `paseo run` LAUNCHES the provider agent +# directly with a prompt and a --cwd, and `paseo send` delivers a follow-up +# MESSAGE to that agent over the daemon API. So this adapter has no +# type-then-submit composer dance: a steer is one atomic `paseo send --no-wait`. +# +# Worktree ownership stays with treehouse, exactly like tmux/herdr (NOT like +# Orca, which owns its own worktree). fm-spawn.sh leases a treehouse worktree +# non-interactively (`treehouse get --lease`) and passes it to `paseo run --cwd`; +# Paseo's own `paseo worktree` feature is never used. Teardown returns that lease +# through the standard treehouse-return path in fm-teardown.sh. +# +# Target string shape: the Paseo agent id (a UUID). A task's meta records +# `backend=paseo` and `paseo_agent_id=`; `window=fm-` is kept only as +# a human-facing display alias (fm_backend_target_of_meta resolves paseo tasks +# to the agent id, mirroring how it resolves orca tasks to `terminal=`). +# +# Gating: selectable ONLY via explicit `--backend paseo` / `FM_BACKEND=paseo` / +# `config/backend` - never runtime auto-detected (the zellij/orca precedent), +# even though firstmate itself commonly runs AS a Paseo agent. Spawn refuses +# grok (not a Paseo provider) and refuses --secondmate (out of scope initially). +# +# Requires: paseo (CLI + running daemon) and node (JSON parsing; a universal +# firstmate requirement already). Both are gated behind selecting this backend; +# bin/fm-bootstrap.sh's core tool list is unaffected (the version/daemon/provider +# gate happens at spawn time instead). + +# The verified CLI floor. Paseo is pre-1.0 and its --json shapes are still +# moving, so the floor is pinned to the exact verified version rather than a +# looser range: every command and JSON field below was verified against +# v0.1.104 only (docs/paseo-backend.md). Raise this only after re-verifying +# against the newer build. +FM_BACKEND_PASEO_MIN_VERSION="0.1.104" + +# fm_backend_paseo_bin: the paseo binary. ~/.local/bin is where the verified +# install lives but is not always on a non-interactive PATH, so fall back to it. +fm_backend_paseo_bin() { + if command -v paseo >/dev/null 2>&1; then + printf 'paseo' + return 0 + fi + if [ -x "$HOME/.local/bin/paseo" ]; then + printf '%s' "$HOME/.local/bin/paseo" + return 0 + fi + return 1 +} + +fm_backend_paseo_tool_check() { + fm_backend_paseo_bin >/dev/null || { echo "error: backend=paseo selected but the 'paseo' CLI is not installed (https://paseo.sh)" >&2; return 1; } + command -v node >/dev/null 2>&1 || { echo "error: backend=paseo selected but 'node' is not installed (required to parse paseo's JSON output)" >&2; return 1; } + return 0 +} + +# fm_backend_paseo_cli: run the paseo binary, resolving it once per call. +fm_backend_paseo_cli() { # + local bin + bin=$(fm_backend_paseo_bin) || return 1 + "$bin" "$@" +} + +# fm_backend_paseo_json_field: read one top-level scalar from a paseo +# --json object on stdin, or exit non-zero if absent. Mirrors orca.sh's +# node-based JSON reader (node is the universal firstmate JSON tool here). +fm_backend_paseo_json_field() { # (json on stdin) + node -e ' +const fs = require("fs"); +const field = process.argv[1]; +let data; +try { data = JSON.parse(fs.readFileSync(0, "utf8")); } catch (e) { process.exit(2); } +const v = data && data[field]; +if (v === undefined || v === null || v === "") process.exit(1); +process.stdout.write(String(v)); +' "$1" +} + +# fm_backend_paseo_version_ge: is >= , numeric dotted compare. +fm_backend_paseo_version_ge() { # + node -e ' +const a = String(process.argv[1]).split(".").map(n => parseInt(n, 10) || 0); +const b = String(process.argv[2]).split(".").map(n => parseInt(n, 10) || 0); +const len = Math.max(a.length, b.length); +for (let i = 0; i < len; i++) { + const x = a[i] || 0, y = b[i] || 0; + if (x > y) process.exit(0); + if (x < y) process.exit(1); +} +process.exit(0); +' "$1" "$2" +} + +# fm_backend_paseo_runtime_check: refuse loudly unless the paseo CLI, node, a +# running/reachable daemon, and a new-enough CLI are all present. `paseo status +# --json` reports localDaemon/connectedDaemon and cliVersion (verified +# docs/paseo-backend.md). +fm_backend_paseo_runtime_check() { + fm_backend_paseo_tool_check || return 1 + # `st`, not `status`: `status` is a read-only special variable in zsh, which + # can source this file (fm-backend.sh header). + local st local_d conn_d version + st=$(fm_backend_paseo_cli status --json 2>/dev/null) || { echo "error: 'paseo status --json' failed; is the Paseo daemon running? (paseo start)" >&2; return 1; } + local_d=$(printf '%s' "$st" | fm_backend_paseo_json_field localDaemon 2>/dev/null || true) + conn_d=$(printf '%s' "$st" | fm_backend_paseo_json_field connectedDaemon 2>/dev/null || true) + if [ "$local_d" != running ] && [ "$conn_d" != reachable ]; then + echo "error: the Paseo daemon is not running/reachable (localDaemon=${local_d:-unknown}, connectedDaemon=${conn_d:-unknown}); start it with 'paseo start'" >&2 + return 1 + fi + version=$(printf '%s' "$st" | fm_backend_paseo_json_field cliVersion 2>/dev/null || true) + if [ -z "$version" ]; then + echo "error: could not read Paseo cliVersion from 'paseo status --json'; refusing to use an unverified Paseo build" >&2 + return 1 + fi + if ! fm_backend_paseo_version_ge "$version" "$FM_BACKEND_PASEO_MIN_VERSION"; then + echo "error: Paseo CLI $version is older than the verified minimum $FM_BACKEND_PASEO_MIN_VERSION; update paseo before using backend=paseo" >&2 + return 1 + fi + return 0 +} + +# fm_backend_paseo_provider_for_harness: map a firstmate harness to a Paseo +# provider name, refusing a harness Paseo cannot launch. grok is NOT a Paseo +# provider (verified: `paseo provider ls` lists claude/codex/opencode/pi as +# available, plus copilot/omp; no grok), so a grok crewmate on this backend is +# refused loudly rather than silently mis-launched. +fm_backend_paseo_provider_for_harness() { # + case "$1" in + claude|codex|opencode|pi) printf '%s' "$1"; return 0 ;; + grok) echo "error: backend=paseo cannot launch harness 'grok' (grok is not a Paseo provider); use --backend tmux for grok, or a Paseo-supported harness (claude, codex, opencode, pi)" >&2; return 1 ;; + *) echo "error: backend=paseo does not support harness '$1' (Paseo providers: claude, codex, opencode, pi)" >&2; return 1 ;; + esac +} + +# fm_backend_paseo_run: launch the agent. Echoes the new agent id on success. +# The prompt is the brief's CONTENT (Paseo takes the initial task as the run +# prompt, not a shell launch command). --mode bypassPermissions is the +# autonomous-crewmate equivalent of claude's --dangerously-skip-permissions +# (verified mode id for claude; docs/paseo-backend.md). --label fm-task= +# tags the agent for label-based lookup; --title fm- is the human-facing +# name shown in the Paseo app. GOTMPDIR rides --env (Paseo has no pane to +# `export` into). +fm_backend_paseo_run() { # <label-id> <gotmpdir> <brief-path> + local cwd=$1 provider=$2 model=$3 title=$4 label_id=$5 gotmpdir=$6 brief=$7 spec out agent_id + spec=$provider + if [ -n "$model" ] && [ "$model" != default ]; then + spec="$provider/$model" + fi + out=$(fm_backend_paseo_cli run --detach --cwd "$cwd" --provider "$spec" \ + --mode bypassPermissions --title "$title" --label "fm-task=$label_id" \ + --env "GOTMPDIR=$gotmpdir" --json "$(cat "$brief")" 2>/dev/null) || { + echo "error: 'paseo run' failed for $title (provider $spec, cwd $cwd)" >&2 + return 1 + } + agent_id=$(printf '%s' "$out" | fm_backend_paseo_json_field agentId 2>/dev/null) || { + echo "error: 'paseo run' did not return an agentId for $title" >&2 + return 1 + } + printf '%s' "$agent_id" +} + +# fm_backend_paseo_status_raw: the raw agent status string (running/idle/...), +# or empty when the agent is not found or unreadable. `paseo inspect --json` +# reports it as PascalCase `Status` (verified); a not-found agent yields an +# `{error:...}` body and a non-zero exit. +fm_backend_paseo_status_raw() { # <agent-id> + local out + out=$(fm_backend_paseo_cli inspect "$1" --json 2>/dev/null) || { printf ''; return 0; } + printf '%s' "$out" | fm_backend_paseo_json_field Status 2>/dev/null || printf '' +} + +# fm_backend_paseo_capture: bounded activity read. Paseo's timeline is +# `paseo logs --tail N` (not a raw terminal scrollback); it is the closest +# analogue to tmux's capture-pane for a message-based agent. +fm_backend_paseo_capture() { # <agent-id> <lines> + local id=$1 lines=${2:-40} + case "$lines" in ''|*[!0-9]*) lines=40 ;; esac + fm_backend_paseo_cli logs "$id" --tail "$lines" 2>/dev/null || return 1 +} + +# fm_backend_paseo_send_text_submit: deliver <text> to the agent as one atomic +# message. Paseo's API delivers it in a single call (no composer to submit, no +# autocomplete-popup hazard), so this ignores the tmux-style retries/sleeps and +# reports the shared "empty" verdict ("confirmed submitted") on success. The +# extra positional args exist only to match the generic dispatcher signature. +fm_backend_paseo_send_text_submit() { # <agent-id> <text> [retries] [enter-sleep] [settle] + local id=$1 text=$2 + if fm_backend_paseo_cli send "$id" --no-wait --prompt "$text" >/dev/null 2>&1; then + printf 'empty' + else + printf 'send-failed' + fi +} + +# fm_backend_paseo_send_key: Paseo is message-based, so most "keys" have no +# meaning. C-c maps to `paseo stop` (interrupt a running agent). Enter is a +# no-op success (a message is already delivered atomically by send). Escape is +# unsupported (the Orca precedent), refused loudly. +fm_backend_paseo_send_key() { # <agent-id> <key> + local id=$1 key=$2 + case "$key" in + C-c|c-c|ctrl+c|Ctrl+C|Ctrl-c|Ctrl-C) + fm_backend_paseo_cli stop "$id" >/dev/null 2>&1 || true + ;; + Enter|enter) + : # no-op: send delivers the message atomically, there is no pending composer to submit + ;; + Escape|escape|Esc|esc) + echo "error: backend=paseo does not support the Escape key (no terminal composer)" >&2 + return 1 + ;; + *) + echo "error: backend=paseo does not support key '$key'" >&2 + return 1 + ;; + esac +} + +# fm_backend_paseo_kill: hard-delete the agent, best-effort (mirrors tmux +# kill-window's `|| true` contract). `paseo delete` interrupts a running agent +# then removes its record; deleting an already-gone agent is a harmless no-op +# for teardown's purposes. +fm_backend_paseo_kill() { # <agent-id> [ignored...] + [ -n "${1:-}" ] || return 0 + fm_backend_paseo_cli delete "$1" >/dev/null 2>&1 || true +} + +# fm_backend_paseo_busy_state: semantic busy state from Paseo's native agent +# status. running -> busy (actively working a turn); idle -> idle; anything +# else/unreadable -> unknown (the caller's cue to fall back to its own policy). +fm_backend_paseo_busy_state() { # <agent-id> + case "$(fm_backend_paseo_status_raw "$1")" in + running) printf 'busy' ;; + idle) printf 'idle' ;; + *) printf 'unknown' ;; + esac +} + +# fm_backend_paseo_agent_alive: CONFIDENT liveness for the session-start +# secondmate-liveness sweep and recovery digests (fm_backend_agent_alive +# dispatcher). running/idle are both a live registered agent -> alive; a +# not-found agent -> dead (its record is gone); error/closed -> dead; anything +# unreadable -> unknown (never treated as confirmed-dead - fail safe toward +# refusal, exactly like the tmux/herdr probes). +# +# NOTE: no local variable is named `status` - it is a read-only special +# variable in zsh, and this file can be sourced by a zsh diagnostic +# (fm-backend.sh header), where `local status` would hard-error. +fm_backend_paseo_agent_alive() { # <agent-id> + local out code msg st + out=$(fm_backend_paseo_cli inspect "$1" --json 2>&1) || { + code=$(printf '%s' "$out" | node -e 'try{const d=JSON.parse(require("fs").readFileSync(0,"utf8"));process.stdout.write((d.error&&d.error.code)||"")}catch(e){}' 2>/dev/null || true) + msg=$(printf '%s' "$out" | node -e 'try{const d=JSON.parse(require("fs").readFileSync(0,"utf8"));process.stdout.write((d.error&&d.error.message)||"")}catch(e){}' 2>/dev/null || true) + case "$msg" in + *"not found"*|*"Not found"*|*"NOT_FOUND"*) printf 'dead'; return 0 ;; + esac + case "$code" in + *NOT_FOUND*) printf 'dead'; return 0 ;; + esac + printf 'unknown' + return 0 + } + st=$(printf '%s' "$out" | fm_backend_paseo_json_field Status 2>/dev/null || true) + case "$st" in + running|idle) printf 'alive' ;; + error|closed|failed|stopped) printf 'dead' ;; + *) printf 'unknown' ;; + esac +} + +# fm_backend_paseo_target_exists: cheap read-only presence check - does the +# recorded agent id still exist? A successful `paseo inspect` means yes. +fm_backend_paseo_target_exists() { # <agent-id> + [ -n "${1:-}" ] || return 1 + fm_backend_paseo_cli inspect "$1" --json >/dev/null 2>&1 +} diff --git a/bin/fm-backend.sh b/bin/fm-backend.sh index d38bd9f06..f6e4c1a3c 100644 --- a/bin/fm-backend.sh +++ b/bin/fm-backend.sh @@ -33,8 +33,8 @@ # treats that as `tmux` (fm_backend_of_meta), and fm-spawn.sh does not write # `backend=tmux` for a default-backend task, so existing and newly spawned # default-path metas stay byte-identical. Only a task spawned on a non-tmux -# spawn-capable backend, currently experimental herdr, zellij, orca, or cmux, -# carries an explicit `backend=` line. +# spawn-capable backend, currently experimental herdr, zellij, orca, cmux, or +# paseo, carries an explicit `backend=` line. # # Event-source framing (herdr-addendum "Events as the core abstraction"): a # backend's supervision surface is conceptually an EVENT SOURCE - it produces @@ -65,9 +65,15 @@ FM_BACKEND_CONFIG_DIR="${FM_CONFIG_OVERRIDE:-$FM_HOME/config}" # spawn-capable; unlike tmux/herdr/zellij it is also the worktree provider. # cmux is EXPERIMENTAL and spawn-capable, session-provider-only like # herdr/zellij - verified against the real 0.64.17 binary (docs/cmux-backend.md). +# paseo is EXPERIMENTAL and spawn-capable; unlike the multiplexer backends it is +# a message-based agent-session daemon (`paseo run` launches the provider agent, +# `paseo send` messages it), NOT a terminal - verified against the real v0.1.104 +# CLI (docs/paseo-backend.md). Its worktree stays treehouse-owned like +# tmux/herdr; it is NEVER runtime auto-detected (the zellij/orca precedent), +# even though firstmate itself commonly runs as a Paseo agent. # codex-app remains deliberately absent; see docs/codex-app-backend.md. -FM_BACKEND_KNOWN="tmux herdr zellij orca cmux" -FM_BACKEND_SPAWN="tmux herdr zellij orca cmux" +FM_BACKEND_KNOWN="tmux herdr zellij orca cmux paseo" +FM_BACKEND_SPAWN="tmux herdr zellij orca cmux paseo" # fm_backend_list_contains: whitespace-delimited membership without relying on # shell word splitting. fm-backend.sh is normally sourced by bash scripts, but @@ -356,6 +362,12 @@ fm_backend_target_of_meta() { # <meta-file> terminal=$(fm_meta_get "$meta" terminal) [ -n "$terminal" ] && { printf '%s' "$terminal"; return 0; } fi + if [ "$backend" = paseo ]; then + # A paseo task's operational target is its agent id; window= is only a + # human-facing fm-<id> display alias. + terminal=$(fm_meta_get "$meta" paseo_agent_id) + [ -n "$terminal" ] && { printf '%s' "$terminal"; return 0; } + fi window=$(fm_meta_get "$meta" window) [ -n "$window" ] && printf '%s' "$window" } @@ -457,6 +469,13 @@ fm_backend_source() { # <name> _FM_BACKEND_CMUX_SOURCED=1 fi ;; + paseo) + if [ -z "${_FM_BACKEND_PASEO_SOURCED:-}" ]; then + # shellcheck source=bin/backends/paseo.sh + . "$FM_BACKEND_LIB_DIR/backends/paseo.sh" || return 1 + _FM_BACKEND_PASEO_SOURCED=1 + fi + ;; esac } @@ -528,6 +547,7 @@ fm_backend_capture() { # <backend> <target> <lines> [expected-label] zellij) fm_backend_zellij_capture "$@" ;; orca) fm_backend_orca_capture "$@" ;; cmux) fm_backend_cmux_capture "$@" ;; + paseo) fm_backend_paseo_capture "$@" ;; *) echo "error: no capture implementation for backend '$backend'" >&2; return 1 ;; esac } @@ -543,6 +563,7 @@ fm_backend_send_key() { # <backend> <target> <key> [expected-label] zellij) fm_backend_zellij_send_key "$@" ;; orca) fm_backend_orca_send_key "$@" ;; cmux) fm_backend_cmux_send_key "$@" ;; + paseo) fm_backend_paseo_send_key "$@" ;; *) echo "error: no send-key implementation for backend '$backend'" >&2; return 1 ;; esac } @@ -560,6 +581,7 @@ fm_backend_send_text_submit() { # <backend> <target> <text> <retries> <enter-sl zellij) fm_backend_zellij_send_text_submit "$@" ;; orca) fm_backend_orca_send_text_submit "$@" ;; cmux) fm_backend_cmux_send_text_submit "$@" ;; + paseo) fm_backend_paseo_send_text_submit "$@" ;; *) echo "error: no send-text implementation for backend '$backend'" >&2; return 1 ;; esac } @@ -577,6 +599,7 @@ fm_backend_kill() { # <backend> <target> zellij) fm_backend_zellij_kill "$@" ;; orca) fm_backend_orca_kill "$@" ;; cmux) fm_backend_cmux_kill "$@" ;; + paseo) fm_backend_paseo_kill "$@" ;; *) echo "error: no kill implementation for backend '$backend'" >&2; return 1 ;; esac } @@ -614,6 +637,7 @@ fm_backend_busy_state() { # <backend> <target> fm_backend_source "$backend" || { printf 'unknown'; return 0; } case "$backend" in herdr) fm_backend_herdr_busy_state "$@" ;; + paseo) fm_backend_paseo_busy_state "$@" ;; *) printf 'unknown' ;; esac } @@ -688,6 +712,10 @@ fm_backend_target_exists() { # <backend> <target> [expected-label] fm_backend_source cmux || return 1 fm_backend_cmux_target_ready "$target" "$expected_label" ;; + paseo) + fm_backend_source paseo || return 1 + fm_backend_paseo_target_exists "$target" + ;; *) return 1 ;; @@ -721,6 +749,7 @@ fm_backend_agent_alive() { # <backend> <target> case "$backend" in tmux) fm_backend_tmux_agent_alive "$target" ;; herdr) fm_backend_herdr_agent_alive "$target" ;; + paseo) fm_backend_paseo_agent_alive "$target" ;; *) printf 'unknown' ;; esac } diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index c973e4416..e55e86829 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -186,9 +186,16 @@ if [ "$BACKEND" = cmux ] && [ "$KIND" = secondmate ]; then echo "error: backend=cmux does not support --secondmate spawns yet" >&2 exit 1 fi +if [ "$BACKEND" = paseo ] && [ "$KIND" = secondmate ]; then + echo "error: backend=paseo does not support --secondmate spawns yet" >&2 + exit 1 +fi if [ "$BACKEND" = orca ]; then fm_backend_orca_runtime_check || exit 1 fi +if [ "$BACKEND" = paseo ]; then + fm_backend_paseo_runtime_check || exit 1 +fi ORCA_ABORT_CLEANUP=0 ORCA_WORKTREE_ID= ORCA_TERMINAL= @@ -388,6 +395,15 @@ case "$ARG3" in ;; esac +# Paseo launches the provider agent itself (paseo run --provider), so the +# resolved harness must map to a Paseo provider. Refuse grok (not a Paseo +# provider) and any non-{claude,codex,opencode,pi} harness LOUDLY here, before +# leasing a worktree - a backend refusal is terminal, never a silent fallback. +PASEO_PROVIDER= +if [ "$BACKEND" = paseo ]; then + PASEO_PROVIDER=$(fm_backend_paseo_provider_for_harness "$HARNESS") || exit 1 +fi + # config/secondmate-harness may carry optional model/effort tokens alongside the # harness ("<harness> [<model>] [<effort>]"). They apply only when this is a # --secondmate spawn and no explicit per-spawn harness/raw launch was supplied, so @@ -795,6 +811,25 @@ EOF fi T="$ORCA_TERMINAL" ;; + paseo) + # Paseo has no bare shell to type `treehouse get` into: `paseo run` launches + # the provider agent directly with --cwd. So acquire the treehouse worktree + # here, NON-interactively, with `treehouse get --lease` (prints only the + # absolute worktree path to stdout; banners go to stderr). The Paseo agent + # itself is created later, AFTER the turn-end hook is written into this + # worktree, so a claude crewmate starts with its Stop hook already in place + # (docs/paseo-backend.md "Turn-end signal"). T stays unset until then. + # treehouse resolves its pool from the working directory, so lease from the + # project checkout. + WT=$( cd "$PROJ_ABS" && treehouse get --lease --lease-holder "fm-$ID" 2>/dev/null ) || { + echo "error: 'treehouse get --lease' failed to acquire a worktree for $W from $PROJ_ABS" >&2 + exit 1 + } + WT=$(printf '%s' "$WT" | tail -1) + [ -n "$WT" ] || { echo "error: 'treehouse get --lease' returned no worktree path for $W" >&2; exit 1; } + validate_spawn_worktree "treehouse get --lease" "$WT" + T="" + ;; esac # #134 robustness: only tmux needs a worktree-detection target distinct from $T - # its rename-safe stable window id, set as WT_TARGET=$WID in the tmux branch above. @@ -837,7 +872,7 @@ spawn_send_key() { # <target> <key> cmux) fm_backend_cmux_send_key "$1" "$2" "$W" ;; esac } -if [ "$KIND" != secondmate ] && [ "$BACKEND" != orca ]; then +if [ "$KIND" != secondmate ] && [ "$BACKEND" != orca ] && [ "$BACKEND" != paseo ]; then spawn_send_text_line "$WT_TARGET" 'treehouse get' # Wait for the treehouse subshell: the pane's cwd moves from the project to the worktree. @@ -975,6 +1010,18 @@ EOF esac fi +# Paseo launch: `paseo run` creates AND starts the agent in one call, launching +# the provider directly in the leased worktree with the brief as its initial +# prompt. Done here (not in the container case above) so the claude turn-end +# Stop hook written into the worktree just above is already present when the +# agent starts (verified: the hook fires under `paseo run`, docs/paseo-backend.md +# "Turn-end signal"). GOTMPDIR rides --env since Paseo has no pane to export +# into. The agent id becomes the operational target T and is recorded in meta. +if [ "$BACKEND" = paseo ]; then + PASEO_AGENT_ID=$(fm_backend_paseo_run "$WT" "$PASEO_PROVIDER" "$MODEL" "$W" "$ID" "$TASK_TMP/gotmp" "$BRIEF") || exit 1 + T="$PASEO_AGENT_ID" +fi + # Per-project delivery mode + yolo flag (bin/fm-project-mode.sh; the project-management skill and AGENTS.md task lifecycle). # Recorded in meta so fm-teardown's safety check and the validate/merge stages can # branch on them. Mode governs ship tasks; a scout's deliverable is a report, not a @@ -993,6 +1040,9 @@ fi META_WINDOW=$T [ "$BACKEND" = orca ] && META_WINDOW=$W +# Paseo's operational target is the agent id; window= keeps the fm-<id> display +# alias (fm_backend_target_of_meta resolves paseo tasks to paseo_agent_id). +[ "$BACKEND" = paseo ] && META_WINDOW=$W { echo "window=$META_WINDOW" echo "worktree=$WT" @@ -1027,6 +1077,9 @@ META_WINDOW=$T echo "cmux_workspace_id=$CMUX_WORKSPACE_ID" echo "cmux_surface_id=$CMUX_SURFACE_ID" fi + if [ "$BACKEND" = paseo ]; then + echo "paseo_agent_id=$PASEO_AGENT_ID" + fi if [ "$KIND" = secondmate ]; then echo "home=$PROJ_ABS" echo "projects=$SECONDMATE_PROJECTS" @@ -1034,31 +1087,35 @@ META_WINDOW=$T } > "$STATE/$ID.meta" [ "$BACKEND" = orca ] && ORCA_ABORT_CLEANUP=0 -sq_brief=$(shell_quote "$BRIEF") -sq_turnend=$(shell_quote "$TURNEND") -sq_piext=$(shell_quote "$STATE/$ID.pi-ext.ts") -sq_piturnend=$(shell_quote "$PROJ_ABS/.pi/extensions/fm-primary-turnend-guard.ts") -sq_piwatch=$(shell_quote "$PROJ_ABS/.pi/extensions/fm-primary-pi-watch.ts") -MODELFLAG=$(model_flag_for_harness "$HARNESS" "$MODEL") -EFFORTFLAG=$(effort_flag_for_harness "$HARNESS" "$EFFORT") -LAUNCH=${LAUNCH//__MODELFLAG__/$MODELFLAG} -LAUNCH=${LAUNCH//__EFFORTFLAG__/$EFFORTFLAG} -LAUNCH=${LAUNCH//__BRIEF__/$sq_brief} -LAUNCH=${LAUNCH//__TURNEND__/$sq_turnend} -LAUNCH=${LAUNCH//__PIEXT__/$sq_piext} -LAUNCH=${LAUNCH//__PITURNEND__/$sq_piturnend} -LAUNCH=${LAUNCH//__PIWATCH__/$sq_piwatch} -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" +# Paseo already launched the agent above (paseo run, with GOTMPDIR via --env and +# the brief as the run prompt), so there is no pane launch command to send here. +if [ "$BACKEND" != paseo ]; then + sq_brief=$(shell_quote "$BRIEF") + sq_turnend=$(shell_quote "$TURNEND") + sq_piext=$(shell_quote "$STATE/$ID.pi-ext.ts") + sq_piturnend=$(shell_quote "$PROJ_ABS/.pi/extensions/fm-primary-turnend-guard.ts") + sq_piwatch=$(shell_quote "$PROJ_ABS/.pi/extensions/fm-primary-pi-watch.ts") + MODELFLAG=$(model_flag_for_harness "$HARNESS" "$MODEL") + EFFORTFLAG=$(effort_flag_for_harness "$HARNESS" "$EFFORT") + LAUNCH=${LAUNCH//__MODELFLAG__/$MODELFLAG} + LAUNCH=${LAUNCH//__EFFORTFLAG__/$EFFORTFLAG} + LAUNCH=${LAUNCH//__BRIEF__/$sq_brief} + LAUNCH=${LAUNCH//__TURNEND__/$sq_turnend} + LAUNCH=${LAUNCH//__PIEXT__/$sq_piext} + LAUNCH=${LAUNCH//__PITURNEND__/$sq_piturnend} + LAUNCH=${LAUNCH//__PIWATCH__/$sq_piwatch} + 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 + # Export GOTMPDIR into the crewmate's pane shell so the agent and every child + # process (go build, go test, ...) inherit it. Sent before the launch command so + # the env is set when the agent starts; the brief sleep lets the export land. + spawn_send_text_line "$T" "export GOTMPDIR=$TASK_TMP/gotmp" + sleep 0.3 + spawn_send_literal "$T" "$LAUNCH" + sleep 0.3 + spawn_send_key "$T" Enter fi -# Export GOTMPDIR into the crewmate's pane shell so the agent and every child -# process (go build, go test, ...) inherit it. Sent before the launch command so -# the env is set when the agent starts; the brief sleep lets the export land. -spawn_send_text_line "$T" "export GOTMPDIR=$TASK_TMP/gotmp" -sleep 0.3 -spawn_send_literal "$T" "$LAUNCH" -sleep 0.3 -spawn_send_key "$T" Enter echo "spawned $ID harness=$HARNESS kind=$KIND mode=$MODE yolo=$YOLO window=$META_WINDOW worktree=$WT" diff --git a/bin/fm-teardown.sh b/bin/fm-teardown.sh index 9d84f4d6c..d63b4a012 100755 --- a/bin/fm-teardown.sh +++ b/bin/fm-teardown.sh @@ -120,6 +120,11 @@ if [ "$BACKEND" = orca ]; then T_ORCA=$(grep '^terminal=' "$META" | tail -1 | cut -d= -f2- || true) [ -n "$T_ORCA" ] && T=$T_ORCA fi +if [ "$BACKEND" = paseo ]; then + # window= is a display alias for a paseo task; the kill target is the agent id. + T_PASEO=$(fm_meta_get "$META" paseo_agent_id) + [ -n "$T_PASEO" ] && T=$T_PASEO +fi HOME_PATH=$(grep '^home=' "$META" | cut -d= -f2- || true) PR_URL=$(grep '^pr=' "$META" | tail -1 | cut -d= -f2- || true) # tasktmp is recorded by fm-spawn for tasks that set up a per-task temp root diff --git a/docs/configuration.md b/docs/configuration.md index 6dd0a1d0e..8ff45ec18 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -40,18 +40,19 @@ The file format is unchanged in both modes; tasks-axi and manual edits produce t ## Runtime backend (config/backend / FM_BACKEND) For spawn-capable adapters, the runtime session-provider backend controls where task windows/endpoints are created, captured, sent to, watched, and killed. -`tmux` is the verified reference backend (see [`docs/tmux-backend.md`](tmux-backend.md)); `herdr`, `zellij`, `orca`, and `cmux` are experimental spawn backends (see [`docs/herdr-backend.md`](herdr-backend.md), [`docs/zellij-backend.md`](zellij-backend.md), [`docs/orca-backend.md`](orca-backend.md), and [`docs/cmux-backend.md`](cmux-backend.md)). -Treehouse remains the worktree provider for tmux, herdr, zellij, and cmux, since herdr, zellij, and cmux are session providers only; Orca provides both the task worktree and terminal endpoint. +`tmux` is the verified reference backend (see [`docs/tmux-backend.md`](tmux-backend.md)); `herdr`, `zellij`, `orca`, `cmux`, and `paseo` are experimental spawn backends (see [`docs/herdr-backend.md`](herdr-backend.md), [`docs/zellij-backend.md`](zellij-backend.md), [`docs/orca-backend.md`](orca-backend.md), [`docs/cmux-backend.md`](cmux-backend.md), and [`docs/paseo-backend.md`](paseo-backend.md)). +Treehouse remains the worktree provider for tmux, herdr, zellij, cmux, and paseo, since those are session providers only; Orca provides both the task worktree and terminal endpoint. New spawns choose the backend in this order: an explicit `--backend` flag firstmate passes when it spawns a task, then `FM_BACKEND`, then the first non-empty line of local gitignored `config/backend`, then runtime auto-detection from `$TMUX`, `HERDR_ENV=1`, or cmux runtime signals, then default `tmux`. If more than one runtime marker is present, detection resolves innermost-first: `$TMUX` is checked before `HERDR_ENV=1`, which is checked before cmux's primary `CMUX_WORKSPACE_ID` marker and its documented fallback signals - tmux or herdr started from inside a cmux terminal is the innermost, currently-executing layer, while cmux itself (a terminal application, not a nestable multiplexer) is always checked last. See [`docs/cmux-backend.md`](cmux-backend.md#runtime-auto-detection) for why cmux can be selected when `CMUX_WORKSPACE_ID` is absent. Auto-detected herdr or cmux prints a stderr notice naming `config/backend` and `--backend tmux` as opt-outs; auto-detected tmux stays silent to preserve existing default behavior. -Zellij and Orca are never auto-detected; select them by putting the name in a local `config/backend` file, by exporting `FM_BACKEND=<name>`, or by telling the first mate in chat. -Any value other than `tmux`, `herdr`, `zellij`, `orca`, or `cmux` is rejected until another adapter is implemented and verified. -`fm-spawn.sh` accepts `tmux`, `herdr`, `zellij`, `orca`, and `cmux` for ship and scout tasks; `backend=orca` and `backend=cmux` both still refuse `--secondmate` until secondmate launch semantics are designed for each. +Zellij, Orca, and paseo are never auto-detected; select them by putting the name in a local `config/backend` file, by exporting `FM_BACKEND=<name>`, or by telling the first mate in chat. +Any value other than `tmux`, `herdr`, `zellij`, `orca`, `cmux`, or `paseo` is rejected until another adapter is implemented and verified. +`fm-spawn.sh` accepts `tmux`, `herdr`, `zellij`, `orca`, `cmux`, and `paseo` for ship and scout tasks; `backend=orca`, `backend=cmux`, and `backend=paseo` still refuse `--secondmate` until secondmate launch semantics are designed for each. +Paseo is message-based (not a terminal): `paseo run` launches the provider agent directly in a treehouse-leased worktree, so it refuses harness `grok` (not a Paseo provider) and its worktree stays treehouse-owned; see [`docs/paseo-backend.md`](paseo-backend.md). `codex-app` is not an accepted runtime backend yet; [`docs/codex-app-backend.md`](codex-app-backend.md) owns the Codex App boundary. The session-start secondmate liveness sweep uses a deeper `fm_backend_agent_alive` probe where verified. -Today that probe can classify tmux and herdr secondmate endpoints as `alive`, `dead`, or `unknown`; zellij, Orca, and cmux report `unknown` until their own agent-process classifiers are verified. +Today that probe can classify tmux, herdr, and paseo endpoints as `alive`, `dead`, or `unknown`; zellij, Orca, and cmux report `unknown` until their own agent-process classifiers are verified. A herdr spawn additionally version-gates against the installed `herdr` binary's protocol and requires `jq`, refusing loudly on an incompatible or missing installation. A zellij spawn additionally version-gates against the installed `zellij` binary's version and requires `jq`, refusing loudly when either is missing or the version is older than 0.44. A cmux spawn additionally version-gates against the installed `cmux` binary's version, requires `jq`, and requires the control socket to be reachable and accessible (see [`docs/cmux-backend.md`](cmux-backend.md) "Setup" for the one-time socket-access configuration this needs; Automation mode is the recommended socket control mode, with Password mode supported via `config/cmux-socket-password`), refusing loudly and non-retryably on a `cmuxOnly`/unauthenticated socket. @@ -61,10 +62,11 @@ A herdr task additionally records `herdr_session=`, `herdr_workspace_id=`, `herd A zellij task additionally records `zellij_session=`, `zellij_tab_id=`, and `zellij_pane_id=`. An Orca task additionally records `orca_worktree_id=` and `terminal=`, with `window=fm-<id>` kept as the shared firstmate alias. A cmux task additionally records `cmux_workspace_id=` and `cmux_surface_id=`. +A paseo task additionally records `paseo_agent_id=`, with `window=fm-<id>` kept as the shared firstmate alias. Task selectors for `fm-peek.sh`, `fm-send.sh`, and `fm-crew-state.sh` resolve centrally through `fm_backend_resolve_selector`. A selector containing `:` is passed through as an explicit backend endpoint escape hatch. Otherwise an exact task id matching `state/<id>.meta` wins before the legacy `fm-<id>` label fallback, so task ids that themselves start with `fm-` route to their own metadata instead of being stripped. -A metadata-routed selector returns the recorded backend target (`terminal=` for Orca, otherwise `window=`), and matching explicit targets can still recover the recorded backend when metadata contains the same endpoint. +A metadata-routed selector returns the recorded backend target (`terminal=` for Orca, `paseo_agent_id=` for paseo, otherwise `window=`), and matching explicit targets can still recover the recorded backend when metadata contains the same endpoint. Only metadata-routed task selectors carry secondmate-marker and Codex-harness context; explicit endpoint escape hatches do not. These five sentences are the single owner of the task-selector vocabulary; backend guides and other documents point here instead of restating the resolution order. `fm-teardown.sh <id>` takes a task id directly and uses the same recorded backend target fields after loading `state/<id>.meta`. diff --git a/docs/paseo-backend.md b/docs/paseo-backend.md new file mode 100644 index 000000000..c2b680f7b --- /dev/null +++ b/docs/paseo-backend.md @@ -0,0 +1,128 @@ +# Paseo runtime backend (experimental) + +This document records the empirical verification behind `bin/backends/paseo.sh`, the Paseo agent-session-provider adapter. +It is the Paseo equivalent of the tmux facts in the `harness-adapters` skill and the herdr/orca/cmux backend docs. + +Paseo ([paseo.sh](https://paseo.sh), getpaseo/paseo) is a self-hosted TypeScript daemon that manages headless coding-agent sessions and surfaces them in desktop/mobile/web clients. +Unlike tmux/herdr/zellij/cmux it is NOT a terminal multiplexer: there is no pane to type into. +`paseo run` launches a provider agent directly with a prompt and a `--cwd`, and `paseo send` delivers a follow-up message to that agent over the daemon API. + +All facts below were verified against the real installed CLI on 2026-07-16. + +- Paseo CLI: v0.1.104 (`paseo --version` -> `0.1.104`), at `~/.local/bin/paseo`. +- Daemon: `paseo status --json` -> `localDaemon: "running"`, `connectedDaemon: "reachable"`, `listen: "127.0.0.1:6767"`, `cliVersion: "0.1.104"`, `daemonVersion: "0.1.104"`. +- node is used to parse Paseo's `--json` output (the universal firstmate JSON tool, as in `bin/backends/orca.sh`). + +## Status: experimental + +Paseo is experimental, exactly like every non-tmux backend. +Select it by putting `paseo` in a local `config/backend` file, by exporting `FM_BACKEND=paseo`, or by telling the first mate in chat. + +Paseo is NEVER runtime auto-detected, even though firstmate itself commonly runs AS a Paseo agent (verified: this repo's own sessions appear in `paseo ls`). +This follows the zellij/orca precedent: auto-detecting the runtime firstmate happens to run inside would silently opt every fleet into the experimental backend, so backend selection stays explicit. + +A paseo spawn refuses loudly before leasing a worktree if the `paseo` CLI or `node` is missing, if the daemon is not running/reachable, if the CLI is older than the verified floor, or if the resolved harness is not a Paseo provider (see below). + +## Worktree provider stays treehouse + +Paseo is a session provider only. +Treehouse remains the worktree provider, exactly as it is for tmux/herdr - NOT like Orca, which owns its own worktree. +Paseo's own `paseo worktree` and `paseo run --worktree` features are never used. + +Because `paseo run` has no bare shell to type `treehouse get` into, `bin/fm-spawn.sh` acquires the worktree first, non-interactively: + +``` +treehouse get --lease --lease-holder fm-<id> +``` + +`--lease` prints ONLY the worktree's absolute path to stdout (all banners go to stderr) and durably reserves the pool slot; teardown releases it through the standard `treehouse return --force` path in `bin/fm-teardown.sh`. +Verified: `treehouse get --lease` from a scratch pooled repo printed `/Users/jacobcole/.treehouse/proj-472f6f/1/proj` on stdout with setup banners on stderr. + +## Provider gating: grok is refused + +`paseo provider ls --json` (verified) lists: + +| provider | status | +|---|---| +| claude | available | +| codex | available | +| opencode | available | +| pi | available | +| copilot | unavailable | +| omp | disabled | + +There is no `grok` provider. +`fm_backend_paseo_provider_for_harness` maps a firstmate harness to a Paseo provider and refuses `grok` (and any non-`{claude,codex,opencode,pi}` harness) LOUDLY at spawn time, before a worktree is leased. +A backend refusal is terminal, never a silent fallback to another backend (AGENTS.md section 4). + +## Verified CLI facts + +| Operation | Verified paseo call | What was verified | +|---|---|---| +| Version / daemon gate | `paseo status --json` -> `.localDaemon`/`.connectedDaemon`/`.cliVersion` | `running`/`reachable` and `cliVersion` `0.1.104`. The floor is pinned to the exact verified version (`FM_BACKEND_PASEO_MIN_VERSION`) because Paseo is pre-1.0 and its `--json` shapes are still moving; raise it only after re-verifying against the newer build. | +| Spawn | `paseo run --detach --cwd <wt> --provider <name>[/<model>] --mode bypassPermissions --title fm-<id> --label fm-task=<id> --env GOTMPDIR=<path> --json "<brief>"` | Returns `{"agentId": "...", "status": "running", "provider": "...", "cwd": "...", "title": "..."}`. The brief CONTENT is the run prompt (Paseo takes the initial task as the prompt, not a shell launch command). `bypassPermissions` is the verified claude mode id (from `paseo inspect`'s `AvailableModes`) - the autonomous equivalent of claude's `--dangerously-skip-permissions`. GOTMPDIR rides `--env` since there is no pane to `export` into. | +| Steer | `paseo send <id> --no-wait --prompt "<text>"` | Delivers one message atomically over the daemon API - no composer to submit, no autocomplete-popup hazard. Verified: a `send` after the agent went idle started a fresh turn that produced the requested reply. `send_text_submit` reports the shared `empty` ("submitted") verdict on success. | +| Peek | `paseo logs <id> --tail N` | Returns the activity timeline (the closest analogue to tmux's `capture-pane` for a message-based agent). Verified against a real claude agent's `[User]`/`[Shell]`/`[Write]` timeline. | +| Busy state | `paseo inspect <id> --json` -> `.Status` | `running` while a turn is active, `idle` once it finishes. Mapped: `running` -> busy; `idle` -> idle; anything else/unreadable -> unknown. NOTE: `inspect` uses PascalCase keys (`Status`, `Id`, `Mode`, `Cwd`); `ls --json` uses lowercase (`status`, `id`). | +| Agent liveness | `paseo inspect <id> --json` | `running`/`idle` -> `alive`; a not-found agent (`{"error":{"code":"INSPECT_FAILED","message":"...Agent not found..."}}`, non-zero exit) -> `dead`; `error`/`closed`/`failed`/`stopped` -> `dead`; anything unreadable -> `unknown` (fail-safe toward refusal, exactly like the tmux/herdr probes). Verified: a deleted agent's id returns `dead`. | +| Interrupt | `paseo stop <id>` | Interrupts a running agent (no-op for an idle one). Bound to the `C-c` key in `fm_backend_paseo_send_key`. | +| Teardown / kill | `paseo delete <id> --json` | Interrupts a running agent then hard-deletes it, returning `{"deletedCount":1,"agentIds":[...]}`. Best-effort (`|| true`); deleting an already-gone agent is a harmless no-op. Verified: `inspect` after `delete` reports the agent not found. | + +### Escape is unsupported + +Paseo is message-based, so most terminal "keys" have no meaning. +`fm_backend_paseo_send_key` maps `C-c` to `paseo stop` (interrupt) and treats `Enter` as a no-op success (a `send` already delivers its message atomically). +`Escape` is refused loudly, the same Orca precedent (`bin/backends/orca.sh`) - Paseo exposes no terminal-composer key primitive. + +## Turn-end signal: the claude Stop hook fires under `paseo run` + +Deliverable #3 required an empirical decision: does `bin/fm-spawn.sh`'s existing claude turn-end Stop hook (written into the worktree's `.claude/settings.local.json`) fire when the agent is launched via `paseo run`, or is a `paseo wait`-based sidecar needed? + +**It fires.** Paseo's claude provider spawns the real `claude` binary in `--cwd`, which reads `.claude/settings.local.json` and runs its `Stop` hook at every turn boundary. + +Smoke test (2026-07-16), reproduced twice: + +1. A worktree with `.claude/settings.local.json` containing `{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"touch '<TURNEND>'"}]}]}}`. +2. `paseo run --detach --cwd <wt> --provider claude --mode bypassPermissions ...`. +3. `paseo wait <id>` returned `idle`, and `<TURNEND>` was touched - the Stop hook fired. + +Confirmed end to end through the real `bin/fm-spawn.sh` paseo path: after the spawned claude crewmate finished its first turn, `state/<id>.turn-ended` was touched; after a `fm-send.sh` steer started a second turn, the file was re-touched. +So the watcher wakes on every crew turn completion, through the existing turn-end mechanism, with no sidecar added. + +`bin/fm-spawn.sh` writes the claude Stop hook into the leased worktree BEFORE the `paseo run` call (the run happens after the turn-end-hook block, not in the container-ensure case), so the hook is already present when the agent starts. + +### Known limitation: non-claude providers + +Only claude's turn-end hook is filesystem-based (`.claude/settings.local.json`) and therefore survives Paseo's own provider invocation. +The codex (`-c notify=`) and pi (`-e` extension) turn-end signals in `bin/fm-spawn.sh` ride the SHELL LAUNCH COMMAND, which Paseo does not use - Paseo constructs the provider invocation itself - so those signals are not wired under the paseo backend yet. +A codex/opencode/pi crewmate on paseo is still supervised by the watcher's heartbeat and stale-detection paths, just without the prompt per-turn wake claude gets. +The provider-agnostic generalization would be a per-task `paseo wait <id>` sidecar that touches `state/<id>.turn-ended` on each idle transition (respawned by the poll path if it dies); it is left as future work since the live-verified target (claude) already has a working turn-end signal. + +## End-to-end verification (spawn -> peek -> send -> busy -> alive -> turn-end -> interrupt -> teardown) + +Driven 2026-07-16 through this branch's own scripts, in a scratch `FM_HOME`, a scratch pooled git project, and a real `claude` crewmate: + +1. `FM_BACKEND=paseo bin/fm-spawn.sh e2e-t1 <proj> claude` - spawned successfully; printed `window=fm-e2e-t1 worktree=<leased path>`, and wrote `backend=paseo` plus `paseo_agent_id=2a558bb8-...` to the task's meta with `window=fm-e2e-t1` kept as the display alias. +2. `fm_backend_agent_alive paseo <id>` -> `alive`; `fm_backend_busy_state paseo <id>` -> `idle` once the turn finished; `fm_backend_target_exists paseo <id>` -> success. +3. `state/e2e-t1.turn-ended` was touched (the claude Stop hook), and the crewmate created `hello.txt` (`ahoy`) in the leased worktree. +4. `bin/fm-peek.sh fm-e2e-t1` - showed the agent's `[User]`/`[Shell]`/`[Write]`/`DONE` timeline. +5. `bin/fm-send.sh fm-e2e-t1 "..."` - delivered a fresh turn (verified in the timeline); `state/e2e-t1.turn-ended` was re-touched after that turn. +6. `fm_backend_send_key paseo <id> C-c` succeeded (`paseo stop`); `fm_backend_send_key paseo <id> Escape` was refused as expected. +7. `bin/fm-teardown.sh e2e-t1` REFUSED (uncommitted work: `REFUSED: worktree ... has uncommitted changes.`); `bin/fm-teardown.sh e2e-t1 --force` then completed, `paseo delete`d the agent (verified gone via `paseo inspect`), returned the treehouse lease, and removed the task's `state/` files. + +The scratch agent, project, and `FM_HOME` were all deleted after the run; no real repo or the captain's own agents were touched. + +## Daemon-restart semantics (as observed) + +The Paseo daemon persists agent records: `paseo ls` and `paseo inspect` continued to resolve agents created hours/days earlier across the verification window, and `paseo status` reported a single long-running daemon (`startedAt` days prior, one `pid`). +One transient `paseo status` flake was noted during the earlier feasibility research; not reproduced during this verification. +A stored `paseo_agent_id=` is therefore a durable operational target for the life of the daemon; `fm_backend_paseo_agent_alive` re-checks it live rather than trusting the stored id blindly. +Full daemon-restart survival (kill + restart the daemon, then reconcile in-flight agents) was NOT exercised and is not claimed here. + +## Out of scope (initial) + +- `--secondmate` spawns: refused (`backend=paseo does not support --secondmate spawns yet`), like orca/cmux. +- Paseo-native worktrees (`paseo run --worktree`): never used; treehouse owns worktrees. +- The MCP layer (`create_agent` `notifyOnFinish`, `relationship:subagent`): an opportunistic push bonus only; the bash watcher backbone cannot call MCP, so supervision is never built on it. +- Runtime auto-detection: deliberately never, even when firstmate runs as a Paseo agent. +- Effort/`--thinking`: Paseo's `--thinking <id>` takes a thinking-option id, not firstmate's `low|medium|high|xhigh` vocabulary, so the effort axis is not threaded into paseo spawns. diff --git a/tests/fm-backend-paseo.test.sh b/tests/fm-backend-paseo.test.sh new file mode 100755 index 000000000..4c63e45b0 --- /dev/null +++ b/tests/fm-backend-paseo.test.sh @@ -0,0 +1,183 @@ +#!/usr/bin/env bash +# tests/fm-backend-paseo.test.sh - fake-paseo-CLI unit tests for the Paseo +# agent-session-provider adapter (bin/backends/paseo.sh). +# +# Mirrors tests/fm-backend-cmux.test.sh / fm-backend-herdr.test.sh: a small +# canned-response fake `paseo` on PATH (logging every invocation) plus real +# `node` (node is a genuine required tool for this adapter, not faked, exactly +# as jq is for herdr). No live Paseo daemon is required, so this runs in CI. +# The real-daemon end-to-end verification is recorded in docs/paseo-backend.md. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +command -v node >/dev/null 2>&1 || { echo "skip: node not found (required by the paseo adapter)"; exit 0; } + +TMP_ROOT=$(fm_test_tmproot fm-backend-paseo-tests) +FAKE_BIN="$TMP_ROOT/fakebin" +LOG="$TMP_ROOT/paseo.log" +mkdir -p "$FAKE_BIN" +: > "$LOG" + +# make_paseo_fakebin: a canned-response `paseo` stub. Dispatches on the +# subcommand and honours a few env knobs a test can flip: +# FAKE_PASEO_VERSION (default 0.1.104) - status --json cliVersion +# FAKE_PASEO_DAEMON (default running) - status --json localDaemon +# FAKE_PASEO_STATUS (default idle) - inspect Status for a normal id +# FAKE_PASEO_RUN_FAIL (default unset) - make `run` exit non-zero +# An inspect id containing the literal "gone" returns a not-found error body on +# a non-zero exit, modelling a deleted agent. +cat > "$FAKE_BIN/paseo" <<'SH' +#!/usr/bin/env bash +set -u +LOG="${FAKE_PASEO_LOG:?}" +{ printf 'ARGS'; for a in "$@"; do printf '\x1f%s' "$a"; done; printf '\n'; } >> "$LOG" +sub=${1:-} +case "$sub" in + status) + printf '{"localDaemon":"%s","connectedDaemon":"%s","cliVersion":"%s","daemonVersion":"%s"}\n' \ + "${FAKE_PASEO_DAEMON:-running}" "${FAKE_PASEO_CONN:-reachable}" "${FAKE_PASEO_VERSION:-0.1.104}" "${FAKE_PASEO_VERSION:-0.1.104}" + ;; + provider) + printf '[{"provider":"claude","status":"available"},{"provider":"codex","status":"available"},{"provider":"pi","status":"available"}]\n' + ;; + run) + [ -n "${FAKE_PASEO_RUN_FAIL:-}" ] && exit 1 + printf '{"agentId":"agent-xyz-123","status":"running","provider":"claude","cwd":"/wt","title":"fm-t"}\n' + ;; + inspect) + id=${2:-} + case "$id" in + *gone*) printf '{"error":{"code":"INSPECT_FAILED","message":"Failed to inspect agent: Agent not found: %s"}}\n' "$id" >&2; exit 1 ;; + *) printf '{"Id":"%s","Status":"%s","Mode":"bypassPermissions","Cwd":"/wt"}\n' "$id" "${FAKE_PASEO_STATUS:-idle}" ;; + esac + ;; + logs) printf '[User] do the thing\n[Write] /wt/hello.txt\nDONE\n' ;; + send) : ;; + stop) : ;; + delete) printf '{"deletedCount":1,"agentIds":["%s"]}\n' "${2:-}" ;; + *) exit 2 ;; +esac +exit 0 +SH +chmod +x "$FAKE_BIN/paseo" + +export FAKE_PASEO_LOG="$LOG" +export PATH="$FAKE_BIN:$PATH" + +# shellcheck source=bin/backends/paseo.sh +. "$(dirname "${BASH_SOURCE[0]}")/../bin/backends/paseo.sh" + +last_call() { tail -1 "$LOG"; } + +# --- provider mapping / grok refusal ---------------------------------------- +for h in claude codex opencode pi; do + out=$(fm_backend_paseo_provider_for_harness "$h") || fail "provider_for_harness $h should succeed" + [ "$out" = "$h" ] || fail "provider_for_harness $h -> '$out', expected '$h'" +done +pass "paseo provider mapping accepts claude/codex/opencode/pi" + +if out=$(fm_backend_paseo_provider_for_harness grok 2>&1); then + fail "provider_for_harness grok should refuse" +fi +assert_contains "$out" "grok is not a Paseo provider" "grok refusal message" +pass "paseo refuses harness grok" + +if fm_backend_paseo_provider_for_harness bogus >/dev/null 2>&1; then + fail "provider_for_harness bogus should refuse" +fi +pass "paseo refuses an unknown harness" + +# --- version gate ----------------------------------------------------------- +fm_backend_paseo_version_ge 0.1.104 0.1.104 || fail "0.1.104 >= 0.1.104 should hold" +fm_backend_paseo_version_ge 0.2.0 0.1.104 || fail "0.2.0 >= 0.1.104 should hold" +if fm_backend_paseo_version_ge 0.1.103 0.1.104; then fail "0.1.103 >= 0.1.104 must be false"; fi +pass "paseo version_ge compares dotted versions" + +# --- runtime check ---------------------------------------------------------- +fm_backend_paseo_runtime_check || fail "runtime_check should pass with running daemon + verified version" +pass "paseo runtime_check passes on a running, verified daemon" + +if FAKE_PASEO_VERSION=0.1.103 fm_backend_paseo_runtime_check 2>/dev/null; then + fail "runtime_check should refuse an under-floor CLI version" +fi +pass "paseo runtime_check refuses an under-floor version" + +if FAKE_PASEO_DAEMON=stopped FAKE_PASEO_CONN=unreachable fm_backend_paseo_runtime_check 2>/dev/null; then + fail "runtime_check should refuse a stopped daemon" +fi +pass "paseo runtime_check refuses a stopped/unreachable daemon" + +# --- run -------------------------------------------------------------------- +echo "brief body" > "$TMP_ROOT/brief.md" +aid=$(fm_backend_paseo_run "/wt" "claude" "default" "fm-t" "t" "/tmp/fm-t/gotmp" "$TMP_ROOT/brief.md") \ + || fail "paseo_run should succeed" +[ "$aid" = "agent-xyz-123" ] || fail "paseo_run should echo the agentId, got '$aid'" +run_call=$(grep '^ARGS' "$LOG" | grep -F 'run' | tail -1) +assert_contains "$run_call" "--mode" "run passes --mode" +assert_contains "$run_call" "bypassPermissions" "run passes bypassPermissions" +assert_contains "$run_call" "fm-task=t" "run passes the fm-task label" +assert_contains "$run_call" "GOTMPDIR=/tmp/fm-t/gotmp" "run passes GOTMPDIR via --env" +pass "paseo_run launches with mode/label/env and returns the agent id" + +if FAKE_PASEO_RUN_FAIL=1 fm_backend_paseo_run "/wt" "claude" "default" "fm-t" "t" "/tmp/g" "$TMP_ROOT/brief.md" >/dev/null 2>&1; then + fail "paseo_run should fail when the CLI fails" +fi +pass "paseo_run surfaces a run failure" + +# --- run with a model spec -------------------------------------------------- +fm_backend_paseo_run "/wt" "claude" "claude-fable-5" "fm-t" "t" "/tmp/g" "$TMP_ROOT/brief.md" >/dev/null +assert_contains "$(last_call)" "claude/claude-fable-5" "run passes provider/model spec" +pass "paseo_run threads a model into the provider spec" + +# --- busy state ------------------------------------------------------------- +[ "$(FAKE_PASEO_STATUS=running fm_backend_paseo_busy_state agent-1)" = busy ] || fail "running -> busy" +[ "$(FAKE_PASEO_STATUS=idle fm_backend_paseo_busy_state agent-1)" = idle ] || fail "idle -> idle" +[ "$(fm_backend_paseo_busy_state agent-gone-1)" = unknown ] || fail "not-found -> unknown busy state" +pass "paseo busy_state maps running/idle/unknown" + +# --- agent liveness --------------------------------------------------------- +[ "$(FAKE_PASEO_STATUS=running fm_backend_paseo_agent_alive agent-1)" = alive ] || fail "running -> alive" +[ "$(FAKE_PASEO_STATUS=idle fm_backend_paseo_agent_alive agent-1)" = alive ] || fail "idle -> alive" +[ "$(fm_backend_paseo_agent_alive agent-gone-1)" = dead ] || fail "not-found -> dead" +pass "paseo agent_alive maps running/idle -> alive and not-found -> dead" + +# --- target exists ---------------------------------------------------------- +fm_backend_paseo_target_exists agent-1 || fail "present agent should exist" +if fm_backend_paseo_target_exists agent-gone-1; then fail "gone agent should not exist"; fi +pass "paseo target_exists reflects presence" + +# --- send / steer ----------------------------------------------------------- +verdict=$(fm_backend_paseo_send_text_submit agent-1 "hello there") +[ "$verdict" = empty ] || fail "send_text_submit should report 'empty' (submitted), got '$verdict'" +send_call=$(grep '^ARGS' "$LOG" | grep -F 'send' | tail -1) +assert_contains "$send_call" "--no-wait" "send uses --no-wait" +assert_contains "$send_call" "hello there" "send delivers the text" +pass "paseo send_text_submit delivers atomically and reports empty" + +# --- capture ---------------------------------------------------------------- +cap=$(fm_backend_paseo_capture agent-1 5) +assert_contains "$cap" "DONE" "capture returns the logs timeline" +assert_contains "$(last_call)" "--tail" "capture uses logs --tail" +pass "paseo capture reads the activity timeline" + +# --- keys ------------------------------------------------------------------- +fm_backend_paseo_send_key agent-1 C-c || fail "C-c should succeed" +assert_contains "$(last_call)" "stop" "C-c maps to paseo stop" +fm_backend_paseo_send_key agent-1 Enter || fail "Enter should be a no-op success" +if fm_backend_paseo_send_key agent-1 Escape 2>/dev/null; then fail "Escape must be refused"; fi +pass "paseo send_key: C-c -> stop, Enter no-op, Escape refused" + +# --- kill ------------------------------------------------------------------- +fm_backend_paseo_kill agent-1 +assert_contains "$(last_call)" "delete" "kill maps to paseo delete" +pass "paseo kill hard-deletes the agent" + +# --- dispatcher wiring (fm-backend.sh) -------------------------------------- +# shellcheck source=bin/fm-backend.sh +. "$(dirname "${BASH_SOURCE[0]}")/../bin/fm-backend.sh" +fm_backend_list_contains "$FM_BACKEND_SPAWN" paseo || fail "paseo must be spawn-capable in fm-backend.sh" +[ "$(fm_backend_busy_state paseo agent-1)" = idle ] || fail "dispatcher busy_state should reach paseo" +[ "$(fm_backend_agent_alive paseo agent-gone-1)" = dead ] || fail "dispatcher agent_alive should reach paseo" +pass "fm-backend.sh dispatches paseo ops"