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