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
2 changes: 1 addition & 1 deletion .omp/extensions/fm-branch-supervision-omp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ import {
captureTaskStatusSnapshot,
BRANCH_ELIGIBLE_STATUS_FILE,
FM_BRANCH_DISPATCH_EVENT,
MERGE_NOTE_BOAT,
releaseEligibleRowsSnapshot,
rollbackEligibleRowsOwnerActivation,
scopeForUnreadWake,
Expand Down Expand Up @@ -147,7 +148,6 @@ const BRANCH_TOOL_NAMES = ["read", "bash", "fm_branch_report"] as const;
const branchCacheKey = `fm-branch-${createHash("sha256").update(fmHome).digest("hex").slice(0, 24)}`;

const MIRROR_MESSAGE_CAP = 4000;
const MERGE_NOTE_BOAT = "⛵";
// One provider failure falls back immediately but leaves room for a transient
// outage to recover on the next wake. A second consecutive provider failure
// latches the branch off. While latched, main keeps every wake except one
Expand Down
115 changes: 97 additions & 18 deletions .omp/extensions/fm-primary-omp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { createPrimaryWatchCore, ompNativeProcessIdentity, type WakeDeliveryEvid
import {
createBranchDispatchOffer,
FM_BRANCH_DISPATCH_EVENT,
MERGE_NOTE_BOAT,
scopeForUnreadWake,
} from "./lib/fm-branch-dispatch.ts";
import { installTaskInboxDoorbell } from "./lib/fm-task-inbox-doorbell.ts";
Expand Down Expand Up @@ -214,10 +215,106 @@ function runGuard(event: SessionStopEvent): Promise<ProcessResult> {
});
}

// The text OMP carries in a user message_start: sendMessage wraps a string
// as one text part, so the joined text parts equal the sent content.
function userMessageText(content: unknown): string {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
const parts: string[] = [];
for (const part of content) {
if (
typeof part === "object" && part !== null &&
(part as { type?: unknown }).type === "text" &&
typeof (part as { text?: unknown }).text === "string"
) {
parts.push((part as { text: string }).text);
}
}
return parts.join("\n");
}

// Silent wake turns (docs/supervision-protocols/omp.md). OMP re-prompts a final
// message whose only content is signed thinking ("You said you would continue
// ..."; features.unexpectedStopDetection, default "mechanical"), so a wake turn
// cannot end without text. In mechanical mode it never re-prompts a final
// message with text. Its chat view renders text made only of dots, ellipses,
// and blank space as nothing in every frame, so SILENT_WAKE_MARKER ends a wake turn
// silently. An episode ends on session replacement, an assistant abort, or a
// final assistant stop that carried text; errors keep the episode for retries.
// In an episode a watcher wake opened and no captain input or
// captain-facing branch merge joined, a whole-message no-change line is
// rewritten to the marker as a backstop; it streamed before the rewrite, so the
// marker stays the primary path. In an episode the captain joined, a dot-only
// final text is emptied so OMP asks for a visible answer as for any silent stop.
export const SILENT_WAKE_MARKER = "\u2026";
const DOT_ONLY = /^[.\u2026 \t\r\n]+$/;
const NO_CHANGE_SENTENCE = "(?:captain,\\s+)?(?:aye|shipshape|standing by" +
"|no (?:change|changes|news)(?: since my last (?:message|update))?" +
"|no new (?:results|updates)(?: to report)?" +
"|no (?:action|reply) (?:needed|required)" +
"|nothing (?:new|changed|has changed)(?: (?:for you|needs you|to report|since my last (?:message|update)))?" +
"|nothing needs you" +
"|nothing was queued, so there(?:'|\u2019)s nothing new for you)" +
"(?: right now)?(?:,\\s+captain)?[.!]?";
const NO_CHANGE_LINE = new RegExp(`^${NO_CHANGE_SENTENCE}(?:\\s+${NO_CHANGE_SENTENCE})?$`, "i");

export function installSilentWakeTurns(omp: Pick<ExtensionAPI, "on">): void {
let wakeOpened = false;
let captainJoined = false;
omp.on("session_switch", () => {
wakeOpened = false;
captainJoined = false;
});
omp.on("message_start", (event) => {
const message = event.message;
const attribution = "attribution" in message ? message.attribution : undefined;
const customType = "customType" in message ? message.customType : undefined;
if (message.role === "user" || attribution === "user") {
captainJoined = true;
} else if (customType === "firstmate-watcher-wake") {
wakeOpened = true;
} else if (customType === "fm-branch-merge" && "content" in message &&
!userMessageText(message.content).startsWith(MERGE_NOTE_BOAT)) {
captainJoined = true;
}
});
omp.on("assistant_message", (event) => {
const { content, stopReason } = event.message;
if (stopReason !== "stop" || content.some((block) => block.type === "toolCall")) return undefined;
const spoken = content.flatMap((block, index) => block.type === "text" && block.text.trim() ? [index] : []);
if (spoken.length !== 1) return undefined;
const target = content[spoken[0]];
const text = target.type === "text" ? target.text.trim() : "";
let replacement: string;
if (captainJoined && DOT_ONLY.test(text)) {
replacement = "";
} else if (wakeOpened && !captainJoined && NO_CHANGE_LINE.test(text)) {
replacement = SILENT_WAKE_MARKER;
} else {
return undefined;
}
return {
content: content.map((block, index) =>
index === spoken[0] && block.type === "text" ? { ...block, text: replacement } : block
),
};
});
omp.on("message_end", (event) => {
const message = event.message;
if (message.role !== "assistant") return;
if (message.stopReason !== "aborted" &&
!(message.stopReason === "stop" &&
message.content.some((block) => block.type === "text" && block.text.trim()))) return;
wakeOpened = false;
captainJoined = false;
});
}

export default function (omp: ExtensionAPI) {
if (!primaryIntegrationApplies()) return;
publishNativeProcessIdentity();
const taskInboxDoorbell = installTaskInboxDoorbell(omp, { observeTurns: false, deferUntilTurnStart: true });
installSilentWakeTurns(omp);
let pendingStartupNudge = "";

// Hidden next-turn delivery with triggerTurn. OMP schedules an internal
Expand Down Expand Up @@ -246,24 +343,6 @@ export default function (omp: ExtensionAPI) {
);
};

// The text OMP carries in a user message_start: sendMessage wraps a string
// as one text part, so the joined text parts equal the sent content.
function userMessageText(content: unknown): string {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
const parts: string[] = [];
for (const part of content) {
if (
typeof part === "object" && part !== null &&
(part as { type?: unknown }).type === "text" &&
typeof (part as { text?: unknown }).text === "string"
) {
parts.push((part as { text: string }).text);
}
}
return parts.join("\n");
}

// Durable rows are the whole persistence: only fm-wake-drain acknowledgement
// removes them, so an interruption leaves the next session event able to
// re-notify. At most one notification is sent per session event, and the
Expand Down
5 changes: 5 additions & 0 deletions .omp/extensions/lib/fm-branch-dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ import { runCommandAsync } from "./fm-async-exec.ts";

export const FM_BRANCH_DISPATCH_EVENT = "fm-branch-supervision:dispatch";

// Prefix of every routine fm-branch-merge note. A merge without it is a
// captain-facing delivery whose follow-up main turn is itself the
// captain-visible outcome (docs/omp-supervision-branch.md).
export const MERGE_NOTE_BOAT = "⛵";

export type UnreadWakeScopeStatus = "safe" | "empty" | "unsafe";

export interface UnreadWakeScope {
Expand Down
2 changes: 2 additions & 0 deletions docs/supervision-protocols/omp.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ When this session owns supervision and away mode is not active:
12. Missing, failed, or unhealthy cycle only: drain queued notifications, inspect the failure, call `fm_watch_arm_omp`, and restart with the explicit `-e` fallback if the integration is missing or stale.
13. Never use shell `&` for watcher supervision.
14. While the supervision branch is active, MAIN must claim the reserved backlog lease before every `tasks-axi` or direct `data/backlog.md` mutation, then release it after the mutation: `bin/fm-lease.sh claim backlog`, mutate, `bin/fm-lease.sh release backlog`.
15. A turn opened only by a watcher wake that has nothing captain-facing ends with exactly `…` (one ellipsis character) as its whole final message, never a no-change line; OMP shows nothing for it and, in the default `mechanical` recovery mode, does not re-prompt a final message that has text.
Never use it in place of a real outcome, decision, PR-ready report, or failure; on a turn where the captain wrote, the primary extension discards it so OMP asks for a visible answer.

For a persistent secondmate, the watcher in that secondmate home touches `state/.last-watcher-beat` at the start of every cycle.
The parent watcher treats a fresh, non-future secondmate-home beacon as positive liveness evidence when the secondmate is neither paused nor captain-held and its pane is idle between child polls, reading remote beacon age through a short bounded call with a forced-kill grace on the configured host route.
Expand Down
28 changes: 28 additions & 0 deletions docs/verification/runtime-backends.md
Original file line number Diff line number Diff line change
Expand Up @@ -739,6 +739,34 @@ The phase fires a watcher close while a captain turn runs `sleep 20`; in the dia
The draft-preservation failure reproduces identically at base `2ec2c819` on OMP 18.10.1: the composer capture reads the typed draft with one extra trailing character, so that assertion needs its own fix.
With only the two composer-draft assertions removed, the same run also passed `ok - OMP omp/18.10.1 primary E2E proved an idle session runs the watcher wake turn itself with an intact editable draft`.

#### OMP silent wake turns

In `@oh-my-pi/pi-coding-agent` 18.10.1, `features.unexpectedStopDetection` is `none|mechanical|smart`, defaulting to `mechanical` (`src/session/settings.ts:1052-1056`).
The recovery handler (`src/session/turn-recovery.ts:1108-1195`, invoked at `src/session/agent-session.ts:4381-4385`) retries a final `stop` with no tool call, no visible text, and a thinking block with non-empty thinking and signature (`src/session/unexpected-stop-classifier.ts:42-61`) in both mechanical and smart modes (`src/session/turn-recovery.ts:1127-1135`), up to 3 times (`:93`).
Signed thinking counts as actionable content (`src/session/messages.ts:544,568`), bypassing the empty-stop path.
It never retries a final message that has visible text in mechanical mode (`src/session/turn-recovery.ts:1136-1138`), and its chat view renders text made only of dots, ellipses, or blank space as nothing in every streamed frame (`pi-tui` `canonicalizeMessage`).
Smart mode's judge on `…` is unproven.
There is no supported per-turn silent stop in this version: the recovery setting is session-wide and not extension-settable, `acceptTerminalEmptyStop` is internal and handles empty stops rather than signed thinking-only stops, and `yield` is the subagent terminal protocol.
Rewriting text to empty through `assistant_message` restores the retried thinking-only shape.
The silent wake-turn marker `…` and the primary extension's backstop and captain guard (owner: `installSilentWakeTurns` in `.omp/extensions/fm-primary-omp.ts`; agent rule: [OMP supervision protocol](../supervision-protocols/omp.md)) rely on both behaviors.
On 2026-10-20 the opt-in real-SDK regression ran under Bun 1.4.3 against `@oh-my-pi/pi-coding-agent` 18.10.1, the same version as the installed `omp` binary, installed into a scratch directory with `bun add @oh-my-pi/pi-coding-agent@18.10.1`:

```sh
FM_OMP_PACKAGE_DIR=<scratch>/node_modules/@oh-my-pi/pi-coding-agent bash tests/fm-omp-primary.test.sh
```

```text
ok - OMP primary silences only marker or no-change finals of wake-only episodes and re-asks silent captain turns
ok - real OMP: silent wake turns draw no retry and render nothing in any streamed frame, captain turns and real outcomes stay visible
```

The real-SDK phase streams each reply in delayed chunks and renders every streamed frame through OMP's assistant message component.
It first asserts that a thinking-only wake turn is re-prompted, so it fails rather than passing vacuously once OMP stops retrying such stops.
Without `FM_OMP_PACKAGE_DIR` it prints a skip line and only the portable episode-rule phase runs.
The portable phase also covers episode reset on assistant abort and session replacement, and episode retention on errors for retries.
OMP versions before 18.4.4 lack the `assistant_message` hook, so the marker remains usable but the backstop and captain guard are unavailable.
The backstop can briefly stream a no-change line before rewriting the settled message; only the marker path is proven invisible in every streamed frame.

## Herdr

The compatibility floor is protocol 14.
Expand Down
Loading
Loading