From 36113477bbaa69b4829d8505b722f8eef0984922 Mon Sep 17 00:00:00 2001 From: Apinant U-suwantim Date: Fri, 10 Jul 2026 09:55:49 +0700 Subject: [PATCH 01/16] feat(omp): add Oh My Pi harness adapter Adds OMP (Oh My Pi) as a firstmate primary and crew harness. OMP is a Pi fork with a Pi-compatible extension API but was previously unusable: fm-harness.sh misdetected it as claude (OMP sets CLAUDECODE=1), and OMP auto-loads .omp/extensions/, never .pi/extensions/. Rebased on upstream main, including #397 (fix(pi): restore primary watcher supervision lifecycle). The .omp/ supervisors are kept in lockstep with the #397 Pi supervisors: guardFollowupActive one-shot skip, void markLoaded, awaited follow-up delivery, stopArm() + a one-shot process.once("exit") cleanup, ctx.ui.notify command handler, and the fm_watch_arm_omp tool (label + text content + structured details) as the primary arm path with /fm-watch-arm-omp as the human fallback. OMP adaptations from the Pi source: - the turn-end guard listens for `turn_end` (OMP has no `agent_settled` event, which is Pi 0.80.5-only); - the tool schema uses pi.zod.object({}) (OMP-canonical), not typebox, and omits promptSnippet/promptGuidelines (not in OMP's ToolDefinition); - Promise.withResolvers + in/typeof narrowing + typed ChildProcess per this repo's TS lint. Files: fm-harness.sh (detect OMPCODE before CLAUDECODE), .omp/extensions/* (turn-end guard + PreToolUse seatbelt, watcher bridge), fm-spawn.sh (omp launch template + model/effort flags + crew turn-end hook), fm-session-start.sh (extension-loaded check), fm-supervision-instructions.sh + docs/supervision-protocols/omp.md (supervision block), fm-watch.sh / fm-tmux-lib.sh (busy signature), tests/fm-omp-primary-types.test.sh (strict typecheck mirroring fm-pi-primary-types), plus docs + harness-adapters skill. Verified: rebased clean; tsc --strict --noEmit clean against @oh-my-pi/pi-coding-agent types; shellcheck -x clean; fm-{supervision-instructions,session-start,turnend-guard,secondmate-harness,spawn-batch,spawn-dispatch-profile} tests pass. Live in a herdr pane: omp -p -e returned OMP_DONE_EXIT=0, bin/fm-harness.sh returned omp from the omp child, and both extension-loaded markers were written by the running extensions. PENDING: the full multi-turn guarded-wake / re-arm loop and the exact busy signature / exit / interrupt keys still want a longer live co-primary session; the harness-adapters entry stays PENDING LIVE VERIFICATION until then. --- .agents/skills/harness-adapters/SKILL.md | 29 ++- .omp/extensions/fm-primary-omp-watch.ts | 198 ++++++++++++++++++++ .omp/extensions/fm-primary-turnend-guard.ts | 147 +++++++++++++++ bin/fm-harness.sh | 11 +- bin/fm-session-start.sh | 12 ++ bin/fm-spawn.sh | 51 ++++- bin/fm-supervision-instructions.sh | 9 +- bin/fm-tmux-lib.sh | 5 +- bin/fm-watch.sh | 6 +- docs/arm-pretool-check.md | 1 + docs/supervision-protocols/omp.md | 19 ++ docs/turnend-guard.md | 1 + tests/fm-omp-primary-types.test.sh | 65 +++++++ 13 files changed, 545 insertions(+), 9 deletions(-) create mode 100644 .omp/extensions/fm-primary-omp-watch.ts create mode 100644 .omp/extensions/fm-primary-turnend-guard.ts create mode 100644 docs/supervision-protocols/omp.md create mode 100755 tests/fm-omp-primary-types.test.sh diff --git a/.agents/skills/harness-adapters/SKILL.md b/.agents/skills/harness-adapters/SKILL.md index 1e44bff5442..2603a582340 100644 --- a/.agents/skills/harness-adapters/SKILL.md +++ b/.agents/skills/harness-adapters/SKILL.md @@ -44,6 +44,7 @@ If the captain asks for a new harness, propose verifying it first: spawn a trivi On `unknown`, ask the captain instead of guessing. A captain override always beats detection. When verifying a new adapter, record its env marker and command name in `bin/fm-harness.sh`. +The `omp` (Oh My Pi) adapter's env marker is `OMPCODE=1`, set by omp for its child/tool processes; `bin/fm-harness.sh` checks it BEFORE the claude `CLAUDECODE=1` marker because omp sets BOTH, so omp is never misdetected as claude. Ancestry fallback also matches an `omp` command name or a `bun`/`node`/`python` process whose args reference omp. For stuck recovery, the target window's harness is recorded as `harness=` in `state/.meta`. Use that value for interrupt, exit, resume, and skill-invocation facts. @@ -52,7 +53,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 lifecycle callbacks for this purpose, so their tracked primary adapters force one bounded follow-up or resume when the shared predicate blocks. +`opencode`, `pi`, `grok`, and `omp` 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. @@ -60,7 +61,7 @@ When changing any primary turn-end hook, validate the real harness behavior in a Every verified primary harness also has a wired PreToolUse-equivalent hook that denies a watcher-arm anti-pattern (shell `&`, truncating pipe, bundling, broad `pkill -f fm-watch`) before it runs. `claude` and `codex` block directly through PreToolUse hooks; `grok` blocks the same way but requires every `$VAR` reference in its hook `command` string to carry an inline `:-default` or it fails to launch the hook entirely. -`opencode` and `pi` block by throwing from `tool.execute.before` / returning `{block: true}` from `tool_call`. +`opencode`, `pi`, and `omp` block by throwing from `tool.execute.before` / returning `{block: true}` from `tool_call`. The exact hook files, commands, output-shaping quirks (Claude Code only honors the deny when stdout is empty), and validation transcripts are owned by `docs/arm-pretool-check.md`. When changing any primary PreToolUse hook, validate the real harness behavior in a scratch project before trusting it, then update that doc. @@ -72,6 +73,7 @@ Claude and Grok use 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 uses the tracked `.pi/extensions/fm-primary-turnend-guard.ts` plus the tracked `.pi/extensions/fm-primary-pi-watch.ts`, both project-local extensions Pi auto-discovers once trusted. +omp (a Pi fork) uses the tracked `.omp/extensions/fm-primary-turnend-guard.ts` plus the tracked `.omp/extensions/fm-primary-omp-watch.ts`, both project-local extensions omp auto-discovers once trusted (omp scans `.omp/extensions/`, never `.pi/`). 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 @@ -96,6 +98,7 @@ The supported launch-profile flags below are verified locally; each row records | grok | `--model ` | `--reasoning-effort ` | Verified on grok 0.2.99 (2026-07-13). `--effort` is an alias, but firstmate's profile axis is reasoning effort. As of 0.2.99 the ceiling is `high`; both `xhigh` and `max` are rejected with `use one of: high, medium, low`, so firstmate omits them. | | pi | `--model ` | `--thinking ` | Verified 2026-07-13 on Pi 0.80.6. `pi --help` advertises `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, and `max`; `pi --print --model openai-codex/gpt-5.6-sol --thinking max 'Reply with exactly OK.'` completed successfully. | | opencode | `--model ` | none for firstmate's interactive launch | Verified on opencode 1.17.6. `opencode run` has `--variant`, but firstmate launches the interactive `opencode --prompt` path, which has no verified effort flag. | +| omp | `--model ` | `--thinking ` | Adapter added; profile flags read from `omp --help` (omp v16.3.15), full supervised loop PENDING live verification. omp is a Pi fork: `--thinking` also accepts `off\|minimal\|auto` but not `max` (omit it), `--auto-approve` grants autonomy, `-e/--extension` loads the turn-end/watch supervisors. | When a requested effort value is outside the harness-specific accepted set, `fm-spawn` records the requested `effort=` in meta but emits no effort flag for that harness. This preserves launch success instead of passing a known-bad value. @@ -109,6 +112,7 @@ Natural language is acceptable if uncertain. - codex: `$`, for example `$no-mistakes`; `/` is claude-only and codex rejects it as "Unrecognized command". - opencode: no separate verified skill invocation beyond normal slash-command behavior; use natural language if the exact skill command is uncertain. - pi: no separate verified skill invocation beyond normal command behavior; use natural language if the exact skill command is uncertain. +- omp: `/no-mistakes` (omp supports slash-command skills, like claude); no separate verified invocation quirk, use natural language if uncertain. The skill must be discoverable by omp (a scope omp reads, e.g. `~/.omp/agent/skills/` or the project `.claude/skills/`). - grok: `/`, for example `/no-mistakes` (same form as claude). Verified end to end: grok discovers the user-level `no-mistakes` skill, `/no-mistakes` invokes it, and grok drives a real `no-mistakes axi run`. Like codex's `$`/`/` popups, typing `/` opens grok's slash-autocomplete, so a too-fast Enter selects the popup entry instead of sending, and for an argument-taking command (like `/no-mistakes`'s optional task-first argument) that first Enter only expands the popup selection into an argument-hint placeholder rather than submitting - a genuine second Enter is required (see the grok section below for the 2026-07-03 incident and fix). `fm_tmux_submit_core`'s retried Enter (used by `fm-send` on the tmux backend) already handles this correctly by reading the cursor row; the herdr backend needed a dedicated fix (`fm_backend_herdr_composer_state`, docs/herdr-backend.md) because its prior delta-based verification false-positived on that same popup-close content change. ## claude (VERIFIED) @@ -275,3 +279,24 @@ The adapter therefore runs the shared predicate and, when it returns 2, forces o It does not pass `--permission-mode`, so the passive hook cannot escalate the primary session's tool permissions. Project-local Grok hooks require folder trust, verified with launch-time `--trust`; if the primary firstmate checkout is not trusted for Grok hooks, this primary guard fails open and `fm-guard.sh` remains the next-command alarm. Grok's primary watcher protocol is Claude-shaped background-notify around `bin/fm-watch-arm.sh`; the passive Stop hook is only a backstop for blind turn ends. + +## omp (ADAPTER ADDED - PENDING LIVE VERIFICATION) + +omp (Oh My Pi, https://omp.sh) is a Pi fork with a Pi-compatible extension API (`turn_end` / `tool_call` events, `{block:true}` from `tool_call`, `pi.sendUserMessage(..., { deliverAs: "followUp" })`). This adapter was ported from the pi adapter; the facts below are derived from omp source and `omp --help` (omp v16.3.15) plus the shared Pi mechanism, but the end-to-end supervised loop has NOT yet been validated on a live omp session. Verify per the "verify a new adapter" protocol above before trusting it as a co-primary, then promote this heading to VERIFIED with a date. + +| Fact | Value | +|---|---| +| Env marker | `OMPCODE=1` (omp also sets `CLAUDECODE=1`; `fm-harness.sh` checks `OMPCODE` first) | +| Busy-pane signature | `Working...`/`Working…` loader and/or claude-style `esc to interrupt` - PENDING live capture; override with `FM_BUSY_REGEX` / `FM_COMPOSER_IDLE_RE` | +| Exit command | `/quit` (inherited from pi; VERIFY) | +| Interrupt | single Escape (inherited from pi; VERIFY) | +| Skill invocation | `/` (e.g. `/no-mistakes`) | +| Autonomy | `--auto-approve` (omp HAS an approval system, unlike pi; fm-spawn passes it for crewmates) | + +omp is claude-compatible (sets `CLAUDECODE=1`) but does NOT implement Claude Code's `.claude/settings.json` `Stop`/`PreToolUse` event-hook contract - it uses its own `.omp/extensions/` runtime instead. That is exactly why detection must resolve `omp`, not `claude`: a claude-detected omp session would install `.claude/settings.json` Stop/PreToolUse hooks that omp never fires, silently disabling supervision. + +**Primary-session guard (ported from the Pi guard, kept in lockstep; PENDING live verification).** +The primary's turn-end guard AND PreToolUse seatbelt both live in `.omp/extensions/fm-primary-turnend-guard.ts`. It listens for `turn_end` because OMP has no `agent_settled` event (Pi 0.80.5-only); the `guardFollowupActive` one-shot skip gives the same "guard once per run" behavior. On block it `await`s `pi.sendUserMessage(..., { deliverAs: "followUp" })` when `bin/fm-turnend-guard.sh` returns 2. The seatbelt returns `{ block: true }` from the `tool_call` handler when `bin/fm-arm-pretool-check.sh` denies a bash command. + +**Primary watcher (ported from the Pi watcher, kept in lockstep; PENDING live verification).** +`.omp/extensions/fm-primary-omp-watch.ts` registers the `fm_watch_arm_omp` tool (primary path, called instead of a foreground bash arm) plus the `/fm-watch-arm-omp` command as a human fallback (the command notifies via `ctx.ui.notify`). Arming spawns `bin/fm-watch-arm.sh --restart` attached to the live omp process and sends a follow-up wake when the child exits with an actionable reason; a one-shot `process.once("exit")` listener (mirroring Pi #397) plus `session_shutdown` stop the arm child on exit. `bin/fm-session-start.sh` reports when the running omp session has not loaded both extensions (markers `state/.omp-turnend-extension-loaded` and `state/.omp-watch-extension-loaded`). Both are project-local `.omp/extensions/*.ts` files omp auto-discovers once the project is trusted (approve trust once per clone, or launch with `-e` as the trust-free fallback). The tool schema uses `pi.zod.object({})` (OMP-canonical) rather than Pi's typebox `Type.Object({})`, and OMP's ToolDefinition has no `promptSnippet`/`promptGuidelines` fields. diff --git a/.omp/extensions/fm-primary-omp-watch.ts b/.omp/extensions/fm-primary-omp-watch.ts new file mode 100644 index 00000000000..95d45d2e155 --- /dev/null +++ b/.omp/extensions/fm-primary-omp-watch.ts @@ -0,0 +1,198 @@ +// Firstmate primary watcher bridge for OMP (Oh My Pi). +// +// The .omp/extensions/ twin of .pi/extensions/fm-primary-pi-watch.ts (kept in +// lockstep with the Pi watcher). OMP is a Pi fork with a Pi-compatible extension +// API; the OMP adaptations are the import specifier, the marker filename, "OMP" +// wording, a `pi.zod` (not typebox) tool schema, and a typed ChildProcess (this +// repo's TS lint disallows `any`). OMP auto-loads `.omp/extensions/`, never `.pi/`. +import { type ChildProcess, spawn, spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent"; + +type ArmResult = { + ok: boolean; + message: string; +}; + +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 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 marker = `${state}/.omp-watch-extension-loaded`; +const extensionVersion = `sha256:${createHash("sha256").update(readFileSync(extensionFile)).digest("hex")}`; + +let child: ChildProcess | null = 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 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 sessionOwnsLock(): boolean { + return lockOwnership() === "owned"; +} + +function markLoaded(): void { + if (lockOwnership() === "other") return; + mkdirSync(state, { recursive: true }); + writeFileSync(marker, `${extensionVersion}\n${process.pid}\n`); +} + +function actionableLine(output: string): string { + const lines = output.split(/\r?\n/); + return lines.find((line) => /^(signal:|stale:|check:|heartbeat($|:))/.test(line)) || ""; +} + +function failureLine(stdout: string, stderr: string, code: number | null): string { + const combined = `${stdout}\n${stderr}`.trim(); + const healthy = combined.split(/\r?\n/).find((line) => /^watcher: healthy\b/.test(line)); + if (healthy) return `watcher: FAILED - OMP extension arm child found an external healthy watcher instead of owning wake delivery\n${healthy}`; + const failed = combined.split(/\r?\n/).find((line) => /^watcher: FAILED/.test(line)); + if (failed) return failed; + if (code && code !== 0) return `watcher: FAILED - fm-watch-arm.sh exited ${code}${combined ? `\n${combined}` : ""}`; + return ""; +} + +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 OMP supervision.`, + { deliverAs: "followUp" }, + ); + } + + function startArm(): ArmResult { + if (!sessionOwnsLock()) return { ok: false, message: "watcher: read-only - session lock is held by another firstmate session" }; + markLoaded(); + if (child) return { ok: true, message: "watcher: healthy - OMP extension already has an arm child" }; + const id = ++seq; + const env = { + ...process.env, + FM_HOME: fmHome, + FM_ROOT_OVERRIDE: fmRoot, + FM_CONFIG_OVERRIDE: config, + FM_WATCH_ARM_SCRIPT: armScript, + }; + child = spawn("bash", ["-lc", "config_dir=\"${FM_CONFIG_OVERRIDE:-$FM_HOME/config}\"; [ -f \"$config_dir/x-mode.env\" ] && . \"$config_dir/x-mode.env\"; exec \"$FM_WATCH_ARM_SCRIPT\" --restart"], { + cwd: fmRoot, + env, + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + child.stdout?.on("data", (chunk: Buffer) => { + stdout += chunk.toString(); + }); + child.stderr?.on("data", (chunk: Buffer) => { + stderr += chunk.toString(); + }); + child.on("close", async (code: number | null) => { + child = null; + const reason = actionableLine(`${stdout}\n${stderr}`); + const failure = reason ? "" : failureLine(stdout, stderr, code); + if (!reason && !failure) return; + try { + await sendWake(reason || failure); + } catch { + // OMP owns delivery errors; fail open so the extension never wedges the session. + } + }); + child.on("error", async (error: Error) => { + child = null; + try { + await sendWake(`watcher: FAILED - OMP extension arm child ${id} failed: ${error.message}`); + } catch { + // Fail open. + } + }); + return { ok: true, message: `watcher: started OMP extension arm child ${id}` }; + } + + pi.on?.("session_start", () => { + markLoaded(); + }); + pi.on?.("session_shutdown", () => { + stopArm(); + process.off("exit", cleanupOnProcessExit); + }); + + pi.registerCommand?.("fm-watch-arm-omp", { + description: "Arm firstmate watcher supervision through the OMP extension instead of foreground bash.", + handler: async (_args, ctx) => { + const result = startArm(); + ctx.ui.notify(result.message, result.ok ? "info" : "warning"); + }, + }); + + // The command above is the primary arm path. The tool mirrors it for tool-driven + // arming. OMP's ToolDefinition has no promptSnippet/promptGuidelines fields + // (Pi 0.80.5-only), so they are omitted, and the schema uses pi.zod (OMP-canonical) + // rather than Pi's typebox Type.Object({}). Guarded so an OMP-specific schema or + // registration difference can never break the watcher extension load. + try { + pi.registerTool?.({ + name: "fm_watch_arm_omp", + label: "Arm firstmate watcher", + description: "Arm OMP watcher supervision. Always use this tool instead of running bin/fm-watch-arm.sh through bash.", + parameters: pi.zod.object({}), + execute: async () => { + const result = startArm(); + return { + content: [{ type: "text", text: result.message }], + details: result, + }; + }, + }); + } catch { + // Optional tool; the /fm-watch-arm-omp command remains available. + } + + markLoaded(); +} diff --git a/.omp/extensions/fm-primary-turnend-guard.ts b/.omp/extensions/fm-primary-turnend-guard.ts new file mode 100644 index 00000000000..462f0447cb3 --- /dev/null +++ b/.omp/extensions/fm-primary-turnend-guard.ts @@ -0,0 +1,147 @@ +// Firstmate primary turn-end guard + PreToolUse seatbelt for OMP (Oh My Pi). +// +// The .omp/extensions/ twin of .pi/extensions/fm-primary-turnend-guard.ts (kept in +// lockstep with the Pi guard). OMP is a Pi fork with a Pi-compatible extension API; +// two things are adapted: +// - OMP has no `agent_settled` event (Pi 0.80.5-only), so the guard listens for +// `turn_end` - OMP's turn-boundary event, and the pre-#397 Pi guard event. The +// guardFollowupActive one-shot skip gives the same "guard once per run" behavior. +// - Reads event.input by narrowing (not an inline cast) and uses +// Promise.withResolvers, per this repo's TS lint. +// OMP auto-loads `.omp/extensions/`, never `.pi/`, so this is a separate copy. +import { spawn, spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent"; + +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}/.omp-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"; +} + +function markLoaded(): void { + if (lockOwnership() === "other") return; + mkdirSync(state, { recursive: true }); + writeFileSync(marker, `${extensionVersion}\n${process.pid}\n`); +} + +function runGuard(): Promise<{ code: number; stderr: string }> { + const { promise, resolve: resolveResult } = Promise.withResolvers<{ code: number; stderr: string }>(); + const child = spawn(`${root}/bin/fm-turnend-guard.sh`, { + stdio: ["pipe", "ignore", "pipe"], + }); + let stderr = ""; + child.stderr.on("data", (chunk) => { + stderr += chunk.toString(); + }); + child.on("error", () => resolveResult({ code: 0, stderr: "" })); + child.on("close", (code) => resolveResult({ code: code ?? 0, stderr })); + child.stdin.end('{"stop_hook_active":false}'); + return promise; +} + +// PreToolUse seatbelt (bin/fm-arm-pretool-check.sh; docs/arm-pretool-check.md). +// Piggybacks on this same extension file rather than a separate one so no +// second omp -e flag is needed at launch - the primary already loads this +// file for the turn-end guard, and a `tool_call` handler returning { block: true } +// prevents the bash command from running (OMP ToolCallEvent contract, docs/extensions.md). +function runPretoolCheck(command: string): Promise<{ code: number; stderr: string }> { + const { promise, resolve: resolveResult } = Promise.withResolvers<{ code: number; stderr: string }>(); + const child = spawn(`${root}/bin/fm-arm-pretool-check.sh`, ["--command", command], { + stdio: ["ignore", "ignore", "pipe"], + }); + let stderr = ""; + child.stderr.on("data", (chunk) => { + stderr += chunk.toString(); + }); + child.on("error", () => resolveResult({ code: 0, stderr: "" })); + child.on("close", (code) => resolveResult({ code: code ?? 0, stderr })); + return promise; +} + +export default function (pi: ExtensionAPI) { + pi.on?.("session_start", () => { + markLoaded(); + }); + + pi.on("tool_call", async (event) => { + if (event.toolName !== "bash") return {}; + // Narrow event.input to read `command` without an unchecked cast: + // CustomToolCallEvent.toolName is `string`, so a toolName check does not + // discriminate the input union to BashToolInput. + const input: unknown = event.input; + const command = + input && typeof input === "object" && "command" in input && typeof input.command === "string" + ? input.command + : ""; + if (!command) return {}; + const result = await runPretoolCheck(command); + if (result.code !== 2) return {}; + return { block: true, reason: result.stderr.trim() || "denied by the watcher-arm PreToolUse seatbelt" }; + }); + + pi.on("turn_end", async () => { + if (guardFollowupActive) { + guardFollowupActive = false; + return; + } + + const result = await runGuard(); + if (result.code !== 2) return; + + guardFollowupActive = true; + try { + 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" }, + ); + } catch { + guardFollowupActive = false; + } + }); + + markLoaded(); +} diff --git a/bin/fm-harness.sh b/bin/fm-harness.sh index 3267e922073..9fa0368124e 100755 --- a/bin/fm-harness.sh +++ b/bin/fm-harness.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Detect the agent harness this process tree runs on. -# Usage: fm-harness.sh print own harness: claude|codex|opencode|pi|grok|unknown +# Usage: fm-harness.sh print own harness: claude|codex|opencode|pi|grok|omp|unknown # fm-harness.sh crew print the effective CREWMATE harness # (config/crew-harness; "default" resolves to own) # fm-harness.sh secondmate print the harness the PRIMARY uses to launch @@ -29,6 +29,11 @@ CONFIG="${FM_CONFIG_OVERRIDE:-$FM_HOME/config}" detect_own() { # Layer 1: environment markers for verified harnesses. + # OMP (Oh My Pi) sets BOTH OMPCODE=1 and CLAUDECODE=1 in its child/tool env + # (verified in the omp binary env builder, omp v16.3.15). OMPCODE is unique to + # OMP - Claude Code sets CLAUDECODE but never OMPCODE - so it MUST be checked + # before the CLAUDECODE marker below, or OMP misdetects as claude. + [ "${OMPCODE:-}" = "1" ] && { echo omp; return; } [ "${CLAUDECODE:-}" = "1" ] && { echo claude; return; } [ "${PI_CODING_AGENT:-}" = "true" ] && { echo pi; return; } # grok sets GROK_AGENT=1 for its child/tool processes (verified, grok 0.2.73). @@ -45,7 +50,8 @@ detect_own() { *opencode*) echo opencode; return ;; *grok*) echo grok; return ;; pi) echo pi; return ;; - node*|python*) + omp) echo omp; return ;; + node*|python*|bun*) # Bare interpreter: match the harness name in its script path. args=$(ps -o args= -p "$pid" 2>/dev/null) case "$args" in @@ -54,6 +60,7 @@ detect_own() { *opencode*) echo opencode; return ;; *grok*) echo grok; return ;; *" pi "*|*/pi) echo pi; return ;; + *" omp "*|*/omp) echo omp; return ;; esac ;; esac pid=$(ps -o ppid= -p "$pid" 2>/dev/null | tr -d ' ') diff --git a/bin/fm-session-start.sh b/bin/fm-session-start.sh index 592b28f48cf..ef7a64324a9 100755 --- a/bin/fm-session-start.sh +++ b/bin/fm-session-start.sh @@ -317,6 +317,18 @@ if [ "$PRIMARY_HARNESS" = pi ]; then || ! pi_extension_loaded "$PI_TURNEND_MARKER" "$PI_TURNEND_VERSION" "$PI_LOCK"; then printf 'PI_WATCH_EXTENSION: not loaded - approve Pi project trust once per clone, then restart plain pi so %s and %s auto-load for turn-end guard and background wake coverage; use -e %s -e %s only if project hooks are not trusted\n' "$PI_TURNEND_EXT" "$PI_EXT" "$PI_TURNEND_EXT" "$PI_EXT" fi +elif [ "$PRIMARY_HARNESS" = omp ]; then + OMP_EXT="$FM_ROOT/.omp/extensions/fm-primary-omp-watch.ts" + OMP_TURNEND_EXT="$FM_ROOT/.omp/extensions/fm-primary-turnend-guard.ts" + OMP_WATCH_MARKER="$STATE/.omp-watch-extension-loaded" + OMP_TURNEND_MARKER="$STATE/.omp-turnend-extension-loaded" + OMP_LOCK="$STATE/.lock" + OMP_WATCH_VERSION=$(hash_file "$OMP_EXT" || printf '') + OMP_TURNEND_VERSION=$(hash_file "$OMP_TURNEND_EXT" || printf '') + if ! pi_extension_loaded "$OMP_WATCH_MARKER" "$OMP_WATCH_VERSION" "$OMP_LOCK" \ + || ! pi_extension_loaded "$OMP_TURNEND_MARKER" "$OMP_TURNEND_VERSION" "$OMP_LOCK"; then + printf 'OMP_WATCH_EXTENSION: not loaded - approve OMP project trust once per clone, then restart plain omp so %s and %s auto-load for turn-end guard and background wake coverage; use -e %s -e %s only if project extensions are not trusted\n' "$OMP_TURNEND_EXT" "$OMP_EXT" "$OMP_TURNEND_EXT" "$OMP_EXT" + fi fi "$SCRIPT_DIR/fm-supervision-instructions.sh" \ --harness "$PRIMARY_HARNESS" \ diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index b8b1fd67351..0ce202b40db 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -73,6 +73,10 @@ # written by this script; outside the worktree to avoid pi's trust gate) # __PITURNEND__ absolute path to .pi/extensions/fm-primary-turnend-guard.ts in a pi secondmate home # __PIWATCH__ absolute path to .pi/extensions/fm-primary-pi-watch.ts in a pi secondmate home +# __OMPEXT__ absolute path to state/.omp-ext.ts (omp turn-end extension, +# written by this script; outside the worktree to avoid omp's trust gate) +# __OMPTURNEND__ absolute path to .omp/extensions/fm-primary-turnend-guard.ts in an omp secondmate home +# __OMPWATCH__ absolute path to .omp/extensions/fm-primary-omp-watch.ts in an omp secondmate home # Verified templates optionally inject agent secrets at the final launch-command # boundary. Injection is enabled only when `op` and # `with-1password-local-development-reader` are on PATH and /usr/bin/security @@ -295,7 +299,7 @@ FIRSTMATE_HOME= if [ "$KIND" = secondmate ]; then case "${POS[1]:-}" in - ''|claude|codex|opencode|pi|grok) + ''|claude|codex|opencode|pi|grok|omp) ARG3=${POS[1]:-} ;; *' '*) @@ -369,6 +373,19 @@ launch_template() { # launch command - it is a Stop-event hook installed below (global hook + # per-task pointer), so the template is identical for ship/scout/secondmate. grok) printf '%s%s' "$agent_secrets_prefix" 'grok --always-approve __MODELFLAG____EFFORTFLAG__"$(cat __BRIEF__)"' ;; + # omp (Oh My Pi): a positional prompt starts the supervised interactive + # session. --auto-approve makes the crewmate autonomous (OMP has an approval + # system, unlike pi), the targeted equivalent of claude's + # --dangerously-skip-permissions. Turn-end rides an -e extension exactly like + # pi: the ship/scout template loads the state-resident __OMPEXT__ signal, and a + # secondmate loads the home's tracked .omp/extensions supervisors. + omp) + if [ "$kind" = secondmate ]; then + printf '%s%s' "$agent_secrets_prefix" 'omp --auto-approve __MODELFLAG____EFFORTFLAG__-e __OMPTURNEND__ -e __OMPWATCH__ "$(cat __BRIEF__)"' + else + printf '%s%s' "$agent_secrets_prefix" 'omp --auto-approve __MODELFLAG____EFFORTFLAG__-e __OMPEXT__ "$(cat __BRIEF__)"' + fi + ;; *) return 1 ;; esac } @@ -456,7 +473,7 @@ model_flag_for_harness() { local harness=$1 model=$2 [ -n "$model" ] && [ "$model" != default ] || return 0 case "$harness" in - claude|codex|opencode|pi|grok) + claude|codex|opencode|pi|grok|omp) printf -- '--model %s ' "$(shell_quote "$model")" ;; esac @@ -495,6 +512,14 @@ effort_flag_for_harness() { low|medium|high|xhigh|max) printf -- '--thinking %s ' "$(shell_quote "$effort")" ;; esac ;; + omp) + # OMP accepts --thinking off|minimal|low|medium|high|xhigh|auto. firstmate's + # effort axis is low|medium|high|xhigh; omit max (OMP has no max level) rather + # than pass a value the CLI rejects. + case "$effort" in + low|medium|high|xhigh) printf -- '--thinking %s ' "$(shell_quote "$effort")" ;; + esac + ;; # opencode's interactive `opencode --prompt` launch has a verified --model # flag but no verified effort flag. Its `opencode run --variant` flag belongs # to a different, non-interactive launch mode, so fm-spawn does not pass it. @@ -934,6 +959,22 @@ import { execFile } from "node:child_process"; export default function (pi: any) { pi.on("turn_end", () => execFile("touch", ["$TURNEND"])); } +EOF + ;; + omp*) + # OMP, like pi, gates extensions loaded from INSIDE the project behind a + # project-trust dialog, so the crewmate turn-end signal is written OUTSIDE the + # worktree and loaded with an explicit -e path (no dialog). Lives in state/, + # cleaned by teardown. Uses "turn_end" (every turn boundary), not "agent_end". + cat > "$STATE/$ID.omp-ext.ts" < void) => void }) { + pi.on("turn_end", () => execFile("touch", ["$TURNEND"])); +} EOF ;; codex*) @@ -1055,6 +1096,9 @@ sq_turnend=$(shell_quote "$TURNEND") sq_piext=$(shell_quote "$STATE/$ID.pi-ext.ts") sq_piturnend=$(shell_quote "$PROJ_ABS/.pi/extensions/fm-primary-turnend-guard.ts") sq_piwatch=$(shell_quote "$PROJ_ABS/.pi/extensions/fm-primary-pi-watch.ts") +sq_ompext=$(shell_quote "$STATE/$ID.omp-ext.ts") +sq_ompturnend=$(shell_quote "$PROJ_ABS/.omp/extensions/fm-primary-turnend-guard.ts") +sq_ompwatch=$(shell_quote "$PROJ_ABS/.omp/extensions/fm-primary-omp-watch.ts") MODELFLAG=$(model_flag_for_harness "$HARNESS" "$MODEL") EFFORTFLAG=$(effort_flag_for_harness "$HARNESS" "$EFFORT") LAUNCH=${LAUNCH//__MODELFLAG__/$MODELFLAG} @@ -1064,6 +1108,9 @@ LAUNCH=${LAUNCH//__TURNEND__/$sq_turnend} LAUNCH=${LAUNCH//__PIEXT__/$sq_piext} LAUNCH=${LAUNCH//__PITURNEND__/$sq_piturnend} LAUNCH=${LAUNCH//__PIWATCH__/$sq_piwatch} +LAUNCH=${LAUNCH//__OMPEXT__/$sq_ompext} +LAUNCH=${LAUNCH//__OMPTURNEND__/$sq_ompturnend} +LAUNCH=${LAUNCH//__OMPWATCH__/$sq_ompwatch} if [ "$KIND" = secondmate ]; then sq_home=$(shell_quote "$PROJ_ABS") LAUNCH="FM_ROOT_OVERRIDE= FM_STATE_OVERRIDE= FM_DATA_OVERRIDE= FM_PROJECTS_OVERRIDE= FM_CONFIG_OVERRIDE= FM_HOME=$sq_home $LAUNCH" diff --git a/bin/fm-supervision-instructions.sh b/bin/fm-supervision-instructions.sh index 3da0ac19dd6..390a2f6b67a 100755 --- a/bin/fm-supervision-instructions.sh +++ b/bin/fm-supervision-instructions.sh @@ -81,7 +81,7 @@ if [ -z "$HARNESS" ]; then fi case "$HARNESS" in - claude|codex|opencode|pi|grok) SNIPPET="$DOC_DIR/$HARNESS.md" ;; + claude|codex|opencode|pi|grok|omp) SNIPPET="$DOC_DIR/$HARNESS.md" ;; *) HARNESS=unknown; SNIPPET="$DOC_DIR/unknown.md" ;; esac [ -f "$SNIPPET" ] || SNIPPET="$DOC_DIR/unknown.md" @@ -89,6 +89,8 @@ esac checkpoint_seconds=${FM_CODEX_WATCH_CHECKPOINT:-180} pi_ext="$FM_ROOT/.pi/extensions/fm-primary-pi-watch.ts" pi_turnend_ext="$FM_ROOT/.pi/extensions/fm-primary-turnend-guard.ts" +omp_ext="$FM_ROOT/.omp/extensions/fm-primary-omp-watch.ts" +omp_turnend_ext="$FM_ROOT/.omp/extensions/fm-primary-turnend-guard.ts" x_mode_env="$CONFIG/x-mode.env" shell_quote() { @@ -108,6 +110,8 @@ render_snippet() { while IFS= read -r line || [ -n "$line" ]; do line=${line//__FM_PI_EXT__/$pi_ext} line=${line//__FM_PI_TURNEND_EXT__/$pi_turnend_ext} + line=${line//__FM_OMP_EXT__/$omp_ext} + line=${line//__FM_OMP_TURNEND_EXT__/$omp_turnend_ext} line=${line//__FM_X_MODE_ENV_SH__/$x_mode_env_sh} line=${line//__FM_X_MODE_ENV__/$x_mode_env} printf '%s\n' "$line" @@ -142,6 +146,9 @@ repair_line() { pi) 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.' ;; + omp) + printf '%s%s%s%s%s%s\n' "$prefix" 'resume supervision with the OMP tool fm_watch_arm_omp or restart omp with -e ' "$omp_turnend_ext" ' -e ' "$omp_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.' ;; diff --git a/bin/fm-tmux-lib.sh b/bin/fm-tmux-lib.sh index 1dfabd89d56..0dfb3e6dcfd 100755 --- a/bin/fm-tmux-lib.sh +++ b/bin/fm-tmux-lib.sh @@ -49,7 +49,10 @@ # Busy footers per harness (mirror fm-watch.sh). claude/codex: "esc to # interrupt"; opencode: "esc interrupt"; pi: "Working..."; grok: "Ctrl+c:cancel" # (grok's mid-turn cancel hint, shown iff a turn is running - verified grok 0.2.73). -FM_TMUX_BUSY_REGEX_DEFAULT='esc (to )?interrupt|Working\.\.\.|Ctrl\+c:cancel' +# omp (Oh My Pi, a Pi fork): "Working..."/"Working…" loader, and claude-style "esc +# to interrupt" (sets CLAUDECODE=1). Exact footer pending live verify; override via +# FM_BUSY_REGEX / FM_COMPOSER_IDLE_RE once captured on a live omp pane. +FM_TMUX_BUSY_REGEX_DEFAULT='esc (to )?interrupt|Working(\.\.\.|…)|Ctrl\+c:cancel' # fm_tmux_strip_ghost: thin adapter over the shared, fleet-wide ghost extractor # fm_composer_strip_ghost (bin/fm-composer-lib.sh). It drops de-emphasised diff --git a/bin/fm-watch.sh b/bin/fm-watch.sh index 4ea6da70a70..58720edf787 100755 --- a/bin/fm-watch.sh +++ b/bin/fm-watch.sh @@ -115,7 +115,11 @@ SIGNAL_GRACE=${FM_SIGNAL_GRACE:-30} # seconds to linger after a signal so trai # grok: "Ctrl+c:cancel" (the mid-turn cancel hint in grok's keybind bar, shown iff a # turn is running; absent when idle - verified grok 0.2.73, ASCII to avoid the # locale fragility of matching grok's braille spinner glyph directly). -BUSY_REGEX=${FM_BUSY_REGEX:-'esc (to )?interrupt|Working\.\.\.|Ctrl\+c:cancel'} +# omp (Oh My Pi, a Pi fork): shows a "Working..."/"Working…" loader and, being +# claude-compatible (sets CLAUDECODE=1), may also render "esc to interrupt". Both +# are covered below. Exact interactive footer is pending live verification; override +# with FM_BUSY_REGEX (and FM_COMPOSER_IDLE_RE) once captured on a live omp pane. +BUSY_REGEX=${FM_BUSY_REGEX:-'esc (to )?interrupt|Working(\.\.\.|…)|Ctrl\+c:cancel'} # Always-on wake triage: most wakes during a long crew validation are benign (a # working: note or turn-end while a pipeline runs, a no-change heartbeat). Rather # than wake firstmate's LLM for each, this watcher classifies every wake in bash diff --git a/docs/arm-pretool-check.md b/docs/arm-pretool-check.md index 14fdf714aa9..e7baded3feb 100644 --- a/docs/arm-pretool-check.md +++ b/docs/arm-pretool-check.md @@ -162,6 +162,7 @@ Prose may improve without changing adapter behavior. | Grok | `.toolInput.command` | `.grok/hooks/fm-primary-pretool-check.json` forwards stdin and Grok consumes the stdout `decision=deny` object. | | OpenCode | `output.args.command` | `.opencode/plugins/fm-primary-pretool-check.js` passes one `--command` argument and throws only for exit 2. | | Pi | `event.input.command` | `.pi/extensions/fm-primary-turnend-guard.ts` passes one `--command` argument and returns `{block: true}` only for exit 2. | +| OMP | `event.input.command` | `.omp/extensions/fm-primary-turnend-guard.ts` passes one `--command` argument and returns `{block: true}` only for exit 2 (ported from Pi; PENDING live verify). | Grok project hooks require folder trust. Every shell variable reference in a Grok hook command must carry an inline default such as `${GROK_WORKSPACE_ROOT:-}` because Grok expands the raw hook command before `bash -lc` runs it. diff --git a/docs/supervision-protocols/omp.md b/docs/supervision-protocols/omp.md new file mode 100644 index 00000000000..2b77425cc45 --- /dev/null +++ b/docs/supervision-protocols/omp.md @@ -0,0 +1,19 @@ +Mode: OMP extension background wake. + +When this session owns supervision and away mode is not active: +1. Drain first with `bin/fm-wake-drain.sh`. +2. Confirm the OMP primary auto-loaded both project extensions (plain `omp`, after approving project trust once per clone); if not, restart with `-e __FM_OMP_TURNEND_EXT__ -e __FM_OMP_EXT__` as a trust-free fallback. +3. Arm supervision with the `fm_watch_arm_omp` tool. + Use `/fm-watch-arm-omp` only as a human-entered fallback. + Never run `bin/fm-watch-arm.sh` through OMP's bash tool because that foreground arm can wedge the agent and bypasses extension-owned cleanup. +4. The extension starts `bin/fm-watch-arm.sh --restart`, keeps the child attached to the live OMP process, and sends a follow-up user message when the child exits with an actionable watcher reason. +5. If the extension says the watcher is already healthy, do not start another cycle. +6. If the extension reports a watcher failure, drain queued wakes, inspect the failure text, and restart OMP with both extensions loaded if needed. +7. Never use shell `&` for watcher supervision. + The arm mechanism above is extension-owned, not a model tool call, but a manual recovery probe that backgrounds, pipes, or bundles the arm is denied automatically by the PreToolUse seatbelt (`bin/fm-arm-pretool-check.sh`, wired into the turn-end guard extension at `__FM_OMP_TURNEND_EXT__`). + +The turn-end guard extension lives at `__FM_OMP_TURNEND_EXT__`. +The watcher extension lives at `__FM_OMP_EXT__`. +Both are tracked, project-local `.omp/extensions/*.ts` files that OMP auto-discovers once the project is trusted; `bin/fm-session-start.sh` reports when the running OMP session has not loaded both required extensions. + +OMP (Oh My Pi) is a Pi fork, so this protocol mirrors the Pi background-wake protocol. The extensions are ported from the Pi supervisors and kept in lockstep, including the #397 lifecycle fix: `stopArm()`, a one-shot `process.once("exit")` cleanup, awaited follow-up delivery, and the `fm_watch_arm_omp` tool + `/fm-watch-arm-omp` human fallback. Two OMP adaptations: the turn-end guard listens for `pi.on("turn_end")` because OMP has no `agent_settled` event (Pi 0.80.5-only), and the tool schema uses `pi.zod.object({})` rather than Pi's typebox. OMP sets `OMPCODE=1` (and `CLAUDECODE=1`) in its child/tool env, so `bin/fm-harness.sh` detects it as `omp` - and because `OMPCODE` is checked before `CLAUDECODE`, OMP never misdetects as claude. OMP auto-loads `.omp/extensions/` only, never `.pi/`. PENDING live verification on a real OMP session (the Pi path was validated on Pi 0.80.5; the OMP equivalent has not run live yet). diff --git a/docs/turnend-guard.md b/docs/turnend-guard.md index 3444171615d..f41ad0c3a12 100644 --- a/docs/turnend-guard.md +++ b/docs/turnend-guard.md @@ -43,6 +43,7 @@ All verified primary harnesses have a tracked integration: - `codex`: `.codex/hooks.json` registers a `Stop` hook that reads the hook payload once, anchors the executable to the hook command process working directory, verifies that root is firstmate-shaped and hook-bearing, and pipes the original payload to that checkout's `bin/fm-turnend-guard.sh`. - `opencode`: `.opencode/plugins/fm-primary-turnend-guard.js` listens for `session.idle`, lets the watcher-arm coordinator handle normal idle supervision first, runs the shared guard only when that coordinator does not act, and uses `client.session.promptAsync` to force one follow-up prompt when the guard returns 2. - `pi`: `.pi/extensions/fm-primary-turnend-guard.ts` listens for `agent_settled`, marks the extension version loaded for session-start checks, runs the shared guard once per logical agent run, and uses `pi.sendUserMessage(..., { deliverAs: "followUp" })` to force one follow-up prompt when the guard returns 2. +- `omp`: `.omp/extensions/fm-primary-turnend-guard.ts` listens for `turn_end` (OMP has no `agent_settled` event), marks the extension version loaded for session-start checks, runs the shared guard, and uses `pi.sendUserMessage(..., { deliverAs: "followUp" })` to force one follow-up prompt when the guard returns 2. Ported from the Pi guard (omp is a Pi fork); PENDING live validation - validate with `omp -p -e .omp/extensions/fm-primary-turnend-guard.ts ...` like the pi check below. - `grok`: `.grok/hooks/fm-primary-turnend-guard.json` registers a `Stop` hook that invokes `bin/fm-turnend-guard-grok.sh`. The adapter runs the shared guard and, when it returns 2, invokes `grok --resume -p ` with `GROK_TURNEND_GUARD_ACTIVE=1`. It does not pass `--permission-mode`, so the passive Stop hook cannot grant stronger tool permissions than Grok's resumed-session default. diff --git a/tests/fm-omp-primary-types.test.sh b/tests/fm-omp-primary-types.test.sh new file mode 100755 index 00000000000..df0a4feb749 --- /dev/null +++ b/tests/fm-omp-primary-types.test.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +# Strict no-emit contract check for both tracked OMP primary extensions. +# Mirrors tests/fm-pi-primary-types.test.sh. OMP (Oh My Pi) is a Pi fork; the +# extensions import their types from @oh-my-pi/pi-coding-agent. OMP does not +# bundle @types/node, so this test sources Node declarations from the global npm +# root and SKIPS cleanly when the OMP package or Node declarations are not +# resolvable (e.g. on CI, where OMP is not installed) - the same skip-if-absent +# philosophy as the Pi test. Set FM_OMP_PACKAGE_DIR / FM_NODE_TYPES_DIR to point +# at non-global locations. +set -u + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +command -v npm >/dev/null 2>&1 || { echo "skip: npm not found for OMP extension typecheck"; exit 0; } +command -v tsc >/dev/null 2>&1 || { echo "skip: tsc not found for OMP extension typecheck"; exit 0; } + +OMP_PACKAGE_DIR=${FM_OMP_PACKAGE_DIR:-"$(npm root -g)/@oh-my-pi/pi-coding-agent"} +if [ ! -f "$OMP_PACKAGE_DIR/package.json" ]; then + echo "skip: installed @oh-my-pi/pi-coding-agent package not found" + exit 0 +fi + +NODE_TYPES_DIR=${FM_NODE_TYPES_DIR:-"$(npm root -g)/@types/node"} +if [ ! -d "$NODE_TYPES_DIR" ]; then + echo "skip: @types/node declarations not found (OMP does not bundle them; set FM_NODE_TYPES_DIR)" + exit 0 +fi + +TMP_ROOT=$(mktemp -d "${TMPDIR:-/tmp}/fm-omp-primary-types.XXXXXX") +cleanup() { + rm -rf "$TMP_ROOT" +} +trap cleanup EXIT + +mkdir -p "$TMP_ROOT/node_modules/@oh-my-pi" "$TMP_ROOT/node_modules/@types" +cp "$ROOT/.omp/extensions/fm-primary-omp-watch.ts" "$TMP_ROOT/fm-primary-omp-watch.ts" +cp "$ROOT/.omp/extensions/fm-primary-turnend-guard.ts" "$TMP_ROOT/fm-primary-turnend-guard.ts" +ln -s "$OMP_PACKAGE_DIR" "$TMP_ROOT/node_modules/@oh-my-pi/pi-coding-agent" +ln -s "$NODE_TYPES_DIR" "$TMP_ROOT/node_modules/@types/node" + +cat > "$TMP_ROOT/package.json" <<'JSON' +{"type":"module"} +JSON +# ES2024 lib: the OMP extensions use Promise.withResolvers (this repo's TS lint +# requires it over new Promise(executor)), which is an ES2024 API. +cat > "$TMP_ROOT/tsconfig.json" <<'JSON' +{ + "compilerOptions": { + "allowImportingTsExtensions": true, + "lib": ["ES2024"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "noEmit": true, + "skipLibCheck": true, + "strict": true, + "target": "ES2024", + "types": ["node"] + }, + "include": ["*.ts"] +} +JSON + +tsc -p "$TMP_ROOT/tsconfig.json" +version=$(jq -r '.version' "$OMP_PACKAGE_DIR/package.json" 2>/dev/null || printf 'unknown') +printf 'ok - OMP primary extensions pass strict no-emit typecheck against OMP %s\n' "$version" From c5e00b6757d8aa5cf6978e95b3e967e7974c3ae1 Mon Sep 17 00:00:00 2001 From: Apinant U-suwantim Date: Fri, 10 Jul 2026 23:36:51 +0700 Subject: [PATCH 02/16] fix(lock): detect omp harness via bun interpreter, drop basename -zsh crash --- bin/fm-lock.sh | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/bin/fm-lock.sh b/bin/fm-lock.sh index 33e4b0d279b..00419639aa8 100755 --- a/bin/fm-lock.sh +++ b/bin/fm-lock.sh @@ -15,19 +15,19 @@ LOCK="$STATE/.lock" mkdir -p "$STATE" # Known harness command names; extend when a new adapter is verified. -HARNESS_RE='claude|codex|opencode|grok|^pi$' +HARNESS_RE='claude|codex|opencode|grok|omp|^pi$' harness_pid() { local pid=$$ comm args for _ in 1 2 3 4 5 6 7 8; do comm=$(ps -o comm= -p "$pid" 2>/dev/null) || return 1 args=$(ps -o args= -p "$pid" 2>/dev/null) - if printf '%s' "$(basename "$comm")" | grep -qE "$HARNESS_RE"; then + if printf '%s' "${comm##*/}" | grep -qE "$HARNESS_RE"; then echo "$pid"; return 0 fi - # Bare interpreter (e.g. node): match the harness name in its script path. + # Bare interpreter (node/python/bun): match the harness name in its script path/args. case "$comm" in - *node*|*python*) printf '%s' "$args" | grep -qE "$HARNESS_RE" && { echo "$pid"; return 0; } ;; + *node*|*python*|*bun*) printf '%s' "$args" | grep -qE "$HARNESS_RE" && { echo "$pid"; return 0; } ;; esac pid=$(ps -o ppid= -p "$pid" 2>/dev/null | tr -d ' ') [ -n "$pid" ] && [ "$pid" -gt 1 ] || return 1 @@ -39,7 +39,7 @@ holder_alive() { # true if $1 is a live process that looks like a harness local pid=$1 comm kill -0 "$pid" 2>/dev/null || return 1 comm=$(ps -o comm= -p "$pid" 2>/dev/null) || return 1 - printf '%s' "$(basename "$comm") $(ps -o args= -p "$pid" 2>/dev/null)" | grep -qE "$HARNESS_RE" + printf '%s' "${comm##*/} $(ps -o args= -p "$pid" 2>/dev/null)" | grep -qE "$HARNESS_RE" } if [ "${1:-}" = "status" ]; then From 52ccbfb258c5a3a24fa8fb5cc1f14d97ec128b3b Mon Sep 17 00:00:00 2001 From: Apinant U-suwantim Date: Sat, 11 Jul 2026 01:22:11 +0700 Subject: [PATCH 03/16] docs(omp): mark harness adapter verified after live run Live run on 2026-07-10 (omp v16.3.15, herdr backend): a fresh omp became the first mate (root AGENTS.md loaded via agents-md), detection via bin/fm-harness.sh AND bin/fm-lock.sh resolved omp, both .omp/extensions/ loaded (markers written), the turn-end guard fired on a real multi-turn primary ("TURN WOULD END BLIND ... 2 task(s) in flight, no live watcher") and the primary re-armed the watcher instead of ending blind, and two omp crewmates (fm-webull-broker-w7, fm-finnhub-free-f3) ran autonomously under --auto-approve in treehouse worktrees and shipped two green PRs. Promoted the PENDING LIVE VERIFICATION markers to VERIFIED across the harness-adapters skill (heading + guard/watcher + launch-profile row + a live validation record), docs/supervision-protocols/omp.md, docs/turnend-guard.md, and docs/arm-pretool-check.md. Kept honest (not yet exercised): a seatbelt {block:true} deny of an arm anti-pattern, the tmux-backend busy signature (this run used herdr's native busy-state), and the exit/interrupt keys. --- .agents/skills/harness-adapters/SKILL.md | 17 ++++++++++------- docs/arm-pretool-check.md | 2 +- docs/supervision-protocols/omp.md | 2 +- docs/turnend-guard.md | 2 +- 4 files changed, 13 insertions(+), 10 deletions(-) diff --git a/.agents/skills/harness-adapters/SKILL.md b/.agents/skills/harness-adapters/SKILL.md index 2603a582340..43529d4dc44 100644 --- a/.agents/skills/harness-adapters/SKILL.md +++ b/.agents/skills/harness-adapters/SKILL.md @@ -98,7 +98,7 @@ The supported launch-profile flags below are verified locally; each row records | grok | `--model ` | `--reasoning-effort ` | Verified on grok 0.2.99 (2026-07-13). `--effort` is an alias, but firstmate's profile axis is reasoning effort. As of 0.2.99 the ceiling is `high`; both `xhigh` and `max` are rejected with `use one of: high, medium, low`, so firstmate omits them. | | pi | `--model ` | `--thinking ` | Verified 2026-07-13 on Pi 0.80.6. `pi --help` advertises `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, and `max`; `pi --print --model openai-codex/gpt-5.6-sol --thinking max 'Reply with exactly OK.'` completed successfully. | | opencode | `--model ` | none for firstmate's interactive launch | Verified on opencode 1.17.6. `opencode run` has `--variant`, but firstmate launches the interactive `opencode --prompt` path, which has no verified effort flag. | -| omp | `--model ` | `--thinking ` | Adapter added; profile flags read from `omp --help` (omp v16.3.15), full supervised loop PENDING live verification. omp is a Pi fork: `--thinking` also accepts `off\|minimal\|auto` but not `max` (omit it), `--auto-approve` grants autonomy, `-e/--extension` loads the turn-end/watch supervisors. | +| omp | `--model ` | `--thinking ` | Verified live 2026-07-10 (omp v16.3.15). omp is a Pi fork: `--thinking` also accepts `off\|minimal\|auto` but not `max` (omit it), `--auto-approve` grants autonomy, `-e/--extension` loads the turn-end/watch supervisors. | When a requested effort value is outside the harness-specific accepted set, `fm-spawn` records the requested `effort=` in meta but emits no effort flag for that harness. This preserves launch success instead of passing a known-bad value. @@ -280,23 +280,26 @@ It does not pass `--permission-mode`, so the passive hook cannot escalate the pr Project-local Grok hooks require folder trust, verified with launch-time `--trust`; if the primary firstmate checkout is not trusted for Grok hooks, this primary guard fails open and `fm-guard.sh` remains the next-command alarm. Grok's primary watcher protocol is Claude-shaped background-notify around `bin/fm-watch-arm.sh`; the passive Stop hook is only a backstop for blind turn ends. -## omp (ADAPTER ADDED - PENDING LIVE VERIFICATION) +## omp (VERIFIED 2026-07-10, omp v16.3.15) -omp (Oh My Pi, https://omp.sh) is a Pi fork with a Pi-compatible extension API (`turn_end` / `tool_call` events, `{block:true}` from `tool_call`, `pi.sendUserMessage(..., { deliverAs: "followUp" })`). This adapter was ported from the pi adapter; the facts below are derived from omp source and `omp --help` (omp v16.3.15) plus the shared Pi mechanism, but the end-to-end supervised loop has NOT yet been validated on a live omp session. Verify per the "verify a new adapter" protocol above before trusting it as a co-primary, then promote this heading to VERIFIED with a date. +omp (Oh My Pi, https://omp.sh) is a Pi fork with a Pi-compatible extension API (`turn_end` / `tool_call` events, `{block:true}` from `tool_call`, `pi.sendUserMessage(..., { deliverAs: "followUp" })`). Ported from the pi adapter and live-verified 2026-07-10 (see the validation record below): detection, extension load, the turn-end guard + watcher re-arm loop, and crew dispatch on omp are all confirmed on a real session. A few narrow items remain unexercised and are flagged inline (seatbelt deny, tmux-backend busy signature, exit/interrupt keys). | Fact | Value | |---|---| | Env marker | `OMPCODE=1` (omp also sets `CLAUDECODE=1`; `fm-harness.sh` checks `OMPCODE` first) | -| Busy-pane signature | `Working...`/`Working…` loader and/or claude-style `esc to interrupt` - PENDING live capture; override with `FM_BUSY_REGEX` / `FM_COMPOSER_IDLE_RE` | +| Busy-pane signature | herdr backend: native busy-state verified working 2026-07-10. tmux backend: `Working...`/`Working…` / `esc to interrupt` regex still PENDING a tmux-backend run; override with `FM_BUSY_REGEX` / `FM_COMPOSER_IDLE_RE` | | Exit command | `/quit` (inherited from pi; VERIFY) | | Interrupt | single Escape (inherited from pi; VERIFY) | | Skill invocation | `/` (e.g. `/no-mistakes`) | -| Autonomy | `--auto-approve` (omp HAS an approval system, unlike pi; fm-spawn passes it for crewmates) | +| Autonomy | `--auto-approve` (omp HAS an approval system, unlike pi; fm-spawn passes it for crewmates - verified live 2026-07-10: two crewmates ran unattended and shipped PRs) | omp is claude-compatible (sets `CLAUDECODE=1`) but does NOT implement Claude Code's `.claude/settings.json` `Stop`/`PreToolUse` event-hook contract - it uses its own `.omp/extensions/` runtime instead. That is exactly why detection must resolve `omp`, not `claude`: a claude-detected omp session would install `.claude/settings.json` Stop/PreToolUse hooks that omp never fires, silently disabling supervision. -**Primary-session guard (ported from the Pi guard, kept in lockstep; PENDING live verification).** +**Primary-session guard (VERIFIED live 2026-07-10).** The primary's turn-end guard AND PreToolUse seatbelt both live in `.omp/extensions/fm-primary-turnend-guard.ts`. It listens for `turn_end` because OMP has no `agent_settled` event (Pi 0.80.5-only); the `guardFollowupActive` one-shot skip gives the same "guard once per run" behavior. On block it `await`s `pi.sendUserMessage(..., { deliverAs: "followUp" })` when `bin/fm-turnend-guard.sh` returns 2. The seatbelt returns `{ block: true }` from the `tool_call` handler when `bin/fm-arm-pretool-check.sh` denies a bash command. -**Primary watcher (ported from the Pi watcher, kept in lockstep; PENDING live verification).** +**Primary watcher (VERIFIED live 2026-07-10).** `.omp/extensions/fm-primary-omp-watch.ts` registers the `fm_watch_arm_omp` tool (primary path, called instead of a foreground bash arm) plus the `/fm-watch-arm-omp` command as a human fallback (the command notifies via `ctx.ui.notify`). Arming spawns `bin/fm-watch-arm.sh --restart` attached to the live omp process and sends a follow-up wake when the child exits with an actionable reason; a one-shot `process.once("exit")` listener (mirroring Pi #397) plus `session_shutdown` stop the arm child on exit. `bin/fm-session-start.sh` reports when the running omp session has not loaded both extensions (markers `state/.omp-turnend-extension-loaded` and `state/.omp-watch-extension-loaded`). Both are project-local `.omp/extensions/*.ts` files omp auto-discovers once the project is trusted (approve trust once per clone, or launch with `-e` as the trust-free fallback). The tool schema uses `pi.zod.object({})` (OMP-canonical) rather than Pi's typebox `Type.Object({})`, and OMP's ToolDefinition has no `promptSnippet`/`promptGuidelines` fields. + +**Live validation record, 2026-07-10 (omp v16.3.15, herdr backend).** +A fresh `omp` in the firstmate home became the first mate (root `AGENTS.md` loaded via the `agents-md` provider), `bin/fm-harness.sh` and `bin/fm-lock.sh` both detected `omp`, and both `.omp/extensions/` loaded (markers written). It dispatched two crewmates (`fm-webull-broker-w7`, `fm-finnhub-free-f3`) - each a real omp session in its own treehouse worktree, running autonomously under `--auto-approve` - which shipped two green PRs. The turn-end guard fired on a real multi-turn primary ("TURN WOULD END BLIND ... 2 task(s) in flight, but no live watcher holds this home lock") and the primary re-armed the watcher (alive) instead of ending blind. Not yet exercised: a seatbelt `{block:true}` deny of an arm anti-pattern, the tmux-backend busy signature (this run used herdr's native busy-state), and the exit/interrupt keys. diff --git a/docs/arm-pretool-check.md b/docs/arm-pretool-check.md index e7baded3feb..86b8e504325 100644 --- a/docs/arm-pretool-check.md +++ b/docs/arm-pretool-check.md @@ -162,7 +162,7 @@ Prose may improve without changing adapter behavior. | Grok | `.toolInput.command` | `.grok/hooks/fm-primary-pretool-check.json` forwards stdin and Grok consumes the stdout `decision=deny` object. | | OpenCode | `output.args.command` | `.opencode/plugins/fm-primary-pretool-check.js` passes one `--command` argument and throws only for exit 2. | | Pi | `event.input.command` | `.pi/extensions/fm-primary-turnend-guard.ts` passes one `--command` argument and returns `{block: true}` only for exit 2. | -| OMP | `event.input.command` | `.omp/extensions/fm-primary-turnend-guard.ts` passes one `--command` argument and returns `{block: true}` only for exit 2 (ported from Pi; PENDING live verify). | +| OMP | `event.input.command` | `.omp/extensions/fm-primary-turnend-guard.ts` passes one `--command` argument and returns `{block: true}` only for exit 2 (ported from Pi; the extension and its `tool_call`/`turn_end` handlers are live-verified 2026-07-10, though a seatbelt deny was not specifically exercised). | Grok project hooks require folder trust. Every shell variable reference in a Grok hook command must carry an inline default such as `${GROK_WORKSPACE_ROOT:-}` because Grok expands the raw hook command before `bash -lc` runs it. diff --git a/docs/supervision-protocols/omp.md b/docs/supervision-protocols/omp.md index 2b77425cc45..d8fb7d9a09f 100644 --- a/docs/supervision-protocols/omp.md +++ b/docs/supervision-protocols/omp.md @@ -16,4 +16,4 @@ The turn-end guard extension lives at `__FM_OMP_TURNEND_EXT__`. The watcher extension lives at `__FM_OMP_EXT__`. Both are tracked, project-local `.omp/extensions/*.ts` files that OMP auto-discovers once the project is trusted; `bin/fm-session-start.sh` reports when the running OMP session has not loaded both required extensions. -OMP (Oh My Pi) is a Pi fork, so this protocol mirrors the Pi background-wake protocol. The extensions are ported from the Pi supervisors and kept in lockstep, including the #397 lifecycle fix: `stopArm()`, a one-shot `process.once("exit")` cleanup, awaited follow-up delivery, and the `fm_watch_arm_omp` tool + `/fm-watch-arm-omp` human fallback. Two OMP adaptations: the turn-end guard listens for `pi.on("turn_end")` because OMP has no `agent_settled` event (Pi 0.80.5-only), and the tool schema uses `pi.zod.object({})` rather than Pi's typebox. OMP sets `OMPCODE=1` (and `CLAUDECODE=1`) in its child/tool env, so `bin/fm-harness.sh` detects it as `omp` - and because `OMPCODE` is checked before `CLAUDECODE`, OMP never misdetects as claude. OMP auto-loads `.omp/extensions/` only, never `.pi/`. PENDING live verification on a real OMP session (the Pi path was validated on Pi 0.80.5; the OMP equivalent has not run live yet). +OMP (Oh My Pi) is a Pi fork, so this protocol mirrors the Pi background-wake protocol. The extensions are ported from the Pi supervisors and kept in lockstep, including the #397 lifecycle fix: `stopArm()`, a one-shot `process.once("exit")` cleanup, awaited follow-up delivery, and the `fm_watch_arm_omp` tool + `/fm-watch-arm-omp` human fallback. Two OMP adaptations: the turn-end guard listens for `pi.on("turn_end")` because OMP has no `agent_settled` event (Pi 0.80.5-only), and the tool schema uses `pi.zod.object({})` rather than Pi's typebox. OMP sets `OMPCODE=1` (and `CLAUDECODE=1`) in its child/tool env, so `bin/fm-harness.sh` detects it as `omp` - and because `OMPCODE` is checked before `CLAUDECODE`, OMP never misdetects as claude. OMP auto-loads `.omp/extensions/` only, never `.pi/`. VERIFIED live 2026-07-10 (omp v16.3.15): a real omp primary ran this loop end-to-end - the turn-end guard fired with two crewmate tasks in flight and the primary re-armed the watcher rather than ending blind, while two omp crewmates shipped green PRs from treehouse worktrees. (Not yet exercised: a seatbelt deny, the tmux-backend busy signature, and exit/interrupt keys.) diff --git a/docs/turnend-guard.md b/docs/turnend-guard.md index f41ad0c3a12..5e511c31997 100644 --- a/docs/turnend-guard.md +++ b/docs/turnend-guard.md @@ -43,7 +43,7 @@ All verified primary harnesses have a tracked integration: - `codex`: `.codex/hooks.json` registers a `Stop` hook that reads the hook payload once, anchors the executable to the hook command process working directory, verifies that root is firstmate-shaped and hook-bearing, and pipes the original payload to that checkout's `bin/fm-turnend-guard.sh`. - `opencode`: `.opencode/plugins/fm-primary-turnend-guard.js` listens for `session.idle`, lets the watcher-arm coordinator handle normal idle supervision first, runs the shared guard only when that coordinator does not act, and uses `client.session.promptAsync` to force one follow-up prompt when the guard returns 2. - `pi`: `.pi/extensions/fm-primary-turnend-guard.ts` listens for `agent_settled`, marks the extension version loaded for session-start checks, runs the shared guard once per logical agent run, and uses `pi.sendUserMessage(..., { deliverAs: "followUp" })` to force one follow-up prompt when the guard returns 2. -- `omp`: `.omp/extensions/fm-primary-turnend-guard.ts` listens for `turn_end` (OMP has no `agent_settled` event), marks the extension version loaded for session-start checks, runs the shared guard, and uses `pi.sendUserMessage(..., { deliverAs: "followUp" })` to force one follow-up prompt when the guard returns 2. Ported from the Pi guard (omp is a Pi fork); PENDING live validation - validate with `omp -p -e .omp/extensions/fm-primary-turnend-guard.ts ...` like the pi check below. +- `omp`: `.omp/extensions/fm-primary-turnend-guard.ts` listens for `turn_end` (OMP has no `agent_settled` event), marks the extension version loaded for session-start checks, runs the shared guard, and uses `pi.sendUserMessage(..., { deliverAs: "followUp" })` to force one follow-up prompt when the guard returns 2. VERIFIED live 2026-07-10: the guard fired on a real multi-turn omp primary ("TURN WOULD END BLIND ... 2 task(s) in flight") and the primary re-armed the watcher instead of ending blind. - `grok`: `.grok/hooks/fm-primary-turnend-guard.json` registers a `Stop` hook that invokes `bin/fm-turnend-guard-grok.sh`. The adapter runs the shared guard and, when it returns 2, invokes `grok --resume -p ` with `GROK_TURNEND_GUARD_ACTIVE=1`. It does not pass `--permission-mode`, so the passive Stop hook cannot grant stronger tool permissions than Grok's resumed-session default. From 0b83b7d8f1b9d7628bcb35fe2ba7ec11f9c01ff6 Mon Sep 17 00:00:00 2001 From: Apinant U-suwantim Date: Sat, 11 Jul 2026 01:57:23 +0700 Subject: [PATCH 04/16] test(omp): add live-e2e regression, backing the verified adapter Adds tests/fm-omp-primary-live-e2e.test.sh, the OMP counterpart to fm-pi-primary-live-e2e.test.sh, so the VERIFIED omp adapter is backed by a repeatable regression like the author's pi/grok adapters (deviation from the verified-harness pattern, closed). The test (opt-in, FM_OMP_LIVE_E2E=1) launches omp on a private tmux socket with the tracked .omp/extensions via -e, drives bash/read turns, triggers the turn-end guard, arms fm_watch_arm_omp, delivers a watcher wake, drains + re-arms, and asserts: one-or-more guard injections, no foreground bin/fm-watch-arm.sh arm, a live re-armed watcher pid, and a clean /quit (OMP_EXIT=0) that reaps both the watcher and arm children. Passes on omp v16.4.0. OMP adaptations vs the pi e2e: no PI_OFFLINE stub model (drives a real model turn, hence opt-in); uses the default authed agent dir because a fresh PI_CODING_AGENT_DIR triggers omp's blocking first-run setup wizard; and it asserts >=1 guard injection (not exactly 1) because omp's guard listens for turn_end (no agent_settled), so it re-nags on each blind turn until armed - the run saw 3 injections before the model armed the watcher. Reconciled the docs to this stronger, honest state: Exit (/quit) is now VERIFIED (clean exit + child cleanup), the guard 'once per run' claim is corrected to the per-turn re-nag, and the still-unexercised items are narrowed to a seatbelt {block:true} deny, the tmux busy-footer regex, and the interrupt (Escape) key. --- .agents/skills/harness-adapters/SKILL.md | 11 +- docs/turnend-guard.md | 2 +- tests/fm-omp-primary-live-e2e.test.sh | 189 +++++++++++++++++++++++ 3 files changed, 197 insertions(+), 5 deletions(-) create mode 100755 tests/fm-omp-primary-live-e2e.test.sh diff --git a/.agents/skills/harness-adapters/SKILL.md b/.agents/skills/harness-adapters/SKILL.md index 43529d4dc44..b89cf77220f 100644 --- a/.agents/skills/harness-adapters/SKILL.md +++ b/.agents/skills/harness-adapters/SKILL.md @@ -288,18 +288,21 @@ omp (Oh My Pi, https://omp.sh) is a Pi fork with a Pi-compatible extension API ( |---|---| | Env marker | `OMPCODE=1` (omp also sets `CLAUDECODE=1`; `fm-harness.sh` checks `OMPCODE` first) | | Busy-pane signature | herdr backend: native busy-state verified working 2026-07-10. tmux backend: `Working...`/`Working…` / `esc to interrupt` regex still PENDING a tmux-backend run; override with `FM_BUSY_REGEX` / `FM_COMPOSER_IDLE_RE` | -| Exit command | `/quit` (inherited from pi; VERIFY) | -| Interrupt | single Escape (inherited from pi; VERIFY) | +| Exit command | `/quit` - VERIFIED 2026-07-10 by `tests/fm-omp-primary-live-e2e.test.sh` (clean `OMP_EXIT=0`; watcher + arm children reaped on exit) | +| Interrupt | single Escape (inherited from pi; not exercised by the e2e) | | Skill invocation | `/` (e.g. `/no-mistakes`) | | Autonomy | `--auto-approve` (omp HAS an approval system, unlike pi; fm-spawn passes it for crewmates - verified live 2026-07-10: two crewmates ran unattended and shipped PRs) | omp is claude-compatible (sets `CLAUDECODE=1`) but does NOT implement Claude Code's `.claude/settings.json` `Stop`/`PreToolUse` event-hook contract - it uses its own `.omp/extensions/` runtime instead. That is exactly why detection must resolve `omp`, not `claude`: a claude-detected omp session would install `.claude/settings.json` Stop/PreToolUse hooks that omp never fires, silently disabling supervision. **Primary-session guard (VERIFIED live 2026-07-10).** -The primary's turn-end guard AND PreToolUse seatbelt both live in `.omp/extensions/fm-primary-turnend-guard.ts`. It listens for `turn_end` because OMP has no `agent_settled` event (Pi 0.80.5-only); the `guardFollowupActive` one-shot skip gives the same "guard once per run" behavior. On block it `await`s `pi.sendUserMessage(..., { deliverAs: "followUp" })` when `bin/fm-turnend-guard.sh` returns 2. The seatbelt returns `{ block: true }` from the `tool_call` handler when `bin/fm-arm-pretool-check.sh` denies a bash command. +The primary's turn-end guard AND PreToolUse seatbelt both live in `.omp/extensions/fm-primary-turnend-guard.ts`. It listens for `turn_end` because OMP has no `agent_settled` event (Pi 0.80.5-only). The `guardFollowupActive` one-shot skip suppresses the guard on its OWN injected follow-up turn, but - unlike pi's per-logical-run `agent_settled` - it re-nags on each subsequent blind turn until supervision is armed (the live e2e saw 3 injections before the model armed the watcher). On block it `await`s `pi.sendUserMessage(..., { deliverAs: "followUp" })` when `bin/fm-turnend-guard.sh` returns 2. The seatbelt returns `{ block: true }` from the `tool_call` handler when `bin/fm-arm-pretool-check.sh` denies a bash command. **Primary watcher (VERIFIED live 2026-07-10).** `.omp/extensions/fm-primary-omp-watch.ts` registers the `fm_watch_arm_omp` tool (primary path, called instead of a foreground bash arm) plus the `/fm-watch-arm-omp` command as a human fallback (the command notifies via `ctx.ui.notify`). Arming spawns `bin/fm-watch-arm.sh --restart` attached to the live omp process and sends a follow-up wake when the child exits with an actionable reason; a one-shot `process.once("exit")` listener (mirroring Pi #397) plus `session_shutdown` stop the arm child on exit. `bin/fm-session-start.sh` reports when the running omp session has not loaded both extensions (markers `state/.omp-turnend-extension-loaded` and `state/.omp-watch-extension-loaded`). Both are project-local `.omp/extensions/*.ts` files omp auto-discovers once the project is trusted (approve trust once per clone, or launch with `-e` as the trust-free fallback). The tool schema uses `pi.zod.object({})` (OMP-canonical) rather than Pi's typebox `Type.Object({})`, and OMP's ToolDefinition has no `promptSnippet`/`promptGuidelines` fields. **Live validation record, 2026-07-10 (omp v16.3.15, herdr backend).** -A fresh `omp` in the firstmate home became the first mate (root `AGENTS.md` loaded via the `agents-md` provider), `bin/fm-harness.sh` and `bin/fm-lock.sh` both detected `omp`, and both `.omp/extensions/` loaded (markers written). It dispatched two crewmates (`fm-webull-broker-w7`, `fm-finnhub-free-f3`) - each a real omp session in its own treehouse worktree, running autonomously under `--auto-approve` - which shipped two green PRs. The turn-end guard fired on a real multi-turn primary ("TURN WOULD END BLIND ... 2 task(s) in flight, but no live watcher holds this home lock") and the primary re-armed the watcher (alive) instead of ending blind. Not yet exercised: a seatbelt `{block:true}` deny of an arm anti-pattern, the tmux-backend busy signature (this run used herdr's native busy-state), and the exit/interrupt keys. +A fresh `omp` in the firstmate home became the first mate (root `AGENTS.md` loaded via the `agents-md` provider), `bin/fm-harness.sh` and `bin/fm-lock.sh` both detected `omp`, and both `.omp/extensions/` loaded (markers written). It dispatched two crewmates (`fm-webull-broker-w7`, `fm-finnhub-free-f3`) - each a real omp session in its own treehouse worktree, running autonomously under `--auto-approve` - which shipped two green PRs. The turn-end guard fired on a real multi-turn primary ("TURN WOULD END BLIND ... 2 task(s) in flight, but no live watcher holds this home lock") and the primary re-armed the watcher (alive) instead of ending blind. + +**Automated live E2E, 2026-07-10 (omp v16.4.0, tmux).** +`tests/fm-omp-primary-live-e2e.test.sh` (opt-in, `FM_OMP_LIVE_E2E=1`; mirrors `fm-pi-primary-live-e2e.test.sh`) passes: it launches omp on a private tmux socket with the tracked extensions via `-e`, drives bash/read turns, triggers the turn-end guard, arms `fm_watch_arm_omp`, delivers a watcher wake, drains + re-arms, and asserts one-or-more guard injections (omp re-nags per blind turn), NO foreground `bin/fm-watch-arm.sh` arm, a live re-armed watcher pid, and a clean `/quit` (`OMP_EXIT=0`) that reaps both the watcher and arm children. It uses the default (already-authed) agent dir because a fresh `PI_CODING_AGENT_DIR` triggers omp's blocking first-run setup wizard. Still not exercised anywhere: a seatbelt `{block:true}` deny of an arm anti-pattern, the tmux busy-footer regex (`FM_BUSY_REGEX`), and the interrupt (Escape) key. diff --git a/docs/turnend-guard.md b/docs/turnend-guard.md index 5e511c31997..fdedf8f8997 100644 --- a/docs/turnend-guard.md +++ b/docs/turnend-guard.md @@ -43,7 +43,7 @@ All verified primary harnesses have a tracked integration: - `codex`: `.codex/hooks.json` registers a `Stop` hook that reads the hook payload once, anchors the executable to the hook command process working directory, verifies that root is firstmate-shaped and hook-bearing, and pipes the original payload to that checkout's `bin/fm-turnend-guard.sh`. - `opencode`: `.opencode/plugins/fm-primary-turnend-guard.js` listens for `session.idle`, lets the watcher-arm coordinator handle normal idle supervision first, runs the shared guard only when that coordinator does not act, and uses `client.session.promptAsync` to force one follow-up prompt when the guard returns 2. - `pi`: `.pi/extensions/fm-primary-turnend-guard.ts` listens for `agent_settled`, marks the extension version loaded for session-start checks, runs the shared guard once per logical agent run, and uses `pi.sendUserMessage(..., { deliverAs: "followUp" })` to force one follow-up prompt when the guard returns 2. -- `omp`: `.omp/extensions/fm-primary-turnend-guard.ts` listens for `turn_end` (OMP has no `agent_settled` event), marks the extension version loaded for session-start checks, runs the shared guard, and uses `pi.sendUserMessage(..., { deliverAs: "followUp" })` to force one follow-up prompt when the guard returns 2. VERIFIED live 2026-07-10: the guard fired on a real multi-turn omp primary ("TURN WOULD END BLIND ... 2 task(s) in flight") and the primary re-armed the watcher instead of ending blind. +- `omp`: `.omp/extensions/fm-primary-turnend-guard.ts` listens for `turn_end` (OMP has no `agent_settled` event), marks the extension version loaded for session-start checks, runs the shared guard, and uses `pi.sendUserMessage(..., { deliverAs: "followUp" })` to force one follow-up prompt when the guard returns 2. Because `turn_end` fires per turn (not per logical run like pi's `agent_settled`), it re-nags on each blind turn until supervision is armed. VERIFIED 2026-07-10 by `tests/fm-omp-primary-live-e2e.test.sh` (guard fired, watcher armed + re-armed after a wake, clean `/quit` reaped both children). - `grok`: `.grok/hooks/fm-primary-turnend-guard.json` registers a `Stop` hook that invokes `bin/fm-turnend-guard-grok.sh`. The adapter runs the shared guard and, when it returns 2, invokes `grok --resume -p ` with `GROK_TURNEND_GUARD_ACTIVE=1`. It does not pass `--permission-mode`, so the passive Stop hook cannot grant stronger tool permissions than Grok's resumed-session default. diff --git a/tests/fm-omp-primary-live-e2e.test.sh b/tests/fm-omp-primary-live-e2e.test.sh new file mode 100755 index 00000000000..a1e8d0edc8f --- /dev/null +++ b/tests/fm-omp-primary-live-e2e.test.sh @@ -0,0 +1,189 @@ +#!/usr/bin/env bash +# Opt-in interactive OMP (Oh My Pi) primary regression on a private tmux socket +# and isolated homes. Mirrors tests/fm-pi-primary-live-e2e.test.sh. +# +# OMP has no PI_OFFLINE stub model, so this drives a REAL omp model session (same +# cost profile as the pi live e2e) - hence it is opt-in behind FM_OMP_LIVE_E2E=1. +# It launches omp with explicit `-e` extension paths (the documented trust-free +# path), so it does not depend on an interactive project-trust prompt, and gates +# readiness on the extension-loaded marker files rather than pane text. +set -u + +if [ "${FM_OMP_LIVE_E2E:-0}" != 1 ]; then + echo "skip: set FM_OMP_LIVE_E2E=1 to run the isolated interactive OMP regression" + exit 0 +fi + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +command -v omp >/dev/null 2>&1 || { echo "skip: omp not found"; exit 0; } +command -v tmux >/dev/null 2>&1 || { echo "skip: tmux not found"; exit 0; } + +TMUX=$(command -v tmux) +SOCKET="fm-omp-live-e2e-$$" +SESSION=omp-live-e2e +LAB="$ROOT/.omp-live-e2e.$$" +PROJECT="$LAB/project" +HOME_DIR="$LAB/fmhome" +OMP_VERSION=$(omp --version 2>/dev/null | head -1) +TURNEND_EXT=".omp/extensions/fm-primary-turnend-guard.ts" +WATCH_EXT=".omp/extensions/fm-primary-omp-watch.ts" + +fail() { + printf 'not ok - %s\n' "$1" >&2 + exit 1 +} + +capture() { + "$TMUX" -L "$SOCKET" capture-pane -p -t "$SESSION" -S -600 2>/dev/null || true +} + +wait_for_text() { + local expected=$1 attempts=${2:-120} i=0 + while [ "$i" -lt "$attempts" ]; do + if capture | grep -Fq "$expected"; then + return 0 + fi + sleep 0.5 + i=$((i + 1)) + done + capture >&2 + return 1 +} + +wait_for_exact_line() { + local expected=$1 attempts=${2:-120} i=0 + while [ "$i" -lt "$attempts" ]; do + if capture | grep -Fxq " $expected"; then + return 0 + fi + sleep 0.5 + i=$((i + 1)) + done + capture >&2 + return 1 +} + +wait_for_markers() { + local attempts=${1:-60} i=0 + while [ "$i" -lt "$attempts" ]; do + if [ -f "$HOME_DIR/state/.omp-turnend-extension-loaded" ] \ + && [ -f "$HOME_DIR/state/.omp-watch-extension-loaded" ]; then + return 0 + fi + sleep 0.5 + i=$((i + 1)) + done + capture >&2 + return 1 +} + +lab_pid_is_safe() { + local pid=$1 command + command=$(ps -p "$pid" -o command= 2>/dev/null || true) + case "$command" in + *"$LAB"*) return 0 ;; + *) return 1 ;; + esac +} + +cleanup() { + local pid_file watcher_pid arm_pid + pid_file=$(find "$HOME_DIR/state" -maxdepth 3 -type f -name pid 2>/dev/null | head -1 || true) + watcher_pid= + arm_pid= + if [ -n "$pid_file" ]; then + watcher_pid=$(sed -n '1p' "$pid_file" 2>/dev/null || true) + arm_pid=$(ps -p "$watcher_pid" -o ppid= 2>/dev/null | tr -d ' ' || true) + fi + "$TMUX" -L "$SOCKET" kill-server 2>/dev/null || true + sleep 0.1 + if [ -n "$watcher_pid" ] && lab_pid_is_safe "$watcher_pid"; then + kill -TERM "$watcher_pid" 2>/dev/null || true + fi + if [ -n "$arm_pid" ] && lab_pid_is_safe "$arm_pid"; then + kill -TERM "$arm_pid" 2>/dev/null || true + fi + rm -rf "$LAB" +} +trap cleanup EXIT + +send_prompt() { + local prompt=$1 + "$TMUX" -L "$SOCKET" send-keys -t "$SESSION" -l "$prompt" + sleep 0.6 + "$TMUX" -L "$SOCKET" send-keys -t "$SESSION" Enter + sleep 0.4 +} + +wait_pid_dead() { + local pid=$1 i=0 + while [ "$i" -lt 50 ]; do + kill -0 "$pid" 2>/dev/null || return 0 + sleep 0.1 + i=$((i + 1)) + done + return 1 +} + +mkdir -p "$LAB" +git clone -q "$ROOT" "$PROJECT" +mkdir -p "$PROJECT/.omp/extensions" +cp "$ROOT/$TURNEND_EXT" "$PROJECT/$TURNEND_EXT" +cp "$ROOT/$WATCH_EXT" "$PROJECT/$WATCH_EXT" +cp "$ROOT/bin/fm-supervision-instructions.sh" "$PROJECT/bin/fm-supervision-instructions.sh" +mkdir -p "$HOME_DIR/state" "$HOME_DIR/config" + +# Uses the default (already-authed) agent dir, not an isolated PI_CODING_AGENT_DIR: +# a fresh agent dir triggers omp's blocking first-run setup wizard. --no-session keeps +# the run ephemeral. Only FM_HOME (the firstmate home) is isolated. +"$TMUX" -L "$SOCKET" new-session -d -s "$SESSION" -c "$PROJECT" \ + "env FM_HOME='$HOME_DIR' FM_ROOT_OVERRIDE='$PROJECT' FM_POLL=1 FM_SIGNAL_GRACE=0 FM_HEARTBEAT=600 bash -lc 'printf \"%s\\n\" \"\$\$\" > \"\$FM_HOME/state/.lock\"; omp --no-session -e $TURNEND_EXT -e $WATCH_EXT; rc=\$?; printf \"OMP_EXIT=%s\\n\" \"\$rc\"; sleep 300'" + +# Extensions load early in startup; wait for both markers, then for omp to finish +# starting up (MCP connect) before driving the composer. omp shows no interactive +# project-trust prompt, so no approving keystroke is needed to reach the session. +wait_for_markers 60 || fail "OMP primary extensions did not load (no extension-loaded markers)" +wait_for_text "Connected to MCP" 60 || sleep 8 +sleep 2 + +send_prompt "Use the bash tool to run printf OMP_E2E_BASH_ONE. Then reply exactly BASH-ONE." +wait_for_exact_line "BASH-ONE" || fail "first bash turn did not complete" +send_prompt "Use the read tool to read the first five lines of README.md. Then reply exactly READ-ONE." +wait_for_exact_line "READ-ONE" || fail "read turn did not complete" +send_prompt "Use the bash tool to run printf OMP_E2E_BASH_TWO. Then reply exactly BASH-TWO." +wait_for_exact_line "BASH-TWO" || fail "second bash turn did not complete" + +: > "$HOME_DIR/state/omp-e2e.meta" +send_prompt "Reply exactly GUARD-TRIGGER with no tools. When the guard follow-up arrives, use fm_watch_arm_omp and never use bash to arm supervision. After any FIRSTMATE WATCHER WAKE, run bin/fm-wake-drain.sh, read the signaled status, call fm_watch_arm_omp to re-arm, and finish exactly REARMED." +wait_for_text "watcher: started OMP extension arm child 1" || fail "guard follow-up did not render the OMP watcher tool result" + +printf 'done: omp live e2e watcher fire\n' > "$HOME_DIR/state/omp-e2e.status" +wait_for_text "watcher: started OMP extension arm child 2" 180 || fail "watcher wake did not drain and re-arm through the OMP tool" +wait_for_exact_line "REARMED" 120 || fail "OMP did not settle after re-arming watcher supervision" + +pane=$(capture) +guard_count=$(printf '%s\n' "$pane" | grep -Fc "TURN WOULD END BLIND - supervision is off." || true) +# omp's guard listens for `turn_end` (omp has no `agent_settled`), so it re-nags on +# EVERY blind turn boundary until supervision is armed - unlike pi's once-per-logical-run +# `agent_settled`. Expect one-or-more injections, not exactly one. The guardFollowupActive +# latch still prevents double-firing on the guard's own injected follow-up turn. +[ "$guard_count" -ge 1 ] || fail "expected at least one guard injection, saw $guard_count" +foreground_arm='$ bin/fm-watch-arm.sh' +if printf '%s\n' "$pane" | grep -Fq "$foreground_arm"; then + fail "OMP used a foreground bash watcher arm" +fi + +pid_file=$(find "$HOME_DIR/state" -maxdepth 3 -type f -name pid | head -1) +[ -n "$pid_file" ] || fail "re-armed watcher pid was not recorded" +watcher_pid=$(sed -n '1p' "$pid_file") +arm_pid=$(ps -p "$watcher_pid" -o ppid= | tr -d ' ') +[ -n "$arm_pid" ] || fail "re-armed watcher parent was not live" + +"$TMUX" -L "$SOCKET" send-keys -t "$SESSION" -l '/quit' +sleep 1 +"$TMUX" -L "$SOCKET" send-keys -t "$SESSION" Enter +wait_for_text "OMP_EXIT=0" 60 || fail "OMP did not exit cleanly" +wait_pid_dead "$watcher_pid" || fail "watcher child survived clean OMP exit" +wait_pid_dead "$arm_pid" || fail "arm child survived clean OMP exit" + +printf 'ok - OMP %s live E2E rendered the tool, guarded once, woke, re-armed, and cleaned up on exit\n' "$OMP_VERSION" From af5819fa8393414c6ff496b822350bb5c9b49df4 Mon Sep 17 00:00:00 2001 From: Apinant U-suwantim Date: Sat, 11 Jul 2026 02:45:33 +0700 Subject: [PATCH 05/16] test(omp): isolate harness-detecting tests from ambient OMPCODE (extends #432) omp (Oh My Pi) exports OMPCODE=1 (and CLAUDECODE=1) into child shells and fm-harness.sh detect_own checks OMPCODE first, so running the suite from an omp session leaks OMPCODE past the suites' harness pins - flipping detection to omp and breaking cases that CI (no ambient marker) keeps green. Extends #432's ambient-marker isolation to OMPCODE: - fm-session-start: add OMPCODE to run_session_start's env -u neutralization. - fm-secondmate-harness / fm-turnend-guard / fm-x-mode: drop ambient OMPCODE at the top so their CLAUDECODE=1 harness pins stay authoritative. (fm-watcher-lock's ambient failure was a pre-existing flaky restart timeout, status 124 - unrelated to omp, left untouched.) --- tests/fm-secondmate-harness.test.sh | 6 ++++++ tests/fm-session-start.test.sh | 6 +++--- tests/fm-turnend-guard.test.sh | 6 ++++++ tests/fm-x-mode.test.sh | 6 ++++++ 4 files changed, 21 insertions(+), 3 deletions(-) diff --git a/tests/fm-secondmate-harness.test.sh b/tests/fm-secondmate-harness.test.sh index e4a430d3e9d..c2bff5410d4 100755 --- a/tests/fm-secondmate-harness.test.sh +++ b/tests/fm-secondmate-harness.test.sh @@ -42,6 +42,12 @@ fm_git_identity fmtest fmtest@example.com TMP_ROOT=$(fm_test_tmproot fm-secondmate-harness) export FM_BACKEND=tmux +# omp (Oh My Pi) exports OMPCODE=1 (and CLAUDECODE=1) into child shells, and +# fm-harness.sh detect_own checks OMPCODE first. Drop it so an ambient omp +# session can't override the CLAUDECODE=1 pins below (CI has no such marker). +# Extends the ambient-marker isolation from #432. +unset OMPCODE + # =========================================================================== # A) fm-harness.sh secondmate resolution + fallback (deterministic detect_own) # =========================================================================== diff --git a/tests/fm-session-start.test.sh b/tests/fm-session-start.test.sh index 6ae0d124bc4..39b6ec27571 100755 --- a/tests/fm-session-start.test.sh +++ b/tests/fm-session-start.test.sh @@ -237,13 +237,13 @@ SH # run_session_start # Drop every harness env marker from bin/fm-harness.sh detect_own so the # surrounding interactive shell cannot leak past the suite's fake ps harness. -# Markers today: CLAUDECODE (claude), PI_CODING_AGENT (pi), GROK_AGENT (grok). +# Markers today: CLAUDECODE (claude), PI_CODING_AGENT (pi), GROK_AGENT (grok), OMPCODE (omp; omp also sets CLAUDECODE). # codex and opencode have no env markers (ancestry only). Without this, a local -# claude/pi/grok session fails cases that pin a different fake harness while CI +# claude/pi/grok/omp session fails cases that pin a different fake harness while CI # (no ambient markers) still passes. run_session_start() { local home=$1 root=$2 path=$3 - env -u CLAUDECODE -u PI_CODING_AGENT -u GROK_AGENT \ + env -u CLAUDECODE -u PI_CODING_AGENT -u GROK_AGENT -u OMPCODE \ FM_HOME="$home" FM_ROOT_OVERRIDE="$root" PATH="$path" \ "$SESSION_START" } diff --git a/tests/fm-turnend-guard.test.sh b/tests/fm-turnend-guard.test.sh index 3e5d54f829d..af2e8004094 100755 --- a/tests/fm-turnend-guard.test.sh +++ b/tests/fm-turnend-guard.test.sh @@ -19,6 +19,12 @@ set -u TMP_ROOT=$(fm_test_tmproot fm-turnend-guard) fm_git_identity fmtest fmtest@example.invalid +# omp (Oh My Pi) exports OMPCODE=1 (and CLAUDECODE=1) into child shells, and +# fm-harness.sh detect_own checks OMPCODE first. Drop it so an ambient omp +# session can't override the CLAUDECODE=1 pins below (CI has no such marker). +# Extends the ambient-marker isolation from #432. +unset OMPCODE + REQUIRED_REASON='resume supervision with bin/fm-watch-arm.sh as its own Claude Code background task' # --- PREDICATE: bin/fm-supervision-lib.sh ----------------------------------- diff --git a/tests/fm-x-mode.test.sh b/tests/fm-x-mode.test.sh index da2800034c4..9daafb10439 100755 --- a/tests/fm-x-mode.test.sh +++ b/tests/fm-x-mode.test.sh @@ -21,6 +21,12 @@ JQ_DIR=$(command -v jq 2>/dev/null) && JQ_DIR=$(dirname "$JQ_DIR") || JQ_DIR= [ -n "$JQ_DIR" ] && BASE_PATH="$JQ_DIR:$BASE_PATH" TMP_ROOT=$(fm_test_tmproot fm-x-mode-tests) +# omp (Oh My Pi) exports OMPCODE=1 (and CLAUDECODE=1) into child shells, and +# fm-harness.sh detect_own checks OMPCODE first. Drop it so an ambient omp +# session can't override the CLAUDECODE=1 harness pins below (CI has no such +# marker). Extends the ambient-marker isolation from #432. +unset OMPCODE + # A fakebin `curl` that mimics the relay: it reads its behavior from env # (FAKE_POLL_CODE/FAKE_POLL_BODY/FAKE_ANSWER_CODE, and # FAKE_REQCTX_CODE/FAKE_REQCTX_BODY for the request-context lookup), records each From 0aba296c3f95a4a581c6386719bfb6631c4ec443 Mon Sep 17 00:00:00 2001 From: Apinant U-suwantim Date: Sat, 11 Jul 2026 10:07:55 +0700 Subject: [PATCH 06/16] no-mistakes(review): clean omp-ext on teardown, anchor omp lock regex --- bin/fm-lock.sh | 2 +- bin/fm-teardown.sh | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/bin/fm-lock.sh b/bin/fm-lock.sh index 00419639aa8..e01f9404a73 100755 --- a/bin/fm-lock.sh +++ b/bin/fm-lock.sh @@ -15,7 +15,7 @@ LOCK="$STATE/.lock" mkdir -p "$STATE" # Known harness command names; extend when a new adapter is verified. -HARNESS_RE='claude|codex|opencode|grok|omp|^pi$' +HARNESS_RE='claude|codex|opencode|grok|(^|/| )omp( |$)|^pi$' harness_pid() { local pid=$$ comm args diff --git a/bin/fm-teardown.sh b/bin/fm-teardown.sh index 9d84f4d6cfb..3023870bdd6 100755 --- a/bin/fm-teardown.sh +++ b/bin/fm-teardown.sh @@ -1005,7 +1005,7 @@ cleanup_firstmate_home_children() { fi remove_grok_turnend_auth "$sub_state" "$child_id" remove_pr_poll_artifacts "$sub_state" "$child_id" || return 1 - rm -f "$sub_state/$child_id.status" "$sub_state/$child_id.turn-ended" "$sub_state/$child_id.meta" "$sub_state/$child_id.pi-ext.ts" "$sub_state/$child_id.grok-turnend-token" + rm -f "$sub_state/$child_id.status" "$sub_state/$child_id.turn-ended" "$sub_state/$child_id.meta" "$sub_state/$child_id.pi-ext.ts" "$sub_state/$child_id.omp-ext.ts" "$sub_state/$child_id.grok-turnend-token" done } @@ -1136,7 +1136,7 @@ fm_backend_clear_transition "$BACKEND" "$STATE" "$T" || true # Read before the state-file rm below; empty (pre-fix tasks without tasktmp=) is a no-op. [ -n "$TASK_TMP" ] && rm -rf "$TASK_TMP" remove_pr_poll_artifacts "$STATE" "$ID" || exit 1 -rm -f "$STATE/$ID.status" "$STATE/$ID.turn-ended" "$STATE/$ID.meta" "$STATE/$ID.pi-ext.ts" "$STATE/$ID.grok-turnend-token" +rm -f "$STATE/$ID.status" "$STATE/$ID.turn-ended" "$STATE/$ID.meta" "$STATE/$ID.pi-ext.ts" "$STATE/$ID.omp-ext.ts" "$STATE/$ID.grok-turnend-token" if [ "$KIND" != scout ] && [ "$KIND" != secondmate ] && [ "$MODE" != local-only ]; then "$FM_ROOT/bin/fm-fleet-sync.sh" "$PROJ" || true fi From ca3f36d76eb5d3fe2f20e4be609e85107faef126 Mon Sep 17 00:00:00 2001 From: Apinant U-suwantim Date: Sat, 11 Jul 2026 13:03:30 +0700 Subject: [PATCH 07/16] no-mistakes(review): add omp to bootstrap harness allowlists --- bin/fm-bootstrap.sh | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bin/fm-bootstrap.sh b/bin/fm-bootstrap.sh index 753c5cec341..3b78bae3af9 100755 --- a/bin/fm-bootstrap.sh +++ b/bin/fm-bootstrap.sh @@ -400,7 +400,7 @@ secondmate_liveness_sweep() { [ -n "$target" ] || target="$window" verdict=$(fm_backend_agent_alive "$backend" "$target" 2>/dev/null) || verdict="unknown" case "$harness" in - claude|codex|opencode|pi|grok) ;; + claude|codex|opencode|pi|grok|omp) ;; *) [ "$verdict" = dead ] && verdict=unknown ;; esac case "$verdict" in @@ -655,12 +655,12 @@ crew_dispatch_validate() { return 0 fi err=$(jq -r ' - def verified($h): ["claude","codex","opencode","pi","grok"] | index($h); + def verified($h): ["claude","codex","opencode","pi","grok","omp"] | index($h); def effort_ok($h; $e): if $e == null then true elif ($e | type) != "string" then false elif $h == "claude" then (["low","medium","high","xhigh","max"] | index($e)) - elif $h == "codex" then (["low","medium","high","xhigh"] | index($e)) + elif ($h == "codex" or $h == "omp") then (["low","medium","high","xhigh"] | index($e)) elif $h == "grok" then (["low","medium","high"] | index($e)) elif $h == "pi" then (["low","medium","high","xhigh","max"] | index($e)) elif $h == "opencode" then false From 50b01b317b16c5931fac37cb9744acf76428db96 Mon Sep 17 00:00:00 2001 From: Apinant U-suwantim Date: Sat, 11 Jul 2026 19:00:55 +0700 Subject: [PATCH 08/16] no-mistakes(review): add omp to AGENTS.md verified adapter list --- AGENTS.md | 2 +- bin/fm-harness.sh | 2 +- bin/fm-spawn.sh | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2c745f2df73..a26a2dd773b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -149,7 +149,7 @@ A silent bootstrap section needs no action; for any printed actionable diagnosti ## 4. Harness and runtime dispatch Load `harness-adapters` before every spawn or recovery and before trust handling, skill invocation, interrupt, exit, resume, or adapter verification. -The verified harnesses are `claude`, `codex`, `opencode`, `pi`, and `grok`; never dispatch on an unverified adapter. +The verified harnesses are `claude`, `codex`, `opencode`, `pi`, `grok`, and `omp`; never dispatch on an unverified adapter. If configured harness data names an unverified adapter, report it and fall back only to a verified adapter rather than launching it. `docs/configuration.md` owns dispatch-profile and runtime-backend schemas, `bin/fm-dispatch-select.sh` owns selector mechanics, `bin/fm-harness.sh` owns static resolution, and `bin/fm-spawn.sh` owns launch flags and fail-closed validation. diff --git a/bin/fm-harness.sh b/bin/fm-harness.sh index 9fa0368124e..c1ca6142948 100755 --- a/bin/fm-harness.sh +++ b/bin/fm-harness.sh @@ -44,7 +44,7 @@ detect_own() { local pid=$$ comm args for _ in 1 2 3 4 5 6 7 8; do comm=$(ps -o comm= -p "$pid" 2>/dev/null) || break - case "$(basename "$comm")" in + case "${comm##*/}" in *claude*) echo claude; return ;; *codex*) echo codex; return ;; *opencode*) echo opencode; return ;; diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index 0ce202b40db..49246395a90 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -33,7 +33,7 @@ # profile consultation. A --secondmate spawn is exempt and resolves the SECONDMATE # harness (config/secondmate-harness -> config/crew-harness -> own), so the # secondmate-vs-crewmate split is DURABLE across every respawn (recovery, -# /updatefirstmate, restart). A bare adapter name (claude|codex|opencode|pi|grok) +# /updatefirstmate, restart). A bare adapter name (claude|codex|opencode|pi|grok|omp) # overrides it for this spawn (either kind). A non-flag string containing # whitespace is treated as a RAW launch command - the escape hatch for verifying # new adapters. From abc52275db2534a9067197292b63dba71470eccc Mon Sep 17 00:00:00 2001 From: Apinant U-suwantim Date: Sat, 11 Jul 2026 11:35:34 +0700 Subject: [PATCH 09/16] chore(gate): run the no-mistakes test step in a hermetic env commands.test iterates the full tests/*.test.sh suite to mirror ci.yml, which runs with no ambient harness/backend markers. A contributor gating from inside an omp/herdr session, though, leaks OMPCODE/CLAUDECODE/HERDR_ENV/TMUX into the run: HERDR_ENV routes fm-bootstrap.sh into the EXPERIMENTAL herdr backend (a deterministic FLEET_SYNC timeout-scaling failure) and OMPCODE/CLAUDECODE flip detect_own harness detection. Strip those per test so the gate matches CI's clean env regardless of the shell it runs from. --- .no-mistakes.yaml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.no-mistakes.yaml b/.no-mistakes.yaml index b95e69b4125..92dd20b1696 100644 --- a/.no-mistakes.yaml +++ b/.no-mistakes.yaml @@ -23,9 +23,12 @@ disable_project_settings: true # iterate every tests/*.test.sh, run each, and fail the step if any one exits # non-zero (an agent-driven test step has crashed the daemon). The e2e tests need # tmux on PATH, which the firstmate environment provides. +# Each test runs with ambient harness/backend markers stripped +# (OMPCODE/CLAUDECODE/HERDR_ENV/TMUX) so the gate matches CI's clean env even when +# the daemon or push originates from inside an omp or herdr session. commands: lint: 'bin/fm-lint.sh' - test: 'command -v tmux >/dev/null || { echo "tmux is required for e2e tests" >&2; exit 1; }; tmux -V; rc=0; for t in tests/*.test.sh; do echo "== $t =="; bash "$t" || rc=1; done; exit "$rc"' + test: 'command -v tmux >/dev/null || { echo "tmux is required for e2e tests" >&2; exit 1; }; tmux -V; rc=0; for t in tests/*.test.sh; do echo "== $t =="; env -u OMPCODE -u CLAUDECODE -u HERDR_ENV -u TMUX bash "$t" || rc=1; done; exit "$rc"' # Keep test evidence out of this repo; it stays in a temp dir instead. test: From 49f38776ec21b8ab283894b4d2c3f00081ed076e Mon Sep 17 00:00:00 2001 From: Apinant U-suwantim Date: Mon, 13 Jul 2026 01:01:33 +0700 Subject: [PATCH 10/16] fix(omp): port cd-guard seatbelt to OMP turnend-guard extension Upstream #483 (feat: guard primary shells from persistent cd commands) refactored the Pi turnend-guard's runPretoolCheck into a generic runChecker(script, command) and wired a cd-guard check before the watcher-arm check. Port the same change to the OMP twin: - .omp/extensions/fm-primary-turnend-guard.ts: replace runPretoolCheck with runChecker, run fm-cd-pretool-check.sh then fm-arm-pretool-check.sh in the tool_call handler (cd-guard deny short-circuits before the arm check, matching Pi's order). Wrappers inlined per the ts-no-tiny-functions lint rule (single-return, one call site each). - docs/cd-guard.md: add OMP row to the harness wiring table. - tests/fm-cd-pretool-check.test.sh: add test_omp_wiring parallel to test_pi_wiring, asserting the OMP extension runs both seatbelts. --- .omp/extensions/fm-primary-turnend-guard.ts | 22 +++++++++++++-------- docs/cd-guard.md | 1 + tests/fm-cd-pretool-check.test.sh | 12 +++++++++++ 3 files changed, 27 insertions(+), 8 deletions(-) diff --git a/.omp/extensions/fm-primary-turnend-guard.ts b/.omp/extensions/fm-primary-turnend-guard.ts index 462f0447cb3..3d933f8237e 100644 --- a/.omp/extensions/fm-primary-turnend-guard.ts +++ b/.omp/extensions/fm-primary-turnend-guard.ts @@ -81,14 +81,16 @@ function runGuard(): Promise<{ code: number; stderr: string }> { return promise; } -// PreToolUse seatbelt (bin/fm-arm-pretool-check.sh; docs/arm-pretool-check.md). -// Piggybacks on this same extension file rather than a separate one so no -// second omp -e flag is needed at launch - the primary already loads this -// file for the turn-end guard, and a `tool_call` handler returning { block: true } -// prevents the bash command from running (OMP ToolCallEvent contract, docs/extensions.md). -function runPretoolCheck(command: string): Promise<{ code: number; stderr: string }> { +// PreToolUse seatbelts (bin/fm-arm-pretool-check.sh, docs/arm-pretool-check.md; +// bin/fm-cd-pretool-check.sh, docs/cd-guard.md). Both piggyback on this same +// extension file rather than separate ones so no extra omp -e flag is needed at +// launch - the primary already loads this file for the turn-end guard, and a +// `tool_call` handler returning { block: true } prevents the bash command from +// running (OMP ToolCallEvent contract, docs/extensions.md). Each owner script +// owns its own decision and is inert outside the real primary checkout. +function runChecker(script: string, command: string): Promise<{ code: number; stderr: string }> { const { promise, resolve: resolveResult } = Promise.withResolvers<{ code: number; stderr: string }>(); - const child = spawn(`${root}/bin/fm-arm-pretool-check.sh`, ["--command", command], { + const child = spawn(`${root}/bin/${script}`, ["--command", command], { stdio: ["ignore", "ignore", "pipe"], }); let stderr = ""; @@ -116,7 +118,11 @@ export default function (pi: ExtensionAPI) { ? input.command : ""; if (!command) return {}; - const result = await runPretoolCheck(command); + const cdResult = await runChecker("fm-cd-pretool-check.sh", command); + if (cdResult.code === 2) { + return { block: true, reason: cdResult.stderr.trim() || "denied by the cd-guard PreToolUse seatbelt" }; + } + const result = await runChecker("fm-arm-pretool-check.sh", command); if (result.code !== 2) return {}; return { block: true, reason: result.stderr.trim() || "denied by the watcher-arm PreToolUse seatbelt" }; }); diff --git a/docs/cd-guard.md b/docs/cd-guard.md index 2d8082e1ce8..61046a72389 100644 --- a/docs/cd-guard.md +++ b/docs/cd-guard.md @@ -117,6 +117,7 @@ The cd-guard never duplicates shell lexing; it adds only the cd-specific decisio | Grok | `.grok/hooks/fm-primary-cd-check.json` PreToolUse hook anchored on `${GROK_WORKSPACE_ROOT:-}` | Consumes the stdout `decision=deny` object. | | OpenCode | `.opencode/plugins/fm-primary-cd-check.js` `tool.execute.before` | Throws, which surfaces as the failed tool result. | | Pi | `.pi/extensions/fm-primary-turnend-guard.ts` `tool_call` handler | Returns `{block: true}`; piggybacks on the already-loaded primary extension so no extra `-e` flag is needed. | +| OMP | `.omp/extensions/fm-primary-turnend-guard.ts` `tool_call` handler | Returns `{block: true}`; piggybacks on the already-loaded primary extension so no extra `-e` flag is needed (ported from Pi). | Each harness runs the cd-guard alongside the watcher-arm seatbelt; the two are independent checks, and either deny blocks the command. Every shell variable reference in the Grok hook command carries an inline default (`${GROK_WORKSPACE_ROOT:-}`) because Grok expands the raw hook command before `bash -lc` runs it, the same requirement documented in `docs/arm-pretool-check.md`. diff --git a/tests/fm-cd-pretool-check.test.sh b/tests/fm-cd-pretool-check.test.sh index d34bdda3ae9..82e4d610345 100755 --- a/tests/fm-cd-pretool-check.test.sh +++ b/tests/fm-cd-pretool-check.test.sh @@ -434,6 +434,17 @@ test_pi_wiring() { pass ".pi primary extension: tool_call runs the cd-guard alongside the watcher-arm check" } +test_omp_wiring() { + local ext content + ext="$ROOT/.omp/extensions/fm-primary-turnend-guard.ts" + [ -f "$ext" ] || fail "tracked omp primary extension is missing" + content=$(cat "$ext") + assert_contains "$content" 'runChecker("fm-cd-pretool-check.sh"' "omp extension must run the cd check in tool_call" + assert_contains "$content" 'runChecker("fm-arm-pretool-check.sh"' "omp extension must keep running the watcher-arm check" + assert_contains "$content" 'return { block: true, reason:' "omp extension must block on a checker exit 2" + pass ".omp primary extension: tool_call runs the cd-guard alongside the watcher-arm check" +} + test_scripts_are_shellcheck_clean() { shellcheck "$ROOT/bin/fm-cd-pretool-check.sh" >/dev/null 2>&1 \ || fail "bin/fm-cd-pretool-check.sh is not shellcheck-clean" @@ -457,4 +468,5 @@ test_codex_wiring test_grok_wiring test_opencode_wiring test_pi_wiring +test_omp_wiring test_scripts_are_shellcheck_clean From a5c27aaea8512755cb96ad7374fc3abb6864fc37 Mon Sep 17 00:00:00 2001 From: Apinant U-suwantim Date: Sat, 11 Jul 2026 22:35:28 +0700 Subject: [PATCH 11/16] no-mistakes(document): sync harness-list docs for new omp adapter --- .agents/skills/harness-adapters/SKILL.md | 2 +- README.md | 6 +++--- docs/architecture.md | 4 ++-- docs/configuration.md | 4 ++-- docs/orca-backend.md | 2 +- docs/turnend-guard.md | 1 + 6 files changed, 10 insertions(+), 9 deletions(-) diff --git a/.agents/skills/harness-adapters/SKILL.md b/.agents/skills/harness-adapters/SKILL.md index b89cf77220f..6a453b95114 100644 --- a/.agents/skills/harness-adapters/SKILL.md +++ b/.agents/skills/harness-adapters/SKILL.md @@ -1,6 +1,6 @@ --- name: harness-adapters -description: Agent-only reference for firstmate harness operations. Use before spawning or recovering a crewmate or secondmate, handling a trust dialog, sending a harness-specific skill invocation, interrupting or exiting an agent, resuming an exited agent, or verifying a new harness adapter. Contains verified facts for claude, codex, opencode, pi, and grok. +description: Agent-only reference for firstmate harness operations. Use before spawning or recovering a crewmate or secondmate, handling a trust dialog, sending a harness-specific skill invocation, interrupting or exiting an agent, resuming an exited agent, or verifying a new harness adapter. Contains verified facts for claude, codex, opencode, pi, grok, and omp. user-invocable: false metadata: internal: true diff --git a/README.md b/README.md index cedf95df7b1..9621a9aec11 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ Full detail on every feature lives in [docs/architecture.md](docs/architecture.m ### Requirements -- A verified agent harness: Claude Code, Grok, Pi, Codex, or OpenCode. +- A verified agent harness: Claude Code, Grok, Pi, Codex, OpenCode, or Oh My Pi (omp). - Git and the GitHub CLI, authenticated through `gh auth login`. - tmux, for the reference session backend. @@ -71,7 +71,7 @@ Claude Code and Grok use background-notify wake cycles; Pi uses its tracked prim All three have verified turn-end guard paths when launched with their documented setup. Pick whichever one matches your subscription and workflow. -Codex and OpenCode are also verified and supported as primary harnesses; Codex uses bounded foreground checkpoints, and OpenCode uses a TUI plugin, so both carry more harness-specific supervision tradeoffs than the three co-primaries. +Codex, OpenCode, and Oh My Pi (omp, a Pi fork) are also verified and supported as primary harnesses; Codex uses bounded foreground checkpoints, OpenCode uses a TUI plugin, and omp uses tracked extensions like Pi, so all three carry more harness-specific supervision tradeoffs than the three co-primaries. ### Install and launch @@ -192,7 +192,7 @@ Firstmate's skills live in two separate places with different audiences: - [docs/cmux-backend.md](docs/cmux-backend.md) - setup guide for the experimental cmux backend, plus its verification notes and known gaps. - [docs/codex-app-backend.md](docs/codex-app-backend.md) - Codex App backend boundary, evidence, and rollout contract. - [docs/turnend-guard.md](docs/turnend-guard.md) - the primary session's structural "no turn ends blind" backstop: verified per-harness hook mechanisms, scoping, loop safety, and fail-open tradeoffs. -- [docs/supervision-protocols/](docs/supervision-protocols/) - rendered primary-harness watcher protocols for Claude, Codex, OpenCode, Pi, Grok, and unknown harness fallback. +- [docs/supervision-protocols/](docs/supervision-protocols/) - rendered primary-harness watcher protocols for Claude, Codex, OpenCode, Pi, Grok, OMP, and unknown harness fallback. - [docs/scripts.md](docs/scripts.md) - the `bin/` toolbelt reference. - [`AGENTS.md`](AGENTS.md) - the distro's always-loaded operating contract and routing index for conditional procedures. - [CONTRIBUTING.md](CONTRIBUTING.md) - how to contribute, including the dev/test commands. diff --git a/docs/architecture.md b/docs/architecture.md index 6e8ad3f0f5b..8fbf3e0a064 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -46,7 +46,7 @@ The default path remains local-only; live GitHub enrichment exists only behind t Optional X mode integrates with the watcher only after explicit opt-in; [configuration.md](configuration.md#x-mode-env) owns its generated-artifact and dispatch mechanics. 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 two tracked primary extensions, 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 and OMP use their 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. @@ -127,7 +127,7 @@ The session-start bootstrap step surfaces either the active rule block or a conc When the file exists, `fm-spawn.sh` refuses crewmate and scout launches without an explicit harness, so `config/crew-harness` is only automatic when no dispatch profile file is active. Secondmate launches are exempt because they resolve the secondmate harness and any optional secondmate model or effort tokens instead. Unsupported effort values are still recorded in task meta when passed to `fm-spawn.sh`, but the launch template omits any effort flag that the selected harness does not accept. -That keeps spawn launch compatible across claude, codex, grok, pi, and opencode while preserving the requested profile for later audit. +That keeps spawn launch compatible across claude, codex, grok, pi, opencode, and omp while preserving the requested profile for later audit. ## Optional secondmates diff --git a/docs/configuration.md b/docs/configuration.md index 6920d919ca8..c9da68b6092 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -161,12 +161,12 @@ The full cmux home label also includes a short hash of the resolved `FM_ROOT` pa ## Harness support -claude, codex, opencode, pi, and grok are all empirically verified; new harnesses get verified through a supervised trial task before joining the set. +claude, codex, opencode, pi, grok, and omp are all empirically verified; new harnesses get verified through a supervised trial task before joining the set. The verified adapter knowledge - busy signatures, interrupt and exit commands, skill-invocation syntax, and per-harness quirks - lives in [`.agents/skills/harness-adapters/SKILL.md`](../.agents/skills/harness-adapters/SKILL.md). Launch mechanics, including the verified command templates and optional agent-secrets injection contract, live in [`bin/fm-spawn.sh`](../bin/fm-spawn.sh) and its `--help` output. 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 two tracked primary extensions, and OpenCode uses its TUI plugin. +Claude and Grok use background-notify cycles, Codex uses bounded foreground checkpoints, Pi and OMP use their 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. diff --git a/docs/orca-backend.md b/docs/orca-backend.md index 6be2ad58f9f..09d2b0492f6 100644 --- a/docs/orca-backend.md +++ b/docs/orca-backend.md @@ -1,7 +1,7 @@ # Orca Backend Orca is an experimental runtime backend for firstmate. -It is distinct from the crewmate harness: the harness is the agent process firstmate launches (`claude`, `codex`, `opencode`, `pi`, or `grok`), while Orca owns the task worktree and terminal endpoint underneath that process. +It is distinct from the crewmate harness: the harness is the agent process firstmate launches (`claude`, `codex`, `opencode`, `pi`, `grok`, or `omp`), while Orca owns the task worktree and terminal endpoint underneath that process. Firstmate agents operating this backend should load the agent-only [`firstmate-orca`](../.agents/skills/firstmate-orca/SKILL.md) checklist before switching to Orca, spawning or supervising Orca-backed work, smoke-testing, debugging task state, or reconciling Orca metadata. ## Setup diff --git a/docs/turnend-guard.md b/docs/turnend-guard.md index fdedf8f8997..cf0a3aa8377 100644 --- a/docs/turnend-guard.md +++ b/docs/turnend-guard.md @@ -150,3 +150,4 @@ No Herdr command was issued and no fleet state was touched; the experiment wrote `tests/fm-turnend-guard.test.sh` covers the shared predicate, primary scoping (including a secondmate's own home being guarded like the main primary while its child worktrees stay exempt), `FM_HOME` and `FM_STATE_OVERRIDE` precedence, Pi logical-run latch behavior for no-tool and multi-tool runs, fail-open behavior without `jq`, tracked hook registration for all five harnesses, and the Grok adapter's forced-resume loop guard and permission-mode regression. The default behavior suite does not invoke live language-model harnesses. `FM_PI_LIVE_E2E=1 tests/fm-pi-primary-live-e2e.test.sh` opts into the isolated interactive Pi regression recorded above. +`FM_OMP_LIVE_E2E=1 tests/fm-omp-primary-live-e2e.test.sh` opts into the isolated interactive OMP regression recorded above. From 0f13226c8352c62014ed6db14bd65219f708b9d3 Mon Sep 17 00:00:00 2001 From: Apinant U-suwantim Date: Mon, 13 Jul 2026 22:48:58 +0700 Subject: [PATCH 12/16] no-mistakes(document): add omp to three stale harness enumerations --- .agents/skills/afk/SKILL.md | 2 +- .agents/skills/firstmate-orca/SKILL.md | 2 +- docs/turnend-guard.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.agents/skills/afk/SKILL.md b/.agents/skills/afk/SKILL.md index 4a09c3a0767..1106f320e24 100644 --- a/.agents/skills/afk/SKILL.md +++ b/.agents/skills/afk/SKILL.md @@ -80,7 +80,7 @@ explicit word - the daemon just batches the notification. The daemon prefixes every injection with `FM_INJECT_MARK` (U+2063 INVISIBLE SEPARATOR), which has no normal keyboard keystroke and survives terminal transport as UTF-8 text. This is how firstmate tells a daemon escalation apart from a real message in the same pane. -The marker travels with the message text; it does not rely on harness-level typed-vs-injected detection, which is not portable across claude, codex, opencode, pi, and grok. +The marker travels with the message text; it does not rely on harness-level typed-vs-injected detection, which is not portable across claude, codex, opencode, pi, grok, and omp. ## Busy-guard and composer guard diff --git a/.agents/skills/firstmate-orca/SKILL.md b/.agents/skills/firstmate-orca/SKILL.md index 854d4979399..f92e4d68cf7 100644 --- a/.agents/skills/firstmate-orca/SKILL.md +++ b/.agents/skills/firstmate-orca/SKILL.md @@ -13,7 +13,7 @@ It does not replace `AGENTS.md`, `docs/orca-backend.md`, or `harness-adapters`. Orca is a runtime backend, not an agent harness. The runtime backend owns the task endpoint and, for Orca, the task worktree. -The harness is the agent process launched inside that endpoint, such as `claude`, `codex`, `opencode`, `pi`, or `grok`. +The harness is the agent process launched inside that endpoint, such as `claude`, `codex`, `opencode`, `pi`, `grok`, or `omp`. Load `harness-adapters` for harness-specific launch, interrupt, resume, trust-dialog, and skill-invocation facts. Implementation details, metadata fields, teardown guarantees, limitations, and smoke evidence live in `docs/orca-backend.md`. diff --git a/docs/turnend-guard.md b/docs/turnend-guard.md index cf0a3aa8377..a32bd7c4deb 100644 --- a/docs/turnend-guard.md +++ b/docs/turnend-guard.md @@ -52,7 +52,7 @@ Claude and Codex support a direct blocking Stop hook. For those harnesses, exit status 2 plus stderr from `bin/fm-turnend-guard.sh` blocks the stop and feeds the reason back into the model. Both payloads include `stop_hook_active`; when it is true, the shared guard exits 0 so the harness can end after one forced continuation. -OpenCode, Pi, and Grok expose passive lifecycle callbacks for this purpose. +OpenCode, Pi, Grok, and OMP expose passive lifecycle callbacks for this purpose. Their adapters fail open at the hook boundary to avoid corrupting a user session, but they force one follow-up turn when the shared predicate blocks. Each adapter carries its own in-process or environment loop guard so the forced follow-up does not recursively schedule another follow-up. Pi keeps that latch active across every internal tool turn and clears it only when the generated guard follow-up reaches `agent_settled`, or immediately when follow-up delivery fails. From bc8f4773ee009d991e4226f64934935d2314b4b8 Mon Sep 17 00:00:00 2001 From: Apinant U-suwantim Date: Fri, 17 Jul 2026 09:20:07 +0700 Subject: [PATCH 13/16] test(omp): cover omp launch templates in agent-secrets suite Upstream #658 (agent secret injection) wraps every launch template with $agent_secrets_prefix but its coverage loop omitted omp. The rebase resolution added the prefix to the omp template (mirroring pi); extend the test's baselines and both harness loops so the omp ship/secondmate launch commands and their prefix wrapping are verified, not just eyeballed. --- tests/fm-spawn-agent-secrets.test.sh | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/tests/fm-spawn-agent-secrets.test.sh b/tests/fm-spawn-agent-secrets.test.sh index ad1721c0e20..df111377ddc 100755 --- a/tests/fm-spawn-agent-secrets.test.sh +++ b/tests/fm-spawn-agent-secrets.test.sh @@ -32,6 +32,8 @@ codex|secondmate|codex __MODELFLAG____EFFORTFLAG__--dangerously-bypass-approvals opencode|ship|OPENCODE_CONFIG_CONTENT='{"permission":{"*":"allow"}}' opencode __MODELFLAG__--prompt "$(cat __BRIEF__)" pi|ship|pi __MODELFLAG____EFFORTFLAG__-e __PIEXT__ "$(cat __BRIEF__)" pi|secondmate|pi __MODELFLAG____EFFORTFLAG__-e __PITURNEND__ -e __PIWATCH__ "$(cat __BRIEF__)" +omp|ship|omp --auto-approve __MODELFLAG____EFFORTFLAG__-e __OMPEXT__ "$(cat __BRIEF__)" +omp|secondmate|omp --auto-approve __MODELFLAG____EFFORTFLAG__-e __OMPTURNEND__ -e __OMPWATCH__ "$(cat __BRIEF__)" grok|ship|grok --always-approve __MODELFLAG____EFFORTFLAG__"$(cat __BRIEF__)" ROWS } @@ -102,7 +104,7 @@ test_absent_gate_keeps_final_commands_byte_identical() { fm_git_worktree "$project" "$wt" baseline-worktree fakebin=$(make_spawn_fakebin "$world/fake") - for harness in claude codex opencode pi grok; do + for harness in claude codex opencode pi grok omp; do id="agent-secrets-${harness}-z1" mkdir -p "$home/data/$id" printf '%s\n' 'brief' > "$home/data/$id/brief.md" @@ -124,13 +126,16 @@ test_absent_gate_keeps_final_commands_byte_identical() { grok) expected="grok --always-approve \"\$(cat '$home/data/$id/brief.md')\"" ;; + omp) + expected="omp --auto-approve -e '$home/state/$id.omp-ext.ts' \"\$(cat '$home/data/$id/brief.md')\"" + ;; esac actual=$(cat "$launchlog") [ "$actual" = "$expected" ] \ || fail "$harness absent-gate final command changed"$'\n'"expected: $expected"$'\n'"actual: $actual" done - for harness in codex pi; do + for harness in codex pi omp; do id="agent-secrets-${harness}-secondmate-z2" secondmate="$world/$id" make_secondmate_home "$secondmate" "$id" @@ -144,6 +149,9 @@ test_absent_gate_keeps_final_commands_byte_identical() { pi) expected="FM_ROOT_OVERRIDE= FM_STATE_OVERRIDE= FM_DATA_OVERRIDE= FM_PROJECTS_OVERRIDE= FM_CONFIG_OVERRIDE= FM_HOME='$secondmate_real' pi -e '$secondmate_real/.pi/extensions/fm-primary-turnend-guard.ts' -e '$secondmate_real/.pi/extensions/fm-primary-pi-watch.ts' \"\$(cat '$secondmate_real/data/charter.md')\"" ;; + omp) + expected="FM_ROOT_OVERRIDE= FM_STATE_OVERRIDE= FM_DATA_OVERRIDE= FM_PROJECTS_OVERRIDE= FM_CONFIG_OVERRIDE= FM_HOME='$secondmate_real' omp --auto-approve -e '$secondmate_real/.omp/extensions/fm-primary-turnend-guard.ts' -e '$secondmate_real/.omp/extensions/fm-primary-omp-watch.ts' \"\$(cat '$secondmate_real/data/charter.md')\"" + ;; esac actual=$(cat "$launchlog") [ "$actual" = "$expected" ] \ @@ -165,7 +173,7 @@ test_launch_template_baselines() { [ "$got" = "$expected" ] \ || fail "$harness/$kind baseline changed"$'\n'"expected: $expected"$'\n'"actual: $got" done < <(launch_template_baselines) - [ "$count" -eq 7 ] || fail "expected seven verified launch-template rows, got $count" + [ "$count" -eq 9 ] || fail "expected nine verified launch-template rows, got $count" pass "all verified harness templates match the pre-injection byte baseline" } @@ -228,7 +236,7 @@ assert_templates_for_gate() { [ "$got" = "$expected" ] \ || fail "$label: $harness/$kind launch mismatch"$'\n'"expected: $expected"$'\n'"actual: $got" done < <(launch_template_baselines) - [ "$count" -eq 7 ] || fail "$label: expected seven verified launch-template rows, got $count" + [ "$count" -eq 9 ] || fail "$label: expected nine verified launch-template rows, got $count" } test_all_presence_conditions_enable_prefix() { From 5713500ed5e24f7c7192a2b7ef3c2fc4d215df00 Mon Sep 17 00:00:00 2001 From: Apinant U-suwantim Date: Fri, 17 Jul 2026 09:51:45 +0700 Subject: [PATCH 14/16] no-mistakes(review): fail omp typecheck test on tsc error; sync guard comment --- bin/fm-turnend-guard.sh | 4 ++-- tests/fm-omp-primary-types.test.sh | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/bin/fm-turnend-guard.sh b/bin/fm-turnend-guard.sh index eef947f859a..2855d424fd8 100755 --- a/bin/fm-turnend-guard.sh +++ b/bin/fm-turnend-guard.sh @@ -11,8 +11,8 @@ # This script is push-based: verified harness turn-end hooks invoke it every time # the primary is about to end a turn. # Claude and codex can block directly by preserving exit status 2 and stderr. -# OpenCode, pi, and grok adapters use the same predicate and force one bounded -# follow-up because their turn-end events are passive. +# OpenCode, pi, grok, and omp adapters use the same predicate and force one +# bounded follow-up because their turn-end events are passive. # See docs/turnend-guard.md for the per-harness mechanics, validation evidence, # and fail-open tradeoffs. # diff --git a/tests/fm-omp-primary-types.test.sh b/tests/fm-omp-primary-types.test.sh index df0a4feb749..20a88418956 100755 --- a/tests/fm-omp-primary-types.test.sh +++ b/tests/fm-omp-primary-types.test.sh @@ -60,6 +60,6 @@ cat > "$TMP_ROOT/tsconfig.json" <<'JSON' } JSON -tsc -p "$TMP_ROOT/tsconfig.json" +tsc -p "$TMP_ROOT/tsconfig.json" || { printf 'not ok - OMP primary extensions failed strict typecheck\n' >&2; exit 1; } version=$(jq -r '.version' "$OMP_PACKAGE_DIR/package.json" 2>/dev/null || printf 'unknown') printf 'ok - OMP primary extensions pass strict no-emit typecheck against OMP %s\n' "$version" From 614e865af4993ebe8bf7677314b7dd09edaf7f87 Mon Sep 17 00:00:00 2001 From: Apinant U-suwantim Date: Fri, 17 Jul 2026 10:28:46 +0700 Subject: [PATCH 15/16] no-mistakes(document): sync guard transport and tmux liveness docs for omp --- bin/backends/tmux.sh | 10 +++++----- docs/arm-pretool-check.md | 2 +- docs/cd-guard.md | 3 ++- docs/tmux-backend.md | 7 ++++--- docs/turnend-guard.md | 2 +- 5 files changed, 13 insertions(+), 11 deletions(-) diff --git a/bin/backends/tmux.sh b/bin/backends/tmux.sh index dee5813d64b..70ddd4fca05 100644 --- a/bin/backends/tmux.sh +++ b/bin/backends/tmux.sh @@ -147,11 +147,11 @@ fm_backend_tmux_current_command() { # # own process name, never wrapped by a generic interpreter). # dead - the foreground command is a bare shell: nothing is running in # the pane, so a prior agent process has exited. -# unknown - anything else, INCLUDING a bare "node"/"python" interpreter -# name (pi's own launcher execs into a generic "node" process -# with no reliable way to attribute it back to pi from outside -# the pane - docs/tmux-backend.md "Known gaps"), or an unreadable -# pane. Callers must never treat unknown as a confirmed-dead +# unknown - anything else, INCLUDING a bare "node"/"python"/"bun" interpreter +# name (pi's launcher execs into a generic "node" process and +# omp's runs under "bun", with no reliable way to attribute either +# back from outside the pane - docs/tmux-backend.md "Known gaps"), +# or an unreadable pane. Callers must never treat unknown as a confirmed-dead # signal (bin/fm-bootstrap.sh's secondmate-liveness sweep gates a # respawn on `dead` only). fm_backend_tmux_agent_alive() { # diff --git a/docs/arm-pretool-check.md b/docs/arm-pretool-check.md index 86b8e504325..33c524ab191 100644 --- a/docs/arm-pretool-check.md +++ b/docs/arm-pretool-check.md @@ -24,7 +24,7 @@ It tokenizes the bytes and classifies lexical execution positions only. - Stdin JSON at `.tool_input.command` for Claude and Codex. - Stdin JSON at `.toolInput.command` for Grok. -- `--command ` for OpenCode and Pi. +- `--command ` for OpenCode, Pi, and OMP. - `--background` as a compatibility-only field that never changes the decision. - `--claude` to preserve Claude's stderr-only deny requirement. diff --git a/docs/cd-guard.md b/docs/cd-guard.md index 61046a72389..a6174f37a49 100644 --- a/docs/cd-guard.md +++ b/docs/cd-guard.md @@ -74,13 +74,14 @@ It does not permit `cd /home/project`, because an absolute-path `cd` remains a p ## Transport and fail-open behavior -`bin/fm-cd-pretool-check.sh` supports all five harness entry shapes used by the tracked adapters: +`bin/fm-cd-pretool-check.sh` supports all six harness entry shapes used by the tracked adapters: - Claude sends stdin JSON at `.tool_input.command` and adds `--claude` to preserve Claude's stderr-only deny requirement. - Codex sends stdin JSON at `.tool_input.command` without `--claude`. - Grok sends stdin JSON at `.toolInput.command`. - OpenCode sends the exact command string through `--command `. - Pi sends the exact command string through `--command `. +- OMP sends the exact command string through `--command ` (same shape as Pi, from the ported extension). Processing order is cheapest-first: a strict-superset prefilter, then the primary-checkout scope, then the Node policy owner. The prefilter removes ordinary single quotes, double quotes, backslashes, carriage returns, and newlines before fast-allowing any command that carries no `cd`, `pushd`, or `popd` substring and no quoting-decoder marker (`$'` ANSI-C or `$"` locale), so quoted or escaped command-word fragments delegate to the policy while most commands never pay for the git scoping calls or the Node process. diff --git a/docs/tmux-backend.md b/docs/tmux-backend.md index 544781e39e6..c2c74a6d9f3 100644 --- a/docs/tmux-backend.md +++ b/docs/tmux-backend.md @@ -103,12 +103,13 @@ The classifier (`fm_backend_tmux_agent_alive`) maps the observed name to `alive` - `dead` - the name is a bare shell (`zsh`, `bash`, `sh`, `dash`, `ash`, `ksh`, `mksh`, `tcsh`, `csh`, `fish`). - `unknown` - anything else, including an unreadable pane. -### Known gap: `pi` cannot be confidently classified +### Known gap: `pi` and `omp` cannot be confidently classified `pi` is a `#!/usr/bin/env node` script (confirmed via its shebang and installed path, 2026-07-07), so a live `pi` agent's pane reports `node` as its `pane_current_command`, not `pi` - verified by running a long-lived `node -e` script in a pane and confirming its foreground process is a genuine child reachable via `pgrep -P ` with an inspectable `ps -o args=` (the same technique `bin/fm-harness.sh`'s own self-detection uses when walking UP its ancestry), while `pi --version` itself was observed to exit too quickly under the same pane to reliably capture its live foreground state - real `pi` invocations were not available to test. Since `node` is also the generic name for a plain interpreter session, any future JS-based harness, or someone's unrelated node script, there is no way to attribute a bare `node` foreground process back to `pi` specifically from outside the pane without deeper (and fragile) argument introspection. -The classifier deliberately reports `unknown` for `node`/`python`/`python3` rather than guess - per the secondmate-liveness sweep's correctness bar, a wrong `alive` is harmless but a wrong `dead` spins up a duplicate agent, so an unresolvable case must never be treated as confidently dead. -Practical effect: a dead `pi` secondmate is not auto-healed by the liveness sweep today; it is reported as `skipped: liveness probe inconclusive` instead, which still surfaces it for a human to act on. +`omp` (Oh My Pi, a Pi fork) shares the same gap: it runs under the `bun` interpreter (the same ancestry fact `bin/fm-lock.sh` uses, matching omp in a `bun`/`node`/`python` process's args rather than by its own `comm`), so a live omp pane is expected to report a bare interpreter name - not yet captured on a live tmux pane, like omp's busy signature. +The classifier deliberately reports `unknown` for bare interpreter names (`node`/`python`/`python3`, and omp's `bun`) rather than guess - per the secondmate-liveness sweep's correctness bar, a wrong `alive` is harmless but a wrong `dead` spins up a duplicate agent, so an unresolvable case must never be treated as confidently dead. +Practical effect: a dead `pi` or `omp` secondmate is not auto-healed by the liveness sweep today; it is reported as `skipped: liveness probe inconclusive` instead, which still surfaces it for a human to act on. Resolving this would need either a `pi`-specific env marker inspectable from outside the process (mirroring `PI_CODING_AGENT=true`, which `bin/fm-harness.sh` already uses for self-detection but which is not readable from a different process without deeper introspection) or accepting the argument-inspection fragility - not attempted here. ## Limitations diff --git a/docs/turnend-guard.md b/docs/turnend-guard.md index a32bd7c4deb..ab41f296d2a 100644 --- a/docs/turnend-guard.md +++ b/docs/turnend-guard.md @@ -147,7 +147,7 @@ No Herdr command was issued and no fleet state was touched; the experiment wrote ## Tests -`tests/fm-turnend-guard.test.sh` covers the shared predicate, primary scoping (including a secondmate's own home being guarded like the main primary while its child worktrees stay exempt), `FM_HOME` and `FM_STATE_OVERRIDE` precedence, Pi logical-run latch behavior for no-tool and multi-tool runs, fail-open behavior without `jq`, tracked hook registration for all five harnesses, and the Grok adapter's forced-resume loop guard and permission-mode regression. +`tests/fm-turnend-guard.test.sh` covers the shared predicate, primary scoping (including a secondmate's own home being guarded like the main primary while its child worktrees stay exempt), `FM_HOME` and `FM_STATE_OVERRIDE` precedence, Pi logical-run latch behavior for no-tool and multi-tool runs, fail-open behavior without `jq`, tracked hook registration for the claude, codex, opencode, pi, and grok adapters (the omp extension is typechecked by `tests/fm-omp-primary-types.test.sh` and exercised by the opt-in live e2e below), and the Grok adapter's forced-resume loop guard and permission-mode regression. The default behavior suite does not invoke live language-model harnesses. `FM_PI_LIVE_E2E=1 tests/fm-pi-primary-live-e2e.test.sh` opts into the isolated interactive Pi regression recorded above. `FM_OMP_LIVE_E2E=1 tests/fm-omp-primary-live-e2e.test.sh` opts into the isolated interactive OMP regression recorded above. From 28be797a4b6e0681ec8f3f1bd69ad9f495763dfd Mon Sep 17 00:00:00 2001 From: Apinant U-suwantim Date: Fri, 17 Jul 2026 11:21:41 +0700 Subject: [PATCH 16/16] fix(harness): match omp/pi launched-form args in detect_own fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit detect_own's bare-interpreter args globs (`*" omp "*|*/omp`, `*" pi "*|*/pi`) only matched a trailing `/omp` or a space-delimited ` omp `, so the real launched form `bun /…/omp --auto-approve …` (a live omp runs as bun with the harness at a path-final component followed by flags) fell through to `unknown`, diverging from fm-lock.sh's `(^|/| )name( |$)` word boundary. Align both globs to that boundary. Detection is layered (OMPCODE=1 catches native omp first), so this hardens the fallback rather than fixing an observed failure. Verified live 2026-07-17: a live omp tmux pane reports pane_current_command=bun with foreground child `bun /Users/…/.bun/bin/omp`. docs/tmux-backend.md records that capture (removing the pending-verification caveat) and its liveness-gap summary now names omp's generic `bun` alongside pi's `node`; `bun` still cannot join the alive-glob without misclassifying unrelated bun processes. Add tests/fm-harness-detect.test.sh covering the launched-form match and guarding against false positives (omp workers, unrelated bun/node sessions). --- bin/fm-harness.sh | 11 +++-- docs/tmux-backend.md | 5 ++- tests/fm-harness-detect.test.sh | 79 +++++++++++++++++++++++++++++++++ 3 files changed, 90 insertions(+), 5 deletions(-) create mode 100755 tests/fm-harness-detect.test.sh diff --git a/bin/fm-harness.sh b/bin/fm-harness.sh index c1ca6142948..8d1b5102b88 100755 --- a/bin/fm-harness.sh +++ b/bin/fm-harness.sh @@ -52,15 +52,20 @@ detect_own() { pi) echo pi; return ;; omp) echo omp; return ;; node*|python*|bun*) - # Bare interpreter: match the harness name in its script path. + # Bare interpreter: match the harness name as a path-final component or a + # space-delimited word in its args, mirroring fm-lock.sh's (^|/| )name( |$) + # word-boundary semantics. A live omp/pi runs as `bun`/`node` with the + # harness at `.../omp` or `.../pi` (verified 2026-07-17: a live omp tmux + # pane reports pane_current_command=bun with args `bun /…/omp …`), so the + # match must also accept a trailing-flag form like `.../omp --auto-approve`. args=$(ps -o args= -p "$pid" 2>/dev/null) case "$args" in *claude*) echo claude; return ;; *codex*) echo codex; return ;; *opencode*) echo opencode; return ;; *grok*) echo grok; return ;; - *" pi "*|*/pi) echo pi; return ;; - *" omp "*|*/omp) echo omp; return ;; + */pi|*"/pi "*|*" pi"|*" pi "*) echo pi; return ;; + */omp|*"/omp "*|*" omp"|*" omp "*) echo omp; return ;; esac ;; esac pid=$(ps -o ppid= -p "$pid" 2>/dev/null | tr -d ' ') diff --git a/docs/tmux-backend.md b/docs/tmux-backend.md index c2c74a6d9f3..b9eae25cbf9 100644 --- a/docs/tmux-backend.md +++ b/docs/tmux-backend.md @@ -107,7 +107,8 @@ The classifier (`fm_backend_tmux_agent_alive`) maps the observed name to `alive` `pi` is a `#!/usr/bin/env node` script (confirmed via its shebang and installed path, 2026-07-07), so a live `pi` agent's pane reports `node` as its `pane_current_command`, not `pi` - verified by running a long-lived `node -e` script in a pane and confirming its foreground process is a genuine child reachable via `pgrep -P ` with an inspectable `ps -o args=` (the same technique `bin/fm-harness.sh`'s own self-detection uses when walking UP its ancestry), while `pi --version` itself was observed to exit too quickly under the same pane to reliably capture its live foreground state - real `pi` invocations were not available to test. Since `node` is also the generic name for a plain interpreter session, any future JS-based harness, or someone's unrelated node script, there is no way to attribute a bare `node` foreground process back to `pi` specifically from outside the pane without deeper (and fragile) argument introspection. -`omp` (Oh My Pi, a Pi fork) shares the same gap: it runs under the `bun` interpreter (the same ancestry fact `bin/fm-lock.sh` uses, matching omp in a `bun`/`node`/`python` process's args rather than by its own `comm`), so a live omp pane is expected to report a bare interpreter name - not yet captured on a live tmux pane, like omp's busy signature. +`omp` (Oh My Pi, a Pi fork) shares the same gap: it runs under the `bun` interpreter (the same ancestry fact `bin/fm-lock.sh` uses, matching omp in a `bun`/`node`/`python` process's args rather than by its own `comm`), so a live omp pane reports a bare interpreter name. +Confirmed 2026-07-17 by launching `omp` in a live tmux pane: `#{pane_current_command}` reads `bun`, and the pane's foreground child is `bun /Users/lex/.bun/bin/omp` (so, like `pi`'s `node`, `bun` is a generic interpreter name that cannot be added to the `alive` set without misclassifying unrelated `bun` processes); omp's busy-state regex is tracked separately and still pending a tmux-backend run. The classifier deliberately reports `unknown` for bare interpreter names (`node`/`python`/`python3`, and omp's `bun`) rather than guess - per the secondmate-liveness sweep's correctness bar, a wrong `alive` is harmless but a wrong `dead` spins up a duplicate agent, so an unresolvable case must never be treated as confidently dead. Practical effect: a dead `pi` or `omp` secondmate is not auto-healed by the liveness sweep today; it is reported as `skipped: liveness probe inconclusive` instead, which still surfaces it for a human to act on. Resolving this would need either a `pi`-specific env marker inspectable from outside the process (mirroring `PI_CODING_AGENT=true`, which `bin/fm-harness.sh` already uses for self-detection but which is not readable from a different process without deeper introspection) or accepting the argument-inspection fragility - not attempted here. @@ -115,4 +116,4 @@ Resolving this would need either a `pi`-specific env marker inspectable from out ## Limitations None specific to tmux for the reference path itself - it is the fully verified reference backend, while Orca and cmux are the backends without secondmate support. -The agent-liveness probe above has one known gap (`pi`'s generic `node` process name, see above). +The agent-liveness probe above has one known gap: `pi`'s generic `node` and omp's generic `bun` process names (see above). diff --git a/tests/fm-harness-detect.test.sh b/tests/fm-harness-detect.test.sh new file mode 100755 index 00000000000..911064d9032 --- /dev/null +++ b/tests/fm-harness-detect.test.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# Focused behavior tests for fm-harness.sh's detect_own Layer-2 ancestry match, +# specifically the bare-interpreter args branch (node/python/bun). +# +# Regression: a live omp runs as `bun /…/omp --auto-approve …` and a live pi as +# `node /…/pi …`, so the args carry the harness only as a path-final component +# followed by flags. The old globs (`*" omp "*|*/omp`, `*" pi "*|*/pi`) matched +# only a trailing `/omp` or a space-delimited ` omp `, so the real launched form +# (`.../omp --auto-approve …`) fell through to `unknown`. The globs now mirror +# fm-lock.sh's `(^|/| )name( |$)` word boundary. Verified live 2026-07-17: a real +# omp process reports comm=bun with args `bun /Users/…/.bun/bin/omp`. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +# detect_own checks OMPCODE/CLAUDECODE/PI_CODING_AGENT/GROK_AGENT first; drop any +# ambient markers (this suite may run inside a live omp/claude session) so the +# Layer-2 ancestry path under test is actually exercised. +unset OMPCODE CLAUDECODE PI_CODING_AGENT GROK_AGENT + +HARNESS="$ROOT/bin/fm-harness.sh" +TMP_ROOT=$(mktemp -d "${TMPDIR:-/tmp}/fm-harness-detect.XXXXXX") +FM_TEST_CLEANUP_DIRS+=("$TMP_ROOT") +trap fm_test_cleanup EXIT + +# Extract just detect_own so sourcing does not run the CLI dispatch. +FUNCTIONS="$TMP_ROOT/detect.sh" +awk '/^detect_own\(\)/ {c=1} c{print} c&&/^}/{c=0}' "$HARNESS" > "$FUNCTIONS" +# shellcheck disable=SC1090 # generated directly from the tracked script under test +. "$FUNCTIONS" + +# Fake ps: detect_own calls `ps -o comm= -p`, `ps -o args= -p`, `ps -o ppid= -p`. +# Return canned fields from FAKE_PS_* and a ppid of 1 so the ancestry walk ends +# after a single deterministic iteration. +FAKEBIN="$TMP_ROOT/bin" +mkdir -p "$FAKEBIN" +cat > "$FAKEBIN/ps" <<'SH' +#!/usr/bin/env bash +for a in "$@"; do + case "$a" in + comm=) printf '%s\n' "${FAKE_PS_COMM:-bun}"; exit 0 ;; + args=) printf '%s\n' "${FAKE_PS_ARGS:-}"; exit 0 ;; + ppid=) printf '%s\n' "${FAKE_PS_PPID:-1}"; exit 0 ;; + esac +done +exit 0 +SH +chmod +x "$FAKEBIN/ps" + +check() { + local desc=$1 comm=$2 args=$3 expected=$4 got + got=$(export PATH="$FAKEBIN:$PATH" FAKE_PS_COMM="$comm" FAKE_PS_ARGS="$args" FAKE_PS_PPID=1; detect_own) + [ "$got" = "$expected" ] \ + || fail "$desc: expected '$expected', got '$got'"$'\n'" comm=$comm args=$args" +} + +# The launched form firstmate actually spawns (the bug this fix closes). +check "omp launched form (bun, path + flags)" \ + bun "bun /Users/lex/.bun/bin/omp --auto-approve -e /s/x.omp-ext.ts prompt" omp +check "omp bare path at end of args" \ + bun "bun /Users/lex/.bun/bin/omp" omp +check "pi launched form (node, path + flags)" \ + node "node /Users/lex/.pi/bin/pi --thinking high -e /s/x.pi-ext.ts prompt" pi + +# Guard against false positives: an omp worker embeds omp only as `_omp_`, and an +# unrelated interpreter session must never be attributed to a harness. +check "omp worker args are NOT the harness" \ + bun "bun cli.js __omp_worker_daemon_broker" unknown +check "unrelated bun script is unknown" \ + bun "bun /Users/lex/tools/build.js --watch" unknown +check "unrelated node script is unknown" \ + node "node /some/random/server.js" unknown + +# Native binaries still match by args substring (unchanged behavior). +check "claude via args still matches" \ + node "node /opt/claude/cli.js" claude + +pass "detect_own resolves omp/pi from real bare-interpreter launched-form args and rejects non-harness interpreters"