Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions .agents/skills/harness-adapters/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,8 @@ Use that value for interrupt, exit, resume, and skill-invocation facts.

The primary integrations for `claude`, `codex`, `opencode`, `pi`, and `grok` have empirically validated hook paths for the "no turn ends blind" guard.
`claude` and `codex` block directly through Stop hooks that preserve exit status 2 and stderr from `bin/fm-turnend-guard.sh`.
`opencode`, `pi`, and `grok` expose passive lifecycle callbacks for this purpose, so their tracked primary adapters force one bounded follow-up or resume when the shared predicate blocks.
`pi` also blocks directly, because a follow-up queued from its `agent_end` callback continues the same run and suppresses the idle signal.
`opencode` and `grok` expose passive lifecycle callbacks for this purpose, so their tracked primary adapters force one bounded follow-up or resume when the shared predicate blocks.
Kimi is outside the primary turn-end guard scope, while `docs/turnend-guard.md` owns its separate guarded global hook for crew wake signals.
The exact hook files, commands, scoping rules, and fail-open tradeoffs are owned by `docs/turnend-guard.md`.
`docs/verification/supervision.md` "Turn-end guard" owns active validation evidence.
Expand Down Expand Up @@ -280,8 +281,12 @@ The decision persists per path in `~/.pi/agent/trust.json`, so later spawns in t
The extension must listen for pi's `turn_end` event, not `agent_end`, so the watcher wakes after each completed turn instead of only when the whole agent run exits.
Pi sets `PI_CODING_AGENT=true` for its children; this is its harness-detection env marker.

**Primary-session guard fact (verified 2026-07-09, Pi 0.80.5).**
The firstmate PRIMARY's own `.pi/extensions/fm-primary-turnend-guard.ts` listens for logical-run `agent_settled`, not per-tool-loop `turn_end`, and uses `pi.sendUserMessage(..., { deliverAs: "followUp" })` to force one guarded follow-up when `bin/fm-turnend-guard.sh` returns 2.
**Primary-session guard fact (verified 2026-07-26, Pi 0.81.1).**
The firstmate PRIMARY's own `.pi/extensions/fm-primary-turnend-guard.ts` blocks from logical-run `agent_end`, not per-tool-loop `turn_end`, and uses `pi.sendUserMessage(..., { deliverAs: "followUp" })` to force one guarded follow-up when `bin/fm-turnend-guard.sh` returns 2.
Queuing from `agent_end` keeps the run going so the turn never settles blind; `docs/turnend-guard.md` owns the mechanism.
The same file also registers a second `agent_settled` listener that does nothing but release the single-follow-up latch, because `agent_settled` is the only event pi guarantees once per logical run.
`agent_end` fires again only if the run actually continues, so a run that dies after the guard queued its follow-up would otherwise leave the latch set and let the next logical run end unguarded.
Removing that settle listener is a regression, not a cleanup; `tests/fm-turnend-guard.test.sh` fails without it.
Without `deliverAs: "followUp"`, Pi rejects the send while the agent is still processing.
Pi's primary watcher protocol also requires the tracked `.pi/extensions/fm-primary-pi-watch.ts` extension, same trust-once discovery as the turn-end guard.
The model arms through `fm_watch_arm_pi`, never a foreground bash arm; the watcher tool result and clean-exit fallback are owned by `docs/supervision-protocols/pi.md`.
Expand Down
23 changes: 22 additions & 1 deletion .pi/extensions/fm-primary-turnend-guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,14 @@ export default function (pi: ExtensionAPI) {
return { block: true, reason: result.stderr.trim() || "denied by the watcher-arm PreToolUse seatbelt" };
});

pi.on("agent_settled", async () => {
// agent_end, not agent_settled. Pi drains both queues before emitting agent_end,
// so a follow-up queued from an agent_end handler makes _handlePostAgentRun()
// return true and the session calls agent.continue() - the run keeps going and
// never settles (pi 0.81.1 dist/core/agent-session.js:745-757 and :780-782).
// agent_settled fires after that loop exits, so a guard hooked there emits the
// idle signal first and only then re-opens the turn: reactive, not preventive.
// Verified 2026-07-26 against pi 0.81.1; see docs/turnend-guard.md.
pi.on("agent_end", async () => {
if (guardFollowupActive) {
guardFollowupActive = false;
return;
Expand All @@ -158,5 +165,19 @@ export default function (pi: ExtensionAPI) {
}
});

// Latch release of last resort. agent_end is emitted from inside the agent
// loop and only fires again if the run continues, so a run that dies after the
// guard queued its follow-up - ESC while the follow-up is still queued, a fatal
// session error, a discarded queue - leaves the latch set, and the NEXT logical
// run's agent_end consumes it and skips the guard, ending one turn blind.
// agent_settled is emitted from _runAgentPrompt's finally (pi 0.81.1
// dist/core/agent-session.js:752), so it always runs. This cannot reintroduce
// re-entrancy: agent_settled fires only after the whole agent loop has exited,
// so no further agent_end for that run can follow it, and the handler never
// invokes the predicate or sends a message - it only assigns false.
pi.on("agent_settled", () => {
guardFollowupActive = false;
});

markLoaded();
}
7 changes: 5 additions & 2 deletions bin/fm-turnend-guard.sh
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@
# This script is push-based: verified harness turn-end hooks invoke it every time
# the primary is about to end a turn.
# Claude and codex can block directly by preserving exit status 2 and stderr.
# OpenCode, pi, and grok adapters use the same predicate and force one bounded
# Pi also blocks directly: a follow-up queued from its agent_end handler keeps the
# same run going, so the idle signal never escapes.
# OpenCode and grok adapters use the same predicate and force one bounded
# follow-up because their turn-end events are passive.
# See docs/turnend-guard.md for the per-harness mechanics, validation evidence,
# and fail-open tradeoffs.
Expand All @@ -30,7 +32,8 @@
# Stop payloads carry stop_hook_active=true when the CURRENT stop attempt was
# itself already forced by an earlier block this turn; on that signal we always
# allow the stop, whether or not watcher supervision actually got resumed.
# Passive harness adapters provide their own one-follow-up guard before calling
# Harness adapters that inject a follow-up instead of returning exit status 2
# (opencode, grok, and pi) provide their own one-follow-up guard before calling
# this script.
# That bounds those harnesses to at most one forced continuation per turn -
# never a wedged, un-endable session - while still nagging again on a later turn
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ Its `--restart` mode signals only the watcher recorded in the current home's `st
A pull-based guard (`bin/fm-guard.sh`) warns through supervision tool output if the primary checkout is tangled, or if tasks are in flight and that watcher stops running or queued wakes are waiting to be drained.
The drain script calls that guard after emptying the queue, which avoids repeating the queued-wakes warning for records it just consumed while still warning on stale watcher liveness.
It leads with a prominent bordered tangle banner, while `bin/fm-guard.sh` owns the stale-watcher banner/reminder policy so repeated guarded commands stay noisy without reprinting the full watcher-down banner in the same episode.
On every verified primary harness, tracked hook integration gives the primary session a push-based backstop: when work is in flight and no identity-matched watcher lock with a fresh beacon is live, direct Stop hooks block and passive turn-end hooks force one bounded follow-up.
On every verified primary harness, tracked hook integration gives the primary session a push-based backstop: when work is in flight and no identity-matched watcher lock with a fresh beacon is live, direct hooks block the turn end and passive turn-end hooks force one bounded follow-up.
The guard covers the main primary and genuinely marked secondmate homes, exempts child crewmate/scout worktrees, is loop-safe per harness, and is documented in [turnend-guard.md](turnend-guard.md).

A presence-gated sub-supervisor (`bin/fm-supervise-daemon.sh`) extends this for walk-away supervision: the `/afk` skill starts it through the tracked foreground helper `bin/fm-afk-start.sh`, after which the watcher reverts to daemon-managed one-shot mode and the daemon self-handles routine wakes in bash.
Expand Down
21 changes: 15 additions & 6 deletions docs/turnend-guard.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,16 @@ If `jq` is missing or hook stdin is empty, the guard exits 0 because it cannot s
- Claude registers two `Stop` hooks in `.claude/settings.json`, both anchored through `CLAUDE_PROJECT_DIR`: `bin/fm-turnend-guard.sh --claude`, and `bin/fm-claude-stop-autoarm.sh` with `asyncRewake: true` and `timeout: 28800`.
- Codex registers a `Stop` hook in `.codex/hooks.json`, anchors the executable to the hook process working directory, verifies a Firstmate-shaped hook-bearing root, and passes the original payload to the shared guard.
- OpenCode listens for `session.idle` in `.opencode/plugins/fm-primary-turnend-guard.js`, lets the watcher coordinator act first, and calls `client.session.promptAsync` once when the guard returns 2.
- Pi listens for `agent_settled` in `.pi/extensions/fm-primary-turnend-guard.ts`, runs once per logical agent run, and calls `pi.sendUserMessage(..., { deliverAs: "followUp" })` once when the guard returns 2.
- Pi listens for `agent_end` in `.pi/extensions/fm-primary-turnend-guard.ts`, injects at most one follow-up per logical agent run through `pi.sendUserMessage(..., { deliverAs: "followUp" })` when the guard returns 2, and runs the shared predicate at every `agent_end` the run reaches.
- Grok registers a `Stop` hook in `.grok/hooks/fm-primary-turnend-guard.json` and uses `bin/fm-turnend-guard-grok.sh` to resume the reported session once when the shared guard returns 2.
The adapter intentionally omits `--permission-mode`, so a passive hook cannot grant stronger permissions than the resumed session default.

Pi blocks genuinely rather than reacting after the fact.
Pi drains both message queues before emitting `agent_end`, so a follow-up queued from an `agent_end` handler makes `_handlePostAgentRun()` return true and the session calls `agent.continue()`.
The same run keeps going and `agent_settled`, the idle signal, never fires until the guard stops queuing.
A guard hooked on `agent_settled` instead runs after that loop has already exited, so the idle signal is emitted first and the follow-up only re-opens a turn that already ended blind.
Mechanism verified against pi 0.81.1 in [`verification/supervision.md`](verification/supervision.md#turn-end-guard).

Claude and Codex can block a Stop directly with exit status 2 and stderr.
Both payloads carry `stop_hook_active`.
In the default Codex mode, a true value lets the second stop finish after one forced continuation.
Expand All @@ -55,22 +61,25 @@ The Claude mode waits up to `FM_CLAUDE_AUTOARM_SYNC_WAIT_MS` (default 800 millis
When none of those proofs appears, it re-blocks up to `FM_CLAUDE_TURNEND_BLOCK_BUDGET` times (default 3, below Claude's 8-block override), then allows degraded with a visible `systemMessage`.
Any allow resets the budget.

OpenCode, Pi, and Grok expose passive callbacks for this purpose.
OpenCode and Grok expose passive callbacks for this purpose, and Pi's blocking `agent_end` callback shares the same injection path.
Their adapters fail open at the hook boundary to protect the user session but schedule one bounded follow-up when the predicate blocks.
The generated prompts use the canonical `turn-end-guard` kind after the U+2063 `FIRSTMATE_OP: ` prefix, so Ahoy does not treat them as captain messages.
Each adapter owns a loop latch.
Pi keeps the latch across internal tool turns and clears it only when the generated follow-up settles or delivery fails.
Pi keeps the latch across internal tool turns and clears it when the generated follow-up settles or delivery fails.
A second `agent_settled` handler clears the latch and does nothing else, because `agent_end` fires again only if the run continues: a run that dies after the guard queued its follow-up would otherwise leave the latch set and let the next logical run end unguarded.
Because `agent_end` also fires at auto-retry and compaction boundaries, a Pi run that needs one of those while supervision is already unhealthy spends its single latched follow-up at that earlier boundary rather than at the final one.
The recovery instruction still lands in the same run, so this is an ordering nuance rather than a blind turn end.
Grok's project hook requires the checkout to be trusted with `/hooks-trust` or launch-time `--trust`.
OpenCode's forced follow-up is supported for persistent TUI sessions and remains fail-open in headless `opencode run`.

If a passive adapter cannot invoke its SDK, find `grok`, or recover a Grok session id, the next pull-based `fm-guard.sh` call reports the problem.
If a follow-up-injecting adapter cannot invoke its SDK, find `grok`, or recover a Grok session id, the next pull-based `fm-guard.sh` call reports the problem.
That warning uses `bin/fm-supervision-instructions.sh --repair-line`, so it always points to the active harness protocol rather than embedding another repair command.

## Compatibility limits

- Child crewmate and scout worktrees are outside scope.
- A valid secondmate home is in scope; an idle secondmate endpoint with no X-mode relay poll remains healthy because it has no supervision need.
- Claude and Codex block directly, while OpenCode, Pi, and Grok use bounded passive follow-ups.
- Claude, Codex, and Pi block the turn end directly, while OpenCode and Grok use bounded passive follow-ups.
- OpenCode headless mode and untrusted Grok project hooks remain fail-open at the host boundary.
- Kimi Code CLI 0.29.1 exposes only global `[[hooks]]` configuration in `~/.kimi-code/config.toml`, including a `Stop` event with snake_case payload fields `hook_event_name`, `session_id`, `cwd`, and `stop_hook_active`.
- Kimi has no project-level hook configuration and remains outside the primary guard integrations above.
Expand All @@ -83,7 +92,7 @@ That warning uses `bin/fm-supervision-instructions.sh --repair-line`, so it alwa

## Regression coverage

`tests/fm-turnend-guard.test.sh` covers the predicate, main and secondmate primary scope, child-worktree exclusion, `FM_HOME` and `FM_STATE_OVERRIDE` precedence, the cooperative `--claude` claim wait, epoch allow, re-block budget, Pi logical-run latching, missing-`jq` behavior, all five primary registrations, and Grok resume permission and recursion safety.
`tests/fm-turnend-guard.test.sh` covers the predicate, main and secondmate primary scope, child-worktree exclusion, `FM_HOME` and `FM_STATE_OVERRIDE` precedence, the cooperative `--claude` claim wait, epoch allow, re-block budget, Pi logical-run latching, Pi blocking before the idle signal, Pi latch release after an interrupted run, missing-`jq` behavior, all five primary registrations, and Grok resume permission and recursion safety.
`tests/fm-kimi-harness.test.sh` covers the separate Kimi crew hook's format preservation, idempotence, refusal cases, token guard, spawn registration, and teardown cleanup.
`tests/fm-supervision-instructions.test.sh` covers recovery-line ownership.
`FM_PI_LIVE_E2E=1 tests/fm-pi-primary-live-e2e.test.sh` is the opt-in isolated Pi path.
Expand Down
40 changes: 38 additions & 2 deletions docs/verification/supervision.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,14 +61,14 @@ The detailed reconciliation and task chronology stay in the private audit report

## Turn-end guard

The direct and passive mechanisms were validated across all five harnesses on 2026-07-08 through 2026-07-12, with Claude's replacement Stop-owned path revalidated on 2026-07-24.
The direct and passive mechanisms were validated across all five harnesses on 2026-07-08 through 2026-07-12, with Claude's replacement Stop-owned path revalidated on 2026-07-24 and Pi's blocking `agent_end` path revalidated on 2026-07-26 against pi 0.81.1.

| Harness | Version verified | Mechanism | Observed result |
| --- | --- | --- | --- |
| Claude | 2.1.219 | Cooperative blocking `Stop` guard plus `asyncRewake` auto-arm | A fresh unsupervised session ran session start first, reclaimed a stale dead-owner lock, completed two tokenless rewake cycles with no model arm command or guard continuation, and left a competing live owner unchanged. |
| Codex | 0.142.1 | Blocking `Stop` hook | Hook process root stayed anchored to the trusted checkout and one continuation ran. |
| OpenCode | 1.17.6 | Passive `session.idle` callback | Throwing could not block, while `promptAsync` scheduled one TUI follow-up; headless remained fail-open. |
| Pi | 0.80.5 | Passive `agent_settled` callback | Exactly one guard follow-up ran for an unhealthy cycle, with no recursion across tool turns. |
| Pi | 0.81.1 | Blocking `agent_end` callback | Exactly one guard follow-up ran for an unhealthy cycle, with no recursion across tool turns, and the run continued without emitting the idle signal. |
| Grok | 0.2.93 | Passive `Stop` plus bounded resume | Project hook ran under trust, resumed once without inherited bypass permissions, and the environment latch prevented recursion. |

The secondmate-home scope and manual-repair wake path were measured with Claude Code 2.1.207 on 2026-07-12, when a native background completion re-invoked the idle model with no human input.
Expand Down Expand Up @@ -96,6 +96,42 @@ tests/fm-supervision-instructions.test.sh
FM_PI_LIVE_E2E=1 tests/fm-pi-primary-live-e2e.test.sh
```

### Pi blocks the turn end rather than reacting to it

Measured with pi 0.81.1 on 2026-07-26 against a local mock provider, so no model request left the machine.
A probe extension appended `SETTLED` on `agent_settled` and the stubbed predicate appended `GUARD` and exited 2, giving the ordering directly.

```sh
pi --version
pi -p "hello" --provider mockp --model mock-model \
-e ./.pi/extensions/fm-primary-turnend-guard.ts -e ./.pi/extensions/probe.ts --no-session -nbt
```

Observed probe log with the shipped `agent_end` guard, and with the same file edited back to `agent_settled`:

```text
0.81.1

agent_end GUARD SETTLED SETTLED count: 1
agent_settled GUARD SETTLED SETTLED SETTLED count: 2
```

One idle signal means the guard blocked and the follow-up was consumed inside the same run.
Two means the run settled blind before the follow-up re-opened it, which is the defect this mechanism replaces.

Blocking is repeatable rather than one-shot: a probe that re-queued from `agent_end` continued for 12 consecutive blocks with no ceiling, and `agent_settled` fired exactly once after it stopped.
Firstmate still latches to a single follow-up per logical run, so the ceiling is Firstmate's policy and not a Pi limit.

The four Node-driven Pi extension cases in `tests/fm-turnend-guard.test.sh` import the `.ts` extension directly, so they need a Node build with TypeScript type stripping.
On the Node v22.22.1 Debian system build used here that support is absent: the import fails with `ERR_UNKNOWN_FILE_EXTENSION`, and adding `--experimental-strip-types` fails with `ERR_NO_TYPESCRIPT` because the binary was compiled without TypeScript support.
Those cases therefore report `not ok` rather than skipping, and because `fail` in `tests/lib.sh` exits, the first one aborts the script before the remaining cases run.
`tests/fm-pi-watch-extension.test.sh` and `tests/fm-calm-pi-extension.test.sh` carry the same requirement and also fail on this Node build.
The same is true on unmodified `origin/main`, so this limitation is pre-existing rather than introduced by the `agent_end` guard.

Whether these cases pass in CI is unverified from this worktree.
`.github/workflows/ci.yml` pins only `runs-on: ubuntu-latest`, with no `setup-node` step and no pinned Node version, so the runner's Node is whatever GitHub currently ships and can change without a repository change.
No CI run was observed.

## Watcher continuity

The cross-harness evidence combines the 2026-07-17 live pass with Claude's replacement Stop-owned path revalidated on 2026-07-24, all against isolated project and home state.
Expand Down
Loading
Loading