@junghanacs/entwurf 0.15.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/AGENTS.md +1 -0
  2. package/CHANGELOG.md +299 -0
  3. package/DELIVERY.md +4 -3
  4. package/README.md +99 -67
  5. package/VERIFY.md +13 -7
  6. package/docs/acp-backend-rail.md +1 -1
  7. package/docs/external-mcp-host.md +147 -9
  8. package/docs/setup-clean-host.md +221 -16
  9. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +65 -20
  10. package/mcp/entwurf-bridge/dist/pi-extensions/entwurf-capabilities.json +1 -0
  11. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-self-address.js +17 -6
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-sender-identity.js +1 -1
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +172 -5
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +152 -17
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/omp-fresh-preflight.js +271 -0
  16. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-omp.js +988 -0
  17. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-receive-omp.js +574 -0
  18. package/mcp/entwurf-bridge/dist/scripts/omp-receive-facts.js +84 -0
  19. package/mcp/entwurf-bridge/src/index.ts +65 -19
  20. package/mcp/entwurf-bridge/tsconfig.build.json +19 -3
  21. package/mcp/tsconfig.json +10 -0
  22. package/package.json +14 -4
  23. package/pi/entwurf-capabilities.json +1 -0
  24. package/pi/meta-bridge-omp/entwurf-meta-omp/package.json +7 -0
  25. package/pi/omp-receive/entwurf-receive-omp/package.json +7 -0
  26. package/pi-extensions/entwurf-control.ts +12 -9
  27. package/pi-extensions/lib/entwurf-self-address.ts +18 -7
  28. package/pi-extensions/lib/meta-sender-identity.ts +1 -1
  29. package/pi-extensions/lib/meta-session.ts +219 -5
  30. package/pi-extensions/lib/mux-fresh-call.ts +171 -17
  31. package/pi-extensions/lib/omp-fresh-preflight.ts +275 -0
  32. package/pi-extensions/meta-bridge-omp.ts +1244 -0
  33. package/pi-extensions/meta-bridge-receive-omp.ts +666 -0
  34. package/run.sh +1041 -174
  35. package/scripts/agy-bridge.sh +4 -2
  36. package/scripts/check-copilot-birth-hook.ts +700 -23
  37. package/scripts/check-entwurf-self-address.ts +31 -0
  38. package/scripts/check-fresh-cut-gate.sh +12 -4
  39. package/scripts/check-gate-qualification.ts +8 -2
  40. package/scripts/check-harness-admission-parity.ts +143 -0
  41. package/scripts/check-meta-doctor-oracle.sh +20 -0
  42. package/scripts/check-omp-birth-hook.ts +1049 -0
  43. package/scripts/check-omp-fresh-preflight.ts +181 -0
  44. package/scripts/check-omp-receive-arm.ts +516 -0
  45. package/scripts/check-setup-qualification.sh +126 -0
  46. package/scripts/copilot-bridge-doctor.sh +104 -71
  47. package/scripts/copilot-bridge-install.sh +180 -20
  48. package/scripts/copilot-bridge-oracle.sh +223 -0
  49. package/scripts/copilot-bridge-uninstall.sh +137 -0
  50. package/scripts/dev-bin.sh +50 -9
  51. package/scripts/fake-copilot-vendor.sh +67 -0
  52. package/scripts/inventory-verification-surface.ts +1 -1
  53. package/scripts/meta-bridge-hook-log.sh +9 -1
  54. package/scripts/mutants/copilot-birth.json +96 -5
  55. package/scripts/mutants/mux-fresh-call.json +22 -22
  56. package/scripts/mutants/omp-birth.json +173 -0
  57. package/scripts/mutants/omp-fresh.json +300 -0
  58. package/scripts/mutants/omp-receive.json +135 -0
  59. package/scripts/mutants/pi-package-ownership.json +92 -0
  60. package/scripts/mutants/self-address.json +34 -0
  61. package/scripts/mutants/setup-verdict.json +148 -0
  62. package/scripts/mutants/source-install.json +32 -0
  63. package/scripts/omp-bridge-doctor.sh +315 -0
  64. package/scripts/omp-bridge-install.sh +221 -0
  65. package/scripts/omp-bridge-oracle.sh +154 -0
  66. package/scripts/omp-bridge-uninstall.sh +57 -0
  67. package/scripts/omp-mcp-bridge.sh +320 -0
  68. package/scripts/omp-mcp-config.py +392 -0
  69. package/scripts/omp-receive-doctor.sh +246 -0
  70. package/scripts/omp-receive-facts.ts +106 -0
  71. package/scripts/omp-receive-install.sh +228 -0
  72. package/scripts/omp-receive-uninstall.sh +60 -0
  73. package/scripts/omp-tool-surface.py +349 -0
  74. package/scripts/pi_settings_io.py +22 -0
  75. package/scripts/raw-omp-measure/README.md +420 -0
  76. package/scripts/raw-omp-measure/probe-extension.ts +76 -0
  77. package/scripts/raw-omp-measure/probe-receive-surface.ts +250 -0
  78. package/scripts/raw-omp-measure/source-audit.md +414 -0
  79. package/scripts/register-pi-package.py +525 -7
  80. package/scripts/register-pi-provider.py +162 -12
  81. package/scripts/smoke-agy-install-state.sh +86 -30
  82. package/scripts/smoke-agy-statusline-state.sh +9 -6
  83. package/scripts/smoke-omp-bridge-state.sh +221 -0
  84. package/scripts/smoke-omp-fresh-live.ts +497 -0
  85. package/scripts/smoke-omp-mcp-state.sh +327 -0
  86. package/scripts/smoke-omp-receive-live.ts +340 -0
  87. package/scripts/smoke-omp-receive-state.sh +196 -0
  88. package/scripts/smoke-setup-verdict.sh +291 -0
  89. package/scripts/smoke-user-scope-citizen.sh +388 -1
  90. package/scripts/tsconfig.json +2 -0
@@ -0,0 +1,1244 @@
1
+ /**
2
+ * meta-bridge-omp — the OMP (oh-my-pi) native-session BIRTH entry (#87).
3
+ *
4
+ * WHAT THIS IS, AND WHAT IT DELIBERATELY IS NOT
5
+ *
6
+ * It mints the visible host session, then names the minted citizen as this host's SENDER:
7
+ *
8
+ * session_start | session_switch (omp in-process extension events)
9
+ * -> mode === "tui" ? the §3.5 discriminator — the ONE new predicate in this lane
10
+ * -> roots() the shared OMP four-root policy, resolved in code
11
+ * -> upsertMetaSession(omp) idempotent create/attach the record
12
+ * -> gardenId the session's garden address
13
+ * -> writeMetaSenderMarker(process.pid) who-sent join for this host's MCP children
14
+ * -> ctx.ui.setStatus(...) visible identity (step 4)
15
+ * -> any other mode REFUSE + log. A task subagent is not a citizen.
16
+ *
17
+ * No receiver marker, no mailbox arm — omp's receive rail is a separate admission (#87
18
+ * bundle B). A receiver marker is a claim that a LIVE process holds a watch; nothing
19
+ * installed by this unit holds one, and minting it here would make a citizen read
20
+ * deliverable while wired to nothing.
21
+ *
22
+ * WHY THIS FILE IS AN EXTENSION FACTORY AND NOT AN EXEC'd PAYLOAD. `[source]` omp
23
+ * "hooks" are an in-process EXTENSION event bus — `--hook` is an alias of `--extension`,
24
+ * a module default-exporting `(pi: ExtensionAPI) => void`, and handlers receive
25
+ * `(event, ctx)` (oh-my-pi v18.0.0 `docs/hooks.md` "Current status in runtime";
26
+ * `packages/coding-agent/src/extensibility/extensions/types.ts:1186`, `:1592`). There is
27
+ * no stdin envelope and no per-event child process, so the Claude/Copilot launcher pair
28
+ * (`hook-launch.sh` + `exec`) has nothing to do here: this code already runs INSIDE the
29
+ * omp process. That is also what makes the step-6 join a ONE-process join — see
30
+ * resolveOmpOwnerPid below.
31
+ *
32
+ * THE SCOPE FENCE IS `mode === "tui"`, AND IT IS LOAD-BEARING. `[source]` Every task
33
+ * subagent re-emits its own `session_start` (`task/executor.ts:3305`) against a fresh
34
+ * per-session extension API (`sdk.ts:2000-2028`), and bundled agents do NOT set
35
+ * `restrictToolNames`, so this very factory is re-executed inside subagents by default.
36
+ * Same OS pid therefore does not prevent minting — only the mode does. Only the
37
+ * interactive TUI controller passes `"tui"` (`modes/controllers/extension-ui-controller.ts:302`;
38
+ * `:531` is test-only), the runner default is `"print"` (`runner.ts:438`, `:651`), and
39
+ * rpc / rpc-ui / `omp acp` all pass `"rpc"`. `ctx.hasUI` is NOT a fence and must never be
40
+ * used as one: rpc, rpc-ui and ACP all hand the runner a real uiContext, so `hasUI` is
41
+ * true there (`runner.ts:879-881`) while the `types.ts:465` comment says otherwise —
42
+ * a live instance of the docs-vs-code trap. `[LIVE 2026-08-27]` host
43
+ * `{mode:"tui",hasUI:true}` vs a real task subagent `{mode:"print",hasUI:false}` on the
44
+ * SAME pid 479624, record store 519 before == 519 after
45
+ * (`scripts/raw-omp-measure/README.md` §3.5).
46
+ *
47
+ * `session_shutdown` IS NOT WIRED, AND THAT IS NOT AN OVERSIGHT. `[LIVE 2026-08-27]` on
48
+ * `/exit` a `session_shutdown` arrived with a `"print"` context BEFORE the tui one, in the
49
+ * same run — shutdown events are not host-scoped. It is not a birth edge, so nothing here
50
+ * listens to it; a future unit must not read it as one either.
51
+ *
52
+ * BOTH BIRTH EDGES, BECAUSE THE NAME DOES NOT TELL YOU WHEN IT FIRES. `[source, audited
53
+ * C1/C2]` The TUI host fires `session_start` once per process, after first paint and
54
+ * before the first prompt (`interactive-mode.ts:1221`, `:1238`,
55
+ * `extension-ui-controller.ts:309-311`) — no model turn required. `/new`, fork and in-TUI
56
+ * resume then re-fire as `session_switch` (reasons `"new"`/`"fork"`/`"resume"`,
57
+ * `agent-session.ts:6910-8074`), NOT as `session_start`. A birth unit wired only to
58
+ * `session_start` would leave every post-`/new` session unminted, still showing the
59
+ * previous citizen's id in the status line. The upsert is idempotent and keyed by the
60
+ * native session id, so the switch edge attaches when the id is unchanged and mints the
61
+ * replacement's own record when it changed — the same rule pi's in-process replacement
62
+ * already follows.
63
+ *
64
+ * FAILURE POLICY, inherited from the Claude and Copilot units: BEST-EFFORT + LOG. Never
65
+ * throw into the operator's TUI, never block a turn. On any error append a level-tagged
66
+ * line to `<omp garden root>/meta-bridge-hook.log` (see `roots()` — the OMP root policy,
67
+ * NOT `PI_CODING_AGENT_DIR`) — the same file the other native units
68
+ * append to, tagged `[omp]` so one grep still covers the host and each doctor stays
69
+ * rail-scoped (`scripts/meta-bridge-hook-log.sh`). The fail-loud surface is
70
+ * `./run.sh doctor-omp-bridge`, which reads that log.
71
+ *
72
+ * LAUNCH: the installer places this file as `index.ts` inside
73
+ * `<omp agent dir>/extensions/entwurf-meta-omp/`, which is one of omp's three native
74
+ * discovery rules — a subdirectory whose entry is `index.{ts,js}` (`discovery/builtin.ts:483`
75
+ * → `discovery/helpers.ts:625-712`, preference at `:700-710`). Nothing is baked into this
76
+ * file and nothing declares a path to it: the rule needs only the name.
77
+ */
78
+
79
+ import * as fs from "node:fs";
80
+ import * as path from "node:path";
81
+ import {
82
+ isPlausibleOwnerPid,
83
+ type MetaRootBundle,
84
+ ompMetaRootBase,
85
+ ompMetaRoots,
86
+ requireGardenId,
87
+ upsertMetaSession,
88
+ writeMetaSenderMarker,
89
+ } from "./lib/meta-session.ts";
90
+
91
+ /**
92
+ * The §3.5 host discriminator. THE one new predicate this whole lane is allowed to
93
+ * introduce, and it is the vendor's own top-level mode — not a heuristic over cwd,
94
+ * process age, pid or session id. If a vendor upgrade flips it, STOP and remeasure
95
+ * (issue #87 stop rule); do not add a second predicate beside it.
96
+ */
97
+ const HOST_MODE = "tui";
98
+
99
+ /** Our own status key, owned exactly. Hook status text is stored per key and sorted by
100
+ * key name (`docs/hooks.md` "Status line behavior"), so this string is the whole extent
101
+ * of our claim on the operator's status line. */
102
+ const STATUS_KEY = "entwurf";
103
+
104
+ /** The backend id this unit mints under. Registered in `META_BACKENDS` (#87 A1). */
105
+ const BACKEND = "omp" as const;
106
+
107
+ // ---------------------------------------------------------------------------
108
+ // THE TWO-STAGE FRESH BOOTSTRAP (#87 Bundle C).
109
+ //
110
+ // WHY THE POSITIONAL PROMPT HAD TO GO, MEASURED RATHER THAN ARGUED. `[LIVE 2026-08-30]`
111
+ // the first public `entwurf_fresh_call` at omp handed the whole fresh prompt to the vendor
112
+ // as a bare positional argument. The window opened, the record minted
113
+ // (garden `20260830T181342-452167`, native `01a051f2-3107-7147-8806-fa2a6f527610`), the
114
+ // byte-identical prompt arrived as a user message at `09:13:42.413Z` — and the model
115
+ // answered the literal text `ACK` at `09:13:47.105Z` with ZERO tool calls, because the
116
+ // callback tool did not exist yet. The caller timed out at 240s and the pane took a SIGHUP.
117
+ //
118
+ // `[source]` The gap is structural, not a slow host. The interactive UI DEFERS MCP
119
+ // discovery (`sdk.ts:1847-1855`), starts `discoverAndConnect()` fire-and-forget and only
120
+ // calls `session.refreshMCPTools()` once it settles (`sdk.ts:1881-1905`), while
121
+ // `session_start` is awaited inside `mode.init()` (`extension-ui-controller.ts:302-312`)
122
+ // and the positional `initialMessage` prompts immediately after `await mode.init()`
123
+ // (`main.ts:540-565`, `595-610`). `[측정 2026-08-30]` a `/tmp` observer on the same bare
124
+ // runtime: `turn_start` at +654ms with the entwurf tools ABSENT, callback tool present only
125
+ // at +1484ms. The turn began ~830ms before the tool it was told to call existed.
126
+ //
127
+ // SO THE FIRST TURN IS NOT COMPOSED BY ARGV ANY MORE. The launcher carries a payload on a
128
+ // fixed registered flag, and THIS unit — already in-process, already the thing that knows
129
+ // when the session is real — waits for the callback tool, sends a callback-ONLY prompt, and
130
+ // releases the task only after it has seen that exact call succeed:
131
+ //
132
+ // argv `--entwurf-bootstrap <payload>` fixed, one purpose, registered before argv parse
133
+ // -> session_start (mode === "tui") the same fence birth already owns
134
+ // -> record + sender marker AUTHORITY FIRST, exactly as before
135
+ // -> decode + validate payload refuse narrowly; a bad payload never starts a turn
136
+ // -> bounded readiness poll getAllTools(source==="mcp") AND getActiveTools()
137
+ // -> sendUserMessage(callback-only) NO task, no ACK, no competing goal
138
+ // -> tool_call exact name+target+nonce remember toolCallId
139
+ // -> tool_result same id, isError===false -> release, THEN sendUserMessage(task)
140
+ //
141
+ // `[측정 2026-08-30]` the callback-only half is the half that was proven: model
142
+ // `openai-codex/gpt-5.6-sol`, no positional task, tool present at +1105ms, prompt injected at
143
+ // +1107ms, and the sibling called `mcp__entwurf_bridge_entwurf_v` with the exact nonce
144
+ // (`omp-cb-btkvva4r87` -> `20260830T184054-1aa1f2`, result `meta-mailbox → enqueued`) with no
145
+ // ACK/DONE competition anywhere in the transcript. That is why the task is a SECOND message
146
+ // and not a clause in the first one.
147
+ //
148
+ // THE FLAG IS ONE PURPOSE AND NOT A CARRIER. `[측정 2026-08-30]` a normal discovered
149
+ // extension that calls `registerFlag` at factory time receives the operator's argv value
150
+ // byte-identical — quotes, `$VAR`, backticks and a semicolon all survived a 137-byte JSON
151
+ // payload — because extensions load BEFORE argv classification and `applyExtensionFlags`
152
+ // reparses with the registered map (`main.ts:1799-1810`, `cli/extension-flags.ts:36-43`,
153
+ // `extensions/loader.ts:221-228`). That is also why nothing here reads `process.env` or a
154
+ // temp file: argv owns its own quoting, and a general env/command carrier would hand callers
155
+ // the environment-shaping power the fresh rail exists to refuse.
156
+ // ---------------------------------------------------------------------------
157
+
158
+ /** The ONE argv flag this unit owns. Registered without the leading dashes — the vendor's
159
+ * flag map is keyed by bare name (`extensions/loader.ts:221-228`). */
160
+ export const OMP_BOOTSTRAP_FLAG = "entwurf-bootstrap";
161
+
162
+ /** Payload grammar version. A payload that does not say exactly this is refused rather than
163
+ * best-guessed: the launcher and this unit ship in the same package, so a mismatch means a
164
+ * STALE installed unit — the one condition `doctor-omp-bridge` exists to name out loud. */
165
+ export const OMP_BOOTSTRAP_VERSION = 1;
166
+
167
+ /**
168
+ * The model-facing callback tool, spelled for omp. `[측정]` the vendor's minter sanitises to
169
+ * `[a-z_]` and EATS the digit in `entwurf_v2` (`mcp/tool-bridge.ts:384-396`;
170
+ * `scripts/raw-omp-measure/README.md` "Tool-name dialect").
171
+ *
172
+ * It is duplicated from `FRESH_CALL_CALLBACK_TOOL.omp` ON PURPOSE and the duplication is
173
+ * GATED. This file is copied into `<omp agent dir>/extensions/entwurf-meta-omp/` with only
174
+ * `lib/meta-session` and `lib/session-id` beside it, so it cannot import the launcher's
175
+ * module; the alternative — growing the installed unit's closure by a file — grows the
176
+ * installer, the doctor's parity list and two artifact manifests for a constant. The two
177
+ * spellings are held equal by `test/omp-fresh-bootstrap.contract.test.ts`, the same shape
178
+ * `check-omp-fresh-preflight` already uses for the preflight's reproduced oracles.
179
+ */
180
+ export const OMP_BOOTSTRAP_CALLBACK_TOOL = "mcp__entwurf_bridge_entwurf_v";
181
+
182
+ /** Task ceiling, held equal to the launcher's `TASK_MAX_CHARS` by the same contract test. */
183
+ export const OMP_BOOTSTRAP_TASK_MAX_CHARS = 16000;
184
+
185
+ /** How long readiness may take before this bootstrap FAILS and the task is never sent.
186
+ * `[측정]` the tool appeared at +1484ms and +1105ms on the two observed runs; this is two
187
+ * orders of margin over that, and still well inside the caller's own 240s callback wait, so
188
+ * a timeout here surfaces as an honest fresh-call timeout rather than a silent hang. */
189
+ export const OMP_BOOTSTRAP_READY_TIMEOUT_MS = 60_000;
190
+
191
+ /** Readiness poll period. There is NO public MCP-ready event in v18.0.0 — the only
192
+ * mechanisms are the private `MCPManager#setOnToolsChanged` (`mcp/manager.ts:296-306`) and
193
+ * `ExtensionRunner#onToolRegistered`, which fires for extension-registered tools rather than
194
+ * MCP ones (`runner.ts:907-978`). Polling the two public snapshots is the smallest honest
195
+ * primitive; a FIXED DELAY is forbidden because the gap is a race, not a constant. */
196
+ export const OMP_BOOTSTRAP_POLL_MS = 100;
197
+
198
+ /** The nonce the launcher mints (`mintNonce`: `mux-fresh-call-` + 12 random bytes as hex). */
199
+ const BOOTSTRAP_NONCE_RE = /^mux-fresh-call-[0-9a-f]{24}$/;
200
+
201
+ /** What the launcher put on the flag. Exactly three fields — a target to call back to, the
202
+ * correlation nonce, and the operator's task. Nothing here is a command, a path or an env
203
+ * name, and the decoder refuses anything wider. */
204
+ export interface OmpBootstrapPayload {
205
+ target: string;
206
+ nonce: string;
207
+ task: string;
208
+ }
209
+
210
+ /** Every way a payload can be refused. Each is logged verbatim, because each names a
211
+ * different repair: a stale unit, a launcher bug, or an operator who typed the flag by hand. */
212
+ export type OmpBootstrapRejectReason =
213
+ | "flag-absent"
214
+ | "flag-not-string"
215
+ | "payload-not-json"
216
+ | "payload-not-object"
217
+ | "version-unsupported"
218
+ | "payload-unknown-key"
219
+ | "target-invalid"
220
+ | "nonce-invalid"
221
+ | "task-empty"
222
+ | "task-too-long";
223
+
224
+ export type OmpBootstrapDecode =
225
+ | { ok: true; value: OmpBootstrapPayload }
226
+ | { ok: false; reason: OmpBootstrapRejectReason };
227
+
228
+ /** The wire keys, closed. An unknown key is a REFUSAL rather than an ignored extra: this
229
+ * flag is the one thing a caller can put arbitrary bytes into, so the decoder's job is to
230
+ * make "what the launcher meant" and "what this unit will act on" the same set. */
231
+ const BOOTSTRAP_KEYS = new Set(["v", "target", "nonce", "task"]);
232
+
233
+ /**
234
+ * Read the flag value into a payload, or name why not.
235
+ *
236
+ * `getFlag` returns `boolean | string | undefined` and only for flags THIS extension
237
+ * registered (`extensions/loader.ts:253-255`), so a `string` check is a real discriminator
238
+ * and not a formality. `target` is validated by the SHARED record validator rather than a
239
+ * local regex — the callback has to reach a garden id the store would accept, and a second
240
+ * spelling of that rule here is a second place for it to drift.
241
+ */
242
+ export function decodeOmpBootstrapPayload(raw: unknown): OmpBootstrapDecode {
243
+ if (raw === undefined) return { ok: false, reason: "flag-absent" };
244
+ if (typeof raw !== "string" || raw.length === 0) return { ok: false, reason: "flag-not-string" };
245
+ let parsed: unknown;
246
+ try {
247
+ parsed = JSON.parse(raw);
248
+ } catch {
249
+ return { ok: false, reason: "payload-not-json" };
250
+ }
251
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
252
+ return { ok: false, reason: "payload-not-object" };
253
+ }
254
+ const obj = parsed as Record<string, unknown>;
255
+ if (obj.v !== OMP_BOOTSTRAP_VERSION) return { ok: false, reason: "version-unsupported" };
256
+ for (const key of Object.keys(obj)) {
257
+ if (!BOOTSTRAP_KEYS.has(key)) return { ok: false, reason: "payload-unknown-key" };
258
+ }
259
+ let target: string;
260
+ try {
261
+ target = requireGardenId(obj.target);
262
+ } catch {
263
+ return { ok: false, reason: "target-invalid" };
264
+ }
265
+ const nonce = obj.nonce;
266
+ if (typeof nonce !== "string" || !BOOTSTRAP_NONCE_RE.test(nonce)) return { ok: false, reason: "nonce-invalid" };
267
+ const task = obj.task;
268
+ if (typeof task !== "string" || task.trim().length === 0) return { ok: false, reason: "task-empty" };
269
+ if (task.length > OMP_BOOTSTRAP_TASK_MAX_CHARS) return { ok: false, reason: "task-too-long" };
270
+ return { ok: true, value: { target, nonce, task } };
271
+ }
272
+
273
+ /**
274
+ * Stage one's whole message: call back, and nothing else.
275
+ *
276
+ * THE ABSENCE OF THE TASK IS THE FEATURE. `[측정 2026-08-30]` the failed LIVE asked for the
277
+ * callback AND carried the task in the same prompt, and the model answered `ACK` — one
278
+ * observation, confounded by the missing tool, but the reference run that DID work carried
279
+ * no competing goal at all. Stage two exists so this message never has to compete with the
280
+ * work; adding "then do X" back into it would rebuild exactly the prompt that failed.
281
+ */
282
+ export function buildOmpCallbackOnlyPrompt(params: { target: string; nonce: string }): string {
283
+ return [
284
+ "You are a fresh visible citizen that entwurf opened in the operator's tmux session.",
285
+ "",
286
+ `FIRST AND ONLY ACTION RIGHT NOW: call ${OMP_BOOTSTRAP_CALLBACK_TOOL} with ` +
287
+ `target=${params.target}, intent=fire-and-forget, wants_reply=false, and ` +
288
+ `message set to exactly ${params.nonce} — that string alone, nothing added.`,
289
+ "That call is how the agent that opened you learns your address. Make the call before",
290
+ "reading files, before planning, and before answering in prose. Do not reword the message.",
291
+ "",
292
+ "Do not inspect environment variables, do not call entwurf_self, and do not start an MCP",
293
+ "server yourself. Your own report of your identity is not the address anyone needs.",
294
+ "",
295
+ "Your actual task arrives as the NEXT user message, immediately after that call succeeds.",
296
+ "Do not ask for it and do not guess at it.",
297
+ ].join("\n");
298
+ }
299
+
300
+ /**
301
+ * WHERE THIS UNIT WRITES — the shared OMP four-root policy, resolved in CODE (#87 B1).
302
+ *
303
+ * `PI_CODING_AGENT_DIR` means "pi's persistence root" to entwurf and "my agent dir" to
304
+ * the OMP vendor, and `setProfile` exports it for every named profile
305
+ * (`oh-my-pi` v18.0.0 `packages/utils/src/dirs.ts:452-473`). A unit that let the shared
306
+ * default resolve its roots would send an `omp --profile work` session's record and marker
307
+ * into a different garden store — into a pi sandbox, if that is where the value came from.
308
+ *
309
+ * IT HAS TO BE CODE HERE, and that is the structural half of the fix. This extension runs
310
+ * IN-PROCESS inside the omp host, so there is no exec for a launcher to sanitise: nothing
311
+ * outside this file can repair the environment before the first write. The bridge CHILD
312
+ * half of the same policy lives in `applyOmpBridgeChildRootPolicy`, and both read the same
313
+ * pure leaf so their agreement is by construction rather than by coincidence.
314
+ *
315
+ * Resolved per call rather than once at module load: the factory is re-executed per
316
+ * session (including inside subagents), and a cached root would outlive the context that
317
+ * justified it.
318
+ */
319
+ function roots(): MetaRootBundle {
320
+ return ompMetaRoots();
321
+ }
322
+
323
+ /**
324
+ * Where the diagnostic log goes — and it must resolve even when the ROOT POLICY REFUSED
325
+ * this environment (a relative `ENTWURF_META_*` override, #87 A2). That refusal is exactly
326
+ * the moment an operator needs a line to read, so it must never be the moment logging
327
+ * disappears. A hook log is a diagnostic, not a garden artifact, so falling back to the
328
+ * policy's own unambiguous base is honest: it never consults the refused override, and it
329
+ * never consults `PI_CODING_AGENT_DIR`.
330
+ */
331
+ function hookLogFile(): string {
332
+ try {
333
+ return path.join(path.dirname(roots().sessionsDir), "meta-bridge-hook.log");
334
+ } catch {
335
+ return path.join(ompMetaRootBase(), "meta-bridge-hook.log");
336
+ }
337
+ }
338
+
339
+ // ---------------------------------------------------------------------------
340
+ // Vendor surface, typed NARROWLY and locally.
341
+ //
342
+ // omp is not a dependency of this repo and its types are not published here, so the
343
+ // shapes below are hand-written from the measured v18.0.0 source rather than imported.
344
+ // They are deliberately the SMALLEST subset this unit touches: a wider mirror would rot
345
+ // silently at the next vendor release, and every field named here has a receipt.
346
+ // ---------------------------------------------------------------------------
347
+
348
+ /** `ReadonlySessionManager` — `session-manager.ts:359-376` (`getSessionId` `:1946-1948`,
349
+ * `getSessionFile` `:1950-1952` returns `string | undefined` because persistence is lazy,
350
+ * `getCwd`). */
351
+ interface OmpReadonlySessionManager {
352
+ getSessionId(): unknown;
353
+ getSessionFile(): unknown;
354
+ getCwd(): unknown;
355
+ }
356
+
357
+ /** `ExtensionUIContext.setStatus` — `types.ts:285`, called as `ctx.ui.setStatus(key, text)`
358
+ * (`docs/hooks.md` "Status line behavior"). It is the ONLY rendering surface at v18:
359
+ * `setFooter`/`setHeader` are literal TUI no-ops (`extension-ui-controller.ts:139`) and the
360
+ * built-in statusLine segment set is a closed enum with no custom/command segment
361
+ * (`status-line/segments.ts:731-757`). */
362
+ interface OmpExtensionUiContext {
363
+ setStatus(key: string, text: string | undefined): void;
364
+ }
365
+
366
+ /**
367
+ * `ExtensionContext` — `types.ts:455-480`. `mode` is `"tui" | "rpc" | "json" | "print"`.
368
+ *
369
+ * `setTimeout` and `clearTimer` are the VENDOR-OWNED timer surface (`types.ts:501-509`): a
370
+ * `ctx.setTimeout` is registered against the session and cleared automatically on
371
+ * `session_shutdown`, and `clearTimer` is the only canceller this vendor exposes. They are
372
+ * optional here on the same terms as every other vendor member — a build that drops one must
373
+ * cost this unit a logged refusal, never a throw into the operator's TUI. The receive unit
374
+ * reached that rule first, for the same reason (`meta-bridge-receive-omp.ts:423-436`).
375
+ */
376
+ interface OmpExtensionContext {
377
+ mode: unknown;
378
+ cwd: unknown;
379
+ ui?: Partial<OmpExtensionUiContext>;
380
+ sessionManager?: OmpReadonlySessionManager;
381
+ setTimeout?(fn: () => void, ms: number): unknown;
382
+ clearTimer?(handle: unknown): void;
383
+ }
384
+
385
+ /** The two birth edges. `SessionSwitchEvent` carries `reason` (`shared-events.ts:42-49`);
386
+ * `SessionStartEvent` carries nothing but its type (`:28-31`). */
387
+ interface OmpSessionEvent {
388
+ type?: unknown;
389
+ reason?: unknown;
390
+ }
391
+
392
+ /**
393
+ * `tool_call` — fired BEFORE a tool executes (`extensions/types.ts:916-964`, emitted at
394
+ * `session/agent-session.ts:3431-3467` and `extensions/wrapper.ts:171-220`). An MCP tool
395
+ * arrives on the `CustomToolCallEvent` arm: `toolName` is a plain string and `input` a plain
396
+ * record. `[source]` there is NO `serverName`, no original MCP tool name, no session id and
397
+ * no mode on the event — which is precisely why the fence below is built from the handler's
398
+ * `ctx`, and the match from the tool's own arguments.
399
+ */
400
+ interface OmpToolCallEvent {
401
+ toolCallId?: unknown;
402
+ toolName?: unknown;
403
+ input?: unknown;
404
+ }
405
+
406
+ /**
407
+ * `tool_result` — fired AFTER execution (`extensions/types.ts:966-1017`,
408
+ * `extensions/wrapper.ts:348-414`). Every runtime tool is wrapped when a runner exists
409
+ * (`session/session-tools.ts:523-527`) and the MCP refresh path wraps its adapters too
410
+ * (`:1649-1684`), so an MCP callback reaches BOTH events. `isError` is the vendor's own
411
+ * success axis and MCP protocol errors set it (`mcp/tool-bridge.ts:230-275`).
412
+ */
413
+ interface OmpToolResultEvent extends OmpToolCallEvent {
414
+ isError?: unknown;
415
+ }
416
+
417
+ /** `ToolInfo` as `getAllTools()` returns it (`session/session-tools.ts:497-514`). The only
418
+ * field this unit reads besides the name is the MCP provenance. */
419
+ interface OmpToolInfo {
420
+ name?: unknown;
421
+ sourceInfo?: { source?: unknown } | null;
422
+ }
423
+
424
+ /**
425
+ * `ExtensionAPI`, still the SMALLEST subset this unit touches — now four more members than
426
+ * the birth-only version, each with its receipt:
427
+ *
428
+ * `registerFlag` `extensions/types.ts:1367-1381`; stored at `loader.ts:221-228`.
429
+ * `getFlag` `loader.ts:253-255` — returns only flags THIS extension registered.
430
+ * `getAllTools` `types.ts:1429-1436` -> `ToolInfo[]` with `sourceInfo.source`.
431
+ * `getActiveTools` `extension-ui-controller.ts:185-187` -> enabled tool NAMES.
432
+ * `sendUserMessage` `types.ts:1417-1421`; delivery semantics at `agent-session.ts:6509-6556`.
433
+ *
434
+ * Every one is OPTIONAL here. `[source]` action methods throw
435
+ * `ExtensionRuntimeNotInitializedError` until the runner initialises (`loader.ts:65-112`), and
436
+ * a vendor that renames or drops one must cost this unit a logged WARN — never a throw into
437
+ * the operator's TUI, which is this file's standing failure policy.
438
+ */
439
+ interface OmpExtensionApi {
440
+ on(
441
+ event: "session_start" | "session_switch",
442
+ handler: (event: OmpSessionEvent, ctx: OmpExtensionContext) => void,
443
+ ): void;
444
+ on(event: "tool_call", handler: (event: OmpToolCallEvent, ctx: OmpExtensionContext) => void): void;
445
+ on(event: "tool_result", handler: (event: OmpToolResultEvent, ctx: OmpExtensionContext) => void): void;
446
+ /** `turn_end` — `{turnIndex, message, toolResults}` and nothing session-identifying
447
+ * (`extensibility/shared-events.ts:211-217`; emitted at `agent-session.ts:3607-3620`). */
448
+ on(event: "turn_end", handler: (event: unknown, ctx: OmpExtensionContext) => void): void;
449
+ registerFlag?(
450
+ name: string,
451
+ options: { description?: string; type: "boolean" | "string"; default?: boolean | string },
452
+ ): void;
453
+ getFlag?(name: string): boolean | string | undefined;
454
+ getAllTools?(): readonly OmpToolInfo[];
455
+ getActiveTools?(): readonly string[];
456
+ sendUserMessage?(content: string, options?: { deliverAs?: "steer" | "followUp" }): void;
457
+ }
458
+
459
+ type LogLevel = "INFO" | "WARN" | "ERROR";
460
+
461
+ /** Append a best-effort diagnostic line; swallow even its own failure. Same log file and
462
+ * same LEVEL vocabulary as the Claude and Copilot units, tagged `[omp]`. */
463
+ function logLine(level: LogLevel, message: string): void {
464
+ try {
465
+ const file = hookLogFile();
466
+ fs.mkdirSync(path.dirname(file), { recursive: true });
467
+ fs.appendFileSync(file, `${new Date().toISOString()} ${level} [omp] ${message}\n`);
468
+ } catch {
469
+ /* logging is best-effort; a broken log must not break the session */
470
+ }
471
+ }
472
+
473
+ /** The two fields birth needs plus the one nullable axis the vendor can supply. */
474
+ export interface OmpBirthEnvelope {
475
+ nativeSessionId: string;
476
+ cwd: string;
477
+ /** `getSessionFile()` when the vendor already has a path for it, else null. */
478
+ transcriptPath: string | null;
479
+ }
480
+
481
+ function asNonEmptyString(value: unknown): string {
482
+ return typeof value === "string" && value.length > 0 ? value : "";
483
+ }
484
+
485
+ /**
486
+ * Reduce the vendor context to the fields birth needs, or refuse.
487
+ *
488
+ * REFUSE A DEGRADED ENVELOPE; NEVER GUESS A FIELD (`adding-a-harness.md` step 3(c)). A
489
+ * record minted from a guessed id is a citizen no live session can be joined back to.
490
+ *
491
+ * `cwd` HAS TWO VENDOR SOURCES AND THEY MUST AGREE. `ctx.cwd` (`types.ts:470`) and
492
+ * `ctx.sessionManager.getCwd()` are both vendor-authoritative, so `a ?? b` would silently
493
+ * pick a winner exactly when the disagreement is the interesting fact. Both present and
494
+ * equal is fine, one present is fine, both present and different is a REFUSAL — the same
495
+ * rule the Copilot unit applies to its two envelope shapes. There is deliberately no
496
+ * `process.cwd()` fallback: this process is the omp host, whose cwd may be anything the
497
+ * operator launched it from.
498
+ *
499
+ * `transcriptPath` is nullable at mint by design and is NOT refused when absent:
500
+ * `getSessionFile()` is lazy and may legitimately name nothing yet (`session-manager.ts:1950-1952`).
501
+ * `model` is omitted rather than guessed, exactly as the Copilot unit omits it.
502
+ */
503
+ export function readBirthEnvelope(ctx: OmpExtensionContext): OmpBirthEnvelope | { refusal: string } {
504
+ const manager = ctx.sessionManager;
505
+ if (!manager || typeof manager.getSessionId !== "function") {
506
+ return { refusal: "ctx.sessionManager is missing or has no getSessionId()" };
507
+ }
508
+ let rawId: unknown;
509
+ let rawManagerCwd: unknown;
510
+ let rawFile: unknown;
511
+ try {
512
+ rawId = manager.getSessionId();
513
+ rawManagerCwd = typeof manager.getCwd === "function" ? manager.getCwd() : undefined;
514
+ rawFile = typeof manager.getSessionFile === "function" ? manager.getSessionFile() : undefined;
515
+ } catch (err) {
516
+ return { refusal: `ctx.sessionManager threw: ${err instanceof Error ? err.message : String(err)}` };
517
+ }
518
+ const nativeSessionId = asNonEmptyString(rawId);
519
+ if (!nativeSessionId) return { refusal: "sessionManager.getSessionId() is not a non-empty string" };
520
+
521
+ const ctxCwd = asNonEmptyString(ctx.cwd);
522
+ const managerCwd = asNonEmptyString(rawManagerCwd);
523
+ if (ctxCwd && managerCwd && ctxCwd !== managerCwd) {
524
+ return { refusal: `ctx.cwd and sessionManager.getCwd() disagree (${ctxCwd} vs ${managerCwd})` };
525
+ }
526
+ const cwd = ctxCwd || managerCwd;
527
+ if (!cwd) return { refusal: "neither ctx.cwd nor sessionManager.getCwd() is a non-empty string" };
528
+
529
+ return { nativeSessionId, cwd, transcriptPath: asNonEmptyString(rawFile) || null };
530
+ }
531
+
532
+ /**
533
+ * Which pid does a sender marker written here belong to — or NONE.
534
+ *
535
+ * THE JOIN IS ONE PROCESS, NOT TWO. `[source]` omp spawns its stdio MCP servers from the
536
+ * host process itself; on Linux `detached: true` means setsid only, so the child keeps omp
537
+ * as its parent (`mcp/transports/stdio.ts:41-57`, `:334`). The extension runs in that same
538
+ * host process (there is no launcher to `exec` through), so the pid the bridge child will
539
+ * look its marker up under is OUR OWN `process.pid` — not `process.ppid`, which here is the
540
+ * shell that started omp. `[LIVE 2026-08-27]` `479023 -bash` → `479624 omp` (probe pid ==
541
+ * omp pid) → `479695 node …/entwurf-bridge/src/index.ts` with ppid 479624
542
+ * (`scripts/raw-omp-measure/README.md` M5).
543
+ *
544
+ * THE THREE GUARDS ARE ALL STILL ASKED; ONE OF THEM IS ANSWERED BY A DIFFERENT VENDOR FACT.
545
+ * 0. PLAUSIBLE OWNER — `isPlausibleOwnerPid`, the predicate shared with every marker
546
+ * writer, reader and the generation cut (#53 A). Asked here, and asked again inside
547
+ * `writeMetaSenderMarker`.
548
+ * 1. PID + START-KEY LIVENESS — stamped by `writeMetaSenderMarker` itself
549
+ * (`ownerStartKey`), so a dead session's reused pid cannot inherit its garden id.
550
+ * 2. THE BACKING RECORD — the marker is written INSIDE the successful-upsert branch
551
+ * below, never before it.
552
+ * The Claude and Copilot units add a fourth, LAUNCH PROVENANCE check
553
+ * (`ENTWURF_META_HOOK_LAUNCH`), because their payload is a separate process that has to
554
+ * prove its parent really is the harness. That question cannot arise here — this code is
555
+ * not a child of the host, it IS the host — and the matching question that CAN arise ("is
556
+ * this omp process the operator-visible session, or a task subagent borrowing my factory?")
557
+ * is answered by the vendor's own `mode === "tui"` before this function is ever reached.
558
+ * That is a substitution of a measured vendor fact for another rail's measured vendor fact,
559
+ * not a dropped guard: if the mode fence is ever removed, this marker writer becomes wrong
560
+ * in the same breath as the mint.
561
+ */
562
+ function resolveOmpOwnerPid(): number | null {
563
+ const ownerPid = process.pid;
564
+ if (!isPlausibleOwnerPid(ownerPid)) return null;
565
+ return ownerPid;
566
+ }
567
+
568
+ /**
569
+ * Arm who-sent for this host's MCP children, or say in the log why it could not be.
570
+ *
571
+ * BEST-EFFORT + LOG, like the mint above it. The three outcomes get three distinct tokens
572
+ * because they need three different fixes, and `doctor-omp-bridge` greps them on an axis
573
+ * of their own — a marker failure is NOT a birth failure, and a doctor that judged them on
574
+ * one axis would print "the hook ran and did not mint" about a session whose record is
575
+ * right there (`adding-a-harness.md` step 6, measured on Copilot):
576
+ *
577
+ * `sender marker <pid> -> <gid>` armed.
578
+ * `sender-marker-refused` we declined to claim an owner. Fail-closed, WARN.
579
+ * `sender-marker-failed` the write itself broke. ERROR.
580
+ *
581
+ * Either non-success costs only who-sent: the citizen still exists, still appears in
582
+ * `entwurf_peers`, and can still be addressed BY others. Only its own outbound sends fall
583
+ * back to the bridge's default refusal.
584
+ */
585
+ function writeOmpSenderMarker(gardenId: string, envelope: OmpBirthEnvelope): void {
586
+ const ownerPid = resolveOmpOwnerPid();
587
+ if (ownerPid === null) {
588
+ logLine(
589
+ "WARN",
590
+ `sender-marker-refused garden=${gardenId}: this process's own pid ${process.pid} is not a plausible owner; ` +
591
+ "this citizen exists but cannot send",
592
+ );
593
+ return;
594
+ }
595
+ try {
596
+ writeMetaSenderMarker({
597
+ backend: BACKEND,
598
+ gardenId,
599
+ nativeSessionId: envelope.nativeSessionId,
600
+ cwd: envelope.cwd,
601
+ ownerPid,
602
+ sendersDir: roots().sendersDir,
603
+ });
604
+ logLine("INFO", `sender marker ${ownerPid} -> ${gardenId}`);
605
+ } catch (err) {
606
+ logLine(
607
+ "ERROR",
608
+ `sender-marker-failed pid=${ownerPid} garden=${gardenId}: ${err instanceof Error ? err.message : String(err)}`,
609
+ );
610
+ }
611
+ }
612
+
613
+ /**
614
+ * Show the garden id on the harness's own persistent visible surface (step 4).
615
+ *
616
+ * `ctx.ui.setStatus` is the ONLY surface that renders extension-owned text on a v18 TUI,
617
+ * and it is gated by `statusLine.showHookStatus`, default true
618
+ * (`settings-schema.ts:952-956`). Failure to render is NOT a birth failure, so this never
619
+ * throws and never blocks the marker or the record; it logs at WARN and the doctor reads
620
+ * that. The rendered string mirrors the agy statusline's shape (`🪛 <garden-id> <rail>`)
621
+ * so an operator with two harnesses open reads one vocabulary.
622
+ */
623
+ function showGardenId(ctx: OmpExtensionContext, gardenId: string): void {
624
+ const setStatus = ctx.ui?.setStatus;
625
+ if (typeof setStatus !== "function") {
626
+ logLine("WARN", `status-refused garden=${gardenId}: ctx.ui.setStatus is not available on this context`);
627
+ return;
628
+ }
629
+ try {
630
+ setStatus.call(ctx.ui, STATUS_KEY, `🪛 ${gardenId} omp`);
631
+ } catch (err) {
632
+ logLine("WARN", `status-failed garden=${gardenId}: ${err instanceof Error ? err.message : String(err)}`);
633
+ }
634
+ }
635
+
636
+ // ---------------------------------------------------------------------------
637
+ // Stage machine. One bootstrap per minted visible session, owned by the closure that
638
+ // created it — the same creator-owned discipline the Bundle B receiver established, and for
639
+ // the same reason: nothing here may assume a later event's `ctx` belongs to the session that
640
+ // started the work.
641
+ // ---------------------------------------------------------------------------
642
+
643
+ /**
644
+ * Where one bootstrap is. The transitions are one-way and only `task-sent` has ever
645
+ * delivered the caller's task.
646
+ *
647
+ * `released` IS NOT THE END, AND THAT SPLIT IS THE #87 STAGE-TWO CORRECTION. `[LIVE
648
+ * 2026-08-30]` the first two-stage candidate sent the task from inside the `tool_result`
649
+ * handler with an explicit `deliverAs: "followUp"`. Everything up to that point worked —
650
+ * `bootstrap-armed`, `bootstrap-ready` at +440ms, `bootstrap-callback-observed`,
651
+ * `bootstrap-released` — and the task still never appeared in the session at all. `[source]`
652
+ * the vendor says why in one sentence: "Omitted `deliverAs` starts a turn when idle and
653
+ * queues as a steer while streaming. Explicit `deliverAs` queues WITHOUT starting a turn in
654
+ * either state." (`session/agent-session.ts:6511-6513`, implementation `:6515-6556`). Inside
655
+ * a `tool_result` handler the callback turn is still streaming, so there is no good option
656
+ * there: explicit queues into a turn nobody starts, and omitted steers the callback turn.
657
+ * `[LIVE 2026-08-30]` the same transcript proved the working form three seconds later — the
658
+ * Bundle B doorbell's OMITTED-option `sendUserMessage` landed as a real user message and
659
+ * started a turn on the same idle session.
660
+ *
661
+ * So `released` records "the callback provably succeeded" and nothing more; the send waits
662
+ * for the `turn_end` boundary, where the callback turn is over and the omitted-option call
663
+ * means what it says.
664
+ */
665
+ export type OmpBootstrapPhase = "waiting" | "callback-sent" | "released" | "task-sent" | "failed";
666
+
667
+ /** The clock/timer seam. It has NO default: production passes the creator context's own
668
+ * vendor timers (`creatorOwnedTimers`) and the gate passes a fake clock, so there is no
669
+ * syntax in this file for a bootstrap to reach a global timer. */
670
+ export interface OmpBootstrapTimers {
671
+ set(fn: () => void, ms: number): unknown;
672
+ clear(handle: unknown): void;
673
+ now(): number;
674
+ }
675
+
676
+ export interface OmpBootstrapHandle {
677
+ phase(): OmpBootstrapPhase;
678
+ /** The tool call id this bootstrap is waiting on a result for, once it has seen one. */
679
+ pendingCallId(): string | null;
680
+ start(): void;
681
+ onToolCall(event: OmpToolCallEvent): void;
682
+ onToolResult(event: OmpToolResultEvent): void;
683
+ /** The stage-two boundary. Sends the task exactly once, and only from `released`. */
684
+ onTurnEnd(): void;
685
+ /** Stop this bootstrap for a reason that is not its own timeout — a session switch, or a
686
+ * replacement bootstrap taking over. Never sends anything. */
687
+ invalidate(why: string): void;
688
+ }
689
+
690
+ /**
691
+ * The only timer surface a production bootstrap may have: the creator context's own.
692
+ *
693
+ * WHY THE RAW GLOBAL `setTimeout` HAD TO GO. `[source]` the vendor registers a
694
+ * `ctx.setTimeout` against the session and clears it automatically on `session_shutdown`
695
+ * (`extensions/types.ts:501-509`). A raw global timer is in no registry at all, so inside a
696
+ * LIVING omp process it survives the disposal of the session that scheduled it and keeps
697
+ * polling state that is gone. `unref` never answered that — it only stops a poll from holding
698
+ * the process open, which is a different property from ownership.
699
+ *
700
+ * AND IT IS ONLY HALF THE REPAIR. The vendor's automatic clear fires on session SHUTDOWN,
701
+ * while `/new`, a fork and a same-file resume are all `session_switch` INSIDE a living
702
+ * process — no shutdown, so no automatic clear. This binding stops a poll from outliving a
703
+ * DISPOSED session; the unconditional epoch invalidation in `startOmpBootstrap` stops a
704
+ * bootstrap from outliving a REPLACED one. Neither covers the other's case, which is why the
705
+ * amendment carries both.
706
+ *
707
+ * REFUSING TO START AN UNCANCELLABLE TIMER IS THE POINT, and it is the receive unit's
708
+ * existing rule rather than a new one (`meta-bridge-receive-omp.ts:423-436`): `clearTimer` is
709
+ * the only canceller this vendor exposes, so a build without it would leave a 100ms poll
710
+ * running in the operator's TUI with no way to stop it. `null` here means this launch never
711
+ * arms — the public caller then times out on a callback that did not happen, which is honest,
712
+ * rather than this unit installing a defect to avoid a timeout.
713
+ */
714
+ function creatorOwnedTimers(ctx: OmpExtensionContext): OmpBootstrapTimers | null {
715
+ const set = ctx?.setTimeout;
716
+ const clear = ctx?.clearTimer;
717
+ if (typeof set !== "function" || typeof clear !== "function") return null;
718
+ // CAPTURE THE CREATOR, NOT JUST THE HANDLE — the receiver's rule, for its reason: nothing
719
+ // measured says a second event context can cancel a timer a first one created. After these
720
+ // two lines there is no syntax here for a foreign context to schedule or cancel this poll.
721
+ return {
722
+ set: (fn, ms) => set.call(ctx, fn, ms),
723
+ clear: (handle) => {
724
+ clear.call(ctx, handle);
725
+ },
726
+ now: () => Date.now(),
727
+ };
728
+ }
729
+
730
+ /**
731
+ * Is the exact callback tool callable RIGHT NOW — asked of BOTH public snapshots.
732
+ *
733
+ * ONE SNAPSHOT IS NOT ENOUGH, AND THAT IS A MEASURED CLAIM ABOUT THE VENDOR, NOT CAUTION.
734
+ * `getAllTools()` is the full registry with provenance (`session-tools.ts:497-514`) while
735
+ * `getActiveTools()` is the ENABLED name list (`extension-ui-controller.ts:185-187`,
736
+ * `session-tools.ts:364-372`) — a tool can be registered and not enabled, and a prompt can
737
+ * only call an enabled one. Requiring `sourceInfo.source === "mcp"` on the registry side is
738
+ * what stops an identically-named extension or built-in tool from being read as the bridge.
739
+ */
740
+ export function ompCallbackToolReady(pi: OmpExtensionApi): boolean {
741
+ let all: readonly OmpToolInfo[];
742
+ let active: readonly string[];
743
+ try {
744
+ if (typeof pi.getAllTools !== "function" || typeof pi.getActiveTools !== "function") return false;
745
+ all = pi.getAllTools() ?? [];
746
+ active = pi.getActiveTools() ?? [];
747
+ } catch {
748
+ // Action methods throw until the runner is initialised (`loader.ts:65-112`). That is a
749
+ // "not yet", not a fault, and the deadline is what turns a permanent one into a failure.
750
+ return false;
751
+ }
752
+ const registered = all.some(
753
+ (tool) => tool?.name === OMP_BOOTSTRAP_CALLBACK_TOOL && tool?.sourceInfo?.source === "mcp",
754
+ );
755
+ return registered && active.includes(OMP_BOOTSTRAP_CALLBACK_TOOL);
756
+ }
757
+
758
+ /**
759
+ * Does this tool event name OUR callback, with OUR target and OUR nonce?
760
+ *
761
+ * The event carries no server name, no session id and no original MCP tool name
762
+ * (`extensions/types.ts:916-1017`), so the match is built from what IS there: the canonical
763
+ * minted tool name plus the two argument values this rail already owns as contract. Exact
764
+ * string equality on both — a prefix or `includes` here would let a sibling's nonce release
765
+ * this session's task.
766
+ */
767
+ function matchesCallback(event: OmpToolCallEvent, payload: OmpBootstrapPayload): boolean {
768
+ if (event?.toolName !== OMP_BOOTSTRAP_CALLBACK_TOOL) return false;
769
+ const input = event?.input;
770
+ if (typeof input !== "object" || input === null || Array.isArray(input)) return false;
771
+ const args = input as Record<string, unknown>;
772
+ return args.target === payload.target && args.message === payload.nonce;
773
+ }
774
+
775
+ /**
776
+ * Create one bootstrap for one minted session.
777
+ *
778
+ * THE TASK IS RELEASED BY A RESULT, NEVER BY A CALL AND NEVER BY TEXT. `tool_call` fires
779
+ * before scheduling and before approval (`agent-session.ts:3431-3467`), so a call alone
780
+ * proves only that the model tried. The release predicate is the whole contract: the phase
781
+ * is `callback-sent`, the result's `toolCallId` is the exact id remembered from the matching
782
+ * call, the result still names our tool/target/nonce, and `isError === false`. Anything
783
+ * else — a wrong tool, a wrong nonce, an errored result, a second result, a result for a
784
+ * call we never matched — leaves the task unsent, which is the honest outcome: the public
785
+ * caller times out on a callback that did not happen instead of a sibling silently working.
786
+ */
787
+ export function createOmpBootstrap(opts: {
788
+ payload: OmpBootstrapPayload;
789
+ pi: OmpExtensionApi;
790
+ /** REQUIRED, and the absence of a default is the D2 repair: a bootstrap that could fall
791
+ * back to a global timer would silently reacquire the ownership this seam exists to hold. */
792
+ timers: OmpBootstrapTimers;
793
+ log?: (level: LogLevel, message: string) => void;
794
+ }): OmpBootstrapHandle {
795
+ const { payload, pi, timers } = opts;
796
+ const log = opts.log ?? logLine;
797
+
798
+ let phase: OmpBootstrapPhase = "waiting";
799
+ let timer: unknown = null;
800
+ let callId: string | null = null;
801
+ const deadline = timers.now() + OMP_BOOTSTRAP_READY_TIMEOUT_MS;
802
+
803
+ function stopTimer(): void {
804
+ if (timer === null) return;
805
+ try {
806
+ timers.clear(timer);
807
+ } catch {
808
+ /* a timer that cannot be cleared must not break the session */
809
+ }
810
+ timer = null;
811
+ }
812
+
813
+ /** `task-sent` and `failed` are the only outcomes nothing may rewrite. `released` is
814
+ * deliberately NOT among them: a bootstrap whose session is replaced between the callback
815
+ * result and the next turn_end must lose its task, not deliver it into a session the
816
+ * caller never opened. */
817
+ function isFinal(): boolean {
818
+ return phase === "task-sent" || phase === "failed";
819
+ }
820
+
821
+ function fail(why: string): void {
822
+ stopTimer();
823
+ if (isFinal()) return;
824
+ phase = "failed";
825
+ log("WARN", `bootstrap-failed nonce=${payload.nonce}: ${why}; the task was NOT sent`);
826
+ }
827
+
828
+ /**
829
+ * Both stages send the SAME way: `pi.sendUserMessage(content)` with NO delivery option.
830
+ *
831
+ * `[source]` that is the only form whose semantics match what each stage needs — idle
832
+ * starts a turn, streaming queues as a steer, and an EXPLICIT option queues without
833
+ * starting a turn in either state (`session/agent-session.ts:6511-6513`). Stage one runs
834
+ * on an idle session right after `session_start`; stage two runs at a `turn_end`
835
+ * boundary. Neither wants a queue nobody drains, and `[LIVE 2026-08-30]` a queue nobody
836
+ * drains is exactly what the explicit form produced.
837
+ */
838
+ function send(content: string): boolean {
839
+ try {
840
+ if (typeof pi.sendUserMessage !== "function") {
841
+ fail("pi.sendUserMessage is not available on this vendor context");
842
+ return false;
843
+ }
844
+ pi.sendUserMessage(content);
845
+ return true;
846
+ } catch (err) {
847
+ fail(`pi.sendUserMessage threw: ${err instanceof Error ? err.message : String(err)}`);
848
+ return false;
849
+ }
850
+ }
851
+
852
+ function tick(): void {
853
+ timer = null;
854
+ if (phase !== "waiting") return;
855
+ if (ompCallbackToolReady(pi)) {
856
+ // ATOMIC BEFORE THE SEND. The phase moves first so a re-entrant tick, a second
857
+ // readiness observation, or a handler that fires during the send can never produce
858
+ // two callback prompts for one bootstrap.
859
+ phase = "callback-sent";
860
+ stopTimer();
861
+ log("INFO", `bootstrap-ready nonce=${payload.nonce}: callback tool live, sending callback-only prompt`);
862
+ send(buildOmpCallbackOnlyPrompt({ target: payload.target, nonce: payload.nonce }));
863
+ return;
864
+ }
865
+ if (timers.now() >= deadline) {
866
+ fail(
867
+ `the callback tool ${OMP_BOOTSTRAP_CALLBACK_TOOL} was not callable within ${OMP_BOOTSTRAP_READY_TIMEOUT_MS}ms`,
868
+ );
869
+ return;
870
+ }
871
+ schedule();
872
+ }
873
+
874
+ function schedule(): void {
875
+ try {
876
+ timer = timers.set(tick, OMP_BOOTSTRAP_POLL_MS);
877
+ } catch (err) {
878
+ fail(`readiness timer could not be scheduled: ${err instanceof Error ? err.message : String(err)}`);
879
+ }
880
+ }
881
+
882
+ return {
883
+ phase: () => phase,
884
+ pendingCallId: () => callId,
885
+ start(): void {
886
+ if (phase !== "waiting") return;
887
+ // One immediate observation before the first sleep: on a warm host the tools may
888
+ // already be there, and a mandatory first delay would be the fixed wait this design
889
+ // refuses.
890
+ tick();
891
+ },
892
+ onToolCall(event: OmpToolCallEvent): void {
893
+ if (phase !== "callback-sent" || callId !== null) return;
894
+ if (!matchesCallback(event, payload)) return;
895
+ const id = event?.toolCallId;
896
+ if (typeof id !== "string" || id.length === 0) return;
897
+ callId = id;
898
+ log("INFO", `bootstrap-callback-observed nonce=${payload.nonce} toolCallId=${id}`);
899
+ },
900
+ onToolResult(event: OmpToolResultEvent): void {
901
+ if (phase !== "callback-sent" || callId === null) return;
902
+ if (event?.toolCallId !== callId) return;
903
+ if (!matchesCallback(event, payload)) return;
904
+ if (event?.isError !== false) {
905
+ log("WARN", `bootstrap-callback-errored nonce=${payload.nonce} toolCallId=${callId}; the task was NOT sent`);
906
+ return;
907
+ }
908
+ // RELEASED, AND THE TASK IS NOT SENT HERE. This handler runs inside the callback
909
+ // turn, which is still streaming — the one place where neither delivery form is
910
+ // right (`[LIVE 2026-08-30]`, see OmpBootstrapPhase). All that happens is the
911
+ // record that the callback provably succeeded; `onTurnEnd` owns the send.
912
+ phase = "released";
913
+ stopTimer();
914
+ log("INFO", `bootstrap-released nonce=${payload.nonce} toolCallId=${callId}: task armed for the next turn_end`);
915
+ },
916
+ /**
917
+ * The stage-two boundary: the callback turn is over, so the session is at the edge
918
+ * where an omitted-option `sendUserMessage` is the working form.
919
+ *
920
+ * IT FIRES ON EVERY TURN OF EVERY BOOTSTRAPPED SESSION, so the phase test is the
921
+ * whole guard. A `turn_end` before the callback result finds `callback-sent` and does
922
+ * nothing; a second `turn_end` finds `task-sent`; an invalidated bootstrap finds
923
+ * `failed`. `[source]` the event carries `turnIndex`, `message` and `toolResults` and
924
+ * nothing that identifies a session (`shared-events.ts:211-217`, emitted at
925
+ * `session/agent-session.ts:3607-3620`) — which is why the session fence lives on the
926
+ * handler's `ctx`, in `bootstrapFor`, and never on the event.
927
+ *
928
+ * LATCH BEFORE SEND, exactly as the release does. `sendUserMessage` returns void and
929
+ * its ordering is owned by session state rather than by a promise, so the only way one
930
+ * task stays one task is for the phase to move first.
931
+ */
932
+ onTurnEnd(): void {
933
+ if (phase !== "released") return;
934
+ phase = "task-sent";
935
+ log("INFO", `bootstrap-task-sent nonce=${payload.nonce}: delivering the task at the turn_end boundary`);
936
+ send(payload.task);
937
+ },
938
+ invalidate(why: string): void {
939
+ stopTimer();
940
+ // `released` is invalidatable ON PURPOSE: between the callback result and the next
941
+ // turn_end the session can be replaced by `/new`, a fork or a resume, and the
942
+ // caller's task must die with the session it was addressed to.
943
+ if (isFinal()) return;
944
+ phase = "failed";
945
+ log("INFO", `bootstrap-invalidated nonce=${payload.nonce}: ${why}; the task was NOT sent`);
946
+ },
947
+ };
948
+ }
949
+
950
+ /**
951
+ * The one live bootstrap, keyed by the native session that owns it.
952
+ *
953
+ * MODULE STATE IS SAFE HERE ONLY BECAUSE THE FENCES ARE. `[source]` the factory is
954
+ * re-executed per session and inside every task subagent (`sdk.ts:1995-2038`,
955
+ * `task/executor.ts:3250-3307`) against a fresh API, but ESM caches the MODULE — so this
956
+ * binding is shared by all of them. Two fences make that harmless and both are load-bearing:
957
+ * every entry point requires `ctx.mode === "tui"` (a subagent is `"print"`), and every tool
958
+ * event requires the handler context's own `getSessionId()` to equal the id this bootstrap
959
+ * was bound to. Identity is never inferred from a tool call id or from the mode alone.
960
+ */
961
+ let activeBootstrap: { sessionId: string; handle: OmpBootstrapHandle } | null = null;
962
+
963
+ /**
964
+ * ONE LAUNCH, ONE BOOTSTRAP — and this latch is what makes that true.
965
+ *
966
+ * `[source]` `getFlag` reads a per-PROCESS map that `applyExtensionFlags` filled once at
967
+ * startup (`extensions/loader.ts:221-228`, `:253-255`, `cli/extension-flags.ts:36-43`); it is
968
+ * not consumed by reading. So after `/new`, a fork or an in-TUI resume — all of which re-fire
969
+ * as `session_switch` (`agent-session.ts:6910-8074`) and all of which this unit correctly
970
+ * re-mints a record for — the flag would STILL be there, and a bootstrap without this latch
971
+ * would send the caller's task a second time into a session the caller never opened. The
972
+ * payload is a property of the LAUNCH, not of the process's flag map.
973
+ */
974
+ let bootstrapConsumed = false;
975
+
976
+ /** Test seam: drop whatever is live, without sending anything. */
977
+ export function resetOmpBootstrapForTest(): void {
978
+ activeBootstrap?.handle.invalidate("test reset");
979
+ activeBootstrap = null;
980
+ bootstrapConsumed = false;
981
+ }
982
+
983
+ /**
984
+ * May an event carrying THIS context act on a bootstrap bound to `boundSessionId`?
985
+ *
986
+ * BOTH FENCES, IN ONE PURE PREDICATE, BECAUSE NEITHER IS OPTIONAL. `tool_call`,
987
+ * `tool_result` and `turn_end` carry no mode and no session id of their own
988
+ * (`extensions/types.ts:916-1017`, `shared-events.ts:211-217`), so the only honest source is
989
+ * the handler's own context (`extensions/types.ts:452-471`, `session-manager.ts:1946-1952`):
990
+ *
991
+ * mode === "tui" a task subagent re-runs this very factory (`task/executor.ts:3250-3307`)
992
+ * and would otherwise let a subagent's tool result release the host's task.
993
+ * session id equal `/new`, fork and resume replace the session under a LIVING process, so
994
+ * the pid is not a discriminator and the native id is.
995
+ *
996
+ * Identity is never inferred from a tool call id, from the mode alone, or from the fact that
997
+ * some bootstrap happens to be live.
998
+ */
999
+ export function ompBootstrapCtxAccepts(ctx: OmpExtensionContext, boundSessionId: string): boolean {
1000
+ if (ctx?.mode !== HOST_MODE) return false;
1001
+ let id: unknown;
1002
+ try {
1003
+ id = ctx?.sessionManager?.getSessionId?.();
1004
+ } catch {
1005
+ return false;
1006
+ }
1007
+ return typeof id === "string" && id === boundSessionId;
1008
+ }
1009
+
1010
+ /** The bootstrap this event belongs to, or null. */
1011
+ function bootstrapFor(ctx: OmpExtensionContext): OmpBootstrapHandle | null {
1012
+ if (activeBootstrap === null) return null;
1013
+ return ompBootstrapCtxAccepts(ctx, activeBootstrap.sessionId) ? activeBootstrap.handle : null;
1014
+ }
1015
+
1016
+ /**
1017
+ * Start stage one for a session that has just been minted — or say in the log why not.
1018
+ *
1019
+ * IT RUNS AFTER THE RECORD AND THE MARKER, NEVER BEFORE. The callback the sibling is about
1020
+ * to make carries this session's garden id as its SENDER, and the sender join is the marker.
1021
+ * A bootstrap that raced ahead of it would produce a callback the caller cannot attribute.
1022
+ *
1023
+ * A MANUAL `omp` HAS NO FLAG AND MUST NOTICE NOTHING. `flag-absent` is the overwhelmingly
1024
+ * common case — every operator session on this host — so it is the one refusal that does not
1025
+ * even log: a line per manual session would drown the diagnostic the doctor actually reads.
1026
+ *
1027
+ * `edge` IS DIAGNOSTIC, NOT A DISCRIMINATOR, and that is the D1 repair's shape. Every later
1028
+ * birth edge ends the epoch below with no test on its name and no test on the session id, so
1029
+ * nothing here re-derives the vendor's event spelling; the string only tells the log which
1030
+ * edge did it.
1031
+ */
1032
+ /**
1033
+ * EVERY LATER BIRTH EDGE ENDS A NONFINAL BOOTSTRAP — NO ID COMPARISON, AND THIS FUNCTION IS
1034
+ * THE ONLY PLACE THAT RULE IS SPELLED.
1035
+ *
1036
+ * The frozen candidate invalidated only when the native id CHANGED, and `[source]` the vendor
1037
+ * emits `session_switch(reason:"resume")` unconditionally on the reload/same-file path —
1038
+ * `switchingToDifferentSession` is computed at `session/agent-session.ts:7986-7988` but never
1039
+ * gates the emit at `:8071-8078`, and `agent.replaceMessages(...)` swaps the transcript
1040
+ * immediately after it. So a same-id switch is a real epoch change wearing the old id, and a
1041
+ * bootstrap that survived it could release the caller's task into a conversation the caller
1042
+ * never opened. The id is not the epoch; the edge is (#87 Terra review, Defect 1).
1043
+ *
1044
+ * IT IS CALLED BEFORE THE ENVELOPE, WHICH IS THE O-D1r REPAIR. Ending the epoch used to live
1045
+ * inside `startOmpBootstrap`, so it was reachable only by an edge that got all the way there —
1046
+ * and `onBirthEdge` has two earlier exits, a refused envelope and a throwing upsert. A
1047
+ * same-id `session_switch` that took either exit left the previous epoch alive while passing
1048
+ * both fences in `ompBootstrapCtxAccepts`, so a released bootstrap could still deliver at the
1049
+ * next `turn_end`. A bail is a WORSE edge than a healthy one, never a reason to keep an old
1050
+ * epoch: whatever else failed, this host just told us the session moved.
1051
+ *
1052
+ * `nativeSessionId` is null exactly when the caller does not have one yet — the pre-envelope
1053
+ * position. The id is diagnostic here and never a condition, which is why not having it costs
1054
+ * a log word rather than the rule.
1055
+ */
1056
+ function endBootstrapEpochOnLaterEdge(edge: string, nativeSessionId: string | null): void {
1057
+ if (!bootstrapConsumed || activeBootstrap === null) return;
1058
+ const native =
1059
+ nativeSessionId === null
1060
+ ? "unknown/pre-envelope"
1061
+ : `${nativeSessionId}, same-id=${activeBootstrap.sessionId === nativeSessionId}`;
1062
+ activeBootstrap.handle.invalidate(`birth edge ${edge} ended the bootstrap epoch (native=${native})`);
1063
+ activeBootstrap = null;
1064
+ }
1065
+
1066
+ export function startOmpBootstrap(opts: {
1067
+ pi: OmpExtensionApi;
1068
+ /** The BIRTH context — the creator whose timers this bootstrap's poll will belong to. */
1069
+ ctx: OmpExtensionContext;
1070
+ envelope: OmpBirthEnvelope;
1071
+ edge: string;
1072
+ log?: (level: LogLevel, message: string) => void;
1073
+ }): void {
1074
+ const { pi, ctx, envelope, edge } = opts;
1075
+ const log = opts.log ?? logLine;
1076
+ if (bootstrapConsumed) {
1077
+ // The SAME rule, through the same owner. On the wired path `onBirthEdge` already ran it
1078
+ // before the envelope, so this is a no-op; it is here because a caller that drives this
1079
+ // function directly — the contract gate does — must reach the rule too, and because a
1080
+ // rule spelled twice is where the next drift would live.
1081
+ endBootstrapEpochOnLaterEdge(edge, envelope.nativeSessionId);
1082
+ // The latch is NEVER rearmed from here. `getFlag` reads a per-process map that is not
1083
+ // consumed by reading, so without this the very next edge would arm the same payload again.
1084
+ return;
1085
+ }
1086
+ let raw: unknown;
1087
+ try {
1088
+ raw = typeof pi.getFlag === "function" ? pi.getFlag(OMP_BOOTSTRAP_FLAG) : undefined;
1089
+ } catch (err) {
1090
+ log("WARN", `bootstrap-flag-unreadable: ${err instanceof Error ? err.message : String(err)}`);
1091
+ return;
1092
+ }
1093
+ const decoded = decodeOmpBootstrapPayload(raw);
1094
+ if (!decoded.ok) {
1095
+ if (decoded.reason !== "flag-absent") {
1096
+ log("WARN", `bootstrap-refused reason=${decoded.reason}: --${OMP_BOOTSTRAP_FLAG} was present but unusable`);
1097
+ }
1098
+ return;
1099
+ }
1100
+ // THE TIMER CHECK COMES AFTER THE DECODE ON PURPOSE: a manual `omp` returns on
1101
+ // `flag-absent` above, so no operator session can ever reach this line and no vendor build
1102
+ // gets a WARN it has no launch to explain.
1103
+ const timers = creatorOwnedTimers(ctx);
1104
+ if (timers === null) {
1105
+ // AND IT STILL SPENDS THE LATCH. The payload is a property of the LAUNCH, and this edge
1106
+ // was the launch's one chance; leaving the latch open would let the next `/new` arm the
1107
+ // caller's task into a session the caller never opened, which is the exact failure the
1108
+ // latch exists to refuse. An unarmed launch is an honest fresh-call timeout.
1109
+ bootstrapConsumed = true;
1110
+ log(
1111
+ "WARN",
1112
+ `bootstrap-timers-unavailable edge=${edge}: ctx.setTimeout=${typeof ctx?.setTimeout} ctx.clearTimer=${typeof ctx?.clearTimer} — refusing to arm a readiness poll this vendor build cannot own or cancel; the task was NOT sent`,
1113
+ );
1114
+ return;
1115
+ }
1116
+ // A replacement session takes the lane; the previous bootstrap dies without sending.
1117
+ activeBootstrap?.handle.invalidate(`replaced by native session ${envelope.nativeSessionId}`);
1118
+ const handle = createOmpBootstrap({ payload: decoded.value, pi, timers, log });
1119
+ activeBootstrap = { sessionId: envelope.nativeSessionId, handle };
1120
+ bootstrapConsumed = true;
1121
+ log("INFO", `bootstrap-armed nonce=${decoded.value.nonce} target=${decoded.value.target}`);
1122
+ handle.start();
1123
+ }
1124
+
1125
+ /**
1126
+ * One birth edge. `edge` is diagnostic only — mint does not branch on it, because both
1127
+ * wired events mean the same thing here (this visible host now owns this session).
1128
+ */
1129
+ export function onBirthEdge(edge: string, ctx: OmpExtensionContext, pi?: OmpExtensionApi): void {
1130
+ if (ctx.mode !== HOST_MODE) {
1131
+ // THE DESIGNED ANSWER, NOT A FAULT — INFO, and it must stay cheap. Every task
1132
+ // subagent reaches this line (they inherit the extension by default), so this is
1133
+ // also the only place the fence leaves a receipt: one line per refused session,
1134
+ // naming the mode that was refused.
1135
+ logLine("INFO", `scope-refused edge=${edge} mode=${String(ctx.mode)}: not the visible tui host, no record minted`);
1136
+ return;
1137
+ }
1138
+
1139
+ // THE EPOCH ENDS HERE, BEFORE ANYTHING THAT CAN BAIL. Past the mode fence this host has
1140
+ // told us the session moved, and that fact does not depend on the envelope being readable
1141
+ // or the record being writable — the two exits below. Placing it after either one made the
1142
+ // rule conditional on this edge succeeding, which is the narrow window O-D1r named.
1143
+ endBootstrapEpochOnLaterEdge(edge, null);
1144
+
1145
+ const envelope = readBirthEnvelope(ctx);
1146
+ if ("refusal" in envelope) {
1147
+ // ERROR: this session did NOT become a garden citizen. The doctor reads this line.
1148
+ logLine("ERROR", `degraded envelope edge=${edge}: ${envelope.refusal}`);
1149
+ return;
1150
+ }
1151
+
1152
+ try {
1153
+ const result = upsertMetaSession({
1154
+ input: {
1155
+ backend: BACKEND,
1156
+ nativeSessionId: envelope.nativeSessionId,
1157
+ cwd: envelope.cwd,
1158
+ transcriptPath: envelope.transcriptPath,
1159
+ },
1160
+ dir: roots().sessionsDir,
1161
+ });
1162
+ logLine(
1163
+ "INFO",
1164
+ `${result.action} record ${path.basename(result.path)} (edge=${edge}, native=${envelope.nativeSessionId})`,
1165
+ );
1166
+ // RECORD AUTHORITY FIRST: the marker is only a pid->garden hint the record must
1167
+ // vouch for, so both the marker and the visible id are written INSIDE the success
1168
+ // branch. A marker minted after a failed upsert would name a garden id with no
1169
+ // record behind it, and a status line would advertise the same ghost.
1170
+ writeOmpSenderMarker(result.record.gardenId, envelope);
1171
+ showGardenId(ctx, result.record.gardenId);
1172
+ // STAGE ONE, LAST. The bootstrap is the only thing here that starts a model turn, so
1173
+ // it runs after the record, the sender join and the visible id are all settled — the
1174
+ // callback it is about to ask for is attributed by exactly those three facts. A
1175
+ // manual `omp` carries no flag and this call is a no-op for it. `ctx` rides along as the
1176
+ // creator whose timers the readiness poll belongs to, and `edge` so the log can name
1177
+ // which birth edge ended a previous epoch.
1178
+ if (pi) startOmpBootstrap({ pi, ctx, envelope, edge });
1179
+ } catch (err) {
1180
+ logLine(
1181
+ "ERROR",
1182
+ `upsert failed (edge=${edge}, native=${envelope.nativeSessionId}): ${err instanceof Error ? err.message : String(err)}`,
1183
+ );
1184
+ }
1185
+ }
1186
+
1187
+ /**
1188
+ * The extension factory omp calls with a per-session API (`types.ts:1592`; re-executed per
1189
+ * subagent against a fresh API, `sdk.ts:2000-2028`). Binding plus ONE registration is all it
1190
+ * does — every decision still waits for an event's `ctx`, because the factory itself cannot
1191
+ * see the mode.
1192
+ *
1193
+ * THE FLAG MUST BE REGISTERED HERE AND NOWHERE LATER. `[source]` the normal CLI path loads
1194
+ * discovered extensions and only THEN aggregates the registered flags and reparses raw argv
1195
+ * (`main.ts:1799-1810`); a flag registered after that reparse would never be filled. It is
1196
+ * also the only thing this factory may do: every action method throws
1197
+ * `ExtensionRuntimeNotInitializedError` until the runner initialises (`loader.ts:65-112`), so
1198
+ * reading the flag here would throw and minting here would mint before the session exists.
1199
+ *
1200
+ * THE TOOL EVENTS ARE BOUND UNCONDITIONALLY, AND THE COST IS PAID DELIBERATELY. The flag's
1201
+ * VALUE is not readable yet at factory time, so "bind only when bootstrapping" is not
1202
+ * available; and late binding after `session_start` is not a measured vendor behaviour, so it
1203
+ * is not gambled on. Both handlers therefore run on every tool call of every omp session on
1204
+ * this host — which is why their first act is a null check on a module binding that is null
1205
+ * for every manual session, and why nothing in them can throw: extension handlers are serial
1206
+ * and fail closed (`runner.ts:1455-1495`), so a throw here would cost the operator a tool.
1207
+ */
1208
+ export default function entwurfMetaOmp(pi: OmpExtensionApi): void {
1209
+ try {
1210
+ pi.registerFlag?.(OMP_BOOTSTRAP_FLAG, {
1211
+ type: "string",
1212
+ description: "entwurf fresh-call bootstrap payload (set by entwurf_fresh_call; not for manual use)",
1213
+ });
1214
+ } catch (err) {
1215
+ logLine("WARN", `bootstrap-flag-unregistered: ${err instanceof Error ? err.message : String(err)}`);
1216
+ }
1217
+ pi.on("session_start", (_event, ctx) => onBirthEdge("session_start", ctx, pi));
1218
+ // `turn_end` joins them as the stage-two boundary — see `OmpBootstrapPhase`. It is bound
1219
+ // on the same unconditional terms and pays the same null check.
1220
+ pi.on("session_switch", (event, ctx) =>
1221
+ onBirthEdge(`session_switch(${asNonEmptyString(event?.reason) || "unlabeled"})`, ctx, pi),
1222
+ );
1223
+ pi.on("tool_call", (event, ctx) => {
1224
+ try {
1225
+ bootstrapFor(ctx)?.onToolCall(event);
1226
+ } catch (err) {
1227
+ logLine("WARN", `bootstrap-tool-call-handler-failed: ${err instanceof Error ? err.message : String(err)}`);
1228
+ }
1229
+ });
1230
+ pi.on("tool_result", (event, ctx) => {
1231
+ try {
1232
+ bootstrapFor(ctx)?.onToolResult(event);
1233
+ } catch (err) {
1234
+ logLine("WARN", `bootstrap-tool-result-handler-failed: ${err instanceof Error ? err.message : String(err)}`);
1235
+ }
1236
+ });
1237
+ pi.on("turn_end", (_event, ctx) => {
1238
+ try {
1239
+ bootstrapFor(ctx)?.onTurnEnd();
1240
+ } catch (err) {
1241
+ logLine("WARN", `bootstrap-turn-end-handler-failed: ${err instanceof Error ? err.message : String(err)}`);
1242
+ }
1243
+ });
1244
+ }