Skip to content

feat(bin): add prime-agent as a verified harness adapter - #1966

Closed
eduardstan wants to merge 14 commits into
kunchenguid:mainfrom
eduardstan:fm/prime-adapter
Closed

feat(bin): add prime-agent as a verified harness adapter#1966
eduardstan wants to merge 14 commits into
kunchenguid:mainfrom
eduardstan:fm/prime-adapter

Conversation

@eduardstan

Copy link
Copy Markdown

Intent

firstmate gains prime-agent as a verified harness adapter - crewmate, scout and secondmate capable - with its own supervision protocol, primary guard extensions under the .prime/agent layout, teardown that stops its daemon session, and composer recognition so steers verify.

Scope context a reviewer reading only the diff would not have. The task was deliberately NARROWED partway through by the captain, because prime-agent already runs firstmate workers today through fm-spawn's raw-launch escape hatch and Herdr already reads their busy/idle state from prime-agent's own built-in reporter. The instruction was to ship only what that escape hatch genuinely cannot do - (1) teardown shutting down prime-agent's detached daemon, (2) composer recognition so bin/fm-send.sh verifies instead of false-failing, (3) secondmate capability - plus the minimum harness registration those three require, and nothing beyond it.

Deliberate exclusions the captain asked for, so their absence is intended and not an oversight:

  • No firstmate-owned busy-state extension. prime-agent's own built-in Herdr reporter already publishes pane state, so the per-task extension carries ONLY the turn_end wake touch and nothing is armed; a seeded busy record would have no firstmate writer that could ever clear it. The accepted cost is that an idle prime-agent worker classifies "unknown missing" rather than "idle", while a busy one classifies "busy herdr-native". That is the pre-existing escape-hatch behaviour and unknown surfaces rather than hides.
  • No tmux agent-process classification and no tmux composer arm. This home runs Herdr, and that check is harness-dependent on rendered output, so per the repo's own coding guidelines it must be proven against a real prime-agent pane under real tmux before it ships. The resulting gap is recorded explicitly in the adapter's skill section: a mid-turn steer to a tmux-hosted prime-agent worker still reads unknown and fails loudly rather than silently.
  • No harness-adapters model-discovery row, and no remote-secondmate allowlist entries; remote secondmates stay refused on prime-agent because none exists here.

Deliberate design decisions that may look surprising in the diff:

  • bin/fm-harness.sh tests prime-agent's own markers BEFORE the CLAUDECODE fast path. This ordering is required, not stylistic. prime-agent's resident session worker inherits the long-lived per-user daemon supervisor's environment rather than the launching client's, so a supervisor first started from a Claude session hands CLAUDECODE=1 to every later prime-agent worker and no launch-side env -u can reach it. This was reproduced live as a crewmate reporting itself as claude. The same inheritance is why FM_PI_HARNESS, which works as the launch boundary for pi-signed, never reaches a prime-agent tool subprocess at all. The precedence flip is deliberately narrow: it requires PI_CODING_AGENT=true alongside the prime-agent marker, so a lone stale PRIME_AGENT_* still resolves to claude, and both directions are pinned by tests.
  • .prime/agent/extensions/ holds a SEPARATE pair of primary extensions rather than sharing the .pi/ ones. prime-agent has no agent_settled event at all and registering one is silently accepted but never fires, so the guard reconstructs the settle from agent_end following prime-agent's own built-in Herdr reporter. ctx.isIdle() is deliberately NOT the gate: it was measured false throughout a live run. The watcher extension is the Pi one minus its Calm rendering layer, which prime-agent's API cannot support and never sees anyway. Both files import the shared operational-input encoder from the .pi/ tree on purpose, to keep one owner rather than a second copy that would drift.
  • bin/fm-prime-agent-lib.sh exists as a lib rather than a copy in each caller because teardown and secondmate relaunch need exactly the same retirement. It never uses prime-agent shutdown, which would stop the captain's own sessions and every other home's workers, and it deliberately ignores prime-agent status, which marks even a live session's forkserver "stale".
  • The Herdr composer arm requires TWO independent signals before promoting a bare ">" row to a composer. The fleet-wide safety rule that a bare shell glyph on an unstructured row is a dead shell is NOT relaxed. Both signals are needed because /quit leaves prime-agent's reporter identity behind on a pane that has already returned to a login shell, and on the reporter alone a shell with PS1='> ' was verified live to become an injection target.
  • prime-agent is added to bin/fm-session-lock-lib.sh even though the pid it records is a detached daemon worker that outlives the pane. That is safe only because fm-spawn --secondmate now retires the worker bound to a home before relaunching it there. The wedge and the fix were both reproduced live rather than argued.

Everything relied on about prime-agent's Herdr integration comes from reading dist/core/extensions/builtin/herdr-agent-state.js, because prime-agent ships zero mentions of that integration across all 36 of its doc files. That source dependency is recorded in the adapter section so a future upgrade re-reads the source rather than trusting a release note.

Delivery constraints for this run: the branch fm/prime-adapter must not be reset or replaced. Do not change any git remote - origin is the parent kunchenguid/firstmate and the gate pushes to the fork eduardstan/firstmate exactly as CONTRIBUTING.md prescribes. The branch sits on upstream main, so a rebase warning about bundling ~52 commits is a fork/upstream divergence artifact and the rebase step is to be skipped rather than rebased onto origin/main. The PR must open against the parent repo, not the fork.

What Changed

  • Registers prime-agent across the harness surface: bin/fm-harness.sh detects it from its own PRIME_AGENT_* markers alongside PI_CODING_AGENT=true before the CLAUDECODE fast path (its daemon worker inherits the long-lived supervisor's environment, so a supervisor first started under Claude otherwise misreports every later worker), bin/fm-spawn.sh gains crewmate and secondmate launch shapes plus --model/--thinking mapping, and it is accepted as a verified harness by bootstrap crew dispatch and secondmate liveness. Adds a prime-agent supervision protocol doc and a separate pair of primary extensions under .prime/agent/extensions/ (turn-end guard reconstructing settle from agent_end, and a watcher extension), wired into session-start load reporting and the supervision instructions.
  • Adds bin/fm-prime-agent-lib.sh as the single owner of retiring prime-agent's detached daemon sessions by recorded cwd (bounded CLI calls, never prime-agent shutdown). bin/fm-teardown.sh stops sessions bound to a worktree or removed home before the generic process reaper, fm-spawn --secondmate retires the worker bound to a home before relaunching it, and bin/fm-session-lock-lib.sh lists prime-agent as a lock-holding harness while treating a worker with no attached client and no in-flight activity as not alive.
  • Teaches the Herdr backend two prime-agent-specific reads: a pane whose reporter still claims prime-agent but whose process subtree holds none is reported no-agent (its reporter identity survives /quit), and a bare > row is promoted from dead shell to composer only when the live foreground process and the native reporter both say prime-agent. New tests cover detection in both directions, launch shapes, composer promotion and its refusal, daemon retirement, lock ancestry, and the watcher extension.

Risk Assessment

⚠️ Medium: The adapter is broad (2378 insertions touching fleet-wide session-lock liveness, the Herdr pane-agent-state classifier, and every home-removal path) and several safety-critical verdicts rest on prime-agent's internal dist/ JSON shapes that an upgrade can change silently, but every fix-round claim verified against that source holds and only two low-severity issues remain, so it is safe to merge with those as follow-ups.

Testing

Ran the five test scripts this change touches — the new prime-agent harness, prime watcher-extension, session-lock ancestry and the herdr backend suite all pass; the Pi/prime TypeScript no-emit check gate-skips here because neither tsc nor the global pi-coding-agent package is installed and installing them is outside the worktree boundary, so CI owns that one. Because the machine has real prime-agent 0.7.1 and real herdr, I went past the fixtures: I launched a real prime-agent session in a temp directory, confirmed its detached daemon worker outlived its client, and watched fm_prime_agent_stop_sessions_under retire exactly that session while the captain's live session and one unrelated session stayed untouched. I fed the real inherited environment of a live prime-agent daemon worker into both the base and target fm-harness.sh: base answers "claude" (the reported live misidentification), target answers "prime-agent". In an isolated fm-herdr-lab session I ran the real prime-agent TUI in a pane — its bare ">" composer classifies "empty" on the target but "unknown" at base, which is exactly the false-fail steers were hitting — then reproduced the /quit hazard, where the reporter still claims agent: prime-agent on a login shell with PS1='> ' and the target still refuses it as a composer and reports no-agent. tmux-side classification is intentionally out of scope per the intent, and this home has no tmux, so nothing there was exercised. Evidence is CLI transcripts rather than screenshots because every surface here is shell-level; the herdr pane renders were captured as text through herdr's own read API.

Evidence: Live teardown of a real detached prime-agent daemon session (only the target worktree's session stops)

$ ps -o pid,stat,args -p 3321054 # daemon worker outlived the pane's client process 3321054 Ssl prime-agent BEFORE: [{"id":"7d92f3b43dd7","lifecycle":"live","cwd":"/home/eduard/.treehouse/fm-7bab20/1/fm"},{"id":"13171cb33bf2",...},{"id":"c32a348db616","lifecycle":"draft","cwd":"/tmp/fm-prime-e2e.fMp7f8"}] $ . bin/fm-prime-agent-lib.sh; fm_prime_agent_stop_sessions_under /tmp/fm-prime-e2e.fMp7f8 prime-agent: stopping detached session c32a348db616 bound to /tmp/fm-prime-e2e.fMp7f8 AFTER: [{"id":"7d92f3b43dd7","lifecycle":"live",...},{"id":"13171cb33bf2",...}]

0.7.1
### prime-agent  / real daemon, no stubs

$ ps -o pid,stat,args -p 3321054   # daemon worker outlived the pane's client process
    PID STAT COMMAND
3321054 Ssl  prime-agent

$ prime-agent list --json | jq '[.sessions[]|{id,lifecycle,cwd}]'   # BEFORE teardown
[{"id":"7d92f3b43dd7","lifecycle":"live","cwd":"/home/eduard/.treehouse/fm-7bab20/1/fm"},{"id":"13171cb33bf2","lifecycle":"draft","cwd":"/tmp/claude-1000/-home-eduard-Dropbox-Projects-fm/ad8ca542-8c91-48e4-af74-978f07febf2b/scratchpad/prime-suspend"},{"id":"c32a348db616","lifecycle":"draft","cwd":"/tmp/fm-prime-e2e.fMp7f8"}]

$ . bin/fm-prime-agent-lib.sh; fm_prime_agent_stop_sessions_under /tmp/fm-prime-e2e.fMp7f8
prime-agent: stopping detached session c32a348db616 bound to /tmp/fm-prime-e2e.fMp7f8

$ prime-agent list --json | jq '[.sessions[]|{id,lifecycle,cwd}]'   # AFTER
[{"id":"7d92f3b43dd7","lifecycle":"live","cwd":"/home/eduard/.treehouse/fm-7bab20/1/fm"},{"id":"13171cb33bf2","lifecycle":"draft","cwd":"/tmp/claude-1000/-home-eduard-Dropbox-Projects-fm/ad8ca542-8c91-48e4-af74-978f07febf2b/scratchpad/prime-suspend"}]
Evidence: Harness detection under the real environment of a live prime-agent daemon worker (base vs target)

relevant inherited vars (pid 2210194, prime-agent 0.7.1): CLAUDECODE=1 PI_CODING_AGENT=true PRIME_AGENT_INTERNAL_DAEMON_WORKER=1 $ /tmp/fm-harness-base.sh # base 833a9a2 (before this change) claude $ bin/fm-harness.sh # target 1894f52 (this change) prime-agent

### fm-harness.sh run under the REAL environment of a live prime-agent 0.7.1 daemon worker (pid 2210194)

relevant inherited vars:
  CLAUDECODE=1
  PI_CODING_AGENT=true
  PRIME_AGENT_INTERNAL_DAEMON_WORKER=1
  FM_PI_HARNESS=<unset>
  PRIME_AGENT_CODING_AGENT_DIR=<unset>

$ /tmp/fm-harness-base.sh   # base 833a9a2 (before this change)
claude

$ bin/fm-harness.sh   # target 1894f52 (this change)
prime-agent
Evidence: Composer recognition on a live prime-agent TUI in a real herdr pane (base false-fails, target verifies)

$ herdr pane read (bottom of the real TUI) > Try "add tests for @<filepath>" ← agents/resume Claude Opus 5 • medium ? f… 0 (0%) $ herdr agent get w1:p1 {"agent":"prime-agent","agent_status":"idle"} $ fm_backend_herdr_composer_state base 1894f52^: unknown target 1894f52 : empty $ fm_backend_herdr_pane_agent_state (target) live

### live herdr pane running real prime-agent 0.7.1 (isolated fm-herdr-lab session)

$ herdr pane read  (bottom of the real TUI)

 >   Try "add tests for @<filepath>"

← agents/resume  Claude Opus 5 • medium  ? f…  0 (0%)
$ herdr agent get w1:p1
{"agent":"prime-agent","agent_status":"idle"}

$ fm_backend_herdr_composer_state   # base 833a9a2 vs target 1894f52
  base   1894f52^: unknown
  target 1894f52 : empty

$ fm_backend_herdr_pane_agent_state  (target)
  live
Evidence: The /quit hazard: reporter identity survives onto a `> ` login shell and is still refused

$ herdr agent get w1:p1 # still claims a prime-agent is here {"agent":"prime-agent","agent_status":"idle"} pane render: > PS1='> ' > $ fm_backend_herdr_composer_state (target) unknown <- injection target refused $ fm_backend_herdr_pane_agent_state (target) no-agent <- quit pane no longer counted alive

### the /quit hazard: prime-agent's reporter identity SURVIVES onto a login shell with PS1='> '

$ herdr agent get w1:p1   # still claims a prime-agent is here
{"agent":"prime-agent","agent_status":"idle"}

$ pane render (bottom rows) - a bare shell prompt wearing prime-agent's composer glyph

← agents/resume  Claude Opus 5 • medium  ? f…  0 (0%)
> PS1='> '
>
$ fm_backend_herdr_composer_state   (target 1894f52)
  unknown   <- injection target refused
$ fm_backend_herdr_pane_agent_state (target 1894f52)
  no-agent   <- quit pane no longer counted alive
Evidence: Session-lock reclaim query run against the real prime-agent 0.7.1 listing

[{"id":"7d92f3b43dd7","workerPid":2210194,"attachedClients":1,...},{"id":"13171cb33bf2","workerPid":2657115,"attachedClients":0,...}] pid 2210194 -> live/unknown (lock kept) pid 2657115 -> abandoned (lock reclaimable) pid 999999 -> live/unknown (lock kept)

### fm_prime_agent_worker_abandoned against the real 0.7.1 listing

[{"id":"7d92f3b43dd7","lifecycle":"live","workerPid":2210194,"attachedClients":1,"activeSessionId":"7d92f3b43dd7"},{"id":"13171cb33bf2","lifecycle":"draft","workerPid":2657115,"attachedClients":0,"activeSessionId":"13171cb33bf2"}]

pid 2210194 -> live/unknown (lock kept)
pid 2657115 -> abandoned (lock reclaimable)
pid 999999 -> live/unknown (lock kept)

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

⚠️ **Review** - 2 infos
  • ⚠️ bin/fm-teardown.sh:2362 - Teardown of a prime-agent SECONDMATE never retires that home's detached daemon worker. The new retirement calls cover task worktrees (line 2218, guarded by [ &#34;$KIND&#34; != secondmate ]) and a home's child task worktrees (line 2067), but the KIND=secondmate path runs remove_firstmate_home &#34;$HOME_PATH&#34; with no fm_prime_agent_stop_sessions_under anywhere ahead of it. Concrete sequence: spawn a prime-agent secondmate into home H; bin/fm-teardown.sh &lt;id&gt; --force; the worker whose recorded cwd is H is still lifecycle=live (the same survival the change documents as verified for task worktrees), while remove_firstmate_home either treehouse returns H to the pool or rm -rfs it. A later treehouse get then hands that same worktree to a new task with a foreign live agent process still holding it as cwd and holding a transcript lease. This is the one path the intent's required behavior ("teardown that stops its daemon session") plus required secondmate capability intersect on and it is missed. Earliest shared boundary that closes it for every home-removal caller, including nested secondmate homes reached via cleanup_firstmate_home_children: one fm_prime_agent_stop_sessions_under &#34;$abs_home_path&#34; inside remove_firstmate_home() (bin/fm-teardown.sh:1679), after validate_firstmate_home_for_removal resolves the path and before the treehouse return / safe_rm_rf.
  • ⚠️ bin/fm-bootstrap.sh:689 - A /quit prime-agent pane classifies alive, so the relaunch that the change relies on as its own safety proof can never fire. The diff establishes as verified fact (bin/backends/herdr.sh:2802-2807, and it is the entire reason the composer arm needs two signals) that after /quit the pane returns to a login shell while Herdr still reports agent: prime-agent, agent_status: idle. fm_backend_herdr_pane_agent_state (bin/backends/herdr.sh:1905-1909) maps idle straight to live, and fm_backend_herdr_agent_state maps that to alive. Concrete sequence: a prime-agent secondmate's operator types /quit; the pane is now an unusable login shell but secondmate_liveness_one takes the alive branch and prints nothing actionable; fm-spawn.sh --secondmate is never invoked, so the new fm_prime_agent_stop_sessions_under &#34;$PROJ_ABS&#34; retirement (bin/fm-spawn.sh:1368) never runs; and the detached worker keeps satisfying FM_HARNESS_RE as a live lock holder. That is exactly the wedge bin/fm-session-lock-lib.sh:17-23 says the spawn-side retirement is what makes safe, so the stated invariant does not hold for the quit path. The change already ships the correct proof: gate the live verdict for a prime-agent-identified pane on fm_backend_herdr_pane_prime_agent_foreground, the same kernel-level check the composer arm uses, so a quit pane classifies dead and the existing respawn-plus-retire path takes over.
  • ⚠️ bin/fm-harness.sh:57 - An inherited PRIME_AGENT_CODING_AGENT_DIR outranks the explicit FM_PI_HARNESS launch-boundary stamp, misidentifying pi and pi-signed workers. The new branch fires on PI_CODING_AGENT=true plus any prime-agent marker without consulting FM_PI_HARNESS, so it wins before the pi/pi-signed resolution at line 68. Concrete sequence, newly reachable because this change makes prime-agent a supported primary: the captain runs prime-agent as primary; a spawn from its bash tool subprocess starts the tmux/herdr server, which stores PRIME_AGENT_CODING_AGENT_DIR from that subprocess in its session environment (the same stored-environment hazard the surrounding comment already documents); a later pi-signed crewmate is launched into that server with FM_PI_HARNESS=pi-signed prepended by bin/fm-spawn.sh:960; inside it, bin/fm-harness.sh prints prime-agent instead of pi-signed, discarding the marker the file itself calls "the authoritative selection marker". Fix is to make the prime branch yield when the launch boundary explicitly names another Pi-family member, e.g. add &amp;&amp; [ &#34;${FM_PI_HARNESS:-}&#34; != pi ] &amp;&amp; [ &#34;${FM_PI_HARNESS:-}&#34; != pi-signed ] to the condition; the existing both-directions detection test can be extended with that pair.
  • ⚠️ README.md:61 - README's verified-primary list is now inconsistent with the shipped adapter, and it is the declared owner of that set. docs/configuration.md:209 (changed by this diff) states "README requirements own the set supported for the primary session", and this change makes prime-agent primary-capable: it has docs/supervision-protocols/prime-agent.md, a bin/fm-session-start.sh extension-load report, a turn-end guard, and secondmate support (a secondmate IS a firstmate primary). README.md:61 still reads "Claude Code, Grok, Pi, pi-signed, Codex, or OpenCode", and README.md:214's supervision-protocols enumeration likewise omits it, while AGENTS.md:178, docs/architecture.md:60, and docs/configuration.md:209 were all updated. Concrete effect: a reader following the one-owner pointer from configuration.md to README concludes prime-agent is not supported for a primary session, contradicting the adapter that just shipped.
  • ℹ️ bin/backends/herdr.sh:2869 - The prime promotion costs one or two extra Herdr RPCs per composer read on non-prime panes whose capture window contains a markdown blockquote. prime_line is set for any row matching ^&gt;( |$), and the promotion runs whenever prime_line &gt; generic_line; for a Pi pane (separated composer, no prompt glyph or border, so generic_line is often 0) any &gt; quoted text transcript row in the last 20 lines triggers pane process-info and then agent get on every call. Concrete case: away-mode's pre-injection guard polling a Pi pane whose transcript ends with a quoted block pays two extra round-trips per poll. The verdict is still correct (both signals fail, the row is not promoted) and the cost is bounded, so this is a note rather than a defect; a cheap guard would be to skip the promotion unless the held-aside row is the last non-blank row of the capture.

🔧 Fix: let explicit FM_PI_HARNESS outrank stale prime-agent markers
6 issues (5 warnings, 1 info) still open:

  • ⚠️ bin/fm-teardown.sh:2362 - Teardown of a prime-agent SECONDMATE never retires that home's detached daemon worker. The new retirement calls cover task worktrees (line 2219, guarded by [ &#34;$KIND&#34; != secondmate ]) and a home's child task worktrees (line 2067), but the KIND=secondmate path goes straight to remove_firstmate_home &#34;$HOME_PATH&#34; with no fm_prime_agent_stop_sessions_under anywhere ahead of it - the comment at line 2211 says a secondmate home's runtime lifecycle is owned by 'the process-event and firstmate-home removal machinery further below', and that machinery has no prime-agent step. Concrete sequence: spawn a prime-agent secondmate into home H; run bin/fm-teardown.sh &lt;id&gt; --force; the worker whose recorded cwd is H is still lifecycle=live (the exact survival this change documents and verifies for task worktrees), while remove_firstmate_home either treehouse returns H to the pool or safe_rm_rfs it. A later treehouse get then hands that same worktree to a new task while a foreign live agent holds it as cwd and holds a transcript lease. This is where the intent's required 'teardown that stops its daemon session' and required secondmate capability intersect, and it is the one intersection with no coverage: tests/fm-prime-agent-harness.test.sh:267 exercises a SCOUT teardown, which takes the non-secondmate branch. Earliest shared boundary that closes it for every home-removal caller, including nested homes reached via cleanup_firstmate_home_children: one fm_prime_agent_stop_sessions_under &#34;$abs_home_path&#34; inside remove_firstmate_home() (bin/fm-teardown.sh:1679) after validate_firstmate_home_for_removal resolves the path and before the treehouse return / safe_rm_rf.
  • ⚠️ bin/fm-session-lock-lib.sh:24 - Adding prime-agent to FM_HARNESS_RE makes an in-place restart of a prime-agent home land read-only, and the retirement that is supposed to make this entry safe only exists on the fm-spawn --secondmate path. The lock records the harness ancestor pid, which for prime-agent is the detached worker, and this change's own verified fact is that /quit leaves that worker live. Concrete sequence, driven by instructions this same change ships: bin/fm-session-start.sh:648 prints 'restart prime-agent from this repo root so <ext> and <ext> auto-load', and docs/supervision-protocols/prime-agent.md step 2 says the same; the operator quits and relaunches prime-agent in that same pane (a fresh session, not --resume); bin/fm-lock.sh:67 then reads the old worker pid, fm_harness_pid_alive returns true because its comm is still prime-agent, and the new session is refused with 'another live firstmate session holds the lock; operate read-only until resolved' - the identical wedge the commit message says was reproduced live, now with no caller to clear it, because fm_prime_agent_stop_sessions_under runs only from bin/fm-spawn.sh:1368 and bin/fm-teardown.sh. Recovery requires a manual prime-agent stop, which nothing in the shipped docs mentions. Earliest shared boundary: the live-holder refusal in bin/fm-lock.sh, which for prime-agent needs to treat an alive-but-unbound worker as reclaimable (or the restart instructions in fm-session-start.sh and the supervision protocol must retire the home's session first).
  • ⚠️ bin/fm-bootstrap.sh:689 - A /quit prime-agent secondmate pane classifies alive, so the relaunch path that the rest of the design leans on never fires. The change establishes as verified fact (bin/backends/herdr.sh:2802-2807 - it is the whole reason the composer arm needs two signals) that after /quit the pane is a login shell while Herdr still reports agent: prime-agent, agent_status: idle. fm_backend_herdr_pane_agent_state (bin/backends/herdr.sh:1905-1909) maps idle to live, and fm_backend_herdr_agent_state maps that to alive. Concrete sequence: a prime-agent secondmate's operator types /quit; the pane is now an unusable shell but secondmate_liveness_one takes the alive branch and prints nothing actionable; fm-spawn.sh --secondmate is never invoked, so the retirement at bin/fm-spawn.sh:1368 never runs and the detached worker keeps satisfying FM_HARNESS_RE as a lock holder. The change already ships the correct discriminator: gate the live verdict for a prime-agent-identified pane on fm_backend_herdr_pane_prime_agent_foreground, the same kernel-level check the composer arm uses, so a quit pane classifies dead and the existing respawn-plus-retire path takes over.
  • ⚠️ bin/fm-harness.sh:62 - The fix-round commit (bc19394) inverts the precedence the intent marks as required, and re-opens the misdetection in the other direction. The intent states: 'bin/fm-harness.sh tests prime-agent's own markers BEFORE the CLAUDECODE fast path. This ordering is required, not stylistic' - because a prime-agent worker inherits the long-lived per-user SUPERVISOR's environment, not the launching client's. The new &amp;&amp; [ &#34;${FM_PI_HARNESS:-}&#34; != pi ] &amp;&amp; [ &#34;${FM_PI_HARNESS:-}&#34; != pi-signed ] conjunct makes an inherited FM_PI_HARNESS outrank the per-tool-call vendor markers, but FM_PI_HARNESS is subject to exactly the same supervisor inheritance: if the daemon supervisor is first started from a context that carries FM_PI_HARNESS=pi-signed (a pi-signed worker launched by bin/fm-spawn.sh:960 running prime-agent from its bash tool, or the raw-launch escape hatch the intent says is in daily use), every later prime-agent worker inherits it. Detection then skips the prime branch and falls into the CLAUDECODE fast path on the very next line, so such a worker reports pi-signed - or claude when that supervisor also captured CLAUDECODE=1, which is the exact live-reproduced failure the ordering exists to prevent. The added tests (tests/fm-prime-agent-harness.test.sh:56-62) pin only the pi-side direction and would still pass with this hole open. Both markers are ambiguous under stored/inherited environments, so this is a precedence call the author should make rather than the pipeline: PRIME_AGENT_CODING_AGENT_DIR is injected per tool call and PRIME_AGENT_INTERNAL_DAEMON_WORKER=1 is set by the worker itself, which is why the original ordering ranked them highest.
  • ⚠️ README.md:61 - README's verified-primary list is now inconsistent with the shipped adapter, and it is the declared owner of that set. docs/configuration.md:209 (changed by this diff) states 'README requirements own the set supported for the primary session', and this change makes prime-agent primary-capable: docs/supervision-protocols/prime-agent.md, the bin/fm-session-start.sh:635 extension-load report, the turn-end guard, and secondmate support (a secondmate IS a firstmate primary). README.md:61 still reads 'Claude Code, Grok, Pi, pi-signed, Codex, or OpenCode', and README.md:214's supervision-protocols enumeration likewise omits it, while AGENTS.md:178, docs/architecture.md:60, and docs/configuration.md:209 were all updated. A reader following the one-owner pointer from configuration.md to README concludes prime-agent is unsupported for a primary session, contradicting the adapter that just shipped.
  • ℹ️ .prime/agent/extensions/fm-primary-prime-watch.ts:1 - The two new tracked extensions (729 lines, the guard and watcher a prime-agent secondmate actually runs) sit outside both contracts their Pi siblings are held to: tests/fm-pi-primary-types.test.sh copies and strict-typechecks only the three .pi/extensions/*.ts files, and tests/fm-pi-watch-extension.test.sh behaviorally drives the Pi watcher's generation ownership, retry, and unchanged-no-op paths against a mock API - the prime pair has neither, so its evidence is the live run recorded in the commit message alone. Note before extending the typecheck: both files import type { ExtensionAPI } from @earendil-works/pi-coding-agent while registering prime-agent's own event set (agent_start/agent_end with ctx.hasPendingMessages), so adding them to the existing tsconfig may need a local type shim rather than being a pure test edit - which is why this is a call for the author rather than a mechanical fix.

🔧 Fix: reconcile prime-agent daemon workers with teardown, locks and liveness
5 issues (4 warnings, 1 info) still open:

  • ⚠️ bin/fm-prime-agent-lib.sh:85 - fm_prime_agent_worker_abandoned decides liveness from attachedClients ALONE, so a detached worker that is still running a turn is reported dead and its home's lock can be taken by a second session. The change's own evidence is that the worker outlives the pane mid-flight ("a torn-down task's session was still lifecycle=live"). Concrete sequence: a prime-agent secondmate is mid-turn when its pane dies (herdr/tmux restart, pane kill); the daemon worker keeps streaming and writing into the home, but no client is attached, so attachedClients is 0. The operator follows this change's own in-place-restart instruction (bin/fm-session-start.sh:648, docs/supervision-protocols/prime-agent.md), and bin/fm-lock.sh:64/89 now sees the recorded holder as not alive, overwrites state/.lock, and the new session starts writing the same home while the old worker is still working - the exact double-writer the fleet lock exists to prevent. The vendor publishes the discriminator in the same summary the query already reads: its own idle-eviction predicate is !session.isSessionActive &amp;&amp; session.attachedClients === 0 (dist/bundle/chunk-VNU2AJHD.js:42035). Requiring isSessionActive == false (and isStreaming == false) alongside the client count keeps the quit/dead-pane reclaim the user asked for while refusing to steal the lock from a worker that is still running. Flagging rather than fixing because narrowing the reclaim touches the "an in-place restart must reacquire its own lock" instruction directly.
  • ⚠️ bin/fm-prime-agent-lib.sh:83 - The abandoned query joins on workerPid - which identifies a WORKER - but then answers from .[0].attachedClients, one arbitrary session of that worker. A prime-agent worker hosts MANY sessions: its list handler returns Array.from(this.sessions.values()) (dist/modes/daemon/daemon-mode.js:2810) and the supervisor stamps every one of them with the same workerPid via publicSummary (dist/modes/daemon/daemon-supervisor.js:2635-2643), including kind: &#34;subagent&#34; / RLM child sessions, which no client ever attaches to and which therefore always report attachedClients: 0. Concrete sequence: a prime-agent secondmate with an operator attached (root session, attachedClients 1) spawns a subagent, then does /new or /resume so the replacement root session is inserted into the worker's session map after the subagent entry; the first entry matching the lock pid is now the subagent with 0 clients, fm_harness_pid_alive returns false for a fully live client-attached session, and bin/fm-lock.sh:89 lets another process overwrite that home's lock. Ordering is the only thing standing between this and a wrong answer, and nothing in the protocol guarantees it. Fix is mechanical: aggregate over every matching session instead of the first, e.g. map(select(...)) | map(.attachedClients // 0) | max // empty, so "abandoned" means no client is attached to ANY session on that worker.
  • ⚠️ bin/backends/herdr.sh:1921 - The new liveness demotion treats "prime-agent is not in the pane's FOREGROUND process group" as proof of a husk, but prime-agent deliberately removes itself from the foreground on Ctrl-Z: its interactive mode self-suspends the whole group with process.kill(0, &#34;SIGTSTP&#34;) (dist/modes/interactive/interactive-mode.js:5739), which hands the terminal back to the login shell while the agent and its daemon worker stay alive. Concrete sequence: an operator presses Ctrl-Z in a prime-agent secondmate pane; pane process-info reads cleanly and returns 1 (prime-agent absent from the foreground group), so fm_backend_herdr_pane_agent_state prints no-agent, fm_backend_herdr_agent_state maps that to dead, and secondmate_liveness_one (bin/fm-bootstrap.sh:701-707) takes the dead branch: fm_backend_kill destroys the pane and fm-spawn.sh --secondmate relaunches, whose new retirement (bin/fm-spawn.sh:1368) then runs prime-agent stop on the still-live session, discarding its in-flight turn. fm_backend_herdr_tab_is_husk likewise licenses close-and-replace on the same verdict. The husk-close path in bin/fm-herdr-session-cleanup.sh is protected by its extra idle-childless-shell proof, but the recovery path is not. A tighter discriminator (for example also requiring the pane's foreground process to be the pane's own shell with no stopped prime-agent child, or keeping live unless the reporter has also gone) would still make the /quit pane classify dead without demoting a suspended live agent.
  • ⚠️ tests/fm-claude-stop-autoarm.test.sh:33 - bin/fm-session-lock-lib.sh:14-17 now unconditionally sources fm-prime-agent-lib.sh from its own directory, but the three fixtures that copy the lib's dependency set into a lab checkout were not updated: tests/fm-claude-stop-autoarm.test.sh:33, tests/fm-turnend-guard.test.sh:1123, and tests/fm-session-lock-ancestry.test.sh:232 all copy fm-primary-scope-lib.sh, fm-supervision-lib.sh, fm-wake-lib.sh, fm-session-lock-lib.sh and fm-lock.sh, and none copies fm-prime-agent-lib.sh. Reproduced against the current tree: cp bin/fm-session-lock-lib.sh bin/fm-lock.sh &lt;lab&gt;/bin/ &amp;&amp; &lt;lab&gt;/bin/fm-lock.sh status prints &lt;lab&gt;/bin/fm-prime-agent-lib.sh: No such file or directory before its result. The scripts only set -u, so they continue, but every hook invocation in those labs now emits that line into the captured state/hook.out those tests assert against, and fm_prime_agent_worker_abandoned is undefined there. Fix is one cp line per fixture (or guard the source with a readability test).
  • ℹ️ bin/fm-prime-agent-lib.sh:79 - fm_harness_pid_alive was pure ps/kill -0; it now shells out to prime-agent list --json with no bound whenever the recorded holder is a prime-agent process. That call makes the supervisor refresh every worker's summaries and run syncAgentPeers before answering (dist/modes/daemon/daemon-supervisor.js:1556-1563), so a wedged or slow daemon socket now stalls bin/fm-lock.sh - and therefore session start, bin/fm-sessionstart-run.sh and bin/fm-startup-network.sh, which all source this lib - instead of failing fast. The repo already owns a bound runner (fm_run_timed, bin/fm-timeout-lib.sh); wrapping the listing in a few-second bound would keep the existing fail-safe (any unknown answers "not abandoned"). Noted rather than blocking because the herdr backend's CLI calls are unbounded today too.

🔧 Fix: bound prime-agent RPCs and fix worker-abandoned verdict
3 issues (2 warnings, 1 info) still open:

  • ⚠️ bin/backends/herdr.sh:1918 - The new liveness demotion destroys a LIVE prime-agent secondmate that the operator merely suspended. fm_backend_herdr_pane_agent_state now demotes a prime-agent-identified pane to no-agent whenever fm_backend_herdr_pane_prime_agent_foreground returns 1, i.e. prime-agent is absent from the pane's foreground process group. prime-agent deliberately removes itself from the foreground on Ctrl+Z: app.suspend is bound (dist/modes/interactive/interactive-mode.js:3246 -> handleCtrlZ, hinted as "to suspend" at :956) and the handler stops the whole group with process.kill(0, &#34;SIGTSTP&#34;) (:5739), handing the terminal back to the login shell while the agent and its detached daemon worker stay alive. The reporter keeps answering agent: prime-agent, agent_status: idle because it lives in the daemon worker, not the suspended client process group. Concrete sequence: operator presses Ctrl+Z in a prime-agent secondmate pane; the next bootstrap sweep reads no-agent, fm_backend_herdr_agent_state maps it to dead, and secondmate_liveness_one (bin/fm-bootstrap.sh:701-707) takes the dead branch - fm_backend_kill destroys the pane, then fm-spawn.sh --secondmate runs the new retirement at bin/fm-spawn.sh:1368, which issues prime-agent stop against the still-live session and discards its in-flight turn. The added test (tests/fm-backend-herdr.test.sh:73-111) covers quit / live / unreadable / other-harness but not suspend, and a suspended pane is indistinguishable from a quit pane under this discriminator. Note also that fm_backend_herdr_tab_is_husk licenses close-and-replace on the same verdict; only the husk-close path in bin/fm-herdr-session-cleanup.sh carries the extra idle-childless-shell proof, the recovery path does not. The user's instruction offered two boundaries - "a quit pane must NOT classify alive, OR the relaunch path must not depend on that classification" - and only the first was implemented. The second is the one that discriminates: before killing and relaunching a prime-agent secondmate, ask prime-agent whether any client is still attached to the worker bound to that home (the same attachedClients answer bin/fm-prime-agent-lib.sh already reads). A quit or pane-killed client is gone from the daemon's client set; a Ctrl+Z-suspended one still holds its socket.
  • ⚠️ bin/fm-prime-agent-lib.sh:114 - fm_prime_agent_worker_abandoned decides liveness from attachedClients ALONE, so a detached worker that is still running a turn is reported not-alive and its home's lock can be handed to a second session. The change's own verified fact is that the worker outlives the pane mid-flight. Concrete sequence: a prime-agent home's pane dies mid-turn (terminal closed, herdr restart, pane kill); the daemon worker keeps streaming and writing into that home's worktree, but no client socket remains, so every one of its sessions reports attachedClients: 0 and max is 0. The operator follows this change's own in-place-restart instruction (bin/fm-session-start.sh:648, docs/supervision-protocols/prime-agent.md step 2) and launches prime-agent in that home; bin/fm-lock.sh:64 and :89 now see the recorded holder as not alive, overwrite state/.lock, and the new session starts writing the same worktree while the old worker is still working - the double-writer the fleet lock exists to prevent. Nothing else closes this: fm_prime_agent_stop_sessions_under runs only from bin/fm-spawn.sh --secondmate and bin/fm-teardown.sh, neither of which a manual in-place restart goes through. The vendor publishes the missing discriminator in the same listing this query already reads: publicSummary spreads the full summary (dist/modes/daemon/daemon-supervisor.js:2635-2643), so isSessionActive and the derived status (&#34;running&#34; when isSessionActive || hasRunningRlmChildren, dist/modes/daemon/daemon-session-list.js:23-31) are both present per session, and the vendor's own idle-eviction predicate is !isSessionActive &amp;&amp; attachedClients === 0 (dist/bundle/chunk-VNU2AJHD.js:42035). Requiring no running session alongside the zero client count keeps the quit/dead-pane reclaim the user asked for while refusing to steal the lock from a worker that is still mid-turn. Flagged rather than auto-fixed because narrowing the reclaim touches the "an in-place restart must reacquire its own lock" instruction directly.
  • ℹ️ bin/fm-session-lock-lib.sh:20 - Sourcing bin/fm-session-lock-lib.sh now turns on set -u in the caller's shell, contradicting the contract stated three lines above the new source block ("This file is sourced by scripts and has no side effects on source"). Chain: fm-session-lock-lib.sh:20 -> fm-prime-agent-lib.sh:37 -> fm-timeout-lib.sh, which runs set -u at file scope. Reproduced against the current tree: bash -c &#39;. bin/fm-session-lock-lib.sh; echo $-&#39; prints flags containing u where the parent shell had none. No caller breaks today - every executable that reaches this chain (including bin/backends/tmux.sh:24, which is sourced lazily into fm-send/fm-peek/fm-bootstrap and is itself written without nounset) already sets -u at its top - so this is a latent trap rather than a live defect. Worth a note because the four backend libs are deliberately nounset-free source-only files and this quietly changes the mode they load under.

🔧 Fix: keep suspended and mid-turn prime-agent sessions alive
2 issues (1 warning, 1 info) still open:

  • ⚠️ bin/fm-prime-agent-lib.sh:141 - The new busy predicate treats a MISSING field as busy, but prime-agent's own listing returns sessions of two different shapes under the same workerPid, and the inactive shape omits two of the six flags - so abandoned is unreachable for any worker whose root session has a persisted, non-resident subagent, and the in-place-restart reclaim the previous round was asked to deliver is silently gone again. Source chain, all in the installed prime-agent 0.7.1: the worker's list handler answers with buildSessionListWithPassiveRlmSubagents(activeSessions, [], scheduledJobs) (dist/modes/daemon/daemon-mode.js:2809), which walks EVERY resident session's RLM-subagent registry regardless of the empty saved list (dist/modes/daemon/daemon-mode.js:736-745, :792-820) and renders each non-resident child through summaryForInactiveSession (dist/modes/daemon/daemon-session-list.js:172-190). That shape publishes isSessionActive, isStreaming, isCompacting and unfinishedActionCount but NOT isBashRunning, hasRunningRlmChildren or isRunningTools. The supervisor then stamps workerPid on every summary it hands back, inactive ones included (publicSummary spread, dist/modes/daemon/daemon-supervisor.js:2635-2643, applied to all of worker.summaries at :1563). So busy_flag(.isBashRunning) sees null, returns true, any is true, and the verdict is live. Reproduced against the shipped jq expression with a two-row listing (one complete active row, attachedClients 0 and every flag false; one passive subagent row missing the two active-only flags): output live, where the same listing without the passive row gives abandoned. Concrete sequence: a prime-agent secondmate spawns a subagent, the child finishes and stops being resident, the operator quits, and the operator then follows this change's own instruction (bin/fm-session-start.sh:648, docs/supervision-protocols/prime-agent.md step 2) to restart prime-agent in place; bin/fm-lock.sh:64 reads the old worker pid, fm_harness_pid_alive gets live forever, and the session lands read-only with no documented recovery. The added test cannot catch this: tests/fm-session-lock-ancestry.test.sh:414 (unknown-activity) is a listing with NO activity fields at all, which is the intended unknown case, and no case mixes a complete active row with an incomplete sibling row. Fix keeps both requirements: judge only summaries that actually describe a resident session (map(select(.activeSessionId != null)), the field summaryForActiveSession always sets and summaryForInactiveSession never does), or require booleans only for the four flags both shapes publish and default the active-only three to false. isSessionActive already subsumes isBashRunning (dist/core/agent-session.js:4738-4746), so no mid-turn protection is lost.
  • ℹ️ docs/configuration.md:213 - Two owner docs still describe the pre-round-4 rules. docs/configuration.md:213 says an in-place restart "needs no retirement at all: session-lock liveness asks prime-agent whether any client is still attached to the recorded worker" - the verdict now also requires every session of that worker to prove it is idle, so a worker detached mid-turn deliberately keeps the lock and the restart DOES land read-only until it finishes. The same paragraph's "a quit pane classifies dead rather than alive" no longer states the discriminator that was added: absence from the pane's foreground is not enough, the pane's whole process subtree must hold no prime-agent process, precisely so a Ctrl+Z-suspended agent stays alive. .agents/skills/harness-adapters/SKILL.md:342, the declared owner of adapter facts, still says the fm-spawn retirement "is what makes the prime-agent entry in bin/fm-session-lock-lib.sh safe", which is now the abandoned-worker query in bin/fm-prime-agent-lib.sh, and its composer section never mentions the suspend case a future reader would otherwise re-break. Docs-only; no behavior depends on it.

🔧 Fix: judge prime-agent liveness on resident sessions only
2 infos still open:

  • ℹ️ bin/backends/herdr.sh:2858 - fm_backend_herdr_pane_prime_agent_foreground returns 1 (a positive "prime-agent is NOT in the foreground" claim) when jq errors on an unexpected foreground_processes element shape, contradicting the three-outcome contract documented directly above it, which reserves 2 for "the read itself is unusable (RPC failure, unexpected shape, missing field). Never treat this as evidence either way." The first jq -e validates only that foreground_processes is an array, not its element shapes; the second one dies on a malformed element. Reproduced against the shipped expression: echo &#39;{&#34;result&#34;:{&#34;process_info&#34;:{&#34;pane_id&#34;:&#34;p&#34;,&#34;foreground_processes&#34;:[{&#34;name&#34;:&#34;bash&#34;,&#34;argv&#34;:&#34;bash&#34;}]}}}&#39; | jq -e &#39;...&#39; prints Cannot index string with number and exits 5, so || return 1 turns a failed read into evidence. fm_backend_herdr_pane_agent_state (line 1929) then falls through to the subtree probe instead of keeping the registered live verdict. Impact is bounded - the ps-based subtree probe must independently prove absence before the pane is demoted to no-agent, and the composer arm accepts only 0 - so this is a contract gap rather than a live wedge. Fix: fold the per-element field validation into the first jq -e, or wrap the predicate in try ... catch, so an unparseable element answers 2.
  • ℹ️ bin/backends/herdr.sh:2884 - fm_backend_herdr_pane_prime_agent_in_subtree issues a second pane process-info RPC for a payload its only liveness caller fetched one line earlier, and it consumes just one field (shell_pid) that the first response already carries. In fm_backend_herdr_pane_agent_state (lines 1927-1930) ..._foreground runs pane process-info, and when it returns 1 the next call runs the identical RPC again, so every prime-agent pane whose agent is not in the foreground costs two round-trips per classification - paid per pane per pass by the bootstrap secondmate-liveness sweep. The new fixture has to stage the same canned response twice (resp/3.out and resp/4.out) to model it. Taking shell_pid as an argument, or having the foreground probe publish it, would halve the RPCs and close the split-read window between the two calls. Not a defect: a disagreement across the gap only ever makes the verdict more conservative.
✅ **Test** - passed

✅ No issues found.

  • bin/fm-test-run.sh tests/fm-prime-agent-harness.test.sh tests/fm-prime-watch-extension.test.sh tests/fm-session-lock-ancestry.test.sh tests/fm-pi-primary-types.test.sh — 3 pass, pi-primary-types gate-skips (skip: tsc not found)
  • bin/fm-test-run.sh tests/fm-backend-herdr.test.sh — pass, incl. the new prime composer-shape cases
  • Live: launched a real prime-agent --cwd &lt;tmp&gt; session, killed its client, confirmed the daemon worker survived, then . bin/fm-prime-agent-lib.sh; fm_prime_agent_stop_sessions_under &lt;tmp&gt; — only that session stopped, two pre-existing sessions untouched
  • Live: ran base bin/fm-harness.sh (833a9a2) and target bin/fm-harness.sh under the real environ of live prime-agent worker pid 2210194 (CLAUDECODE=1 + PI_CODING_AGENT=true + PRIME_AGENT_INTERNAL_DAEMON_WORKER=1) — base prints claude, target prints prime-agent
  • Live: fm_prime_agent_worker_abandoned against the real prime-agent list --json — attached worker keeps the lock, zero-client idle worker reclaimable, unknown pid keeps the lock
  • Live: isolated bin/fm-herdr-lab.sh provision/run/teardown session, real prime-agent --offline TUI in pane w1:p1fm_backend_herdr_composer_state returns empty (base returns unknown), fm_backend_herdr_pane_agent_state returns live
  • Live: sent /quit then PS1=&#39;&gt; &#39; into the same pane — herdr agent get still reports agent: prime-agent, yet fm_backend_herdr_composer_state = unknown and fm_backend_herdr_pane_agent_state = no-agent
  • Cleanup: lab session torn down, temp session stopped, temp dirs removed, git status --porcelain clean
🔧 **Document** - 3 issues found → auto-fixed ✅
  • ℹ️ docs/subagent-guard.md:187 - docs/subagent-guard.md now carries a prime-agent row marked "not reviewed" because its delegation-tool surface cannot be enumerated without a live prime-agent session (the repo's own rule forbids wiring a harness hook that was not validated against the real binary). Its detached-worker lib comments reference RLM child sessions, so a delegation surface plausibly exists. Enumerate its tools on a host with the binary and wire or rule out the guard.
  • ℹ️ docs/verification/supervision.md:152 - All prime-agent verification evidence (2026-08-08, 0.7.1) lives in the agent-runtime harness-adapters skill; docs/verification/supervision.md gained no prime-agent rows in its busy-state, session-start, or turn-end evidence tables. Per docs/documentation-audiences.md, dated maintainer-verification evidence is owned by that file. I did not transcribe evidence I did not run. A follow-up should move or restate the dated live records there.
  • ℹ️ docs/scripts.md:8 - docs/scripts.md does not list the new bin/fm-prime-agent-lib.sh, but it already omits 27 other tracked bin scripts (fm-lint.sh, fm-transition-lib.sh, fm-procevent*.sh, the fm-remote-* family, and more), so the inventory is not exhaustive and this change did not make it stale. Restoring or explicitly bounding that inventory is an out-of-scope consolidation worth its own change.

🔧 Fix: rule out delegation guard for prime-agent harness
✅ Re-checked - no issues remain.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

Prime Agent 0.7.1 already runs firstmate workers today through fm-spawn's
raw-launch escape hatch, and Herdr already reads their busy/idle state from
prime-agent's own built-in reporter. This change ships only the three things
that path genuinely cannot do, plus the minimum harness registration they need.

1. Teardown retires prime-agent's detached daemon

prime-agent runs every root session in a detached daemon worker under one
per-user supervisor. Closing the pane detaches the client, and so does an
explicit `/quit`: both leave the worker `live`, holding its launch directory as
cwd and a lease on its transcript. Observed twice - a torn-down task's session
was still `lifecycle=live` on that worktree hours later, and a quit session
stayed `live` too. Killing the endpoint does not end the agent, and the generic
leaked-process reaper only SIGTERMs the survivors.

`bin/fm-prime-agent-lib.sh` is the one owner of retiring those sessions,
selected by a recorded session cwd that is the target directory or a path
inside it. Two callers need exactly this, which is why it is a lib rather than
a copy in each: teardown before its reaper, and secondmate relaunch (below).
Never `prime-agent shutdown` - one supervisor serves the whole user, so it
would stop the captain's own sessions and every other home's workers.
`prime-agent status` is deliberately not consulted: it marks even a live
session's forkserver `stale`, so that word is not a health signal.

2. Composer recognition, so fm-send verifies instead of false-failing

prime-agent's prompt glyph is a plain `>`, which the fleet-wide composer rule
correctly treats as a dead shell on an unstructured row. That rule is NOT
relaxed. The Herdr adapter instead proves the container with two independent
signals: the pane's live foreground process IS prime-agent (kernel-level) AND
Herdr's native reporter says so. Both are required - `/quit` leaves the
reporter identity behind on a pane that has already returned to a login shell,
and on the reporter alone a shell with `PS1='> '` was verified live to become
an injection target.

Without this, every mid-turn steer to a BUSY prime-agent pane returned
`verdict=unknown` and reported a false failure, because Herdr confirms an
idle-baseline submit from native agent state but falls back to the composer
read whenever the pane is already working. Verified live in both directions:
empty / pending / (shell) unknown, and a busy-pane steer now returns `empty`
and lands as a queued Steering message.

3. Secondmate capability

`.prime/agent/extensions/` holds the tracked primary pair - turn-end guard and
watcher - which prime-agent auto-discovers with no trust gate, unlike Pi. They
are separate files rather than shared with the Pi pair because the two
harnesses differ on the exact event the guard is built on: prime-agent has no
`agent_settled` at all, and registering one is silently accepted and never
fires. The guard reconstructs the settle from `agent_end` following
prime-agent's own built-in Herdr reporter - hold through an auto-retry grace
window on an error stop, skip an end with queued messages behind it, otherwise
settle now. `ctx.isIdle()` is deliberately not the gate: it was measured false
throughout a live run. The watcher extension is the Pi one minus its Calm
rendering layer, which prime-agent's API cannot support (no
`registerEntryRenderer`) and never sees anyway (it discovers only `.prime/`).

The session lock needed one extra rule to make this sound. It records the
harness ancestor pid, which for prime-agent IS that detached worker, so a home
whose pane died would keep a live lock holder and every relaunch would land
read-only. `fm-spawn --secondmate` therefore retires the worker bound to that
home before launching its replacement, which is what makes the `prime-agent`
entry in `bin/fm-session-lock-lib.sh` safe.

Minimum registration those three require: detection, the launch template,
`--model`/`--thinking`, the secondmate positional allowlist, the secondmate
liveness allowlist, the crew-dispatch verified list, and the supervision
protocol plus its session-start extension-loaded check.

Detection is the one non-obvious piece. prime-agent exports the same
`PI_CODING_AGENT=true` as pi, so the family marker cannot split it, and
`FM_PI_HARNESS` - which works as the launch boundary for pi-signed - never
reaches a prime-agent tool subprocess at all: a resident worker inherits the
long-lived supervisor's environment rather than the launching client's.
The same inheritance means a supervisor first started from a Claude session
hands `CLAUDECODE=1` to every later prime-agent worker, which no launch-side
`env -u` can reach - reproduced live as a crewmate reporting itself as
`claude`. Detection therefore keys on prime-agent's own per-tool-call markers
(`PRIME_AGENT_CODING_AGENT_DIR`, `PRIME_AGENT_INTERNAL_DAEMON_WORKER`)
alongside the family marker, tested before the `CLAUDECODE` fast path. Both
directions are pinned by tests: the vendor marker outranks an inherited
`CLAUDECODE`, and a lone stale `PRIME_AGENT_*` with no family marker still
resolves to `claude`.

Registered name `prime-agent` matches the binary; a hyphen is precedented by
`pi-signed`, and no existing glob in any allowlist, regex, or `case` arm can
match it.

Dropped per the narrowed scope

No busy-source extension: prime-agent's own built-in reporter already publishes
pane state, so the per-task extension carries only the `turn_end` wake touch
and nothing is armed - a seeded busy record would have no firstmate writer to
clear it. The cost is explicit: an IDLE prime-agent worker classifies
`unknown missing` rather than `idle`, while a busy one classifies
`busy herdr-native`. That is the escape hatch's existing behaviour, and unknown
surfaces rather than hides.

Also dropped: tmux agent-process classification (this home runs Herdr, and that
check is harness-dependent on rendered output, so it needs proving against a
real prime-agent pane under real tmux before it ships), the full fact table's
model-discovery row, and the remote-secondmate allowlists - remote secondmates
stay refused on prime-agent.

Everything relied on about prime-agent's Herdr integration comes from reading
`dist/core/extensions/builtin/herdr-agent-state.js`: it ships zero mentions of
that integration across all 36 of its doc files. That dependency is recorded in
the adapter's own section so a future upgrade re-reads the source rather than
trusting a release note.

Live verification (Herdr backend, free model)

Crewmate, spawned through the normal path with no escape hatch: brief
delivered, worktree isolated, `state/<id>.turn-ended` touched, busy readable as
`busy herdr-native`, mid-turn steers delivered and verified on both idle and
busy panes, `bin/fm-harness.sh` reporting `prime-agent` from inside the
crewmate, and a clean teardown leaving no surviving process. The daemon-stop
lib was separately exercised against a real live session and printed its own
retirement line.

Secondmate: launched into a real home, auto-discovered BOTH tracked extensions
(`.prime-turnend-extension-loaded` and `.prime-watch-extension-loaded`), ran its
own session start, acquired its own session lock, armed its watcher through
`fm_watch_arm_prime` with the arm child parented by the prime-agent worker, and
answered a redundant call with the ownership-based `watcher: unchanged` no-op.

The lock wedge and its fix were both reproduced concretely rather than argued:
with the pane closed, the recorded lock pid was still a live prime-agent worker
and `fm_harness_pid_alive` returned true (the read-only wedge); the relaunch
then printed `prime-agent: stopping detached session ... bound to <home>`, the
old worker died, and the new session took the lock instead of being refused.
The new fm_prime_agent_stop_sessions_under call is a cleanup courtesy, so
guard it with || true exactly like the leaked-process reaper two lines
below it: a best-effort step must never fail the whole teardown.

The measured cause of the tests/fm-gotmp.test.sh failure was one level
earlier, though. fm-teardown.sh sources bin/fm-prime-agent-lib.sh
unconditionally, and that suite's two fake-root builders symlink each
sibling teardown requires - the new lib was missing from both, so the
source line itself aborted under set -e with a valid tasktmp. Both
builders now link it, matching how the other thirteen siblings are
handled, and all three cases pass.
@eduardstan

Copy link
Copy Markdown
Author

Closing this in favour of a sequence of smaller pull requests.

This PR carries the whole prime-agent adapter: 41 files, 2811 lines. That is well past the 20-file / 1000-line limit the auto-merge evaluator applies, so it could never clear that gate regardless of its contents. Rather than leave a large PR open alongside the split, I am closing it so there is exactly one live version of this work.

The same content, split along dependency seams and each measured against the cap:

Each was verified to stand alone against current main before being opened. Two pieces were measured as genuinely dependent on #2062 and deliberately held back rather than shipped early: the tmux liveness classification arm and the supervision extensions both resolve prime-agent through fm_harness_path_name, which has no prime-agent arm on main, so their tests would either fail or pass vacuously. Those follow once #2062 lands.

Review order is #2062 first; the other two are independent of each other.

One note that may be worth a maintainer's attention independently: since 2026-08-08 both workflow runs on fork pull requests here complete with action_required, so they report "0 passed, 0 failed — this PR has no CI checks configured". #1966 itself ran its checks green on 2026-08-06 under the previous behaviour. The three PRs above are all held that way now and cannot show a green result until someone approves the runs.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant