diff --git a/.agents/skills/firstmate-orca/SKILL.md b/.agents/skills/firstmate-orca/SKILL.md index ae7d648c8ff..f8261cacdb1 100644 --- a/.agents/skills/firstmate-orca/SKILL.md +++ b/.agents/skills/firstmate-orca/SKILL.md @@ -51,7 +51,7 @@ Do not manually patch metadata to make an externally-created Orca terminal look ## Supervision Use `bin/fm-peek.sh`, `bin/fm-send.sh`, `bin/fm-crew-state.sh`, and `bin/fm-teardown.sh` for routine operation. -For steer messages, send short lines through `bin/fm-send.sh fm- '...'`. +For steer messages, send short lines through `bin/fm-send.sh '...'`; the stable `fm-` alias also works. Put long instructions in the task brief or a temporary file and point the crewmate at that file. When supervising, treat `state/.meta` as the routing record and Orca's own ids as backend implementation details. diff --git a/.agents/skills/harness-adapters/SKILL.md b/.agents/skills/harness-adapters/SKILL.md index c392e70c72a..a09e9b000e7 100644 --- a/.agents/skills/harness-adapters/SKILL.md +++ b/.agents/skills/harness-adapters/SKILL.md @@ -111,7 +111,7 @@ After those settings are loaded, hook command resolution is still cwd-sensitive | Skill invocation | `$` (e.g. `$no-mistakes`); `/` is claude-only and codex rejects it as "Unrecognized command" | A `$` invocation opens a `$`-autocomplete (skill) popup, the same hazard as the `/` slash popup: submitting too fast lets the popup swallow the Enter, so the invocation never lands. -`fm-send` handles it the same way it handles `/` - it gives the popup a longer settle (1.2s) between typing and the first Enter, with the target backend's submit retry as the safety net - but the `$` settle is scoped to `harness=codex`, read from the target's `state/.meta`. +`fm-send` handles it the same way it handles `/` - it gives the popup a longer settle (1.2s) between typing and the first Enter, with the target backend's submit retry as the safety net - but the `$` settle is scoped to `harness=codex`, read from the target metadata for exact task ids or legacy `fm-` labels. That scope matters because, unlike `/`, a leading `$` commonly starts ordinary text (`$5/month`, `$HOME`), so a universal `$` rule would needlessly slow plain steers to claude/opencode/pi; only a codex target receiving a `$...` message gets the popup-settle. An explicit `session:window` target has no meta, so its harness is unknown and treated as non-codex (the safe fast-path default). This is why the validation trigger (`$no-mistakes`) to a codex crew now lands on the first Enter instead of biting the popup. diff --git a/AGENTS.md b/AGENTS.md index 99e59add68d..155dfd2c421 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -467,10 +467,10 @@ Read `data/secondmates.md` before dispatching and compare the work request to ea Route by the nature of the task, not just the project name. A project may appear in several `projects:` clone lists, so choose the secondmate whose natural-language scope actually fits the work, such as triage versus feature development. If the resolved project is `local-only`, keep the work with the main firstmate even when a secondmate scope sounds relevant. -If a secondmate's scope fits, steer that secondmate with one concise instruction via `bin/fm-send.sh fm- ''` and let it run the normal lifecycle inside its own home. -The bare `fm-` target resolves through this home's `state/.meta`; pass an explicit backend target only when intentionally targeting an endpoint outside this firstmate home. +If a secondmate's scope fits, steer that secondmate with one concise instruction via `bin/fm-send.sh ''` and let it run the normal lifecycle inside its own home. +The stable `fm-` label printed by lifecycle commands still works, but exact task ids resolve first through this home's `state/.meta`; pass an explicit backend target containing `:` only when intentionally targeting an endpoint outside this firstmate home. A secondmate is itself a firstmate, so a request reaches it in its own chat, which you never read - the return channel that wakes you is its status file. -So `fm-send` to a bare `fm-` whose meta is `kind=secondmate` automatically prepends a from-firstmate marker (`bin/fm-marker-lib.sh`); the secondmate recognizes it and returns its answer via its status file, or via a doc under its home plus a status pointer for a detailed response, never only in chat. +So `fm-send` to a task selector whose meta is `kind=secondmate` automatically prepends a from-firstmate marker (`bin/fm-marker-lib.sh`); the secondmate recognizes it and returns its answer via its status file, or via a doc under its home plus a status pointer for a detailed response, never only in chat. Expect and read that response on the status/doc path the same way you read any other status signal; do not peek the secondmate's chat for the answer. A captain typing directly into the secondmate's window is unmarked and stays a conversational captain intervention, so do not relay captain-destined chat through this path; the marker is applied only by `fm-send` to a `kind=secondmate` target. Do not spawn a direct crewmate for work that belongs to a secondmate scope unless the secondmate is blocked or the captain explicitly redirects it. diff --git a/bin/backends/herdr.sh b/bin/backends/herdr.sh index 9889fff431a..87635b11fbc 100644 --- a/bin/backends/herdr.sh +++ b/bin/backends/herdr.sh @@ -25,8 +25,9 @@ # remainder is the whole pane id - fm_backend_herdr_parse_target splits on the # first colon only). This is the value stored in a herdr task's meta window= # field and is what fm_backend_resolve_selector already returns unchanged for -# both the fm- and explicit backend-target forms (that function has no -# herdr-specific logic; it just returns meta's window= verbatim). +# exact task-id, legacy fm-, and explicit backend-target forms (that +# function has no herdr-specific logic; it just returns meta's window= +# verbatim). # # Recovery/orphan discovery (ids may not deterministically match live state # after a server restart in a differently-configured session; see the diff --git a/bin/backends/tmux.sh b/bin/backends/tmux.sh index dc31f953054..da78f4457d4 100644 --- a/bin/backends/tmux.sh +++ b/bin/backends/tmux.sh @@ -22,7 +22,7 @@ . "$FM_BACKEND_LIB_DIR/fm-tmux-lib.sh" # fm_backend_tmux_resolve_bare_selector: the live-window-listing fallback for a -# selector that is neither "session:window" nor a bare "fm-" routed +# selector that is neither an explicit target nor a task selector routed # through meta - an ad hoc window name with no recorded task. Mirrors the # `tmux list-windows -a ... | grep` pipeline that used to live inline in # fm-send.sh's and fm-peek.sh's own (until now duplicated) resolve(). diff --git a/bin/backends/zellij.sh b/bin/backends/zellij.sh index 4df2ac21419..02fc5313fdc 100644 --- a/bin/backends/zellij.sh +++ b/bin/backends/zellij.sh @@ -83,8 +83,8 @@ # target. Mitigated: send/capture/cwd ops verify session liveness first # (fm_backend_zellij_session_exists, a passive list-sessions query, never # auto-creating), verify the specific pane still appears in list-panes JSON, -# and, for metadata-routed fm- operations, verify the pane's tab still -# matches the expected caller-facing task label through the home-scoped or +# and, for metadata-routed task selector operations, verify the pane's tab +# still matches the expected caller-facing task label through the home-scoped or # unambiguous legacy title before use. Kill verifies the session and, when # teardown supplies an expected tab label, verifies a tab id still matches # that label before closing it. Output-SHAPE validation (a bare integer tab @@ -595,8 +595,8 @@ fm_backend_zellij_list_live() { # # posture. Rare path in practice (zellij tasks normally carry meta); # best-effort. Not wired into fm_backend_resolve_selector's dispatcher # (bin/fm-backend.sh), mirroring herdr: that bare-selector fallback stays -# tmux-only by design, and zellij/herdr tasks are targeted via fm- meta or -# an explicit recorded target. +# tmux-only by design, and zellij/herdr tasks are targeted via task-selector +# meta or an explicit recorded target. fm_backend_zellij_resolve_bare_selector() { # local name=$1 scoped sessions session tabs tab_id count=0 pane_id bare_session='' bare_tab_id='' scoped=$(fm_backend_zellij_scoped_title "$name") diff --git a/bin/fm-backend.sh b/bin/fm-backend.sh index 8a69cafb13d..2aaeeaf30ad 100644 --- a/bin/fm-backend.sh +++ b/bin/fm-backend.sh @@ -335,14 +335,36 @@ fm_backend_meta_for_window() { # return 1 } -fm_backend_of_selector() { # - local raw=$1 resolved=$2 state=$3 meta +fm_backend_task_id_for_selector() { # + local raw=$1 state=$2 id + case "$raw" in + *:*) return 1 ;; + esac + if [ -f "$state/$raw.meta" ]; then + printf '%s' "$raw" + return 0 + fi case "$raw" in fm-*) - meta="$state/${raw#fm-}.meta" - [ -f "$meta" ] && { fm_backend_of_meta "$meta"; return 0; } + id=${raw#fm-} + [ -f "$state/$id.meta" ] || return 1 + printf '%s' "$id" + return 0 ;; esac + return 1 +} + +fm_backend_meta_for_selector() { # + local raw=$1 state=$2 id + id=$(fm_backend_task_id_for_selector "$raw" "$state") || return 1 + printf '%s/%s.meta' "$state" "$id" +} + +fm_backend_of_selector() { # + local raw=$1 resolved=$2 state=$3 meta + meta=$(fm_backend_meta_for_selector "$raw" "$state" 2>/dev/null || true) + [ -n "$meta" ] && { fm_backend_of_meta "$meta"; return 0; } if [ -n "$resolved" ]; then meta=$(fm_backend_meta_for_window "$resolved" "$state" 2>/dev/null || true) [ -n "$meta" ] && { fm_backend_of_meta "$meta"; return 0; } @@ -351,13 +373,10 @@ fm_backend_of_selector() { # } fm_backend_expected_label_of_selector() { # - local raw=$1 state=$2 meta - case "$raw" in - fm-*) - meta="$state/${raw#fm-}.meta" - [ -f "$meta" ] && printf '%s' "$raw" - ;; - esac + local raw=$1 state=$2 id + id=$(fm_backend_task_id_for_selector "$raw" "$state" 2>/dev/null || true) + [ -n "$id" ] && printf 'fm-%s' "$id" + return 0 } # fm_backend_source: source the named backend's adapter file, once per shell. @@ -404,15 +423,18 @@ fm_backend_source() { # } # fm_backend_resolve_selector: resolve a raw fm-send.sh/fm-peek.sh style -# selector to a live session-provider target. Three forms, in order: +# selector to a live session-provider target. Four forms, in order: # target with ":" used as-is (the escape hatch for a window/pane outside # this firstmate home) - backend-independent, a literal string. -# "fm-" routed through /.meta's backend target +# exact task id routed through /.meta's backend target # (`window=` normally, `terminal=` for Orca) - # backend-independent, a stored value, NOT re-verified # against a live backend inventory (matches today's # behavior: tmux window names can be trusted from meta # without a live re-check). +# "fm-" legacy task window label fallback routed through +# /.meta when no exact +# /fm-.meta exists. # anything else first matched against recorded `window=`/`terminal=` # metadata, then treated as an ad hoc bare window name and # resolved by searching the legacy tmux live inventory. @@ -423,16 +445,18 @@ fm_backend_resolve_selector() { # printf '%s' "$raw" return 0 ;; + esac + meta=$(fm_backend_meta_for_selector "$raw" "$state" 2>/dev/null || true) + if [ -n "$meta" ]; then + window=$(fm_backend_target_of_meta "$meta") + [ -n "$window" ] || { echo "error: no backend target recorded in $meta" >&2; return 1; } + printf '%s' "$window" + return 0 + fi + case "$raw" in fm-*) - meta="$state/${raw#fm-}.meta" - if [ ! -f "$meta" ]; then - echo "error: no metadata for $raw in $state; pass session:window to target a window outside this firstmate home" >&2 - return 1 - fi - window=$(fm_backend_target_of_meta "$meta") - [ -n "$window" ] || { echo "error: no backend target recorded in $meta" >&2; return 1; } - printf '%s' "$window" - return 0 + echo "error: no metadata for $raw in $state; pass session:window to target a window outside this firstmate home" >&2 + return 1 ;; *) meta=$(fm_backend_meta_for_window "$raw" "$state" 2>/dev/null || true) diff --git a/bin/fm-peek.sh b/bin/fm-peek.sh index 8d6183a7a47..97d2ffe2d25 100755 --- a/bin/fm-peek.sh +++ b/bin/fm-peek.sh @@ -1,8 +1,8 @@ #!/usr/bin/env bash # Print the tail of a crewmate endpoint (bounded, for cheap diagnosis). # Usage: fm-peek.sh [lines=40] -# may be a bare firstmate task name (fm-xyz), resolved through -# this home's state/.meta, or an explicit backend target. +# may be an exact task id, a legacy fm- task label resolved +# through this home's state/.meta, or an explicit backend target. set -eu SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" diff --git a/bin/fm-send.sh b/bin/fm-send.sh index af887ef8e08..f0d85d22988 100755 --- a/bin/fm-send.sh +++ b/bin/fm-send.sh @@ -1,8 +1,8 @@ #!/usr/bin/env bash # Send one line of literal text to a crewmate endpoint, then Enter. # Usage: fm-send.sh -# may be a bare firstmate task name (fm-xyz), resolved through -# this home's state/.meta, or an explicit backend target. +# may be an exact task id, a legacy fm- task label resolved +# through this home's state/.meta, or an explicit backend target. # Special keys instead of text: fm-send.sh --key Enter # Key support is backend-specific: tmux/herdr support Escape, Enter, and C-c; # Orca currently supports Enter and C-c only, and rejects Escape. @@ -18,7 +18,7 @@ # Slash commands, and codex `$...` skill invocations resolved through harness # meta, get a longer pre-Enter settle so completion popups do not swallow Enter. # -# From-firstmate marker: when the resolved target is a bare `fm-` whose meta +# From-firstmate marker: when the resolved target is a task selector whose meta # records kind=secondmate, the text is prefixed with the from-firstmate marker # (bin/fm-marker-lib.sh) so the secondmate routes its reply via its status file # or a status-pointed doc instead of stranding it in chat the main firstmate @@ -46,40 +46,31 @@ STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" RAW_TARGET=$1 T=$(fm_backend_resolve_selector "$1" "$STATE") +TARGET_META=$(fm_backend_meta_for_selector "$RAW_TARGET" "$STATE" 2>/dev/null || true) shift -# Mark a from-firstmate -> secondmate request. Only a bare `fm-` target, -# resolved through this home's meta and recording kind=secondmate, is marked: the +# Mark a from-firstmate -> secondmate request. Only a task selector resolved +# through this home's meta and recording kind=secondmate is marked: the # secondmate then routes its reply via the status path (see fm-marker-lib.sh). # An explicit backend target (the escape hatch for endpoints outside this home) # and any crewmate/scout target are left unmarked, and so is the --key path. MARK_PREFIX="" -case "$RAW_TARGET" in - fm-*) - meta="$STATE/${RAW_TARGET#fm-}.meta" - if [ -f "$meta" ] && grep -q '^kind=secondmate$' "$meta" 2>/dev/null; then - MARK_PREFIX="$FM_FROMFIRST_MARK" - fi - ;; -esac +if [ -n "$TARGET_META" ] && grep -q '^kind=secondmate$' "$TARGET_META" 2>/dev/null; then + MARK_PREFIX="$FM_FROMFIRST_MARK" +fi # Resolve the target's harness from its meta (recorded by fm-spawn), used only to -# scope the codex `$` popup-settle below. A bare fm- target carries +# scope the codex `$` popup-settle below. A task selector carries # meta; an explicit backend-target escape hatch has none, so its harness is # unknown and treated as non-codex (the safe default that keeps the fast path). -# The target's BACKEND comes from fm- meta, or from matching the resolved +# The target's BACKEND comes from selector meta, or from matching the resolved # explicit target back to recorded meta, then falls back to tmux. TARGET_HARNESS="" TARGET_BACKEND=$(fm_backend_of_selector "$RAW_TARGET" "$T" "$STATE") EXPECTED_LABEL=$(fm_backend_expected_label_of_selector "$RAW_TARGET" "$STATE") -case "$RAW_TARGET" in - fm-*) - meta="$STATE/${RAW_TARGET#fm-}.meta" - if [ -f "$meta" ]; then - TARGET_HARNESS=$(fm_meta_get "$meta" harness) - fi - ;; -esac +if [ -n "$TARGET_META" ]; then + TARGET_HARNESS=$(fm_meta_get "$TARGET_META" harness) +fi if [ "${1:-}" = "--key" ]; then fm_backend_send_key "$TARGET_BACKEND" "$T" "$2" "$EXPECTED_LABEL" diff --git a/docs/architecture.md b/docs/architecture.md index b2c79f4e69d..6b5adf397f3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -42,7 +42,7 @@ Its supervisor injection path supports tmux and herdr panes, with `FM_SUPERVISOR Pane existence, busy checks, composer checks, capture, and verified submit route through `bin/fm-backend.sh`: tmux keeps the same submit core used by the tmux send backend, while herdr uses native busy state, native agent-state submit confirmation on idle baselines, and its structural composer classifier for pending-input guards and submit fallback. Unsupported supervisor backends refuse at daemon startup. Stalled escalation delivery raises `state/.subsuper-inject-wedged` after `FM_MAX_DEFER_SECS` instead of silently deferring forever. -`fm-send.sh` selects a pre-Enter popup-settle for slash commands and for codex `$...` skill invocations using the target's recorded `harness=` meta, then adds its own `FM_SEND_SETTLE` pause after successful text sends so immediate peeks catch the receiving turn starting; the sub-supervisor uses only the shared submit core and does not pay that post-submit pause. +`fm-send.sh` selects a pre-Enter popup-settle for slash commands and for codex `$...` skill invocations using metadata-routed target `harness=` values, then adds its own `FM_SEND_SETTLE` pause after successful text sends so immediate peeks catch the receiving turn starting; the sub-supervisor uses only the shared submit core and does not pay that post-submit pause. ## Runtime session backends @@ -59,7 +59,7 @@ The deeper session-start agent-process liveness probe is separate from that busy Herdr is experimental and can be selected explicitly or by runtime auto-detection: treehouse remains the worktree provider for it exactly as it is for tmux (herdr is a session provider only), and its full verification - the container shape decision, created-vs-adopted default-tab prune safety, restored-layout husk respawn idempotency, verified CLI facts, a verified small-`--lines` capture bug and its workaround, and known gaps - is recorded in `docs/herdr-backend.md`. Herdr's container shape is workspace-per-home plus tab-per-task: the primary home uses workspace label `firstmate`, secondmate homes use `2ndmate-`, and recovery/list-live scopes to the current `FM_HOME`'s workspace. Zellij is experimental and selected only explicitly: treehouse remains its worktree provider too, and its full verification - the resolved "gaps to verify" list from the original design report, the unconditional-exit-0 CLI quirk and its mitigation, the focus-steal-on-new-tab finding, the home-scoped tab-title collision fix, and known gaps - is recorded in `docs/zellij-backend.md`. -Zellij's container shape is simpler than herdr's: one shared `firstmate` session, one tab per task, with no per-home workspace split; visible tab titles are scoped by the active home label plus a short hash of the resolved `FM_ROOT` path while callers keep using `fm-`. +Zellij's container shape is simpler than herdr's: one shared `firstmate` session, one tab per task, with no per-home workspace split; visible tab titles are scoped by the active home label plus a short hash of the resolved `FM_ROOT` path while task selectors can use exact ids or stable `fm-` labels. Orca is experimental and selected only explicitly: Orca owns both worktree and terminal lifecycle, records `orca_worktree_id=` and `terminal=`, and removes worktrees through `orca worktree rm` only after the usual firstmate teardown checks pass. Its current behavior and limitations are recorded in `docs/orca-backend.md`. 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 is a session provider only, like herdr/zellij), and its full verification - the socket access setup requirement with Automation mode recommended, the read-screen-fails-on-a-fresh-surface finding, the close-surface-refuses-on-the-last-surface finding, the source-verified runtime marker and fallback behavior, and known gaps - is recorded in `docs/cmux-backend.md`. 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. @@ -107,7 +107,7 @@ Seeding is transactional: if validation, cloning, initialization, or registry up `local-only` projects stay with the main first mate because they merge into the main local checkout instead of a remote-backed PR path. The same project may appear in multiple secondmate homes when their scopes differ, such as issue triage versus feature development. Secondmates are idle by default: after startup recovery reconciles only work already in their own home, an empty queue waits silently for routed tasks, and they never self-initiate surveys or audits. -Bare `fm-send.sh fm-` requests to a live `kind=secondmate` are prefixed with the from-firstmate marker from `bin/fm-marker-lib.sh`, so the secondmate returns terse answers through status lines and detailed answers through docs plus status pointers instead of replying only in its own chat. +Metadata-routed `fm-send.sh` requests to a live `kind=secondmate` are prefixed with the from-firstmate marker from `bin/fm-marker-lib.sh`, so the secondmate returns terse answers through status lines and detailed answers through docs plus status pointers instead of replying only in its own chat. Explicit backend-target sends and direct human typing stay unmarked, so captain intervention in a secondmate pane remains conversational. After seeding a secondmate, `fm-backlog-handoff.sh` moves already-judged in-scope queued items from the main backlog into that secondmate home so the domain queue starts in the right place. Idle secondmate panes are healthy; teardown is explicit and refuses while the secondmate home has in-flight work unless the captain has approved discard with `--force`. diff --git a/docs/cmux-backend.md b/docs/cmux-backend.md index 0c165b5d3bb..9c059aed41c 100644 --- a/docs/cmux-backend.md +++ b/docs/cmux-backend.md @@ -55,8 +55,8 @@ A cmux spawn refuses loudly, with an actionable message pointing back to this do No first-run provisioning beyond the socket-access setup above and having `jq` installed; firstmate creates the workspace it needs on first spawn, launching the app itself (`open -a cmux`) if it is not already running. Watching and attaching: firstmate uses one workspace per task in whatever cmux window is currently open. -Callers still use firstmate's universal `fm-` selector vocabulary, while the actual cmux workspace title is home-scoped as `fm--`, for example `fm-firstmate-<8hex>-cmux-e2e-t1` in the primary home or `fm-2ndmate--<8hex>-cmux-e2e-t1` in a secondmate home. -You do not need to bring the window forward for routine supervision: `bin/fm-peek.sh fm-` reads a task's surface without focusing it, and `bin/fm-send.sh fm- ""` steers it - workspace/surface/pane creation all default `focus` to `false`, so an unattended spawn never steals your view. +Callers can use exact task ids or stable `fm-` labels, while the actual cmux workspace title is home-scoped as `fm--`, for example `fm-firstmate-<8hex>-cmux-e2e-t1` in the primary home or `fm-2ndmate--<8hex>-cmux-e2e-t1` in a secondmate home. +You do not need to bring the window forward for routine supervision: `bin/fm-peek.sh ` reads a task's surface without focusing it, and `bin/fm-send.sh ""` steers it - workspace/surface/pane creation all default `focus` to `false`, so an unattended spawn never steals your view. Verify it works by spawning a trivial task with `--backend cmux` and confirming the task's meta records `backend=cmux` plus `cmux_workspace_id=` and `cmux_surface_id=`. The cmux sidebar should show a new `fm-firstmate-<8hex>-` workspace in the primary home. diff --git a/docs/configuration.md b/docs/configuration.md index 14eabbf3e23..0e0afb341b8 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -41,12 +41,18 @@ A herdr task additionally records `herdr_session=`, `herdr_workspace_id=`, `herd A zellij task additionally records `zellij_session=`, `zellij_tab_id=`, and `zellij_pane_id=`. An Orca task additionally records `orca_worktree_id=` and `terminal=`, with `window=fm-` kept as the shared firstmate alias. A cmux task additionally records `cmux_workspace_id=` and `cmux_surface_id=`. +Task selectors for `fm-peek.sh`, `fm-send.sh`, and `fm-crew-state.sh` resolve centrally through `fm_backend_resolve_selector`. +A selector containing `:` is passed through as an explicit backend endpoint escape hatch. +Otherwise an exact task id matching `state/.meta` wins before the legacy `fm-` label fallback, so task ids that themselves start with `fm-` route to their own metadata instead of being stripped. +A metadata-routed selector returns the recorded backend target (`terminal=` for Orca, otherwise `window=`), and matching explicit targets can still recover the recorded backend when metadata contains the same endpoint. +Only metadata-routed task selectors carry secondmate-marker and Codex-harness context; explicit endpoint escape hatches do not. +`fm-teardown.sh ` takes a task id directly and uses the same recorded backend target fields after loading `state/.meta`. Herdr workspaces are derived from `FM_HOME`: the primary home uses `firstmate`, and a secondmate home marked by `.fm-secondmate-home` uses `2ndmate-`. Spawn, list-live, and recovery paths read that label from the active home, so a secondmate's own crewmates stay inside that secondmate home's herdr space. For normal herdr operations, `HERDR_SESSION` selects the named session, but destructive test cleanup must not rely on `HERDR_SESSION` alone. Use the explicit guarded cleanup path described in [`docs/herdr-backend.md`](herdr-backend.md) instead of `herdr server stop`. For normal zellij operations, `FM_ZELLIJ_SESSION` selects the named session and defaults to `firstmate`. -Zellij has no per-home workspace split: primary and secondmate tasks share that one session, callers keep using `fm-`, and visible tab titles are scoped by the active `FM_HOME` readable label plus a short hash of the resolved `FM_ROOT` path as `fm--`. +Zellij has no per-home workspace split: primary and secondmate tasks share that one session, task selectors can be exact ids or stable `fm-` labels, and visible tab titles are scoped by the active `FM_HOME` readable label plus a short hash of the resolved `FM_ROOT` path as `fm--`. Use the guarded cleanup path described in [`docs/zellij-backend.md`](zellij-backend.md) instead of `kill-all-sessions` or `delete-all-sessions`. cmux has no session layer at all - one workspace per task, in whatever cmux window is open - and its socket password (when configured) is read from local, gitignored `config/cmux-socket-password` under the effective config directory, never committed. The caller-facing label remains `fm-`, but the actual cmux workspace title is scoped by the active `FM_HOME` readable label plus a short hash of the resolved `FM_ROOT` path as `fm--`. diff --git a/docs/herdr-backend.md b/docs/herdr-backend.md index c07af911a5c..130221060fd 100644 --- a/docs/herdr-backend.md +++ b/docs/herdr-backend.md @@ -163,8 +163,9 @@ A workspace whose label this adapter did not derive (see "Label derivation" abov A herdr task's `window=` meta field holds `:`, for example `default:w1:p2`. The pane id itself contains a colon, so the adapter splits on the FIRST colon only, never on every colon. This mirrors tmux's `session:window` target shape closely enough that `fm_backend_resolve_selector` (in `bin/fm-backend.sh`) needed no backend-specific logic at all - it already just returns a task's recorded `window=` value verbatim. -Operational commands should prefer the bare `fm-` form, which resolves through this home's metadata. -An explicit herdr target also works when it exactly matches recorded metadata, but ad hoc non-`fm-` bare-name lookup remains the legacy tmux live-window fallback. +Operational commands should prefer the exact task id or stable `fm-` label, both of which resolve through this home's metadata. +Exact task ids win first, so ids beginning with `fm-` are not stripped as legacy labels. +An explicit herdr target also works when it exactly matches recorded metadata, but ad hoc bare-name lookup with no metadata remains the legacy tmux live-window fallback for non-`fm-` names. Herdr tasks additionally record: diff --git a/docs/orca-backend.md b/docs/orca-backend.md index ace5765464f..5c68473db94 100644 --- a/docs/orca-backend.md +++ b/docs/orca-backend.md @@ -25,7 +25,7 @@ Spawn fails closed if the runtime is not ready. The first spawn against a given project also auto-registers that project's repo in Orca (`orca repo add --path`) if it is not already registered - no manual registration step is needed. Watching and attaching: Orca owns both the worktree and the terminal for its tasks, so there is nothing to attach to outside the Orca app itself - open the app and find the terminal for the task (recorded as `terminal=` in the task's meta, with `window=fm-` as the shared firstmate alias). -You do not need to open the app for routine supervision: `bin/fm-peek.sh fm-` reads a task's terminal without opening Orca, and `bin/fm-send.sh fm- ""` steers it (Enter and Ctrl-C are supported; Escape is not). +You do not need to open the app for routine supervision: `bin/fm-peek.sh ` reads a task's terminal without opening Orca, and `bin/fm-send.sh ""` steers it (the stable `fm-` alias also works; Enter and Ctrl-C are supported; Escape is not). Verify it works by spawning a trivial task with `--backend orca` and confirming the task's meta records `backend=orca`, `terminal=`, `orca_worktree_id=`, and `worktree=`; the Orca app should show a new terminal for the task. @@ -60,7 +60,8 @@ orca_worktree_id= worktree= ``` -`window=` remains the shared firstmate selector field used by `fm-peek.sh`, `fm-send.sh`, `fm-watch.sh`, `fm-crew-state.sh`, and `fm-teardown.sh`. +`window=` remains the shared firstmate alias used by selector-driven supervision tools after a task selector has resolved through metadata. +`fm-teardown.sh ` uses the same recorded fields after loading `state/.meta`. For Orca, `window=` keeps the stable firstmate alias while `terminal=` carries the stable Orca terminal handle that backend operations use. The recorded `backend=orca` field tells shared call sites to route capture, send, interrupt, and close through `bin/backends/orca.sh` instead of tmux assumptions. diff --git a/docs/scripts.md b/docs/scripts.md index 8fb1add3271..05a4f8c39b5 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -18,7 +18,7 @@ If you have changed away from the firstmate home in an interactive shell, invoke | `fm-home-seed.sh` | Lease/provision a secondmate home transactionally, clone projects, initialize gates, and maintain `data/secondmates.md` | | `fm-spawn.sh` | Spawn one task, several `id=repo` pairs, or a persistent secondmate with `--secondmate`; accepts concrete `--harness`, `--model`, `--effort`, and `--backend` axes; rejects `backend=codex-app`; ship/scout spawns require an explicit resolved harness when dispatch profiles are active and an isolated worktree, install per-harness turn-end signaling, and secondmate spawns resolve the secondmate harness plus optional `config/secondmate-harness` model/effort tokens, locally sync the home, propagate declared inheritable config, land herdr tabs in the target home's workspace, land home-scoped zellij tabs in the selected shared zellij session, land cmux workspaces in the shared cmux app, or create Orca worktrees/terminals before launch | | `fm-dispatch-select.sh` | Resolve one already-matched crew-dispatch rule to a concrete JSON profile; owns deterministic `quota-balanced` selection and quota-axi fallback behavior | -| `fm-backend.sh` | Runtime session-provider backend selector with explicit/env/config/runtime auto-detection precedence, meta helper, selector resolver, spawn-capability validation, operation dispatcher, and shell-portable backend-name membership for bash-sourced scripts or zsh-sourced diagnostics; deliberately keeps `codex-app` out of known/spawn-capable backends; defaults absent `backend=` meta to `tmux`; `fm_backend_target_exists` is a cheap read-only alive/dead endpoint check that never starts a server or session; `fm_backend_agent_alive` is the deeper agent-process liveness probe used by the session-start secondmate sweep; `fm_backend_composer_state` exposes backend composer checks for pending-input guards and submit fallbacks | +| `fm-backend.sh` | Runtime session-provider backend selector with explicit/env/config/runtime auto-detection precedence, meta helper, exact-id-first selector resolver, spawn-capability validation, operation dispatcher, and shell-portable backend-name membership for bash-sourced scripts or zsh-sourced diagnostics; deliberately keeps `codex-app` out of known/spawn-capable backends; defaults absent `backend=` meta to `tmux`; `fm_backend_target_exists` is a cheap read-only alive/dead endpoint check that never starts a server or session; `fm_backend_agent_alive` is the deeper agent-process liveness probe used by the session-start secondmate sweep; `fm_backend_composer_state` exposes backend composer checks for pending-input guards and submit fallbacks | | `fm-backend-hometag-lib.sh` | Shared home-tag derivation for zellij tab titles and cmux workspace titles, using the active `FM_HOME` label plus a short hash of the resolved `FM_ROOT` path | | `backends/tmux.sh` | Verified tmux session-provider adapter used by `fm-backend.sh`; owns create, send, capture, current-path, live-window, agent-process liveness, and kill primitives | | `backends/herdr.sh` | Experimental herdr session-provider adapter used by `fm-backend.sh`; owns version/tool gating, per-home workspace/tab creation, created-vs-adopted default-tab prune safety, restored-layout husk respawn replacement, agent-process liveness through the husk classifier, session-scoped CLI calls, send with native agent-state submit confirmation plus composer-state guard/fallback, capture, native busy-state, current-path, label-based live discovery, and kill primitives | @@ -42,7 +42,7 @@ If you have changed away from the firstmate home in an interactive shell, invoke | `fm-wake-drain.sh` | Atomically drain queued watcher wakes before handling supervision work, then run the watcher-liveness guard | | `fm-wake-lib.sh` | Shared durable wake queue, portable lock helpers, path-age helpers, and watcher identity/health helpers sourced by the watcher, drain, arm, guard, turn-end guard, daemon, and teardown | | `fm-classify-lib.sh` | Shared captain-relevant wake classifier sourced by the watcher and daemon, plus the watcher's provably-working predicate | -| `fm-send.sh` | Send one verified literal line or backend-supported `--key` through the target's recorded runtime backend; exits non-zero on confirmed swallowed Enter; bare `kind=secondmate` targets are marked as from-firstmate; slash commands and codex `$...` skill invocations get popup-settle before backend-specific submit verification; text sends pause `FM_SEND_SETTLE` seconds after success | +| `fm-send.sh` | Send one verified literal line or backend-supported `--key` through the target's recorded runtime backend; exits non-zero on confirmed swallowed Enter; metadata-routed `kind=secondmate` task selectors are marked as from-firstmate; slash commands and codex `$...` skill invocations get popup-settle before backend-specific submit verification; text sends pause `FM_SEND_SETTLE` seconds after success | | `fm-tmux-lib.sh` | Shared tmux pane primitives for busy detection, dim-ghost-aware and border-aware composer detection, and verified submit retry | | `fm-peek.sh` | Print a bounded tail of a crewmate endpoint through the target's recorded runtime backend | | `fm-pr-check.sh` | Record `pr=` and GitHub's `pr_head=` when available for a PR-ready task, then arm the watcher's merge poll | diff --git a/docs/zellij-backend.md b/docs/zellij-backend.md index e130e6b8603..88779f2e8f2 100644 --- a/docs/zellij-backend.md +++ b/docs/zellij-backend.md @@ -57,7 +57,8 @@ No empirical evidence surfaced during verification that forces a different conta Because every task in every firstmate home - primary or secondmate - shares this ONE session's tab bar with no per-home container split, and zellij enforces no tab-name uniqueness at all (verified: two tabs can share a name), two firstmate homes whose task ids happen to collide could send/peek/close each other's tabs. This is the exact gap a captain-directed no-mistakes review gate caught for the cmux backend (`docs/cmux-backend.md` "Task container shape") - cmux's fix was ported here for the identical reason, sharing its tag-derivation code (`bin/fm-backend-hometag-lib.sh`). -The caller-facing task label stays `fm-` everywhere (meta, `fm-send.sh`/`fm-peek.sh` selectors, briefs), but the actual zellij tab title a NEW task's tab is created with is home-scoped: `fm--`. +The caller-facing task label stays `fm-` in meta and briefs, while `fm-send.sh` and `fm-peek.sh` also accept exact task ids before the legacy `fm-` selector fallback. +The actual zellij tab title a NEW task's tab is created with is home-scoped: `fm--`. `` is `firstmate` for the primary home, or `2ndmate-` when `$FM_HOME/.fm-secondmate-home` contains a secondmate id, plus a short stable hash of the resolved `FM_ROOT` path - the same identity scheme as cmux's home label (`docs/cmux-backend.md` "Task container shape"), so e.g. `fm-firstmate-a1b2c3d4-fix-login-k3` or `fm-2ndmate-sm1-9f8e7d6c-fix-login-k3`. The path hash means even two independent PRIMARY installations on one machine (each with no `.fm-secondmate-home` marker, so both would otherwise resolve to the same `firstmate` prefix) still get distinct tags. `fm_backend_zellij_create_task` creates every new tab with this scoped title and checks for a duplicate against the scoped title, never the bare label. @@ -77,7 +78,7 @@ This is accepted, exactly as it is for cmux: a task's own recorded worktree path A zellij task's `window=` meta field holds `:`, for example `firstmate:7`. The pane id is a bare non-negative integer with no embedded colon (simpler than herdr's own pane-id shape, which itself contains a colon), so splitting on the first colon is trivially correct. This mirrors tmux's `session:window` and herdr's `session:pane` target shapes closely enough that `fm_backend_resolve_selector` (`bin/fm-backend.sh`) needed no zellij-specific logic at all. -When a caller reaches a zellij endpoint through firstmate metadata (`fm-` or a meta scan), it also supplies the expected caller-facing tab label `fm-` to the zellij adapter, which internally checks it against the home-scoped title (falling back to the unambiguous-untagged legacy match described above). +When a caller reaches a zellij endpoint through firstmate metadata (an exact task id, a legacy `fm-` selector, or a meta scan), it also supplies the expected caller-facing tab label `fm-` to the zellij adapter, which internally checks it against the home-scoped title (falling back to the unambiguous-untagged legacy match described above). That label check prevents a stale numeric pane id from being trusted after an external session deletion/recreation, or from being trusted for a different firstmate home's same-named tab; explicit raw `session:pane` targets remain a pane-existence-only escape hatch because there is no metadata label to verify. Zellij tasks additionally record: diff --git a/tests/fm-backend.test.sh b/tests/fm-backend.test.sh index 9ea34fb55f2..ee8e01392d3 100755 --- a/tests/fm-backend.test.sh +++ b/tests/fm-backend.test.sh @@ -514,12 +514,30 @@ test_resolve_selector_three_forms() { local state=$TMP_ROOT/resolve-state fakebin out mkdir -p "$state" fm_write_meta "$state/task1.meta" "window=firstmate:fm-task1" + fm_write_meta "$state/dotfiles-d6.meta" "window=default:wA:p2" "backend=herdr" + fm_write_meta "$state/fm-turnend-all-harnesses-v9.meta" "window=default:wB:p3" "backend=herdr" [ "$(fm_backend_resolve_selector 'sess:win' "$state")" = "sess:win" ] \ || fail "explicit session:window should be used as-is" + [ "$(fm_backend_resolve_selector 'dotfiles-d6' "$state")" = "default:wA:p2" ] \ + || fail "bare non-fm task id should resolve through exact metadata" + [ "$(fm_backend_of_selector 'dotfiles-d6' 'default:wA:p2' "$state")" = herdr ] \ + || fail "bare non-fm task id should use its recorded backend" + [ "$(fm_backend_expected_label_of_selector 'dotfiles-d6' "$state")" = "fm-dotfiles-d6" ] \ + || fail "bare non-fm task id should report the spawned fm- label" + + [ "$(fm_backend_resolve_selector 'fm-turnend-all-harnesses-v9' "$state")" = "default:wB:p3" ] \ + || fail "exact fm-* task id should resolve through its exact metadata" + [ "$(fm_backend_of_selector 'fm-turnend-all-harnesses-v9' 'default:wB:p3' "$state")" = herdr ] \ + || fail "exact fm-* task id should use exact metadata without stripping fm-" + [ "$(fm_backend_expected_label_of_selector 'fm-turnend-all-harnesses-v9' "$state")" = "fm-fm-turnend-all-harnesses-v9" ] \ + || fail "exact fm-* task id should report the spawned fm- label" + [ "$(fm_backend_resolve_selector 'fm-task1' "$state")" = "firstmate:fm-task1" ] \ - || fail "fm- should resolve through meta's window=" + || fail "legacy fm- label should resolve through .meta's window=" + [ "$(fm_backend_expected_label_of_selector 'fm-task1' "$state")" = "fm-task1" ] \ + || fail "legacy fm- label should preserve its backend label" out=$(fm_backend_resolve_selector 'fm-missing' "$state" 2>&1) && fail "fm- with no meta should fail" assert_contains "$out" "no metadata for fm-missing" "missing-meta error text changed" @@ -535,26 +553,33 @@ SH chmod +x "$fakebin/tmux" out=$(PATH="$fakebin:$PATH" fm_backend_resolve_selector 'fm-adhoc' "$state" 2>&1) || true # fm-adhoc carries no meta file, so it is NOT the bare-name fallback path - it - # is the fm-* meta-miss error path (a bare fm-* selector always routes through - # meta; only a NON fm-* bare name falls through to the live-window search). + # is the fm-* meta-miss error path after exact-id and legacy-label metadata + # lookup both miss. + # Only a NON fm-* bare name falls through to the live-window search. assert_contains "$out" "no metadata for fm-adhoc" "an fm-* selector must always require meta, not silently fall back to a live search" out=$(PATH="$fakebin:$PATH" fm_backend_resolve_selector 'adhoc' "$state") [ "$out" = "firstmate:adhoc" ] || fail "an ad hoc bare name should resolve via the tmux live-window fallback, got '$out'" - pass "fm_backend_resolve_selector: session:window literal, fm- via meta (always, even when the meta is missing), ad hoc bare name via tmux list-windows" + pass "fm_backend_resolve_selector: session:window literal, exact task id first, legacy fm- label fallback, ad hoc bare name via tmux list-windows" } test_backend_of_selector_matches_explicit_target_meta() { local state=$TMP_ROOT/backend-selector-state mkdir -p "$state" fm_write_meta "$state/herdr-task.meta" "window=default:w1:p2" "backend=herdr" + fm_write_meta "$state/dotfiles-d6.meta" "window=default:wA:p2" "backend=herdr" + fm_write_meta "$state/fm-turnend-all-harnesses-v9.meta" "window=default:wB:p3" "backend=herdr" fm_write_meta "$state/tmux-task.meta" "window=firstmate:fm-tmux-task" fm_write_meta "$state/custom-window-task.meta" "window=custom-window" fm_write_meta "$state/orca-task.meta" "window=fm-orca-task" "terminal=term-orca-task" "backend=orca" + [ "$(fm_backend_of_selector 'dotfiles-d6' 'default:wA:p2' "$state")" = herdr ] \ + || fail "bare non-fm task id selector should use its recorded backend" + [ "$(fm_backend_of_selector 'fm-turnend-all-harnesses-v9' 'default:wB:p3' "$state")" = herdr ] \ + || fail "exact fm-* task id selector should use exact metadata before legacy stripping" [ "$(fm_backend_of_selector 'fm-herdr-task' 'default:w1:p2' "$state")" = herdr ] \ - || fail "bare fm- selector should use its recorded backend" + || fail "legacy fm- selector should use its recorded backend" [ "$(fm_backend_resolve_selector 'fm-orca-task' "$state")" = term-orca-task ] \ || fail "Orca fm- selector should resolve to terminal=, not window=" [ "$(fm_backend_resolve_selector 'term-orca-task' "$state")" = term-orca-task ] \ @@ -570,7 +595,7 @@ test_backend_of_selector_matches_explicit_target_meta() { [ "$(fm_backend_of_selector 'manual:outside' 'manual:outside' "$state")" = tmux ] \ || fail "explicit target with no matching metadata should keep the tmux compatibility default" - pass "fm_backend_of_selector: fm- and matching explicit targets inherit metadata backend" + pass "fm_backend_of_selector: exact task ids, legacy fm- labels, and matching explicit targets inherit metadata backend" } # --- old vs new: fm-send.sh -------------------------------------------------- diff --git a/tests/fm-send-popup-settle.test.sh b/tests/fm-send-popup-settle.test.sh index fcf0d2b66a3..685ce627abb 100755 --- a/tests/fm-send-popup-settle.test.sh +++ b/tests/fm-send-popup-settle.test.sh @@ -67,7 +67,7 @@ SH printf '%s\n' "$fb" } -# first_settle