Skip to content
Merged
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
8 changes: 4 additions & 4 deletions .agents/skills/harness-adapters/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ Use that value for interrupt, exit, resume, and skill-invocation facts.

Every verified primary harness has an empirically validated hook path 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 turn-end events for this purpose, so their tracked primary adapters force one bounded follow-up or resume when the shared predicate blocks.
`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.
The exact hook files, commands, validation transcripts, scoping rules, and fail-open tradeoffs are owned by `docs/turnend-guard.md`.
When changing any primary turn-end hook, validate the real harness behavior in a scratch project or throwaway home before trusting it, then update that doc and the relevant concise fact below.

Expand Down Expand Up @@ -200,11 +200,11 @@ 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-08, Pi 0.80.2).**
The firstmate PRIMARY's own `.pi/extensions/fm-primary-turnend-guard.ts` listens for `turn_end`.
Pi's `turn_end` cannot block directly, so the primary adapter uses `pi.sendUserMessage(..., { deliverAs: "followUp" })` to force one follow-up turn when `bin/fm-turnend-guard.sh` returns 2.
**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.
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`.
`bin/fm-session-start.sh` reports when the live Pi session has not loaded both the turn-end guard and watcher extensions, and points at plain `pi` after project trust as the fix, with `-e` as a trust-free fallback.
When a secondmate is launched on Pi, `fm-spawn.sh --secondmate` launches Pi with both `-e .pi/extensions/fm-primary-turnend-guard.ts` and `-e .pi/extensions/fm-primary-pi-watch.ts`, both already present in the secondmate home's git worktree.

Expand Down
50 changes: 36 additions & 14 deletions .pi/extensions/fm-primary-pi-watch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";

type ArmResult = {
ok: boolean;
Expand Down Expand Up @@ -63,11 +64,10 @@ function sessionOwnsLock(): boolean {
return lockOwnership() === "owned";
}

function markLoaded() {
if (lockOwnership() === "other") return false;
function markLoaded(): void {
if (lockOwnership() === "other") return;
mkdirSync(state, { recursive: true });
writeFileSync(marker, `${extensionVersion}\n${process.pid}\n`);
return true;
}

function actionableLine(output: string): string {
Expand All @@ -86,6 +86,16 @@ function failureLine(stdout: string, stderr: string, code: number | null): strin
}

export default function (pi: ExtensionAPI) {
function stopArm(): void {
if (child) child.kill("SIGTERM");
child = null;
}

const cleanupOnProcessExit = () => {
stopArm();
};
process.once("exit", cleanupOnProcessExit);

async function sendWake(message: string) {
await pi.sendUserMessage(
`FIRSTMATE WATCHER WAKE: ${message}\n\nRun bin/fm-wake-drain.sh first, handle the queued wake, then resume Pi supervision.`,
Expand Down Expand Up @@ -140,26 +150,38 @@ export default function (pi: ExtensionAPI) {
return { ok: true, message: `watcher: started Pi extension arm child ${id}` };
}

pi.on?.("session_start", markLoaded);
pi.on?.("session_start", () => {
markLoaded();
});
pi.on?.("session_shutdown", () => {
if (child) child.kill("TERM");
child = null;
stopArm();
process.off("exit", cleanupOnProcessExit);
});

pi.registerCommand?.("fm-watch-arm-pi", {
description: "Arm firstmate watcher supervision through the Pi extension.",
handler: async () => startArm().message,
description: "Arm firstmate watcher supervision through the Pi extension instead of foreground bash.",
handler: async (_args, ctx) => {
const result = startArm();
ctx.ui.notify(result.message, result.ok ? "info" : "warning");
},
});

pi.registerTool?.({
name: "fm_watch_arm_pi",
description: "Arm firstmate watcher supervision through the Pi extension.",
parameters: {
type: "object",
properties: {},
additionalProperties: false,
label: "Arm firstmate watcher",
description: "Arm Pi watcher supervision. Always use this tool instead of running bin/fm-watch-arm.sh through bash.",
promptSnippet: "Arm firstmate watcher supervision through Pi without a foreground bash arm.",
promptGuidelines: [
"For Pi watcher supervision, call fm_watch_arm_pi instead of running bin/fm-watch-arm.sh through bash.",
],
parameters: Type.Object({}),
execute: async () => {
const result = startArm();
return {
content: [{ type: "text", text: result.message }],
details: result,
};
},
execute: async () => startArm(),
});

markLoaded();
Expand Down
25 changes: 13 additions & 12 deletions .pi/extensions/fm-primary-turnend-guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";

let skipNextTurnEnd = false;
let guardFollowupActive = false;

type LockOwnership = "owned" | "missing" | "other";

Expand Down Expand Up @@ -49,11 +49,10 @@ function lockOwnership(): LockOwnership {
return pidAlive(lockPid) ? "other" : "missing";
}

function markLoaded() {
if (lockOwnership() === "other") return false;
function markLoaded(): void {
if (lockOwnership() === "other") return;
mkdirSync(state, { recursive: true });
writeFileSync(marker, `${extensionVersion}\n${process.pid}\n`);
return true;
}

function runGuard(): Promise<{ code: number; stderr: string }> {
Expand All @@ -75,7 +74,7 @@ function runGuard(): Promise<{ code: number; stderr: string }> {
// Piggybacks on this same extension file rather than a separate one so no
// second Pi -e flag is needed at launch - the primary already loads this
// file for the turn-end guard, and pi.on("tool_call", ...) can block
// (verified 2026-07-09 against pi 0.80.2: returning {block: true} prevents
// (verified 2026-07-09 against pi 0.80.5: returning {block: true} prevents
// the bash command from running).
function runPretoolCheck(command: string): Promise<{ code: number; stderr: string }> {
return new Promise((resolveResult) => {
Expand All @@ -92,7 +91,9 @@ function runPretoolCheck(command: string): Promise<{ code: number; stderr: strin
}

export default function (pi: ExtensionAPI) {
pi.on?.("session_start", markLoaded);
pi.on?.("session_start", () => {
markLoaded();
});

pi.on("tool_call", async (event) => {
if (event.type !== "tool_call" || event.toolName !== "bash") return {};
Expand All @@ -103,25 +104,25 @@ export default function (pi: ExtensionAPI) {
return { block: true, reason: result.stderr.trim() || "denied by the watcher-arm PreToolUse seatbelt" };
});

pi.on("turn_end", async () => {
if (skipNextTurnEnd) {
skipNextTurnEnd = false;
pi.on("agent_settled", async () => {
if (guardFollowupActive) {
guardFollowupActive = false;
return;
}

const result = await runGuard();
if (result.code !== 2) return;

guardFollowupActive = true;
try {
pi.sendUserMessage(
await pi.sendUserMessage(
"TURN WOULD END BLIND - supervision is off. " +
"Resume supervision according to the session-start operating block before ending the turn.\n\n" +
result.stderr,
{ deliverAs: "followUp" },
);
skipNextTurnEnd = true;
} catch {
skipNextTurnEnd = false;
guardFollowupActive = false;
}
});

Expand Down
2 changes: 2 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ for test_script in tests/*.test.sh; do bash "$test_script"; done # behavior te
tests/fm-wake-queue.test.sh # durable wake queue losslessness, catch-up, double-drain, duplicate-collapse, and drain liveness guard tests
tests/fm-watcher-lock.test.sh # watcher singleton, lock-race, PID identity stability, watch-arm liveness, and guard-warning tests
tests/fm-turnend-guard.test.sh # shared supervision predicate plus Claude Stop-hook scoping, loop guard, fail-open, and live watcher health tests
tests/fm-pi-primary-types.test.sh # strict no-emit TypeScript check for both tracked Pi primary extensions against an installed Pi package
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 # PreToolUse watcher-arm seatbelt: CLI/stdin allow-deny table, fail-open, --claude output shaping, and all five harness wiring files
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
Expand Down
2 changes: 1 addition & 1 deletion bin/fm-supervision-instructions.sh
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ repair_line() {
printf '%s%s%s%s\n' "$prefix" 'resume supervision with a foreground checkpoint: bin/fm-watch-checkpoint.sh --seconds ' "$checkpoint_seconds" '.'
;;
pi)
printf '%s%s%s%s%s%s\n' "$prefix" 'resume supervision through the Pi extension command /fm-watch-arm-pi or restart Pi with -e ' "$pi_turnend_ext" ' -e ' "$pi_ext" ' if the extension is not loaded.'
printf '%s%s%s%s%s%s\n' "$prefix" 'resume supervision with the Pi tool fm_watch_arm_pi or restart Pi with -e ' "$pi_turnend_ext" ' -e ' "$pi_ext" ' if the extension is not loaded.'
;;
opencode)
printf '%s%s\n' "$prefix" 'resume supervision by letting the OpenCode TUI plugin arm after idle; use bin/fm-watch-arm.sh only as a manual recovery probe if the plugin reports failure.'
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ The script header owns the exact JSON schema.
Optional X mode rides the same check path: the locked session-start bootstrap step drops a local `state/x-watch.check.sh` shim only after the user opts in with `FMX_PAIRING_TOKEN`, and non-X homes keep the default watcher behavior.

At session start, `bin/fm-session-start.sh` emits exactly one primary-harness supervision block rendered by `bin/fm-supervision-instructions.sh` from `docs/supervision-protocols/`.
That block owns the live wait shape for the running primary harness: Claude and Grok use background-notify cycles, Codex uses bounded foreground checkpoints, Pi uses its generated primary watcher extension, and OpenCode uses its TUI plugin.
That block owns the live wait shape for the running primary harness: Claude and Grok use background-notify cycles, Codex uses bounded foreground checkpoints, Pi uses its two tracked primary extensions, and OpenCode uses its TUI plugin.
`bin/fm-watch-arm.sh` remains the verified arm wrapper for protocols that call it; it forks the watcher as a tracked child, verifies it is genuinely alive with a fresh liveness beacon, and prints exactly one honest status line (`started` / `attached` / restart-only `healthy` / `FAILED`, the last exiting non-zero).
On `attached` it stays live until that existing cycle ends so background-notify harnesses do not get an empty false wake from a healthy no-op exit.
Its `--restart` mode signals only the watcher recorded in the current home's `state/.watch.lock`, so restarting one home cannot kill sibling secondmate watchers.
Expand Down
2 changes: 1 addition & 1 deletion docs/arm-pretool-check.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ No harness was left with a residual gap: every verified adapter supports a genui
The hook command mirrors the existing Stop hook's root-anchoring: it reads the payload once, resolves the executable root from the hook process's own `pwd -P` (not the payload's `cwd`), and verifies that root is firstmate-shaped and hook-bearing before invoking the checker, so it stays inert outside a genuine firstmate checkout.
- **OpenCode**: the arm mechanism itself is entirely plugin-owned (`fm-primary-watch-arm.js` spawns `bin/fm-watch-arm.sh --restart` as a real child process, never a model tool call), so this hook is a residual-risk backstop for the agent shelling the arm wrong through its own bash tool, exactly like Codex.
`tool.execute.before` receives `{tool: "bash", ...}` / `{args: {command}}` and can block by throwing - the thrown message becomes the failed tool result shown to the model.
- **Pi**: the arm mechanism is extension-owned (`/fm-watch-arm-pi` / `fm_watch_arm_pi` spawns `bin/fm-watch-arm.sh --restart`), so again this is a residual-risk backstop.
- **Pi**: watcher arming is extension-owned, with `fm_watch_arm_pi` as the model-facing tool and `/fm-watch-arm-pi` as its human-entered fallback, so this is a residual-risk backstop; the complete Pi arm contract lives in [`docs/supervision-protocols/pi.md`](supervision-protocols/pi.md).
The `tool_call` handler is added to the **existing** `fm-primary-turnend-guard.ts` extension file rather than a new one, so no additional `-e` flag is needed at Pi launch - the primary already loads this file for the turn-end guard, and `AGENTS.md`/`docs/supervision-protocols/pi.md`'s launch instructions (`-e <turnend-ext> -e <watch-ext>`) are unchanged.

## Grok `${VAR}` regression (discovered and fixed 2026-07-09)
Expand Down
2 changes: 1 addition & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ The verified adapter knowledge - busy signatures, interrupt and exit commands, s
Launch mechanics, including the verified command templates, live in [`bin/fm-spawn.sh`](../bin/fm-spawn.sh).
Primary-session turn-end guard integrations for verified harnesses are tracked as repo-level hook files and documented in [`docs/turnend-guard.md`](turnend-guard.md).
Primary-session watcher wake protocols are rendered at session start by [`bin/fm-supervision-instructions.sh`](../bin/fm-supervision-instructions.sh) from [`docs/supervision-protocols/`](supervision-protocols/).
Claude and Grok use background-notify cycles, Codex uses bounded foreground checkpoints, Pi uses its generated primary watcher extension, and OpenCode uses its TUI plugin.
Claude and Grok use background-notify cycles, Codex uses bounded foreground checkpoints, Pi uses its two tracked primary extensions, and OpenCode uses its TUI plugin.
`config/crew-harness` is a local, gitignored file containing one adapter name for crewmate and scout launches.
When it is absent or contains `default`, crewmates mirror the firstmate's own harness.
`config/secondmate-harness` is a separate local, gitignored file containing the adapter the primary uses to launch secondmate agents, optionally followed by model and effort tokens on the same line.
Expand Down
2 changes: 1 addition & 1 deletion docs/scripts.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ If you have changed away from the firstmate home in an interactive shell, invoke

| Script | Description |
| ------------------------ | ------------------------------------------------------------------------------------------------------------------- |
| `fm-session-start.sh` | The one command AGENTS.md sections 3 and 5 run at every session start: composes `fm-lock.sh`, `fm-bootstrap.sh` (its four mutating sweeps gated on holding the lock via `FM_BOOTSTRAP_DETECT_ONLY`), and `fm-wake-drain.sh`, emits exactly one primary-harness supervision operating block, then prints a full context digest (`data/projects.md`, `data/secondmates.md`, `data/captain.md`, `data/learnings.md`, each `ABSENT`-marked when missing) and fleet-state digest (`data/backlog.md`, every `state/*.meta`, a bounded `state/*.status` tail, `state/.afk`, and a cheap per-task endpoint-liveness read); prints a loud read-only banner and skips every mutating step when the lock is held elsewhere; refreshes Pi's generated primary watcher extension before reporting whether both Pi primary extensions are loaded; never starts supervision itself |
| `fm-session-start.sh` | The one command AGENTS.md sections 3 and 5 run at every session start: composes `fm-lock.sh`, `fm-bootstrap.sh` (its four mutating sweeps gated on holding the lock via `FM_BOOTSTRAP_DETECT_ONLY`), and `fm-wake-drain.sh`, emits exactly one primary-harness supervision operating block, then prints a full context digest (`data/projects.md`, `data/secondmates.md`, `data/captain.md`, `data/learnings.md`, each `ABSENT`-marked when missing) and fleet-state digest (`data/backlog.md`, every `state/*.meta`, a bounded `state/*.status` tail, `state/.afk`, and a cheap per-task endpoint-liveness read); prints a loud read-only banner and skips every mutating step when the lock is held elsewhere; checks whether Pi's two tracked primary extensions are loaded; never starts supervision itself |
| `fm-bootstrap.sh` | Detect required toolchain and version problems (including `tasks-axi` version/archive-body compatibility, `quota-axi`, and the other bootstrap AXI tools), dispatch profile JSON errors or active-rule blocks, default backlog-backend status, primary-checkout `TANGLE:` problems, and actionable clone refresh outcomes or timeout summaries; refresh project clones best-effort under the configured bootstrap timeout; locally sync live secondmate homes and propagate declared inheritable config; run the secondmate agent-liveness sweep; set up opt-in X mode; install tools only after consent; `FM_BOOTSTRAP_DETECT_ONLY=1` skips the four mutating sweeps and prints advisory-only `TANGLE:` wording without a checkout command |
| `fm-fleet-sync.sh` | Fetch all clones, or one clone selected by absolute path, relative path, bare project name, or `projects/<name>` resolved against this home's projects dir; fast-forward safe default-branch states, self-heal clean detached ancestor drift, report unsafe drift as `STUCK:`, and safely prune branches whose remote is gone |
| `fm-fleet-snapshot.sh` | Print the read-only structured fleet snapshot JSON contract, schema `fm-fleet-snapshot.v1`, used by bearings and human fleet views; preserves current state from `fm-crew-state.sh` separately from historical status-log event data |
Expand Down
Loading