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
4 changes: 2 additions & 2 deletions .agents/skills/harness-adapters/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,11 +210,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-09, Pi 0.80.5).**
**Primary-session guard fact (verified 2026-07-09, Pi 0.80.5; resume fixture verified 2026-07-16).**
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`.
The watcher extension performs the initial `session_start` arm; after a wake, the model re-arms through `fm_watch_arm_pi`, never a foreground bash arm, with the full recovery and clean-exit protocol 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
74 changes: 39 additions & 35 deletions .pi/extensions/fm-primary-pi-watch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ type ArmResult = {
message: string;
};

type LockOwnership = "owned" | "missing" | "other";
type LockOwnership = "owned" | "missing" | "dead" | "other" | "malformed" | "unknown";

const extensionFile = fileURLToPath(import.meta.url);
const extensionDir = dirname(extensionFile);
Expand All @@ -22,50 +22,35 @@ const fmRoot = process.env.FM_ROOT_OVERRIDE || root;
const state = process.env.FM_STATE_OVERRIDE || `${fmHome}/state`;
const config = process.env.FM_CONFIG_OVERRIDE || `${fmHome}/config`;
const armScript = `${fmRoot}/bin/fm-watch-arm.sh`;
const lockScript = `${fmRoot}/bin/fm-lock.sh`;
const marker = `${state}/.pi-watch-extension-loaded`;
const extensionVersion = `sha256:${createHash("sha256").update(readFileSync(extensionFile)).digest("hex")}`;

let child: any = null;
let seq = 0;

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 lockEnvironment() {
return {
...process.env,
FM_HOME: fmHome,
FM_ROOT_OVERRIDE: fmRoot,
FM_STATE_OVERRIDE: state,
};
}

function lockOwnership(): LockOwnership {
let lockPid = "";
try {
lockPid = readFileSync(`${state}/.lock`, "utf8").trim();
} catch {
return "missing";
const result = spawnSync(lockScript, ["ownership"], { encoding: "utf8", env: lockEnvironment() });
const ownership = String(result.stdout || "").trim();
if (result.status !== 0) return "unknown";
if (["owned", "missing", "dead", "other", "malformed", "unknown"].includes(ownership)) {
return ownership as LockOwnership;
}
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";
}

function sessionOwnsLock(): boolean {
return lockOwnership() === "owned";
return "unknown";
}

function markLoaded(): void {
if (lockOwnership() === "other") return;
const ownership = lockOwnership();
if (ownership === "other" || ownership === "malformed" || ownership === "unknown") return;
mkdirSync(state, { recursive: true });
writeFileSync(marker, `${extensionVersion}\n${process.pid}\n`);
}
Expand Down Expand Up @@ -104,7 +89,25 @@ export default function (pi: ExtensionAPI) {
}

function startArm(): ArmResult {
if (!sessionOwnsLock()) return { ok: false, message: "watcher: read-only - session lock is held by another firstmate session" };
let ownership = lockOwnership();
let acquisitionError = "";
let attemptedAcquisition = false;
if (ownership === "missing" || ownership === "dead") {
attemptedAcquisition = true;
const acquisition = spawnSync(lockScript, [], { encoding: "utf8", env: lockEnvironment() });
acquisitionError = String(acquisition.stderr || "").trim().split(/\r?\n/)[0] || "";
ownership = lockOwnership();
}
if (ownership === "other") {
const message = attemptedAcquisition
? "watcher: lock ownership changed during resume - a verified live firstmate session now owns this home"
: "watcher: read-only - a verified live firstmate session owns this home";
return { ok: false, message };
}
if (ownership !== "owned") {
const detail = acquisitionError ? ` (${acquisitionError})` : "";
return { ok: false, message: `watcher: lock acquisition failed - session lock is ${ownership}${detail}` };
}
markLoaded();
if (child) return { ok: true, message: "watcher: healthy - Pi extension already has an arm child" };
const id = ++seq;
Expand Down Expand Up @@ -150,8 +153,9 @@ 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", (_event, ctx) => {
const result = startArm();
if (!result.ok) ctx?.ui?.notify?.(result.message, "warning");
});
pi.on?.("session_shutdown", () => {
stopArm();
Expand Down
50 changes: 20 additions & 30 deletions .pi/extensions/fm-primary-turnend-guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,57 +7,47 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";

let guardFollowupActive = false;

type LockOwnership = "owned" | "missing" | "other";
type LockOwnership = "owned" | "missing" | "dead" | "other" | "malformed" | "unknown";

const extensionFile = fileURLToPath(import.meta.url);
const extensionDir = dirname(extensionFile);
const root = resolve(extensionDir, "../..");
const fmHome = process.env.FM_HOME || process.env.FM_ROOT_OVERRIDE || root;
const fmRoot = process.env.FM_ROOT_OVERRIDE || root;
const state = process.env.FM_STATE_OVERRIDE || `${fmHome}/state`;
const lockScript = `${fmRoot}/bin/fm-lock.sh`;
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 lockEnvironment() {
return {
...process.env,
FM_HOME: fmHome,
FM_ROOT_OVERRIDE: fmRoot,
FM_STATE_OVERRIDE: state,
};
}

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;
const result = spawnSync(lockScript, ["ownership"], { encoding: "utf8", env: lockEnvironment() });
const ownership = String(result.stdout || "").trim();
if (result.status !== 0) return "unknown";
if (["owned", "missing", "dead", "other", "malformed", "unknown"].includes(ownership)) {
return ownership as LockOwnership;
}
return pidAlive(lockPid) ? "other" : "missing";
return "unknown";
}

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

function runGuard(): Promise<{ code: number; stderr: string }> {
return new Promise((resolveResult) => {
const child = spawn(`${root}/bin/fm-turnend-guard.sh`, {
const child = spawn(`${fmRoot}/bin/fm-turnend-guard.sh`, {
stdio: ["pipe", "ignore", "pipe"],
});
let stderr = "";
Expand All @@ -79,7 +69,7 @@ function runGuard(): Promise<{ code: number; stderr: string }> {
// script owns its own decision and is inert outside the real primary checkout.
function runChecker(script: string, command: string): Promise<{ code: number; stderr: string }> {
return new Promise((resolveResult) => {
const child = spawn(`${root}/bin/${script}`, ["--command", command], {
const child = spawn(`${fmRoot}/bin/${script}`, ["--command", command], {
stdio: ["ignore", "ignore", "pipe"],
});
let stderr = "";
Expand Down
Loading
Loading