@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.
- package/AGENTS.md +1 -0
- package/CHANGELOG.md +299 -0
- package/DELIVERY.md +4 -3
- package/README.md +99 -67
- package/VERIFY.md +13 -7
- package/docs/acp-backend-rail.md +1 -1
- package/docs/external-mcp-host.md +147 -9
- package/docs/setup-clean-host.md +221 -16
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +65 -20
- package/mcp/entwurf-bridge/dist/pi-extensions/entwurf-capabilities.json +1 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-self-address.js +17 -6
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-sender-identity.js +1 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +172 -5
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +152 -17
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/omp-fresh-preflight.js +271 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-omp.js +988 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-receive-omp.js +574 -0
- package/mcp/entwurf-bridge/dist/scripts/omp-receive-facts.js +84 -0
- package/mcp/entwurf-bridge/src/index.ts +65 -19
- package/mcp/entwurf-bridge/tsconfig.build.json +19 -3
- package/mcp/tsconfig.json +10 -0
- package/package.json +14 -4
- package/pi/entwurf-capabilities.json +1 -0
- package/pi/meta-bridge-omp/entwurf-meta-omp/package.json +7 -0
- package/pi/omp-receive/entwurf-receive-omp/package.json +7 -0
- package/pi-extensions/entwurf-control.ts +12 -9
- package/pi-extensions/lib/entwurf-self-address.ts +18 -7
- package/pi-extensions/lib/meta-sender-identity.ts +1 -1
- package/pi-extensions/lib/meta-session.ts +219 -5
- package/pi-extensions/lib/mux-fresh-call.ts +171 -17
- package/pi-extensions/lib/omp-fresh-preflight.ts +275 -0
- package/pi-extensions/meta-bridge-omp.ts +1244 -0
- package/pi-extensions/meta-bridge-receive-omp.ts +666 -0
- package/run.sh +1041 -174
- package/scripts/agy-bridge.sh +4 -2
- package/scripts/check-copilot-birth-hook.ts +700 -23
- package/scripts/check-entwurf-self-address.ts +31 -0
- package/scripts/check-fresh-cut-gate.sh +12 -4
- package/scripts/check-gate-qualification.ts +8 -2
- package/scripts/check-harness-admission-parity.ts +143 -0
- package/scripts/check-meta-doctor-oracle.sh +20 -0
- package/scripts/check-omp-birth-hook.ts +1049 -0
- package/scripts/check-omp-fresh-preflight.ts +181 -0
- package/scripts/check-omp-receive-arm.ts +516 -0
- package/scripts/check-setup-qualification.sh +126 -0
- package/scripts/copilot-bridge-doctor.sh +104 -71
- package/scripts/copilot-bridge-install.sh +180 -20
- package/scripts/copilot-bridge-oracle.sh +223 -0
- package/scripts/copilot-bridge-uninstall.sh +137 -0
- package/scripts/dev-bin.sh +50 -9
- package/scripts/fake-copilot-vendor.sh +67 -0
- package/scripts/inventory-verification-surface.ts +1 -1
- package/scripts/meta-bridge-hook-log.sh +9 -1
- package/scripts/mutants/copilot-birth.json +96 -5
- package/scripts/mutants/mux-fresh-call.json +22 -22
- package/scripts/mutants/omp-birth.json +173 -0
- package/scripts/mutants/omp-fresh.json +300 -0
- package/scripts/mutants/omp-receive.json +135 -0
- package/scripts/mutants/pi-package-ownership.json +92 -0
- package/scripts/mutants/self-address.json +34 -0
- package/scripts/mutants/setup-verdict.json +148 -0
- package/scripts/mutants/source-install.json +32 -0
- package/scripts/omp-bridge-doctor.sh +315 -0
- package/scripts/omp-bridge-install.sh +221 -0
- package/scripts/omp-bridge-oracle.sh +154 -0
- package/scripts/omp-bridge-uninstall.sh +57 -0
- package/scripts/omp-mcp-bridge.sh +320 -0
- package/scripts/omp-mcp-config.py +392 -0
- package/scripts/omp-receive-doctor.sh +246 -0
- package/scripts/omp-receive-facts.ts +106 -0
- package/scripts/omp-receive-install.sh +228 -0
- package/scripts/omp-receive-uninstall.sh +60 -0
- package/scripts/omp-tool-surface.py +349 -0
- package/scripts/pi_settings_io.py +22 -0
- package/scripts/raw-omp-measure/README.md +420 -0
- package/scripts/raw-omp-measure/probe-extension.ts +76 -0
- package/scripts/raw-omp-measure/probe-receive-surface.ts +250 -0
- package/scripts/raw-omp-measure/source-audit.md +414 -0
- package/scripts/register-pi-package.py +525 -7
- package/scripts/register-pi-provider.py +162 -12
- package/scripts/smoke-agy-install-state.sh +86 -30
- package/scripts/smoke-agy-statusline-state.sh +9 -6
- package/scripts/smoke-omp-bridge-state.sh +221 -0
- package/scripts/smoke-omp-fresh-live.ts +497 -0
- package/scripts/smoke-omp-mcp-state.sh +327 -0
- package/scripts/smoke-omp-receive-live.ts +340 -0
- package/scripts/smoke-omp-receive-state.sh +196 -0
- package/scripts/smoke-setup-verdict.sh +291 -0
- package/scripts/smoke-user-scope-citizen.sh +388 -1
- 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
|
+
}
|