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
6 changes: 3 additions & 3 deletions .agents/skills/harness-adapters/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ Do not substitute another harness's wait shape when resuming supervision.
Claude's Stop `asyncRewake` hook (`bin/fm-claude-stop-autoarm.sh`) owns tokenless re-arm around `bin/fm-watch-arm.sh`, and Grok uses tracked background-notify cycles around `bin/fm-watch-arm.sh`.
Codex uses bounded foreground checkpoints through `bin/fm-watch-checkpoint.sh` because Codex cannot reason while a foreground tool call is running.
OpenCode uses `.opencode/plugins/fm-primary-watch-arm.js`, which coordinates with the turn-end guard plugin and wakes the TUI with `client.session.promptAsync`.
Pi and pi-signed use the tracked `.pi/extensions/fm-primary-turnend-guard.ts` plus the tracked `.pi/extensions/fm-primary-pi-watch.ts`, both project-local extensions the Pi engine auto-discovers once trusted.
Pi and pi-signed use the tracked `.pi/extensions/fm-primary-turnend-guard.ts`, `.pi/extensions/fm-primary-pi-watch.ts`, and `.pi/extensions/fm-primary-decision-nudge.ts`, all project-local extensions the Pi engine auto-discovers once trusted.
When changing any primary watcher adapter, update `docs/supervision-protocols/`, `docs/turnend-guard.md` if a shared idle or turn-end hook changed, and the relevant concise fact below.

## Launch profile axes
Expand Down Expand Up @@ -295,8 +295,8 @@ The firstmate PRIMARY's own `.pi/extensions/fm-primary-turnend-guard.ts` listens
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-family session has not loaded both the turn-end guard and watcher extensions, and points at the selected executable after project trust as the fix, with `-e` as a trust-free fallback.
When a secondmate is launched on Pi or pi-signed, `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 already present in the secondmate home's git worktree.
`bin/fm-session-start.sh` reports when the live Pi-family session has not loaded the turn-end guard, watcher, and captain-attention nudge extensions, and points at the selected executable after project trust as the fix, with `-e` as a trust-free fallback.
When a secondmate is launched on Pi or pi-signed, `fm-spawn.sh --secondmate` launches the selected executable with `-e .pi/extensions/fm-primary-turnend-guard.ts`, `-e .pi/extensions/fm-primary-pi-watch.ts`, and `-e .pi/extensions/fm-primary-decision-nudge.ts`, all already present in the secondmate home's git worktree.

## grok (VERIFIED 2026-06-29, grok 0.2.73; slash-submit re-verified 2026-07-03 on 0.2.82; reasoning-effort ceiling re-verified 2026-07-13 on 0.2.99; exit paths re-verified 2026-07-19 on grok 0.2.103)

Expand Down
153 changes: 153 additions & 0 deletions .pi/extensions/fm-primary-decision-nudge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
// Pi primary captain-attention nudge.
//
// Pi normally asks the captain in chat. Once agent_settled proves no automatic
// retry, compaction, or follow-up remains, this extension inspects the latest
// assistant text and arms bin/fm-decision-nudge.sh only for an explicit
// captain-facing question or decision request. The shared script owns primary
// scope, Telegram opt-in, marker, timer, and send semantics.
import { spawn } from "node:child_process";
import { existsSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
import { extensionVersionOf, lockOwnership } from "./lib/fm-primary-loaded-marker.ts";

const extensionFile = fileURLToPath(import.meta.url);
const extensionDir = dirname(extensionFile);
const root = resolve(extensionDir, "../..");
const fmRoot = process.env.FM_ROOT_OVERRIDE || root;
const fmHome = process.env.FM_HOME || 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 nudgeScript = `${fmRoot}/bin/fm-decision-nudge.sh`;
const loadedMarker = `${state}/.pi-decision-nudge-extension-loaded`;
const extensionVersion = extensionVersionOf(extensionFile);

// Same loaded-marker contract as the two sibling primary extensions, so
// bin/fm-session-start.sh can report this one as missing instead of silently
// losing the nudge when project trust was never approved.
function markLoaded(): void {
try {
if (!existsSync(state) || lockOwnership(state) === "other") return;
writeFileSync(loadedMarker, `${extensionVersion}\n${process.pid}\n`);
} catch {
}
}

// A bare decision verb is not an ask: a settled watcher turn like "Captain, PR
// #7 is merged. I'll confirm the deploy once you're back." must not page him.
// The verb only counts in an imperative (sentence- or vocative-initial) or
// second-person/explicit-request position.
const DECISION_VERBS = "choose|select|pick|decide|confirm|approve";
const DECISION_PATTERNS = [
/\b(?:yes\s*\/\s*no|yes\s+or\s+no)\b/i,
/\b(?:do you want|would you like|shall I|should I|may I|can I)\b/i,
new RegExp(String.raw`(?:^|[.!?]\s+|\n\s*|\bcaptain\s*[,:-]\s*)(?:please\s+)?(?:${DECISION_VERBS})\b`, "i"),
new RegExp(
String.raw`\b(?:please|need you to|needs you to|want you to|waiting (?:on|for) you to|for you to|your call|up to you)\b[^.?!]{0,60}\b(?:${DECISION_VERBS})\b`,
"i",
),
new RegExp(String.raw`\byou\s+(?:${DECISION_VERBS})\b`, "i"),
/\b(?:need|needs|awaiting|requires?|requesting)\b.{0,80}\b(?:decision|approval|choice|answer|confirmation)\b/i,
/\b(?:decision|approval|choice|answer|confirmation)\b.{0,80}\b(?:needed|required|awaiting|please)\b/i,
/\boptions?\s*:/i,
];

export function isCaptainAttentionWait(text: string): boolean {
const candidate = text.trim();
if (!candidate || candidate === "Captain, shipshape.") return false;
if (!/\bCaptain\b/.test(candidate)) return false;
return candidate.includes("?") || DECISION_PATTERNS.some((pattern) => pattern.test(candidate));
}

type SessionMessageEntry = {
type?: string;
id?: string;
message?: {
role?: string;
content?: unknown;
};
};

function assistantText(content: unknown): string {
if (typeof content === "string") return content.trim();
if (!Array.isArray(content)) return "";
return content
.filter((block): block is { type: "text"; text: string } => (
typeof block === "object" && block !== null &&
(block as { type?: unknown }).type === "text" &&
typeof (block as { text?: unknown }).text === "string"
))
.map((block) => block.text)
.join("\n")
.trim();
}

export function latestCaptainAttentionWait(ctx: Pick<ExtensionContext, "sessionManager">): { id: string; text: string } | null {
const branch = ctx.sessionManager.getBranch() as SessionMessageEntry[];
for (let index = branch.length - 1; index >= 0; index -= 1) {
const entry = branch[index];
if (entry.type !== "message") continue;
// The scan stops at the turn boundary: an ask from an earlier turn the
// captain already answered must never re-arm.
if (entry.message?.role === "user") return null;
if (entry.message?.role !== "assistant") continue;
const text = assistantText(entry.message.content);
if (!text) continue;
const id = typeof entry.id === "string" ? entry.id : "";
return id && isCaptainAttentionWait(text) ? { id, text } : null;
}
return null;
}

function invokeNudge(mode: "--pi-arm" | "--pi-resolved", id = ""): void {
try {
const args = id ? [mode, id] : [mode];
const child = spawn(nudgeScript, args, {
detached: true,
env: {
...process.env,
FM_HOME: fmHome,
FM_ROOT_OVERRIDE: fmRoot,
FM_STATE_OVERRIDE: state,
FM_CONFIG_OVERRIDE: config,
},
stdio: "ignore",
});
child.on("error", () => {});
child.unref();
} catch {
// Notification support must never interfere with the Pi session.
}
}

export default function (pi: ExtensionAPI) {
const disarm = (): void => invokeNudge("--pi-resolved");

// A real interactive or RPC input is direct evidence that the captain is
// present. Extension-injected operational messages are not presence signals.
pi.on("input", (event) => {
if (event.source !== "extension") disarm();
return { action: "continue" };
});

// Covers expanded prompts and any run started without traversing input.
pi.on("before_agent_start", () => {
disarm();
});

pi.on("agent_settled", (_event, ctx) => {
const wait = latestCaptainAttentionWait(ctx);
if (wait) invokeNudge("--pi-arm", wait.id);
});

pi.on("session_shutdown", () => {
disarm();
});

pi.on?.("session_start", () => {
markLoaded();
});

markLoaded();
}
48 changes: 7 additions & 41 deletions .pi/extensions/fm-primary-pi-watch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,8 @@
// a new live generation so monitoring can arm again without restarting Pi. Terminal
// quit leaves the final generation stopped so late callbacks cannot rearm. Stale
// callbacks from a prior generation are no-ops against the active replacement.
import { spawn, spawnSync, type ChildProcess } from "node:child_process";
import { createHash } from "node:crypto";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { spawn, type ChildProcess } from "node:child_process";
import { mkdirSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import type { ExtensionAPI, Theme } from "@earendil-works/pi-coding-agent";
Expand All @@ -22,14 +21,13 @@ import {
FIRSTMATE_CALM_PRESENTATION_EVENT,
} from "./lib/fm-calm-visibility.ts";
import { encodeFirstmateOperationalInput } from "./lib/fm-operational-input.ts";
import { extensionVersionOf, lockOwnership } from "./lib/fm-primary-loaded-marker.ts";

type ArmResult = {
ok: boolean;
message: string;
};

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

type CloseClassification = {
kind: "actionable" | "failure";
message: string;
Expand Down Expand Up @@ -84,7 +82,7 @@ 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 marker = `${state}/.pi-watch-extension-loaded`;
const extensionVersion = `sha256:${createHash("sha256").update(readFileSync(extensionFile)).digest("hex")}`;
const extensionVersion = extensionVersionOf(extensionFile);
const retryBaseMs = positiveInteger("FM_WATCH_REARM_RETRY_BASE_MS", 250);
const retryMaxMs = positiveInteger("FM_WATCH_REARM_RETRY_MAX_MS", 4000);
const retryLimit = positiveInteger("FM_WATCH_REARM_RETRY_LIMIT", 5);
Expand All @@ -110,40 +108,8 @@ function positiveInteger(name: string, fallback: number): number {
return Math.floor(value);
}

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";
}

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

function scheduleRetry(owner: SessionGeneration, message: string, predecessorArmPid: string): void {
if (!generationIsLive(owner) || owner.child || owner.retryTimer) return;
const ownership = lockOwnership();
const ownership = lockOwnership(state);
if (ownership !== "owned") {
surfaceFailure(owner, `watcher: FAILED - Pi extension cannot restore continuity because this session no longer owns the lock\n${message}`);
return;
Expand All @@ -341,7 +307,7 @@ export default function (pi: ExtensionAPI) {

function startArm(owner: SessionGeneration, predecessorArmPid = ""): ArmResult {
if (!generationIsLive(owner)) return { ok: false, message: shuttingDownMessage };
const ownership = lockOwnership();
const ownership = lockOwnership(state);
if (ownership === "other") return { ok: false, message: "watcher: read-only - session lock is held by another firstmate session" };
if (ownership === "missing") {
return {
Expand Down
42 changes: 4 additions & 38 deletions .pi/extensions/fm-primary-turnend-guard.ts
Original file line number Diff line number Diff line change
@@ -1,57 +1,23 @@
import { spawn, spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { existsSync, 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 { encodeFirstmateOperationalInput } from "./lib/fm-operational-input.ts";
import { extensionVersionOf, lockOwnership } from "./lib/fm-primary-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, "../..");
const fmHome = process.env.FM_HOME || process.env.FM_ROOT_OVERRIDE || root;
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";
}
const extensionVersion = extensionVersionOf(extensionFile);

function markLoaded(): void {
if (!existsSync(state) || lockOwnership() === "other") return;
if (!existsSync(state) || lockOwnership(state) === "other") return;
writeFileSync(marker, `${extensionVersion}\n${process.pid}\n`);
}

Expand Down
46 changes: 46 additions & 0 deletions .pi/extensions/lib/fm-primary-loaded-marker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
// One definition of "this Pi session owns the firstmate session lock" and of the
// extension version stamp, shared by every tracked primary extension that writes
// a state/.pi-*-extension-loaded marker. bin/fm-session-start.sh reads those
// markers and compares each stamp against the hash of the extension file it
// found on disk, so all writers must agree on both contracts.
import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";

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

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;
}
}

export function lockOwnership(state: string): 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";
}

export function extensionVersionOf(extensionFile: string): string {
return `sha256:${createHash("sha256").update(readFileSync(extensionFile)).digest("hex")}`;
}
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ Launching a supported harness inside it instantiates your first mate - and makes
- **Optional secondmates** - opt in to persistent second mates that run from isolated firstmate homes with their own `FM_HOME`, state, projects, and session lock, supervising project clones or a project-less firstmate-repo domain, kept on the primary firstmate version by guarded local fast-forwards and checked for live agent processes at session start.
- **Event-driven, zero-token supervision** - a bash watcher sleeps on the fleet and wakes the first mate only when something needs you; verified primary harnesses also get a turn-end backstop that blocks or follows up on a blind stop when work is under way and supervision is not live.
- **Optional X mode** - opt in with one local `.env` token so firstmate can answer your public `@myfirstmate` mentions, act on normal reversible mention requests through the same lifecycle as chat requests, acknowledge spawned work, and post up to three public-safe completion follow-ups within seven days for genuine milestones and the final outcome without changing non-X behavior; dry-run preview records would-be replies and dismissals locally before go-live.
- **Optional Telegram mode** - opt in with one local flag file so the messages you send your own phone-inbox Telegram bot reach firstmate as work or questions and get a real reply in the same chat, with up to three bounded completion follow-ups; while you are away, each away-mode escalation batch also reaches that same chat once, as notice only that grants no approval; it stays fully inert until you opt in, authorizes only reversible lifecycle actions from the phone, and keeps the bot token entirely in the separate phone-inbox deployment.
- **Optional Telegram mode** - opt in with one local flag file so the messages you send your own phone-inbox Telegram bot reach firstmate as work or questions and get a real reply in the same chat, with up to three bounded completion follow-ups; a Pi-family session that settles waiting on a decision only you can make pings that chat once after 30 seconds with a content-free "something's awaiting your attention" message that never repeats the question; while you are away, each away-mode escalation batch also reaches that same chat once, as notice only that grants no approval; it stays fully inert until you opt in, authorizes only reversible lifecycle actions from the phone, and keeps the bot token entirely in the separate phone-inbox deployment.
- **Guarded by construction** - the first mate is read-only over your projects except for the guarded paths authorized by [hard rule 1](AGENTS.md#1-identity-and-prime-directives), with fleet sync's safe branch pruning remaining part of the fleet-sync exception; crewmates make every project change behind the configured merge authority.
- **Restart-proof** - all state lives on disk and in the active session backend (tmux by hard default, herdr or cmux when selected or auto-detected, zellij/orca when explicitly selected); kill the session anytime and the next one reconciles, including confirmed-dead secondmate agents, and carries on.

Expand Down
Loading
Loading