diff --git a/AGENTS.md b/AGENTS.md index 256e5c536de..297f330cf48 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -81,7 +81,7 @@ config/supervision-branch-model config/supervision-branch-effort Pi supervision config/supervision-host optional opt-in to the supervision host, which runs the supervision branch's contract on a headless engine beside a Claude primary in the away posture; LOCAL, gitignored, not inherited; absent changes nothing; see docs/configuration.md "Supervision host" config/startup-memory-budget primary-authoritative per-home startup-memory budget; LOCAL, gitignored, materialized as 7,500 estimated tokens by locked primary bootstrap and inherited into secondmate homes; see docs/configuration.md "Startup memory budget" config/stow-pass-horizon optional presence flag opting this home in to /stow's default-off pass-count decay horizon; LOCAL, gitignored, and not inherited; see docs/configuration.md "Stow pass horizon" -config/herdr-presentation-spaces optional "off" opt-out from, or "on" opt-in to, Herdr's default-on disposable single-task visual projection, which is unconfigured-default-on only at or above a Herdr version floor; LOCAL, gitignored; inherited by secondmate homes; see docs/herdr-backend.md "Presentation spaces" +config/herdr-presentation-spaces optional Herdr visual layout: "off" opts out of, "on" opts into the default-on disposable single-task projection, and "crew" opens task panes in a shared crew workspace; the unconfigured default requires a Herdr version floor; LOCAL, gitignored; inherited by secondmate homes; see docs/herdr-backend.md "Presentation spaces" and "Crew workspace" config/trace-context optional presence flag enabling default-off native W3C trace-context propagation to spawned agents; LOCAL, gitignored; inherited by secondmate homes; see docs/configuration.md "Trace context propagation" and docs/trace-context.md config/lavish-axi-host optional one-line per-machine Lavish server address; LOCAL, gitignored, inherited by secondmate homes, and exported into every worker launch; see docs/configuration.md "Lavish server address" for opening versus polling config/brief-include.md optional standing worker instructions appended verbatim as the last section of every ship and scout scaffold; LOCAL, gitignored, and not inherited; keep its text out of `## Firstmate spec`; see docs/configuration.md "Home brief include" diff --git a/README.md b/README.md index a4faabeaa13..00fd9bfb86c 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ Launching a supported harness inside it for your primary session instantiates yo ## Features - **One liaison** - you talk only to the first mate; it dispatches, supervises, escalates only real decisions, and reports plain outcomes. -- **A visible crew** - every crewmate works in its own tmux window or Herdr tab, or in an experimental Zellij tab, experimental cmux workspace, or experimental Orca terminal you can watch or type into; the first mate reconciles. +- **A visible crew** - every crewmate works in its own tmux window, Herdr tab or configured crew pane, or an experimental Zellij tab, experimental cmux workspace, or experimental Orca terminal you can watch or type into; the first mate reconciles. - **Disposable worktrees** - each task runs in a clean [treehouse](https://github.com/kunchenguid/treehouse) git worktree, or an Orca-managed worktree when `backend=orca`, so parallel work on one repo never collides. - **Two task shapes** - ship tasks deliver authorized changes; scout tasks leave standalone investigation reports when the intake contract warrants separate research. - **Explicit project modes** - each project ships via `no-mistakes`, `direct-PR`, or `local-only`, with an optional `+yolo` merge-autonomy flag, an optional `branch=` override for the default `fm/` ship-branch prefix, and an optional `forge=gerrit` binding under which the worker publishes a Gerrit change instead of opening a pull request. 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..68ee994a7fb 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 +# durable per-home crew workspaces 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,22 @@ 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 +# durable per-home crew workspace, at most this many panes per workspace +# before another crew workspace opens right after it. The workspace ids are +# 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:" +# (shared crew workspaces, split panes capped at per workspace: "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 +183,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 shared crew workspaces of split panes)" >&2 printf 'default\n' ;; esac @@ -326,8 +338,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 +352,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 +2651,258 @@ 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 ids for , one per line in the session's workspace order, +# keeping only ids still present there with this home's crew label; echo +# nothing when there is no usable record, which includes workspaces Herdr +# removed with their last pane. Returns 1 when the session's workspaces cannot +# be read, so an unreadable layout refuses instead of minting another crew +# workspace. Never mutates. +fm_backend_herdr_crew_workspace_recorded() { # + local state=$1 session=$2 record ids list + record=$(fm_backend_herdr_crew_record_path "$state" "$session") + { [ -f "$record" ] && [ ! -L "$record" ]; } || return 0 + ids=$(jq -R -s -c 'split("\n") | map(select(length > 0))' < "$record" 2>/dev/null) || return 0 + [ "$ids" != '[]' ] || 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 --argjson ids "$ids" --arg want "$(fm_backend_herdr_crew_workspace_label)" ' + .result.workspaces[] | select(.label == $want and (.workspace_id as $ws | $ids | any(. == $ws))) | .workspace_id + ' 2>/dev/null +} + +fm_backend_herdr_crew_record_write() { # ... + local state=$1 record tmp + record=$(fm_backend_herdr_crew_record_path "$state" "$2") + shift 2 + mkdir -p "$state" || return 1 + tmp=$(mktemp "$record.XXXXXX") || return 1 + if ! { printf '%s\n' "$@" > "$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 +# first recorded crew workspace in holding fewer than +# panes. When none has room, a new crew workspace is created, recorded, and +# ordered right after the last crew workspace, or after +# (or the unique home label) when it is the first, best-effort. The caller +# must hold the named-session presentation lock so the record, workspace +# 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 any 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 wsids ws wsid="" last_ws="" out="" panes all_panes="" tab_id="" pane="" count anchor husk husks="" focus_before + local created=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 + wsids=$(fm_backend_herdr_crew_workspace_recorded "$state" "$session") || { + echo "error: could not read herdr workspaces in session '$session' to find this home's crew workspaces" >&2 + return 1 + } + while IFS= read -r ws; do + [ -n "$ws" ] || continue + panes=$(fm_backend_herdr_cli "$session" pane list --workspace "$ws" 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 $ws (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 $ws (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) + last_ws=$ws + # The first crew workspace with room gets a split, so panes freed by + # cleanup refill before another workspace opens. + count=$(printf '%s' "$panes" | jq -r '.result.panes | length' 2>/dev/null) + if [ -z "$wsid" ] && [ "${count:-0}" -gt 0 ] && [ "$count" -lt "$cap" ]; then + wsid=$ws + all_panes=$panes + fi + done </dev/null)" \ + "$(fm_backend_herdr_crew_grid_columns "$cap")") + if [ -n "$anchor" ]; then + 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) + fi + else + 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 + 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 + # shellcheck disable=SC2086 # one live workspace id per line + fm_backend_herdr_crew_record_write "$state" "$session" $wsids "$wsid" || { + echo "error: could not record herdr crew workspace $wsid for session $session" >&2 + return 1 + } + if [ -n "$last_ws" ]; then + fm_backend_herdr_projection_order_best_effort "$session" "$wsid" \ + "$(fm_backend_herdr_crew_workspace_label)" "$last_ws" + else + fm_backend_herdr_projection_order_best_effort "$session" "$wsid" \ + "$(fm_backend_herdr_workspace_label)" "$parent_ws" + fi + 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 +3749,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 +3765,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 +3940,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 +3952,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 +3981,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