From 6b9f57181b3d6ed1765d483e3b30149820939114 Mon Sep 17 00:00:00 2001 From: Curtis Kormos Date: Thu, 24 Sep 2026 09:49:48 -0600 Subject: [PATCH 1/3] feat(bin): add a per-home Herdr crew workspace of task panes A home that writes `crew` (or `crew:`) into config/herdr-presentation-spaces keeps the Firstmate agent alone in its workspace and places every crewmate or scout as a split pane in one durable per-home crew workspace, created on first use right after the home's workspace. The default cap of six panes forms an even 3x2 grid, filled top row first; a full tab overflows to a new tab in the same crew workspace. Split ratios are evened out with layout.set_split_ratio after every placement and crew pane cleanup, never layout.apply. Placement runs under the session presentation lock with focus preserved, reuses a recorded crew workspace only while its id and label both still match, refuses a live same-labeled pane, and replaces an exited husk only after its successor exists. Cleanup closes the exact pane through the existing focus-safe kill path, so the workspace goes with its last pane. list-live and bare-selector resolution find crew panes, and a reclaim of a gone endpoint follows the same setting. Secondmates keep their placement. --- bin/backends/herdr-crew-equalize.py | 192 +++++++++ bin/backends/herdr.sh | 338 +++++++++++++-- bin/fm-spawn.sh | 132 ++++-- bin/fm-test-run.sh | 4 +- docs/configuration.md | 2 +- docs/herdr-backend.md | 31 ++ docs/verification/runtime-backends.md | 28 ++ ...m-backend-herdr-crew-workspace-e2e.test.sh | 275 +++++++++++++ tests/fm-backend-herdr-crew.test.sh | 294 +++++++++++++ tests/herdr-crew-fake.py | 385 ++++++++++++++++++ 10 files changed, 1619 insertions(+), 62 deletions(-) create mode 100755 bin/backends/herdr-crew-equalize.py create mode 100755 tests/fm-backend-herdr-crew-workspace-e2e.test.sh create mode 100755 tests/fm-backend-herdr-crew.test.sh create mode 100755 tests/herdr-crew-fake.py diff --git a/bin/backends/herdr-crew-equalize.py b/bin/backends/herdr-crew-equalize.py new file mode 100755 index 00000000000..430269e174d --- /dev/null +++ b/bin/backends/herdr-crew-equalize.py @@ -0,0 +1,192 @@ +#!/usr/bin/env python3 +"""Even out the split ratios of one Herdr tab without touching its panes. + +This helper is the wire transport for Firstmate's crew-workspace grid +(docs/herdr-backend.md "Crew workspace"). It sends only two methods: the +read-only ``layout.export`` for one exact tab, and the non-destructive +``layout.set_split_ratio``. It never sends ``layout.apply``, which recreates +every pane (verified against Herdr 0.9.1, protocol 22, and would kill the +agents running in them). + +Each split gets the ratio that gives every pane an equal share along that +split's direction: a pane counts 1, a split in the same direction sums its +children, and a split in the other direction takes the larger child. A tree +of right splits holding down splits therefore becomes an even grid. + +Wire protocol verified against Herdr 0.9.1, protocol 22: + + request: {"id":ID,"method":"layout.export","params":{"tab_id":T}}\\n + response: {"id":ID,"result":{"type":"layout_export","layout": + {"tab_id":T,"root":NODE,...}}}\\n + request: {"id":ID,"method":"layout.set_split_ratio", + "params":{"tab_id":T,"path":[bool...],"ratio":R}}\\n + response: {"id":ID,"result":{"type":"layout_split_ratio_set","layout":{...}}}\\n + +A path step of false selects a split's first child and true its second. + +Usage: herdr-crew-equalize.py + +Exit status: + 0 the tab's final layout has the same panes and the even ratios; + 2 arguments or socket connection were invalid; + 3 a request could not be sent or its response could not be read; + 4 a response was malformed, mismatched, reported an error, or the final + layout changed a pane or kept an uneven ratio. +""" + +import json +import socket +import sys +import time + + +CONNECT_TIMEOUT = 5.0 +RESPONSE_TIMEOUT = 5.0 +RECV_CHUNK = 65536 +MAX_RESPONSE_BYTES = 4 * 1024 * 1024 +REQUEST_ID = "fm-crew-equalize" +TOLERANCE = 0.001 + + +class Failure(Exception): + def __init__(self, status): + super().__init__(status) + self.status = status + + +def _read_line(sock, deadline): + buffer = b"" + while b"\n" not in buffer: + remaining = deadline - time.monotonic() + if remaining <= 0: + return None + sock.settimeout(remaining) + try: + chunk = sock.recv(RECV_CHUNK) + except (OSError, socket.timeout): + return None + if not chunk: + return None + buffer += chunk + if len(buffer) > MAX_RESPONSE_BYTES: + return None + return buffer.split(b"\n", 1)[0] + + +def _call(socket_path, method, params, result_type, tab_id): + try: + sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + sock.settimeout(CONNECT_TIMEOUT) + sock.connect(socket_path) + except OSError: + raise Failure(2) + try: + request = {"id": REQUEST_ID, "method": method, "params": params} + try: + sock.sendall( + (json.dumps(request, separators=(",", ":")) + "\n").encode("utf-8") + ) + except OSError: + raise Failure(3) + line = _read_line(sock, time.monotonic() + RESPONSE_TIMEOUT) + finally: + sock.close() + if line is None: + raise Failure(3) + try: + response = json.loads(line.decode("utf-8", "replace")) + except ValueError: + raise Failure(4) + result = response.get("result") if isinstance(response, dict) else None + layout = result.get("layout") if isinstance(result, dict) else None + if ( + response.get("id") != REQUEST_ID + or response.get("error") is not None + or result.get("type") != result_type + or not isinstance(layout, dict) + or layout.get("tab_id") != tab_id + or not isinstance(layout.get("root"), dict) + ): + raise Failure(4) + return layout["root"] + + +def _count(node, direction): + kind = node.get("type") + if kind == "pane": + return 1 + if kind != "split": + raise Failure(4) + first, second = node.get("first"), node.get("second") + if not isinstance(first, dict) or not isinstance(second, dict): + raise Failure(4) + if node.get("direction") == direction: + return _count(first, direction) + _count(second, direction) + return max(_count(first, direction), _count(second, direction)) + + +def _targets(node, path, out): + """Collect (path, current ratio, even ratio) for every split under node.""" + if node.get("type") != "split": + return + direction = node.get("direction") + if direction not in ("right", "down"): + raise Failure(4) + first = _count(node["first"], direction) + second = _count(node["second"], direction) + ratio = node.get("ratio") + if not isinstance(ratio, (int, float)): + raise Failure(4) + out.append((path, float(ratio), first / (first + second))) + _targets(node["first"], path + [False], out) + _targets(node["second"], path + [True], out) + + +def _panes(node): + if node.get("type") == "pane": + return [node.get("pane_id")] + return _panes(node["first"]) + _panes(node["second"]) + + +def main(argv): + if len(argv) != 3: + return 2 + socket_path, tab_id = argv[1:] + if not socket_path.startswith("/") or not tab_id: + return 2 + if any(char in tab_id for char in "\t\r\n"): + return 2 + try: + root = _call(socket_path, "layout.export", {"tab_id": tab_id}, "layout_export", tab_id) + before = _panes(root) + targets = [] + _targets(root, [], targets) + for path, current, even in targets: + if abs(current - even) <= TOLERANCE: + continue + _call( + socket_path, + "layout.set_split_ratio", + {"tab_id": tab_id, "path": path, "ratio": even}, + "layout_split_ratio_set", + tab_id, + ) + root = _call(socket_path, "layout.export", {"tab_id": tab_id}, "layout_export", tab_id) + if _panes(root) != before: + return 4 + final = [] + _targets(root, [], final) + if any(abs(current - even) > TOLERANCE for _, current, even in final): + return 4 + except Failure as failure: + return failure.status + except (KeyError, TypeError, AttributeError, RecursionError): + return 4 + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main(sys.argv)) + except (BrokenPipeError, KeyboardInterrupt): + sys.exit(3) diff --git a/bin/backends/herdr.sh b/bin/backends/herdr.sh index d7de3b66a66..8f87e9c87fb 100644 --- a/bin/backends/herdr.sh +++ b/bin/backends/herdr.sh @@ -17,8 +17,9 @@ # FIRSTMATE HOME (the primary, and each secondmate, gets its own), ONE herdr TAB # per task inside its home's workspace. The default-on presentation projection # creates a disposable workspace for a clean fresh task instead unless the home -# opts out. That -# workspace is a non-authoritative visual projection containing only the normal +# opts out, and a home that selects "crew" places each task as a split pane in +# one durable per-home crew workspace instead (fm_backend_herdr_crew_create_task). +# A projected workspace is a non-authoritative visual projection containing only the normal # task pane. Its random token and mutable label never authorize lookup, # adoption, reuse, closure, deletion, task ownership, or endpoint selection. # A version 2 journal can participate in replacing only its exact same-identity @@ -150,13 +151,21 @@ FM_BACKEND_HERDR_SECONDMATE_MARKER=".fm-secondmate-home" FM_BACKEND_HERDR_PRESENTATION_JOURNAL_SUFFIX=".herdr-presentation" # The config item a home writes to opt out of, or explicitly in to, the -# projection. +# projection, or to select the shared crew workspace instead. FM_BACKEND_HERDR_PRESENTATION_CONFIG="herdr-presentation-spaces" +# Crew layout: every crewmate and scout of a home becomes one split pane in a +# single durable per-home crew workspace, at most this many panes per tab +# before a new tab starts. The workspace id is recorded per named session in +# the home's state dir under this prefix, so placement never trusts a label. +FM_BACKEND_HERDR_CREW_PANE_CAP_DEFAULT=6 +FM_BACKEND_HERDR_CREW_RECORD_PREFIX=".herdr-crew-workspace-" # fm_backend_herdr_presentation_preference : the single owner of # config/herdr-presentation-spaces parsing. Echoes exactly one of "off", "on" -# (a deliberate opt-in, honored even below the version floor), or "default" -# (this home configured nothing, so the floor decides). +# (a deliberate opt-in, honored even below the version floor), "crew:" +# (the shared crew workspace, split panes capped at per tab: "crew" +# alone means the default cap, "crew:" sets it), or "default" (this home +# configured nothing, so the floor decides). # Values are read with the whole-file whitespace-stripped convention the other # scalar config items already use (config/backlog-backend, config/crew-harness), # plus case folding. An empty file is the historical presence-based opt-in form @@ -173,8 +182,10 @@ fm_backend_herdr_presentation_preference() { # case "$value" in off) printf 'off\n' ;; ''|on) printf 'on\n' ;; + crew) printf 'crew:%s\n' "$FM_BACKEND_HERDR_CREW_PANE_CAP_DEFAULT" ;; + crew:[1-9]|crew:1[0-6]) printf '%s\n' "$value" ;; *) - echo "warning: $file: unrecognized value \"$value\"; herdr presentation spaces fall back to the default (write \"off\" to opt out, \"on\" to force the projection on)" >&2 + echo "warning: $file: unrecognized value \"$value\"; herdr presentation spaces fall back to the default (write \"off\" to opt out, \"on\" to force the projection on, \"crew\" or \"crew:<1-16>\" for one shared crew workspace of split panes)" >&2 printf 'default\n' ;; esac @@ -326,8 +337,10 @@ fm_backend_herdr_presentation_default_supported() { # [] # fm_backend_herdr_presentation_enabled []: the one gate # bin/fm-spawn.sh consults before projecting this home's children into # disposable one-task workspaces (docs/herdr-backend.md "Presentation spaces" -# owns the full contract). An explicit "off" or "on" is obeyed as written; a -# home that configured nothing is projected only at or above the version floor, +# owns the full contract). An explicit "off" or "on" is obeyed as written, and +# "crew:" is never a per-task projection (the caller reads the +# out-parameter below to take the crew path instead); a home that configured +# nothing is projected only at or above the version floor, # and otherwise falls back to the flat layout with one warning. Sets # FM_BACKEND_HERDR_PRESENTATION_PREFERENCE for the new-projection boundary to # distinguish an unconfigured default from an explicit opt-in. @@ -338,7 +351,7 @@ fm_backend_herdr_presentation_enabled() { # [] # shellcheck disable=SC2034 FM_BACKEND_HERDR_PRESENTATION_PREFERENCE=$preference case "$preference" in - off) return 1 ;; + off|crew:*) return 1 ;; on) return 0 ;; esac fm_backend_herdr_presentation_default_supported "$state_dir" @@ -2637,6 +2650,255 @@ fm_backend_herdr_projection_cleanup_exact() { # + printf '%s/%s%s' "$1" "$FM_BACKEND_HERDR_CREW_RECORD_PREFIX" "${2//[^A-Za-z0-9_-]/_}" +} + +# fm_backend_herdr_crew_workspace_recorded: echo this home's recorded crew +# workspace id for only while that exact id is still present there +# and still carries this home's crew label; echo nothing when there is no +# usable record, which includes a workspace Herdr removed with its last pane. +# Returns 1 when the session's workspaces cannot be read, so an unreadable +# layout refuses instead of minting a second crew workspace. Never mutates. +fm_backend_herdr_crew_workspace_recorded() { # + local state=$1 session=$2 record wsid="" list + record=$(fm_backend_herdr_crew_record_path "$state" "$session") + { [ -f "$record" ] && [ ! -L "$record" ]; } || return 0 + IFS= read -r wsid < "$record" || [ -n "$wsid" ] || return 0 + [ -n "$wsid" ] || return 0 + list=$(fm_backend_herdr_cli "$session" workspace list 2>/dev/null) || return 1 + printf '%s' "$list" | jq -e '(.result.workspaces | type) == "array"' >/dev/null 2>&1 || return 1 + printf '%s' "$list" | jq -r --arg ws "$wsid" --arg want "$(fm_backend_herdr_crew_workspace_label)" ' + [.result.workspaces[] | select(.workspace_id == $ws and .label == $want)] + | if length == 1 then .[0].workspace_id else empty end + ' 2>/dev/null +} + +fm_backend_herdr_crew_record_write() { # + local state=$1 record tmp + record=$(fm_backend_herdr_crew_record_path "$state" "$2") + mkdir -p "$state" || return 1 + tmp=$(mktemp "$record.XXXXXX") || return 1 + if ! { printf '%s\n' "$3" > "$tmp" && mv -f "$tmp" "$record"; }; then + rm -f "$tmp" + return 1 + fi +} + +# fm_backend_herdr_crew_grid_columns : the crew tab's grid width. +# Up to three panes sit side by side; a larger cap uses two rows, so the +# default cap of 6 is a grid of 3 columns by 2 rows. +fm_backend_herdr_crew_grid_columns() { # + if [ "$1" -le 3 ]; then printf '%s' "$1"; else printf '%s' $(( ($1 + 1) / 2 )); fi +} + +# fm_backend_herdr_crew_split_anchor: " " for the next +# pane in the crew tab holding , read from that tab's live pane +# geometry. The grid fills its top row left to right, then its bottom row left +# to right: +# 1. While the tab has fewer than columns and its rightmost column +# is one full-height pane, split that pane right to add a column. +# 2. Otherwise split the leftmost full-height pane down, which also refills +# a slot a closed pane left behind. +# 3. Otherwise (a whole column closed while others stayed full, a shape a +# pane split cannot rebuild) split the largest pane across its longer +# visual side; the rebalance below still evens every split. +# Empty when the layout cannot be read. +fm_backend_herdr_crew_split_anchor() { # + fm_backend_herdr_cli "$1" pane layout --pane "$2" 2>/dev/null | jq -r --argjson columns "$3" ' + .result.layout as $layout + | ($layout.panes | select(type == "array" and length > 0)) as $panes + | ($layout.area.height) as $height + | ([$panes[].rect.x] | unique) as $xs + | [$panes[] | select(.rect.x == ($xs | max))] as $right + | [$panes[] | select(.rect.height == $height)] as $full + | if ($xs | length) < $columns and ($right | length) == 1 and $right[0].rect.height == $height + then "\($right[0].pane_id) right" + elif ($full | length) > 0 + then "\($full | min_by(.rect.x) | .pane_id) down" + else ($panes | max_by(.rect.width * .rect.height)) + | "\(.pane_id) \(if .rect.width >= 2 * .rect.height then "right" else "down" end)" + end + ' 2>/dev/null +} + +# fm_backend_herdr_crew_equalize: best-effort rebalance of one crew tab so +# every pane gets an equal share of the grid, through +# bin/backends/herdr-crew-equalize.py, which only reads the tab's layout and +# sets split ratios, never recreating a pane. Any unavailable capability or +# failure warns and leaves the tab as Herdr drew it; it never fails a caller. +fm_backend_herdr_crew_equalize() { # + local session=$1 tab=$2 socket helper + command -v python3 >/dev/null 2>&1 || { + echo "warning: herdr crew grid rebalance requires python3; leaving the crew tab's split sizes as they are" >&2 + return 0 + } + fm_backend_herdr_cli "$session" api schema --json 2>/dev/null | jq -e ' + [.schemas.request.oneOf[]?.properties.method.const] as $methods + | ($methods | index("layout.export")) != null and ($methods | index("layout.set_split_ratio")) != null + ' >/dev/null 2>&1 || { + echo "warning: herdr crew grid rebalance needs layout.export and layout.set_split_ratio; leaving the crew tab's split sizes as they are" >&2 + return 0 + } + socket=$(fm_backend_herdr_presentation_session_socket_path "$session") || { + echo "warning: herdr crew grid rebalance found an ambiguous named session socket; leaving the crew tab's split sizes as they are" >&2 + return 0 + } + helper=${FM_BACKEND_HERDR_CREW_EQUALIZER:-$FM_BACKEND_HERDR_ROOT/bin/backends/herdr-crew-equalize.py} + "$helper" "$socket" "$tab" >/dev/null 2>&1 || \ + echo "warning: herdr crew grid rebalance could not verify even split sizes for tab $tab; leaving it as Herdr drew it" >&2 + return 0 +} + +# fm_backend_herdr_crew_tab_of: echo " " for +# only when it lives in this home's labeled crew workspace. The label only +# gates the non-destructive rebalance after a close, never any placement or +# removal. +fm_backend_herdr_crew_tab_of() { # + local session=$1 pane=$2 info ws tab + info=$(fm_backend_herdr_cli "$session" pane get "$pane" 2>/dev/null) || return 1 + ws=$(printf '%s' "$info" | jq -r '.result.pane.workspace_id // empty' 2>/dev/null) + tab=$(printf '%s' "$info" | jq -r '.result.pane.tab_id // empty' 2>/dev/null) + [ -n "$ws" ] && [ -n "$tab" ] || return 1 + fm_backend_herdr_cli "$session" workspace list 2>/dev/null | jq -e --arg ws "$ws" --arg want "$(fm_backend_herdr_crew_workspace_label)" ' + [.result.workspaces[]? | select(.workspace_id == $ws and .label == $want)] | length == 1 + ' >/dev/null 2>&1 || return 1 + printf '%s %s' "$ws" "$tab" +} + +# fm_backend_herdr_crew_create_task: place one task as a pane in this home's +# crew workspace in , creating and recording that workspace on first +# use and ordering it right after (or the unique home +# label) best-effort. The caller must hold the named-session presentation lock +# so the record, tab choice, and split cannot race another spawn. Call it as a +# plain statement; it prints nothing and sets: +# FM_BACKEND_HERDR_CREW_WORKSPACE_ID FM_BACKEND_HERDR_CREW_TAB_ID +# FM_BACKEND_HERDR_CREW_PANE_ID +# The pane id is set as soon as Herdr returns one, even on a later failure, so +# the caller's abort cleanup can close exactly that pane. +# A same-labeled pane in the crew workspace refuses unless it is a husk, which +# is replaced only after the new pane exists (the flat tab path's rule). +fm_backend_herdr_crew_create_task() { # [] + local session=$1 state=$2 cwd=$3 label=$4 cap=$5 parent_ws=${6:-} + local wsid out="" panes="" tabs tab tab_id="" pane="" count anchor husk husks="" focus_before created=0 split=0 + FM_BACKEND_HERDR_CREW_WORKSPACE_ID="" + FM_BACKEND_HERDR_CREW_TAB_ID="" + FM_BACKEND_HERDR_CREW_PANE_ID="" + case "$cap" in ''|*[!0-9]*|0) cap=$FM_BACKEND_HERDR_CREW_PANE_CAP_DEFAULT ;; esac + wsid=$(fm_backend_herdr_crew_workspace_recorded "$state" "$session") || { + echo "error: could not read herdr workspaces in session '$session' to find this home's crew workspace" >&2 + return 1 + } + if [ -n "$wsid" ]; then + panes=$(fm_backend_herdr_cli "$session" pane list --workspace "$wsid" 2>/dev/null) || panes= + printf '%s' "$panes" | jq -e '(.result.panes | type) == "array"' >/dev/null 2>&1 || { + echo "error: could not parse herdr pane list output for crew workspace $wsid (session $session)" >&2 + return 1 + } + while IFS= read -r husk; do + [ -n "$husk" ] || continue + if ! fm_backend_herdr_tab_is_husk "$session" "$husk"; then + echo "error: herdr pane '$label' already exists in crew workspace $wsid (session $session)" >&2 + return 1 + fi + husks="${husks}${husk}"$'\n' + done < <(printf '%s' "$panes" | jq -r --arg want "$label" '.result.panes[] | select(.label == $want) | .pane_id' 2>/dev/null) + fi + # Every create below passes --no-focus; the snapshot is the same exact-tab + # restore backstop the presentation path uses, skipped only when the session + # has no single focused tab to restore. + focus_before=$(fm_backend_herdr_projection_focus_snapshot "$session") || focus_before= + if [ -z "$wsid" ]; then + out=$(fm_backend_herdr_cli "$session" workspace create --cwd "$cwd" \ + --label "$(fm_backend_herdr_crew_workspace_label)" --no-focus 2>/dev/null) || out= + wsid=$(printf '%s' "$out" | jq -r '.result.workspace.workspace_id // empty' 2>/dev/null) + tab_id=$(printf '%s' "$out" | jq -r '.result.tab.tab_id // empty' 2>/dev/null) + pane=$(printf '%s' "$out" | jq -r '.result.root_pane.pane_id // empty' 2>/dev/null) + created=1 + else + tabs=$(fm_backend_herdr_cli "$session" tab list --workspace "$wsid" 2>/dev/null) || tabs= + printf '%s' "$tabs" | jq -e '(.result.tabs | type) == "array"' >/dev/null 2>&1 || { + echo "error: could not parse herdr tab list output for crew workspace $wsid (session $session)" >&2 + return 1 + } + # The first tab with room gets a split, so panes freed by cleanup refill. + while IFS= read -r tab; do + [ -n "$tab" ] || continue + count=$(printf '%s' "$panes" | jq -r --arg tab "$tab" '[.result.panes[] | select(.tab_id == $tab)] | length' 2>/dev/null) + case "$count" in ''|*[!0-9]*|0) continue ;; esac + [ "$count" -lt "$cap" ] || continue + anchor=$(fm_backend_herdr_crew_split_anchor "$session" \ + "$(printf '%s' "$panes" | jq -r --arg tab "$tab" '[.result.panes[] | select(.tab_id == $tab)][0].pane_id' 2>/dev/null)" \ + "$(fm_backend_herdr_crew_grid_columns "$cap")") + [ -n "$anchor" ] || continue + out=$(fm_backend_herdr_cli "$session" pane split "${anchor%% *}" --direction "${anchor#* }" \ + --cwd "$cwd" --no-focus 2>/dev/null) || out= + tab_id=$(printf '%s' "$out" | jq -r '.result.pane.tab_id // empty' 2>/dev/null) + pane=$(printf '%s' "$out" | jq -r '.result.pane.pane_id // empty' 2>/dev/null) + split=1 + break + done < <(printf '%s' "$tabs" | jq -r '.result.tabs[].tab_id' 2>/dev/null) + if [ -z "$out" ]; then + # Every tab is at the cap, or none could be split: start a new tab. + out=$(fm_backend_herdr_cli "$session" tab create --workspace "$wsid" --cwd "$cwd" --no-focus 2>/dev/null) || out= + tab_id=$(printf '%s' "$out" | jq -r '.result.tab.tab_id // empty' 2>/dev/null) + pane=$(printf '%s' "$out" | jq -r '.result.root_pane.pane_id // empty' 2>/dev/null) + fi + fi + # shellcheck disable=SC2034 # callers consume the crew placement's parts + FM_BACKEND_HERDR_CREW_PANE_ID=$pane + [ -z "$focus_before" ] || fm_backend_herdr_projection_focus_restore "$session" "$focus_before" "crew pane create" || true + if [ -z "$wsid" ] || [ -z "$tab_id" ] || [ -z "$pane" ]; then + echo "error: herdr did not return a complete crew workspace, tab, and pane for $label (session $session)" >&2 + return 1 + fi + if [ "$created" = 1 ]; then + fm_backend_herdr_crew_record_write "$state" "$session" "$wsid" || { + echo "error: could not record herdr crew workspace $wsid for session $session" >&2 + return 1 + } + fm_backend_herdr_projection_order_best_effort "$session" "$wsid" \ + "$(fm_backend_herdr_workspace_label)" "$parent_ws" + fi + # The pane label is what duplicate detection and list-live read, never + # endpoint authority, so an unlabeled pane is not a usable crew placement. + fm_backend_herdr_cli "$session" pane rename "$pane" "$label" >/dev/null 2>&1 || { + echo "error: could not label herdr crew pane $pane as '$label' (session $session)" >&2 + return 1 + } + while IFS= read -r husk; do + [ -n "$husk" ] || continue + fm_backend_herdr_explicit_close_pane_confirmed "$session" "$husk" || { + echo "error: failed to remove preexisting herdr crew pane $husk for label '$label' (session $session)" >&2 + return 1 + } + done < @@ -3483,7 +3745,7 @@ fm_backend_herdr_kill_serialized() { # fm_backend_herdr_kill() { # fm_backend_herdr_target_ready "$1" || return 0 local session=$FM_BACKEND_HERDR_SESSION pane=$FM_BACKEND_HERDR_PANE - local lock_path attempt=0 lock_held=0 + local lock_path attempt=0 lock_held=0 crew_tab if ! declare -F fm_lock_try_acquire >/dev/null 2>&1; then # shellcheck source=bin/fm-wake-lib.sh . "$FM_BACKEND_HERDR_ROOT/bin/fm-wake-lib.sh" @@ -3499,7 +3761,20 @@ fm_backend_herdr_kill() { # done fi if [ "$lock_held" = 1 ]; then + # A crew pane's tab is rebalanced after the close so the remaining panes + # keep an even grid (docs/herdr-backend.md "Crew workspace"); a home that + # never placed a crew pane has no crew record and makes no extra calls. + crew_tab= + if [ -f "$(fm_backend_herdr_crew_record_path "${FM_STATE_OVERRIDE:-${FM_HOME:-}/state}" "$session")" ]; then + crew_tab=$(fm_backend_herdr_crew_tab_of "$session" "$pane") || crew_tab= + fi fm_backend_herdr_kill_serialized "$session" "$pane" + if [ -n "$crew_tab" ] \ + && [ "$(fm_backend_herdr_pane_presence_state "$session" "$pane")" = dead ] \ + && fm_backend_herdr_cli "$session" tab get "${crew_tab#* }" 2>/dev/null \ + | jq -e --arg tab "${crew_tab#* }" '.result.tab.tab_id == $tab' >/dev/null 2>&1; then + fm_backend_herdr_crew_equalize "$session" "${crew_tab#* }" + fi fm_lock_release "$lock_path" || true else echo "warning: herdr task kill could not acquire its session presentation lock; refusing an unlocked pane close" >&2 @@ -3661,8 +3936,8 @@ fm_backend_herdr_pane_for_tab() { # # fm_backend_herdr_resolve_bare_selector: the live-tab-listing fallback for an # ad hoc selector with no meta (mirrors tmux's list-windows grep). Searches -# every RUNNING named herdr session (herdr session list) for a tab whose label -# matches , since herdr sessions are not addressed by one ambient +# every RUNNING named herdr session (herdr session list) for a tab (or, for a +# crew-layout task, a pane) whose label matches , since herdr sessions are not addressed by one ambient # server the way a single tmux server is. Rare path in practice (herdr tasks # normally carry meta), best-effort. fm_backend_herdr_resolve_bare_selector() { # @@ -3673,10 +3948,15 @@ fm_backend_herdr_resolve_bare_selector() { # tabs=$(fm_backend_herdr_cli "$session" tab list 2>/dev/null) || continue tab_id=$(printf '%s' "$tabs" | jq -r --arg want "$name" \ '.result.tabs[]? | select(.label == $want) | .tab_id' 2>/dev/null | head -1) - [ -n "$tab_id" ] || continue - wsid=$(printf '%s' "$tabs" | jq -r --arg tab "$tab_id" '.result.tabs[]? | select(.tab_id == $tab) | .workspace_id' 2>/dev/null | head -1) - [ -n "$wsid" ] || continue - pane_id=$(fm_backend_herdr_pane_for_tab "$session" "$wsid" "$tab_id") || continue + if [ -n "$tab_id" ]; then + wsid=$(printf '%s' "$tabs" | jq -r --arg tab "$tab_id" '.result.tabs[]? | select(.tab_id == $tab) | .workspace_id' 2>/dev/null | head -1) + [ -n "$wsid" ] || continue + pane_id=$(fm_backend_herdr_pane_for_tab "$session" "$wsid" "$tab_id") || continue + else + # A crew-layout task is a labeled pane rather than a labeled tab. + pane_id=$(fm_backend_herdr_cli "$session" pane list 2>/dev/null | jq -r --arg want "$name" \ + '.result.panes[]? | select(.label == $want) | .pane_id' 2>/dev/null | head -1) + fi [ -n "$pane_id" ] || continue printf '%s:%s' "$session" "$pane_id" return 0 @@ -3697,18 +3977,26 @@ EOF # workspace because FM_HOME already names it - no glue needed, unlike the # primary-spawns-a-secondmate path in fm-spawn.sh. Read-only: a session/ # workspace that does not exist yet simply lists nothing. One -# ":\t