Skip to content
8 changes: 7 additions & 1 deletion .agents/skills/harness-adapters/references/harness/pi.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,13 +44,19 @@ Pi sets `PI_CODING_AGENT=true` for its children as its harness-detection marker.
The primary turn-end behavior was verified on 2026-07-09 with Pi 0.80.5.
`.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.
That option does not cover two prompts that both start while the primary is idle, because Pi chooses between queueing and a new turn before its preflight; `.pi/extensions/lib/fm-pi-prompt-delivery.ts` owns how the primary joins such an overlapping prompt to the running turn instead of dropping it.
On native Windows, the extension runs its session-start, both PreToolUse, turn-end, and operational-input Bash helpers through `bash`; macOS and Linux invoke those helpers directly.

The primary watcher protocol also requires `.pi/extensions/fm-primary-pi-watch.ts`.
The Pi engine auto-discovers both tracked project-local extensions once the project is trusted.
The model arms through the `fm_watch_arm_pi` tool, never through a foreground shell arm.
The tool result and clean-exit fallback are owned by `../../../docs/supervision-protocols/pi.md`.
`../../../bin/fm-session-start.sh` reports when the live Pi-family session has not loaded both extensions and points at the selected executable after project trust as the fix, with `-e` as a trust-free fallback.
`../../../bin/fm-session-start.sh` reports when the live Pi-family session has not loaded both extensions and points at `/reload` or restarting the selected executable after project trust as the fix, with `-e` as a trust-free fallback.

Changed extension code activates only through `/reload` in the running primary or a full process restart (verified 2026-09-14 with Pi 0.85.1).
Pi caches each extension factory for the life of the process, and `/new`, `/resume`, and `/fork` rebind that cached factory, so a session replaced that way keeps running the extension code it first loaded even after the files on disk change.
Run `/reload` between turns: a handler already running keeps its old code, and the reload keeps the durable wake queue and any pending replacement wakes.
The loaded-generation markers are the proof of which build the lock-holding session loaded; only that session writes them, so a `--list-models` probe run from its shell cannot make stale code read as current.

When a secondmate is launched on Pi or Pi-signed, `../../../bin/fm-spawn.sh --secondmate` launches the selected executable with both `-e .pi/extensions/fm-primary-turnend-guard.ts` and `-e .pi/extensions/fm-primary-pi-watch.ts`.
Both files already exist in the secondmate home's git worktree.
Expand Down
1 change: 1 addition & 0 deletions .agents/skills/updatefirstmate/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ This touches only the firstmate repo and its own worktrees, never anything under
When the updater printed `reread-firstmate: yes`, the tracked instruction surface (`AGENTS.md`, `bin/`, or `.agents/skills/`) just advanced under you.
**Read `AGENTS.md` now** (CLAUDE.md is a real `@AGENTS.md` pointer to it) to refresh your operating instructions before doing anything else, so you are acting on the new instructions rather than the stale ones you were started with.
When it printed `reread-firstmate: no`, nothing changed for you - skip the re-read.
A Pi primary also runs the tracked `.pi/extensions/` code it loaded, which a re-read cannot replace and a `/new`, `/resume`, or `/fork` does not re-import; when the update changed those files, ask the captain to run `/reload` between turns or restart Pi, as the Pi harness reference's primary integration section owns.

3. **Restart every second mate the updater named.**
Pass the whole `restart-secondmates:` list to one command (skip this step entirely when it says `none`):
Expand Down
41 changes: 28 additions & 13 deletions .pi/extensions/fm-primary-pi-watch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,14 @@
// queued while main is streaming joins the running run without ever raising
// before_agent_start, so waiting on that event stalls every later close.
// Consumption is tracked only so a replacement can replay a follow-up Pi had
// not consumed. An idle main consumes at before_agent_start; a streaming main
// consumes at the user message_start carrying the exact wake text; either
// event finishes the pending record, and a still-unconsumed record rides the
// replacement handoff.
// not consumed. A wake is consumed only when a model turn accepts it, which is
// the user message_start carrying the exact wake text on every path: an idle
// main raises it in the turn the wake opens, a streaming main raises it when
// the running turn drains the follow-up, and a wake that lost a preflight race
// raises it in the turn it joined (./lib/fm-pi-prompt-delivery.ts owns that
// join). before_agent_start is not consumption, because Pi's preflight can
// still reject the prompt after it. Consumption finishes the pending record,
// and a still-unconsumed record rides the replacement handoff.
//
// Restore versus delivery (stated once here):
// delivering is true while the serialized pending-wake pump is in flight.
Expand Down Expand Up @@ -64,6 +68,8 @@ import {
FIRSTMATE_CALM_PRESENTATION_EVENT,
} from "./lib/fm-calm-visibility.ts";
import { encodeFirstmateOperationalInput } from "./lib/fm-operational-input.ts";
import { markerWriterMayRecord } from "./lib/fm-pi-loaded-marker.ts";
import { installPiPromptDelivery } from "./lib/fm-pi-prompt-delivery.ts";

type ArmResult = {
ok: boolean;
Expand Down Expand Up @@ -264,8 +270,15 @@ function lockOwnership(): LockOwnership {
return pidAlive(lockPid) ? "other" : "missing";
}

// The loaded-generation marker is evidence that THIS session process loaded
// this build (bin/fm-wake-lib.sh fm_pi_extension_loaded owns the proof). Only
// a live session writes it - session_start or an arm - never factory load,
// because a descendant `pi --list-models` probe loads the same factory without
// starting a session. The writer must be the lock pid itself, or no live
// process may hold the lock yet; a descendant of the lock holder can never
// satisfy the proof and must not overwrite the holder's evidence.
function markLoaded(): void {
if (lockOwnership() === "other") return;
if (!markerWriterMayRecord(`${state}/.lock`)) return;
mkdirSync(state, { recursive: true });
writeFileSync(marker, `${extensionVersion}\n${process.pid}\n`);
}
Expand Down Expand Up @@ -536,6 +549,7 @@ const cleanupOnProcessExit = () => {
process.once("exit", cleanupOnProcessExit);

export default function (pi: ExtensionAPI) {
const promptDelivery = installPiPromptDelivery();
let generation = createGeneration();
activateGeneration(generation);

Expand Down Expand Up @@ -582,8 +596,8 @@ export default function (pi: ExtensionAPI) {
return generationIsLive(owner);
}

// Pi consumed a main follow-up: an idle main at before_agent_start, a
// streaming main at the user message_start that joins the running run.
// A model turn accepted a main follow-up: the user message_start carrying
// its exact text (see "Delivery versus consumption" above).
function consumeWake(owner: SessionGeneration, text: string): void {
for (const [token, wake] of owner.unconsumedWakes) {
if (wake.content !== text) continue;
Expand Down Expand Up @@ -1247,17 +1261,20 @@ export default function (pi: ExtensionAPI) {
return result;
}

pi.on?.("before_agent_start", (event) => {
consumeWake(generation, event.prompt);
});
pi.on?.("message_start", (event) => {
if (event.message.role !== "user") return;
consumeWake(generation, userMessageText(event.message.content));
});

pi.on?.("session_start", async () => {
pi.on?.("session_start", async (_event, ctx) => {
if (generation.stopping) generation = createGeneration();
activateGeneration(generation);
if (!promptDelivery.ok) {
ctx?.ui?.notify?.(
`watcher: prompt delivery unprotected - overlapping Firstmate and captain prompts can still be dropped (${promptDelivery.detail})`,
"warning",
);
}
markLoaded();
if (lockOwnership() !== "owned") return;
activateOwnedWatch(generation);
Expand Down Expand Up @@ -1319,6 +1336,4 @@ export default function (pi: ExtensionAPI) {
};
},
});

markLoaded();
}
41 changes: 4 additions & 37 deletions .pi/extensions/fm-primary-turnend-guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,10 @@ import {
encodeFirstmateOperationalInput,
firstmateShellInvocation,
} from "./lib/fm-operational-input.ts";
import { markerWriterMayRecord } from "./lib/fm-pi-loaded-marker.ts";

let guardFollowupActive = false;

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

const extensionFile = fileURLToPath(import.meta.url);
const extensionDir = dirname(extensionFile);
const root = resolve(extensionDir, "../..");
Expand All @@ -22,40 +21,10 @@ const state = process.env.FM_STATE_OVERRIDE || `${fmHome}/state`;
const marker = `${state}/.pi-turnend-extension-loaded`;
const extensionVersion = `sha256:${createHash("sha256").update(readFileSync(extensionFile)).digest("hex")}`;

function parentPid(pid: string): string {
const result = spawnSync("ps", ["-o", "ppid=", "-p", pid], { encoding: "utf8" });
if (result.status !== 0) return "";
return result.stdout.trim();
}

function pidAlive(pid: string): boolean {
try {
process.kill(Number(pid), 0);
return true;
} catch {
return false;
}
}

function lockOwnership(): LockOwnership {
let lockPid = "";
try {
lockPid = readFileSync(`${state}/.lock`, "utf8").trim();
} catch {
return "missing";
}
if (!/^[0-9]+$/.test(lockPid) || lockPid === "1") return "other";
let pid = String(process.pid);
for (let i = 0; i < 8; i += 1) {
if (pid === lockPid) return "owned";
pid = parentPid(pid);
if (!pid || pid === "1") break;
}
return pidAlive(lockPid) ? "other" : "missing";
}

// Written only from session_start, under the writer rule the watch extension
// shares (./lib/fm-pi-loaded-marker.ts owns it).
function markLoaded(): void {
if (!existsSync(state) || lockOwnership() === "other") return;
if (!existsSync(state) || !markerWriterMayRecord(`${state}/.lock`)) return;
writeFileSync(marker, `${extensionVersion}\n${process.pid}\n`);
}

Expand Down Expand Up @@ -625,6 +594,4 @@ export default function (pi: ExtensionAPI) {
guardFollowupActive = false;
}
});

markLoaded();
}
31 changes: 31 additions & 0 deletions .pi/extensions/lib/fm-pi-loaded-marker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { readFileSync } from "node:fs";

// Writer rule for the Pi primary extensions' loaded-generation markers
// (state/.pi-watch-extension-loaded and state/.pi-turnend-extension-loaded).
// bin/fm-wake-lib.sh fm_pi_extension_loaded owns what a marker proves: the
// build it names was loaded by exactly the process recorded in state/.lock.
//
// So a marker may be recorded only by that process itself, or while no live
// process holds the lock yet (a fresh or dead lock, which the session that
// takes it next rewrites from its own session_start or arm). A live descendant
// of the lock holder - such as a `pi --list-models` probe the primary runs from
// its bash tool, which loads the same extension factories from disk - can never
// satisfy the proof, so it must never overwrite the holder's evidence with a
// newer build hash and its own pid. Callers also write only from a started
// session, never from factory load, because such a probe never starts one.
export function markerWriterMayRecord(lockFile: string): boolean {
let lockPid = "";
try {
lockPid = readFileSync(lockFile, "utf8").trim();
} catch {
return true;
}
if (lockPid === String(process.pid)) return true;
if (!/^[0-9]+$/.test(lockPid) || lockPid === "1") return false;
try {
process.kill(Number(lockPid), 0);
return false;
} catch (error) {
return (error as { code?: unknown }).code === "ESRCH";
}
}
Loading
Loading