Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion .omp/extensions/fm-primary-omp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,31 @@ export default function (omp: ExtensionAPI) {

const deliverSessionstartNudge = (forceForNativeSwitch = false): void => {
watch.markLoaded();
pendingStartupNudge = runSessionstartNudge(forceForNativeSwitch);
const nudge = runSessionstartNudge(forceForNativeSwitch);
if (!forceForNativeSwitch) {
pendingStartupNudge = nudge;
return;
}
// A native /new or /resume switch replaces the conversation while this
// process still owns the lock, so the watcher re-arms immediately and its
// first wake can start an agent-initiated turn. OMP never emits
// before_agent_start for such a turn, nor for a captain prompt queued into
// it, so a nudge staged for before_agent_start would stay parked past the
// new session's first turn. Append the instruction to the replacement
// session's context right now instead, ahead of any wake, and leave nothing
// staged so the next before_agent_start cannot deliver it a second time.
pendingStartupNudge = "";
if (!nudge) return;
omp.sendMessage(
{
customType: "firstmate-sessionstart-nudge",
content: nudge,
display: false,
attribution: "agent",
details: { kind: "session-start", runtime: "omp" },
},
{ deliverAs: "nextTurn" },
);
};

omp.on("session_start", (_event, ctx) => {
Expand Down
5 changes: 3 additions & 2 deletions docs/sessionstart-nudge.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ Every path exits 0, including malformed state and adapter errors, because a Clau
| Codex | `.codex/hooks.json` anchors to the hook process working directory, verifies a Firstmate-shaped hook-bearing root, and executes the wrapper. | Native stdout context injection is supported. |
| OpenCode | `.opencode/plugins/fm-primary-sessionstart-nudge.js` listens for `session.created`, runs once per session id, and calls `client.session.promptAsync` only when the wrapper prints a nudge. | Interactive TUI delivery is supported; headless `opencode run` is intentionally fail-open because the process can exit before the queued turn. |
| Pi / pi-signed | `.pi/extensions/fm-primary-turnend-guard.ts` handles `session_start` reasons `startup`, `new`, and `resume`, then injects the wrapper output with `pi.sendMessage`. | The custom message reaches model context without racing an initial positional prompt. |
| OMP | `.omp/extensions/fm-primary-omp.ts` runs the wrapper on native `session_start` and `session_switch`, then injects the result as one hidden `before_agent_start` message. A native `/new` or `/resume` switch re-injects the same typed instruction directly instead of through the wrapper, which stays silent while this session still holds the lock. | The custom message reaches model context without racing the launch prompt, and native project discovery loads the adapter without a trust prompt. |
| OMP | `.omp/extensions/fm-primary-omp.ts` runs the wrapper on native `session_start` and injects the result as one hidden `before_agent_start` message bound to the launch prompt. A native `/new` or `/resume` switch instead appends the same typed instruction to the replacement session's context at once through `sendMessage` without starting a turn: the wrapper stays silent while this session still holds the lock, and the restored watcher's first wake can start an agent-initiated turn that never reaches `before_agent_start`. `/fork` still invokes the wrapper silently under the held lock, and OMP reloads likewise keep that wrapper invocation silent while following the native `resume` switch transport. | The custom message reaches model context without racing the launch prompt or the restored watcher's first wake, and native project discovery loads the adapter without a trust prompt. |
| Grok | `.grok/hooks/fm-primary-sessionstart-nudge.json` registers a project `SessionStart` hook and invokes the wrapper through inline-defaulted `${GROK_WORKSPACE_ROOT:-}`. | The project hook runs when the checkout is trusted, but Grok currently discards hook stdout from model context, so this path is intentionally fail-open. |

The OpenCode nudge runs only on `session.created`.
Expand All @@ -38,7 +38,8 @@ That alternative expands trust and writes outside this repository, so Firstmate
`tests/fm-sessionstart-nudge.test.sh` proves wrapper silence for both gate signals, an unmarked linked worktree, a missing state directory, and an already-owned lock.
It proves exact U+2063 `FIRSTMATE_OP:`-prefixed, `session-start`-typed one-line output for a plain primary and a marked linked secondmate primary.
`tests/fm-pi-primary-live-e2e.test.sh` and `tests/fm-opencode-primary-live-e2e.test.sh` exercise native startup paths with first-message and later-message Ahoy regressions.
`tests/fm-omp-primary-live-e2e.test.sh` covers OMP's native discovery and once-only startup delivery, including a `/new` switch and an exact-session resume launch.
`tests/fm-omp-primary.test.sh` proves each in-process `/new` and `/resume` switch appends exactly one instruction without staging a second copy, including a second `/new` with no `before_agent_start` in between, while `/fork` stays silent under the held lock.
`tests/fm-omp-primary-live-e2e.test.sh` covers OMP's native discovery and once-only startup delivery, including two consecutive `/new` switches whose instruction precedes each new session's first assistant turn, and an exact-session resume launch.
`tests/fm-turnend-guard.test.sh`, `tests/fm-pi-watch-extension.test.sh`, and `tests/fm-daemon.test.sh` cover marked guard, monitoring, and away-mode delivery.

[`verification/supervision.md`](verification/supervision.md#native-session-start-delivery) records the active version-scoped transport evidence.
2 changes: 1 addition & 1 deletion docs/supervision-protocols/omp.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ When this session owns supervision and away mode is not active:
6. The extension starts `bin/fm-watch-arm.sh --restart`, keeps the child attached to the live OMP process, and owns every later successor launch.
The tool and the fallback command return only after that child reports readiness, so a `watcher: FAILED` readiness timeout is a real failure to handle under step 11 rather than a slow success.
7. OMP `/new`, `/resume`, `/fork`, and session reload emit `session_switch`, replace the prior extension generation, and restore the watcher without a foreground watcher command.
`/new` and `/resume` also inject the session-start instruction exactly once for the new conversation.
`/new` and `/resume` also append the session-start instruction exactly once to the new conversation, ahead of the restored watcher's first wake.
8. After an actionable child close, the shared watcher core rechecks session-lock ownership and verifies one successor before it delivers the follow-up notification; a replacement generation receives an actionable close whose prior delivery was not yet consumed.
9. Ordinary work, turn completion, and ordinary notification handling must not call `fm_watch_arm_omp` again because continuity is extension-owned.
10. An unexpected child close enters bounded exponential retry, and an exhausted retry or lost session lock is surfaced as a watcher failure.
Expand Down
16 changes: 14 additions & 2 deletions docs/verification/supervision.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,18 @@ ok - OMP omp/17.2.10 primary E2E proved watcher delivery with an intact editable
```

The live guard observed the watcher wake in the OMP session and found the exact draft unchanged after delivery.
The OMP 18.1.5 repeated-`/new` delivery guard ran on 2026-09-05 after the adapter began appending the replacement instruction through `sendMessage` at switch time; the guard now drives two consecutive `/new` switches and requires each new session's instruction to precede its first assistant record.

```sh
FM_OMP_PRIMARY_LIVE_E2E=1 bin/fm-test-run.sh tests/fm-omp-primary-live-e2e.test.sh
```

```text
ok - OMP omp/18.1.5 primary E2E proved fresh no-state and ordinary native discovery, exact ownership, once-only startup, guarded watcher startup, repeated /new continuity, shutdown, resume, and away-mode delivery
ok - OMP omp/18.1.5 primary E2E proved watcher delivery with an intact editable draft
```

Before that change the same guard failed on OMP 18.1.5 at the first `/new`: the restored watcher's first wake started an agent-initiated turn, and OMP emits `before_agent_start` neither for that turn nor for a captain prompt queued into it, so an instruction staged for `before_agent_start` never reached the replacement session.

Standalone OMP executable compatibility is recorded in [runtime backend verification](runtime-backends.md#omp-lifecycle).

Expand Down Expand Up @@ -354,8 +366,8 @@ FM_OMP_PRIMARY_LIVE_E2E=1 bin/fm-test-run.sh tests/fm-omp-primary-live-e2e.test.
not ok - OMP /new did not inject exactly one startup instruction for the new session
```

The identical pre-port failure establishes a pre-existing OMP 18.1.5 session-start-nudge compatibility finding rather than a watcher-continuity regression.
The provider-free adapter regression remains the current proof that its `/new` and `/resume` replacement handlers each stage one nudge while re-arming, and the live guard remains intentionally red until that separate compatibility finding is resolved.
The identical pre-port failure established a pre-existing OMP 18.1.5 session-start-nudge compatibility finding rather than a watcher-continuity regression.
That finding was resolved on 2026-09-05; the [native session-start delivery](#native-session-start-delivery) section records the passing repeated-`/new` guard, and `tests/fm-omp-primary.test.sh` keeps the provider-free proof that each `/new` and `/resume` replacement handler appends exactly one instruction while re-arming, including a second `/new` with no `before_agent_start` in between.
The remaining primary harnesses are not applicable because they do not bind the shared Pi-compatible extension generation lifecycle.

The once-per-generation recovery bound and immediate handling-successor poll were verified on 2026-08-21 at revision `549dd1e0ff05f96607c5e7457b4d8e3d7396bd16` with the tracked Pi extension, real watcher processes, and an isolated home.
Expand Down
49 changes: 47 additions & 2 deletions tests/fm-omp-primary-live-e2e.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,28 @@ wait_pid_change() {
return 1
}

# The replacement session's instruction must already be in context when its
# first turn starts, whether the restored watcher's first wake or a captain
# prompt starts that turn.
session_nudge_precedes_first_assistant() { # <session-file>
node -e '
const lines = require("node:fs").readFileSync(process.argv[1], "utf8").trimEnd().split("\n");
let nudge = -1;
let assistant = -1;
lines.forEach((line, index) => {
let entry;
try { entry = JSON.parse(line); } catch { return; }
if (nudge < 0 && entry.type === "custom_message" && entry.customType === "firstmate-sessionstart-nudge") nudge = index;
if (assistant < 0 && entry.message?.role === "assistant") assistant = index;
});
process.exit(nudge >= 0 && (assistant < 0 || nudge < assistant) ? 0 : 1);
' "$1"
}

newest_session_file() {
find "$SESSION_DIR" -maxdepth 1 -type f -name '*.jsonl' -print | sort | tail -n 1
}

submit_omp() {
local text=$1 bun bin
bun=$(sed -n '3p' "$MARKER")
Expand Down Expand Up @@ -269,6 +291,29 @@ done
[ "${sessions_after:-0}" -gt "$sessions_before" ] || fail "OMP /new did not create a native session"
[ "$(grep -Rhc '"customType":"firstmate-sessionstart-nudge"' "$SESSION_DIR"/*.jsonl | awk '{s+=$1} END{print s+0}')" -eq 2 ] \
|| fail "OMP /new did not inject exactly one startup instruction for the new session"
session_nudge_precedes_first_assistant "$(newest_session_file)" \
|| fail "OMP /new startup instruction did not precede the new session's first assistant turn"

# A second /new must deliver its own instruction even though the first
# replacement's turn may have been started by the restored watcher rather than
# by a captain prompt.
sessions_before=$sessions_after
submit_omp /new || fail "OMP second /new command was not submitted"
third_watch_pid=$(wait_pid_change "$WATCH_LOCK" "$second_watch_pid") \
|| fail "OMP second /new did not replace and restore the extension-owned watcher generation"
submit_omp 'Reply exactly OMP_SECOND_NEW_READY.' || fail "second new OMP session did not accept a prompt"
wait_text OMP_SECOND_NEW_READY || fail "second new OMP session did not complete its first turn"
for _ in $(seq 1 240); do
sessions_after=$(find "$SESSION_DIR" -maxdepth 1 -type f -name '*.jsonl' | wc -l | tr -d ' ')
[ "$sessions_after" -gt "$sessions_before" ] && break
sleep 0.25
done
[ "${sessions_after:-0}" -gt "$sessions_before" ] || fail "OMP second /new did not create a native session"
[ "$(grep -Rhc '"customType":"firstmate-sessionstart-nudge"' "$SESSION_DIR"/*.jsonl | awk '{s+=$1} END{print s+0}')" -eq 3 ] \
|| fail "OMP second /new did not inject exactly one startup instruction for its new session"
session_nudge_precedes_first_assistant "$(newest_session_file)" \
|| fail "OMP second /new startup instruction did not precede its session's first assistant turn"
second_watch_pid=$third_watch_pid

submit_omp /exit || fail "OMP primary /exit was not submitted"
for _ in $(seq 1 240); do
Expand Down Expand Up @@ -296,7 +341,7 @@ second_omp_pid=$(sed -n '2p' "$MARKER")
[ "$second_omp_pid" = "$(cat "$LOCK")" ] || fail "OMP resume did not bind the lock to the new process"
resume_watch_pid=$(wait_pid_change "$WATCH_LOCK" "$second_watch_pid") \
|| fail "OMP resume did not restore a live watcher generation"
[ "$(grep -Rhc '"customType":"firstmate-sessionstart-nudge"' "$SESSION_DIR"/*.jsonl | awk '{s+=$1} END{print s+0}')" -eq 3 ] \
[ "$(grep -Rhc '"customType":"firstmate-sessionstart-nudge"' "$SESSION_DIR"/*.jsonl | awk '{s+=$1} END{print s+0}')" -eq 4 ] \
|| fail "OMP process resume did not add exactly one fresh startup instruction"
kill -0 "$resume_watch_pid" 2>/dev/null || fail "OMP resume watcher is not live"

Expand All @@ -322,7 +367,7 @@ grep -R -F 'OMP_AWAY_DELIVERY' "$SESSION_DIR"/*.jsonl >/dev/null 2>&1 \
grep -R -F 'away-supervisor' "$SESSION_DIR"/*.jsonl >/dev/null 2>&1 \
|| fail "OMP away-mode notification lost its operational-input kind"
wait_idle || fail "OMP $OMP_VERSION did not reach an idle boundary after away-mode delivery"
printf 'ok - OMP %s primary E2E proved fresh no-state and ordinary native discovery, exact ownership, once-only startup, guarded watcher startup, /new continuity, shutdown, resume, and away-mode delivery\n' \
printf 'ok - OMP %s primary E2E proved fresh no-state and ordinary native discovery, exact ownership, once-only startup, guarded watcher startup, repeated /new continuity, shutdown, resume, and away-mode delivery\n' \
"$OMP_VERSION"
draft="human-draft-survives-omp-watcher-wake"
PATH="$WRAPPER_BIN:$PATH" tmux send-keys -t "$TARGET" -l "$draft"
Expand Down
53 changes: 39 additions & 14 deletions tests/fm-omp-primary.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -536,24 +536,49 @@ if (await handlers.get("before_agent_start")({ type: "before_agent_start" }, {})
throw new Error("startup nudge repeated within one OMP session");
}
writeFileSync(`${process.env.FM_STATE_OVERRIDE}/.lock`, `${process.pid}\n`);
// A native switch re-arms the watcher at once, and OMP starts its first wake as
// an agent-initiated turn that never emits before_agent_start. The replacement
// nudge therefore has to land in the replacement session context at switch time,
// through sendMessage, and nothing may stay staged for a later before_agent_start.
const switchNudges = () => watcherMessages.filter((entry) => entry.message.customType === "firstmate-sessionstart-nudge");
async function expectSwitchNudge(label, expectedTotal) {
const nudges = switchNudges();
if (nudges.length !== expectedTotal) {
throw new Error(`${label} did not append exactly one startup instruction (${nudges.length} total): ${JSON.stringify(nudges)}`);
}
const latest = nudges[nudges.length - 1];
if (
latest.message.content !== "encoded:session-start:Run `bin/fm-session-start.sh` now, exactly once, before executing any other instructions." ||
latest.message.attribution !== "agent" ||
latest.message.display !== false ||
latest.options?.deliverAs !== "nextTurn" ||
latest.options?.triggerTurn !== undefined
) {
throw new Error(`${label} startup instruction was not appended as hidden agent context without a turn: ${JSON.stringify(latest)}`);
}
for (const attempt of [1, 2]) {
if (await handlers.get("before_agent_start")({ type: "before_agent_start" }, {}) !== undefined) {
throw new Error(`${label} staged its startup instruction for before_agent_start too (attempt ${attempt})`);
}
}
}
await handlers.get("session_switch")({ type: "session_switch", reason: "new" }, extensionContext);
await waitForWatchCount(1, "in-process OMP /new automatic watcher arm");
const newStartup = await handlers.get("before_agent_start")({ type: "before_agent_start" }, {});
if (newStartup?.message?.customType !== "firstmate-sessionstart-nudge" || newStartup.message.attribution !== "agent") {
throw new Error(`in-process OMP /new lost its once-only startup instruction: ${JSON.stringify(newStartup)}`);
}
if (await handlers.get("before_agent_start")({ type: "before_agent_start" }, {}) !== undefined) {
throw new Error("in-process OMP /new repeated its startup instruction");
}
await expectSwitchNudge("in-process OMP /new", 1);
// Regression: a second /new with no before_agent_start in between must still
// deliver its own nudge because the replacement turn was agent-initiated.
await handlers.get("session_switch")({ type: "session_switch", reason: "new" }, extensionContext);
await waitForWatchCount(2, "in-process OMP second /new automatic watcher arm");
await expectSwitchNudge("in-process OMP second /new", 2);
await handlers.get("session_switch")({ type: "session_switch", reason: "resume" }, extensionContext);
await waitForWatchCount(2, "in-process OMP /resume automatic watcher arm");
const resumeStartup = await handlers.get("before_agent_start")({ type: "before_agent_start" }, {});
if (resumeStartup?.message?.customType !== "firstmate-sessionstart-nudge" || resumeStartup.message.attribution !== "agent") {
throw new Error(`in-process OMP /resume lost its once-only startup instruction: ${JSON.stringify(resumeStartup)}`);
}
if (await handlers.get("before_agent_start")({ type: "before_agent_start" }, {}) !== undefined) {
throw new Error("in-process OMP /resume repeated its startup instruction");
await waitForWatchCount(3, "in-process OMP /resume automatic watcher arm");
await expectSwitchNudge("in-process OMP /resume", 3);
await handlers.get("session_switch")({ type: "session_switch", reason: "fork" }, extensionContext);
await waitForWatchCount(4, "in-process OMP /fork automatic watcher arm");
if (switchNudges().length !== 3 || await handlers.get("before_agent_start")({ type: "before_agent_start" }, {}) !== undefined) {
throw new Error("in-process OMP /fork delivered a startup instruction while this session still holds the lock");
}
watcherMessages.length = 0;

const signal = new AbortController().signal;
const stop = await handlers.get("session_stop")({
Expand Down
Loading