@junghanacs/entwurf 0.15.1 → 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 (69) hide show
  1. package/CHANGELOG.md +229 -0
  2. package/DELIVERY.md +3 -2
  3. package/README.md +70 -87
  4. package/VERIFY.md +4 -1
  5. package/docs/acp-backend-rail.md +1 -1
  6. package/docs/external-mcp-host.md +147 -9
  7. package/docs/setup-clean-host.md +105 -0
  8. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +65 -20
  9. package/mcp/entwurf-bridge/dist/pi-extensions/entwurf-capabilities.json +1 -0
  10. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-self-address.js +17 -6
  11. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-sender-identity.js +1 -1
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +172 -5
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +152 -17
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/omp-fresh-preflight.js +271 -0
  15. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-omp.js +988 -0
  16. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-receive-omp.js +574 -0
  17. package/mcp/entwurf-bridge/dist/scripts/omp-receive-facts.js +84 -0
  18. package/mcp/entwurf-bridge/src/index.ts +65 -19
  19. package/mcp/entwurf-bridge/tsconfig.build.json +19 -3
  20. package/mcp/tsconfig.json +10 -0
  21. package/package.json +13 -3
  22. package/pi/entwurf-capabilities.json +1 -0
  23. package/pi/meta-bridge-omp/entwurf-meta-omp/package.json +7 -0
  24. package/pi/omp-receive/entwurf-receive-omp/package.json +7 -0
  25. package/pi-extensions/entwurf-control.ts +12 -9
  26. package/pi-extensions/lib/entwurf-self-address.ts +18 -7
  27. package/pi-extensions/lib/meta-sender-identity.ts +1 -1
  28. package/pi-extensions/lib/meta-session.ts +219 -5
  29. package/pi-extensions/lib/mux-fresh-call.ts +171 -17
  30. package/pi-extensions/lib/omp-fresh-preflight.ts +275 -0
  31. package/pi-extensions/meta-bridge-omp.ts +1244 -0
  32. package/pi-extensions/meta-bridge-receive-omp.ts +666 -0
  33. package/run.sh +296 -4
  34. package/scripts/check-entwurf-self-address.ts +31 -0
  35. package/scripts/check-gate-qualification.ts +4 -1
  36. package/scripts/check-harness-admission-parity.ts +143 -0
  37. package/scripts/check-meta-doctor-oracle.sh +20 -0
  38. package/scripts/check-omp-birth-hook.ts +1049 -0
  39. package/scripts/check-omp-fresh-preflight.ts +181 -0
  40. package/scripts/check-omp-receive-arm.ts +516 -0
  41. package/scripts/inventory-verification-surface.ts +1 -1
  42. package/scripts/meta-bridge-hook-log.sh +9 -1
  43. package/scripts/mutants/copilot-birth.json +7 -5
  44. package/scripts/mutants/mux-fresh-call.json +22 -22
  45. package/scripts/mutants/omp-birth.json +173 -0
  46. package/scripts/mutants/omp-fresh.json +300 -0
  47. package/scripts/mutants/omp-receive.json +135 -0
  48. package/scripts/mutants/self-address.json +34 -0
  49. package/scripts/omp-bridge-doctor.sh +315 -0
  50. package/scripts/omp-bridge-install.sh +221 -0
  51. package/scripts/omp-bridge-oracle.sh +154 -0
  52. package/scripts/omp-bridge-uninstall.sh +57 -0
  53. package/scripts/omp-mcp-bridge.sh +320 -0
  54. package/scripts/omp-mcp-config.py +392 -0
  55. package/scripts/omp-receive-doctor.sh +246 -0
  56. package/scripts/omp-receive-facts.ts +106 -0
  57. package/scripts/omp-receive-install.sh +228 -0
  58. package/scripts/omp-receive-uninstall.sh +60 -0
  59. package/scripts/omp-tool-surface.py +349 -0
  60. package/scripts/raw-omp-measure/README.md +420 -0
  61. package/scripts/raw-omp-measure/probe-extension.ts +76 -0
  62. package/scripts/raw-omp-measure/probe-receive-surface.ts +250 -0
  63. package/scripts/raw-omp-measure/source-audit.md +414 -0
  64. package/scripts/smoke-omp-bridge-state.sh +221 -0
  65. package/scripts/smoke-omp-fresh-live.ts +497 -0
  66. package/scripts/smoke-omp-mcp-state.sh +327 -0
  67. package/scripts/smoke-omp-receive-live.ts +340 -0
  68. package/scripts/smoke-omp-receive-state.sh +196 -0
  69. package/scripts/tsconfig.json +2 -0
@@ -0,0 +1,250 @@
1
+ /**
2
+ * raw-omp-measure probe #2 — the STEP 7 (receive) surface, for #87 bundle B.
3
+ *
4
+ * Loaded into a live omp process via `omp -e <this file>`. It answers the five
5
+ * D5 cells the bundle B design is blocked on, and it does so WITHOUT the vendor
6
+ * source checkout (which is not on this host):
7
+ *
8
+ * (a) where `sendUserMessage` / `sendMessage` actually live — the factory `pi`
9
+ * object or the event `ctx`. (`setStatus` turned out to be on `ctx.ui`, not
10
+ * on `pi`, so this is a measured question, not an obvious one.)
11
+ * (b) whether calling it while IDLE really starts a turn. Gated behind
12
+ * OMP_PROBE_SEND=1 because that costs a real model turn.
13
+ * (c) whether `ctx.setInterval` exists and keeps firing while the session is
14
+ * idle — the mechanism S1 (bounded arm retry) would stand on.
15
+ * (d) whether a SECOND extension in the same process gets its own working API.
16
+ * The installed `entwurf-meta-omp` birth unit is the first extension; this
17
+ * probe is the second, so the run itself is the experiment.
18
+ * (e) the ORDER datum that decides S1 vs S2: at the moment THIS extension's
19
+ * `session_start` handler runs, has the birth unit already written its
20
+ * sender marker? Measured by looking for the marker file directly, then
21
+ * re-checked on a schedule so "not yet" and "never" stay distinguishable.
22
+ *
23
+ * MEASUREMENT ONLY. It mints nothing, writes no garden artifact, sends no
24
+ * entwurf message, and never throws into the host.
25
+ *
26
+ * Log path: $OMP_PROBE_LOG (default /tmp/omp-receive-probe.jsonl), append-only JSONL.
27
+ */
28
+ import { appendFileSync, existsSync, readFileSync } from "node:fs";
29
+ import * as os from "node:os";
30
+ import * as path from "node:path";
31
+
32
+ const LOG = process.env.OMP_PROBE_LOG ?? "/tmp/omp-receive-probe.jsonl";
33
+ const SEND = process.env.OMP_PROBE_SEND === "1";
34
+ const NONCE = process.env.OMP_PROBE_NONCE ?? "no-nonce";
35
+
36
+ function log(obj: Record<string, unknown>): void {
37
+ try {
38
+ appendFileSync(LOG, `${JSON.stringify({ ts: new Date().toISOString(), ...obj })}\n`);
39
+ } catch {
40
+ // measurement probe: never take the host down
41
+ }
42
+ }
43
+
44
+ /** The omp four-root base, spelled literally rather than imported: a probe must not
45
+ * depend on the product's own resolver, or it would be testing our copy of the answer. */
46
+ function senderMarkerPath(pid: number): string {
47
+ const home = process.env.HOME && process.env.HOME.length > 0 ? process.env.HOME : os.homedir();
48
+ const dir = process.env.ENTWURF_META_SENDERS_DIR;
49
+ const base = dir && dir.length > 0 ? dir : path.join(home, ".pi", "agent", "meta-senders");
50
+ return path.join(base, "omp", `${pid}.json`);
51
+ }
52
+
53
+ function markerFacts(): Record<string, unknown> {
54
+ const file = senderMarkerPath(process.pid);
55
+ try {
56
+ if (!existsSync(file)) return { markerPresent: false, markerFile: file };
57
+ const raw = JSON.parse(readFileSync(file, "utf8"));
58
+ return {
59
+ markerPresent: true,
60
+ markerFile: file,
61
+ markerGarden: raw?.gardenId,
62
+ markerNative: raw?.nativeSessionId,
63
+ markerOwnerPid: raw?.ownerPid,
64
+ };
65
+ } catch (err) {
66
+ return { markerPresent: "unreadable", markerFile: file, markerError: String(err) };
67
+ }
68
+ }
69
+
70
+ function fnKeys(obj: unknown): string[] {
71
+ if (!obj || (typeof obj !== "object" && typeof obj !== "function")) return [];
72
+ const out = new Set<string>();
73
+ let cur: object | null = obj as object;
74
+ for (let depth = 0; cur && depth < 3; depth++) {
75
+ for (const k of Object.getOwnPropertyNames(cur)) out.add(k);
76
+ cur = Object.getPrototypeOf(cur);
77
+ }
78
+ return [...out].sort();
79
+ }
80
+
81
+ function typeOfPath(root: any, ...segs: string[]): string {
82
+ let cur = root;
83
+ for (const s of segs) {
84
+ if (cur == null) return "absent";
85
+ cur = cur[s];
86
+ }
87
+ return typeof cur;
88
+ }
89
+
90
+ export default function probeReceiveSurface(pi: any): void {
91
+ // (a)+(d) the factory surface, before any event.
92
+ log({
93
+ event: "factory",
94
+ pid: process.pid,
95
+ ppid: process.ppid,
96
+ piKeys: fnKeys(pi),
97
+ "typeof pi.sendUserMessage": typeOfPath(pi, "sendUserMessage"),
98
+ "typeof pi.sendMessage": typeOfPath(pi, "sendMessage"),
99
+ "typeof pi.setInterval": typeOfPath(pi, "setInterval"),
100
+ "typeof pi.setTimeout": typeOfPath(pi, "setTimeout"),
101
+ "typeof pi.on": typeOfPath(pi, "on"),
102
+ });
103
+
104
+ const snapshot = (event: string, ctx: any, extra?: Record<string, unknown>) => {
105
+ let sessionId: unknown;
106
+ try {
107
+ sessionId = ctx?.sessionManager?.getSessionId?.();
108
+ } catch {}
109
+ log({
110
+ event,
111
+ pid: process.pid,
112
+ mode: ctx?.mode,
113
+ hasUI: ctx?.hasUI,
114
+ cwd: ctx?.cwd,
115
+ sessionId,
116
+ ctxKeys: fnKeys(ctx),
117
+ "typeof ctx.sendUserMessage": typeOfPath(ctx, "sendUserMessage"),
118
+ "typeof ctx.sendMessage": typeOfPath(ctx, "sendMessage"),
119
+ "typeof ctx.setInterval": typeOfPath(ctx, "setInterval"),
120
+ "typeof ctx.setTimeout": typeOfPath(ctx, "setTimeout"),
121
+ "typeof ctx.ui.setStatus": typeOfPath(ctx, "ui", "setStatus"),
122
+ ...markerFacts(),
123
+ ...extra,
124
+ });
125
+ };
126
+
127
+ const onEdge = (edge: string) => (event: any, ctx: any) => {
128
+ const t0 = Date.now();
129
+ snapshot(edge, ctx, { reason: event?.reason, phase: "t0" });
130
+
131
+ // (e) THE ORDER DATUM. If the marker is absent at t0 this extension ran BEFORE
132
+ // the birth unit, which is exactly the case a receive unit must survive. The
133
+ // re-checks say whether a bounded defer would have caught it, and how late.
134
+ for (const delayMs of [25, 100, 250, 1000, 3000, 8000]) {
135
+ try {
136
+ ctx.setTimeout?.(() => {
137
+ log({
138
+ event: `${edge}_marker_recheck`,
139
+ pid: process.pid,
140
+ mode: ctx?.mode,
141
+ delayMs,
142
+ elapsedMs: Date.now() - t0,
143
+ ...markerFacts(),
144
+ });
145
+ }, delayMs);
146
+ } catch (err) {
147
+ log({ event: `${edge}_recheck_schedule_failed`, delayMs, error: String(err) });
148
+ }
149
+ }
150
+
151
+ // (c) does a REPEATING contained timer exist, and does it keep firing while idle?
152
+ // Bounded on purpose: 6 ticks then self-clear, so the probe cannot outlive its point.
153
+ if (typeof ctx?.setInterval === "function") {
154
+ let ticks = 0;
155
+ try {
156
+ // CANCELLATION IS THE WHOLE POINT OF THIS CELL. `[LIVE run1]` there is no
157
+ // `ctx.clearInterval`: calling it through `?.` was a SILENT no-op and the
158
+ // timer ran 58 ticks past its stop condition inside the operator's TUI.
159
+ // The vendor's canceller is `ctx.clearTimer` (ctxKeys, run1). Probe it by
160
+ // NAME and record which name actually stops the timer — a retry loop that
161
+ // cannot be cancelled is not a mechanism a receive unit may stand on.
162
+ const handle = ctx.setInterval(() => {
163
+ ticks++;
164
+ log({
165
+ event: `${edge}_interval_tick`,
166
+ pid: process.pid,
167
+ mode: ctx?.mode,
168
+ tick: ticks,
169
+ elapsedMs: Date.now() - t0,
170
+ });
171
+ if (ticks === 3) {
172
+ const canceller =
173
+ typeof ctx?.clearTimer === "function"
174
+ ? "clearTimer"
175
+ : typeof ctx?.clearInterval === "function"
176
+ ? "clearInterval"
177
+ : null;
178
+ log({ event: `${edge}_interval_canceller`, pid: process.pid, canceller });
179
+ if (canceller) {
180
+ try {
181
+ ctx[canceller](handle);
182
+ log({ event: `${edge}_interval_clear_called`, pid: process.pid, canceller, ticks });
183
+ } catch (err) {
184
+ log({ event: `${edge}_interval_clear_failed`, canceller, error: String(err) });
185
+ }
186
+ }
187
+ }
188
+ if (ticks === 10) {
189
+ log({ event: `${edge}_interval_STILL_RUNNING_after_clear`, pid: process.pid, ticks });
190
+ }
191
+ }, 500);
192
+ log({ event: `${edge}_interval_armed`, pid: process.pid, handleType: typeof handle });
193
+ } catch (err) {
194
+ log({ event: `${edge}_interval_failed`, error: String(err) });
195
+ }
196
+ } else {
197
+ log({ event: `${edge}_no_setInterval`, pid: process.pid, mode: ctx?.mode });
198
+ }
199
+
200
+ // (b) THE COSTED CELL. Only on the visible host, only when explicitly enabled.
201
+ if (SEND && ctx?.mode === "tui") {
202
+ try {
203
+ ctx.setTimeout?.(() => {
204
+ const target =
205
+ typeof pi?.sendUserMessage === "function"
206
+ ? { obj: pi, where: "pi" }
207
+ : typeof ctx?.sendUserMessage === "function"
208
+ ? { obj: ctx, where: "ctx" }
209
+ : null;
210
+ if (!target) {
211
+ log({ event: "send_probe_absent", pid: process.pid });
212
+ return;
213
+ }
214
+ const body = `PROBE ${NONCE}: reply with exactly the token PROBE-ACK-${NONCE} and nothing else.`;
215
+ try {
216
+ const r = target.obj.sendUserMessage(body, { deliverAs: "user" });
217
+ log({
218
+ event: "send_probe_called",
219
+ pid: process.pid,
220
+ where: target.where,
221
+ elapsedMs: Date.now() - t0,
222
+ returnType: typeof r,
223
+ thenable: typeof r?.then === "function",
224
+ });
225
+ if (r && typeof r.then === "function") {
226
+ r.then(
227
+ (v: unknown) => log({ event: "send_probe_resolved", value: String(v) }),
228
+ (e: unknown) => log({ event: "send_probe_rejected", error: String(e) }),
229
+ );
230
+ }
231
+ } catch (err) {
232
+ log({ event: "send_probe_threw", where: target.where, error: String(err) });
233
+ }
234
+ }, 12_000);
235
+ } catch (err) {
236
+ log({ event: "send_probe_schedule_failed", error: String(err) });
237
+ }
238
+ }
239
+ };
240
+
241
+ pi.on("session_start", onEdge("session_start"));
242
+ pi.on("session_switch", onEdge("session_switch"));
243
+ // Turn edges: they say whether (b) actually produced a model turn, and they are the
244
+ // edges a Copilot-style retry would have used — measured so the "retry is not enough"
245
+ // claim keeps a receipt of its own.
246
+ pi.on("agent_start", (_e: any, ctx: any) => snapshot("agent_start", ctx));
247
+ pi.on("turn_start", (_e: any, ctx: any) => snapshot("turn_start", ctx));
248
+ pi.on("turn_end", (_e: any, ctx: any) => snapshot("turn_end", ctx));
249
+ pi.on("session_shutdown", (_e: any, ctx: any) => snapshot("session_shutdown", ctx));
250
+ }
@@ -0,0 +1,414 @@
1
+ Author: xai/grok-4.6 (entwurf fresh sibling, 2026-08-27) — source audit for #87; review as a separate viewpoint.
2
+
3
+ Vendor: `~/repos/3rd/oh-my-pi` HEAD `4142f881` = tag `v18.0.0` (`02696f5b`) + 2 natives lint commits. Paths below are `packages/coding-agent/src/…` unless noted. Code is the oracle.
4
+
5
+ # CORRECTIONS SUMMARY
6
+
7
+ Only CORRECTED / UNRESOLVED. Fence-design impact first.
8
+
9
+ 1. **A2 — `hasUI` is not the host predicate; `mode==="tui"` is stronger, not weaker.**
10
+ rpc, rpc-ui, and ACP all pass a real `uiContext`, so `ExtensionRunner.hasUI()` is **true** (`runner.ts:879-881` = `uiContext !== noOpUIContext`). types.ts:465 comment ("false in print/RPC mode") **disagrees with code**. No initialize site other than `extension-ui-controller.ts` passes `"tui"`. **Do not gate birth on `hasUI`. Keep `mode==="tui"`.** Nothing in source weakens that fence.
11
+
12
+ 2. **H2 — `setFooter` is a no-op on the TUI at v18.** `extension-ui-controller.ts:139` `setFooter: () => {}`. Visible identity cannot use `setFooter`. `setStatus` **does** render (hook-status lines under the status line; `statusLine.showHookStatus` default true).
13
+
14
+ 3. **H3 — built-in `statusLine` has no custom-text / command segment.** Closed `StatusLineSegmentId` enum (`status-line/segments.ts:731-757`). Extension-owned persistent text = `setStatus`, not a Copilot-style `statusLine.command`.
15
+
16
+ 4. **A1 — initialize call-site map was incomplete.** Direct `ExtensionRunner.initialize` sites: TUI `:302`/`:531` (`"tui"`), ACP `:2524` (`"rpc"`), executor `:3252` (default `"print"`), plus shared wrapper `runtime-init.ts:56` used by print/json/rpc and subagent revive. `rpc-mode.ts:961` is `initializeExtensions({mode:"rpc"})`, not a direct runner call. `runner.ts:651` is `initialize()` itself, not the field default (`:438`).
17
+
18
+ 5. **A4 — `omp acp` does load discovered extensions.** `createAcpSessionFactory` (`main.ts:434`) calls `createAgentSession` without `disableExtensionDiscovery`. Mode `"rpc"`, `ExtensionContext.hasUI:true`, session-level `hasUI:false`, `enableMCP:false` at create (ACP then injects client servers). A `mode==="tui"` birth guard **stays silent**.
19
+
20
+ 6. **B2 — bundled task agents do NOT set `restrictToolNames:true`.** That flag is a spawn option (planMode / host restrict / security coordinator), not agent frontmatter. scout/reviewer/librarian/security-reviewer have `tools:` allowlists but still inherit extensions unless the flag is set. Default `task`/`sonic` load extensions.
21
+
22
+ UNRESOLVED: none at source layer. LIVE slots in the ledger stay empty by design.
23
+
24
+ # Item ledger
25
+
26
+ ## A. Discriminator completeness
27
+
28
+ ### A1. Every `ExtensionRunner.initialize` call site — CORRECTED
29
+
30
+ Field default `#mode = "print"`: `extensibility/extensions/runner.ts:438`.
31
+ `initialize(..., mode: ExtensionMode = "print")`: `runner.ts:651-655`.
32
+
33
+ Direct call sites (grep `extensionRunner.initialize` / `runner.initialize`):
34
+
35
+ | site | mode passed | notes |
36
+ |---|---|---|
37
+ | `modes/controllers/extension-ui-controller.ts:302` | `"tui"` | `initHooksAndCustomTools`; **emits `session_start` :309-311** |
38
+ | `modes/controllers/extension-ui-controller.ts:531` | `"tui"` | `initializeHookRunner`; **no `session_start`**. Production callers of this method: none (only a unit test). |
39
+ | `modes/runtime-init.ts:56` | `options.mode` default `"print"` | shared wrapper; then `emit session_start` `:147` |
40
+ | `modes/acp/acp-agent.ts:2524` 5th arg `:2619` | `"rpc"` | then `emit session_start` `:2603` |
41
+ | `task/executor.ts:3252` | omitted → `"print"` | then `emit session_start` `:3305` |
42
+
43
+ `runtime-init` callers: `modes/print-mode.ts:120` (`mode === "json" ? "json" : "print"`); `modes/rpc/rpc-mode.ts:960-961` (`mode: "rpc"`); executor revive `executor.ts:3164` (no mode → print).
44
+
45
+ Claimed `rpc-mode.ts:961` as a direct initialize: **wrong shape, right mode** — it is `initializeExtensions({mode:"rpc"})`.
46
+
47
+ `cli/models-cli.ts:311` constructs `new ExtensionRunner` and does **not** call `initialize`.
48
+
49
+ ### A2. CLI → ExtensionMode + hasUI — CORRECTED (hasUI); tui predicate still sound
50
+
51
+ CLI `Mode` (`cli/args.ts:23`): `"text" | "json" | "rpc" | "acp" | "rpc-ui"`.
52
+ Interactive TUI iff `!print && !autoPrint && mode === undefined` (`main.ts:1406-1407`).
53
+ `sessionOptions.hasUI = isInteractive || mode === "rpc-ui"` (`main.ts:1737`) — this is **session-level** `CreateAgentSessionOptions.hasUI`, not `ExtensionContext.hasUI`.
54
+
55
+ `ExtensionContext.hasUI` = `runner.hasUI()` = `this.#uiContext !== noOpUIContext` (`runner.ts:879-881`, wired at `runner.ts:1163`).
56
+
57
+ | CLI | host runner mode | ExtensionContext.hasUI | session.hasUI |
58
+ |---|---|---|---|
59
+ | plain interactive TUI | `"tui"` | true (TUI uiContext) | true |
60
+ | `-p` / `--print` / `--mode text` | `"print"` | false (no uiContext) | false |
61
+ | `--mode json` | `"json"` | false | false |
62
+ | `--mode rpc` | `"rpc"` | **true** (`rpcUiContext` always passed, `rpc-mode.ts:956-975`) | false |
63
+ | `--mode rpc-ui` | `"rpc"` (not `"tui"`) | **true** | true |
64
+ | `omp acp` / `--mode acp` | `"rpc"` | **true** (ACP uiContext) | false |
65
+
66
+ **Operator-visible TUI whose extensions see `mode !== "tui"`?** No. Only the TUI controller passes `"tui"`.
67
+ **rpc-ui:** operator-visible *elsewhere* (RPC UI client), extensions see `"rpc"`. Issue #87 forbids garden ids for non-tui — this is intended exclusion, not a hole.
68
+ **Non-visible session that sees `"tui"`?** No production path. `:531` is test-only rewire.
69
+
70
+ Fence: `mode==="tui"` is the sound top-level predicate. `hasUI` would admit rpc/rpc-ui/ACP.
71
+
72
+ ### A3. Task subagents — CONFIRMED
73
+
74
+ `hasUI: false` at `task/executor.ts:3115` (`CreateAgentSessionOptions`).
75
+ `extensionRunner.initialize(actions, contextActions)` two-arg at `:3252` → default mode `"print"`, default uiContext no-op → `ExtensionContext.hasUI:false`.
76
+ `await extensionRunner.emit({ type: "session_start" })` at `:3305`.
77
+
78
+ ### A4. `omp acp` — CORRECTED
79
+
80
+ ACP **does** load discovered extensions: `createAcpSessionFactory` (`main.ts:434-449`) spreads `baseOptions` into `createAgentSession` with `preloadedExtensions: trustedExtensions` (undefined unless `--trusted-extension`) and **does not** set `disableExtensionDiscovery`. Path 3 in `sdk.ts:2031-2034` then discovers + `loadExtensions`.
81
+ `enableMCP: false` at create (`main.ts:446`); client MCP is applied later (`acp-agent.ts:#configureMcpServers`).
82
+ Initialize: mode `"rpc"` (`:2619`), real `createAcpExtensionUiContext` (`:2496`) → `hasUI:true` on the extension context.
83
+ `session_start` at `:2603`.
84
+ A birth extension guarding `mode==="tui"` **stays silent**.
85
+
86
+ ### A5. Subagent factory rebind — CONFIRMED
87
+
88
+ The "rebind" is not a later mutation of a live runner. Each subagent `createAgentSession` re-imports factories against **this** session's API:
89
+
90
+ ```
91
+ sdk.ts:2000-2005 // preloadedExtensionPaths (subagent): skip FS scan, always re-call loadExtensions
92
+ sdk.ts:2024-2028 extensionPaths = options.preloadedExtensionPaths;
93
+ extensionsResult = await loadExtensions(extensionPaths, cwd, eventBus);
94
+ ```
95
+
96
+ `loadExtensions` → `bindExtension` → `runExtensionFactory(factory, api, runtime)` (`loader.ts:362-422`, `:435-443`). `api` is a new `ConcreteExtensionAPI` for that session (`loader.ts:421`).
97
+
98
+ Host must never pass `preloadedExtensions` across the session boundary (`sdk.ts:441-443`).
99
+
100
+ ## B. Subagent inheritance
101
+
102
+ ### B1. CLI `-e` → subagent — CONFIRMED
103
+
104
+ `main.ts:1304-1306`: `cliExtensionPaths = [...extensions, ...hooks]` → `options.additionalExtensionPaths`.
105
+ `discoverSessionExtensionPaths` (`sdk.ts:720-721`) concatenates those onto discovery.
106
+ Host `createAgentSession` with `preloadedExtensions` captures `toolSession.extensionPaths` (`sdk.ts:2041`).
107
+ Task spawn: `structured-subagent.ts:442` `preloadedExtensionPaths: restrictToolNames ? [] : session.extensionPaths`.
108
+ Executor forwards at `:3096`.
109
+
110
+ **Yes: an `-e` probe extension loads inside task subagents by default**, re-executed per A5, unless `restrictToolNames`.
111
+
112
+ ### B2. Bundled agents vs `restrictToolNames` — CORRECTED
113
+
114
+ `task/agents.ts` bundled defs: scout, designer, reviewer, security-reviewer, librarian, task, sonic. **None set `restrictToolNames`.**
115
+ `tools:` allowlists in markdown (not a discovery skip):
116
+ - scout: `read, grep, glob, web_search`
117
+ - reviewer / librarian: `read, grep, glob, bash, lsp, web_search, ast_grep`
118
+ - security-reviewer: `read, grep, glob, lsp, ast_grep`
119
+ - task / sonic / designer: no `tools:` → full host tool set
120
+
121
+ `restrictToolNames: true` is a **spawn option**:
122
+ - `structured-subagent.ts:386` `policy.planMode || session.restrictToolNames === true`
123
+ - `security/coordinator.ts:254` (security scan session, not a TUI task agent)
124
+ - `compress/session.ts:49`
125
+ - isolation runner **clears** paths (`isolation-runner.ts:170`)
126
+
127
+ Agents that would **not** load extensions: any spawn with `restrictToolNames:true` (plan-mode subagents, restricted host, isolated subprocess). Default `task` **does** load them.
128
+
129
+ ### B3. Subagent MCP proxy — CONFIRMED
130
+
131
+ `executor.ts:3021-3024`:
132
+ ```
133
+ restrictToolNames = options.restrictToolNames === true
134
+ enableMCP = !restrictToolNames && (options.enableMCP ?? true)
135
+ mcpManager = enableMCP ? options.mcpManager : undefined
136
+ mcpProxyTools = mcpManager ? createMCPProxyTools(mcpManager) : []
137
+ ```
138
+ Passed as `mcpManager` + `customTools: mcpProxyTools` into `createAgentSession` (`:3123-3124`).
139
+ `createMCPProxyTools` (`executor.ts:807-813`) copies `tool.name` / `mcpServerName` / `mcpToolName` from the **parent** manager's `getTools()`. Same public `mcp__…` names. One bridge child (parent's). No per-subagent MCP process.
140
+
141
+ ## C. session_start semantics (host)
142
+
143
+ ### C1. When TUI host `session_start` fires — CONFIRMED
144
+
145
+ `interactive-mode.ts:1221` first `requestRender(true)` (window paints).
146
+ `:1238` `await this.initHooksAndCustomTools()` → `extension-ui-controller.ts:302` initialize `"tui"` then `:309-311` emit `session_start`.
147
+
148
+ Fires **after first paint, before first prompt**. No model turn required. **Once per process** for the host runner: `/new` does not re-emit `session_start` (C2).
149
+
150
+ Comment at `:1204-1207` notes session_start `sendMessage({display:true})` can race `renderInitialMessages`.
151
+
152
+ ### C2. Re-fire on /new, --resume, --continue, switch/branch — CONFIRMED
153
+
154
+ Host process start (including CLI `--resume`/`--continue`/`--fork`): one `session_start` at TUI init, on whatever session was opened.
155
+
156
+ In-process thereafter, **`session_switch` / `session_branch`, not `session_start`:**
157
+
158
+ | action | events |
159
+ |---|---|
160
+ | `/new` | `session_before_switch` reason `"new"` (`agent-session.ts:6910-6913`) then `session_switch` reason `"new"` (`:6984-6987`) |
161
+ | fork | `session_before_switch` `"fork"` (`:7021`) then `session_switch` `"fork"` (`:7073-7076`) |
162
+ | switch / in-TUI resume | `session_before_switch` `"resume"` (`:7979-7982`) then `session_switch` `"resume"` (`:8071-8074`) |
163
+ | branch | `session_branch` (`:8350`, `:8481`) |
164
+
165
+ A birth hook that mints on `session_start` will not re-mint on `/new` unless it also listens to `session_switch`. Idempotent upsert on switch is the usual pattern; this audit does not implement it.
166
+
167
+ ## D. Session identity API
168
+
169
+ ### D1. ReadonlySessionManager + file naming — CONFIRMED
170
+
171
+ `session-manager.ts:359-376` `ReadonlySessionManager = Pick<SessionManager, "getCwd" | "getSessionDir" | "getSessionId" | "getSessionFile" | …>`.
172
+ - `getSessionId()` `:1946-1948` → `this.#sessionId`
173
+ - `getSessionFile()` `:1950-1952` → `this.#sessionFile` (`string | undefined`)
174
+ - mint: `mintSessionId()` `:95-97` `return Bun.randomUUIDv7();`
175
+ - file: `` `${fileSafeTimestamp(timestamp)}_${this.#sessionId}.jsonl` `` `:1134-1137`
176
+ - `fileSafeTimestamp` `:103-104` `iso.replace(/[:.]/g, "-")`
177
+ - dir: `getSessionsDir` → `~/.omp/agent/sessions/<cwd-encoded>/` (`utils/src/dirs.ts:781-783`; encoding `session-paths.ts:62-80`)
178
+
179
+ `nativeSessionId` candidate = UUIDv7 string (not the filename). Filename = `<iso-with-colons-as-dashes>_<uuidv7>.jsonl`. Persistence is lazy (`getSessionFile` may name a path not yet on disk).
180
+
181
+ ### D2. Extension factory + probe — CONFIRMED (probe names were already right)
182
+
183
+ `ExtensionFactory = (pi: ExtensionAPI) => void | Promise<void>` (`types.ts:1592`).
184
+ `-e` files: default export **or** the module itself if it is a function (`loader.ts:57-61`).
185
+ `ExtensionAPI.on` (`types.ts:1222+`) and `ExtensionAPI.getAllTools` (`types.ts:1433`) exist with those names.
186
+
187
+ Probe guessed `getSessionFile` / `getSessionId` — those **are** the v18 names. Removed nonexistent `getId()` fallback; added `getSessionDir`. File: `scripts/raw-omp-measure/probe-extension.ts`.
188
+
189
+ ## E. MCP tool-name dialect
190
+
191
+ ### E1. — CONFIRMED
192
+
193
+ `sanitizeMCPToolNamePart` `mcp/tool-bridge.ts:351-357`: lower, `[^a-z_]+` → `_`, collapse `_`, trim edge `_`.
194
+ Redundant prefix strip `:368-374` if `sanitizedToolName.startsWith(sanitizedServerName + "_")`.
195
+ Mint `:375` `capMCPToolNameLength(\`mcp__${sanitizedServerName}_${normalizedToolName}\`)` cap 64 (`:365-366`, `:396`).
196
+
197
+ Server `"entwurf-bridge"` → `entwurf_bridge`.
198
+ None of the tools start with `entwurf_bridge_` → **no prefix-strip**.
199
+ Digit in `entwurf_v2`: `2` → `_` → `entwurf_v_` → trim → `entwurf_v` (end-digit is eaten, not kept as `_`).
200
+
201
+ | tool | minted (all ≤64, unique) |
202
+ |---|---|
203
+ | entwurf_v2 | `mcp__entwurf_bridge_entwurf_v` |
204
+ | entwurf_self | `mcp__entwurf_bridge_entwurf_self` |
205
+ | entwurf_peers | `mcp__entwurf_bridge_entwurf_peers` |
206
+ | entwurf_inbox_read | `mcp__entwurf_bridge_entwurf_inbox_read` |
207
+ | entwurf_register_native | `mcp__entwurf_bridge_entwurf_register_native` |
208
+ | entwurf_fresh_call | `mcp__entwurf_bridge_entwurf_fresh_call` |
209
+ | entwurf_resume_call | `mcp__entwurf_bridge_entwurf_resume_call` |
210
+
211
+ Permission/approval: **same string**. `MCPTool.name` is the minted name (`tool-bridge.ts:481`); `readonly approval = "write"` (`:490`). `tools/approval.ts:110` consults `tools.approval.<tool.name>` (or `policyKey`). `approval.ts:261` special-cases `tool.name.startsWith("mcp__")`. No second Copilot-style dialect.
212
+
213
+ Live tool list remains the acceptance oracle (ledger).
214
+
215
+ ## F. MCP discovery / precedence
216
+
217
+ ### F1. Source order — CONFIRMED (priority table)
218
+
219
+ Providers inserted highest-priority first (`capability/index.ts:84-91`). Dedupe **first wins** (`:183`).
220
+
221
+ MCP provider priorities:
222
+
223
+ | priority | id | role |
224
+ |---|---|---|
225
+ | 100 | `native` / builtin | OMP `~/.omp/agent/mcp.json`, `<cwd>/.omp/mcp.json` |
226
+ | 90 | `omp-plugins` | installed omp plugins |
227
+ | 80 | `claude` | Claude Code — **third** among these families |
228
+ | 70 | `claude-plugins` | marketplace plugins |
229
+ | 60 | gemini | |
230
+ | 55 | opencode | |
231
+ | 50 | cursor | |
232
+ | 20 | vscode | |
233
+ | 5 | `mcp-json` | standalone project `mcp.json`/`.mcp.json` |
234
+
235
+ Within native (`builtin.ts:207-210`) and Claude (`claude.ts:104-117`) loaders: **project entries pushed before user entries** so a project name claims the dedupe key first. Claude comment `:104-105` ("Load project entries before user entries"). Same-name shadowing = first (highest priority provider, and within a provider project-before-user).
236
+
237
+ ### F2. disabledServers / enabledServers / mcp.enableProjectConfig — CONFIRMED
238
+
239
+ `mcp/config.ts:104-127`:
240
+ - `disabledServers` (user mcp.json): `suppressServer` `:123` — **highest-precedence denylist**; suppressed items still claim the dedupe key (`:163-165`).
241
+ - `enabledServers`: allowlist that overrides a source `enabled: false` (`:125`).
242
+ - `mcp.enableProjectConfig` default true (`settings-schema.ts:4660-4666`); when false, project-level items are **filtered out before dedupe** (`config.ts:115-116`) so they cannot shadow.
243
+
244
+ Wired at `sdk.ts:1864`.
245
+
246
+ ### F3. Claude import env — CONFIRMED (with `${VAR}` expansion)
247
+
248
+ `discovery/claude.ts:87-97`: `expandEnvVarsDeep(json.mcpServers)` then `env: serverConfig.env as Record<string, string>`.
249
+ `expandEnvVars` (`helpers.ts:446-452`) only substitutes `${NAME}` / `${NAME:-default}`. A literal `ENTWURF_BRIDGE_EXTERNAL_AGENT_ID=external-mcp/claude-code` **rides along unchanged**. Keys are not rewritten.
250
+
251
+ ### F4. omp never writes another tool's config — CONFIRMED
252
+
253
+ `mcp/config-writer.ts:301-304`: sourcePath allowed only for native `.omp/mcp.json` and `mcp-json` files. "Tool-owned configs (opencode.json, claude.json, settings.json …) MUST be omitted; we never mutate another tool's file." Toggle of imported servers goes to user `disabledServers` / `enabledServers` (`:320-324`). Writer targets `writeMCPConfigFile` on those omp-owned paths only.
254
+
255
+ ## G. MCP child process + env
256
+
257
+ ### G1. Bun.spawn env — CONFIRMED
258
+
259
+ `mcp/transports/stdio.ts:577-581`:
260
+ ```
261
+ const env = { ...Bun.env, ...this.config.env };
262
+ ```
263
+ Full process env inherited, then config entry `env` merged on top. Spawn `:600-609` uses that `env`. **No omp-injected session/agent id.** No `OMP_*` / `PI_SESSION_ID` planted here. Danger is pure inheritance passthrough of the parent's env (M6).
264
+
265
+ ### G2. Linux detached + cleanup — CONFIRMED
266
+
267
+ `stdio.ts:41-47`: Linux `detached: true` → setsid (own session, no controlling tty). macOS/Windows `false`.
268
+ `resolveStdioSpawnCommand` `:334` non-win32: `{ cmd, detached: platform !== "darwin" }`.
269
+ setsid does **not** reparent: child ppid stays the omp pid.
270
+ Kill: `signalStdioProcess` `:474-494` — if detached POSIX, `process.kill(-pid, SIGTERM)` (process group), then SIGKILL after grace (`:497+`). `manager.disconnectAll` `:946` discards every connection on teardown.
271
+
272
+ ### G3. PI_* inventory (packages/, production) — CONFIRMED absence of PI_SESSION_ID / PI_AGENT_ID
273
+
274
+ Repo-wide `PI_SESSION_ID` / `PI_AGENT_ID`: **nowhere**. Only `MNEMOPI_SESSION_ID` at `packages/mnemopi/src/mcp-tools.ts:434`.
275
+
276
+ Identity-overlap knobs omp **does** read:
277
+
278
+ | var | where |
279
+ |---|---|
280
+ | `PI_CONFIG_DIR` | `packages/utils/src/dirs.ts:210` (default `.omp`) |
281
+ | `PI_CODING_AGENT_DIR` | `dirs.ts:358+` agent dir override |
282
+ | `PI_PROFILE` | `dirs.ts:38,90,120` (legacy; `OMP_PROFILE` wins) |
283
+ | `PI_SMOL_MODEL` / `PI_SLOW_MODEL` / `PI_PLAN_MODEL` | `coding-agent/src/main.ts:1467-1469`; help `cli/help-extra.ts:59-61` |
284
+ | `PI_CODING_AGENT_SESSION_DIR` | `cli/args.ts:156` |
285
+
286
+ Other PI_* omp reads (not identity carriers; incomplete-by-theme, not a second address axis): `PI_COMPILED`, `PI_BUNDLED`, `PI_TIMING`, `PI_DEBUG_STARTUP`, `PI_NO_PTY`, `PI_NO_TITLE`, `PI_NOTIFICATIONS`, `PI_CONFIG_FILES`, `PI_EDIT_VARIANT`, `PI_FORCE_IMAGE_PROTOCOL`, `PI_TINY_*`, `PI_DIALECT`, `PI_PROXY*`, `PI_STREAM_*`, `PI_OPENAI_STREAM_*`, `PI_OPENROUTER_RESPONSES`, `PI_CODEX_*`, `PI_CACHE_RETENTION`, `PI_NO_INTERLEAVED_THINKING`, `PI_NO_THINKING_LOOP_GUARD`, `PI_AUTH_NO_BORROW`, `PI_AI_ANTIGRAVITY_*`, `PI_AI_GEMINI_CLI_VERSION`, `PI_TUI_*`, `PI_BASH_NO_CI`, `PI_SHELL_PREFIX`, `PI_DISABLE_UUTILS_BUILTINS`, `PI_SUBPROCESS_CMD`, `PI_BLOCKED_AGENT`, `PI_TASK_MAX_OUTPUT_*`, `PI_RPC_EMIT_TITLE`, `PI_COMMIT_*`, `PI_PERPLEXITY_*`, `PI_CODEX_WEB_SEARCH_MODEL`, `PI_TOKENIZER_ACCURATE`, `PI_NO_INTENT`, `PI_NATIVE_VARIANT`, `PI_TEST_RUNTIME`, `PI_PACKAGE_DIR`, `PI_DOCS_EMBED`, `PI_TINY_TRANSFORMERS_VERSION`.
287
+
288
+ ### G4. Legacy `.pi` — CONFIRMED not a native discovery root
289
+
290
+ Native extension dirs (`discovery/builtin.ts:58-72`, `:482-483`): `PATHS.projectDir` = `CONFIG_DIR_NAME` = **`.omp`** (`utils/src/dirs.ts:23`) and `getAgentDir()` = `~/.omp/agent` (or profile). Scans `<dir>/extensions`. **Not** `~/.pi/agent/extensions` or `<cwd>/.pi/extensions`.
291
+
292
+ `.pi` **is** honored as a **package.json manifest alias**:
293
+ - extension modules: `pkg?.omp ?? pkg?.pi` (`discovery/helpers.ts:616-617`)
294
+ - plugins: `pkg.omp ?? pkg.pi` (`extensibility/plugins/loader.ts:267-273`; `types.ts:27` "from package.json omp or pi field")
295
+
296
+ Examples still mention the old `~/.pi/agent/extensions` path; that is docs drift, not a loader root. **entwurf's pi extensions under `~/.pi/` cannot be ambiently loaded by omp.**
297
+
298
+ ## H. Receive + visible identity
299
+
300
+ ### H1. `pi.sendUserMessage` idle starts a turn — CONFIRMED
301
+
302
+ `types.ts:1417-1418`: "idle starts a turn; streaming queues as steer unless deliverAs is set."
303
+ `agent-session.ts:6515-6555`: omitted `deliverAs` → `this.prompt(...)` (starts a turn); `followUp`/`steer` only queue.
304
+ TUI wires `sendUserMessage: this.#sendExtensionUserMessage` (`extension-ui-controller.ts:176`) which calls `session.sendUserMessage`. `ctx.setTimeout` is `runner.ts:1178` managed timers — a background callback in TUI mode can call it. (Idle-wake demo is step 7 / out of this audit.)
305
+
306
+ ### H2. setStatus / setFooter — CORRECTED
307
+
308
+ API: `types.ts:285` `setStatus`, `:294` `setFooter`.
309
+ TUI implementation (`extension-ui-controller.ts:110,139`):
310
+ ```
311
+ setStatus: (key, text) => this.setHookStatus(key, text), // real
312
+ setFooter: () => {}, // NO-OP
313
+ setHeader: () => {}, // NO-OP
314
+ ```
315
+ `setHookStatus` → `statusLine.setHookStatus` (`:575-576` → `status-line/component.ts:678-682`).
316
+ Render: `component.ts:2246-2251`, gated by `statusLine.showHookStatus` default **true** (`settings-schema.ts:952-956`).
317
+ ACP uiContext: `setStatus`/`setFooter` both no-ops (`acp-agent.ts:574,577`).
318
+
319
+ **v18 TUI does not render extension `setFooter`.** Persistent garden-id surface = `setStatus` hook lines (or a built-in segment — H3).
320
+
321
+ ### H3. Built-in statusLine — CORRECTED (no custom command)
322
+
323
+ `config.yml` `statusLine.*`: preset, leftSegments, rightSegments, separator, showHookStatus, sessionAccent, transparent, segmentOptions, compactThinkingLevel, contextLine (`interactive-mode.ts:1977-1987`).
324
+ Segments are a **closed enum** (`status-line/segments.ts:731-757`): pi, model, mode, path, git, pr, subagents, token_*, cost, context_*, time*, session, hostname, cache_*, session_name, usage, collab. **No `command`, no free-text, no argv renderer.**
325
+ Not an alternative garden-id surface unless one of those segments already shows an id (none does). Use `setStatus`.
326
+
327
+ ## I. Config writer shapes
328
+
329
+ ### I1. mcp/config-writer.ts — CONFIRMED
330
+
331
+ User file: `getMCPConfigPath("user")` = `path.join(getAgentDir(), "mcp.json")` = `~/.omp/agent/mcp.json` (profile-aware) (`utils/src/dirs.ts:922-926`).
332
+ Project file: `path.join(cwd, ".omp", "mcp.json")` (`getProjectAgentDir` uses literal `CONFIG_DIR_NAME` `.omp`).
333
+ `addMCPServer` (`config-writer.ts:111-143`) writes `{ $schema, mcpServers: { [name]: config } }` via `writeMCPConfigFile`.
334
+
335
+ Minimal valid stdio entry (`config/mcp-schema.json` `$defs/stdioServer`, `mcp/types.ts:99-112`):
336
+
337
+ ```json
338
+ {
339
+ "$schema": "https://raw.githubusercontent.com/can1357/oh-my-pi/main/packages/coding-agent/src/config/mcp-schema.json",
340
+ "mcpServers": {
341
+ "entwurf-bridge": {
342
+ "command": "/abs/path/to/start.sh",
343
+ "args": [],
344
+ "env": {
345
+ "ENTWURF_BRIDGE_EXTERNAL_AGENT_ID": "external-mcp/omp"
346
+ }
347
+ }
348
+ }
349
+ }
350
+ ```
351
+
352
+ `type: "stdio"` optional (default). `command` required. `env` is a string map.
353
+
354
+ ### I2. `omp config` → config.yml — CONFIRMED
355
+
356
+ `cli/config-cli.ts:4-5`, `handleSet` `:363-378`: `parseAndSetValue` then `await settings.flush()`.
357
+ `Settings.#configPath` = `path.join(this.#agentDir, MAIN_CONFIG_FILENAMES[0])` (`settings.ts:401`) with `MAIN_CONFIG_FILENAMES = ["config.yml", "config.yaml"]` (`utils/src/dirs.ts:26`).
358
+ `flush` → `#saveNow` → `#writeYamlAtomically` to that path. **Yes: `omp config set` writes `~/.omp/agent/config.yml`.**
359
+
360
+ # Cross-review of external review (2026-08-27)
361
+
362
+ Reviewer: B-bot (sonnet) on issue #87 comment. Oracle: same v18.0.0 checkout. This section only; original ledger unchanged.
363
+
364
+ ## (1) CORRECTION-1 — `:531` is test-only vs production TUI caller — OVERSTATED (B-bot); original "test-only" STANDS
365
+
366
+ B-bot: `interactive-mode.ts:4468` calls `initializeHookRunner` in production, so `:531` is not test-only; predicate still holds because that caller is a TUI surface.
367
+
368
+ Walk of the full caller chain:
369
+
370
+ - `extension-ui-controller.ts:531` lives in `initializeHookRunner` (`:393`). Always passes `"tui"` (`:531`); `_hasUI` is unused.
371
+ - In-repo `.initializeHookRunner(` call sites (whole `packages/`):
372
+ - `interactive-mode.ts:4468` — **method body**, not a caller. Public delegate `InteractiveMode.initializeHookRunner` → controller.
373
+ - `test/repro-issue-1020-ctx-shutdown.test.ts:46` — **only actual call**, on the controller, from a unit test.
374
+ - Production TUI boot does **not** go through `:531`. It goes `interactive-mode.ts:1238` `initHooksAndCustomTools()` → controller `:100` → initialize `:302` + `session_start` `:309-311`.
375
+ - Grep for `ctx.initializeHookRunner` / `mode.initializeHookRunner` / `session.initializeHookRunner`: **zero hits**.
376
+
377
+ B-bot mistook a **definition** (`:4467-4468`) for a production call. Fable's spot-check was right.
378
+
379
+ Public-API nuance (does not revive B-bot's claim):
380
+ - `InteractiveMode` is a package export (`src/index.ts:35` `export * from "./modes"` → `modes/index.ts:9` `export * from "./interactive-mode"`; `package.json` `"./modes"` / `"./modes/*"`).
381
+ - `InteractiveModeContext.initializeHookRunner` is on the interface (`modes/types.ts:244`).
382
+ - `ExtensionUiController` is reachable via `"./modes/controllers/*"`.
383
+ - An out-of-repo SDK consumer *could* call `:531`. That path still hardcodes `mode: "tui"`. It is not the operator `omp` TUI. Predicate soundness holds.
384
+
385
+ Final: in-repo operator path, `:531` is test-only. Keep the original A1 note.
386
+
387
+ ## (2) CORRECTION-2 — digit eaten by sanitize, not cap — CONFIRMED
388
+
389
+ Matches E1. `sanitizeMCPToolNamePart` (`mcp/tool-bridge.ts:351-357`) charset is `[a-z_]`; `2` in `entwurf_v2` → `_` → trim → `entwurf_v`. Length of `mcp__entwurf_bridge_entwurf_v` is 29, under the 64-char cap (`:365-366`, `:396`). Cap never fires.
390
+
391
+ ## (3) DEFECT-1 — native shadows import iff same server key — CONFIRMED (with denylist amendment)
392
+
393
+ Location: `capability/mcp.ts:72-92` `isSameMCPConnection` (stdio: command/args/**env**/cwd `deepEquals`); `key: server => server.name` `:98`; `equivalent: isSameMCPConnection` `:99`. Dedupe: `capability/index.ts:183-210`.
394
+
395
+ **(a) Two paths, ordered.** For each item:
396
+ 1. If suppressed: claim `key` into `seen`, skip (never survives, never `equivalent`-shadows) (`:191-196`).
397
+ 2. If `key` already in `seen` → `_shadowed = true`, not pushed to `deduped` (`:203-207`). **`equivalent` is not consulted.**
398
+ 3. Else `aliasSeen = equivalent(existing, item)` only when `!keySeen` (`:205`). Same connection, different name → later name dropped; one connection under the first name.
399
+
400
+ `loadAllMCPConfigs` consumes **`result.items` only** (`mcp/config.ts:137-140`) = `deduped`. Shadowed entries are silent (no warning in the dedupe loop).
401
+
402
+ **(b) Same name, different env (planned case).** Native `ENTWURF_BRIDGE_EXTERNAL_AGENT_ID=external-mcp/omp` vs imported `…=external-mcp/claude-code`: `equivalent` would be **false**, but it never runs because `keySeen` is true. First-wins-by-key **fully suppresses** the import. Not both loaded, not replaced, no warning. Native provider priority 100 before Claude 80 (`discovery/builtin.ts:42` vs `claude.ts:35`), so the native entry is first.
403
+
404
+ **(c) Claude import key.** `discovery/claude.ts:88-92`: `Object.entries(mcpServers).map(([name, config]) => ({ name, … }))`. Plain `mcpServers` object key. No prefix. Host `~/.claude.json` `mcpServers.entwurf-bridge` → `name: "entwurf-bridge"`.
405
+
406
+ **(d) `disabledServers`.** `suppressServer` (`mcp/config.ts:123-127`) is `disabledServers.has(server.name)`. Both native and imported share `name === "entwurf-bridge"`, so **both are suppressed**. Suppressed items still claim the key (`index.ts:163-165`, `:191-196`) → nothing else named `entwurf-bridge` survives. Denylisting `entwurf-bridge` to hide the Claude import would also hide the native writer entry. That is a different operator action from shadowing.
407
+
408
+ **Acceptance sentence:** "native writer uses the same server key as the imported entry, gate-pinned" is **necessary and sufficient for shadowing**. Amend: pin the literal key `entwurf-bridge` (the `mcpServers` map key); env may differ (that is the label swap); do **not** treat `disabledServers: ["entwurf-bridge"]` as the hide-import mechanism when a native entry exists — it would kill both.
409
+
410
+ ## (4) ACP `enableMCP:false` as a free second fence — CONFIRMED restatement, OVERSTATED as a fence
411
+
412
+ A4 stands. `createAcpSessionFactory` comment + flag (`main.ts:407-412`, `:446`): `enableMCP: false` so `createAgentSession` skips on-disk `.mcp.json` discovery (else host tools shadow client servers, issue #1234).
413
+
414
+ Not a fence against **all** MCP. ACP MCP is owned by the client: `session/new.mcpServers` → `#configureMcpServers` (`acp-agent.ts:2608-2669`) builds a fresh `MCPManager` and `connectServers` on whatever the client sent (`:2649-2659`). If an ACP client passes an `entwurf-bridge` stdio/http server, those tools **do** surface (`refreshMCPTools`). Disk-import is skipped; client-supplied MCP is not. "Bridge tools never appear in ACP even if the tui fence loosened" is overstated — they appear iff the ACP client hands them over.