From c1a691f57a8b56c2205507265952cfce173af020 Mon Sep 17 00:00:00 2001 From: Curtis Kormos Date: Thu, 29 Oct 2026 15:17:41 -0600 Subject: [PATCH 1/6] feat(backends): add experimental herdr-core runtime backend Add bin/backends/herdr-core.sh so config/backend=herdr-core launches ship and scout workers as herdr-core (hc) terminals, with treehouse still the worktree provider. Each task is one labelled terminal (fm_home, fm_task) pinned to its recorded hc session; ownership is proven by labels before any read, send, kill, or remove. Wire it through backend selection and validation, HC_TERMINAL_ID auto-detection (ancestry decides when HERDR_ENV is also present), the hc version gate, task metadata, the control plane (interrupt, exit, relaunch with proven-absence rebind), busy and liveness classification, and recorded-inventory discovery. Secondmate spawns are refused. Document setup, detection, and limits in docs/herdr-core-backend.md and record the live hc 0.1.13 verification pass. --- .../references/harness/commandcode.md | 2 +- README.md | 1 + bin/backends/herdr-core.sh | 372 ++++++++++++++++++ bin/fm-backend.sh | 114 +++++- bin/fm-busy-lib.sh | 4 +- bin/fm-control-lib.sh | 21 +- bin/fm-crew-state.sh | 10 +- bin/fm-spawn.sh | 80 +++- bin/fm-test-run.sh | 4 + docs/agent-control.md | 5 +- docs/architecture.md | 6 +- docs/configuration.md | 28 +- docs/documentation-audiences.json | 9 + docs/herdr-core-backend.md | 66 ++++ docs/remote-secondmates.md | 2 +- docs/verification/runtime-backends.md | 60 +++ tests/fm-backend-herdr-core.test.sh | 344 ++++++++++++++++ 17 files changed, 1068 insertions(+), 60 deletions(-) create mode 100644 bin/backends/herdr-core.sh create mode 100644 docs/herdr-core-backend.md create mode 100755 tests/fm-backend-herdr-core.test.sh diff --git a/.agents/skills/harness-adapters/references/harness/commandcode.md b/.agents/skills/harness-adapters/references/harness/commandcode.md index 7d6ebc91dc1..a77a3edc1f9 100644 --- a/.agents/skills/harness-adapters/references/harness/commandcode.md +++ b/.agents/skills/harness-adapters/references/harness/commandcode.md @@ -33,7 +33,7 @@ Command Code draws a bare `❯` composer between two rules, parks the terminal c Its `Ask your question...` placeholder is truecolor bright enough to survive the fleet ghost ceiling, so `../../../bin/fm-composer-lib.sh` reads a screen with a Command Code identity at a higher ceiling; typed text is the default foreground and stays pending. The launch clears `NO_COLOR` and pins `COLORTERM=truecolor`, because a 256-colour terminal renders the placeholder in an untested palette colour. `../../../bin/fm-tmux-lib.sh` supplies the `commandcode` identity on tmux, while Herdr's native detection reports `cmd`. -Zellij, cmux, and orca supply no such identity, so the placeholder would read pending there and `../../../bin/fm-spawn.sh` refuses a Command Code launch on those backends. +Zellij, cmux, orca, and herdr-core supply no such identity, so the placeholder would read pending there and `../../../bin/fm-spawn.sh` refuses a Command Code launch on those backends. The delivery busy signals are `esc to interrupt` and the bullet-framed elapsed cell on the status row. ## Primary integration diff --git a/README.md b/README.md index ad03f938f9e..f445e26406c 100644 --- a/README.md +++ b/README.md @@ -226,6 +226,7 @@ Firstmate's skills live in two separate places with different audiences: - [docs/zellij-backend.md](docs/zellij-backend.md) - current setup and limits for the experimental Zellij backend. - [docs/orca-backend.md](docs/orca-backend.md) - current setup and limits for the experimental Orca backend. - [docs/cmux-backend.md](docs/cmux-backend.md) - current setup, socket security, and limits for the experimental cmux backend. +- [docs/herdr-core-backend.md](docs/herdr-core-backend.md) - current setup, detection, and limits for the experimental herdr-core backend. - [docs/codex-app-backend.md](docs/codex-app-backend.md) - the current blocked Codex App backend boundary and rollout contract. - [docs/verification/runtime-backends.md](docs/verification/runtime-backends.md) - active maintainer verification for runtime backend guarantees. - [docs/gerrit-forge-integration.md](docs/gerrit-forge-integration.md) - maintainer architecture for the forge axis: why change-shaped review is not a forge variant, the mode/forge/shape composition test, and where responsibility for forge mechanics sits. diff --git a/bin/backends/herdr-core.sh b/bin/backends/herdr-core.sh new file mode 100644 index 00000000000..476649e1603 --- /dev/null +++ b/bin/backends/herdr-core.sh @@ -0,0 +1,372 @@ +#!/usr/bin/env bash +# bin/backends/herdr-core.sh - the herdr-core session-provider adapter +# (EXPERIMENTAL). +# +# herdr-core is a separate product from herdr (bin/backends/herdr.sh): its CLI +# is `hc`, it has no workspaces, tabs, or panes, and every program runs in a +# flat, persistent "terminal" addressed as t1, t2, ... inside one per-user core. +# It is a session provider ONLY: treehouse stays the worktree provider. +# docs/herdr-core-backend.md owns setup, detection, limits, and the live +# verification record (hc 0.1.13, Linux). +# +# Container shape: ONE terminal per task, running the user's login shell in the +# project directory, exactly like a tmux window. There is no per-home +# container; every terminal carries two labels instead: +# fm_home= which firstmate installation/home owns it +# fm_task= which task it belongs to +# Labels are the only ownership proof this adapter accepts. A terminal whose +# labels do not name this home (and, when the caller knows it, this task) is +# never read, typed into, killed, or removed through this adapter - it reads as +# `missing`, because whatever owned that address for us is gone. +# +# Target string shape: ":
", for example "default:t7". +# Addresses are per core, and `hc` picks its core from HC_SESSION (default +# "default"), so the session is recorded and pinned on every call: a caller +# whose own ambient HC_SESSION differs still reaches the recorded core. +# +# Verified hc 0.1.13 facts that shaped this adapter (docs/herdr-core-backend.md +# has the evidence): +# 1. `hc send` pastes AND presses Enter in one call; there is no unsubmitted +# paste, and `hc keys` accepts only named keys (no `_`), so there is no +# literal-text primitive. Submits are one `hc send` plus Enter-only +# retries; fm-spawn.sh sends its launch line the same way. +# 2. `hc run` defaults its parent to the calling terminal: the child ENDS +# when the parent's program ends and notes are typed into the parent. +# Firstmate itself runs in an hc terminal, so every task terminal is +# created with --no-parent and --no-notify. +# 3. `hc get --json` reports the foreground process (name, argv, pid, cwd); +# the cwd follows a subshell's own `cd`, so no shell probe is needed. +# 4. `hc read` trims trailing blank screen rows; `--lines 0` is the screen +# only, so it doubles as the viewport-bounded capture. +# 5. Exit codes: 3 not_found, 9 blocked (send refused at a permission +# prompt), 11 finished. +# +# Requires: hc (herdr-core 0.1.13 or newer), jq. Bootstrap detects these +# through fm_backend_required_tools only when herdr-core is the resolved +# backend; this adapter also gates them again before spawning. + +FM_BACKEND_HERDR_CORE_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +FM_ROOT="${FM_ROOT_OVERRIDE:-${FM_ROOT:-$FM_BACKEND_HERDR_CORE_ROOT}}" +FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" + +# shellcheck source=bin/fm-backend-hometag-lib.sh +. "$FM_BACKEND_HERDR_CORE_ROOT/bin/fm-backend-hometag-lib.sh" +# shellcheck source=bin/fm-composer-lib.sh +. "$FM_BACKEND_HERDR_CORE_ROOT/bin/fm-composer-lib.sh" +# shellcheck source=bin/fm-agent-process-lib.sh +. "$FM_BACKEND_HERDR_CORE_ROOT/bin/fm-agent-process-lib.sh" + +# Verified minimum: the version the live pass ran against. +FM_BACKEND_HERDR_CORE_MIN_VERSION=0.1.13 + +fm_backend_herdr_core_tool_check() { + command -v hc >/dev/null 2>&1 || { echo "error: backend=herdr-core selected but the 'hc' CLI is not installed (herdr-core $FM_BACKEND_HERDR_CORE_MIN_VERSION or newer; see docs/herdr-core-backend.md)" >&2; return 1; } + command -v jq >/dev/null 2>&1 || { echo "error: backend=herdr-core selected but 'jq' is not installed (required to parse hc's JSON output)" >&2; return 1; } + return 0 +} + +# fm_backend_herdr_core_version_ge: dotted-numeric >= . +fm_backend_herdr_core_version_ge() { # + local a=$1 b=$2 x y + while [ -n "$a$b" ]; do + x=${a%%.*}; y=${b%%.*} + x=${x:-0}; y=${y:-0} + [ "$x" -gt "$y" ] && return 0 + [ "$x" -lt "$y" ] && return 1 + case "$a" in *.*) a=${a#*.} ;; *) a= ;; esac + case "$b" in *.*) b=${b#*.} ;; *) b= ;; esac + done + return 0 +} + +# fm_backend_herdr_core_version_check: refuse a missing or older hc client. +# `hc --version` prints "hc " and needs no running core. +fm_backend_herdr_core_version_check() { + local raw ver + fm_backend_herdr_core_tool_check || return 1 + raw=$(hc --version 2>/dev/null) || { echo "error: 'hc --version' failed; is herdr-core installed correctly?" >&2; return 1; } + ver=${raw#hc } + case "$ver" in + ''|*[!0-9.]*|.*|*.) + echo "error: could not parse an hc version from '$raw'; refusing to use an unverified herdr-core build" >&2 + return 1 + ;; + esac + if ! fm_backend_herdr_core_version_ge "$ver" "$FM_BACKEND_HERDR_CORE_MIN_VERSION"; then + echo "error: herdr-core $ver is older than the verified minimum $FM_BACKEND_HERDR_CORE_MIN_VERSION; run 'hc update' before using backend=herdr-core" >&2 + return 1 + fi + return 0 +} + +# fm_backend_herdr_core_session: the hc session a NEW task lands in - the one +# this firstmate process itself addresses. +fm_backend_herdr_core_session() { + printf '%s' "${HC_SESSION:-default}" +} + +# fm_backend_herdr_core_cli: `hc ` pinned to 's core. +fm_backend_herdr_core_cli() { # + local session=$1 + shift + HC_SESSION=$session hc "$@" +} + +fm_backend_herdr_core_parse_target() { # + local target=$1 + FM_BACKEND_HERDR_CORE_SESSION=${target%%:*} + FM_BACKEND_HERDR_CORE_ADDRESS=${target#*:} + [ -n "$FM_BACKEND_HERDR_CORE_SESSION" ] && [ -n "$FM_BACKEND_HERDR_CORE_ADDRESS" ] \ + && [ "$FM_BACKEND_HERDR_CORE_ADDRESS" != "$target" ] || return 1 + case "$FM_BACKEND_HERDR_CORE_ADDRESS" in + *:*|*/*) return 1 ;; + esac +} + +# fm_backend_herdr_core_create_task: start the task's shell terminal in , +# labelled for this home and task. Echoes "
". +fm_backend_herdr_core_create_task() { # [session] + local label=$1 cwd=$2 session=${3:-} id home out addr + [ -n "$session" ] || session=$(fm_backend_herdr_core_session) + id=${label#fm-} + home=$(fm_backend_hometag) + if [ -n "$(fm_backend_herdr_core_cli "$session" ls --json --label "fm_home=$home" --label "fm_task=$id" 2>/dev/null \ + | jq -r '.terminals[]?.address' 2>/dev/null)" ]; then + echo "error: a herdr-core terminal for task $id already exists in session $session" >&2 + return 1 + fi + out=$(fm_backend_herdr_core_cli "$session" run --bg --json --no-parent --no-notify \ + --cwd "$cwd" --label "fm_home=$home" --label "fm_task=$id" 2>&1) || { + echo "error: hc run failed for $label: $out" >&2 + return 1 + } + addr=$(printf '%s' "$out" | jq -r '.address // empty' 2>/dev/null) + case "$addr" in + ''|*:*|*/*) + echo "error: hc run did not return a terminal address for $label: $out" >&2 + return 1 + ;; + esac + printf '%s %s' "$session" "$addr" +} + +# fm_backend_herdr_core_get: read the target's facts into +# FM_BACKEND_HERDR_CORE_JSON after proving ownership. Returns 0 owned, 3 when +# the address is gone or now belongs to something else, 1 when unreadable. +fm_backend_herdr_core_get() { # [expected-label] + local expected_label=${2:-} out rc=0 want_task home + FM_BACKEND_HERDR_CORE_JSON= + fm_backend_herdr_core_parse_target "$1" || return 1 + out=$(fm_backend_herdr_core_cli "$FM_BACKEND_HERDR_CORE_SESSION" get "$FM_BACKEND_HERDR_CORE_ADDRESS" --json 2>/dev/null) || rc=$? + [ "$rc" -ne 3 ] || return 3 + [ "$rc" -eq 0 ] || return 1 + home=$(fm_backend_hometag) + want_task= + [ -z "$expected_label" ] || want_task=${expected_label#fm-} + printf '%s' "$out" | jq -e --arg home "$home" --arg task "$want_task" ' + (.labels.fm_home // "") == $home + and ((.labels.fm_task // "") | length > 0) + and ($task == "" or .labels.fm_task == $task)' >/dev/null 2>&1 || { + printf '%s' "$out" | jq -e '.address' >/dev/null 2>&1 || return 1 + return 3 + } + FM_BACKEND_HERDR_CORE_JSON=$out + return 0 +} + +# fm_backend_herdr_core_target_ready: owned and still running. +fm_backend_herdr_core_target_ready() { # [expected-label] + fm_backend_herdr_core_get "$1" "${2:-}" || return 1 + [ "$(printf '%s' "$FM_BACKEND_HERDR_CORE_JSON" | jq -r '.state // empty' 2>/dev/null)" = live ] +} + +# fm_backend_herdr_core_current_path: the foreground process's live cwd +# (finding #3). A shell that has not run anything yet reports no foreground, +# and its cwd is still the creation cwd. +fm_backend_herdr_core_current_path() { # [expected-label] + fm_backend_herdr_core_target_ready "$1" "${2:-}" || return 0 + printf '%s' "$FM_BACKEND_HERDR_CORE_JSON" | jq -r '.foreground.cwd // .cwd // empty' 2>/dev/null +} + +# fm_backend_herdr_core_send_text_line: paste and press Enter (finding +# #1: one hc call does both). Refuses a terminal blocked at a prompt. +fm_backend_herdr_core_send_text_line() { # [expected-label] + fm_backend_herdr_core_target_ready "$1" "${3:-}" || return 1 + fm_backend_herdr_core_cli "$FM_BACKEND_HERDR_CORE_SESSION" send "$FM_BACKEND_HERDR_CORE_ADDRESS" "$2" --json >/dev/null 2>&1 +} + +fm_backend_herdr_core_normalize_key() { # + case "$1" in + Enter|enter) printf 'enter' ;; + Escape|escape|Esc|esc) printf 'esc' ;; + C-c|c-c|ctrl+c|Ctrl+c|Ctrl+C|ctrl-c) printf 'ctrl+c' ;; + C-u|c-u|ctrl+u|Ctrl+u|Ctrl+U|ctrl-u) printf 'ctrl+u' ;; + *) printf '%s' "$1" ;; + esac +} + +fm_backend_herdr_core_send_key() { # [expected-label] + local key + fm_backend_herdr_core_target_ready "$1" "${3:-}" || return 1 + key=$(fm_backend_herdr_core_normalize_key "$2") + fm_backend_herdr_core_cli "$FM_BACKEND_HERDR_CORE_SESSION" keys "$FM_BACKEND_HERDR_CORE_ADDRESS" "$key" --json >/dev/null 2>&1 +} + +# fm_backend_herdr_core_capture: bounded plain-text read, history included. +fm_backend_herdr_core_capture() { # [expected-label] + local lines=${2:-200} out + case "$lines" in ''|*[!0-9]*) lines=200 ;; esac + fm_backend_herdr_core_get "$1" "${3:-}" || return 1 + out=$(fm_backend_herdr_core_cli "$FM_BACKEND_HERDR_CORE_SESSION" read "$FM_BACKEND_HERDR_CORE_ADDRESS" --lines "$lines" 2>/dev/null) || return 1 + printf '%s\n' "$out" | tail -n "$lines" +} + +# fm_backend_herdr_core_visible_capture: the screen only, never history +# (finding #4: `hc read` without --lines reads zero history lines). +fm_backend_herdr_core_visible_capture() { # [expected-label] + fm_backend_herdr_core_get "$1" "${2:-}" || return 1 + fm_backend_herdr_core_cli "$FM_BACKEND_HERDR_CORE_SESSION" read "$FM_BACKEND_HERDR_CORE_ADDRESS" --lines 0 +} + +# Composer reads are plain text with no cursor or identity facts, like cmux; +# bin/fm-composer-lib.sh owns every shape. +fm_backend_herdr_core_composer_caps() { + printf 'styled=0\ncursor=0\nidentity=0\nrows=%s\n' "$FM_COMPOSER_CAPTURE_LINES" +} + +fm_backend_herdr_core_composer_state() { # [expected-label] -> empty|pending|pending-unproven|unknown + local cap verdict + cap=$(fm_backend_herdr_core_capture "$1" "$FM_COMPOSER_CAPTURE_LINES" "${2:-}") || { printf 'unknown'; return 0; } + verdict=$(fm_composer_classify_screen "$(fm_backend_herdr_core_composer_caps)" "$cap") + [ "$verdict" != need-identity ] || verdict=unknown + printf '%s' "$verdict" +} + +# fm_backend_herdr_core_send_text_submit: one `hc send` (paste plus Enter), +# then Enter-only retries while the composer still provably holds the text. +# The shared fm_composer_submit_retry_core is not used because it presses +# Enter first, which here would land on an already-submitted turn. Echoes +# empty|pending|pending-unproven|unknown|send-failed. +fm_backend_herdr_core_send_text_submit() { # [expected-label] + local target=$1 text=$2 retries=$3 sleep_s=$4 settle=$5 expected_label=${6:-} i=0 state + fm_backend_herdr_core_send_text_line "$target" "$text" "$expected_label" || { printf 'send-failed'; return 0; } + sleep "$settle" + while :; do + state=$(fm_backend_herdr_core_composer_state "$target" "$expected_label") + if fm_composer_blocking_dialog_noted >/dev/null; then + printf 'unknown' + return 0 + fi + case "$state" in + pending|pending-unproven) ;; + *) printf '%s' "$state"; return 0 ;; + esac + [ "$i" -lt "$retries" ] || break + i=$((i + 1)) + fm_backend_herdr_core_send_key "$target" Enter "$expected_label" || true + sleep "$sleep_s" + done + fm_composer_queued_enter_verdict "$state" "$(fm_backend_herdr_core_busy_state "$target")" +} + +# fm_backend_herdr_core_foreground_class: agent|shell|other for the owned +# terminal in FM_BACKEND_HERDR_CORE_JSON. The foreground process is what hc +# reports; before the shell first runs anything it reports none, and the +# terminal's own program (the shell) stands in. +fm_backend_herdr_core_foreground_class() { + local fields name argv0 args pid + fields=$(printf '%s' "$FM_BACKEND_HERDR_CORE_JSON" | jq -r ' + (.foreground // {name: null, argv: .argv, pid: .pid}) as $f + | [($f.name // ($f.argv[0] // "" | split("/") | last)), ($f.argv[0] // ""), + (($f.argv // []) | join(" ")), (($f.pid // "") | tostring)] | @tsv' 2>/dev/null) || { printf 'other'; return 0; } + IFS=$'\t' read -r name argv0 args pid < + local rc=0 state + fm_backend_herdr_core_get "$1" || rc=$? + case "$rc" in + 0) ;; + 3) printf 'missing'; return 0 ;; + *) printf 'unreadable'; return 0 ;; + esac + state=$(printf '%s' "$FM_BACKEND_HERDR_CORE_JSON" | jq -r '.state // empty' 2>/dev/null) + case "$state" in + finished) printf 'dead'; return 0 ;; + live) ;; + *) printf 'unreadable'; return 0 ;; + esac + case "$(fm_backend_herdr_core_foreground_class)" in + agent) printf 'alive' ;; + shell) printf 'dead' ;; + *) printf 'ambiguous' ;; + esac +} + +# fm_backend_herdr_core_busy_state: hc's native status (reported by the program +# itself, never guessed from the screen). `hc get` reports it as an object +# ({"app": "claude-code", "state": "working"}) while `hc ls` flattens it to the +# state string, so both shapes are read. `working` is trusted as busy only +# while a harness process is proven in the foreground. +fm_backend_herdr_core_busy_state() { # + local status + fm_backend_herdr_core_target_ready "$1" || { printf 'unknown'; return 0; } + status=$(printf '%s' "$FM_BACKEND_HERDR_CORE_JSON" \ + | jq -r '.status | if type == "object" then .state else . end // empty' 2>/dev/null) + case "$status" in + working) + if [ "$(fm_backend_herdr_core_foreground_class)" = agent ]; then + printf 'busy' + else + printf 'unknown' + fi + ;; + idle|done) printf 'idle' ;; + *) printf 'unknown' ;; + esac +} + +# fm_backend_herdr_core_kill: end and remove the task's terminal. A target that +# is gone, or whose address no longer carries this home's labels, is a silent +# success and is never touched. Nonzero only when an owned terminal provably +# survives or could not be read after the close. +fm_backend_herdr_core_kill() { # [unused] [expected-label] + local target=$1 expected_label=${3:-} rc=0 + fm_backend_herdr_core_get "$target" "$expected_label" || rc=$? + case "$rc" in + 0) ;; + 3) return 0 ;; + *) echo "error: herdr-core terminal $target could not be read, so it was not closed" >&2; return 1 ;; + esac + fm_backend_herdr_core_cli "$FM_BACKEND_HERDR_CORE_SESSION" kill "$FM_BACKEND_HERDR_CORE_ADDRESS" --json >/dev/null 2>&1 || true + fm_backend_herdr_core_cli "$FM_BACKEND_HERDR_CORE_SESSION" rm "$FM_BACKEND_HERDR_CORE_ADDRESS" --json >/dev/null 2>&1 || true + rc=0 + fm_backend_herdr_core_cli "$FM_BACKEND_HERDR_CORE_SESSION" get "$FM_BACKEND_HERDR_CORE_ADDRESS" --json >/dev/null 2>&1 || rc=$? + [ "$rc" -eq 3 ] && return 0 + echo "error: herdr-core terminal $target is still present after its close" >&2 + return 1 +} + +# fm_backend_herdr_core_list_live: recorded-inventory discovery, scoped by the +# fm_home label to this home only. One ":
\tfm-" line per +# live task terminal. Read-only; an unreachable core lists nothing. +fm_backend_herdr_core_list_live() { # [session] + local session=${1:-} home addr task + [ -n "$session" ] || session=$(fm_backend_herdr_core_session) + home=$(fm_backend_hometag) + fm_backend_herdr_core_cli "$session" ls --json --label "fm_home=$home" 2>/dev/null \ + | jq -r '.terminals[]?.address // empty' 2>/dev/null \ + | while IFS= read -r addr; do + task=$(fm_backend_herdr_core_cli "$session" get "$addr" --json 2>/dev/null \ + | jq -r --arg home "$home" 'select(.labels.fm_home == $home) | .labels.fm_task // empty' 2>/dev/null) + [ -n "$task" ] && printf '%s:%s\tfm-%s\n' "$session" "$addr" "$task" + done + return 0 +} diff --git a/bin/fm-backend.sh b/bin/fm-backend.sh index 916e3d2e353..a1ddcd4e0fc 100644 --- a/bin/fm-backend.sh +++ b/bin/fm-backend.sh @@ -34,7 +34,7 @@ # 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 herdr, zellij, orca, or cmux, carries an +# spawn-capable backend, currently herdr, zellij, orca, cmux, or herdr-core, carries an # explicit `backend=` line. # # Event-source framing (herdr-addendum "Events as the core abstraction"): a @@ -66,9 +66,11 @@ 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). +# herdr-core is EXPERIMENTAL and spawn-capable, session-provider-only - verified +# against the real hc 0.1.13 binary (docs/herdr-core-backend.md). # 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 herdr-core" +FM_BACKEND_SPAWN="tmux herdr zellij orca cmux herdr-core" # fm_backend_list_contains: whitespace-delimited membership without relying on # shell word splitting. fm-backend.sh is normally sourced by bash scripts, but @@ -133,9 +135,17 @@ fm_backend_is_known() { # # scrubbed entirely (no bundle id to inherit); NOT usable from inside # tmux, where the tmux server reparents to launchd and the chain never # reaches cmux - which is fine, because $TMUX already won there. +# herdr-core injects HC_TERMINAL_ID (plus HC_TERMINAL_SOCKET) into every +# terminal its core runs, and its terminals inherit the CORE's environment, not +# the caller's. A core auto-started from inside a herdr pane therefore carries +# HERDR_ENV=1 into every hc terminal, while herdr started inside an hc terminal +# also carries both markers. When both are present the first herdr or +# herdr-core process in this process's ancestry is the innermost layer and wins +# (docs/herdr-core-backend.md "Runtime detection"); an inconclusive walk keeps +# herdr's established precedence. Like cmux, herdr-core is checked after tmux. # Callers needing the winning signal read FM_BACKEND_DETECT_SIGNAL (set to -# TMUX, HERDR_ENV, CMUX_WORKSPACE_ID, bundle-id, or ancestry) and -# FM_BACKEND_DETECTED after a direct (non-command-substitution) call. +# TMUX, HERDR_ENV, HC_TERMINAL_ID, CMUX_WORKSPACE_ID, bundle-id, or ancestry) +# and FM_BACKEND_DETECTED after a direct (non-command-substitution) call. FM_BACKEND_CMUX_BUNDLE_ID="com.cmuxterm.app" fm_backend_detect() { @@ -147,6 +157,13 @@ fm_backend_detect() { printf 'tmux' return 0 fi + if [ -n "${HC_TERMINAL_ID:-}" ] \ + && { [ "${HERDR_ENV:-}" != "1" ] || [ "$(fm_backend_detect_herdr_innermost)" = herdr-core ]; }; then + FM_BACKEND_DETECTED=herdr-core + FM_BACKEND_DETECT_SIGNAL=HC_TERMINAL_ID + printf 'herdr-core' + return 0 + fi if [ "${HERDR_ENV:-}" = "1" ]; then FM_BACKEND_DETECTED=herdr FM_BACKEND_DETECT_SIGNAL=HERDR_ENV @@ -167,6 +184,26 @@ fm_backend_detect() { return 1 } +# fm_backend_detect_herdr_innermost: herdr or herdr-core, whichever process +# comes first in this process's ancestry, or nothing when neither is found. +fm_backend_detect_herdr_innermost() { + local pid=$$ ppid comm hops=0 + while [ "$hops" -lt 32 ]; do + comm=$(ps -o comm= -p "$pid" 2>/dev/null) || return 0 + comm="${comm#"${comm%%[![:space:]]*}"}" + comm="${comm%"${comm##*[![:space:]]}"}" + case "${comm##*/}" in + herdr-core) printf 'herdr-core'; return 0 ;; + herdr) printf 'herdr'; return 0 ;; + esac + ppid=$(ps -o ppid= -p "$pid" 2>/dev/null | tr -d '[:space:]') + case "$ppid" in ''|*[!0-9]*) return 0 ;; esac + [ "$ppid" -gt 1 ] || return 0 + pid=$ppid + hops=$((hops + 1)) + done +} + # fm_backend_detect_cmux_fallback: the two macOS-only cmux fallback signals # (see fm_backend_detect's header comment). Sets FM_BACKEND_DETECT_SIGNAL to # bundle-id or ancestry on success. Cheap-first: the bundle-id check is a pure @@ -235,8 +272,9 @@ fm_backend_detect_cmux_app_is_ancestor() { # per-task `--backend` flag is parsed by the caller (fm-spawn.sh) and takes # precedence over this resolution entirely; it is not read here. Auto-detect # fires only when nothing was explicitly configured, so an explicit setting -# always wins. Auto-detected herdr stays silent like tmux. Selecting cmux via -# auto-detect prints one loud stderr notice because cmux remains experimental; +# always wins. Auto-detected herdr stays silent like tmux. Selecting cmux or +# herdr-core via auto-detect prints one loud stderr notice because both remain +# experimental; # the notice names the winning signal, so a fallback-detected cmux (bundle id or # ancestry, after the claude wrapper stripped CMUX_WORKSPACE_ID) is visibly # distinct from the primary-marker case. @@ -267,6 +305,9 @@ fm_backend_name() { esac echo "NOTICE: auto-detected cmux runtime ($marker) - spawning into the EXPERIMENTAL cmux backend. Set config/backend or pass --backend tmux to opt out." >&2 fi + if [ "$detected" = herdr-core ]; then + echo "NOTICE: auto-detected herdr-core runtime (HC_TERMINAL_ID) - spawning into the EXPERIMENTAL herdr-core backend. Set config/backend or pass --backend tmux to opt out." >&2 + fi printf '%s' "$detected" return 0 fi @@ -296,12 +337,13 @@ fm_backend_validate_spawn() { # # docs/configuration.md "Toolchain" and bootstrap's COMMON list). This is the # single owner of the per-backend dependency delta, so bootstrap follows the # RESOLVED backend instead of demanding an inactive backend's tools. Each set is: -# - the session-provider CLI itself (tmux/herdr/zellij/orca/cmux); -# - jq, for the JSON-emitting adapters (herdr, zellij, cmux) whose spawn/liveness +# - the session-provider CLI itself (tmux/herdr/zellij/orca/cmux, and hc for +# herdr-core); +# - jq, for the JSON-emitting adapters (herdr, zellij, cmux, herdr-core) whose spawn/liveness # paths parse the backend's JSON output (see each adapter's # tool check, e.g. fm_backend_herdr_tool_check); # - the treehouse worktree provider for every session-provider-only backend -# (tmux, herdr, zellij, cmux); orca owns its own task worktree and terminal, +# (tmux, herdr, zellij, cmux, herdr-core); orca owns its own task worktree and terminal, # so it drops both treehouse and any other backend's session CLI. # Prints a single space-separated line and returns 0 for a known backend; returns # 1 and prints nothing for an unknown backend. @@ -311,6 +353,7 @@ fm_backend_required_tools() { # herdr) printf '%s' 'herdr jq treehouse' ;; zellij) printf '%s' 'zellij jq treehouse' ;; cmux) printf '%s' 'cmux jq treehouse' ;; + herdr-core) printf '%s' 'hc jq treehouse' ;; orca) printf '%s' 'orca' ;; *) return 1 ;; esac @@ -406,7 +449,7 @@ fm_backend_orca_worktree_id_valid() { # fm_backend_validate_task_endpoint() { # local meta=$1 id=$2 backend_count backend window worktree project binding_count binding - local session pane recorded_session workspace tab terminal worktree_id surface + local session pane recorded_session workspace tab terminal worktree_id surface address FM_BACKEND_VALIDATED_BACKEND= FM_BACKEND_VALIDATED_TARGET= [ -f "$meta" ] && [ ! -L "$meta" ] || { @@ -544,6 +587,20 @@ fm_backend_validate_task_endpoint() { # return 1 fi ;; + herdr-core) + [ "$binding" = "$id" ] || { + echo "REFUSED: herdr-core endpoint metadata for task $id lacks an exact task binding; preserving task state." >&2 + return 1 + } + recorded_session=$(fm_backend_meta_exact_value "$meta" herdr_core_session) || recorded_session= + address=$(fm_backend_meta_exact_value "$meta" herdr_core_terminal) || address= + if [ -z "$recorded_session" ] || [ -z "$address" ] || [ "$window" != "$recorded_session:$address" ] \ + || ! fm_backend_endpoint_atom_valid "$recorded_session" \ + || ! fm_backend_endpoint_atom_valid "$address"; then + echo "REFUSED: herdr-core endpoint metadata for task $id is malformed or inconsistent; preserving task state." >&2 + return 1 + fi + ;; esac # shellcheck disable=SC2034 # Output globals are consumed by sourcing callers. FM_BACKEND_VALIDATED_BACKEND=$backend @@ -643,6 +700,9 @@ fm_backend_source() { # cmux) set -- fm-backend-hometag-lib.sh fm-composer-lib.sh ;; + herdr-core) + set -- fm-backend-hometag-lib.sh fm-composer-lib.sh fm-agent-process-lib.sh fm-session-lock-lib.sh fm-gemini-lib.sh + ;; *) return 1 ;; @@ -688,6 +748,13 @@ fm_backend_source() { # _FM_BACKEND_CMUX_SOURCED=1 fi ;; + herdr-core) + if [ -z "${_FM_BACKEND_HERDR_CORE_SOURCED:-}" ]; then + # shellcheck source=/dev/null + . "$adapter" || return 1 + _FM_BACKEND_HERDR_CORE_SOURCED=1 + fi + ;; esac } @@ -759,6 +826,7 @@ fm_backend_capture() { # [expected-label] zellij) fm_backend_zellij_capture "$@" ;; orca) fm_backend_orca_capture "$@" ;; cmux) fm_backend_cmux_capture "$@" ;; + herdr-core) fm_backend_herdr_core_capture "$@" ;; *) echo "error: no capture implementation for backend '$backend'" >&2; return 1 ;; esac } @@ -769,8 +837,9 @@ fm_backend_capture() { # [expected-label] # pending live verification: its `read-screen` without `--scrollback` plausibly # reads only the viewport, but that has not been observed on a real cmux, and # the adapter's own capture opts into history with `--scrollback`. orca's -# `terminal read --limit` is a history read with no viewport mode. -FM_BACKEND_VISIBLE_CAPTURE="tmux herdr zellij" +# `terminal read --limit` is a history read with no viewport mode. herdr-core's +# `hc read --lines 0` reads the screen with zero history lines. +FM_BACKEND_VISIBLE_CAPTURE="tmux herdr zellij herdr-core" # fm_backend_visible_capture_supported: whether can read the visible # viewport WITHOUT scrollback. Callers that must not mistake a scrolled-away @@ -790,7 +859,7 @@ fm_backend_visible_capture() { # [expected-label] return 1 } fm_backend_source "$backend" || return 1 - "fm_backend_${backend}_visible_capture" "$@" + "fm_backend_${backend//-/_}_visible_capture" "$@" } # fm_backend_send_key: one backend-supported named special key. @@ -804,6 +873,7 @@ fm_backend_send_key() { # [expected-label] zellij) fm_backend_zellij_send_key "$@" ;; orca) fm_backend_orca_send_key "$@" ;; cmux) fm_backend_cmux_send_key "$@" ;; + herdr-core) fm_backend_herdr_core_send_key "$@" ;; *) echo "error: no send-key implementation for backend '$backend'" >&2; return 1 ;; esac } @@ -844,6 +914,7 @@ fm_backend_send_text_submit() { # &2; rc=1 ;; esac fm_composer_dialog_sink_release @@ -873,6 +944,7 @@ fm_backend_kill() { # zellij) fm_backend_zellij_kill "$@" ;; orca) fm_backend_orca_kill "$@" ;; cmux) fm_backend_cmux_kill "$@" ;; + herdr-core) fm_backend_herdr_core_kill "$@" ;; *) echo "error: no kill implementation for backend '$backend'" >&2; return 1 ;; esac } @@ -910,6 +982,7 @@ fm_backend_busy_state() { # fm_backend_source "$backend" || { printf 'unknown'; return 0; } case "$backend" in herdr) fm_backend_herdr_busy_state "$@" ;; + herdr-core) fm_backend_herdr_core_busy_state "$@" ;; *) printf 'unknown' ;; esac } @@ -936,6 +1009,7 @@ fm_backend_composer_state() { # [expected-label] -> empty|pe orca) fm_backend_orca_composer_state "$@" ;; cmux) fm_backend_cmux_composer_state "$@" ;; zellij) fm_backend_zellij_composer_state "$@" ;; + herdr-core) fm_backend_herdr_core_composer_state "$@" ;; *) printf 'unknown' ;; esac } @@ -985,6 +1059,10 @@ fm_backend_target_exists() { # [expected-label] fm_backend_source cmux || return 1 fm_backend_cmux_target_ready "$target" "$expected_label" ;; + herdr-core) + fm_backend_source herdr-core || return 1 + fm_backend_herdr_core_target_ready "$target" "$expected_label" + ;; *) return 1 ;; @@ -1008,15 +1086,19 @@ fm_backend_target_exists() { # [expected-label] # which verifies a registered agent against `pane process-info` and the real # process table, so a registration Herdr kept over a shell-only pane reads # `dead` here (issue #4115) - then maps a positively stopped session server to -# `missing` only in this recovery-grade view. Zellij remains unverified because +# `missing` only in this recovery-grade view. The herdr-core adapter classifies +# hc's reported foreground process through the same shared classifier and reads +# `missing` only when the recorded session's core reports this home's terminal +# absent. Zellij remains unverified because # its secondmate ghost-tab and agent-process recovery path has not been -# empirically validated. Orca and cmux do not support secondmate spawns. +# empirically validated. Orca, cmux, and herdr-core do not support secondmate spawns. fm_backend_agent_state() { # local backend=$1 target=$2 fm_backend_source "$backend" || { printf 'unverified'; return 0; } case "$backend" in tmux) fm_backend_tmux_agent_state "$target" ;; herdr) fm_backend_herdr_agent_state "$target" ;; + herdr-core) fm_backend_herdr_core_agent_state "$target" ;; *) printf 'unverified' ;; esac } diff --git a/bin/fm-busy-lib.sh b/bin/fm-busy-lib.sh index a9c715030ec..63af5be39b8 100755 --- a/bin/fm-busy-lib.sh +++ b/bin/fm-busy-lib.sh @@ -1082,10 +1082,10 @@ fm_busy_classify() { # [tail40] # for BUSY (streaming means a turn is running); native idle is narrower # than turn state (a long foreground tool call reads idle) and stays # unknown here. - if [ "$backend" = herdr ] && command -v fm_backend_busy_state >/dev/null 2>&1; then + if { [ "$backend" = herdr ] || [ "$backend" = herdr-core ]; } && command -v fm_backend_busy_state >/dev/null 2>&1; then native=$(fm_backend_busy_state "$backend" "$target" 2>/dev/null || true) if [ "$native" = busy ]; then - printf 'busy herdr-native' + printf 'busy %s-native' "$backend" return 0 fi fi diff --git a/bin/fm-control-lib.sh b/bin/fm-control-lib.sh index bf3d4319d48..74032160f4f 100644 --- a/bin/fm-control-lib.sh +++ b/bin/fm-control-lib.sh @@ -292,7 +292,7 @@ fm_control_relaunch_resume_flag() { # fm_control_backend_supports_key() { # local backend=${1-} key=${2-} case "$backend" in - tmux|herdr|zellij|cmux) + tmux|herdr|zellij|cmux|herdr-core) case "$key" in Escape|Enter|C-c|C-u) return 0 ;; esac ;; orca) @@ -302,14 +302,14 @@ fm_control_backend_supports_key() { # return 1 } -# Whether has a recovery-grade agent-state classifier. Only tmux and -# herdr implement fm_backend_agent_state; zellij, orca, and cmux report +# Whether has a recovery-grade agent-state classifier. Only tmux, +# herdr, and herdr-core implement fm_backend_agent_state; zellij, orca, and cmux report # `unverified`, so no reading of theirs can prove an agent stopped. The control # plane refuses a stop-proving verb there instead of reporting an unprovable # transition as success. fm_control_backend_state_verified() { # case "${1-}" in - tmux|herdr) return 0 ;; + tmux|herdr|herdr-core) return 0 ;; esac return 1 } @@ -344,6 +344,11 @@ fm_control_backend_state_verified() { # # passes `--session `, so the recheck starts and reads the session # the RECORD names, through that session's own socket. The answer is about # the task's endpoint and nothing else. +# herdr-core CAN, for the same reason: every hc call is pinned to the +# session the record names (HC_SESSION), and hc reaches that session's +# core through its own socket, starting it when it is down. A core that +# was not running took its non-kept terminals with it, so `missing` from +# that core means the terminal is gone. # tmux CANNOT. `list-windows -a` describes only the server the CURRENT # process addresses (its TMUX_TMPDIR/socket), and a task's record does not # carry the endpoint's socket identity - so a different but running server @@ -373,6 +378,14 @@ fm_control_endpoint_absence_verdict() { # *) printf 'unproven\tthe recorded herdr session'"'"'s server could not be started, or its pane could not be classified once it was running' ;; esac ;; + herdr-core) + case "$(fm_backend_herdr_core_agent_state "$target")" in + dead) printf 'dead\t' ;; + alive) printf 'alive\t' ;; + missing) printf 'gone\t' ;; + *) printf 'unproven\tthe recorded herdr-core session'"'"'s terminal could not be classified on a second read' ;; + esac + ;; *) printf 'unproven\tbackend %s has no recovery-grade classifier, so absence cannot be proven on it at all' "'$backend'" ;; diff --git a/bin/fm-crew-state.sh b/bin/fm-crew-state.sh index 4fbcbf7e0b5..7df164c0b6d 100755 --- a/bin/fm-crew-state.sh +++ b/bin/fm-crew-state.sh @@ -1298,19 +1298,19 @@ if ! pane_readable "$BACKEND_TARGET"; then # Backends with no classifier (orca, zellij, and cmux all report unverified) # keep their historical capture-failure-means-gone reading. case "$TASK_BACKEND" in - tmux|herdr) AGENT_STATE=$(fm_backend_agent_state "$TASK_BACKEND" "$BACKEND_TARGET") ;; + tmux|herdr|herdr-core) AGENT_STATE=$(fm_backend_agent_state "$TASK_BACKEND" "$BACKEND_TARGET") ;; *) AGENT_STATE=none ;; esac case "$TASK_BACKEND:$AGENT_STATE" in - tmux:alive|herdr:alive) + tmux:alive|herdr:alive|herdr-core:alive) ;; - tmux:missing|herdr:missing) + tmux:missing|herdr:missing|herdr-core:missing) emit unknown none "backend target gone: $BACKEND_TARGET" ;; - tmux:dead|herdr:dead) + tmux:dead|herdr:dead|herdr-core:dead) emit unknown none "backend target gone: $BACKEND_TARGET (agent gone, pane shell remains)" ;; - tmux:*|herdr:*) + tmux:*|herdr:*|herdr-core:*) emit unknown none "backend unreachable ($TASK_BACKEND endpoint state: $AGENT_STATE)" ;; *) diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index 76035320b07..a5d734d9eec 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -70,15 +70,16 @@ # positional, and batch pairs are all refused alongside it; only harness, # model, and effort may change, which is what makes a harness switch one # ordinary relaunch. It refuses unless the recorded endpoint is positively -# agent-free on a backend with a recovery-grade agent-state classifier (tmux -# or herdr), and clears the previous harness's per-task wiring before arming +# agent-free on a backend with a recovery-grade agent-state classifier (tmux, +# herdr, or herdr-core), and clears the previous harness's per-task wiring before arming # the new incarnation. Two verdicts are agent-free: a `dead` endpoint is # ADOPTED as-is, while an endpoint PROVEN gone is RE-CREATED in the recorded # worktree and the republished record rebinds the task to it. That proof is # its own step, because a backend's `missing` also covers an endpoint that is # merely unreachable from here - and it is only available on HERDR, which must # still read the recorded pane as gone once that session's server is running -# again. A tmux `missing` always refuses: a task record carries no socket +# again, and on herdr-core, whose every read is pinned to the recorded +# session's core. A tmux `missing` always refuses: a task record carries no socket # identity for its endpoint, so no read here can tell a destroyed window from # one on a tmux server this process cannot address. An endpoint that turns out # to have survived refuses too. The worktree is reused untouched either way; a @@ -106,19 +107,21 @@ # exact task only (docs/configuration.md "Runtime backend" owns when that flag # is authorized). Without it, the script resolves FM_BACKEND, then # config/backend, then runtime auto-detection from the runtime firstmate's -# environment: $TMUX, HERDR_ENV=1, or cmux runtime signals (via -# bin/fm-backend.sh's fm_backend_detect, with cmux fallback details in +# environment: $TMUX, HERDR_ENV=1, HC_TERMINAL_ID, or cmux runtime signals +# (via bin/fm-backend.sh's fm_backend_detect, with cmux fallback details in # docs/cmux-backend.md), # then tmux. # Spawn-capable backends are the reference tmux adapter, verified herdr -# adapter, and experimental zellij, orca, and cmux adapters. Orca owns both -# the task worktree and terminal, so ship/scout Orca spawns do not run -# treehouse get; cmux is a session provider only, exactly like herdr/zellij, -# so it does. Auto-detected herdr stays silent like tmux; auto-detected cmux -# prints a loud stderr notice; zellij and orca are never auto-detected. +# adapter, and experimental zellij, orca, cmux, and herdr-core adapters. Orca +# owns both the task worktree and terminal, so ship/scout Orca spawns do not +# run treehouse get; cmux and herdr-core are session providers only, exactly +# like herdr/zellij, so they do. Auto-detected herdr stays silent like tmux; +# auto-detected cmux and herdr-core print a loud stderr notice; zellij and +# orca are never auto-detected. # codex-app is not a known backend yet; docs/codex-app-backend.md owns that # blocked backend contract. Default tmux spawns do not write backend= to meta; -# absent backend= means tmux. cmux does not support --secondmate spawns yet. +# absent backend= means tmux. cmux and herdr-core do not support --secondmate +# spawns yet. # A backend spawn refusal (missing dependency, version gate, unauthenticated # socket, or unsupported secondmate mode) is terminal for that selected backend; # callers must surface it instead of silently retrying another backend. @@ -1950,6 +1953,10 @@ if [ "$RELAUNCH" -eq 0 ]; then echo "error: backend=cmux does not support --secondmate spawns yet" >&2 exit 1 fi + if [ "$BACKEND" = herdr-core ] && [ "$KIND" = secondmate ]; then + echo "error: backend=herdr-core does not support --secondmate spawns yet" >&2 + exit 1 + fi if [ "$BACKEND" = orca ]; then fm_backend_orca_runtime_check || exit 1 ORCA_LAUNCH_ENV_ENABLED=$(fm_config_source_present "$CONFIG/launch-env-tmux-session") || exit 1 @@ -2130,6 +2137,10 @@ if [ "$RELAUNCH" -eq 1 ]; then HERDR_TAB_ID=$(fm_meta_get "$RELAUNCH_META" herdr_tab_id) HERDR_PANE_ID=$(fm_meta_get "$RELAUNCH_META" herdr_pane_id) fi + if [ "$BACKEND" = herdr-core ]; then + HERDR_CORE_SES=$(fm_meta_get "$RELAUNCH_META" herdr_core_session) + HERDR_CORE_TERMINAL=$(fm_meta_get "$RELAUNCH_META" herdr_core_terminal) + fi # With no explicit harness, a relaunch reuses the harness already recorded # for this task. It must NOT fall through to the fresh-spawn config # resolution, which would silently move an existing task onto whatever the @@ -4043,6 +4054,18 @@ if [ "$RELAUNCH" -eq 1 ]; then T=$RELAUNCH_TARGET WT_TARGET=$T SES=${T%%:*} + elif [ "$BACKEND" = herdr-core ]; then + # The recorded terminal is proven gone from the recorded session's core + # (fm_control_endpoint_absence_verdict), so create ONE fresh terminal for + # the same task in that session, opened directly in the recorded worktree. + fm_backend_herdr_core_version_check || exit 1 + HERDR_CORE_TASK_IDS=$(fm_backend_herdr_core_create_task "$W" "$WT" "${RELAUNCH_TARGET%%:*}") || exit 1 + read -r HERDR_CORE_SES HERDR_CORE_TERMINAL <&2 + exit 1 + fi + T="$HERDR_CORE_SES:$HERDR_CORE_TERMINAL" + ;; orca) set +e ORCA_WT_RAW=$(fm_backend_orca_worktree_create "$PROJ_ABS" "$W") @@ -4400,6 +4436,7 @@ spawn_send_text_line() { # zellij) fm_backend_zellij_send_text_line "$1" "$2" "$W" ;; orca) fm_backend_orca_send_text_line "$1" "$2" ;; cmux) fm_backend_cmux_send_text_line "$1" "$2" "$W" ;; + herdr-core) fm_backend_herdr_core_send_text_line "$1" "$2" "$W" ;; esac } spawn_current_path() { # @@ -4408,6 +4445,7 @@ spawn_current_path() { # herdr) fm_backend_herdr_current_path "$1" ;; zellij) fm_backend_zellij_current_path "$1" "$W" ;; cmux) fm_backend_cmux_current_path "$1" "$W" ;; + herdr-core) fm_backend_herdr_core_current_path "$1" "$W" ;; esac } spawn_send_literal() { # @@ -4417,6 +4455,9 @@ spawn_send_literal() { # zellij) fm_backend_zellij_send_literal "$1" "$2" "$W" ;; orca) fm_backend_orca_send_literal "$1" "$2" ;; cmux) fm_backend_cmux_send_literal "$1" "$2" "$W" ;; + # herdr-core has no unsubmitted-text primitive: `hc send` pastes and + # submits in one call, so the launch's separate Enter is skipped below. + herdr-core) fm_backend_herdr_core_send_text_line "$1" "$2" "$W" ;; esac } spawn_send_key() { # @@ -4426,6 +4467,7 @@ spawn_send_key() { # zellij) fm_backend_zellij_send_key "$1" "$2" "$W" ;; orca) fm_backend_orca_send_key "$1" "$2" ;; cmux) fm_backend_cmux_send_key "$1" "$2" "$W" ;; + herdr-core) fm_backend_herdr_core_send_key "$1" "$2" "$W" ;; esac } @@ -5560,7 +5602,7 @@ SPAWN_META_PATH=$SPAWN_META_TMP preserve_relaunch_meta() { awk -F= ' BEGIN { - split("window endpoint_task_id worktree project harness kind mode yolo branch tasktmp base_branch upstream_consent forge backup_remote backup_prefix model effort account account_provider busy_gen spawn_gen traceparent backend herdr_session herdr_workspace_id herdr_tab_id herdr_pane_id zellij_session zellij_tab_id zellij_pane_id orca_worktree_id terminal cmux_workspace_id cmux_surface_id home projects control_relaunch_tx", keys, " ") + split("window endpoint_task_id worktree project harness kind mode yolo branch tasktmp base_branch upstream_consent forge backup_remote backup_prefix model effort account account_provider busy_gen spawn_gen traceparent backend herdr_session herdr_workspace_id herdr_tab_id herdr_pane_id zellij_session zellij_tab_id zellij_pane_id orca_worktree_id terminal cmux_workspace_id cmux_surface_id herdr_core_session herdr_core_terminal home projects control_relaunch_tx", keys, " ") for (i in keys) owned[keys[i]] = 1 } !($1 in owned) @@ -5615,6 +5657,10 @@ preserve_relaunch_meta() { echo "cmux_workspace_id=$CMUX_WORKSPACE_ID" echo "cmux_surface_id=$CMUX_SURFACE_ID" fi + if [ "$BACKEND" = herdr-core ]; then + echo "herdr_core_session=$HERDR_CORE_SES" + echo "herdr_core_terminal=$HERDR_CORE_TERMINAL" + fi if [ "$KIND" = secondmate ]; then echo "home=$PROJ_ABS" echo "projects=$SECONDMATE_PROJECTS" @@ -6142,7 +6188,7 @@ if [ "${HERDR_PROJECTED:-0}" -eq 1 ]; then HERDR_PROJECTION_ABORT_CLEANUP=0 spawn_herdr_presentation_order_lock_release fi -if ! spawn_send_key "$T" Enter; then +if [ "$BACKEND" != herdr-core ] && ! spawn_send_key "$T" Enter; then [ "$ORCA_LAUNCH_ENV_ENABLED" != 1 ] || spawn_orca_launch_send_fail exit 1 fi diff --git a/bin/fm-test-run.sh b/bin/fm-test-run.sh index 4b12a2a1b5f..c93be576ca3 100755 --- a/bin/fm-test-run.sh +++ b/bin/fm-test-run.sh @@ -385,6 +385,7 @@ family_for_basename() { printf '%s\n' live-harness-optin ;; fm-backend-herdr.test.sh|fm-backend-herdr-crew.test.sh|fm-backend-tmux-smoke.test.sh|fm-backend.test.sh|\ + fm-backend-herdr-core.test.sh|\ fm-tmux-agent-liveness.test.sh|\ fm-control.test.sh|fm-control-relaunch.test.sh|\ fm-herdr-session-cleanup.test.sh|fm-send-resolve-key.test.sh|fm-send-strict.test.sh|\ @@ -1444,6 +1445,9 @@ families_for_changed_path() { # through run_script_bounded, so it cannot regress fixture Git isolation. printf '%s\n' pure-contract-unit ;; + bin/backends/herdr-core.sh) + printf '%s\n' backend-dispatch + ;; bin/backends/herdr*|bin/fm-herdr-lab.sh|tests/herdr-test-safety.sh|tests/herdr-client-pair-fixture.sh) printf '%s\n' real-herdr-gated printf '%s\n' backend-dispatch diff --git a/docs/agent-control.md b/docs/agent-control.md index c5643ec9886..c5947fde64f 100644 --- a/docs/agent-control.md +++ b/docs/agent-control.md @@ -167,12 +167,12 @@ The worktree and the task's records are unaffected either way. Muse is a crewmate and scout adapter only, so relaunching a secondmate onto it refuses while its agent is still up rather than leaving that secondmate with no agent when the launch owner refuses. - A backend that cannot deliver the harness's interrupt key, or the composer clear that key needs, is refused rather than sent a different key. Orca's terminal API exposes only an interrupt and an Enter, so it can deliver neither Escape nor Ctrl+U. -- `exit` and `relaunch` require a backend with a recovery-grade agent-state classifier - tmux and herdr - because without one the "the agent stopped" postcondition cannot be proven. +- `exit` and `relaunch` require a backend with a recovery-grade agent-state classifier - tmux, herdr, and herdr-core - because without one the "the agent stopped" postcondition cannot be proven. zellij, orca, and cmux are refused rather than reported as successful blind. - An ambiguous or unreadable endpoint state refuses. Only a positively classified state acts. - `exit`'s composer-empty check, above, is itself a fail-closed boundary that `relaunch` inherits by stopping the old agent through `exit`. -- `fm-spawn --relaunch` independently refuses unless the endpoint is positively agent-free - either a `dead` endpoint that survives, or a Herdr endpoint proven gone by the absence proof above - so a replacement can never join a live agent. +- `fm-spawn --relaunch` independently refuses unless the endpoint is positively agent-free - either a `dead` endpoint that survives, or a Herdr or herdr-core endpoint proven gone by the absence proof above - so a replacement can never join a live agent. An `alive`, `ambiguous`, or `unreadable` verdict all refuse, and so does any endpoint whose absence is not provable, which on tmux is every `missing`; absence is claimed only from positive evidence of it. It also requires the shell to be in the recorded worktree: every backend but Orca (which owns its own task worktree with no current-path probe) gets one explicit `cd` to the recorded path, then a pre-launch path read that refuses before any harness starts unless it confirms the endpoint is sitting in the recorded copy. @@ -184,6 +184,7 @@ Backend capability comes from each adapter's real surface, not from a policy cho | --- | --- | --- | --- | --- | --- | | tmux | yes | yes | yes | yes | yes | | herdr | yes | yes | yes | yes | yes | +| herdr-core | yes | yes | yes | yes | yes | | zellij | yes | yes | yes | yes | no | | cmux | yes | yes | yes | yes | no | | orca | no | yes | yes | no | no | diff --git a/docs/architecture.md b/docs/architecture.md index 7a54c4b1f1f..562eb4dfb18 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -267,10 +267,11 @@ Unknown backend names fail loudly. For compatibility, default tmux tasks do not write `backend=tmux`; every reader treats a missing `backend=` field as `tmux`. `fm-watch.sh` decides each window's busy state through the semantic contract above rather than by polling the backend for rendered text. Herdr's native `agent.get` verdict still participates, but only as evidence of activity: a native `busy` is accepted when the task has no record of its own, while a native `idle` is not, because `agent.get` reports generation state and reads idle while a worker blocks on its own long-running foreground tool call. +herdr-core's own reported status is accepted the same way, as busy only while a harness is in the foreground. tmux, zellij, orca, and cmux expose no native busy primitive at all, so a task on those backends is classified purely from its adapter's own lifecycle record. That poll loop is still the default event source for backends with no native push events, so this stays an extraction of the abstraction rather than a watcher rewrite. For capable Herdr sessions, the same watcher replaces its terminal sleep with a bounded native event wait that immediately surfaces `blocked`; [Push events and polling fallback](herdr-backend.md#push-events-and-polling-fallback) owns the current mechanism and capability gates, while [runtime backend verification](verification/runtime-backends.md#native-blocked-event) owns the active evidence. -The deeper agent-process liveness probe is separate from that busy-state poll and is shared by the session-start sweep and the watcher's liveness tick through `bin/fm-secondmate-liveness-lib.sh`: tmux and Herdr have verified classifiers for secondmate recovery, Zellij remains unverified, and Orca and cmux do not support secondmate spawns. +The deeper agent-process liveness probe is separate from that busy-state poll and is shared by the session-start sweep and the watcher's liveness tick through `bin/fm-secondmate-liveness-lib.sh`: tmux and Herdr have verified classifiers for secondmate recovery, Zellij remains unverified, and Orca, cmux, and herdr-core do not support secondmate spawns. Herdr can be selected explicitly or by runtime auto-detection: Treehouse remains its worktree provider, [`herdr-backend.md`](herdr-backend.md) owns current setup, CI coverage, and safety limits, and [`verification/runtime-backends.md`](verification/runtime-backends.md#herdr) owns active empirical evidence. Herdr ordinarily uses one tab per task; [Watching and task containers](herdr-backend.md#watching-and-task-containers) owns launcher-bound workspace placement, the label-only fallback, and recovery scope. Its optional visual layouts may place a clean new task in a disposable workspace or a crew pane without changing endpoint authority or lifecycle ownership; [Presentation spaces](herdr-backend.md#presentation-spaces) and [Crew workspace](herdr-backend.md#crew-workspace) own their conditional designs, limits, and cleanup behavior. @@ -280,11 +281,12 @@ Orca is experimental and selected only explicitly: Orca owns both worktree and t [`orca-backend.md`](orca-backend.md) owns current behavior and limitations, while [`verification/runtime-backends.md`](verification/runtime-backends.md#orca) owns active smoke evidence. cmux is experimental, GUI-first, macOS-only, and can be selected explicitly or by runtime auto-detection from its primary `CMUX_WORKSPACE_ID` marker plus documented fallback signals: Treehouse remains its worktree provider, [`cmux-backend.md`](cmux-backend.md) owns current setup and limits, and [`verification/runtime-backends.md`](verification/runtime-backends.md#cmux) owns active source and live evidence. cmux's container shape is one workspace per task with one surface, no per-home container split; workspace titles are scoped by the active home label plus a short hash of the resolved `FM_ROOT` path, and `--secondmate` spawns are refused, mirroring Orca. +herdr-core is experimental and can be selected explicitly or by runtime auto-detection from `HC_TERMINAL_ID`: Treehouse remains its worktree provider, each task is one labelled terminal in the recorded `hc` session, [`herdr-core-backend.md`](herdr-core-backend.md) owns current setup and limits, and [`verification/runtime-backends.md`](verification/runtime-backends.md#herdr-core) owns active live evidence. Codex App support is recorded in `docs/codex-app-backend.md`; it is not selectable as a runtime backend. ## Worktrees, not branches in your checkout -Crewmates never intentionally touch your project clone; [treehouse](https://github.com/kunchenguid/treehouse) pools clean worktrees for tmux, herdr, zellij, and cmux tasks, while Orca creates its own worktrees for `backend=orca`. +Crewmates never intentionally touch your project clone; [treehouse](https://github.com/kunchenguid/treehouse) pools clean worktrees for tmux, herdr, zellij, cmux, and herdr-core tasks, while Orca creates its own worktrees for `backend=orca`. The [`fm-spawn.sh` header](../bin/fm-spawn.sh) owns ship/scout worktree isolation and fresh-base refusal rules, including spawns from linked homes. Portable regressions live in [`tests/fm-spawn-pool-base-freshen.test.sh`](../tests/fm-spawn-pool-base-freshen.test.sh) for spawn isolation and base freshness, and [`tests/fm-control-relaunch.test.sh`](../tests/fm-control-relaunch.test.sh) for preserving the recorded copy on relaunch. diff --git a/docs/configuration.md b/docs/configuration.md index 64d69328348..f0176bde274 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -65,6 +65,7 @@ Other transient consumers retain their existing shell-relative behavior. | herdr | `FM_HOME` determines the adapter's workspace label. | | zellij | `FM_HOME` determines the readable home prefix in visible tab titles, but does not split containers; use `FM_ZELLIJ_SESSION` for a separate session; the full home label also includes a short hash of the resolved `FM_ROOT` path. | | cmux | `FM_HOME` determines the default config path and readable home prefix in workspace titles; `FM_CONFIG_OVERRIDE` overrides where `config/cmux-socket-password` is read; the full home label also includes a short hash of the resolved `FM_ROOT` path; there is no per-home container split. | +| herdr-core | `FM_HOME` and the resolved `FM_ROOT` path determine the `fm_home=` label every task terminal carries; there is no per-home container split, and `HC_SESSION` selects the core a new task lands in. | ## Operational home layout and state @@ -421,8 +422,9 @@ For spawn-capable adapters, the runtime session-provider backend controls where | `zellij` | Experimental; no dedicated real-backend CI lane | [`docs/zellij-backend.md`](zellij-backend.md) | | `orca` | Experimental; no dedicated real-backend CI lane | [`docs/orca-backend.md`](orca-backend.md) | | `cmux` | Experimental; no dedicated real-backend CI lane | [`docs/cmux-backend.md`](cmux-backend.md) | +| `herdr-core` | Experimental; no dedicated real-backend CI lane | [`docs/herdr-core-backend.md`](herdr-core-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. +Treehouse remains the worktree provider for tmux, herdr, zellij, cmux, and herdr-core, since herdr, zellij, cmux, and herdr-core are session providers only; Orca provides both the task worktree and terminal endpoint. ### Backend selection order @@ -432,19 +434,20 @@ New spawns choose the backend in this order: A later task cannot inherit that authority by analogy. 2. `FM_BACKEND`. 3. The first non-empty line of local, gitignored `config/backend`. -4. Runtime auto-detection from `$TMUX`, `HERDR_ENV=1`, or cmux runtime signals. +4. Runtime auto-detection from `$TMUX`, `HERDR_ENV=1`, `HC_TERMINAL_ID`, or cmux runtime signals. 5. 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-detection) for why cmux can be selected when `CMUX_WORKSPACE_ID` is absent. +herdr-core's `HC_TERMINAL_ID` is checked after `$TMUX`; when `HERDR_ENV=1` is also present, the nearer of herdr and herdr-core in the process ancestry wins, as [`docs/herdr-core-backend.md`](herdr-core-backend.md#runtime-detection) explains. -Auto-detected Herdr stays silent like tmux, while auto-detected cmux prints a stderr notice naming `config/backend` and `--backend tmux` because cmux remains experimental. +Auto-detected Herdr stays silent like tmux, while auto-detected cmux and herdr-core print a stderr notice naming `config/backend` and `--backend tmux` because both remain experimental. Zellij and Orca are never auto-detected; select them by putting the name in a local `config/backend` file, by exporting `FM_BACKEND=`, or by telling the first mate in chat. ### Accepted backends and secondmate limits -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. +Any value other than `tmux`, `herdr`, `zellij`, `orca`, `cmux`, or `herdr-core` is rejected until another adapter is implemented and verified. +`fm-spawn.sh` accepts `tmux`, `herdr`, `zellij`, `orca`, `cmux`, and `herdr-core` for ship and scout tasks; `backend=orca`, `backend=cmux`, and `backend=herdr-core` still refuse `--secondmate` until secondmate launch semantics are designed for each. `codex-app` is not an accepted runtime backend yet; [`docs/codex-app-backend.md`](codex-app-backend.md) owns the Codex App boundary. @@ -463,6 +466,8 @@ The compatibility helper `fm_backend_agent_alive` continues to collapse those de - 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. +- A herdr-core spawn additionally version-gates against the installed `hc` binary (0.1.13 or newer) and requires `jq`, refusing loudly when either is missing or older. + A backend spawn refusal from a missing dependency, version gate, or unauthenticated socket is terminal for that selected backend; firstmate surfaces it as a blocker instead of silently retrying another backend. ### Task metadata @@ -478,6 +483,8 @@ Task meta records `backend=` only for a non-default backend; an absent `backend= - A cmux task additionally records `cmux_workspace_id=` and `cmux_surface_id=`. +- A herdr-core task additionally records `herdr_core_session=` and `herdr_core_terminal=`, with `window=:`. + ### Task selectors Task selectors for `fm-peek.sh`, `fm-send.sh`, and `fm-crew-state.sh` resolve centrally through `fm_backend_resolve_selector`. @@ -825,7 +832,7 @@ devin is verified for crewmate and scout launches only; a secondmate is refused Its private worker config disables Claude Code imports (including the captain's hooks) and, unless the home sets `config/keep-ai-trailers` (see "Commit attribution"), Devin commit attribution without editing user or project config; [`fm-devin-config.sh`](../bin/fm-devin-config.sh) owns these enforced settings and [Devin verification](verification/devin.md) owns the live evidence and observed model availability. commandcode (Command Code) is verified for crewmate and scout launches only; a secondmate is refused because Command Code has no verified primary supervision protocol. -It runs only on the tmux and Herdr backends; a zellij, cmux, or orca launch is refused because those backends supply no Command Code pane identity, without which its idle composer placeholder never reads empty and steering stalls. +It runs only on the tmux and Herdr backends; a zellij, cmux, orca, or herdr-core launch is refused because those backends supply no Command Code pane identity, without which its idle composer placeholder never reads empty and steering stalls. Its busy-state wiring is a tracked mod loaded per process with `--mod`, so no user or project Command Code settings are edited; [its adapter reference](../.agents/skills/harness-adapters/references/harness/commandcode.md) owns the launch facts and [Command Code verification](verification/commandcode.md) owns the live evidence. ### Verification and primary supervision @@ -1439,15 +1446,16 @@ The per-backend delta is required only for the backend resolved from `FM_BACKEND | `zellij` | `zellij`, `jq`, `treehouse` | | `orca` | `orca` | | `cmux` | `cmux`, `jq`, `treehouse` | +| `herdr-core` | `hc`, `jq`, `treehouse` | -The JSON-emitting adapters (`herdr`, `zellij`, `cmux`) need `jq` because their spawn and liveness paths parse backend JSON. -Every session-provider-only backend (`tmux`, `herdr`, `zellij`, `cmux`) uses `treehouse` for worktrees. +The JSON-emitting adapters (`herdr`, `zellij`, `cmux`, `herdr-core`) need `jq` because their spawn and liveness paths parse backend JSON. +Every session-provider-only backend (`tmux`, `herdr`, `zellij`, `cmux`, `herdr-core`) uses `treehouse` for worktrees. Backend tool availability uses the adapter's own executable resolver, so bootstrap and spawn agree on supported non-`PATH` locations such as cmux's bundled CLI. An unknown resolved backend emits `BACKEND_INVALID` and blocks dispatch instead of silently dropping its dependency delta or falling back to tmux. Orca provides both the task worktree and terminal endpoint (see "Runtime backend" above), so `backend=orca` requires only `orca` on top of the universal toolchain and skips both `treehouse` and every other backend's session CLI. -A herdr, zellij, or cmux home is therefore never told `tmux` is missing, and the `treehouse` durable-lease upgrade check runs only for the backends that actually use treehouse. +A herdr, zellij, cmux, or herdr-core home is therefore never told `tmux` is missing, and the `treehouse` durable-lease upgrade check runs only for the backends that actually use treehouse. **Feature-specific requirements** @@ -2471,7 +2479,7 @@ FM_DATA_OVERRIDE= # alternate data dir, mainly for tests FM_PROJECTS_OVERRIDE= # alternate projects dir, mainly for tests FM_CONFIG_OVERRIDE= # alternate config dir, mainly for tests FM_PROC_ROOT_OVERRIDE= # alternate /proc root for Linux process-identity reads in fm-wake-lib.sh and fm-teardown.sh, mainly for tests -FM_BACKEND= # optional runtime backend override for new spawns; tmux/herdr/zellij/orca/cmux support ship/scout spawns, codex-app is not accepted +FM_BACKEND= # optional runtime backend override for new spawns; tmux/herdr/zellij/orca/cmux/herdr-core support ship/scout spawns, codex-app is not accepted FM_TRACE_CONTEXT= # optional trace-context override; see "Trace context propagation" FM_TASK_ID= # internal task-worker marker fm-spawn.sh exports into ship and scout panes, never set by hand; bin/fm-test-run.sh refuses to execute in the repository primary checkout while it is set FM_TASK_INBOX= # internal: absolute path of the task's steering inbox (state/.inbox) that fm-spawn.sh exports into every ship, scout, and secondmate launch, never set by hand; the steering doorbell names "$FM_TASK_INBOX" diff --git a/docs/documentation-audiences.json b/docs/documentation-audiences.json index 5913225302a..1e6c888bdfc 100644 --- a/docs/documentation-audiences.json +++ b/docs/documentation-audiences.json @@ -31,6 +31,7 @@ "docs/zellij-backend.md", "docs/orca-backend.md", "docs/cmux-backend.md", + "docs/herdr-core-backend.md", "docs/remote-secondmates.md" ], "requiredOwnerPointers": [ @@ -98,6 +99,10 @@ "source": "docs/cmux-backend.md", "target": "docs/verification/runtime-backends.md" }, + { + "source": "docs/herdr-core-backend.md", + "target": "docs/verification/runtime-backends.md" + }, { "source": "docs/codex-app-backend.md", "target": "docs/verification/runtime-backends.md" @@ -400,6 +405,10 @@ "path": "docs/herdr-backend.md", "audience": "operator-current" }, + { + "path": "docs/herdr-core-backend.md", + "audience": "operator-current" + }, { "path": "docs/jev-guards.md", "audience": "maintainer-architecture" diff --git a/docs/herdr-core-backend.md b/docs/herdr-core-backend.md new file mode 100644 index 00000000000..a8a4ecc4327 --- /dev/null +++ b/docs/herdr-core-backend.md @@ -0,0 +1,66 @@ +# herdr-core runtime backend + +herdr-core is an experimental runtime backend for the herdr-core platform, whose CLI is `hc`. +It is a different product from Herdr ([`herdr-backend.md`](herdr-backend.md)): it has no workspaces, tabs, or panes, and every program runs in a flat, persistent terminal addressed as `t1`, `t2`, and so on inside one per-user core. +Treehouse continues to provide git worktrees. +[`configuration.md`](configuration.md#runtime-backend-configbackend--fm_backend) owns shared selection and metadata semantics. + +## Setup + +Pick herdr-core when Firstmate itself runs in a herdr-core terminal and you want its workers there too. + +Prerequisites: + +- herdr-core 0.1.13 or newer, with `hc` on `PATH`; `hc update` upgrades it. +- `jq` for JSON responses. +- The universal harness and toolchain requirements in [`configuration.md`](configuration.md#toolchain). + +Select herdr-core with local `config/backend` containing `herdr-core`, `FM_BACKEND=herdr-core` for one launch, or an explicit request to Firstmate. +It can also be runtime auto-detected when Firstmate runs inside an hc terminal; see [Runtime detection](#runtime-detection). +A spawn stops with an actionable message when `hc` is missing or older than 0.1.13, or `jq` is missing. + +Verify setup by spawning a small task and confirming metadata contains `backend=herdr-core`, `herdr_core_session=`, and `herdr_core_terminal=`, and that `hc ls` lists the new terminal. + +## Runtime detection + +herdr-core puts `HC_TERMINAL_ID` and `HC_TERMINAL_SOCKET` into every terminal its core runs, and `HC_TERMINAL_ID` is the detection marker. +Detection checks tmux first, so a tmux session started inside an hc terminal stays on tmux. + +hc terminals inherit the core's environment, not the environment of the `hc` command that created them. +A core that was itself started from inside a Herdr pane therefore carries `HERDR_ENV=1` into every hc terminal, and Herdr started inside an hc terminal carries both markers too. +When both `HC_TERMINAL_ID` and `HERDR_ENV=1` are present, the first `herdr` or `herdr-core` process in Firstmate's own process ancestry is the innermost layer and wins. +When the ancestry reaches neither, Herdr keeps its established precedence. + +An auto-detected herdr-core prints a stderr notice naming `config/backend` and `--backend tmux`, because the backend is experimental. + +## How tasks run + +Each task is one hc terminal running the user's login shell, started in the project directory and then moved into the task worktree, exactly like a tmux window. +The terminal is created with `--no-parent` and `--no-notify`, so it does not end when the terminal that spawned it ends and hc types no notes into Firstmate's own terminal. + +Ownership is proven by labels, never by address alone. +Every task terminal carries `fm_home=` and `fm_task=`, where the home label is the readable home prefix plus a short hash of the resolved `FM_ROOT` path, shared with zellij and cmux. +A terminal whose labels do not name this home, and the task when the caller knows it, is never read, typed into, killed, or removed, and reads as `missing`. +`hc ls --label fm_home=` lists only this home's task terminals. + +The recorded target is `:
`, for example `default:t7`. +hc picks its core from `HC_SESSION`, defaulting to `default`, and addresses are per core, so every call is pinned to the recorded session. +A new task lands in the session the spawning Firstmate addresses. + +Routine supervision uses `bin/fm-peek.sh ` and `FM_HOME= bin/fm-send.sh ''`. +Interrupt, exit, and relaunch run through `bin/fm-control.sh`, because herdr-core has a recovery-grade agent-state classifier: it classifies the foreground process hc reports through the same shared classifier tmux and Herdr use, reads a finished terminal or a shell in the foreground as agent-free, and reads `missing` only when the recorded session's core reports this home's terminal absent. +A relaunch adopts an agent-free terminal, or creates one fresh terminal in the recorded session and worktree when the old one is proven gone. +Busy state comes from hc's own status, which the program reports itself; `working` counts as busy only while a harness process is in the foreground. + +## Limits + +- herdr-core has no way to type text without submitting it: `hc send` pastes and presses Enter in one call, and `hc keys` takes only named keys. + A submit is therefore one `hc send` followed by Enter-only retries while the composer still holds the text, and the spawn sends its launch line the same way. +- Composer reads are plain text with no cursor or identity facts, so the shared composer classifier runs in its plain-text mode, as it does for cmux. +- `--secondmate` spawns are refused until secondmate launch semantics are designed for this backend. +- There is no native event push, so the watcher uses its ordinary poll loop. +- hc starts a session's core on first use, so a passive liveness read of a recorded session whose core is stopped starts an empty core. + A core that was stopped took its non-kept terminals with it, which is why a `missing` answer from that session is treated as proof the terminal is gone. +- The away-mode sub-supervisor still injects only into tmux or Herdr supervisor panes. + +[`verification/runtime-backends.md`](verification/runtime-backends.md#herdr-core) owns the live evidence behind these facts, and `tests/fm-backend-herdr-core.test.sh` pins the adapter against a fake `hc`. diff --git a/docs/remote-secondmates.md b/docs/remote-secondmates.md index ee60a26903b..cba1e02909f 100644 --- a/docs/remote-secondmates.md +++ b/docs/remote-secondmates.md @@ -437,7 +437,7 @@ All remote secondmates on one host share `fm-remote` and retain separate `2ndmat Launch, liveness recovery, control, and retirement refuse it until an operator explicitly migrates it, instead of attempting a live cutover. - A launch after a host has drifted out of readiness fails with the doctor's own gap text instead of leaving a half-created endpoint. - Raw launch commands are not accepted for remote secondmates. -- Backends that already refuse secondmate launch, currently Orca and cmux, remain unsupported on the remote host. +- Backends that already refuse secondmate launch, currently Orca, cmux, and herdr-core, remain unsupported on the remote host. ### Liveness recovery diff --git a/docs/verification/runtime-backends.md b/docs/verification/runtime-backends.md index df1bb3a9299..a9cd3180e70 100644 --- a/docs/verification/runtime-backends.md +++ b/docs/verification/runtime-backends.md @@ -2145,6 +2145,66 @@ FM_CMUX_CLAUDE_COMPOSER_LIVE=1 bin/fm-test-run.sh tests/fm-cmux-claude-composer- That guard still addresses the worker by task selector, so it no longer reaches the typed submit path and is not a current refresh entry point for this guarantee. The portable classifier regression is `tests/fm-backend-cmux.test.sh`. +## herdr-core + +The current compatibility floor is herdr-core 0.1.13, and the active live evidence uses hc 0.1.13 on Linux x86_64 with Claude Code driven at the Haiku model, recorded on 2026-10-29. +The live pass ran entirely in its own core, started with a scrubbed environment under `HC_SESSION=fmhc-live`, and touched no terminal it did not create; the operator's `default` core kept the same process and terminal list throughout. + +```sh +hc --version +HC_SESSION=fmhc-live hc server status --json +``` + +Observed version: + +```text +hc 0.1.13 +``` + +CLI findings behind the adapter: + +| Guarantee | Command shape | Result | +| --- | --- | --- | +| Environment marker | `env` inside an hc terminal | `HC_TERMINAL_ID=t3` and `HC_TERMINAL_SOCKET=/run/user/1000/herdr-core/default.sock`; no `HERDR_*` from the `hc` caller | +| Core environment inheritance | a terminal in a core auto-started from a Herdr pane | `HERDR_ENV=1` present beside `HC_TERMINAL_ID=t1` and `HC_SESSION=fmhcprobe` | +| Session pinning | `HC_TERMINAL_SOCKET=/nonexistent hc ls` | the `default` core still answered; `HC_SESSION` selects `$XDG_RUNTIME_DIR/herdr-core/.sock` | +| Live cwd | `hc get` while `bash -c "cd /usr && exec sleep 5"` ran after `cd /home` | `foreground.cwd` was `/usr`, then `/home` once the shell was idle; top-level `cwd` stayed the creation directory | +| No unsubmitted text | `hc keys 'echo hi'`, then `hc keys . "'" / - _` | `unknown key "echo hi"`, then `unknown key "_"` (exit 6) | +| Viewport read | `hc read --json` | `"how":"screen"`; trailing blank rows trimmed | +| Status shape | `hc get --json` against Claude Code | `"status":{"app":"claude-code","state":"working"}`, while `hc ls` flattens it to `"status":"done"` | +| Close | `hc kill `, `hc rm `, `hc get ` | `"state":"finished","reason":"killed"`, `{"removed":"t5"}`, then `not_found` with exit 3 | + +The end-to-end pass drove the real scripts against a scratch home and project: + +```sh +bin/fm-spawn.sh hclive --scout --harness claude --model haiku --effort low --backend herdr-core +bin/fm-send.sh hclive 'Live steer check: reply with the single word PINEAPPLE in chat, then wait.' +bin/fm-peek.sh hclive 6 +bin/fm-control.sh hclive interrupt +bin/fm-control.sh hclive exit +bin/fm-control.sh hclive relaunch --note '' +bin/fm-teardown.sh hclive +``` + +Observed results: + +```text +spawned hclive harness=claude kind=scout window=fmhc-live:t1 worktree=/home/curtis/.treehouse/proj-a01c60/1/proj +interrupt-delivered hclive harness=claude backend=herdr-core verified=agent-alive cancel=unconfirmed +stopped hclive harness=claude backend=herdr-core endpoint=fmhc-live:t1 worktree=/home/curtis/.treehouse/proj-a01c60/1/proj +relaunched hclive harness=claude from=claude model=haiku effort=low backend=herdr-core endpoint=fmhc-live:t1 worktree=/home/curtis/.treehouse/proj-a01c60/1/proj +relaunched hclive harness=claude from=claude model=haiku effort=low backend=herdr-core endpoint=fmhc-live:t3 worktree=/home/curtis/.treehouse/proj-a01c60/1/proj +teardown hclive complete (window fmhc-live:t3, worktree /home/curtis/.treehouse/proj-a01c60/1/proj) +``` + +The steer was sent by `fm-send.sh` running inside another hc terminal of the same core, as a Firstmate inside herdr-core would; the doorbell arrived as a plain line with no `` wrapper, the worker answered `PINEAPPLE`, and it moved the inbox message to `handled/`. +During a running turn the adapter read `agent=alive busy=busy`; after the interrupt Claude rendered `Interrupted` and hc reported `"state":"idle"`. +After `exit` the adapter read `agent=dead` with `zsh` in the foreground in the task worktree. +The first relaunch adopted that terminal; the second followed a deliberate `hc kill` and `hc rm` of it, read `missing`, and re-created the task as `t3` in the recorded worktree. +Teardown removed `t3` through the adapter's close. + +`tests/fm-backend-herdr-core.test.sh` is the portable regression for these behaviors; repeat the end-to-end pass above after an hc upgrade to refresh this record. + ## Codex App host tools A reusable Desktop host-tool smoke ran on 2026-07-06 against Codex Desktop bundle version 26.623.101652, build 4674, bundle id `com.openai.codex`. diff --git a/tests/fm-backend-herdr-core.test.sh b/tests/fm-backend-herdr-core.test.sh new file mode 100755 index 00000000000..7996e614778 --- /dev/null +++ b/tests/fm-backend-herdr-core.test.sh @@ -0,0 +1,344 @@ +#!/usr/bin/env bash +# tests/fm-backend-herdr-core.test.sh - fake-hc unit tests for the herdr-core +# session-provider adapter (bin/backends/herdr-core.sh), verified against the +# real hc 0.1.13 binary (docs/herdr-core-backend.md). The fake `hc` keeps a +# small JSON terminal table, answers with the real CLI's JSON shapes and exit +# codes (3 not_found), and logs every call with the HC_SESSION it was pinned to; +# jq is real, as it is a required tool for this backend. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +command -v jq >/dev/null 2>&1 || { echo "skip: jq not found (required by the herdr-core adapter)"; exit 0; } +command -v python3 >/dev/null 2>&1 || { echo "skip: python3 not found (runs the fake hc)"; exit 0; } + +TMP_ROOT=$(fm_test_tmproot fm-backend-herdr-core-tests) +FAKEBIN=$(fm_fakebin "$TMP_ROOT") +cat > "$FAKEBIN/hc" <<'PY' +#!/usr/bin/env python3 +import json, os, sys +state_path = os.environ["FM_HC_STATE"] +with open(os.environ["FM_HC_LOG"], "a") as log: + log.write("HC_SESSION=%s\x1f%s\n" % (os.environ.get("HC_SESSION", ""), "\x1f".join(sys.argv[1:]))) +args = [a for a in sys.argv[1:] if a != "--json"] +if args[:1] == ["--version"]: + print("hc " + os.environ.get("FM_HC_FAKE_VERSION", "0.1.13")); sys.exit(0) +with open(state_path) as f: + st = json.load(f) +terms = st.setdefault("terminals", {}) +def save(): + with open(state_path, "w") as f: + json.dump(st, f) +def opt(name, default=None): + return args[args.index(name) + 1] if name in args else default +def find(addr): + t = terms.get(addr) + if t is None: + print(json.dumps({"error": {"kind": "not_found"}})); sys.exit(3) + return t +cmd = args[0] +if os.environ.get("FM_HC_FAKE_FAIL") == cmd: + sys.exit(1) +if cmd == "run": + labels = dict(a.split("=", 1) for i, a in enumerate(args) if i and args[i - 1] == "--label") + st["next"] = st.get("next", 1) + 1 + addr = "t%d" % st["next"] + terms[addr] = {"address": addr, "argv": ["/usr/bin/zsh"], "cwd": opt("--cwd"), "pid": 4242, + "foreground": None, "labels": labels, "state": "live", "status": None, "screen": ""} + save(); print(json.dumps({"address": addr, "state": "live"})) +elif cmd == "ls": + want = [a.split("=", 1) for i, a in enumerate(args) if i and args[i - 1] == "--label"] + out = [{"address": a} for a, t in terms.items() + if t["state"] == "live" and all(t["labels"].get(k) == v for k, v in want)] + print(json.dumps({"terminals": out})) +elif cmd == "get": + print(json.dumps(find(args[1]))) +elif cmd == "read": + print(find(args[1])["screen"]) +elif cmd in ("send", "keys"): + t = find(args[1]) + t.setdefault("typed", []).append([cmd] + args[2:]) + after = t.get("after_" + cmd) + if after: + t["screen"] = after.pop(0) + save(); print(json.dumps({"turn": 0})) +elif cmd == "kill": + t = find(args[1]) + if not t.get("survives"): + t["state"] = "finished" + save(); print(json.dumps(t)) +elif cmd == "rm": + t = find(args[1]) + if t["state"] == "finished": + del terms[args[1]] + save(); print(json.dumps({"removed": args[1]})) +PY +chmod +x "$FAKEBIN/hc" +export PATH="$FAKEBIN:$PATH" +export FM_HC_STATE="$TMP_ROOT/hc-state.json" +export FM_HC_LOG="$TMP_ROOT/hc.log" + +HOME_DIR="$TMP_ROOT/home" +mkdir -p "$HOME_DIR/state" +export FM_HOME="$HOME_DIR" +unset FM_ROOT_OVERRIDE HC_SESSION HC_TERMINAL_ID HERDR_ENV TMUX CMUX_WORKSPACE_ID FM_BACKEND +# shellcheck source=/dev/null +. "$ROOT/bin/fm-backend.sh" +fm_backend_source herdr-core || fail "the herdr-core adapter did not source" +HOMETAG=$(fm_backend_hometag) + +reset_hc() { + printf '{"next": 1, "terminals": {}}' > "$FM_HC_STATE" + : > "$FM_HC_LOG" +} + +# set_terminal
: edit one fake terminal in place. +set_terminal() { + local tmp="$FM_HC_STATE.tmp" + jq --arg a "$1" ".terminals[\$a] |= ($2)" "$FM_HC_STATE" > "$tmp" && mv "$tmp" "$FM_HC_STATE" +} + +add_foreign_terminal() { #
+ local tmp="$FM_HC_STATE.tmp" + jq --arg a "$1" '.terminals[$a] = {address: $a, argv: ["claude"], cwd: "/", pid: 1, labels: {}, + foreground: {name: "claude", argv: ["claude"], pid: 1, cwd: "/"}, state: "live", status: "working", screen: "captain"}' \ + "$FM_HC_STATE" > "$tmp" && mv "$tmp" "$FM_HC_STATE" +} + +typed() { #
+ jq -c --arg a "$1" '.terminals[$a].typed // []' "$FM_HC_STATE" +} + +new_task() { # -> echoes target + local ids + ids=$(fm_backend_herdr_core_create_task "fm-$1" "$TMP_ROOT") || return 1 + printf '%s:%s' "${ids% *}" "${ids#* }" +} + +test_version_gate() { + FM_HC_FAKE_VERSION=0.1.13 fm_backend_herdr_core_version_check || fail "0.1.13 must pass the version gate" + FM_HC_FAKE_VERSION=0.2.0 fm_backend_herdr_core_version_check || fail "0.2.0 must pass the version gate" + FM_HC_FAKE_VERSION=0.1.12 fm_backend_herdr_core_version_check 2>/dev/null && fail "0.1.12 must be refused" + FM_HC_FAKE_VERSION=garbage fm_backend_herdr_core_version_check 2>/dev/null && fail "an unparseable version must be refused" + local out + out=$(PATH=/usr/bin:/bin fm_backend_herdr_core_version_check 2>&1) && fail "a missing hc must be refused" + assert_contains "$out" "'hc' CLI is not installed" "a missing hc must name the CLI" + pass "version gate: accepts >= 0.1.13, refuses older, unparseable, and missing hc" +} + +test_create_task_labels_and_detaches() { + reset_hc + local target log + target=$(HC_SESSION=lab new_task t1) || fail "create_task failed" + assert_equals "lab:t2" "$target" "create_task must return
in the ambient session" + log=$(grep $'\x1frun\x1f' "$FM_HC_LOG") + assert_contains "$log" "HC_SESSION=lab" "run must be pinned to the session" + assert_contains "$log" $'\x1f--no-parent\x1f--no-notify\x1f' "task terminals must not die with, or type notes into, the spawning terminal" + assert_contains "$log" $'\x1f--cwd\x1f'"$TMP_ROOT"$'\x1f' "the terminal must start in the given cwd" + assert_contains "$log" "fm_home=$HOMETAG" "the terminal must carry this home's label" + assert_contains "$log" "fm_task=t1" "the terminal must carry its task label" + HC_SESSION=lab new_task t1 2>/dev/null && fail "a second live terminal for the same task must be refused" + pass "create_task: detached, labelled, session-pinned, and refuses a duplicate" +} + +test_foreign_terminal_is_never_touched() { + reset_hc + add_foreign_terminal t1 + assert_equals missing "$(fm_backend_herdr_core_agent_state default:t1)" "an unlabelled terminal must read missing" + fm_backend_herdr_core_send_key default:t1 C-c && fail "send_key must refuse an unowned terminal" + fm_backend_herdr_core_send_text_line default:t1 hello && fail "send must refuse an unowned terminal" + fm_backend_herdr_core_capture default:t1 10 >/dev/null && fail "capture must refuse an unowned terminal" + fm_backend_herdr_core_kill default:t1 || fail "kill of an unowned address is a silent success" + assert_equals "[]" "$(typed t1)" "nothing may be typed into an unowned terminal" + assert_no_grep $'\x1fkill\x1f' "$FM_HC_LOG" "an unowned terminal must never be killed" + assert_no_grep $'\x1frm\x1f' "$FM_HC_LOG" "an unowned terminal must never be removed" + assert_equals live "$(jq -r '.terminals.t1.state' "$FM_HC_STATE")" "the unowned terminal must survive" + pass "ownership: a terminal without this home's labels is never read, typed into, killed, or removed" +} + +test_expected_label_binds_the_task() { + reset_hc + local target + target=$(new_task t1) + fm_backend_herdr_core_target_ready "$target" fm-t1 || fail "the owning task label must be accepted" + fm_backend_herdr_core_target_ready "$target" fm-other && fail "another task's label must be refused" + pass "target_ready: an expected label must match the terminal's task label" +} + +test_agent_state() { + reset_hc + local target + target=$(new_task t1) + assert_equals dead "$(fm_backend_herdr_core_agent_state "$target")" "a fresh shell terminal is agent-free" + set_terminal t2 '.foreground = {name: "zsh", argv: ["/usr/bin/zsh"], pid: 4242, cwd: "/"}' + assert_equals dead "$(fm_backend_herdr_core_agent_state "$target")" "a shell in the foreground is agent-free" + set_terminal t2 '.foreground = {name: "2.1.300", argv: ["claude", "--settings", "x"], pid: 4243, cwd: "/"}' + assert_equals alive "$(fm_backend_herdr_core_agent_state "$target")" "a harness argv[0] is a live agent" + set_terminal t2 '.foreground = {name: "sleep", argv: ["sleep", "5"], pid: 4244, cwd: "/"}' + assert_equals ambiguous "$(fm_backend_herdr_core_agent_state "$target")" "an unknown program is ambiguous" + set_terminal t2 '.state = "finished" | .foreground = null' + assert_equals dead "$(fm_backend_herdr_core_agent_state "$target")" "a finished terminal holds no agent" + assert_equals unreadable "$(FM_HC_FAKE_FAIL='get' fm_backend_herdr_core_agent_state "$target")" "a failed read is unreadable" + assert_equals missing "$(fm_backend_herdr_core_agent_state default:t9)" "an absent address is missing" + assert_equals missing "$(fm_backend_agent_state herdr-core default:t9)" "dispatch reaches the adapter" + pass "agent_state: alive, dead, ambiguous, unreadable, and missing from hc's foreground facts" +} + +test_busy_state() { + reset_hc + local target + target=$(new_task t1) + set_terminal t2 '.status = {app: "claude-code", state: "working"} | .foreground = {name: "claude", argv: ["claude"], pid: 1, cwd: "/"}' + assert_equals busy "$(fm_backend_busy_state herdr-core "$target")" "working with a harness in front is busy" + set_terminal t2 '.foreground = {name: "zsh", argv: ["zsh"], pid: 1, cwd: "/"}' + assert_equals unknown "$(fm_backend_busy_state herdr-core "$target")" "a lingering working status over a shell is not busy" + set_terminal t2 '.status = "done"' + assert_equals idle "$(fm_backend_busy_state herdr-core "$target")" "done is idle" + set_terminal t2 '.status = null' + assert_equals unknown "$(fm_backend_busy_state herdr-core "$target")" "no reported status is unknown" + pass "busy_state: native working is busy only with a harness in the foreground" +} + +test_capture_and_current_path() { + reset_hc + local target + target=$(new_task t1) + set_terminal t2 '.screen = "one\ntwo\nthree"' + assert_equals $'two\nthree' "$(fm_backend_capture herdr-core "$target" 2 fm-t1)" "capture must keep the last N lines" + assert_equals $'one\ntwo\nthree' "$(fm_backend_visible_capture herdr-core "$target" fm-t1)" "visible capture reads the screen" + assert_grep $'\x1fread\x1ft2\x1f--lines\x1f0' "$FM_HC_LOG" "visible capture must ask for zero history lines" + assert_equals "$TMP_ROOT" "$(fm_backend_herdr_core_current_path "$target")" "a shell that ran nothing reports its creation cwd" + set_terminal t2 '.foreground = {name: "bash", argv: ["bash"], pid: 1, cwd: "/srv/wt"}' + assert_equals /srv/wt "$(fm_backend_herdr_core_current_path "$target")" "current path follows the foreground cwd" + pass "capture, visible capture, and current path" +} + +# Claude-shaped composer frames, JSON-escaped for set_terminal. +EMPTY_BOX=' ╭────────────────────────╮\n │ ❯ │\n ╰──────── Composer ──────╯' +HELD_BOX=' ╭────────────────────────╮\n │ ❯ hello │\n ╰──────── Composer ──────╯\n\n Enter:send' + +test_send_text_submit() { + reset_hc + local target verdict + target=$(new_task t1) + set_terminal t2 ".screen = \"$EMPTY_BOX\" | .after_send = [\"$EMPTY_BOX\"]" + verdict=$(fm_backend_send_text_submit herdr-core "$target" "hello world" 2 0 0 fm-t1) + assert_equals empty "$verdict" "a landed send must verify empty" + assert_equals '[["send","hello world"]]' "$(typed t2)" "the text must be pasted and submitted once, with no extra Enter" + reset_hc + target=$(new_task t1) + set_terminal t2 ".screen = \"$EMPTY_BOX\" | .after_send = [\"$HELD_BOX\"] | .after_keys = [\"$HELD_BOX\", \"$EMPTY_BOX\"]" + verdict=$(fm_backend_send_text_submit herdr-core "$target" "hello" 3 0 0 fm-t1) + assert_equals empty "$verdict" "a swallowed Enter must be retried until the composer clears" + assert_equals '[["send","hello"],["keys","enter"],["keys","enter"]]' "$(typed t2)" "only Enter is retried, the text is never retyped" + pass "send_text_submit: one paste-and-submit, Enter-only retries" +} + +test_send_key_normalizes() { + reset_hc + local target + target=$(new_task t1) + fm_backend_send_key herdr-core "$target" Escape fm-t1 || fail "Escape must send" + fm_backend_send_key herdr-core "$target" C-c fm-t1 || fail "C-c must send" + fm_backend_send_key herdr-core "$target" C-u fm-t1 || fail "C-u must send" + assert_equals '[["keys","esc"],["keys","ctrl+c"],["keys","ctrl+u"]]' "$(typed t2)" "keys must use hc's names" + pass "send_key: Escape, C-c, and C-u map to hc key names" +} + +test_kill() { + reset_hc + local target + target=$(new_task t1) + fm_backend_kill herdr-core "$target" "" fm-t1 || fail "killing an owned terminal must succeed" + assert_equals null "$(jq -c '.terminals.t2' "$FM_HC_STATE")" "the owned terminal must be ended and removed" + fm_backend_kill herdr-core "$target" "" fm-t1 || fail "an already-gone terminal is a silent success" + target=$(new_task t1) + set_terminal t3 '.survives = true' + fm_backend_kill herdr-core "$target" "" fm-t1 2>/dev/null && fail "a terminal that survives its close must fail the kill" + pass "kill: ends and removes the owned terminal, quiet when gone, loud when it survives" +} + +test_list_live_is_home_scoped() { + reset_hc + local live + new_task t1 >/dev/null + add_foreign_terminal t7 + set_terminal t7 '.labels = {fm_home: "someone-else", fm_task: "x"}' + live=$(fm_backend_herdr_core_list_live) + assert_equals "default:t2"$'\t'"fm-t1" "$live" "list_live must report only this home's terminals" + pass "list_live: recorded-inventory discovery is scoped to this home's label" +} + +test_endpoint_validation_and_absence() { + local meta="$HOME_DIR/state/t1.meta" raw + printf 'window=default:t2\nendpoint_task_id=t1\nworktree=/w\nproject=/p\nbackend=herdr-core\nherdr_core_session=default\nherdr_core_terminal=t2\n' > "$meta" + fm_backend_validate_task_endpoint "$meta" t1 || fail "a consistent herdr-core record must validate" + assert_equals default:t2 "$FM_BACKEND_VALIDATED_TARGET" "the validated target is the recorded window" + printf 'window=default:t3\nendpoint_task_id=t1\nworktree=/w\nproject=/p\nbackend=herdr-core\nherdr_core_session=default\nherdr_core_terminal=t2\n' > "$meta" + fm_backend_validate_task_endpoint "$meta" t1 2>/dev/null && fail "a window that disagrees with the recorded terminal must be refused" + # shellcheck source=/dev/null + . "$ROOT/bin/fm-control-lib.sh" + fm_control_backend_state_verified herdr-core || fail "herdr-core must have a recovery-grade classifier" + fm_control_backend_supports_key herdr-core Escape || fail "herdr-core must deliver Escape" + reset_hc + raw=$(fm_control_endpoint_absence_verdict herdr-core default:t9) + assert_equals gone "${raw%%$'\t'*}" "a terminal the recorded session's core does not have is proven gone" + pass "endpoint record validation, control capabilities, and absence proof" +} + +# A fake `ps` answering this shell's ancestry as: this shell -> pid 500 +# (FM_FAKE_PS_500, the first multiplexer found) -> pid 600 (FM_FAKE_PS_600) -> init. +PS_FAKEBIN="$TMP_ROOT/ps-fakebin" +mkdir -p "$PS_FAKEBIN" +cat > "$PS_FAKEBIN/ps" <<'SH' +#!/usr/bin/env bash +field=$2 pid=$4 +case "$pid:$field" in + 500:comm=) printf '%s\n' "${FM_FAKE_PS_500:-bash}" ;; + 500:ppid=) printf '600\n' ;; + 600:comm=) printf '%s\n' "${FM_FAKE_PS_600:-bash}" ;; + 600:ppid=) printf '1\n' ;; + *:comm=) printf 'bash\n' ;; + *:ppid=) printf '500\n' ;; +esac +SH +chmod +x "$PS_FAKEBIN/ps" + +test_detection() { + assert_equals herdr-core "$(HC_TERMINAL_ID=t1 fm_backend_detect)" "HC_TERMINAL_ID selects herdr-core" + assert_equals tmux "$(TMUX=x HC_TERMINAL_ID=t1 fm_backend_detect)" "tmux nested inside herdr-core wins" + # Both markers: whichever multiplexer is the nearer ancestor is innermost. + assert_equals herdr-core "$(PATH="$PS_FAKEBIN:$PATH" FM_FAKE_PS_500=/x/herdr-core FM_FAKE_PS_600=herdr \ + HERDR_ENV=1 HC_TERMINAL_ID=t1 fm_backend_detect)" "a core started from a herdr pane is innermost" + assert_equals herdr "$(PATH="$PS_FAKEBIN:$PATH" FM_FAKE_PS_500=herdr FM_FAKE_PS_600=herdr-core \ + HERDR_ENV=1 HC_TERMINAL_ID=t1 fm_backend_detect)" "herdr started inside an hc terminal is innermost" + assert_equals herdr "$(PATH="$PS_FAKEBIN:$PATH" HERDR_ENV=1 HC_TERMINAL_ID=t1 fm_backend_detect)" \ + "an inconclusive ancestry keeps herdr's established precedence" + pass "detection: HC_TERMINAL_ID, innermost-first, with ancestry deciding when herdr's marker is also present" +} + +test_secondmate_spawn_refuses_herdr_core() { + local dir out + dir="$TMP_ROOT/secondmate-refuse" + mkdir -p "$dir/state" "$dir/data" "$dir/config" "$dir/projects" + out=$(FM_STATE_OVERRIDE="$dir/state" FM_DATA_OVERRIDE="$dir/data" FM_CONFIG_OVERRIDE="$dir/config" FM_PROJECTS_OVERRIDE="$dir/projects" \ + "$ROOT/bin/fm-spawn.sh" sm-hc-test --secondmate --backend herdr-core 2>&1) && fail "a herdr-core secondmate spawn must be refused" + assert_contains "$out" "does not support --secondmate" "the refusal must name the unsupported secondmate mode" + pass "fm-spawn.sh: refuses backend=herdr-core for --secondmate spawns" +} + +test_version_gate +test_create_task_labels_and_detaches +test_foreign_terminal_is_never_touched +test_expected_label_binds_the_task +test_agent_state +test_busy_state +test_capture_and_current_path +test_send_text_submit +test_send_key_normalizes +test_kill +test_list_live_is_home_scoped +test_endpoint_validation_and_absence +test_detection +test_secondmate_spawn_refuses_herdr_core From b6a26b3568666614273111a4d245da4cee9249a6 Mon Sep 17 00:00:00 2001 From: Curtis Kormos Date: Thu, 29 Oct 2026 15:36:52 -0600 Subject: [PATCH 2/6] docs(scripts): list the herdr-core backend adapter --- docs/scripts.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/scripts.md b/docs/scripts.md index 564813851d8..eab4d32ce0e 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -74,6 +74,7 @@ The shared no-mistakes gate lifecycle boundary is summarized in [architecture.md | `backends/zellij.sh` | Experimental zellij session-provider adapter | | `backends/orca.sh` | Experimental Orca backend adapter owning both worktree and terminal | | `backends/cmux.sh` | Experimental cmux session-provider adapter | +| `backends/herdr-core.sh` | Experimental herdr-core (`hc`) session-provider adapter | | `fm-config-push.sh` | Push declared inherited local material to live local or remote secondmates and send the placement-specific config reread when changed | | `fm-project-mode.sh` | Resolve a project's registered delivery posture, forge binding, or ship-branch prefix from `data/projects.md` for fleet sync, home seeding, and the forge agreement a ship spawn or scout promotion applies | | `fm-forge-detect.sh` | Propose a clone's forge binding from its origin remote for project-add intake, never recording it | From 5e0ff4c23e5fa6b44764827d8317b335212a7a71 Mon Sep 17 00:00:00 2001 From: Curtis Kormos Date: Thu, 29 Oct 2026 15:50:07 -0600 Subject: [PATCH 3/6] no-mistakes(review): Rebind finished herdr-core terminals safely --- bin/backends/herdr-core.sh | 10 ++++++++++ bin/fm-spawn.sh | 11 +++++++++++ tests/fm-backend-herdr-core.test.sh | 16 ++++++++++++++++ 3 files changed, 37 insertions(+) diff --git a/bin/backends/herdr-core.sh b/bin/backends/herdr-core.sh index 476649e1603..e021d9fabcf 100644 --- a/bin/backends/herdr-core.sh +++ b/bin/backends/herdr-core.sh @@ -354,6 +354,16 @@ fm_backend_herdr_core_kill() { # [unused] [expected-label] return 1 } +fm_backend_herdr_core_remove_finished() { # + local rc=0 state + fm_backend_herdr_core_get "$1" "$2" || return 1 + state=$(printf '%s' "$FM_BACKEND_HERDR_CORE_JSON" | jq -r '.state // empty' 2>/dev/null) + [ "$state" = finished ] || return 1 + fm_backend_herdr_core_cli "$FM_BACKEND_HERDR_CORE_SESSION" rm "$FM_BACKEND_HERDR_CORE_ADDRESS" --json >/dev/null 2>&1 || return 2 + fm_backend_herdr_core_get "$1" "$2" || rc=$? + [ "$rc" -eq 3 ] || return 2 +} + # fm_backend_herdr_core_list_live: recorded-inventory discovery, scoped by the # fm_home label to this home only. One ":
\tfm-" line per # live task terminal. Read-only; an unreachable core lists nothing. diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index a5d734d9eec..dc761413551 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -2029,6 +2029,17 @@ if [ "$RELAUNCH" -eq 1 ]; then RELAUNCH_TARGET=$FM_BACKEND_VALIDATED_TARGET fm_backend_validate_spawn "$BACKEND" || exit 1 fm_backend_source "$BACKEND" || exit 1 + if [ "$BACKEND" = herdr-core ]; then + fm_backend_herdr_core_remove_finished "$RELAUNCH_TARGET" "fm-$ID" + case $? in + 0) RELAUNCH_REBIND=1 ;; + 1) ;; + *) + echo "error: task $ID's finished herdr-core terminal could not be removed; refusing to relaunch" >&2 + exit 1 + ;; + esac + fi # A relaunch must PROVE the previous agent is gone before it launches another # one into the same endpoint, and only tmux and herdr have a recovery-grade # classifier that can (bin/fm-control-lib.sh owns that capability table). diff --git a/tests/fm-backend-herdr-core.test.sh b/tests/fm-backend-herdr-core.test.sh index 7996e614778..ae7652a6956 100755 --- a/tests/fm-backend-herdr-core.test.sh +++ b/tests/fm-backend-herdr-core.test.sh @@ -259,6 +259,21 @@ test_kill() { pass "kill: ends and removes the owned terminal, quiet when gone, loud when it survives" } +test_finished_terminal_rebind() { + reset_hc + local target replacement + target=$(HC_SESSION=lab new_task t1) + set_terminal t2 '.state = "finished" | .foreground = null' + fm_backend_herdr_core_remove_finished "$target" fm-t1 || fail "an owned finished terminal must be removable" + assert_equals null "$(jq -c '.terminals.t2' "$FM_HC_STATE")" "the finished terminal must be removed" + replacement=$(HC_SESSION=lab new_task t1) || fail "the replacement terminal must be created" + assert_equals lab:t3 "$replacement" "the replacement must use the recorded session" + assert_equals "$TMP_ROOT" "$(jq -r '.terminals.t3.cwd' "$FM_HC_STATE")" "the replacement must use the recorded worktree" + fm_backend_herdr_core_remove_finished "$replacement" fm-other && fail "a terminal for another task must not be removed" + assert_equals live "$(jq -r '.terminals.t3.state' "$FM_HC_STATE")" "a live terminal must remain for adoption" + pass "finished terminal rebind: removes only the owned exit, then replaces it in its session and worktree" +} + test_list_live_is_home_scoped() { reset_hc local live @@ -338,6 +353,7 @@ test_capture_and_current_path test_send_text_submit test_send_key_normalizes test_kill +test_finished_terminal_rebind test_list_live_is_home_scoped test_endpoint_validation_and_absence test_detection From ef5ac45f22931c0cf75f4a35ab747aa10d1ed121 Mon Sep 17 00:00:00 2001 From: Curtis Kormos Date: Thu, 29 Oct 2026 15:55:30 -0600 Subject: [PATCH 4/6] no-mistakes(review): Fix finished herdr-core relaunch lifecycle --- bin/fm-spawn.sh | 5 ++-- docs/herdr-core-backend.md | 3 ++- tests/fm-backend-herdr-core.test.sh | 38 ++++++++++++++++++++--------- 3 files changed, 32 insertions(+), 14 deletions(-) diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index dc761413551..8befaa0f523 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -2030,8 +2030,9 @@ if [ "$RELAUNCH" -eq 1 ]; then fm_backend_validate_spawn "$BACKEND" || exit 1 fm_backend_source "$BACKEND" || exit 1 if [ "$BACKEND" = herdr-core ]; then - fm_backend_herdr_core_remove_finished "$RELAUNCH_TARGET" "fm-$ID" - case $? in + RELAUNCH_FINISHED_RC=0 + fm_backend_herdr_core_remove_finished "$RELAUNCH_TARGET" "fm-$ID" || RELAUNCH_FINISHED_RC=$? + case "$RELAUNCH_FINISHED_RC" in 0) RELAUNCH_REBIND=1 ;; 1) ;; *) diff --git a/docs/herdr-core-backend.md b/docs/herdr-core-backend.md index a8a4ecc4327..a874edd0a06 100644 --- a/docs/herdr-core-backend.md +++ b/docs/herdr-core-backend.md @@ -49,7 +49,8 @@ A new task lands in the session the spawning Firstmate addresses. Routine supervision uses `bin/fm-peek.sh ` and `FM_HOME= bin/fm-send.sh ''`. Interrupt, exit, and relaunch run through `bin/fm-control.sh`, because herdr-core has a recovery-grade agent-state classifier: it classifies the foreground process hc reports through the same shared classifier tmux and Herdr use, reads a finished terminal or a shell in the foreground as agent-free, and reads `missing` only when the recorded session's core reports this home's terminal absent. -A relaunch adopts an agent-free terminal, or creates one fresh terminal in the recorded session and worktree when the old one is proven gone. +A relaunch adopts a live terminal whose shell is in the foreground. +A finished terminal is removed and replaced in its recorded session and worktree, while an absent terminal is replaced once its absence is proven. Busy state comes from hc's own status, which the program reports itself; `working` counts as busy only while a harness process is in the foreground. ## Limits diff --git a/tests/fm-backend-herdr-core.test.sh b/tests/fm-backend-herdr-core.test.sh index ae7652a6956..961b4787532 100755 --- a/tests/fm-backend-herdr-core.test.sh +++ b/tests/fm-backend-herdr-core.test.sh @@ -259,19 +259,35 @@ test_kill() { pass "kill: ends and removes the owned terminal, quiet when gone, loud when it survives" } -test_finished_terminal_rebind() { +test_finished_terminal_relaunch() { reset_hc - local target replacement - target=$(HC_SESSION=lab new_task t1) + local id=finished-relaunch project="$TMP_ROOT/relaunch-project" wt="$TMP_ROOT/relaunch-worktree" target out + fm_git_worktree "$project" "$wt" "$id" + mkdir -p "$HOME_DIR/data/$id" "$HOME_DIR/config" "$HOME_DIR/projects" + cat > "$HOME_DIR/data/$id/brief.md" < "$HOME_DIR/state/$id.meta" + mkdir -p "$TMP_ROOT/user-home" + out=$(HOME="$TMP_ROOT/user-home" FM_SPAWN_NO_GUARD=1 "$ROOT/bin/fm-spawn.sh" "$id" --relaunch --harness codex 2>&1) \ + || fail "a finished terminal must relaunch through fm-spawn.sh: $out" assert_equals null "$(jq -c '.terminals.t2' "$FM_HC_STATE")" "the finished terminal must be removed" - replacement=$(HC_SESSION=lab new_task t1) || fail "the replacement terminal must be created" - assert_equals lab:t3 "$replacement" "the replacement must use the recorded session" - assert_equals "$TMP_ROOT" "$(jq -r '.terminals.t3.cwd' "$FM_HC_STATE")" "the replacement must use the recorded worktree" - fm_backend_herdr_core_remove_finished "$replacement" fm-other && fail "a terminal for another task must not be removed" - assert_equals live "$(jq -r '.terminals.t3.state' "$FM_HC_STATE")" "a live terminal must remain for adoption" - pass "finished terminal rebind: removes only the owned exit, then replaces it in its session and worktree" + assert_equals lab:t3 "$(fm_meta_get "$HOME_DIR/state/$id.meta" window)" "the task record must bind the replacement in its recorded session" + assert_equals "$wt" "$(jq -r '.terminals.t3.cwd' "$FM_HC_STATE")" "the replacement must use the recorded worktree" + assert_equals live "$(jq -r '.terminals.t3.state' "$FM_HC_STATE")" "the replacement terminal must be live" + pass "finished terminal relaunch: replaces the exit in its recorded session and worktree" } test_list_live_is_home_scoped() { @@ -353,7 +369,7 @@ test_capture_and_current_path test_send_text_submit test_send_key_normalizes test_kill -test_finished_terminal_rebind +test_finished_terminal_relaunch test_list_live_is_home_scoped test_endpoint_validation_and_absence test_detection From d130c306643e16726a26fad327499b4630e1caf6 Mon Sep 17 00:00:00 2001 From: Curtis Kormos Date: Thu, 29 Oct 2026 16:07:43 -0600 Subject: [PATCH 5/6] no-mistakes(document): Document herdr-core recovery behavior --- .agents/skills/stuck-crewmate-recovery/SKILL.md | 6 +++--- bin/fm-spawn.sh | 8 ++++++-- docs/agent-control.md | 15 +++++++++------ 3 files changed, 18 insertions(+), 11 deletions(-) diff --git a/.agents/skills/stuck-crewmate-recovery/SKILL.md b/.agents/skills/stuck-crewmate-recovery/SKILL.md index 2785ffb4e75..10286fee6ae 100644 --- a/.agents/skills/stuck-crewmate-recovery/SKILL.md +++ b/.agents/skills/stuck-crewmate-recovery/SKILL.md @@ -35,13 +35,13 @@ Read the targeted current state with `bin/fm-crew-state.sh ` before deciding A no-mistakes run matched to the crew's branch and current code remains authoritative when the endpoint is dead: handle a terminal or parked run through the normal lifecycle, and keep supervising an active run instead of creating a duplicate worker. When no authoritative run accounts for the task, inspect only its recorded backend and worktree inventory. -Use `treehouse status` for treehouse-backed tmux, herdr, zellij, or cmux tasks, and use the recorded `orca_worktree_id=` and `terminal=` for Orca tasks. +Use `treehouse status` for treehouse-backed tmux, herdr, zellij, cmux, or herdr-core tasks, and use the recorded `orca_worktree_id=` and `terminal=` for Orca tasks. Do not sweep another home's endpoints or infer ownership from a matching window label. Before relaunch, prove that no live agent still owns the recorded task and that the existing worktree remains available. Preserve its uncommitted changes and commits, keep the same task identity, and resume or relaunch the recorded harness in that existing worktree with the same brief plus a concise progress note. -A HERDR endpoint that is not merely idle but destroyed - a pane or workspace removed in Herdr churn - is recovered by that same relaunch, which creates one fresh endpoint in the existing worktree and rebinds the task's record to it; nothing special is needed, and the worktree is untouched ([`docs/agent-control.md`](../../../docs/agent-control.md) "Reclaiming a task whose endpoint is gone"). -That relaunch proves the endpoint is destroyed before it rebinds, so a Herdr server that was merely stopped is adopted back rather than duplicated. +A Herdr or herdr-core endpoint that is not merely idle but destroyed is recovered by that same relaunch, which creates one fresh endpoint in the existing worktree and rebinds the task's record to it; nothing special is needed, and the worktree is untouched ([`docs/agent-control.md`](../../../docs/agent-control.md) "Reclaiming a task whose endpoint is gone"). +That relaunch proves the endpoint is destroyed before it rebinds, so a merely stopped Herdr server is adopted back rather than duplicated, while a stopped herdr-core core's non-kept terminal is re-created. On tmux there is no reclaim: a task record carries no socket identity for its endpoint, so a `missing` window cannot be told apart from one on a tmux server this seat cannot address, and both `exit` and `relaunch` refuse. Do not work around either refusal by respawning - it means a live agent may still hold that worktree. That reclaim is the owning home's operation only, and a secondmate is the one exception: recover it through `bin/fm-spawn.sh --secondmate` as above. diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index 8befaa0f523..07720690305 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -2042,7 +2042,7 @@ if [ "$RELAUNCH" -eq 1 ]; then esac fi # A relaunch must PROVE the previous agent is gone before it launches another - # one into the same endpoint, and only tmux and herdr have a recovery-grade + # one into the same endpoint, and only tmux, herdr, and herdr-core have a recovery-grade # classifier that can (bin/fm-control-lib.sh owns that capability table). fm_control_backend_state_verified "$BACKEND" || { echo "error: backend '$BACKEND' has no recovery-grade agent-state classifier, so a relaunch cannot prove the previous agent exited; refusing rather than risking two agents in one endpoint" >&2 @@ -2060,12 +2060,16 @@ if [ "$RELAUNCH" -eq 1 ]; then # endpoint was DESTROYED" with "the endpoint is UNREACHABLE from here right # now", and an unreachable endpoint can still hold the live agent this # relaunch would duplicate. So absence is PROVEN before it may rebind, never - # inferred from a failed read - and only HERDR can prove it: + # inferred from a failed read - and only Herdr and herdr-core can prove it: # herdr - the recorded session's server is started, and the recorded pane is # RE-READ through that session's own socket. `dead` means the pane # survived the restart and is adopted after all; `alive` means the # agent came back and refuses; only a second `missing` proves the # pane itself did not survive. + # herdr-core - the recorded core is started, and the recorded terminal is + # re-read through its own socket. `dead` means the terminal + # survived and is adopted; only a second `missing` proves it did + # not survive. # tmux - REFUSES, always. A task record carries no socket identity for its # endpoint, and a server-wide inventory describes only the server # this process addresses, so no read available here can tell "gone" diff --git a/docs/agent-control.md b/docs/agent-control.md index c5947fde64f..b7353225f65 100644 --- a/docs/agent-control.md +++ b/docs/agent-control.md @@ -33,7 +33,7 @@ A recorded `harness=` is not always an exact adapter name: a task launched from | Verb | Effect | Postcondition | | --- | --- | --- | | `interrupt` | Deliver the harness's verified interrupt sequence while leaving the agent running. | Delivery succeeds while the endpoint still exists and the agent is still alive where the backend can classify that; cancellation is confirmed only from an adapter-owned acknowledgement and otherwise reports `cancel=unconfirmed`. | -| `exit` | Stop the agent, preserving the endpoint, the worktree, and every uncommitted change. | The backend's recovery-grade classifier reports the agent gone. Already-stopped is idempotent success. An endpoint reading `missing` goes through the same [absence proof](#reclaiming-a-task-whose-endpoint-is-gone) the reclaim uses before anything is claimed about it, and only Herdr can supply one: proven gone reports `endpoint-gone` (the agent went with it, and the endpoint this verb normally preserves did not survive), a pane that turns out to be there and idle is the ordinary `already-stopped`, one whose agent is back takes the ordinary interrupt-then-exit path. A tmux `missing` always refuses rather than claim a stop it cannot see. | +| `exit` | Stop the agent, preserving the endpoint, the worktree, and every uncommitted change. | The backend's recovery-grade classifier reports the agent gone. Already-stopped is idempotent success. An endpoint reading `missing` goes through the same [absence proof](#reclaiming-a-task-whose-endpoint-is-gone) the reclaim uses before anything is claimed about it, and Herdr or herdr-core can supply one: proven gone reports `endpoint-gone` (the agent went with it, and the endpoint this verb normally preserves did not survive), an endpoint that turns out to be there and idle is the ordinary `already-stopped`, one whose agent is back takes the ordinary interrupt-then-exit path. A tmux `missing` always refuses rather than claim a stop it cannot see. | | `relaunch` | Replace the running agent with a new one in the same worktree - and the same endpoint whenever that endpoint still exists - on the exact recorded adapter or an explicitly chosen harness, model, and effort. | The new agent is alive on the endpoint the task's record now names, and that record names the harness that is actually running. | An exit that delivers lifecycle input but cannot prove the agent stopped fails with `exit=unconfirmed`, reports the observed agent state and any interrupt cancellation claim, and never claims that nothing changed. @@ -85,7 +85,7 @@ A relaunch does take one session reference when the endpoint's own runtime recor A secondmate relaunch does not require one and never rewrites its standing charter. 4. **Stop the old agent** through the `exit` verb, with its postcondition. 5. **Launch the replacement** through its single owner, `bin/fm-spawn.sh --relaunch`, which reuses the recorded worktree instead of creating one, adopts the recorded endpoint when it still exists, clears the previous harness's per-task wiring, and arms a fresh busy generation. - When the recorded endpoint is proven gone rather than merely idle or unreachable - which only Herdr can establish - the launch owner creates one fresh endpoint in that same worktree and the republished record rebinds the task to it - see [Reclaiming a task whose endpoint is gone](#reclaiming-a-task-whose-endpoint-is-gone). + When the recorded endpoint is proven gone rather than merely idle or unreachable - which Herdr and herdr-core can establish - the launch owner creates one fresh endpoint in that same worktree and the republished record rebinds the task to it - see [Reclaiming a task whose endpoint is gone](#reclaiming-a-task-whose-endpoint-is-gone). 6. **Preserve runtime-bound status authority where supported.** The endpoint's runtime may bind pane status to one session identity; the launch owner preserves it only when that runtime records a reference the replacement adapter can consume, and otherwise launches the ordinary fresh session. This reference is a launch input, never authority to send, close, or act on the pane. @@ -95,10 +95,10 @@ Switching harness is therefore one ordinary relaunch rather than a separate mech ### Reclaiming a task whose endpoint is gone -A Herdr pane or workspace can be destroyed out from under a live task by churn or a session restart. +A Herdr pane or workspace, or a herdr-core terminal, can be destroyed out from under a live task by churn or a session restart. The task's worktree, branch, commits, and uncommitted changes all survive that; only its terminal does not. -**Reclaim is Herdr-only.** On tmux, both verbs refuse a `missing` endpoint, leaving it exactly as deadlocked as it was before this mechanism existed - deliberately, and with the reason stated rather than guessed past. +**Reclaim is available on Herdr and herdr-core.** On tmux, both verbs refuse a `missing` endpoint, leaving it exactly as deadlocked as it was before this mechanism existed - deliberately, and with the reason stated rather than guessed past. Two endpoint verdicts are agent-free, and both license a relaunch: @@ -113,6 +113,9 @@ An unreachable endpoint can still hold the live agent a rebind would duplicate, `dead` means the pane survived the restart and is adopted after all, with no second tab; `alive` means the agent came back and refuses; only a second `missing` proves the pane itself did not survive ([`docs/herdr-backend.md`](herdr-backend.md) "Restart and liveness behavior"). That server start is a real side effect, and the parenthetical above does not cover it: when the recorded session's server no longer exists at all, the probe stands a fresh empty one up in order to ask, and nothing afterwards uses it. So in that state `exit` - which otherwise reads as a read-only inspection - leaves an idle herdr server behind. +- **herdr-core can prove it.** Every `hc` read is pinned to the session the record names, so the recheck reaches that core through its own socket. + It starts a stopped core and rereads the recorded terminal; a terminal that is then `dead` is adopted, an `alive` terminal refuses, and only a second `missing` verdict proves it is gone. + Because a stopped core takes its non-kept terminals with it, that `missing` verdict licenses a replacement in the recorded worktree. - **tmux cannot.** `list-windows -a` describes only the tmux server the *current process* addresses (its `TMUX_TMPDIR`/socket), and a task record carries no socket identity for its endpoint. A different but running server would answer "not anywhere" about a window it was never able to see, so a server-wide read cannot tell a destroyed window from one on a server this process cannot address. There is no read available that closes that gap, so tmux always refuses - for a renamed session, a moved window, a foreign socket, and a dead server alike. @@ -131,7 +134,7 @@ What a reclaim is not: - It is **not** a peer seat's operation. `fm-control` resolves an exact task id against **this** home's `state/`, so only the home that owns the task can reclaim it. - It does **not** cover a secondmate. A secondmate whose endpoint is gone already has one recovery path - `bin/fm-spawn.sh --secondmate`, driven by the session-start sweep or the watcher's liveness tick - so control-plane reclaim refuses and names it rather than becoming a second path to the same outcome. -The re-created tab is opened in the herdr session the record names, never in whichever session the recovering seat happens to sit in - relocating a task onto another herdr server would be an identity change published as a self-consistent but wrong record. +For Herdr, the re-created tab is opened in the session the record names, never in whichever session the recovering seat happens to sit in - relocating a task onto another herdr server would be an identity change published as a self-consistent but wrong record. A seat that *claims* a herdr launcher pane belonging to a different session is refused rather than allowed to place the endpoint somewhere else, so reclaim such a task from a seat in the recorded session. A seat with no herdr launcher pane at all - a plain ssh or cron shell, which is the ordinary way an operator reclaims - is not refused: placement falls back to the recorded session's labeled container, so the tab still lands in the session the record names. The reclaim pins the recorded **session** but not the **workspace**: the container follows the reclaiming seat, so a reclaim run from a seat inside the recorded session places the new tab in *that seat's* workspace rather than the recorded `herdr_workspace_id`, even when the recorded workspace still exists and only the pane was destroyed. @@ -139,7 +142,7 @@ The record is republished consistently and no work is lost, but the task's `herd The pane id necessarily changes (the pane did not survive), and the record follows it. A Herdr reclaim deliberately uses the flat container shape rather than presentation projection: projection is a presentation-only layout that is never endpoint or ownership authority, and flat is already the documented fallback for every recovery it cannot bind exactly ([`docs/herdr-backend.md`](herdr-backend.md)). -**Known limitation - a refusal before the record is republished leaves a stray husk pane** (follow-up bead `fm-herdr-rebind-leak-20260913`). +**Known Herdr limitation - a refusal before the record is republished leaves a stray husk pane** (follow-up bead `fm-herdr-rebind-leak-20260913`). The rebind registers no abort cleanup, so a refusal in the window between the new tab being created and the record being republished leaves that pane behind while the record still names the old, gone one. The stray pane holds a bare shell - the harness is not delivered until after publication - so the next reclaim cleans up after it: the re-created tab carries the same `fm-` label, `tab create` finds it, classifies it a husk, and closes and replaces it. That self-heals only when the retry resolves the *same* workspace, which the placement rule above does not guarantee. From 94b958321814d137038ae18e9721c4372706aa38 Mon Sep 17 00:00:00 2001 From: Curtis Kormos Date: Thu, 29 Oct 2026 16:44:24 -0600 Subject: [PATCH 6/6] =?UTF-8?q?no-mistakes(ci):=20Updated=20tests/fm-boots?= =?UTF-8?q?trap.test.sh=20so=20the=20behavioral=20unknown-backend=20diagno?= =?UTF-8?q?stic=20expects=20the=20newly=20registered=20herdr-core=20backen?= =?UTF-8?q?d.=20The=20previously=20failing=20assertion=20now=20passes=20du?= =?UTF-8?q?ring=20the=20bootstrap=20suite;=20tests/fm-backend-herdr-core.t?= =?UTF-8?q?est.sh=20passed=20locally.=20The=20full=20bootstrap=20suite=20e?= =?UTF-8?q?xceeded=20this=20environment=E2=80=99s=2030s=20command=20window?= =?UTF-8?q?=20after=20the=20relevant=20assertion=20passed;=20no=20unrelate?= =?UTF-8?q?d=20diff=20hunks=20were=20flagged=20by=20jev.=20Risk:=20only=20?= =?UTF-8?q?the=20stale=20expected=20diagnostic=20was=20changed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/fm-bootstrap.test.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/fm-bootstrap.test.sh b/tests/fm-bootstrap.test.sh index 1b7587c35fa..688c563d115 100755 --- a/tests/fm-bootstrap.test.sh +++ b/tests/fm-bootstrap.test.sh @@ -653,7 +653,7 @@ test_unknown_backend_reports_invalid_configuration() { fakebin=$(make_fake_toolchain "$case_dir") out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$case_dir/home" FM_ROOT_OVERRIDE="$case_dir/home" \ FM_FAKE_TREEHOUSE_LEASE_HELP=1 "$ROOT/bin/fm-bootstrap.sh") - assert_contains "$out" "BACKEND_INVALID: bogus (known: tmux herdr zellij orca cmux)" \ + assert_contains "$out" "BACKEND_INVALID: bogus (known: tmux herdr zellij orca cmux herdr-core)" \ "bootstrap should report an unknown resolved backend" assert_not_contains "$out" "MISSING: tmux" "an unknown backend should not silently fall back to tmux dependencies" pass "bootstrap: unknown resolved backends fail closed with an actionable diagnostic"