From 171207c8fcf4888439620febb741037888057347 Mon Sep 17 00:00:00 2001 From: Kun Chen <3233006+kunchenguid@users.noreply.github.com> Date: Fri, 10 Jul 2026 05:05:46 -0700 Subject: [PATCH 01/35] fix: ignore secondmate home marker during sync (#417) * fix: gitignore the secondmate home marker bin/fm-home-seed.sh writes an untracked .fm-secondmate-home marker into every seeded secondmate home. A secondmate home is a worktree of the firstmate repo, so any plain `git status --porcelain` dirtiness check counted the untracked marker and the home read as dirty forever: fleet-sync reported it STUCK and the local fast-forward convergence sweeps risked leaving it stale on firstmate updates. Add .fm-secondmate-home to the tracked .gitignore so the marker is invisible to every dirtiness check uniformly, without weakening fleet-sync's deliberate untracked-counting for project clones. Convergence chicken-and-egg: existing homes predate the fix and it only arrives by fast-forward. The already-present marker-tolerant ff-skip (ignore_seed_marker=yes, used by the bootstrap sweep, /updatefirstmate, and spawn pre-launch) advances such a home past the fix commit, after which .gitignore takes over - no hand intervention. Tests in tests/fm-secondmate-sync.test.sh cover a freshly seeded home reading clean, an existing marker-only home converging then reading clean, and a genuinely dirty home still skipping. * no-mistakes(review): Captain: document standalone-clone update path * no-mistakes(document): Document secondmate marker migration --- .../skills/secondmate-provisioning/SKILL.md | 2 + .gitignore | 1 + AGENTS.md | 7 +- CONTRIBUTING.md | 2 +- bin/fm-bootstrap.sh | 7 +- bin/fm-ff-lib.sh | 17 ++- bin/fm-home-seed.sh | 2 +- docs/architecture.md | 3 + docs/configuration.md | 5 + docs/scripts.md | 2 +- tests/fm-secondmate-sync.test.sh | 109 ++++++++++++++++++ 11 files changed, 142 insertions(+), 15 deletions(-) diff --git a/.agents/skills/secondmate-provisioning/SKILL.md b/.agents/skills/secondmate-provisioning/SKILL.md index 00d7f591789..ddedc618fc5 100644 --- a/.agents/skills/secondmate-provisioning/SKILL.md +++ b/.agents/skills/secondmate-provisioning/SKILL.md @@ -59,6 +59,7 @@ The slot stays reserved across restarts until the lease is released. Release happens only on explicit retirement or seed rollback, never on routine restart or recovery. `bin/fm-home-seed.sh` copies the charter into the secondmate home as `data/charter.md`. +It also writes the required `.fm-secondmate-home` identity marker, which is gitignored and must remain in place for home validation. `bin/fm-spawn.sh --secondmate` launches it through the secondmate harness path, resolving `config/secondmate-harness` -> `config/crew-harness` -> the primary's own harness unless an explicit per-spawn harness override is passed. `config/secondmate-harness` may also pin a concrete model and effort for the secondmate agent, in the SAME file rather than a new one: the format is a single whitespace-separated line ` [] []`, with only the first non-empty, non-comment line parsed. @@ -71,6 +72,7 @@ Because this resolves from the file on every spawn, the pin is durable across ev This is secondmate-only: crewmate/scout model resolution is untouched by this file. Before launch, `fm-spawn.sh --secondmate` locally fast-forwards the home to the primary firstmate checkout's current default-branch commit when it is safe; dirty, diverged, or in-flight homes launch unchanged with a warning. +That no-fetch path advances a linked worktree immediately; a standalone clone that lacks the target receives firstmate updates through `/updatefirstmate`'s origin refresh. The same launch also propagates the primary's declared inheritable local config, currently `config/crew-dispatch.json`, `config/crew-harness`, and `config/backlog-backend`, into the secondmate home's `config/`. `config/secondmate-harness` is not inherited because it is only the primary's knob for launching secondmate agents. For already-live secondmates, use `bin/fm-config-push.sh` to push a mid-session inherited-config change without running the tracked-file fast-forward or nudging the agents. diff --git a/.gitignore b/.gitignore index dc785fb89fc..7e44dc4939d 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ state/ data/ .no-mistakes/ .lavish/ +.fm-secondmate-home .DS_Store .env config/crew-harness diff --git a/AGENTS.md b/AGENTS.md index a07d6dcdbe5..fa2e82cb76f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -149,9 +149,10 @@ Tell the captain another active session is already managing the work and operate Bootstrap is detect, then consent, then install. Never install anything the captain has not approved in this session. The locked fleet-sync sweep runs via `bin/fm-fleet-sync.sh`, best-effort and non-fatal, under the hard-rule exception in section 1 (set `FM_FLEET_PRUNE=0` to temporarily disable that branch pruning). -The locked local secondmate sync sweep fast-forwards every live secondmate home's worktree to firstmate's own current default-branch commit so the fleet stays converged on whatever version firstmate is on. +The locked local secondmate sync sweep fast-forwards every live secondmate home to firstmate's own current default-branch commit so the fleet stays converged on whatever version firstmate is on. The live set comes from `state/.meta` records with `kind=secondmate`; `data/secondmates.md` only backfills `home=` for older or incomplete meta records. -This is a purely local fast-forward (every secondmate home is a worktree of this same repo, sharing one object store), never a fetch from origin and never a surprise pull: the version followed is simply whatever the primary is currently on, which only the captain changes deliberately via `git pull` or `/updatefirstmate`. +This is a purely local fast-forward for linked-worktree homes, which share the primary's object store, never a fetch from origin or a surprise pull. +A standalone clone that lacks the primary target is skipped untouched by this local sweep and advances through `/updatefirstmate`'s origin refresh instead. A tracked-files fast-forward never touches the gitignored operational dirs, so a secondmate's backlog, projects, and in-flight work are never disturbed; a dirty, diverged, or in-flight home is skipped untouched. The same sweep also propagates the primary's declared inheritable config (`config/crew-dispatch.json`, `config/crew-harness`, and `config/backlog-backend`; sections 4 and 10) into each live secondmate home's `config/`, so every secondmate's own crewmates, dispatch profiles, and backlog backend stay on the primary's settings. Because `config/` is gitignored this is a separate, primary-authoritative copy independent of the tracked-files fast-forward: it re-converges every live home whether or not its tracked files advanced, and it touches only the declared inheritable items (never `config/secondmate-harness`). @@ -749,7 +750,7 @@ If a guard warning says queued wakes are pending, drain them before doing anythi If a guard warning says watcher liveness is stale, drain any queued wakes and then resume the emitted supervision protocol. `fm-guard.sh` carries a second, independent alarm in the same bordered ●-marked style: the **worktree-tangle** guard. -Firstmate is a treehouse-pooled git repo of itself - the primary checkout (the repo root, `FM_ROOT`) and every crewmate worktree and secondmate home are linked worktrees of one repo - and the primary must stay on its default branch. +Firstmate's primary checkout (the repo root, `FM_ROOT`) and crewmate worktrees share the treehouse pool, while a secondmate home may be a linked worktree or a standalone clone, and the primary must stay on its default branch. If a crewmate sent to work firstmate-on-itself branches or commits in the primary instead of its own isolated worktree, the primary is stranded on a feature branch (the failure this guards against); the guard names the offending branch and prints the non-destructive restore (`git -C checkout `), so the tangle surfaces on the very next fleet action. The check is scoped precisely to the primary: detached HEAD (the legitimate resting state of crewmate worktrees and secondmate homes on the default branch) and the default branch itself never alarm; only a named non-default branch checked out in the primary does. The same assertion runs at session start as the bootstrap `TANGLE:` line inside the `bin/fm-session-start.sh` digest (section 3), with read-only wording when this session does not hold the fleet lock. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 15004c9acd4..6b165d1a12a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -100,7 +100,7 @@ tests/fm-dispatch-select.test.sh # deterministic crew-dispatch profile tests/fm-spawn-batch.test.sh # batch dispatch and FM_HOME project-path scoping tests tests/fm-spawn-dispatch-profile.test.sh # concrete dispatch profile flags: active-profile backstop, harness/model/effort meta, launch templates, batch forwarding, and secondmate exemption tests/fm-update.test.sh # fast-forward-only self-update, reread, nudge, dedup, and skip-safety tests -tests/fm-secondmate-sync.test.sh # local-HEAD secondmate sync, no-fetch, bootstrap nudge gating, stable nudge selectors after respawn, and spawn hook tests +tests/fm-secondmate-sync.test.sh # local-HEAD secondmate sync, no-fetch, ignored seed-marker migration and real-dirt protection, bootstrap nudge gating, stable nudge selectors after respawn, and spawn hook tests tests/fm-secondmate-liveness.test.sh # session-start secondmate agent-liveness probe and respawn sweep tests tests/fm-secondmate-harness.test.sh # secondmate-vs-crewmate harness resolution, optional secondmate model/effort pins, primary-to-secondmate config inheritance, and config-push tests tests/fm-secondmate-lifecycle-e2e.test.sh # persistent secondmate routing, seeding, backlog handoff, spawn, recovery, teardown, and FM_HOME flow tests diff --git a/bin/fm-bootstrap.sh b/bin/fm-bootstrap.sh index f4d192d15b9..c60cf8e6a6e 100755 --- a/bin/fm-bootstrap.sh +++ b/bin/fm-bootstrap.sh @@ -180,10 +180,11 @@ fleet_sync() { } secondmate_sync() { - # Local-HEAD secondmate sync: fast-forward every LIVE secondmate home's worktree + # Local-HEAD secondmate sync: fast-forward every LIVE secondmate home # to the primary checkout's current default-branch commit. Purely LOCAL - no - # fetch, no origin dependency: a secondmate home is a worktree of this same repo - # and already holds the primary's commit (fm-ff-lib.sh). Emits NUDGE_SECONDMATES: + # fetch, no origin dependency: a linked-worktree home already holds the primary's + # commit (fm-ff-lib.sh), while a standalone clone without it is skipped until + # /updatefirstmate refreshes it from origin. Emits NUDGE_SECONDMATES: # only for RUNNING secondmates whose instruction surface (AGENTS.md, bin/, or # .agents/skills/) actually changed, so a secondmate already on the primary's # version is never disturbed (AGENTS.md bootstrap + supervision). Mirrors diff --git a/bin/fm-ff-lib.sh b/bin/fm-ff-lib.sh index 6a0971f0a4a..ae290020caa 100644 --- a/bin/fm-ff-lib.sh +++ b/bin/fm-ff-lib.sh @@ -10,12 +10,17 @@ # on startup) follows the PRIMARY checkout's current default-branch commit: # base_mode is that local commit, with NO fetch and no origin dependency. # -# Every secondmate home is a worktree of this same repo, so it already holds the -# primary's commit in the shared object store; the local-HEAD sync is therefore a -# purely local fast-forward that never touches the network. A tracked-files -# fast-forward never touches the gitignored operational dirs (data/, state/, -# config/, projects/, .no-mistakes/), so a secondmate's backlog, projects, and -# in-flight work are never disturbed. Homes are leased at a detached HEAD on the +# A linked-worktree secondmate home already holds the primary's commit in the +# shared object store, so its local-HEAD sync is a purely local fast-forward that +# never touches the network. A standalone clone moves through that path only when +# it already has the target; otherwise it is skipped until the origin path updates it. +# A tracked-files fast-forward never touches the gitignored operational dirs +# (data/, state/, config/, projects/, .no-mistakes/), so it cannot disturb a +# secondmate's backlog, projects, or in-flight work. +# The seeded .fm-secondmate-home identity marker is gitignored too; the local +# sync tolerates only that marker during the one-time upgrade of pre-ignore +# linked-worktree homes. +# Homes are leased at a detached HEAD on the # default branch, so the fast-forward advances HEAD only and never moves the # shared default branch or any other worktree's checkout. diff --git a/bin/fm-home-seed.sh b/bin/fm-home-seed.sh index 215c545107e..d506b95e4f8 100755 --- a/bin/fm-home-seed.sh +++ b/bin/fm-home-seed.sh @@ -16,7 +16,7 @@ # refuses a home with project clones or project-registry entries, so it # never converts populated homes in place. The charter brief # is copied to data/charter.md, newly cloned no-mistakes projects are -# initialized, a .fm-secondmate-home marker is written, and +# initialized, an ignored .fm-secondmate-home identity marker is written, and # data/secondmates.md is updated. # Seeding is transactional: on validation, clone, init, or registry failure, # generated briefs, new homes, new project clones, and registry edits are diff --git a/docs/architecture.md b/docs/architecture.md index e4309df65d5..3eeac54c2d4 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -122,6 +122,9 @@ Idle secondmate panes are healthy; teardown is explicit and refuses while the se Secondmate homes stay on the same firstmate version as the primary checkout. On locked session start, `fm-bootstrap.sh` fast-forwards each live secondmate home recorded in `state/*.meta` to the primary default-branch commit with no origin fetch. +Linked-worktree homes share the primary's object store, so that local target is available immediately. +A standalone clone that does not contain the target remains unchanged in the local sweep and receives firstmate updates through `/updatefirstmate`'s origin refresh instead. +The seeded-home identity marker's ignored state and upgrade path are documented in [configuration.md](configuration.md#secondmate-routes-datasecondmatesmd). The live signal is a `state/.meta` record with `kind=secondmate`; `data/secondmates.md` only backfills `home=` for older or incomplete meta records. A tracked-files fast-forward leaves the home's gitignored `data/`, `state/`, `config/`, `projects/`, and `.no-mistakes/` directories untouched. The same locked session start probes each live secondmate endpoint for a real agent process and respawns only a confidently dead endpoint; inconclusive probes are reported and never acted on. diff --git a/docs/configuration.md b/docs/configuration.md index 34c0c071d79..d082e4bba06 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -106,6 +106,11 @@ Secondmate routes cover `no-mistakes` and `direct-PR` projects; `local-only` pro For `no-mistakes` projects, seeding initializes only projects newly cloned into a secondmate home and refuses to mutate a preexisting clone that is not already initialized. After creating a secondmate, move existing main-backlog queued items that you have judged in-scope with `fm-backlog-handoff.sh ...`; it is idempotent and refuses In flight, Done, or non-secondmate homes. Set `FM_SECONDMATE_CHARTER` to seed from inline charter text when no filled charter brief exists; set `FM_SECONDMATE_SCOPE` when the routing scope should differ from the charter text. +Each seed writes an `.fm-secondmate-home` identity marker at the home root. +The tracked root `.gitignore` ignores that marker, so validation can read it without making a freshly seeded home appear dirty to porcelain-based safety checks. +This does not relax protection for any other untracked file. +An existing linked-worktree home that predates this rule advances through its marker-only state during its next bootstrap or spawn local sync, after which Git ignores the marker normally. +A standalone-clone home cannot receive a primary-local commit through that no-fetch sync, so it receives the rule through `/updatefirstmate`'s origin refresh instead. ## FM_HOME diff --git a/docs/scripts.md b/docs/scripts.md index e91778778a1..11cdefb6c01 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -22,7 +22,7 @@ If you have changed away from the firstmate home in an interactive shell, invoke | `fm-arm-pretool-check.sh` | Stable PreToolUse transport for the command-position watcher policy owned by `fm-arm-command-policy.mjs`; see `docs/arm-pretool-check.md` for the blessed tree, reason codes, adapter outputs, and validation record | | `fm-arm-command-policy.mjs` | The single semantic owner of the watcher-arm PreToolUse policy: a quote-aware, execution-position classifier that only parses executed positions and never executes, sources, evals, or expands any byte of the submitted command; resolves protected watcher-script identities, recognizes the blessed setup-plus-final tree and broad watcher kills, emits stable deny reason codes, and fails closed on unsupported or malformed syntax that contains a protected execution (docs/arm-pretool-check.md) | | `fm-supervision-instructions.sh` | Render the session-start primary-harness supervision block from `docs/supervision-protocols/`, or a one-line harness-aware repair instruction for guards and turn-end hooks | -| `fm-home-seed.sh` | Lease/provision a secondmate home transactionally, clone projects or deliberately seed a project-less firstmate-repo domain with `--no-projects`, initialize gates, and maintain `data/secondmates.md` | +| `fm-home-seed.sh` | Lease/provision a secondmate home transactionally, clone projects or deliberately seed a project-less firstmate-repo domain with `--no-projects`, initialize gates, write its ignored identity marker, 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, 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 | diff --git a/tests/fm-secondmate-sync.test.sh b/tests/fm-secondmate-sync.test.sh index 507bbfb39e5..bc806523d92 100755 --- a/tests/fm-secondmate-sync.test.sh +++ b/tests/fm-secondmate-sync.test.sh @@ -92,6 +92,30 @@ bump_primary() { head_of() { git -C "$1" rev-parse HEAD; } +# ignore_marker_commit : land THE FIX in the primary - add the seed marker to +# the tracked .gitignore and commit it on main. The marker (.fm-secondmate-home) +# is firstmate-generic, written by bin/fm-home-seed.sh into every seeded home; once +# a home fast-forwards past this commit the marker is git-ignored and can no longer +# read as a dirty working tree to any `git status --porcelain` dirtiness check. +ignore_marker_commit() { + local w=$1 + printf '.fm-secondmate-home\n' >> "$w/main/.gitignore" + git -C "$w/main" add -A + git -C "$w/main" commit -qm "gitignore seed marker" +} + +# seed_marked_home : a secondmate home matching what +# bin/fm-home-seed.sh actually lays down - a detached worktree at , the +# seed marker, a live kind=secondmate meta, and the gitignored operational dirs +# with a charter. The ONLY unignored extra file is the seed marker, which is +# exactly what this fix must keep from dirtying the home. +seed_marked_home() { + local w=$1 id=$2 commit=$3 + add_sm_worktree "$w" "$id" "$commit" + mkdir -p "$w/$id/data" "$w/$id/state" "$w/$id/config" "$w/$id/projects" + printf 'charter\n' > "$w/$id/data/charter.md" +} + # run_ff : drive the shared ff helper in THIS shell (output to a file, # not a subshell, so FF_STATUS / FF_INSTR propagate). Sets FF_OUT to the printed # status line. Uses allow_detached=yes, ignore_seed_marker=yes (the secondmate @@ -549,6 +573,87 @@ SH pass "T11 spawn warns when pre-launch sync is skipped" } +# --- T12: a freshly seeded home reads clean once the primary ignores the marker - +# The seed marker used to leave every home permanently dirty: bin/fm-fleet-sync.sh +# and any other plain `git status --porcelain` check counts the untracked marker, +# so a seeded home reported STUCK/dirty forever. With the marker in .gitignore, a +# home seeded from a primary that carries the fix reads clean to that exact signal. +test_seed_marker_clean_when_gitignored() { + local w base + w=$(new_world marker-clean) + ignore_marker_commit "$w" # primary now ignores the marker + base=$(primary_head_commit "$w/main") + seed_marked_home "$w" sm "$base" # fresh home at the post-fix HEAD + + # The exact dirtiness signal bin/fm-fleet-sync.sh reads (its line: dirty=yes when + # `git status --porcelain | head -1` is non-empty). + [ -z "$(git -C "$w/sm" status --porcelain)" ] \ + || fail "seed marker still dirties a fresh home: $(git -C "$w/sm" status --porcelain)" + # And the secondmate ff sweep sees no dirt: an at-HEAD home is a clean no-op. + run_ff "$w/sm" "$base" + [ "$FF_STATUS" = current ] || fail "fresh home not read as clean/current, got '$FF_STATUS': $FF_OUT" + pass "T12 gitignored marker: a freshly seeded home reads clean to fleet-sync and the ff sweep" +} + +# --- T13: an existing marker-only-dirty home converges on the next sweep -------- +# The convergence chicken-and-egg: existing homes predate the fix, so their marker +# is still untracked-and-unignored, and the fix itself only arrives by fast-forward. +# The marker-tolerant ff-skip (ignore_seed_marker=yes) bridges the gap for +# linked-worktree homes, which bootstrap/spawn fast-forward from the primary's local HEAD. +# Standalone-clone homes converge through /updatefirstmate's origin fetch instead. +# Once advanced, the now-ignored marker reads clean with no hand intervention. +test_seed_marker_converges_existing_home() { + local w c0 base + w=$(new_world marker-converge) # primary does NOT ignore the marker yet + c0=$(head_of "$w/main") + seed_marked_home "$w" sm "$c0" # existing home predates the fix + [ -n "$(git -C "$w/sm" status --porcelain)" ] \ + || fail "precondition: the untracked marker should dirty a pre-fix home" + ignore_marker_commit "$w" # THE FIX lands as a later commit + base=$(primary_head_commit "$w/main") + + run_ff "$w/sm" "$base" # the marker-tolerant convergence sweep + + [ "$FF_STATUS" = updated ] || fail "existing marker-only home did not converge, got '$FF_STATUS': $FF_OUT" + [ "$(head_of "$w/sm")" = "$base" ] || fail "home did not fast-forward to the fix commit" + [ -z "$(git -C "$w/sm" status --porcelain)" ] \ + || fail "marker still dirty after convergence: $(git -C "$w/sm" status --porcelain)" + pass "T13 gitignored marker: an existing marker-only-dirty home converges, then reads clean" +} + +# --- T14: marker tolerance does not mask a genuinely dirty home ----------------- +# The ff-skip only forgives the seed marker; a real uncommitted change alongside the +# marker must still refuse the fast-forward and leave the work untouched, exactly as +# before this fix. +test_seed_marker_does_not_mask_real_dirt() { + local w c0 base before + w=$(new_world marker-real-dirt) + c0=$(head_of "$w/main") + seed_marked_home "$w" sm "$c0" + printf 'real local change\n' >> "$w/sm/AGENTS.md" # genuine tracked-file edit + the marker + before=$(head_of "$w/sm") + ignore_marker_commit "$w" + base=$(primary_head_commit "$w/main") + + run_ff "$w/sm" "$base" + + [ "$FF_STATUS" = skipped ] || fail "a genuinely dirty home must skip, got '$FF_STATUS'" + assert_contains "$FF_OUT" "secondmate sm: skipped: dirty working tree" \ + "a genuinely dirty home is skipped even with the marker present" + [ "$(head_of "$w/sm")" = "$before" ] || fail "genuinely dirty home HEAD moved (work at risk)" + grep -q 'real local change' "$w/sm/AGENTS.md" || fail "genuine local edit was discarded" + pass "T14 marker tolerance does not mask a genuinely dirty home" +} + +# --- T15: the shipped firstmate repo gitignores the seed marker ----------------- +# Pins the actual fix so it cannot silently regress: without this .gitignore entry +# every seeded home would read dirty again the moment it lands on this repo's HEAD. +test_repo_gitignores_seed_marker() { + grep -qxF '.fm-secondmate-home' "$ROOT/.gitignore" \ + || fail "the firstmate repo .gitignore must ignore the seed marker (.fm-secondmate-home)" + pass "T15 the firstmate repo gitignores the secondmate seed marker" +} + test_ff_updated test_ff_current test_ff_dirty @@ -561,5 +666,9 @@ test_nudge_selector_stable_after_herdr_respawn test_bootstrap_sweep_surfaces_skipped_home test_spawn_fast_forwards_before_launch test_spawn_warns_when_sync_skipped_before_launch +test_seed_marker_clean_when_gitignored +test_seed_marker_converges_existing_home +test_seed_marker_does_not_mask_real_dirt +test_repo_gitignores_seed_marker echo "# all fm-secondmate-sync tests passed" From a955a0544062432b072d49fb1db8ed14fd39ee72 Mon Sep 17 00:00:00 2001 From: Kun Chen <3233006+kunchenguid@users.noreply.github.com> Date: Fri, 10 Jul 2026 09:35:32 -0700 Subject: [PATCH 02/35] fix(composer): prevent dead-shell message injection (#416) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(composer): stop reading dead-shell prompts as empty agent composers Consolidate composer empty/pending/unknown classification into one shared owner, bin/fm-composer-lib.sh's fm_composer_classify_content, delegated to by all four backend adapters (tmux via fm-tmux-lib.sh, herdr, orca, cmux). This replaces four drifting copies of the glyph decision. Safety fix: a bare shell prompt glyph (> $ % #) on an unstructured row is now classified unknown (a dead shell, unsafe for injection), not empty. It is only empty inside a bordered composer box (the harness's own prompt). Agent glyphs ❯ (claude) and › (codex) read empty either way. The away-mode injector (inject_msg) now requires an affirmatively-empty composer, deferring on pending or unknown, so an escalation can never be typed into (or executed by) a pane whose agent exited to its login shell. Regression coverage: new tests/fm-composer-lib.test.sh pins the shared owner; per-backend dead-shell tests in fm-daemon (tmux + injector), orca, and the existing herdr/cmux suites. shellcheck clean; herdr incident regressions stay green. * no-mistakes(review): Captain: harden composer safety checks * no-mistakes(test): Stabilize Herdr prune safety setup * no-mistakes(document): Document composer injection safety * no-mistakes(lint): Clean composer safety lint * no-mistakes: apply CI fixes --- .agents/skills/afk/SKILL.md | 50 +++----- .agents/skills/harness-adapters/SKILL.md | 2 +- CONTRIBUTING.md | 5 +- bin/backends/cmux.sh | 24 ++-- bin/backends/herdr.sh | 39 +++--- bin/backends/orca.sh | 24 ++-- bin/fm-composer-lib.sh | 87 +++++++++++++ bin/fm-supervise-daemon.sh | 33 +++-- bin/fm-tmux-lib.sh | 55 ++++---- docs/architecture.md | 2 + docs/cmux-backend.md | 3 +- docs/configuration.md | 2 +- docs/herdr-backend.md | 30 ++++- docs/orca-backend.md | 1 + docs/scripts.md | 11 +- docs/tmux-backend.md | 3 + docs/zellij-backend.md | 2 +- .../fm-backend-herdr-prune-safety-e2e.test.sh | 27 ++-- tests/fm-backend-orca.test.sh | 19 ++- tests/fm-backend.test.sh | 2 +- tests/fm-composer-lib.test.sh | 120 ++++++++++++++++++ tests/fm-daemon.test.sh | 83 +++++++++++- 22 files changed, 477 insertions(+), 147 deletions(-) create mode 100644 bin/fm-composer-lib.sh create mode 100755 tests/fm-composer-lib.test.sh diff --git a/.agents/skills/afk/SKILL.md b/.agents/skills/afk/SKILL.md index 74fda0cd275..61bcce35d95 100644 --- a/.agents/skills/afk/SKILL.md +++ b/.agents/skills/afk/SKILL.md @@ -80,26 +80,18 @@ injection, dispatched through `bin/fm-backend.sh` for the supervisor's own backend (tmux or herdr; see "Auto-discovered supervisor pane" below): - **`pane_is_busy`** - the harness shows a busy footer (agent mid-turn) on tmux (shared with `fm-send.sh` via `bin/fm-tmux-lib.sh`); on herdr, tries the native `agent.get`-backed busy state first, trusts only `busy` outright, and corroborates every non-`busy` verdict with the same regex-over-capture reader. -- **`pane_input_pending`** - the composer holds real unsubmitted text (a - human's half-typed line, or a previous injection whose Enter was swallowed). - On tmux, the cursor-line detector **strips the harness's composer box - borders first**, so an idle *bordered* composer (claude draws `│ > … │`) is - correctly read as empty, not pending. Without this, every idle claude pane - looked like pending input and the daemon deferred 100% of escalations - (incident afk-invx-i5). `FM_COMPOSER_IDLE_RE` still overrides empty-composer - matching after border stripping. On herdr, the equivalent ANSI-aware - structural classifier (`fm_backend_herdr_composer_state`, - docs/herdr-backend.md) plays the same role. - -Either condition defers the injection; the buffered escalation survives in -`state/.subsuper-escalations` and is retried on the next housekeeping tick. In -afk mode the composer guard is belt-and-suspenders (no human is typing), but it -protects against the race window between the captain returning and their -message landing, and against the daemon's own previous injection sitting unsent. +- **Composer-state guard** - `inject_msg` reads the full `empty`/`pending`/`unknown` verdict from `fm_backend_composer_state` and injects only when it is affirmatively `empty`. + `pending` means real unsubmitted text, while `unknown` includes an unreadable pane and a bare shell prompt left after the agent exits, so both defer. + The shared `bin/fm-composer-lib.sh` owns the content decision after each backend captures and structurally identifies its own composer row. + It preserves idle bordered composers such as claude's `│ > … │` and bare agent glyphs as empty, but a bare shell glyph is unknown unless inside a genuine bordered composer box; see `docs/herdr-backend.md` "Composer-emptiness safety" for the complete contract. + `pane_input_pending` remains the tested predicate for callers that only need to know whether real unsubmitted text is present, but it is insufficient for an injection-safety decision because it cannot distinguish `empty` from `unknown`. + +Either condition, or any composer verdict other than `empty`, defers the injection; the buffered escalation survives in `state/.subsuper-escalations` and is retried on the next housekeeping tick. +In afk mode the composer guard is belt-and-suspenders (no human is typing), but it protects against the race window between the captain returning and their message landing, a dead shell, and the daemon's own previous injection sitting unsent. **Max-defer escape (the daemon must never silently wedge).** If anything stays buffered past `FM_MAX_DEFER_SECS` (default 300), the daemon -attempts one normal flush, which still requires an idle pane and empty composer. +attempts one normal flush, which still requires an idle pane and an affirmatively empty composer. If that submit cannot be confirmed, it raises a loud, rate-limited wedge alarm: an ERROR in the daemon log, a durable `state/.subsuper-inject-wedged` marker (surface it on the "while you were out" @@ -115,7 +107,7 @@ Enter is retried (Enter only, never a retype) until the backend confirms the submit landed. For tmux that confirmation is a cleared composer, using the same corrected, border-aware detector as the composer guard. -For herdr, normal idle-baseline submits are confirmed by native agent-state showing a real turn started; the ANSI-aware composer classifier remains the pre-injection guard and conservative fallback for non-idle or unreadable baselines. +For herdr, normal idle-baseline submits are confirmed by native agent-state showing a real turn started; the ANSI-aware composer classifier remains the affirmative-empty pre-injection guard and conservative fallback for non-idle or unreadable baselines. A bordered-empty or ghost-only composer is recognized as empty where that backend uses composer confirmation, rather than mistaken for a swallowed Enter. `fm-send.sh` uses the same primitive and exits non-zero when a steer's Enter is positively swallowed, so firstmate learns an instruction @@ -163,22 +155,16 @@ the marker lets firstmate distinguish it from a real captain message. - **Single-line digest** - embedded newlines are collapsed to a literal separator before injection, so submission is unambiguous regardless of harness. -- **Composer guard on the supervisor pane** - before injecting, the daemon - checks both `pane_is_busy` (harness busy footer means agent mid-turn) and - `pane_input_pending` (real unsubmitted text on the cursor line means human - mid-typing or previous injection with swallowed Enter). Either condition - defers injection and preserves the buffer for retry. The daemon never merges - its digest into the captain's half-typed line. -- The composer detector, shared with `fm-send.sh` in `bin/fm-tmux-lib.sh`, drops - dim/faint ghost text, then strips harness composer box borders, so a ghost-only - or idle bordered composer such as claude's `│ > ... │` reads as empty, not - pending. Without these filters, idle bordered composers and dim ghost - suggestions can look like pending input and stall supervision. `FM_COMPOSER_IDLE_RE` - still overrides empty-composer matching after dim-ghost and border stripping, - and `FM_BUSY_REGEX` overrides busy footers. +- **Composer guard on the supervisor pane** - before injecting, the daemon checks `pane_is_busy` (harness busy footer means agent mid-turn) and reads `fm_backend_composer_state` directly. + Only `empty` permits injection; `pending` protects half-typed or swallowed input, and `unknown` protects unreadable panes and bare dead-shell prompts. + Every other result preserves the buffer for retry, so the daemon never merges its digest into the captain's half-typed line or types it into a shell. +- The shared composer classifier receives a candidate row only after the active backend performs its own capture and structural row recognition. + tmux removes dim/faint ghost text and borders before delegation, while herdr retains its ANSI faint-tail override after the shared verdict. + A ghost-only or idle bordered composer such as claude's `│ > ... │` therefore reads empty without allowing an unbordered shell prompt to do the same. + `FM_COMPOSER_IDLE_RE` still overrides tmux empty-composer matching after dim-ghost and border stripping, and `FM_BUSY_REGEX` overrides busy footers. - **Max-defer escape** - the daemon must never silently wedge. If anything stays buffered past `FM_MAX_DEFER_SECS` (default 300s), the daemon attempts one - normal flush, which still requires an idle pane and empty composer. If that + normal flush, which still requires an idle pane and an affirmatively empty composer. If that cannot confirm a submit, it raises a loud, rate-limited wedge alarm: ERROR log, durable `state/.subsuper-inject-wedged` marker, and a status-line flash. A composer false-positive surfaces as a visible stall, never an unbounded silent diff --git a/.agents/skills/harness-adapters/SKILL.md b/.agents/skills/harness-adapters/SKILL.md index 13e1c210c18..7f57aebada7 100644 --- a/.agents/skills/harness-adapters/SKILL.md +++ b/.agents/skills/harness-adapters/SKILL.md @@ -32,7 +32,7 @@ The supervision knowledge lives here: busy signature, exit command, interrupt, d Never dispatch a crewmate or secondmate on an unverified adapter. If `config/crew-harness` or `config/secondmate-harness` names an unverified adapter, tell the captain and fall back to firstmate's own harness until that adapter is verified. -If the captain asks for a new harness, propose verifying it first: spawn a trivial supervised task using `fm-spawn`'s raw-launch-command escape hatch, confirm every fact empirically, then record the mechanics in `fm-spawn`, the busy signature in `fm-watch.sh` and `fm-tmux-lib.sh` defaults, any needed `FM_COMPOSER_IDLE_RE` empty-composer override, the tmux agent-process liveness classification in `bin/backends/tmux.sh` when the harness can launch a secondmate, and the verified knowledge here. +If the captain asks for a new harness, propose verifying it first: spawn a trivial supervised task using `fm-spawn`'s raw-launch-command escape hatch, confirm every fact empirically, then record the mechanics in `fm-spawn`, the busy signature in `fm-watch.sh` and `fm-tmux-lib.sh` defaults, any needed `FM_COMPOSER_IDLE_RE` empty-composer override plus any novel bare agent prompt glyph in `bin/fm-composer-lib.sh`'s shared composer classifier (the one fleet-wide owner of the empty/dead-shell/pending decision, so a new harness's own idle composer is not misread as a dead shell), the tmux agent-process liveness classification in `bin/backends/tmux.sh` when the harness can launch a secondmate, and the verified knowledge here. ## Detection diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6b165d1a12a..556fb28b90b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -78,13 +78,14 @@ tests/fm-pi-primary-types.test.sh # strict no-emit TypeScript check for FM_PI_LIVE_E2E=1 tests/fm-pi-primary-live-e2e.test.sh # opt-in real Pi TUI regression in an isolated home and private tmux socket tests/fm-arm-pretool-check.test.sh # command-position watcher-arm policy: adversarial allow/deny matrix across all five adapter entry forms, reason codes, fail-closed malformed-protected syntax, fail-open transport, and --claude output shaping tests/fm-watch-triage.test.sh # always-on watcher triage: benign absorb, actionable surface, stale status-log override, wedge threshold, repeated wedge demand marker, heartbeat backstop, and afk one-shot coherence -tests/fm-daemon.test.sh # sub-supervisor classifier, /afk presence-gating, fm-afk-start daemon-lock lifecycle, max-defer, composer, and fm-send submit tests +tests/fm-daemon.test.sh # sub-supervisor classifier, /afk presence-gating, fm-afk-start daemon-lock lifecycle, max-defer, dead-shell-safe composer injection, and fm-send submit tests tests/fm-send-settle.test.sh # fm-send post-submit settle pause, tuning, disable, and --key bypass tests tests/fm-send-popup-settle.test.sh # fm-send pre-Enter popup-settle selection for slash commands and codex $skill invocations tests/fm-send-secondmate-marker.test.sh # fm-send from-firstmate marker for kind=secondmate targets: marked vs crewmate/explicit/--key, and the exact marker byte sequence tests/fm-send-strict.test.sh # fm-send strict target resolution: bare lane id did-you-mean, unset FM_HOME, unresolvable selectors, prefixless herdr pane ids, dead explicit tmux targets, and healthy fm- sends tests/fm-wake-daemon-lifecycle-e2e.test.sh # watcher + daemon lifecycle e2e: restart catch-up, batching, dedupe, stale-pane routing, and digest injection tests/fm-composer-ghost.test.sh # dim-ghost stripping, ghost-only composer detection, and escape-free peek tests +tests/fm-composer-lib.test.sh # shared composer classifier: bare shell safety, bordered and bare agent prompts, idle placeholders, and pending input tests/fm-afk-inject-e2e.test.sh # private-socket end-to-end test of the afk injection path (partial-input deferral, swallowed-Enter retry) tests/fm-afk-inject-herdr-e2e.test.sh # real-herdr end-to-end test of the afk daemon's herdr transport, on an isolated throwaway HERDR_SESSION: partial-input deferral, swallowed-Enter retry, a normal digest, and the max-defer wedge alarm on a persistently pending composer tests/fm-bootstrap.test.sh # bootstrap dependency, feature-probe, fleet-sync timeout, and crew-dispatch reporting tests @@ -120,7 +121,7 @@ tests/fm-backend-herdr-prune-safety-e2e.test.sh # isolated real-herdr E2E for th tests/fm-backend-herdr-respawn-idem-e2e.test.sh # isolated real-herdr E2E for restored-layout husk respawn idempotency across a real session restart, covering crewmate/scout and secondmate-shaped tabs plus live-agent duplicate refusal tests/fm-backend-zellij.test.sh # fake zellij CLI unit tests for the experimental zellij adapter, including version/tool gates, target parsing, home-scoped title creation, legacy-title fallback, send/capture, current-path probing, label-checked target safety, secondmate child cleanup, and tab cleanup tests/fm-backend-zellij-smoke.test.sh # real zellij adapter smoke test, skipped when zellij or jq is unavailable, using an isolated throwaway FM_ZELLIJ_SESSION and guarded session cleanup -tests/fm-backend-orca.test.sh # fake Orca CLI unit tests for primitive adapter routing: capture, send text, Enter/interrupt keys, close, and dispatcher sourcing +tests/fm-backend-orca.test.sh # fake Orca CLI unit tests for primitive adapter routing, structural composer verification including bare-shell safety, capture, send text, Enter/interrupt keys, close, and dispatcher sourcing tests/cmux-test-safety.sh # guarded cleanup helper for real-cmux tests, refusing to close anything except a matching fm-test- workspace tests/fm-backend-cmux.test.sh # fake cmux CLI unit tests for the experimental cmux adapter, including socket auth, title scoping, target recovery, fresh-surface liveness, current-path probing, structural composer verification, and secondmate refusal tests/fm-backend-cmux-smoke.test.sh # real cmux adapter smoke test, skipped when cmux or jq is unavailable or the socket is not password-mode authenticated, using fm-test- workspaces and guarded cleanup diff --git a/bin/backends/cmux.sh b/bin/backends/cmux.sh index d8d67e5ed77..70c0d92aeb4 100644 --- a/bin/backends/cmux.sh +++ b/bin/backends/cmux.sh @@ -112,6 +112,12 @@ FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" # shellcheck source=bin/fm-backend-hometag-lib.sh . "$FM_BACKEND_CMUX_ROOT/bin/fm-backend-hometag-lib.sh" +# Shared composer-content classifier (empty|pending|unknown, and the fleet-wide +# dead-shell-vs-agent-composer rule). Owned by bin/fm-composer-lib.sh, reused by +# every backend so the decision cannot drift. +# shellcheck source=bin/fm-composer-lib.sh +. "$FM_BACKEND_CMUX_ROOT/bin/fm-composer-lib.sh" + # Verified minimum: the version the live pass ran against (docs/cmux-backend.md). FM_BACKEND_CMUX_MIN_MAJOR=0 FM_BACKEND_CMUX_MIN_MINOR=64 @@ -555,20 +561,10 @@ fm_backend_cmux_composer_state() { # [expected-label] -> empty|pending stripped=${stripped//|/} stripped="${stripped#"${stripped%%[![:space:]]*}"}" stripped="${stripped%"${stripped##*[![:space:]]}"}" - case "$stripped" in - '❯'|'>'|'$'|'%'|'#') printf 'empty'; return 0 ;; - esac - case "$stripped" in - '❯ '*|'> '*|'$ '*|'% '*|'# '*) stripped=${stripped#??} ;; - '❯'*|'>'*|'$'*|'%'*|'#'*) stripped=${stripped#?} ;; - esac - stripped="${stripped#"${stripped%%[![:space:]]*}"}" - stripped="${stripped%"${stripped##*[![:space:]]}"}" - [ -n "$stripped" ] || { printf 'empty'; return 0; } - if printf '%s' "$stripped" | grep -qE "$FM_BACKEND_CMUX_IDLE_RE"; then - printf 'empty'; return 0 - fi - printf 'pending' + # A row was found only by the bordered shape above, so content came from a + # genuine composer box - delegate to the shared owner with bordered=1. A bare + # dead-shell prompt has no bordered row and already returned 'unknown' above. + fm_composer_classify_content 1 "$stripped" "$FM_BACKEND_CMUX_IDLE_RE" } # fm_backend_cmux_send_text_submit: type into once (raw, diff --git a/bin/backends/herdr.sh b/bin/backends/herdr.sh index 492cde419e6..78ccffc69c2 100644 --- a/bin/backends/herdr.sh +++ b/bin/backends/herdr.sh @@ -49,6 +49,12 @@ FM_BACKEND_HERDR_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" FM_ROOT="${FM_ROOT_OVERRIDE:-${FM_ROOT:-$FM_BACKEND_HERDR_ROOT}}" FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" +# Shared composer-content classifier (empty|pending|unknown, and the fleet-wide +# dead-shell-vs-agent-composer rule). Owned by bin/fm-composer-lib.sh, reused by +# every backend so the decision cannot drift. +# shellcheck source=bin/fm-composer-lib.sh +. "$FM_BACKEND_HERDR_ROOT/bin/fm-composer-lib.sh" + FM_BACKEND_HERDR_MIN_PROTOCOL=14 # .fm-secondmate-home is written by bin/fm-home-seed.sh (AGENTS.md section 6) # at a seeded secondmate home's root, containing exactly that secondmate's id. @@ -706,7 +712,7 @@ FM_BACKEND_HERDR_IDLE_RE=${FM_BACKEND_HERDR_IDLE_RE:-'^Type a message\.\.\.$'} FM_BACKEND_HERDR_BARE_PROMPT_RE=${FM_BACKEND_HERDR_BARE_PROMPT_RE:-'^[❯›]'} fm_backend_herdr_composer_state() { # -> empty|pending|unknown - local target=$1 cap line raw_line trimmed stripped="" found=0 shape="" raw_match="" faint_tail=0 + local target=$1 cap line raw_line trimmed stripped="" found=0 shape="" raw_match="" bordered=0 verdict cap=$(fm_backend_herdr_capture_ansi "$target" "$FM_BACKEND_HERDR_COMPOSER_LINES" 2>/dev/null \ || fm_backend_herdr_capture "$target" "$FM_BACKEND_HERDR_COMPOSER_LINES") || { printf 'unknown'; return 0; } while IFS= read -r line; do @@ -734,6 +740,7 @@ fm_backend_herdr_composer_state() { # -> empty|pending|unknown done < <(printf '%s\n' "$cap") [ "$found" -eq 1 ] || { printf 'unknown'; return 0; } if [ "$shape" = bordered ]; then + bordered=1 # Strip the border glyphs, then trim again. stripped=${stripped//│/} stripped=${stripped//┃/} @@ -741,26 +748,18 @@ fm_backend_herdr_composer_state() { # -> empty|pending|unknown stripped="${stripped#"${stripped%%[![:space:]]*}"}" stripped="${stripped%"${stripped##*[![:space:]]}"}" fi - # A bare prompt glyph = empty composer. - case "$stripped" in - '❯'|'›'|'>'|'$'|'%'|'#') printf 'empty'; return 0 ;; - esac - # Strip a leading prompt glyph before judging what remains. - case "$stripped" in - '❯ '*|'› '*|'> '*|'$ '*|'% '*|'# '*) stripped=${stripped#??} ;; - '❯'*|'›'*|'>'*|'$'*|'%'*|'#'*) stripped=${stripped#?} ;; - esac - stripped="${stripped#"${stripped%%[![:space:]]*}"}" - stripped="${stripped%"${stripped##*[![:space:]]}"}" - [ -n "$stripped" ] || { printf 'empty'; return 0; } - if printf '%s' "$stripped" | grep -qE "$FM_BACKEND_HERDR_IDLE_RE"; then - printf 'empty'; return 0 - fi - if [ "$shape" = bare ] && fm_backend_herdr_prompt_tail_is_faint "$raw_match"; then - faint_tail=1 + # Delegate the empty/pending/unknown decision to the shared owner. The bare + # shape only ever starts with an AGENT glyph (FM_BACKEND_HERDR_BARE_PROMPT_RE + # is '^[❯›]'), so a bare shell prompt never reaches here - it stays 'unknown' + # via the no-composer-row path above, exactly as before. + verdict=$(fm_composer_classify_content "$bordered" "$stripped" "$FM_BACKEND_HERDR_IDLE_RE") + # herdr-only override: a bare-shape prompt whose trailing text is rendered + # faint in the ANSI capture is a Codex idle ghost suggestion, not real input. + if [ "$verdict" = pending ] && [ "$shape" = bare ] \ + && fm_backend_herdr_prompt_tail_is_faint "$raw_match"; then + verdict=empty fi - [ "$faint_tail" -eq 1 ] && { printf 'empty'; return 0; } - printf 'pending' + printf '%s' "$verdict" } # fm_backend_herdr_send_text_submit: type into once (raw, diff --git a/bin/backends/orca.sh b/bin/backends/orca.sh index 1a4311a2541..dc9307de4f6 100644 --- a/bin/backends/orca.sh +++ b/bin/backends/orca.sh @@ -6,6 +6,12 @@ # # Target string shape: the Orca terminal id accepted by `orca terminal ...`. +# Shared composer-content classifier (empty|pending|unknown, and the fleet-wide +# dead-shell-vs-agent-composer rule). Owned by bin/fm-composer-lib.sh, reused by +# every backend so the decision cannot drift. +# shellcheck source=bin/fm-composer-lib.sh +. "$(dirname -- "${BASH_SOURCE[0]}")/../fm-composer-lib.sh" + fm_backend_orca_tool_check() { command -v orca >/dev/null 2>&1 || { echo "error: backend=orca selected but the 'orca' CLI is not installed" >&2; return 1; } } @@ -283,20 +289,10 @@ fm_backend_orca_composer_state() { # -> empty|pending|unknown stripped=${stripped//|/} stripped="${stripped#"${stripped%%[![:space:]]*}"}" stripped="${stripped%"${stripped##*[![:space:]]}"}" - case "$stripped" in - '❯'|'>'|'$'|'%'|'#') printf 'empty'; return 0 ;; - esac - case "$stripped" in - '❯ '*|'> '*|'$ '*|'% '*|'# '*) stripped=${stripped#??} ;; - '❯'*|'>'*|'$'*|'%'*|'#'*) stripped=${stripped#?} ;; - esac - stripped="${stripped#"${stripped%%[![:space:]]*}"}" - stripped="${stripped%"${stripped##*[![:space:]]}"}" - [ -n "$stripped" ] || { printf 'empty'; return 0; } - if printf '%s' "$stripped" | grep -qE "$FM_BACKEND_ORCA_IDLE_RE"; then - printf 'empty'; return 0 - fi - printf 'pending' + # A row was found only by the bordered shape above, so content came from a + # genuine composer box - delegate to the shared owner with bordered=1. A bare + # dead-shell prompt has no bordered row and already returned 'unknown' above. + fm_composer_classify_content 1 "$stripped" "$FM_BACKEND_ORCA_IDLE_RE" } fm_backend_orca_send_key() { # diff --git a/bin/fm-composer-lib.sh b/bin/fm-composer-lib.sh new file mode 100644 index 00000000000..27bc22706ad --- /dev/null +++ b/bin/fm-composer-lib.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +# bin/fm-composer-lib.sh - the ONE fleet-wide owner of composer-content +# classification, shared by every session-provider adapter: the tmux path +# through bin/fm-tmux-lib.sh, and bin/backends/{herdr,orca,cmux}.sh directly. +# +# WHY THIS EXISTS (task fm-composer-shellglyph-safety): the four adapters each +# carried their own copy of the "is this composer row empty / pending / not an +# agent composer" decision, and the copies drifted. The dangerous drift: a BARE +# shell prompt glyph (`>`, `$`, `%`, `#`) - what a pane shows once its agent has +# exited to a plain login shell - was treated as an empty, ready-to-inject +# AGENT composer. The away-mode escalation injector (bin/fm-supervise-daemon.sh) +# reads composer-emptiness to decide whether a pane is a safe injection target, +# so a dead-shell pane misread as "empty" meant an escalation could be typed +# into (and, worst case, executed by) that shell. Consolidating the one decision +# here means the safety rule cannot silently drift across adapters again. +# +# THE SAFETY RULE this owner enforces: a bare shell prompt glyph is a genuine +# empty agent composer ONLY when it appears INSIDE a real agent-composer +# container - a bordered composer box, where the harness draws its own prompt +# glyph (e.g. claude's older `| > ... |`). On a bare, unstructured row it is a +# dead-shell prompt and is NEVER "empty"; it classifies as `unknown` (not a safe +# injection target). The AGENT prompt glyphs `❯` (claude) and `›` (codex) are a +# genuine empty agent composer either way, bordered or bare. +# +# Each adapter still owns its own CAPTURE and structural row-finding, because +# those use genuinely different primitives (tmux's cursor-row read, herdr's ANSI +# tail scan, orca/cmux's plain read-screen). Once an adapter has a candidate +# composer row, it strips the box borders, trims, and hands the resulting +# content plus a flag to fm_composer_classify_content for the shared +# empty|pending|unknown verdict. Re-sourcing is a cheap idempotent redefinition, +# so this file needs no include guard (matching bin/fm-tmux-lib.sh). + +# fm_composer_classify_content: the single shared composer-content verdict. +# 1 when came from a genuine agent-composer container (a +# bordered composer box, or a structurally-identified bare AGENT +# prompt row); 0 for a bare, unstructured row (e.g. tmux's raw +# cursor line that carried no box border). +# the candidate composer content, already border-stripped and +# whitespace-trimmed by the caller. +# [idle_re] optional per-harness idle-placeholder regex (e.g. grok's +# "Type a message...") that reads as empty; matched both before and +# after a leading prompt glyph is stripped, so a pattern written +# with or without the glyph both land. +fm_composer_idle_matches() { + local content=$1 idle_re=$2 idle_case=$3 + [ -n "$idle_re" ] || return 1 + case "$idle_case" in + insensitive) printf '%s' "$content" | grep -qiE "$idle_re" ;; + *) printf '%s' "$content" | grep -qE "$idle_re" ;; + esac +} + +fm_composer_classify_content() { # [idle_re] [idle_case] + local bordered=$1 content=$2 idle_re=${3:-} idle_case=${4:-sensitive} + # A bare prompt glyph on its own row. + case "$content" in + '❯'|'›') + # Agent prompt glyph: a genuine empty agent composer, bordered or bare. + printf 'empty'; return 0 ;; + '>'|'$'|'%'|'#') + # Shell prompt glyph: empty ONLY inside a composer box (the harness's own + # prompt). Bare, it is a dead-shell prompt - never a safe injection target. + if [ "$bordered" = 1 ]; then printf 'empty'; else printf 'unknown'; fi + return 0 ;; + esac + # Nothing on the row = empty composer. + [ -n "$content" ] || { printf 'empty'; return 0; } + # Known idle placeholder (matched before a leading glyph is stripped). + if fm_composer_idle_matches "$content" "$idle_re" "$idle_case"; then + printf 'empty'; return 0 + fi + # Strip a leading prompt glyph, then re-judge the remainder. + case "$content" in + '❯ '*|'› '*|'> '*|'$ '*|'% '*|'# '*) content=${content#??} ;; + '❯'*|'›'*|'>'*|'$'*|'%'*|'#'*) content=${content#?} ;; + esac + content="${content#"${content%%[![:space:]]*}"}" + content="${content%"${content##*[![:space:]]}"}" + [ -n "$content" ] || { printf 'empty'; return 0; } + # Known idle placeholder (matched again after the leading glyph was stripped, + # e.g. "❯ Type a message..."). + if fm_composer_idle_matches "$content" "$idle_re" "$idle_case"; then + printf 'empty'; return 0 + fi + # Real, unsubmitted content remains. + printf 'pending'; return 0 +} diff --git a/bin/fm-supervise-daemon.sh b/bin/fm-supervise-daemon.sh index 1d33c7251c1..8cca56abfa2 100755 --- a/bin/fm-supervise-daemon.sh +++ b/bin/fm-supervise-daemon.sh @@ -499,9 +499,15 @@ pane_is_busy() { # [backend] | grep -qiE "${FM_BUSY_REGEX:-$FM_TMUX_BUSY_REGEX_DEFAULT}" } -# pane_input_pending: dispatches through fm_backend_composer_state, which for -# tmux calls the exact same fm_tmux_composer_state this function called -# directly before - byte-identical for the default/omitted-backend case. +# pane_input_pending: the standalone "is there real unsubmitted text" predicate, +# dispatching through fm_backend_composer_state (byte-identical to a direct +# fm_tmux_composer_state call for the default/omitted-backend case). inject_msg +# no longer routes its composer-guard through this boolean: a safe injection +# target must be affirmatively 'empty', and a boolean pending/not-pending check +# cannot distinguish an empty agent composer from a bare dead-shell prompt or an +# unreadable pane (both 'unknown'), so inject_msg reads the full tri-state +# verdict directly. This predicate is retained as the shared pending check and +# as the vehicle for the composer-classifier dispatch regression tests. pane_input_pending() { # [backend] local target=$1 backend=${2:-tmux} [ "$(fm_backend_composer_state "$backend" "$target" 2>/dev/null)" = pending ] @@ -714,7 +720,7 @@ window_for_task() { # [state] # line, or a previous injection's unsent text), defer entirely - injecting # would merge with the human's text. inject_msg() { # [state] - local msg=$1 state target backend retries sleep_s verdict + local msg=$1 state target backend retries sleep_s verdict composer state="${2:-$(_state_root)}" # (1) Presence-gate: inject ONLY when afk is active. When afk is off, the # daemon self-handles and stays quiet; firstmate drives the normal always-on @@ -734,17 +740,24 @@ inject_msg() { # [state] # discovery), matching this function's pre-existing default assumption. backend="${FM_SUPERVISOR_BACKEND:-tmux}" fm_backend_target_exists "$backend" "$target" || return 1 - # (3) Busy-guard: never inject into an in-use pane. Two checks: + # (3) Busy-guard: never inject into an in-use pane. # a) pane_is_busy: the harness shows a busy footer (agent mid-turn). - # b) pane_input_pending: the cursor line has real unsubmitted text after - # dim/faint ghost text and borders are ignored (a human's half-typed line, - # or a previous injection whose Enter was swallowed). if pane_is_busy "$target" "$backend"; then log "inject deferred: supervisor pane busy (agent mid-turn)" return 1 fi - if pane_input_pending "$target" "$backend"; then - log "inject deferred: supervisor pane has pending input (non-empty composer)" + # b) Composer-guard: inject ONLY into a confirmed-empty GENUINE agent + # composer. The shared classifier (fm_backend_composer_state -> + # fm_composer_classify_content, bin/fm-composer-lib.sh) reports 'pending' + # for real unsubmitted text (a human's half-typed line, or a swallowed + # prior injection) and 'unknown' for a bare dead-shell prompt (the agent + # exited to its login shell) or an unreadable pane. Neither is a safe + # target - typing the escalation into a shell could execute it - so defer + # on anything that is not affirmatively 'empty'. A deferred escalation + # stays buffered for the next cycle or the catch-up flush. + composer=$(fm_backend_composer_state "$backend" "$target" 2>/dev/null) + if [ "$composer" != empty ]; then + log "inject deferred: supervisor composer not confirmed-empty (state=${composer:-unknown}: pending input, dead-shell prompt, or unreadable pane)" return 1 fi # (4) Type the digest ONCE, then submit with Enter (retry Enter only, never diff --git a/bin/fm-tmux-lib.sh b/bin/fm-tmux-lib.sh index 0b4c239042f..0aced439d42 100755 --- a/bin/fm-tmux-lib.sh +++ b/bin/fm-tmux-lib.sh @@ -33,6 +33,14 @@ # # All functions are `set -u` and `set -e` safe (guarded tmux calls, explicit # returns) so they can be sourced into either context. +# +# Composer-content classification (empty|pending|unknown, and the fleet-wide +# rule that a BARE shell prompt glyph is a dead shell, not an empty agent +# composer) is NOT owned here: it is the shared bin/fm-composer-lib.sh, sourced +# below and reused by every backend adapter so the decision cannot drift. + +# shellcheck source=bin/fm-composer-lib.sh +. "$(dirname -- "${BASH_SOURCE[0]}")/fm-composer-lib.sh" # Busy footers per harness (mirror fm-watch.sh). claude/codex: "esc to # interrupt"; opencode: "esc interrupt"; pi: "Working..."; grok: "Ctrl+c:cancel" @@ -104,12 +112,14 @@ fm_tmux_strip_ghost() { } # fm_tmux_composer_state: classify the cursor/composer line of as -# empty - no pending input (blank, a bare prompt, a busy footer, or only dim -# ghost/placeholder text). Safe to inject; also the positive +# empty - no pending input (blank, a busy footer, an empty agent composer, or +# only dim ghost/placeholder text). Safe to inject; also the positive # acknowledgement that a submit landed. # pending - real, unsubmitted text on the cursor line (a human mid-typing, or a # previous injection whose Enter was swallowed). Defer / retry. -# unknown - the pane could not be read (tmux error). The caller decides. +# unknown - the pane could not be read (tmux error), OR the cursor line is a +# bare shell prompt (`$`/`%`/`#`/`>`) - a dead shell, not an agent +# composer, so NOT a safe injection target. The caller decides. # # The cursor line is captured WITH ANSI styling (capture-pane -e) and bounded to # the single composer row (-S/-E), then run through fm_tmux_strip_ghost so dim/faint @@ -117,35 +127,34 @@ fm_tmux_strip_ghost() { # never surfaced. The detector then strips the harness's box-drawing composer # borders ("│ … │", heavy "┃", or a plain ASCII "|") using literal-string # substitution (bash 3.2 safe, locale-independent — no \u escapes, no multibyte -# character classes), and asks whether anything real is left. +# character classes), remembers whether the row was bordered (a genuine composer +# box), and delegates the empty/pending/unknown decision to the shared owner +# fm_composer_classify_content (bin/fm-composer-lib.sh). The bordered flag is +# what lets a bordered `│ > │` (claude's own idle composer) read empty while a +# bare, unbordered `$ ` dead-shell prompt reads unknown. fm_tmux_composer_state() { # -> empty|pending|unknown - local target=$1 cy raw line stripped + local target=$1 cy raw line trimmed stripped bordered=0 cy=$(tmux display-message -p -t "$target" '#{cursor_y}' 2>/dev/null) || { printf 'unknown'; return 0; } case "$cy" in ''|*[!0-9]*) printf 'unknown'; return 0 ;; esac raw=$(tmux capture-pane -e -p -t "$target" -S "$cy" -E "$cy" 2>/dev/null) || { printf 'unknown'; return 0; } line=$(printf '%s\n' "$raw" | fm_tmux_strip_ghost) - # Strip the composer box borders (literal glyphs — no character classes). - stripped=${line//│/} # U+2502 light vertical (claude) - stripped=${stripped//┃/} # U+2503 heavy vertical - stripped=${stripped//|/} # ASCII pipe - # Trim surrounding whitespace. + trimmed="${line#"${line%%[![:space:]]*}"}" + trimmed="${trimmed%"${trimmed##*[![:space:]]}"}" + case "$trimmed" in + '│'*'│') stripped=${trimmed#│}; stripped=${stripped%│}; bordered=1 ;; + '┃'*'┃') stripped=${trimmed#┃}; stripped=${stripped%┃}; bordered=1 ;; + '|'*'|') stripped=${trimmed#|}; stripped=${stripped%|}; bordered=1 ;; + *) stripped=$trimmed ;; + esac stripped="${stripped#"${stripped%%[![:space:]]*}"}" stripped="${stripped%"${stripped##*[![:space:]]}"}" - # Nothing left inside the box = empty composer. - [ -n "$stripped" ] || { printf 'empty'; return 0; } - if [ -n "${FM_COMPOSER_IDLE_RE:-}" ] \ - && printf '%s' "$stripped" | grep -qiE "$FM_COMPOSER_IDLE_RE"; then - printf 'empty'; return 0 - fi - # Just a bare prompt glyph = empty composer (idle). - case "$stripped" in - '>'|'❯'|'$'|'%'|'#') printf 'empty'; return 0 ;; - esac - # A busy footer landing on the cursor line is not pending input. - if printf '%s' "$stripped" | grep -qiE "${FM_BUSY_REGEX:-$FM_TMUX_BUSY_REGEX_DEFAULT}"; then + # A busy footer landing on the cursor line is not pending input (tmux-specific: + # only tmux captures the raw cursor row, which may BE the footer). + if [ -n "$stripped" ] \ + && printf '%s' "$stripped" | grep -qiE "${FM_BUSY_REGEX:-$FM_TMUX_BUSY_REGEX_DEFAULT}"; then printf 'empty'; return 0 fi - printf 'pending'; return 0 + fm_composer_classify_content "$bordered" "$stripped" "${FM_COMPOSER_IDLE_RE:-}" insensitive } # fm_pane_input_pending: 0 (pending) if the cursor line holds real unsubmitted diff --git a/docs/architecture.md b/docs/architecture.md index 3eeac54c2d4..9decb821110 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -46,6 +46,8 @@ The always-on watcher also uses that library's provably-working predicate on no- The daemon escalates only captain-relevant events as one batched, single-line digest (prefixed with an in-band sentinel marker so firstmate can tell daemon injections apart from real messages). Its supervisor injection path supports tmux and herdr panes, with `FM_SUPERVISOR_BACKEND` and `FM_SUPERVISOR_TARGET` resolved independently from the task-spawn backend. 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 ANSI-aware structural composer classifier for pending-input guards and submit fallback. +Composer-content classification has one shared owner, `bin/fm-composer-lib.sh`, used by tmux, herdr, Orca, and cmux after each adapter performs its own capture and composer-row recognition. +The daemon injects only into an affirmatively `empty` composer, so both `pending` and `unknown` defer and a bare dead-shell prompt cannot receive an escalation; the complete policy is in [Composer-emptiness safety](herdr-backend.md#composer-emptiness-safety-2026-07-10-fleet-wide-across-all-four-backends). 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 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. diff --git a/docs/cmux-backend.md b/docs/cmux-backend.md index 48e752e8779..c0740259475 100644 --- a/docs/cmux-backend.md +++ b/docs/cmux-backend.md @@ -268,9 +268,10 @@ Verified live: two workspaces created with the identical title `fm-test-dup` bot cmux's `read-screen` gives plain-text capture with no cursor-row primitive and no ANSI style channel, unlike tmux's `#{cursor_y}` and unlike herdr's later `--format ansi` path for Codex ghost suggestions. Per this build task's explicit direction, `fm_backend_cmux_composer_state` is adapted directly from herdr's post-incident structural border-row classifier (`fm_backend_herdr_composer_state`, `docs/herdr-backend.md`) rather than zellij's content-diff approach: it locates the composer's own row as the only captured line whose trimmed content both starts and ends with the same border glyph (`│`, `┃`, or a plain ASCII `|`), scanning forward and keeping the LAST match so an earlier border-shaped line can never outrank the real bottom-anchored composer row. +After that adapter-owned row finding, cmux delegates the shared `empty`/`pending`/`unknown` decision to `bin/fm-composer-lib.sh`; a bare shell prompt with no boxed composer row reads `unknown`, not empty. This directly defends against the same class of incident herdr hit on 2026-07-03: a slash-command popup's first Enter can close the popup and fill an argument-hint placeholder into the composer rather than submitting, which a raw pane-content-diff check (zellij's approach) would misread as "submitted". `tests/fm-backend-cmux.test.sh` pins this exact regression shape (`test_send_text_submit_popup_autocomplete_requires_second_enter`), verifying the adapter retries a genuine second Enter rather than declaring victory after the first one closes a popup. -All four backends (tmux, herdr, zellij, cmux) expose the identical caller-facing verdict vocabulary (`empty`, `pending`, `unknown`, `send-failed`), so `fm-send.sh` needs no cmux-specific branching. +All implemented submit-verifying backends expose the identical caller-facing verdict vocabulary (`empty`, `pending`, `unknown`, `send-failed`), so `fm-send.sh` needs no cmux-specific branching. ## Test safety diff --git a/docs/configuration.md b/docs/configuration.md index d082e4bba06..3ccc003f011 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -260,7 +260,7 @@ FM_BACKEND= # optional runtime backend override for new spawns; tmux HERDR_SESSION=default # herdr-only: named session for normal backend ops; not enough for destructive cleanup (docs/herdr-backend.md) FM_BACKEND_HERDR_COMPOSER_LINES=20 # herdr-only: tail lines scanned by composer-state guard/fallback paths; idle-baseline submit confirmation uses agent-state FM_BACKEND_HERDR_IDLE_RE='^Type a message\.\.\.$' # herdr-only: empty-composer placeholder regex after ANSI, border, and prompt stripping -FM_BACKEND_HERDR_BARE_PROMPT_RE='^[❯›]' # herdr-only: verified agent glyphs recognized as an UNBORDERED (bare) composer row, e.g. claude's ❯ or codex's ›; faint Codex suggestion text after that prompt reads empty (docs/herdr-backend.md "Incident (2026-07-08)") +FM_BACKEND_HERDR_BARE_PROMPT_RE='^[❯›]' # herdr-only: verified agent glyphs recognized as an UNBORDERED (bare) composer row, e.g. claude's ❯ or codex's ›; shell glyphs remain unknown rather than empty, and faint Codex suggestion text after an agent prompt reads empty (docs/herdr-backend.md "Incident (2026-07-08)") FM_BACKEND_HERDR_SUBMIT_POLLS=6 # herdr-only: agent-state samples spread across each Enter attempt's budget when confirming a submit (docs/herdr-backend.md "Native agent-state submit confirmation") FM_BACKEND_HERDR_SUBMIT_MIN_SLEEP=0.6 # herdr-only: minimum per-Enter confirmation budget before polling agent-state after an idle baseline FM_BACKEND_ORCA_COMPOSER_LINES=200 # orca-only: terminal-read lines scanned to locate the composer row for submit verification diff --git a/docs/herdr-backend.md b/docs/herdr-backend.md index dbcc0575c99..c9cf3f95a9d 100644 --- a/docs/herdr-backend.md +++ b/docs/herdr-backend.md @@ -184,7 +184,7 @@ Herdr tasks additionally record: | Send literal (unsubmitted) | `herdr pane send-text ` | Does NOT auto-submit, contrary to the original design addendum's guess. Verified directly: a unique marker sent this way sits unexecuted in the composer until a separate Enter. Behaves exactly like tmux's `send-keys -l`. | | Send + submit atomically | `herdr pane run ` | Runs and submits a command in one call; used for the two fixed spawn-time commands (`treehouse get`, the `GOTMPDIR` export) exactly where tmux used one `send-keys ... Enter` call. | | Send key | `herdr pane send-keys ` | Verified names: `enter`, `escape` (alias `esc`), `ctrl+c` (aliases `C-c`, `c-c`). `ctrl+c` verified to interrupt a running foreground process immediately. | -| Submit confirmation (idle baseline) | `herdr agent get ` -> `.result.agent.agent_status` after Enter | `fm_backend_herdr_send_text_submit` records the pre-Enter status and, when it is idle/done, confirms delivery by polling for `working`/`blocked` across the Enter attempt's confirmation budget. Composer-state reads remain the pre-injection guard and the conservative fallback for preexisting submit-active or unreadable baselines; see "Native agent-state submit confirmation". | +| Submit confirmation (idle baseline) | `herdr agent get ` -> `.result.agent.agent_status` after Enter | `fm_backend_herdr_send_text_submit` records the pre-Enter status and, when it is idle/done, confirms delivery by polling for `working`/`blocked` across the Enter attempt's confirmation budget. Composer-state reads remain the affirmative-empty pre-injection guard and the conservative fallback for preexisting submit-active or unreadable baselines; see "Native agent-state submit confirmation". | | Bounded capture | `herdr pane read --source recent --lines N` | See "Verified bug" below - N is never passed through directly. | | ANSI capture | `herdr pane read --source recent --lines N --format ansi` | Herdr 0.7.3 preserves Codex's faint SGR style in the composer row, letting `fm_backend_herdr_composer_state` treat ghost suggestions as empty while keeping non-faint real typed text pending. The same small-`--lines` workaround applies. | | Busy state | `herdr agent get ` -> `.result.agent.agent_status` | Verified live against an interactive `claude` session: reports `working` while generating, `done` once idle. Mapped: `working` -> busy; `idle`/`done` -> idle; `blocked` -> idle (surfaced like a stale pane, not suppressed as busy - a blocked agent is stuck waiting on the human, not grinding); anything else -> unknown (the cue for the shared tail-regex fallback). | @@ -260,7 +260,7 @@ See `fm_backend_herdr_composer_state`, `fm_backend_herdr_wait_for_working`, and The herdr adapter no longer diffs raw pane content before/after Enter (see the incident above for why that was unsafe). It keeps `fm_backend_herdr_composer_state` as a structural classifier for the composer's own row - located as the bottom-most bordered composer row or verified bare prompt row described above - and reports `empty`, `pending`, or `unknown`. When ANSI capture is available, the classifier keeps the raw styled row long enough to ignore faint Codex ghost suggestions after the bare `›` prompt while still treating the same non-faint text as pending input. -That classifier is still the pre-injection pending-input guard for the away-mode daemon and the conservative fallback when `fm_backend_herdr_send_text_submit` cannot use an idle/done native agent-state baseline. +That classifier is still the away-mode daemon's affirmative-empty pre-injection guard and the conservative fallback when `fm_backend_herdr_send_text_submit` cannot use an idle/done native agent-state baseline. Normal idle-baseline submit confirmation now uses herdr's native agent-state instead; see "Native agent-state submit confirmation" for the current submit path. A dedicated composer-state or cursor-row/style primitive is still a candidate upstream Herdr feature request; it would let the guard/fallback classifier eventually reach tmux's cursor-row precision instead of relying on a structural approximation over captured tail rows and ANSI style. @@ -372,7 +372,7 @@ Classification policy, batching, the max-defer escape, the `FM_INJECT_MARK` sent A new `FM_SUPERVISOR_BACKEND` override (`tmux`|`herdr`) resolves independently, mirroring `bin/fm-backend.sh`'s own `fm_backend_detect`: `$TMUX_PANE` set selects tmux (even nested inside herdr, matching the innermost-first rule); `$HERDR_ENV=1` with `$HERDR_PANE_ID` present selects herdr, composing the target as `"${HERDR_SESSION:-default}:${HERDR_PANE_ID}"`; absent both, the daemon falls back to tmux/`firstmate:0`, byte-identical to its pre-herdr-support behavior. Other runtime backends, including zellij, orca, and cmux, are not yet supported as supervisor backends - the daemon refuses loudly at startup (`FM_SUPERVISOR_SUPPORTED_BACKENDS="tmux herdr"`) rather than misapplying tmux primitives to a pane that isn't a tmux pane. -**Injection dispatch.** `inject_msg`'s pane-exists probe, busy-guard (`pane_is_busy`), composer-guard (`pane_input_pending`), and verified submit all take an optional `` argument (defaulting to `tmux` when omitted, so every pre-existing caller/test is unaffected) and route through the generic dispatchers instead of calling `tmux` directly. +**Injection dispatch.** `inject_msg`'s pane-exists probe, busy-guard (`pane_is_busy`), composer-guard (a direct `fm_backend_composer_state` read; see the composer-safety note below), and verified submit all take an optional `` argument (defaulting to `tmux` when omitted, so every pre-existing caller/test is unaffected) and route through the generic dispatchers instead of calling `tmux` directly. For `backend=tmux` every dispatch resolves to the exact same underlying call as before (`fm_backend_capture`'s tmux arm runs the identical `tmux capture-pane -p -t -S -40`; `fm_backend_tmux_send_text_submit` re-exports `fm_tmux_submit_core` verbatim), so tmux behavior is unchanged byte-for-byte. For `backend=herdr`, busy detection tries the native `agent.get`-backed `fm_backend_herdr_busy_state` first, trusts only `busy` outright, and corroborates every non-`busy` verdict with the shared regex-over-capture reader before treating the supervisor pane as not busy. This mirrors the per-task stale-pane busy check `bin/fm-supervise-daemon.sh`'s `stale_window_is_busy` already used; composer/pending detection and the verified submit route through `fm_backend_herdr_composer_state`/`fm_backend_herdr_send_text_submit`. @@ -408,7 +408,8 @@ Real `claude`'s live input row is a BARE, unbordered `❯ …` - no border glyph Claude's own startup welcome banner IS bordered, so immediately after launch the classifier's "last bordered row wins" scan locks onto the banner's own blank interior spacer row and misreads it as the composer (a coincidental, and wrong, "empty"). Once ordinary conversation scrolls that banner out of the 20-line capture window - true of any real supervisor pane with any history at all, which is every production case - NO bordered row exists anywhere in view, so the classifier reports `unknown` for a genuinely empty composer, forever. At the time, `fm_backend_herdr_send_text_submit` treated only a composer `empty` verdict as a confirmed submit; `unknown` counted as failure, so `escalate_flush` never cleared the buffer even though the real Enter genuinely submitted the digest to the real pane. -Because `pane_input_pending`'s pre-type guard only defers on `pending` (never `unknown`), the next housekeeping tick's flush attempt retypes and resubmits the SAME unmodified buffer content - the redelivery loop. +At the time, the composer-guard deferred only on `pending` (never `unknown`), so the next housekeeping tick's flush attempt retyped and resubmitted the SAME unmodified buffer content - the redelivery loop. +That guard has since been hardened to require an affirmatively-`empty` composer (see "Composer-emptiness safety" below), so an `unknown` verdict now defers injection instead of proceeding. Also discovered while reproducing: real `codex` (0.142.x) has the identical unbordered-live-row shape, using `›` instead of claude's `❯`, confirming this is not claude-specific. Codex additionally shows dynamic tip/hint text in its idle composer rather than a blank row. @@ -516,7 +517,7 @@ The unit regression coverage is `tests/fm-backend-herdr.test.sh`'s `test_compose `fm_backend_herdr_send_text_submit` now records a pre-Enter native agent-state baseline before choosing the confirmation signal. When that baseline is legibly idle or done, it confirms a submit by polling herdr's own semantic agent-state (`agent get`) for a submit-active transition (`working` or `blocked`), via the new `fm_backend_herdr_wait_for_working` helper. -Composer content (`fm_backend_herdr_composer_state`) is still used for the pre-injection empty-box guard (`bin/fm-supervise-daemon.sh`'s `pane_input_pending`, dispatched through `fm_backend_composer_state`). +Composer content (`fm_backend_herdr_composer_state`) is still used for the pre-injection empty-box guard (`bin/fm-supervise-daemon.sh`'s `inject_msg`, which reads `fm_backend_composer_state` directly and requires an affirmatively-`empty` verdict; see "Composer-emptiness safety" below). It is also the conservative fallback for submit attempts whose pre-Enter baseline is already submit-active or unreadable, because a preexisting `working`/`blocked` status cannot prove that this Enter landed. This makes the normal idle-baseline confirmation path cross-agent: it no longer depends on what a harness's idle composer happens to display. @@ -575,6 +576,25 @@ The composer-guard regression for the 2026-07-08 AFK delivery bug lives in `test The fix: the fixture now registers itself as a real herdr agent via `herdr pane report-agent --source --agent