diff --git a/bin/backends/herdr.sh b/bin/backends/herdr.sh index 51835ca9a..a86426416 100644 --- a/bin/backends/herdr.sh +++ b/bin/backends/herdr.sh @@ -48,7 +48,7 @@ FM_BACKEND_HERDR_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" FM_ROOT="${FM_ROOT_OVERRIDE:-${FM_ROOT:-$FM_BACKEND_HERDR_ROOT}}" FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" -FM_BACKEND_HERDR_MIN_PROTOCOL=14 +FM_BACKEND_HERDR_MIN_PROTOCOL=16 # .fm-secondmate-home is written by bin/fm-home-seed.sh (AGENTS.md section 6) # at a seeded secondmate home's root, containing exactly that secondmate's id. # The primary firstmate home never carries this marker. @@ -108,7 +108,7 @@ fm_backend_herdr_tool_check() { } # fm_backend_herdr_version_check: refuse loudly on a missing/incompatible -# herdr client. Verified locally: v0.7.1, protocol 14 (herdr status --json's +# herdr client. Verified locally: v0.7.2, protocol 16 (herdr status --json's # .client.protocol; client info is session-independent, unlike .server). fm_backend_herdr_version_check() { fm_backend_herdr_tool_check || return 1 diff --git a/bin/fm-backend.sh b/bin/fm-backend.sh index 9be8f2352..b6fb4b0ef 100644 --- a/bin/fm-backend.sh +++ b/bin/fm-backend.sh @@ -56,8 +56,8 @@ FM_BACKEND_CONFIG_DIR="${FM_CONFIG_OVERRIDE:-$FM_HOME/config}" # bin/backends/.sh and empirical verification, mirroring AGENTS.md # section 4's harness-verification discipline. herdr is EXPERIMENTAL (P2; # data/fm-backend-design-d7/herdr-addendum.md) - verified against the real -# v0.7.1/protocol-14 binary (data/fm-backend-design-d7/herdr-verification-p2.md) -# but newer than tmux's long-proven default path. zellij is EXPERIMENTAL (P3; +# v0.7.2/protocol-16 binary (docs/herdr-backend.md) but newer than tmux's +# long-proven default path. zellij is EXPERIMENTAL (P3; # data/fm-backend-design-d7/report.md "Zellij Backend") - verified against the # real 0.44.0 binary (docs/zellij-backend.md). orca is EXPERIMENTAL and # spawn-capable; unlike tmux/herdr/zellij it is also the worktree provider. diff --git a/docs/herdr-backend.md b/docs/herdr-backend.md index cb11fbdf1..055cc11af 100644 --- a/docs/herdr-backend.md +++ b/docs/herdr-backend.md @@ -4,7 +4,7 @@ This document records the empirical verification behind `bin/backends/herdr.sh`, It is the herdr equivalent of the tmux facts recorded in the `harness-adapters` skill and `docs/architecture.md`'s "Runtime session backends" section. Herdr is [an agent-native terminal multiplexer](https://herdr.dev) with a socket API, CLI wrappers, and native per-pane agent-state detection. -Verified against the real installed binary: herdr 0.7.1, protocol 14, macOS aarch64. +Verified against the real installed binary: herdr 0.7.2, protocol 16, macOS aarch64 (herdr --version: 0.7.2; herdr status --json client: {version: 0.7.2, protocol: 16}, server: {version: 0.7.2, protocol: 16, compatible: true}; post-upgrade smoke test on default session passed spawn, peek, send (plain popup-risk-verified), crew-state, and teardown; task herdr-smoke-072, mpf worktree, 2026-07-11). Current real-herdr verification uses isolated `HERDR_SESSION` names plus the guarded teardown helper in `tests/herdr-test-safety.sh`. A 2026-07-02 cleanup bug proved that `HERDR_SESSION` alone is not a safe way to target destructive session cleanup; see "Session targeting: the `--session` flag, not `HERDR_SESSION` alone" below. All real-herdr verification in this document uses isolated sessions and guarded cleanup; the captain's default herdr session and live tmux fleet were never intended targets. @@ -18,7 +18,7 @@ Firstmate only drives the `herdr` CLI as a separate process, which carries no AG Prerequisites: -- `herdr` itself, protocol 14 or newer (installed 0.7.1 verified) - see [herdr.dev](https://herdr.dev) for install instructions. +- `herdr` itself, protocol 16 or newer (installed 0.7.2 verified) - see [herdr.dev](https://herdr.dev) for install instructions. - `jq`, required to parse herdr's JSON output: `brew install jq` (or your platform's package manager). - The same universal requirements as tmux (a verified crew harness, git with GitHub auth, node, treehouse, no-mistakes, gh-axi, chrome-devtools-axi, and lavish-axi); treehouse still provides the worktree, herdr only provides the session. @@ -67,7 +67,7 @@ This refines, but does not reverse, P2's original decision (AGENTS.md task herdr P2 established workspace-per-TASK vs. tab-per-task-in-one-shared-workspace and picked tab-per-task on the human-watching axis (below); that axis is untouched here and workspace-per-task stays rejected. What changed is the container's OWNER: P2 assumed a single firstmate instance per herdr session, so one shared `firstmate` workspace was enough. With secondmates now spawning their own herdr tasks, jamming every home's tabs into that one shared workspace made a captain's tab bar an unlabeled mix of primary and secondmate work with no visual way to tell them apart. -Workspace-per-HOME fixes that while keeping tab-per-task's original human-watching win intact **within** each home: attaching to a home's own workspace (`herdr`, then switching to its space) still shows every one of *that home's* tasks as a tab in one tab bar, switchable with `ctrl+b `; the ADDITIONAL win is that a captain juggling several homes on one herdr session now sees them as clearly labeled, separate spaces in herdr's spaces sidebar instead of one undifferentiated pile. +Workspace-per-HOME fixes that while keeping tab-per-task's original human-watching win intact **within** each home: attaching to a home's own workspace (`herdr`, then switching to its space) still shows every one of _that home's_ tasks as a tab in one tab bar, switchable with `ctrl+b `; the ADDITIONAL win is that a captain juggling several homes on one herdr session now sees them as clearly labeled, separate spaces in herdr's spaces sidebar instead of one undifferentiated pile. ### Label derivation (stable, derived from the home itself) @@ -175,21 +175,21 @@ Herdr tasks additionally record: ## Verified CLI facts -| Operation | Verified herdr call | What was verified | -|---|---|---| -| Version/protocol gate | `herdr status --json` -> `.client.protocol` | Session-independent; `.server.*` fields ARE session-dependent. | -| Headless server start | `HERDR_SESSION= herdr server --session ` (backgrounded) | A bare socket call does NOT auto-start the server; the adapter always starts-then-polls before any workspace/tab/pane call. This fact is for start only, not cleanup, and the explicit `--session` flag is intentional because `HERDR_SESSION` alone is not safe session targeting. | -| Duplicate task check | `herdr tab list --workspace `, match by `.label` | Herdr does NOT enforce tab-label uniqueness itself; two tabs can share a label. The adapter's own duplicate check is required. | -| Send literal (unsubmitted) | `herdr pane send-text ` | Does NOT auto-submit, contrary to the original design addendum's guess. Verified directly: a unique marker sent this way sits unexecuted in the composer until a separate Enter. Behaves exactly like tmux's `send-keys -l`. | -| Send + submit atomically | `herdr pane run ` | Runs and submits a command in one call; used for the two fixed spawn-time commands (`treehouse get`, the `GOTMPDIR` export) exactly where tmux used one `send-keys ... Enter` call. | -| Send key | `herdr pane send-keys ` | Verified names: `enter`, `escape` (alias `esc`), `ctrl+c` (aliases `C-c`, `c-c`). `ctrl+c` verified to interrupt a running foreground process immediately. | -| Bounded capture | `herdr pane read --source recent --lines N` | See "Verified bug" below - N is never passed through directly. | -| Busy state | `herdr agent get ` -> `.result.agent.agent_status` | Verified live against an interactive `claude` session: reports `working` while generating, `done` once idle. Mapped: `working` -> busy; `idle`/`done` -> idle; `blocked` -> idle (surfaced like a stale pane, not suppressed as busy - a blocked agent is stuck waiting on the human, not grinding); anything else -> unknown (the cue for the shared tail-regex fallback). | -| Kill | `herdr pane close ` | Closing a tab's only (root) pane also closes the tab - no separate tab-close call needed for this adapter's one-pane-per-tab shape. Best-effort: closing an already-closed pane exits non-zero, matching tmux's `kill-window \|\| true` contract. Teardown itself only ever closes the task's own pane/tab, never the workspace - but closing a workspace's LAST tab (verified real-herdr behavior) deletes the workspace as a side effect, so a home's own workspace persists only while at least one task tab remains; see "Workspace lifecycle" above. | +| Operation | Verified herdr call | What was verified | +| --------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Version/protocol gate | `herdr status --json` -> `.client.protocol` | Session-independent; `.server.*` fields ARE session-dependent. | +| Headless server start | `HERDR_SESSION= herdr server --session ` (backgrounded) | A bare socket call does NOT auto-start the server; the adapter always starts-then-polls before any workspace/tab/pane call. This fact is for start only, not cleanup, and the explicit `--session` flag is intentional because `HERDR_SESSION` alone is not safe session targeting. | +| Duplicate task check | `herdr tab list --workspace `, match by `.label` | Herdr does NOT enforce tab-label uniqueness itself; two tabs can share a label. The adapter's own duplicate check is required. | +| Send literal (unsubmitted) | `herdr pane send-text ` | Does NOT auto-submit, contrary to the original design addendum's guess. Verified directly: a unique marker sent this way sits unexecuted in the composer until a separate Enter. Behaves exactly like tmux's `send-keys -l`. | +| Send + submit atomically | `herdr pane run ` | Runs and submits a command in one call; used for the two fixed spawn-time commands (`treehouse get`, the `GOTMPDIR` export) exactly where tmux used one `send-keys ... Enter` call. | +| Send key | `herdr pane send-keys ` | Verified names: `enter`, `escape` (alias `esc`), `ctrl+c` (aliases `C-c`, `c-c`). `ctrl+c` verified to interrupt a running foreground process immediately. | +| Bounded capture | `herdr pane read --source recent --lines N` | See "Verified bug" below - N is never passed through directly. | +| Busy state | `herdr agent get ` -> `.result.agent.agent_status` | Verified live against an interactive `claude` session: reports `working` while generating, `done` once idle. Mapped: `working` -> busy; `idle`/`done` -> idle; `blocked` -> idle (surfaced like a stale pane, not suppressed as busy - a blocked agent is stuck waiting on the human, not grinding); anything else -> unknown (the cue for the shared tail-regex fallback). | +| Kill | `herdr pane close ` | Closing a tab's only (root) pane also closes the tab - no separate tab-close call needed for this adapter's one-pane-per-tab shape. Best-effort: closing an already-closed pane exits non-zero, matching tmux's `kill-window \|\| true` contract. Teardown itself only ever closes the task's own pane/tab, never the workspace - but closing a workspace's LAST tab (verified real-herdr behavior) deletes the workspace as a side effect, so a home's own workspace persists only while at least one task tab remains; see "Workspace lifecycle" above. | | Default-tab prune (create_task, first task in a fresh workspace only) | `herdr workspace create`'s own response (`.result.tab.tab_id`) identifies the seeded tab; `herdr tab list` + `herdr agent get ` re-verify it; `herdr pane close ` closes exactly that tab id | `herdr workspace create` seeds the new workspace with one auto-created default tab (label `1`, id captured straight from the create response) firstmate never uses. `fm_backend_herdr_create_task` closes EXACTLY that captured tab id right after creating the first real task tab in a freshly created workspace - never right after `workspace create` itself (see Kill row), and never re-derived from a tab's label or the workspace's tab count at create_task time (see "Default-tab prune" above for the created-vs-adopted safety gate and the 2026-07-02 incident it fixes). Best-effort; an ADOPTED workspace (not freshly created by this same call) is never a prune candidate at all. | -| Recovery / list-live | `herdr tab list --workspace `, filter labels starting with `fm-` | Label-based, never trusts a stored id blindly - see "ID stability" below. `` is always THIS home's own workspace (`fm_backend_herdr_workspace_find`), so recovery never sees a sibling home's tabs. | -| Workspace create / tab create (focus) | `herdr workspace create --no-focus`, `herdr tab create --no-focus` | Verified: neither focuses by default once a workspace already exists in the session, matching pre-P3 (flagless) behavior; `--no-focus` is passed anyway for defense in depth, since the very first workspace ever created in a brand-new session focuses regardless of the flag. `--focus` was separately verified to reliably focus, confirming the flag has real effect. | -| Session targeting for DESTRUCTIVE calls | `herdr session stop --session --json`, then `herdr session delete --session --json`; never `herdr server stop` | Used only through `tests/herdr-test-safety.sh`, which re-queries `herdr session list --json` before every destructive call. See "Session targeting" below - `HERDR_SESSION` alone is not reliably honored once another herdr server is already running on the machine. | +| Recovery / list-live | `herdr tab list --workspace `, filter labels starting with `fm-` | Label-based, never trusts a stored id blindly - see "ID stability" below. `` is always THIS home's own workspace (`fm_backend_herdr_workspace_find`), so recovery never sees a sibling home's tabs. | +| Workspace create / tab create (focus) | `herdr workspace create --no-focus`, `herdr tab create --no-focus` | Verified: neither focuses by default once a workspace already exists in the session, matching pre-P3 (flagless) behavior; `--no-focus` is passed anyway for defense in depth, since the very first workspace ever created in a brand-new session focuses regardless of the flag. `--focus` was separately verified to reliably focus, confirming the flag has real effect. | +| Session targeting for DESTRUCTIVE calls | `herdr session stop --session --json`, then `herdr session delete --session --json`; never `herdr server stop` | Used only through `tests/herdr-test-safety.sh`, which re-queries `herdr session list --json` before every destructive call. See "Session targeting" below - `HERDR_SESSION` alone is not reliably honored once another herdr server is already running on the machine. | ## Verified bug: `pane read --lines N` returns empty for small N diff --git a/tests/fm-backend-herdr.test.sh b/tests/fm-backend-herdr.test.sh index 97ce591e8..30e918e87 100755 --- a/tests/fm-backend-herdr.test.sh +++ b/tests/fm-backend-herdr.test.sh @@ -40,7 +40,7 @@ next=$(( $(cat "$COUNT_FILE" 2>/dev/null || echo 0) + 1 )) printf '\n' } >> "$LOG" if [ "${1:-}" = status ] && [ "${2:-}" = --json ] && [ "${FM_HERDR_SCRIPT_STATUS:-0}" != 1 ]; then - printf '{"client":{"version":"0.7.1","protocol":14},"server":{"running":true}}\n' + printf '{"client":{"version":"0.7.2","protocol":16},"server":{"running":true}}\n' exit 0 fi n=$next @@ -104,7 +104,7 @@ done case "$cmd $sub" in "status --json") - printf '{"client":{"version":"0.7.1","protocol":14},"server":{"running":true}}\n' + printf '{"client":{"version":"0.7.2","protocol":16},"server":{"running":true}}\n' ;; "workspace list") jq_state '{result:{workspaces:.workspaces}}' @@ -182,14 +182,14 @@ herdr_env() { # test_version_check_accepts_current_protocol() { local dir log resp fb status dir="$TMP_ROOT/version-ok"; mkdir -p "$dir/responses"; log="$dir/log"; resp="$dir/responses"; : > "$log" - printf '{"client":{"version":"0.7.1","channel":"stable","protocol":14}}\n' > "$resp/1.out" + printf '{"client":{"version":"0.7.2","channel":"stable","protocol":16}}\n' > "$resp/1.out" fb=$(make_herdr_fakebin "$dir") PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" FM_HERDR_SCRIPT_STATUS=1 \ bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_version_check' "$ROOT" status=$? - expect_code 0 "$status" "version_check should accept protocol 14 (>= the verified minimum)" + expect_code 0 "$status" "version_check should accept protocol 16 (>= the verified minimum)" assert_contains "$(cat "$log")" $'\x1f''status'$'\x1f''--json' "version_check did not call herdr status --json" - pass "fm_backend_herdr_version_check: accepts the current protocol (14)" + pass "fm_backend_herdr_version_check: accepts the current protocol (16)" } test_version_check_refuses_old_protocol() { @@ -287,7 +287,7 @@ test_container_ensure_starts_server_and_workspace() { local dir log resp fb out dir="$TMP_ROOT/container"; mkdir -p "$dir/responses"; log="$dir/log"; resp="$dir/responses"; : > "$log" # 1: version_check status --json (server not running yet, irrelevant to client check) - printf '{"client":{"version":"0.7.1","protocol":14}}\n' > "$resp/1.out" + printf '{"client":{"version":"0.7.2","protocol":16}}\n' > "$resp/1.out" # 2: server_ensure's status --json check -> not running printf '{"server":{"running":false}}\n' > "$resp/2.out" # 3: `herdr server` backgrounded launch - no meaningful output @@ -311,7 +311,7 @@ test_container_ensure_starts_server_and_workspace() { test_container_ensure_reuses_existing_workspace() { local dir log resp fb out dir="$TMP_ROOT/container-reuse"; mkdir -p "$dir/responses"; log="$dir/log"; resp="$dir/responses"; : > "$log" - printf '{"client":{"version":"0.7.1","protocol":14}}\n' > "$resp/1.out" + printf '{"client":{"version":"0.7.2","protocol":16}}\n' > "$resp/1.out" printf '{"server":{"running":true}}\n' > "$resp/2.out" printf '{"result":{"workspaces":[{"workspace_id":"w9","label":"firstmate"}]}}\n' > "$resp/3.out" fb=$(make_herdr_fakebin "$dir") @@ -571,7 +571,7 @@ test_create_task_creates_and_parses_ids() { test_container_ensure_creates_with_no_focus_flag() { local dir log resp fb out dir="$TMP_ROOT/container-no-focus"; mkdir -p "$dir/responses"; log="$dir/log"; resp="$dir/responses"; : > "$log" - printf '{"client":{"version":"0.7.1","protocol":14}}\n' > "$resp/1.out" + printf '{"client":{"version":"0.7.2","protocol":16}}\n' > "$resp/1.out" printf '{"server":{"running":true}}\n' > "$resp/2.out" printf '{"result":{"workspaces":[]}}\n' > "$resp/3.out" printf '{"result":{"workspace":{"workspace_id":"w1","label":"firstmate"},"tab":{"tab_id":"w1:t1"},"root_pane":{"pane_id":"w1:p1"}}}\n' > "$resp/4.out" @@ -588,7 +588,7 @@ test_container_ensure_uses_secondmate_home_label() { local dir log resp fb out home dir="$TMP_ROOT/container-secondmate-label"; mkdir -p "$dir/responses"; log="$dir/log"; resp="$dir/responses"; : > "$log" home="$TMP_ROOT/container-secondmate-home"; mkdir -p "$home"; printf 'sshhip-h7\n' > "$home/.fm-secondmate-home" - printf '{"client":{"version":"0.7.1","protocol":14}}\n' > "$resp/1.out" + printf '{"client":{"version":"0.7.2","protocol":16}}\n' > "$resp/1.out" printf '{"server":{"running":true}}\n' > "$resp/2.out" printf '{"result":{"workspaces":[]}}\n' > "$resp/3.out" printf '{"result":{"workspace":{"workspace_id":"w9","label":"2ndmate-sshhip-h7"},"tab":{"tab_id":"w9:t1"},"root_pane":{"pane_id":"w9:p1"}}}\n' > "$resp/4.out" diff --git a/tests/fm-crew-state.test.sh b/tests/fm-crew-state.test.sh index c67ade719..5c52b617a 100755 --- a/tests/fm-crew-state.test.sh +++ b/tests/fm-crew-state.test.sh @@ -96,7 +96,7 @@ set -u case "${1:-}" in status) [ "${2:-}" = --json ] && { - printf '{"client":{"version":"0.7.1","protocol":14},"server":{"running":true}}\n' + printf '{"client":{"version":"0.7.2","protocol":16},"server":{"running":true}}\n' exit 0 } ;; server) diff --git a/tests/fm-send-popup-settle.test.sh b/tests/fm-send-popup-settle.test.sh index 6d97e3d39..5945537ea 100755 --- a/tests/fm-send-popup-settle.test.sh +++ b/tests/fm-send-popup-settle.test.sh @@ -79,7 +79,7 @@ set -u printf '\n' } >> "$FM_HERDR_LOG" if [ "${1:-}" = status ] && [ "${2:-}" = --json ]; then - printf '{"client":{"version":"0.7.1","protocol":14},"server":{"running":true}}\n' + printf '{"client":{"version":"0.7.2","protocol":16},"server":{"running":true}}\n' fi exit 0 SH