diff --git a/.agents/skills/harness-adapters/SKILL.md b/.agents/skills/harness-adapters/SKILL.md index 63f378bb7..29e8ffcc8 100644 --- a/.agents/skills/harness-adapters/SKILL.md +++ b/.agents/skills/harness-adapters/SKILL.md @@ -125,12 +125,16 @@ If such a dialog is showing, accept it from an active firstmate session using `F Claude renders a predicted-next-prompt suggestion as dim/faint text inside an otherwise-empty composer after a turn completes. A plain `tmux capture-pane` cannot tell that ghost text apart from typed text. -Firstmate launches every claude crewmate and secondmate with `CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false`, scoped to firstmate-launched agents through `bin/fm-spawn.sh`, so it never touches the captain's global config. +Firstmate applies `CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false` per kind through `bin/fm-spawn.sh`, scoped to firstmate-launched agents, so it never touches the captain's global config. +A claude crewmate (ship or scout) launches with the suggestion disabled: it is an autonomous worker the captain never drives from its composer, so the ghost text has no reader to help. +A claude secondmate launches with the native suggestion enabled, because the captain reads and drives that window and should see it exactly as in their own session. The CLI's `--prompt-suggestions` flag is print/SDK-mode only and does not suppress the interactive composer ghost text, verified empirically on v2.1.186. -As defense in depth for any pane that flag cannot reach, including the captain's own firstmate composer that away-mode reads, the shared `fm_composer_strip_ghost` extractor in `bin/fm-composer-lib.sh` removes dim/faint SGR 2 ghost runs before pending-input classification on both ANSI-capable readers (tmux and herdr). -Its broader dark-TRUECOLOR placeholder handling and dark-theme tradeoff are documented in `docs/herdr-backend.md`'s 2026-07-10 incident record. +Every pane the flag does not disable - a secondmate, and the captain's own firstmate composer that away-mode reads - relies on the shared `fm_composer_strip_ghost` extractor in `bin/fm-composer-lib.sh`, which removes dim/faint SGR 2 ghost runs before pending-input classification on both ANSI-capable readers (tmux and herdr). +The plain-screen backends that cannot strip ghost styling - orca, cmux, and zellij - all refuse `--secondmate` spawns in `bin/fm-spawn.sh`, so no secondmate can land on a reader without `fm_composer_strip_ghost`. +That extractor's broader dark-TRUECOLOR placeholder handling and dark-theme tradeoff are documented in `docs/herdr-backend.md`'s 2026-07-10 incident record. That styled capture is internal to the boolean detector only. `fm-peek` and every other human or LLM-facing capture path stays plain `tmux capture-pane` with no escape codes. +Note for a future reader: because those paths are plain, a peek of an IDLE secondmate can render its predicted next prompt as apparent typed input, bounded by AGENTS.md forbidding reads of a secondmate's chat and by the documented post-spawn trust-dialog peek above happening while the agent is still processing its charter, before any ghost renders. **Primary-session guard fact (verified 2026-07-04, Claude Code 2.1.201; preserved 2026-07-08, Claude Code 2.1.204).** This is separate from the per-task crewmate turn-end hook above (that one just `touch`es a marker file in a task's own `.claude/settings.local.json`). diff --git a/bin/fm-backend.sh b/bin/fm-backend.sh index d38bd9f06..c67ce49f7 100644 --- a/bin/fm-backend.sh +++ b/bin/fm-backend.sh @@ -705,12 +705,15 @@ fm_backend_target_exists() { # [expected-label] # dead - CONFIDENTLY not an agent: a bare shell (tmux) or a # structurally-gone/no-agent-registered pane (herdr). # unknown - anything ambiguous, unreadable, or unverified for this backend. -# Scoped to today's --secondmate-spawn-capable backends with an empirically -# verified classifier: tmux (docs/tmux-backend.md "Agent liveness probe") and -# herdr (docs/herdr-backend.md "Agent liveness probe reuses the husk -# classifier"). zellij, orca, and cmux report unknown until independently -# verified - future work, not a functional gap for the two backends -# --secondmate spawns actually support today plus tmux's reference path. +# tmux and herdr are exactly today's --secondmate-spawn-capable backends, and +# both have an empirically verified classifier: docs/tmux-backend.md "Agent +# liveness probe" and docs/herdr-backend.md "Agent liveness probe reuses the +# husk classifier". zellij, orca, and cmux report unknown until independently +# verified; fm-spawn.sh refuses --secondmate on all three (docs/configuration.md +# "Runtime backend" owns that contract). For orca and cmux no secondmate +# endpoint can exist at all, so their unknown is purely an ordinary-crewmate +# gap; for zellij it is that too, and additionally still reached on a LEGACY +# secondmate endpoint, never on a new one. # Callers must treat unknown exactly like an unreadable target: NEVER license # an action from it alone - the secondmate-liveness sweep gates a respawn on # `dead` only, precisely so a momentary read glitch can never duplicate a diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index c973e4416..e063e72f9 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -23,7 +23,8 @@ # auto-detected tmux stays silent; zellij and orca are never auto-detected. # codex-app is not a known backend yet; docs/codex-app-backend.md owns that # blocked backend contract. Default tmux spawns do not write backend= to meta; -# absent backend= means tmux. cmux does not support --secondmate spawns yet. +# absent backend= means tmux. orca, cmux, and zellij do not support +# --secondmate spawns yet. # A backend spawn refusal (missing dependency, version gate, unauthenticated # socket, or unsupported secondmate mode) is terminal for that selected backend; # callers must surface it instead of silently retrying another backend. @@ -178,13 +179,13 @@ else fi fm_backend_validate_spawn "$BACKEND" || exit 1 fm_backend_source "$BACKEND" || exit 1 -if [ "$BACKEND" = orca ] && [ "$KIND" = secondmate ]; then - echo "error: backend=orca does not support --secondmate spawns yet" >&2 - exit 1 -fi -if [ "$BACKEND" = cmux ] && [ "$KIND" = secondmate ]; then - echo "error: backend=cmux does not support --secondmate spawns yet" >&2 - exit 1 +if [ "$KIND" = secondmate ]; then + case "$BACKEND" in + orca | cmux | zellij) + echo "error: backend=$BACKEND does not support --secondmate spawns yet" >&2 + exit 1 + ;; + esac fi if [ "$BACKEND" = orca ]; then fm_backend_orca_runtime_check || exit 1 @@ -316,15 +317,33 @@ launch_template() { # shellcheck disable=SC2016 # single quotes are deliberate: $(cat ...) expands in the crewmate pane, not here case "$harness" in # CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false disables claude's interactive - # predicted-next-prompt ghost text, which renders as dim/faint text inside an - # otherwise-empty composer and would otherwise read like real typed input when - # firstmate captures the pane (see the harness-adapters skill). It is a per-launch env - # prefix scoped to this firstmate-launched agent; it never touches the captain's - # global config. The CLI's --prompt-suggestions flag is print/SDK-mode only and - # does NOT suppress the interactive ghost text (verified empirically), so the env - # var is the correct control. The dim-aware composer reader in fm-tmux-lib.sh is - # the defense-in-depth backstop for any pane this flag cannot reach. - claude) printf '%s' 'CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false claude --dangerously-skip-permissions __MODELFLAG____EFFORTFLAG__"$(cat __BRIEF__)"' ;; + # predicted-next-prompt ghost text, which renders as dim/faint (SGR 2) text inside + # an otherwise-empty composer. It is a per-launch env prefix scoped to this + # firstmate-launched agent; it never touches the captain's global config. The CLI's + # --prompt-suggestions flag is print/SDK-mode only and does NOT suppress the + # interactive ghost text (verified empirically), so the env var is the correct control. + # + # The prefix is applied per KIND, because the suggestion's value and its cost differ: + # ship/scout - DISABLED. An autonomous crewmate is a worker the captain never + # drives from its own composer, so the suggestion has no reader to help and is + # pure classifier risk. + # secondmate - ENABLED (no prefix). A secondmate is a captain-facing agent the + # captain does read and drive, so it shows the native suggestion exactly as the + # captain's own session does. Safety rests on the shared fm_composer_strip_ghost + # (bin/fm-composer-lib.sh), the one fleet-wide ANSI-aware extractor of real typed + # content, which every secondmate-reachable composer read routes through - the + # same backstop that already covers the captain's own firstmate pane, which this + # flag never reached either. The plain-screen readers that cannot strip ghost + # styling - orca, cmux, and zellij - all refuse --secondmate spawns above, so no + # secondmate can land on a reader without fm_composer_strip_ghost. See the + # harness-adapters skill for the contract that rule satisfies. + claude) + if [ "$kind" = secondmate ]; then + printf '%s' 'claude --dangerously-skip-permissions __MODELFLAG____EFFORTFLAG__"$(cat __BRIEF__)"' + else + printf '%s' 'CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false claude --dangerously-skip-permissions __MODELFLAG____EFFORTFLAG__"$(cat __BRIEF__)"' + fi + ;; codex) if [ "$kind" = secondmate ]; then printf '%s' 'codex __MODELFLAG____EFFORTFLAG__--dangerously-bypass-approvals-and-sandbox "$(cat __BRIEF__)"' diff --git a/bin/fm-watch.sh b/bin/fm-watch.sh index 4ea6da70a..c78333316 100755 --- a/bin/fm-watch.sh +++ b/bin/fm-watch.sh @@ -868,6 +868,7 @@ EOF if [ "$n" -ge 2 ] && ! window_is_busy "$w" "$tail40"; then # The pane is idle/stale at hash $h. Triage decides whether this wakes # firstmate. Detection itself is unchanged from above. + # Not dead code: a claude secondmate's native prompt suggestion churns its idle pane, so a paused one usually reaches handle_paused_stale via the hash-changed branch below, but a static paused pane still arrives here. if [ "$kind" = secondmate ]; then case "$(pause_state_class "$w" "$task")" in paused) handle_paused_stale "$w" "$task" "$h" ;; diff --git a/docs/architecture.md b/docs/architecture.md index 4c47f6152..369f89fc6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -85,10 +85,10 @@ 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, ANSI-preserved ghost/placeholder classification through the shared extractor, 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. +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, and `--secondmate` spawns are refused, as on Orca and cmux. 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. +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, as on Orca and zellij; `docs/configuration.md` owns that refusal contract, and the secondmate-capable backends are tmux and herdr. Codex App support is recorded in `docs/codex-app-backend.md`; it is not selectable as a runtime backend. ## Worktrees, not branches in your checkout diff --git a/docs/configuration.md b/docs/configuration.md index abdd1b281..7433935c3 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -48,7 +48,9 @@ See [`docs/cmux-backend.md`](cmux-backend.md#runtime-auto-detection) for why cmu Auto-detected herdr or cmux prints a stderr notice naming `config/backend` and `--backend tmux` as opt-outs; auto-detected tmux stays silent to preserve existing default behavior. Zellij and Orca are never auto-detected; select them by putting the name in a local `config/backend` file, by exporting `FM_BACKEND=`, or by telling the first mate in chat. Any value other than `tmux`, `herdr`, `zellij`, `orca`, or `cmux` is rejected until another adapter is implemented and verified. -`fm-spawn.sh` accepts `tmux`, `herdr`, `zellij`, `orca`, and `cmux` for ship and scout tasks; `backend=orca` and `backend=cmux` both still refuse `--secondmate` until secondmate launch semantics are designed for each. +`fm-spawn.sh` accepts `tmux`, `herdr`, `zellij`, `orca`, and `cmux` for ship and scout tasks; `backend=orca`, `backend=cmux`, and `backend=zellij` all still refuse `--secondmate`, for two distinct reasons. +`backend=orca` and `backend=cmux` refuse until secondmate launch semantics are designed for each. +`backend=zellij` refuses because it is a plain-screen reader with no ANSI channel, so it cannot strip claude's native prompt-suggestion ghost text through the shared `fm_composer_strip_ghost`. `codex-app` is not an accepted runtime backend yet; [`docs/codex-app-backend.md`](codex-app-backend.md) owns the Codex App boundary. The session-start secondmate liveness sweep uses a deeper `fm_backend_agent_alive` probe where verified. Today that probe can classify tmux and herdr secondmate endpoints as `alive`, `dead`, or `unknown`; zellij, Orca, and cmux report `unknown` until their own agent-process classifiers are verified. @@ -74,6 +76,8 @@ For normal herdr operations, `HERDR_SESSION` selects the named session, but dest 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, 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--`. +That sharing stays live for secondmate homes because `config/backend` is not inherited, so a secondmate home can select zellij itself and spawn its own ship and scout tabs into the same session. +Only a secondmate agent endpoint is legacy here, since `fm-spawn.sh` refuses a `--secondmate` launch on this backend. 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 0684bb8f9..253276297 100644 --- a/docs/herdr-backend.md +++ b/docs/herdr-backend.md @@ -744,7 +744,7 @@ The herdr incident regressions (`tests/fm-backend-herdr.test.sh`'s composer-stat The captain woke to find away-mode had never injected: 20 escalations buffered, the max-defer wedge marker at 30623s undelivered, the wake queue at 65. Daemon triage and buffering worked perfectly; the injection leg deferred EVERY attempt with `inject deferred: supervisor pane has pending input (non-empty composer)` - 6524 lifetime occurrences in the daemon log, 2144 of them from the single overnight daemon (`pid 94088`, `backend=herdr`, `target=default:w1:p3`), dominating every other defer reason. -**Root cause.** The primary firstmate runs claude, and claude-code renders a rotating prompt SUGGESTION as ghost text in an otherwise-empty composer (the primary does not set `CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false`; crews do, via `fm-spawn`, so crew panes never show it). +**Root cause.** The primary firstmate runs claude, and claude-code renders a rotating prompt SUGGESTION as ghost text in an otherwise-empty composer (the primary does not set `CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false`; ship/scout crewmates do, via `fm-spawn`, so their panes never show it). Captured read-only from the live primary pane (no Herdr lifecycle touched): ``` diff --git a/docs/tmux-backend.md b/docs/tmux-backend.md index 544781e39..5d623e08d 100644 --- a/docs/tmux-backend.md +++ b/docs/tmux-backend.md @@ -7,7 +7,7 @@ This is the setup guide; for the shared runtime-backend abstraction and selectio tmux is a terminal multiplexer. Firstmate gives each crewmate its own tmux window inside a session, so you can attach and watch a task work, or type into its window to intervene directly. -Pick tmux unless you have a specific reason to try an experimental backend (herdr, zellij, Orca, or cmux) - it is the fully verified reference path for secondmate homes, while Orca and cmux are the backends that do not support secondmate spawns. +Pick tmux unless you have a specific reason to try an experimental backend (herdr, zellij, Orca, or cmux) - it is the fully verified reference path for secondmate homes, while Orca, cmux, and zellij are the backends that do not support secondmate spawns. ## Prerequisites diff --git a/docs/zellij-backend.md b/docs/zellij-backend.md index c83daecab..91d0481bd 100644 --- a/docs/zellij-backend.md +++ b/docs/zellij-backend.md @@ -20,7 +20,9 @@ Prerequisites: Select zellij by putting `zellij` in a local `config/backend` file - the durable way to pick it - or by exporting `FM_BACKEND=zellij` when you launch your harness for a one-off session; telling the first mate in chat to use zellij also works. Unlike tmux and herdr, zellij is **never** auto-detected - it always requires an explicit choice. A zellij spawn refuses loudly before creating a session container or acquiring a ship/scout worktree if `zellij` or `jq` is missing or the installed zellij is older than 0.44. -For `--secondmate` launches, secondmate home sync and inherited-config propagation happen before this spawn-time backend gate. +A `--secondmate` launch is refused outright, before any secondmate home sync or inherited-config propagation, because zellij's plain `dump-screen` capture cannot strip claude's ghost text from a secondmate composer; see the harness-adapters skill. +No legacy zellij secondmate is expected in the wild, because the zellij backend is experimental and explicit-choice-only - it is never selected by runtime auto-detection. +Should a pre-existing one exist, teardown still works, so the migration is to retire it and recreate it on tmux. No first-run provisioning is needed beyond having `zellij` and `jq` on `PATH`; firstmate creates the session and tab it needs on first spawn. @@ -31,7 +33,7 @@ You do not need to attach for routine supervision: from an active firstmate sess Verify it works by spawning a trivial task with `--backend zellij` and confirming the task's meta records `backend=zellij` plus `zellij_session=`, `zellij_tab_id=`, and `zellij_pane_id=`; attaching to the session should show the new home-scoped tab title, such as `fm-firstmate-<8hex>-`. -Limitations: zellij is experimental, has no per-home workspace split (all tasks share one tab bar, unlike herdr), has no verified agent-process liveness classifier for the session-start secondmate sweep, and still carries the known gaps documented below (no native busy-state signal and a narrow focus-steal race on tab creation) - see "Known gaps left for a follow-up" at the end of this document. +Limitations: zellij is experimental, does not support `--secondmate` spawns, has no per-home workspace split (all tasks share one tab bar, unlike herdr), has no verified agent-process liveness classifier for the session-start secondmate sweep of any legacy zellij secondmate endpoint, and still carries the known gaps documented below (no native busy-state signal and a narrow focus-steal race on tab creation) - see "Known gaps left for a follow-up" at the end of this document. ## Status: experimental @@ -103,7 +105,7 @@ Zellij tasks additionally record: | Send + submit, composed | `paste` then `send-keys --pane-id Enter` | Zellij has no single-call atomic "type and submit" primitive (unlike tmux's `send-keys ... Enter` or herdr's `pane run`); `fm_backend_zellij_send_text_line` composes the two calls, which is the only form this adapter has for that operation. | | Bounded capture | `zellij action dump-screen --pane-id ` for 40 lines or fewer; `zellij action dump-screen --pane-id --full` above that threshold | Works for a background session with NO attached client (resolves report gap #1). No `--lines`-style bound flag exists at all (unlike herdr's buggy small-N `--lines`, there is simply no flag). Routine watcher-sized reads use zellij's viewport-only dump to avoid unbounded scrollback reads; larger explicit peeks request `--full` and trim to the caller's requested line count locally with `tail`. The tradeoff: on a very short terminal viewport, a 40-line routine read can see fewer than 40 lines and miss content above the visible screen. | | Busy state | *(no native primitive)* | D5 (`herdr-addendum.md`): zellij has no agent-state API. `fm_backend_busy_state`'s dispatcher (`bin/fm-backend.sh`) falls through to `unknown` for zellij via its wildcard case, exactly like tmux - the watcher's existing pane-hash + regex path is the only busy-state source for this backend. | -| Agent liveness | *(no verified primitive)* | `fm_backend_agent_alive` reports `unknown` for zellij, so `bin/fm-bootstrap.sh`'s session-start secondmate liveness sweep never auto-respawns a zellij secondmate endpoint, conservatively avoiding a false-dead reading that would create a duplicate secondmate supervisor in one home. | +| Agent liveness | *(no verified primitive)* | `fm_backend_agent_alive` reports `unknown` for zellij, so `bin/fm-bootstrap.sh`'s session-start secondmate liveness sweep never auto-respawns any legacy zellij secondmate endpoint, conservatively avoiding a false-dead reading that would create a duplicate secondmate supervisor in one home. New zellij secondmates cannot happen at all, since `fm-spawn.sh` refuses a `--secondmate` launch on this backend. | | Kill | `zellij action close-tab-by-id ` (tab id resolved fresh from the pane id when possible; teardown can pass recorded `zellij_tab_id` plus the expected caller-facing `fm-` label when the pane is already gone) | Unlike herdr (where closing a tab's only pane also closes the tab), closing a zellij pane with `close-pane` does NOT close the now-empty tab - it survives as an empty "ghost" entry in `list-tabs`. `close-tab-by-id` on a LIVE tab (with its pane still running) verified to cleanly remove both pane and tab in one call. Kill resolves the owning tab and closes by tab id; if teardown supplies an expected label, the tab id must still match it through the home-scoped-title or unambiguous legacy-title check before it is closed, including the recorded `zellij_tab_id` ghost-tab fallback. Best-effort (`\|\| true`), matching tmux's `kill-window` and herdr's `pane close` contract. | | Recovery / list-live | `zellij action list-tabs --json`, filter names starting with this home's own `fm--` prefix | Name-based, never trusts a stored pane id blindly - the same posture herdr's `list_live` takes. Scoped to this installation's own home-scoped prefix (see "Home-scoped tab titles" above), so it never lists another firstmate home's tabs; the adapter strips the tag back off and reports the plain `fm-` label. Does not attempt the legacy untagged-title fallback (that fallback is for a single already-known tab, not a bulk sweep). | | Session cleanup (test-only) | `zellij delete-session --force` | The single-call kill-and-delete form, gated behind `tests/zellij-test-safety.sh`'s guard (refuses an empty name, the literal `"firstmate"` default name, or a name not currently listed). Never `kill-all-sessions`/`delete-all-sessions` - see "Session safety" below. | @@ -216,9 +218,9 @@ The isolated zellij session and the scratch `FM_HOME`/project were fully torn do - **No event push at all**, not even herdr's semantic busy-state (D5): zellij has no analogue to herdr's `agent.get`, so `fm-watch.sh`'s existing pane-hash + `FM_BUSY_REGEX` poll loop is the ONLY event source for this backend, identical to the tmux path. This is the expected, designed-for outcome (D5 explicitly calls for "the poll-based capture/hash/busy-regex path, same vocabulary as tmux"), not a shortfall relative to the report. - **No verified agent-process liveness classifier.** The session-start secondmate liveness sweep therefore receives `unknown` from `fm_backend_agent_alive` for zellij and reports `SECONDMATE_LIVENESS: ... skipped: liveness probe inconclusive` instead of killing or respawning the endpoint. - This leaves a dead zellij secondmate for manual recovery, but avoids the worse failure mode of duplicating a live supervisor. + This leaves a dead LEGACY zellij secondmate for manual recovery, but avoids the worse failure mode of duplicating a live supervisor; no new zellij secondmate can be created, since `fm-spawn.sh` refuses a `--secondmate` launch on this backend. - **The focus-steal mitigation has a narrow race window.** Between `new-tab` (which steals focus immediately) and the follow-up `go-to-tab-by-id` restore call, an attached client's view is briefly on the new tab. No flag-based suppression exists to close this window entirely (see "Focus-steal on new-tab" above); a future zellij release may add one. - **A pane can still die after `target_ready` succeeds and before the operation runs.** Metadata-routed operations now verify the expected caller-facing `fm-` label through the home-scoped-title or unambiguous legacy-title check, as well as the pane id up front, but zellij's unconditional exit 0 still leaves this narrow time-of-check/time-of-use race for one-shot operations (see "Unconditional exit code 0" above). - **The `pwd`-probe workaround for worktree-path discovery is scoped to `fm-spawn.sh`'s own poll loop only** (see "Worktree-path discovery" above). It is not a general-purpose live-cwd primitive; a future caller needing a live cwd read for a zellij pane outside that narrow spawn-time context would need the same active-probe approach, not a passive JSON field. -- **No per-home container split**, unlike herdr's later P3 refinement (`docs/herdr-backend.md` "Task container shape"). This is a deliberate simplicity choice per the locked captain decision (D2: "zellij, content unchanged from the report"), not an oversight; if a captain later runs many concurrent secondmates on the zellij backend and wants per-home visual separation in the tab bar, that would be a natural follow-up mirroring herdr's workspace-per-home pass. Note this is a CONTAINER-level (visual tab-bar grouping) gap only - the cross-home NAME-collision gap this shared container shape used to carry (two homes' same-id tabs sending/peeking/closing each other) is closed by home-scoped tab titles, "Home-scoped tab titles" above. +- **No per-home container split**, unlike herdr's later P3 refinement (`docs/herdr-backend.md` "Task container shape"). This is a deliberate simplicity choice per the locked captain decision (D2: "zellij, content unchanged from the report"), not an oversight; if a captain later runs many concurrent homes' tasks on the zellij backend and wants per-home visual separation in the tab bar, that would be a natural follow-up mirroring herdr's workspace-per-home pass. A secondmate AGENT can no longer land here at all, but a secondmate home still selects its own backend, so its ship and scout tabs can share this session. Note this is a CONTAINER-level (visual tab-bar grouping) gap only - the cross-home NAME-collision gap this shared container shape used to carry (two homes' same-id tabs sending/peeking/closing each other) is closed by home-scoped tab titles, "Home-scoped tab titles" above. - **The untagged-legacy migration fallback has one residual ambiguity gap.** `fm_backend_zellij_tab_matches_label`'s bare-title fallback (for a tab spawned before home-scoping shipped) refuses rather than guesses when 2+ live tabs share the exact same untagged bare title - but a genuinely ambiguous case then requires manual intervention (tear down and respawn to get a new home-scoped title) rather than an automatic resolution. This is accepted as the honest trade for not needing a one-time re-tagging migration step; see "Home-scoped tab titles" above. diff --git a/tests/fm-backend-zellij.test.sh b/tests/fm-backend-zellij.test.sh index d7956338d..56a6f4c0e 100755 --- a/tests/fm-backend-zellij.test.sh +++ b/tests/fm-backend-zellij.test.sh @@ -1010,6 +1010,33 @@ test_scripts_reject_fm_target_label_mismatch() { pass "fm-send: fm-id zellij targets reject pane ids whose tab label no longer matches" } +test_spawn_refuses_zellij_secondmate_before_home_mutation() { + local home subhome data state config id out status + id="zelsmz1" + home="$TMP_ROOT/secondmate-refusal-home" + subhome="$TMP_ROOT/secondmate-refusal-subhome" + data="$home/data" + state="$home/state" + config="$home/config" + mkdir -p "$data" "$state" "$config" "$subhome/bin" "$subhome/data" "$subhome/state" "$subhome/projects" + printf '%s\n' "$id" > "$subhome/.fm-secondmate-home" + printf 'firstmate\n' > "$subhome/AGENTS.md" + printf 'claude\n' > "$config/crew-harness" + touch "$state/.last-watcher-beat" + set +e + out=$( FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" FM_STATE_OVERRIDE="$state" FM_DATA_OVERRIDE="$data" FM_CONFIG_OVERRIDE="$config" \ + FM_PROJECTS_OVERRIDE="$home/projects" FM_SPAWN_NO_GUARD=1 \ + "$ROOT/bin/fm-spawn.sh" "$id" "$subhome" claude --backend zellij --secondmate 2>&1 ) + status=$? + set +e + [ "$status" -ne 0 ] || fail "backend=zellij --secondmate should be refused" + assert_contains "$out" "backend=zellij does not support --secondmate spawns yet" \ + "zellij secondmate refusal should happen at backend selection" + assert_absent "$subhome/config/crew-harness" \ + "zellij secondmate refusal should not propagate inheritable config into the secondmate home" + pass "fm-spawn.sh --backend zellij --secondmate: refuses before secondmate-home mutation" +} + # shellcheck source=bin/fm-backend.sh . "$ROOT/bin/fm-backend.sh" @@ -1064,3 +1091,4 @@ test_send_text_submit_send_failed_when_pane_absent test_scripts_route_explicit_target_through_meta_backend test_scripts_verify_label_for_fm_targets test_scripts_reject_fm_target_label_mismatch +test_spawn_refuses_zellij_secondmate_before_home_mutation diff --git a/tests/fm-composer-ghost.test.sh b/tests/fm-composer-ghost.test.sh index 3be65186b..864ffca20 100755 --- a/tests/fm-composer-ghost.test.sh +++ b/tests/fm-composer-ghost.test.sh @@ -15,6 +15,14 @@ # pending, while still treating real (normal/bright) text as pending. # 3. The human/LLM-facing capture path (fm-peek.sh) stays PLAIN - no escape codes # ever reach firstmate's context. +# +# These guarantees are LOAD-BEARING, not belt-and-braces: a claude SECONDMATE now +# launches with the native predicted-next-prompt suggestion ENABLED (it is a +# captain-facing window the captain reads and drives), so its idle composer really +# does hold dim SGR 2 ghost text and this extractor is the only thing keeping that +# out of the pending-input verdict. bin/fm-spawn.sh still disables the suggestion +# for ship/scout crewmates; the kind split is pinned by +# tests/fm-spawn-dispatch-profile.test.sh. set -u # shellcheck source=tests/lib.sh diff --git a/tests/fm-spawn-dispatch-profile.test.sh b/tests/fm-spawn-dispatch-profile.test.sh index d549c9bce..9ff5d3196 100755 --- a/tests/fm-spawn-dispatch-profile.test.sh +++ b/tests/fm-spawn-dispatch-profile.test.sh @@ -123,6 +123,55 @@ test_no_profile_keeps_claude_launch_unchanged() { pass "no --model/--effort records defaults and keeps the claude launch byte-identical" } +# The claude launch carries CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false per KIND: +# ship/scout crewmates are autonomous workers whose composer the captain never +# drives, so the predicted-next-prompt ghost text is disabled; a secondmate is +# captain-facing and launches with the native suggestion ENABLED, relying on the +# shared fm_composer_strip_ghost extractor (pinned by tests/fm-composer-ghost.test.sh) +# to keep that dim ghost out of pending-input classification. +test_claude_prompt_suggestion_split_by_kind() { + local rec id sm status launch prefix expected + prefix='CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false ' + + # scout: keeps the disable prefix. + id=suggestion-scout-z17 + rec=$(make_spawn_case suggestion-scout claude "$id") + read_case_record "$rec" + run_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$LAUNCH_LOG" "$id" "$PROJ_DIR" --scout >/dev/null + status=$? + expect_code 0 "$status" "claude scout spawn should succeed" + assert_grep "kind=scout" "$HOME_DIR/state/$id.meta" "scout meta missing kind=scout" + launch=$(cat "$LAUNCH_LOG") + case "$launch" in + "$prefix"*) ;; + *) fail "claude scout launch lost the prompt-suggestion disable prefix: $launch" ;; + esac + + # secondmate: omits the disable prefix (native suggestions on). + id=suggestion-secondmate-z18 + rec=$(make_spawn_case suggestion-secondmate claude "$id") + read_case_record "$rec" + sm="$CASE_DIR/secondmate-home" + make_seeded_secondmate_home "$sm" "$id" + run_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$LAUNCH_LOG" "$id" "$sm" --secondmate >/dev/null + status=$? + expect_code 0 "$status" "claude secondmate spawn should succeed" + assert_grep "kind=secondmate" "$HOME_DIR/state/$id.meta" "secondmate meta missing kind=secondmate" + launch=$(cat "$LAUNCH_LOG") + case "$launch" in + *CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION*) fail "claude secondmate launch must not disable native prompt suggestions: $launch" ;; + esac + # The secondmate launch legitimately carries the home-isolation env prefix + # (owned by the FM_HOME propagation tests); pin only that the claude command + # itself is intact and unprefixed by the suggestion disable. + expected="claude --dangerously-skip-permissions \"\$(cat '$sm/data/charter.md')\"" + case "$launch" in + *"$expected") ;; + *) fail "claude secondmate launch changed"$'\n'"expected suffix: $expected"$'\n'"actual: $launch" ;; + esac + pass "claude disables prompt suggestions for ship/scout and enables them for a secondmate" +} + test_active_dispatch_profile_requires_explicit_harness_for_ship() { local rec id out status id=profile-required-ship-z11 @@ -385,6 +434,7 @@ test_active_dispatch_profile_does_not_block_secondmate_launch() { } test_no_profile_keeps_claude_launch_unchanged +test_claude_prompt_suggestion_split_by_kind test_active_dispatch_profile_requires_explicit_harness_for_ship test_active_dispatch_profile_requires_explicit_harness_for_scout test_active_dispatch_profile_allows_explicit_harness