@junghanacs/entwurf 0.15.1 → 0.16.1

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 (91) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +354 -0
  3. package/DELIVERY.md +3 -2
  4. package/README.md +68 -88
  5. package/VERIFY.md +4 -1
  6. package/demo/README.md +1 -1
  7. package/docs/acp-backend-rail.md +1 -1
  8. package/docs/external-mcp-host.md +147 -9
  9. package/docs/setup-clean-host.md +125 -6
  10. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +65 -20
  11. package/mcp/entwurf-bridge/dist/pi-extensions/entwurf-capabilities.json +1 -0
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +19 -10
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-self-address.js +17 -6
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-sender-identity.js +1 -1
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +172 -5
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +152 -17
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/omp-fresh-preflight.js +271 -0
  18. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-omp.js +988 -0
  19. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-receive-omp.js +574 -0
  20. package/mcp/entwurf-bridge/dist/scripts/omp-receive-facts.js +84 -0
  21. package/mcp/entwurf-bridge/src/index.ts +65 -19
  22. package/mcp/entwurf-bridge/tsconfig.build.json +19 -3
  23. package/mcp/tsconfig.json +10 -0
  24. package/package.json +19 -9
  25. package/pi/entwurf-capabilities.json +1 -0
  26. package/pi/meta-bridge-omp/entwurf-meta-omp/package.json +7 -0
  27. package/pi/omp-receive/entwurf-receive-omp/package.json +7 -0
  28. package/pi-extensions/entwurf-control.ts +12 -9
  29. package/pi-extensions/lib/acp/backend-adapter.ts +19 -9
  30. package/pi-extensions/lib/acp/backend.ts +125 -7
  31. package/pi-extensions/lib/acp/claude-acp-launch.js +100 -0
  32. package/pi-extensions/lib/entwurf-self-address.ts +18 -7
  33. package/pi-extensions/lib/meta-sender-identity.ts +1 -1
  34. package/pi-extensions/lib/meta-session.ts +219 -5
  35. package/pi-extensions/lib/mux-fresh-call.ts +171 -17
  36. package/pi-extensions/lib/omp-fresh-preflight.ts +275 -0
  37. package/pi-extensions/meta-bridge-omp.ts +1244 -0
  38. package/pi-extensions/meta-bridge-receive-omp.ts +666 -0
  39. package/run.sh +399 -31
  40. package/scripts/check-acp-launch-namespace.ts +127 -0
  41. package/scripts/check-acp-prompt-lifecycle.ts +145 -2
  42. package/scripts/check-copilot-birth-hook.ts +28 -1
  43. package/scripts/check-entwurf-self-address.ts +31 -0
  44. package/scripts/check-gate-qualification.ts +7 -3
  45. package/scripts/check-harness-admission-parity.ts +143 -0
  46. package/scripts/check-meta-doctor-oracle.sh +20 -0
  47. package/scripts/check-omp-birth-hook.ts +1049 -0
  48. package/scripts/check-omp-fresh-preflight.ts +208 -0
  49. package/scripts/check-omp-receive-arm.ts +516 -0
  50. package/scripts/check-setup-qualification.sh +40 -2
  51. package/scripts/copilot-bridge-oracle.sh +14 -6
  52. package/scripts/fake-copilot-vendor.sh +4 -2
  53. package/scripts/inventory-verification-surface.ts +1 -1
  54. package/scripts/meta-bridge-hook-log.sh +9 -1
  55. package/scripts/mutants/acp-launch-namespace.json +34 -0
  56. package/scripts/mutants/acp-prompt-lifecycle.json +67 -2
  57. package/scripts/mutants/copilot-birth.json +10 -10
  58. package/scripts/mutants/mux-fresh-call.json +22 -22
  59. package/scripts/mutants/omp-birth.json +173 -0
  60. package/scripts/mutants/omp-fresh.json +300 -0
  61. package/scripts/mutants/omp-receive.json +135 -0
  62. package/scripts/mutants/pack-install.json +2 -2
  63. package/scripts/mutants/self-address.json +34 -0
  64. package/scripts/mutants/setup-verdict.json +35 -0
  65. package/scripts/omp-bridge-doctor.sh +315 -0
  66. package/scripts/omp-bridge-install.sh +221 -0
  67. package/scripts/omp-bridge-oracle.sh +154 -0
  68. package/scripts/omp-bridge-uninstall.sh +57 -0
  69. package/scripts/omp-config-xdev.py +310 -0
  70. package/scripts/omp-config-xdev.sh +76 -0
  71. package/scripts/omp-mcp-bridge.sh +320 -0
  72. package/scripts/omp-mcp-config.py +392 -0
  73. package/scripts/omp-receive-doctor.sh +246 -0
  74. package/scripts/omp-receive-facts.ts +106 -0
  75. package/scripts/omp-receive-install.sh +228 -0
  76. package/scripts/omp-receive-uninstall.sh +60 -0
  77. package/scripts/omp-tool-surface.py +400 -0
  78. package/scripts/raw-acp-child-exit-measure/README.md +285 -0
  79. package/scripts/raw-acp-child-exit-measure/acp-turn-population.py +89 -0
  80. package/scripts/raw-acp-child-exit-measure/reaper-correlation.py +47 -0
  81. package/scripts/raw-omp-measure/README.md +420 -0
  82. package/scripts/raw-omp-measure/probe-extension.ts +76 -0
  83. package/scripts/raw-omp-measure/probe-receive-surface.ts +250 -0
  84. package/scripts/raw-omp-measure/source-audit.md +414 -0
  85. package/scripts/smoke-omp-bridge-state.sh +221 -0
  86. package/scripts/smoke-omp-fresh-live.ts +497 -0
  87. package/scripts/smoke-omp-mcp-state.sh +327 -0
  88. package/scripts/smoke-omp-receive-live.ts +340 -0
  89. package/scripts/smoke-omp-receive-state.sh +196 -0
  90. package/scripts/smoke-setup-verdict.sh +48 -3
  91. package/scripts/tsconfig.json +2 -0
@@ -119,7 +119,13 @@ export interface AcpChildLike {
119
119
  signalCode: NodeJS.Signals | null;
120
120
  stdin: { destroy(): void; unref?(): void };
121
121
  stdout: { destroy(): void; unref?(): void };
122
- stderr: { on(event: "data", listener: (chunk: Buffer) => void): void; destroy(): void; unref?(): void };
122
+ stderr: {
123
+ on(event: "data", listener: (chunk: Buffer) => void): void;
124
+ /** Optional: absent on minimal fakes; the flush is best-effort, never load-bearing for liveness. */
125
+ once?(event: "close", listener: () => void): void;
126
+ destroy(): void;
127
+ unref?(): void;
128
+ };
123
129
  kill(signal?: NodeJS.Signals | number): boolean;
124
130
  unref(): void;
125
131
  once(event: "exit" | "error", listener: (...args: unknown[]) => void): void;
@@ -210,6 +216,11 @@ interface BridgeSession {
210
216
  * nothing to diagnose it by (observed 2026-07-30 on a live sonnet reuse turn).
211
217
  */
212
218
  stderrTail: string[];
219
+ /**
220
+ * SESSION-scoped like `stderrTail`, and for the same reason: the launcher's
221
+ * frame can arrive on a turn later than the one that spawned the child.
222
+ */
223
+ launchObservation: AcpLaunchObservation;
213
224
  /** How the child ended, once it has — folded into the prompt-phase error. */
214
225
  exit?: { code: number | null; signal: NodeJS.Signals | null };
215
226
  /**
@@ -367,6 +378,85 @@ const CHILD_END_SETTLE_MS = 500;
367
378
  */
368
379
  const ACP_CONNECTION_CLOSED_TEXT = "ACP connection closed";
369
380
 
381
+ /**
382
+ * The launcher's control frame — see `claude-acp-launch.js`.
383
+ *
384
+ * Matched as an EXACT FULL LINE and nothing else. The vendor writes prose that
385
+ * mentions signals; a substring test would let vendor text manufacture an
386
+ * entwurf observation, which is precisely the confusion #72 cost three
387
+ * diagnosis passes. Our own frame is a fixed enum, so exact-line matching is
388
+ * sufficient AND necessary.
389
+ */
390
+ const LAUNCH_SIGNAL_FRAME_PREFIX = "ENTWURF_ACP_LAUNCH_SIGNAL=";
391
+ const LAUNCH_SIGNAL_FRAME_VALUES: ReadonlySet<string> = new Set(["SIGTERM", "SIGINT"]);
392
+
393
+ /** A mutable, session-scoped record of a terminating signal the LAUNCHER caught. */
394
+ export type AcpLaunchObservation = { signal?: "SIGTERM" | "SIGINT" };
395
+
396
+ /**
397
+ * Split a stderr chunk stream into lines, consuming our own frames and passing
398
+ * everything else through to the tail untouched.
399
+ *
400
+ * Line-buffered because a chunk boundary can fall inside a frame; the trailing
401
+ * partial line is held, not emitted, so a frame split across two reads is still
402
+ * recognised exactly.
403
+ */
404
+ function makeLaunchFrameFilter(observation: AcpLaunchObservation, onText: (text: string) => void) {
405
+ let held = "";
406
+ return {
407
+ write(chunk: string): void {
408
+ held += chunk;
409
+ let nl = held.indexOf("\n");
410
+ let passed = "";
411
+ while (nl !== -1) {
412
+ const line = held.slice(0, nl);
413
+ if (
414
+ line.startsWith(LAUNCH_SIGNAL_FRAME_PREFIX) &&
415
+ LAUNCH_SIGNAL_FRAME_VALUES.has(line.slice(LAUNCH_SIGNAL_FRAME_PREFIX.length))
416
+ ) {
417
+ // FIRST caught signal wins: a later one is our own teardown racing
418
+ // the external kill, and reporting that would bury the cause.
419
+ observation.signal ??= line.slice(LAUNCH_SIGNAL_FRAME_PREFIX.length) as "SIGTERM" | "SIGINT";
420
+ } else {
421
+ passed += `${line}\n`;
422
+ }
423
+ held = held.slice(nl + 1);
424
+ nl = held.indexOf("\n");
425
+ }
426
+ if (passed) onText(passed);
427
+ },
428
+ /**
429
+ * The child's LAST words may arrive without a trailing newline — a process
430
+ * dying mid-write is exactly when that happens, and it is exactly when the
431
+ * tail matters most. Line buffering would otherwise hold that fragment
432
+ * forever, so the stream's close flushes it VERBATIM.
433
+ *
434
+ * No frame check here, deliberately: the launcher writes its frame with a
435
+ * single `writeSync` including the newline, well under PIPE_BUF, so a
436
+ * complete frame can never be the un-terminated remainder. Anything left
437
+ * without a newline is vendor text by construction.
438
+ */
439
+ flush(): void {
440
+ if (!held) return;
441
+ onText(held);
442
+ held = "";
443
+ },
444
+ };
445
+ }
446
+
447
+ /**
448
+ * What the operator is told about a caught signal — and what they are NOT told.
449
+ *
450
+ * Absence is reported as "not observed", never as "no signal": the launcher can
451
+ * only see what reached it, and an override launch (`CLAUDE_AGENT_ACP_COMMAND`)
452
+ * has no launcher at all. Attribution of the SENDER is not claimed here either —
453
+ * that needs the host's journal, which this process cannot read.
454
+ */
455
+ function launchSignalLine(observation: AcpLaunchObservation | undefined): string | undefined {
456
+ if (!observation?.signal) return undefined;
457
+ return `[acp] launch observed ${observation.signal} before child exit (sender not attributed)`;
458
+ }
459
+
370
460
  function isAcpConnectionClosure(err: unknown): boolean {
371
461
  const message = err instanceof Error ? err.message : typeof err === "string" ? err : "";
372
462
  return message.trim() === ACP_CONNECTION_CLOSED_TEXT;
@@ -921,14 +1011,24 @@ export function streamAcpTurn(
921
1011
  stream.end();
922
1012
  }
923
1013
 
924
- function finishError(err: unknown, aborted: boolean, stderrTail?: string[], lifecycle?: string): void {
1014
+ function finishError(
1015
+ err: unknown,
1016
+ aborted: boolean,
1017
+ stderrTail?: string[],
1018
+ lifecycle?: string,
1019
+ launchObservation?: AcpLaunchObservation,
1020
+ ): void {
925
1021
  finalizeAcpStreamState(state);
926
1022
  state.output.stopReason = aborted ? "aborted" : "error";
927
1023
  const base = err instanceof Error ? err.message : String(err);
928
1024
  // The FIRST failure stays first and verbatim (it is what the backend
929
1025
  // actually said); the lifecycle line is added, never substituted, so a
930
1026
  // reader can still match the transport's own text.
931
- const diagnosed = lifecycle ? `${base}\n${lifecycle}` : base;
1027
+ const withLifecycle = lifecycle ? `${base}\n${lifecycle}` : base;
1028
+ // Its OWN line, above the vendor tail: an entwurf-owned observation must not
1029
+ // have to be recovered by reading vendor prose.
1030
+ const observed = launchSignalLine(launchObservation);
1031
+ const diagnosed = observed ? `${withLifecycle}\n${observed}` : withLifecycle;
932
1032
  const tail = (stderrTail ?? []).join("").trim().slice(-1_000);
933
1033
  const full = tail ? `${diagnosed}\n--- backend stderr (tail) ---\n${tail}` : diagnosed;
934
1034
  // A-c: a real failure (not an abort) that looks like a context-window
@@ -1124,6 +1224,8 @@ export function streamAcpTurn(
1124
1224
  /** Which phase a failure belongs to — flipped once the prompt is on the wire. */
1125
1225
  let phase: "pre-prompt" | "prompt" = "pre-prompt";
1126
1226
  const stderrTail: string[] = [];
1227
+ // Session-scoped alongside the tail — see the field's note on the session type.
1228
+ const launchObservation: AcpLaunchObservation = {};
1127
1229
  const sessionKey = resolveSessionKey(opts, cwd);
1128
1230
  try {
1129
1231
  if (signal?.aborted) throw new Error("aborted before launch");
@@ -1156,10 +1258,18 @@ export function streamAcpTurn(
1156
1258
  const spawned = child;
1157
1259
 
1158
1260
  // Drain stderr (an unconsumed pipe can backpressure-deadlock a long turn).
1159
- spawned.stderr.on("data", (c: Buffer) => {
1160
- stderrTail.push(c.toString());
1261
+ // The launcher's own control frames are consumed here and kept OUT of the
1262
+ // tail: the tail is vendor evidence, the observation is an entwurf fact,
1263
+ // and mixing the two is the overloading #72 was made of.
1264
+ const consumeStderr = makeLaunchFrameFilter(launchObservation, (text) => {
1265
+ stderrTail.push(text);
1161
1266
  if (stderrTail.length > 50) stderrTail.shift();
1162
1267
  });
1268
+ spawned.stderr.on("data", (c: Buffer) => consumeStderr.write(c.toString()));
1269
+ // `close` rather than `end`: it covers the destroy path our own teardown
1270
+ // takes, and on the EOF-first death it lands inside the post-mortem
1271
+ // settle window, so the flushed fragment is in the tail before we seal.
1272
+ spawned.stderr.once?.("close", () => consumeStderr.flush());
1163
1273
 
1164
1274
  // Abort during BOOTSTRAP (spawn → initialize → newSession → set-model):
1165
1275
  // there is no prompt turn for the agent to cancel yet, so the child is
@@ -1211,6 +1321,8 @@ export function streamAcpTurn(
1211
1321
  // this turn with the session, so a later reuse turn can still report
1212
1322
  // the child's dying words.
1213
1323
  stderrTail,
1324
+ // SAME object the frame filter writes into, for the same reason.
1325
+ launchObservation,
1214
1326
  // Armed at spawn, before ANY turn can fail on this child — the latch
1215
1327
  // must already exist when the `exit` listener below can fire.
1216
1328
  childEnd: makeChildEndLatch(),
@@ -1345,7 +1457,13 @@ export function streamAcpTurn(
1345
1457
  // own SIGTERM; the tail collected by then is sealed before our cleanup
1346
1458
  // touches the stderr pipe.
1347
1459
  const lifecycle = await diagnoseTransportClosure({ err, session, phase, aborted });
1348
- finishError(err, aborted, session?.stderrTail ?? stderrTail, lifecycle);
1460
+ finishError(
1461
+ err,
1462
+ aborted,
1463
+ session?.stderrTail ?? stderrTail,
1464
+ lifecycle,
1465
+ session?.launchObservation ?? launchObservation,
1466
+ );
1349
1467
  // error/abort → drop the (uncertain) session and close its child; an
1350
1468
  // uncertain connection must never be reused (GPT ④).
1351
1469
  if (child) {
@@ -1412,7 +1530,7 @@ export function streamAcpTurn(
1412
1530
  // Without the session-scoped tail a mid-turn child death on a resident
1413
1531
  // session surfaced as a bare "ACP connection closed" with nothing to read
1414
1532
  // it by.
1415
- finishError(err, aborted, session.stderrTail, lifecycle);
1533
+ finishError(err, aborted, session.stderrTail, lifecycle, session.launchObservation);
1416
1534
  // error/abort on a reused session → drop it and close the child (GPT ④).
1417
1535
  session.retiring = true;
1418
1536
  retainedChildren.delete(session.child);
@@ -0,0 +1,100 @@
1
+ // entwurf-owned launcher for the Claude ACP backend (#72).
2
+ //
3
+ // WHY THIS FILE EXISTS — it is not a wrapper for its own sake.
4
+ //
5
+ // The vendor bin is named `claude-agent-acp`, and that name is not ours: any
6
+ // harness on the host that spawns the same package produces a process with the
7
+ // same name. On GLG's oracle host a janitor installed for a DIFFERENT harness
8
+ // (openclaw's acpx, upstream PR #245) selects `claude-agent-acp` by argv
9
+ // SUBSTRING and SIGTERMs anything older than 900s. entwurf's child is retained
10
+ // across turns, so its age is the age of the SESSION, not of a turn — every
11
+ // session older than 15 minutes was shot at every 5 minutes. Measured: 12 of 12
12
+ // anomalous terminations across two boots, pid- and timestamp-locked (receipts
13
+ // in `scripts/raw-acp-child-exit-measure/README.md` §ANSWERED).
14
+ //
15
+ // So this launcher does two things, and deliberately nothing else:
16
+ //
17
+ // 1. NAMESPACE. It carries a name that is ours, so a name-matching janitor
18
+ // stops selecting our process. The vendor is `import`ed INTO this same
19
+ // process — not spawned as a child. That distinction is what keeps this
20
+ // inside #72's repair fence: with no child to restart, this cannot become
21
+ // the supervisor/watcher/retry the issue forbids. Any variant that spawns
22
+ // the vendor breaks the fence and must not be written.
23
+ //
24
+ // 2. OBSERVE A CAUGHT SIGNAL. The vendor's own handler turns SIGTERM/SIGINT
25
+ // into `dispose(); process.exit(0)`, so by the time entwurf sees the child
26
+ // end there is an exit code 0 and NO signal — a clean external kill and a
27
+ // vendor fault are indistinguishable. Registering first lets us record
28
+ // that a terminating signal was caught, before the vendor erases it.
29
+ //
30
+ // WHAT THIS COSTS, STATED SO NOBODY DISCOVERS IT LATER. Once the name split is
31
+ // in place, a host janitor that scans for the vendor name can no longer see
32
+ // entwurf's children AT ALL — including ones that genuinely leaked. If pi is
33
+ // SIGKILLed so `teardownChild` never runs, this adapter reparents to PID 1 and
34
+ // matches neither that janitor's name phase nor its orphan phase (which looks
35
+ // for a bare `claude`). That leak class now belongs to entwurf: after the split,
36
+ // we own our own cleanup story and cannot expect someone else's timer to cover
37
+ // it. This is a deliberate trade — being killed mid-turn is worse than leaking
38
+ // a process on an abnormal exit — but it is a trade, not a free win.
39
+ //
40
+ // ARGV IS NOT OURS TO TOUCH. The vendor reads `--cli` / `--version` /
41
+ // `--hide-claude-auth` from `process.argv`, and builds its own re-invocation
42
+ // command from `process.argv.slice(1)` for the terminal-auth advert. Under this
43
+ // launcher that advert becomes `node <this file> --cli auth login …`, which
44
+ // keeps working ONLY because we consume no flags and resolve the vendor
45
+ // ourselves. Never give this file an option of its own.
46
+
47
+ import { readFileSync, writeSync } from "node:fs";
48
+ import { createRequire } from "node:module";
49
+ import { dirname, join } from "node:path";
50
+ import { pathToFileURL } from "node:url";
51
+
52
+ /**
53
+ * The one line entwurf's stderr drain looks for, matched as an EXACT full line.
54
+ * A fixed enum, never interpolated from vendor text: the backend must be unable
55
+ * to mistake vendor prose (which mentions signals) for our own observation.
56
+ */
57
+ const SIGNAL_FRAME_PREFIX = "ENTWURF_ACP_LAUNCH_SIGNAL=";
58
+
59
+ /** Signals the vendor normalizes to exit 0, and which are therefore invisible downstream. */
60
+ const OBSERVED_SIGNALS = /** @type {const} */ (["SIGTERM", "SIGINT"]);
61
+
62
+ for (const signal of OBSERVED_SIGNALS) {
63
+ process.on(signal, () => {
64
+ // writeSync, not console.error: a handler may run while the process is
65
+ // tearing down and an async write can be dropped.
66
+ try {
67
+ writeSync(2, `${SIGNAL_FRAME_PREFIX}${signal}\n`);
68
+ } catch {
69
+ // stderr already gone — the observation is best-effort, never fatal.
70
+ }
71
+ // THE SINK GUARD. Registering a handler SUPPRESSES node's default
72
+ // termination. If we are still the only listener — the vendor has not
73
+ // registered yet, or its import failed — this observer would make the
74
+ // process immune to the very signal it is observing, including entwurf's
75
+ // own teardown SIGTERM. Stand down and let the signal land for real.
76
+ if (process.listenerCount(signal) === 1) {
77
+ process.removeAllListeners(signal);
78
+ process.kill(process.pid, signal);
79
+ }
80
+ });
81
+ }
82
+
83
+ // Resolution lives INSIDE the same try as the import so both failures speak with
84
+ // one voice: a missing package and a broken package are the same event to an
85
+ // operator reading stderr, and only one of them would otherwise be legible.
86
+ try {
87
+ const require = createRequire(import.meta.url);
88
+ const pkgJsonPath = require.resolve("@agentclientprotocol/claude-agent-acp/package.json");
89
+ const pkgJson = JSON.parse(readFileSync(pkgJsonPath, "utf8"));
90
+ const binPath = typeof pkgJson.bin === "string" ? pkgJson.bin : pkgJson.bin?.["claude-agent-acp"];
91
+ if (!binPath) throw new Error("@agentclientprotocol/claude-agent-acp resolved but exposes no bin entry");
92
+ // Same process, no argv touched. The vendor bin has no main-module guard, so
93
+ // importing it starts the agent exactly as executing it would.
94
+ await import(pathToFileURL(join(dirname(pkgJsonPath), binPath)).href);
95
+ } catch (err) {
96
+ // Fail loud and DIE. No retry, no fallback: a launcher that survives its own
97
+ // failure is the hidden supervisor #72 forbids.
98
+ writeSync(2, `entwurf acp launcher: vendor import failed: ${err instanceof Error ? err.stack : String(err)}\n`);
99
+ process.exit(1);
100
+ }
@@ -33,10 +33,13 @@
33
33
  * - external-mcp: never replyable — no authoritative reply address.
34
34
  *
35
35
  * `origin` stays sender-carrier PROVENANCE, never the citizen identity authority and never a rail.
36
- * Which rail a meta citizen's reply rides is a SECOND axis — `metaDeliveryDomain`, derived
37
- * by the caller from `nativePushSupported(backend)`, not from `wakeMode` (direct-inject also
38
- * covers codex/pi, which have no native-push adapter). Fail-closed: an unsupplied domain is
39
- * not replyable.
36
+ * Which rail a meta citizen's reply rides is a SECOND axis — `metaDeliveryDomain`.
37
+ * The caller derives it as three values, never as a native-push-or-self-fetch binary:
38
+ * native-push `nativePushSupported(backend)` (the adapter list; NOT wakeMode —
39
+ * `direct-inject` also covers codex/pi, which have no native-push adapter)
40
+ * self-fetch ← `resolveMailboxWakeModeCapability` (the decider's own mailbox seam)
41
+ * none ← neither (omp today: no mailbox drain, no native-push adapter)
42
+ * Fail-closed: an unsupplied domain is not replyable.
40
43
  */
41
44
 
42
45
  import { computeMetaReceiverActive, nativePushDeliverable } from "./entwurf-deliverability.ts";
@@ -44,7 +47,7 @@ import { computeMetaReceiverActive, nativePushDeliverable } from "./entwurf-deli
44
47
  export type SelfOrigin = "pi-session" | "meta-session" | "external-mcp";
45
48
 
46
49
  /** Which delivery rail carries a reply back to a meta citizen (the second axis, never `origin`). */
47
- export type MetaDeliveryDomain = "self-fetch" | "native-push";
50
+ export type MetaDeliveryDomain = "self-fetch" | "native-push" | "none";
48
51
 
49
52
  /**
50
53
  * pi control-socket reachability for a reply addressed back to this session.
@@ -108,9 +111,11 @@ export function computeSelfAddressability(facts: SelfAddressabilityFacts): SelfA
108
111
  };
109
112
  }
110
113
  case "meta-session": {
111
- // TWO rails, pinned apart (보정①). Each branch composes the predicate that OWNS its
114
+ // THREE rails, pinned apart (보정①). Each branch composes the predicate that OWNS its
112
115
  // axis — the mailbox receiver atom and the native-push predicate share nothing, so a
113
- // mailbox liveness fact can never leak into a backend that has no mailbox.
116
+ // mailbox liveness fact can never leak into a backend that has no mailbox. `none` is
117
+ // the remainder: no inbound rail at all (not an unsupplied domain — that stays the
118
+ // default fail-closed row).
114
119
  switch (facts.metaDeliveryDomain) {
115
120
  case "native-push": {
116
121
  const push = nativePushDeliverable({ recordBacked: facts.recordBacked, probeAlive: facts.probeAlive });
@@ -136,6 +141,12 @@ export function computeSelfAddressability(facts: SelfAddressabilityFacts): SelfA
136
141
  reason: recv.active ? `meta receiver active (${recv.reason})` : `meta receiver inactive — ${recv.reason}`,
137
142
  };
138
143
  }
144
+ case "none":
145
+ return {
146
+ replyable: false,
147
+ socketState: "none",
148
+ reason: "no inbound rail — this backend has no mailbox and no native-push adapter",
149
+ };
139
150
  default:
140
151
  return {
141
152
  replyable: false,
@@ -57,7 +57,7 @@ import { type NativePushAdapter, resolveNativePushAdapter } from "./native-push/
57
57
  * exactly the #46 defect on agy, and copilot joined the list only once its own hook wrote one
58
58
  * (#82 RAIL 5b). Membership says a marker may EXIST, never that a reply can land: the reply
59
59
  * rail is chosen from `nativePushSupported` at the bridge, not from this list. */
60
- export const META_SENDER_BACKENDS: readonly MetaBackend[] = ["claude-code", "antigravity", "copilot"];
60
+ export const META_SENDER_BACKENDS: readonly MetaBackend[] = ["claude-code", "antigravity", "copilot", "omp"];
61
61
 
62
62
  /** A marker that passed BOTH guards, together with the record that vouches for it. */
63
63
  export interface TrustedMetaSender {
@@ -81,7 +81,7 @@ export class MetaRecordError extends Error {
81
81
  * every write, including its own. `./run.sh doctor-meta-bridge` already flags a
82
82
  * stale deployed writer — run it after the change.
83
83
  */
84
- export const META_BACKENDS = ["claude-code", "antigravity", "codex", "copilot"] as const;
84
+ export const META_BACKENDS = ["claude-code", "antigravity", "codex", "copilot", "omp"] as const;
85
85
  export type MetaBackend = (typeof META_BACKENDS)[number];
86
86
 
87
87
  /**
@@ -170,6 +170,58 @@ export const META_BACKEND_DESCRIPTORS: Record<MetaBackend, MetaBackendDescriptor
170
170
  deliveryLevel: "D6",
171
171
  nativeIdLabel: "sessionId",
172
172
  },
173
+ // SELF-FETCH citizen since #87 bundle B (oh-my-pi v18.0.0). An omp "hook" is an
174
+ // in-process EXTENSION (`--hook` aliases `--extension`), so BOTH units run inside the
175
+ // omp host: the birth unit mints on `session_start`/`session_switch`, and the receiver
176
+ // unit arms a mailbox watch in the same process and rings the doorbell.
177
+ // self-fetch The doorbell announces and the model drains its own inbox with
178
+ // `entwurf_inbox_read` — that read is the receipt, and the notice never
179
+ // carries the body. `[LIVE 2026-08-30, oracle, omp 18.0.0]` the vendor's
180
+ // own wake surface was measured rather than inherited:
181
+ // `pi.sendUserMessage(text,{deliverAs:"user"})` (on the FACTORY object, not
182
+ // the event ctx) called on an IDLE tui session with zero typing started a
183
+ // real turn — `agent_start` +31ms, `turn_start` +64ms, `turn_end` +2.45s,
184
+ // model reply in the transcript. It was `direct-inject` while no process
185
+ // held a watch; that label was the honest "not a drainable mailbox" bucket,
186
+ // and it stopped being true when the receiver unit landed.
187
+ // Replyability is still a fact about a LIVE marker, never a constant of this
188
+ // backend: with no armed receiver every dispatch is refused
189
+ // `mailbox-undeliverable`, which is exactly what an omp host with the birth
190
+ // unit and no receiver unit still gets.
191
+ // D6 the PRODUCT grade, earned by the LIVE roundtrip of 2026-08-30 on oracle
192
+ // (omp 18.0.0). The code landing was NOT the evidence — this number moved
193
+ // when the receipt did (`adding-a-harness.md` step 8(c)):
194
+ // garden `20260830T140819-116f6a`, `lastEnqueuedAt 05:08:20.555Z` /
195
+ // `lastReadAt 05:08:23.958Z`, doorbell rung on an idle session with zero
196
+ // typing, and the citizen's OWN transcript carrying
197
+ // `mcp__entwurf_bridge_entwurf_inbox_read` for its own garden id — the join
198
+ // that makes the drain a fact about THAT session rather than about the clock.
199
+ // D3 is PROVEN here rather than pending: with two live omp citizens armed
200
+ // (pids 3154765 / 3154835) a single addressed enqueue rang exactly one
201
+ // doorbell, and the sibling persisted no transcript and kept an empty
202
+ // mailbox. That is the cell the Copilot row still lists as PENDING.
203
+ // D7 is PARTIAL, deliberately: `lastReadAt` is observable without scraping,
204
+ // but the reply itself is only visible in the transcript, and no completion
205
+ // taxonomy or long-haul operation was measured.
206
+ // D8 is PARTIAL: fresh-only ring (dedupe), `/new` unarm, watch-error unarm,
207
+ // vanished-signal unarm and the identity-guarded teardown are implemented and
208
+ // pinned hermetically by `check-omp-receive-arm`; ordering under load, loop
209
+ // guards and crash recovery are not measured.
210
+ // Replyability stays a fact about a LIVE marker: with no armed receiver every
211
+ // dispatch is still refused `mailbox-undeliverable` — re-proved on the same
212
+ // day against a garden id whose session had just been replaced by `/new`.
213
+ // sessionId the native join key: `ReadonlySessionManager.getSessionId()`
214
+ // (`session-manager.ts:1946-1948`), a UUIDv7 minted at
215
+ // `mintSessionId()` `:95-97` — NOT the transcript filename, which is
216
+ // `<iso>_<uuidv7>.jsonl` (`:1134-1137`). Measured LIVE 2026-08-27:
217
+ // host `01a042da-537a-7770-a275-7b8162eecca4`
218
+ // (`scripts/raw-omp-measure/README.md` M1).
219
+ omp: {
220
+ backend: "omp",
221
+ wakeMode: "self-fetch",
222
+ deliveryLevel: "D6",
223
+ nativeIdLabel: "sessionId",
224
+ },
173
225
  };
174
226
 
175
227
  // ---------------------------------------------------------------------------
@@ -253,7 +305,7 @@ function isoNow(now: Date): string {
253
305
  export const META_SCHEMA_VERSION_V3 = 3 as const;
254
306
 
255
307
  /** Every backend admitted by the one V3 record-citizen schema. */
256
- export const META_CITIZEN_BACKENDS = ["claude-code", "antigravity", "codex", "copilot", "pi"] as const;
308
+ export const META_CITIZEN_BACKENDS = ["claude-code", "antigravity", "codex", "copilot", "omp", "pi"] as const;
257
309
  export type MetaCitizenBackend = (typeof META_CITIZEN_BACKENDS)[number];
258
310
 
259
311
  /**
@@ -1234,12 +1286,16 @@ export function decideUpsert(
1234
1286
  // stays strip-types clean (see module header for why this is not a sibling file).
1235
1287
  // ---------------------------------------------------------------------------
1236
1288
 
1237
- function expandTilde(p: string): string {
1238
- if (p === "~") return os.homedir();
1239
- if (p.startsWith("~/")) return path.join(os.homedir(), p.slice(2));
1289
+ function expandTildeIn(p: string, home: string): string {
1290
+ if (p === "~") return home;
1291
+ if (p.startsWith("~/")) return path.join(home, p.slice(2));
1240
1292
  return p;
1241
1293
  }
1242
1294
 
1295
+ function expandTilde(p: string): string {
1296
+ return expandTildeIn(p, os.homedir());
1297
+ }
1298
+
1243
1299
  /**
1244
1300
  * The pi agent dir — the persistence root pi owns. `PI_CODING_AGENT_DIR` lets an
1245
1301
  * isolated install / test relocate it (symmetric with how pi's own sessions
@@ -1320,6 +1376,164 @@ export function defaultMetaReceiversDir(): string {
1320
1376
  return path.join(piAgentDir(), "meta-receivers");
1321
1377
  }
1322
1378
 
1379
+ // ---------------------------------------------------------------------------
1380
+ // OMP meta-root policy (#87 B1) — ADDITIVE, and only for backend `omp`.
1381
+ //
1382
+ // THE COLLISION THIS CLOSES. `PI_CODING_AGENT_DIR` has two owners. To entwurf it
1383
+ // means "pi's persistence root", and `piAgentDir()` above derives all four garden
1384
+ // roots from it. To the OMP vendor it means "my agent dir": `setProfile` exports it
1385
+ // in-process for every named profile (`oh-my-pi` v18.0.0 `packages/utils/src/dirs.ts:452-473`,
1386
+ // reached from `cli.ts:342-360`), so a plain `omp --profile work` sets it. An OMP
1387
+ // birth that resolved its roots through `piAgentDir()` would therefore mint its record
1388
+ // and marker into a DIFFERENT garden store — and if the value came from a pi sandbox,
1389
+ // into that sandbox. Record-authority violation (Hard Rules 2 and 7), reachable on an
1390
+ // ordinary vendor workflow rather than an exotic one.
1391
+ //
1392
+ // WHY A BUNDLE OF FOUR AND NOT TWO. Splitting mailbox from receivers is the dangerous
1393
+ // cell: dispatch can trust an armed receiver marker in one root and enqueue into the
1394
+ // mailbox of the other while the real watcher drains the first — FALSE DELIVERABILITY.
1395
+ // So the four move together or not at all. Birth (bundle A) writes only sessions and
1396
+ // senders; receive (bundle B) consumes mailbox and receivers from this same object.
1397
+ //
1398
+ // WHAT IT DELIBERATELY DOES NOT DO. It does not change `piAgentDir()` or any
1399
+ // `defaultMeta*Dir()` — every existing backend keeps byte-identical behaviour, and pi's
1400
+ // own sandboxing still relocates through `PI_CODING_AGENT_DIR`. It honours the four
1401
+ // entwurf-owned `ENTWURF_META_*_DIR` overrides INDEPENDENTLY, so Hard Rule 12 sandboxing
1402
+ // stays usable. It bakes no absolute path into any record, marker, install-state or MCP
1403
+ // entry, and it introduces no new authority carrier: the roots are computed, never stored.
1404
+ // ---------------------------------------------------------------------------
1405
+
1406
+ /** The four Entwurf-owned meta roots, resolved as ONE indivisible bundle. */
1407
+ export interface MetaRootBundle {
1408
+ sessionsDir: string;
1409
+ mailboxDir: string;
1410
+ sendersDir: string;
1411
+ receiversDir: string;
1412
+ }
1413
+
1414
+ /** The env shape these pure resolvers read. `process.env` satisfies it. */
1415
+ export type MetaRootEnv = Record<string, string | undefined>;
1416
+
1417
+ /**
1418
+ * The exact provenance label the omp-native MCP entry carries
1419
+ * (`scripts/omp-mcp-config.py` `EXTERNAL_AGENT_ID`). It is what lets a bridge CHILD know
1420
+ * it is an OMP child — the same string both sides pin, never re-spelled.
1421
+ */
1422
+ export const OMP_BRIDGE_PROVENANCE_LABEL = "external-mcp/omp";
1423
+
1424
+ /** `HOME` as the process that owns this env sees it; `os.homedir()` returns exactly this
1425
+ * on POSIX when HOME is set, so a real process and a composed child env agree. */
1426
+ function metaRootHome(env: MetaRootEnv): string {
1427
+ const home = env.HOME;
1428
+ return home !== undefined && home.length > 0 ? home : os.homedir();
1429
+ }
1430
+
1431
+ /**
1432
+ * THE shared OMP root policy — one pure function, two consumers (the in-process birth
1433
+ * extension and the OMP-labeled bridge child). Agreement is by CONSTRUCTION, not by two
1434
+ * places computing the same thing: both read this leaf, and both see the same HOME and the
1435
+ * same four overrides because the vendor composes the child env as parent + entry env
1436
+ * (`oh-my-pi` `packages/coding-agent/src/mcp/transports/stdio.ts:575-607`) and that entry
1437
+ * env is provenance-only.
1438
+ */
1439
+ export class MetaRootPolicyError extends Error {
1440
+ constructor(message: string) {
1441
+ super(message);
1442
+ this.name = "MetaRootPolicyError";
1443
+ }
1444
+ }
1445
+
1446
+ /**
1447
+ * The unambiguous base the OMP bundle hangs off when no override is set. Separate from
1448
+ * {@link ompMetaRoots} because it must NEVER throw: the hook log is a diagnostic, and a
1449
+ * policy refusal still has to be visible somewhere.
1450
+ */
1451
+ export function ompMetaRootBase(env: MetaRootEnv = process.env, home: string = metaRootHome(env)): string {
1452
+ // NEVER `piAgentDir()` here: for backend omp that variable is the VENDOR's agent dir.
1453
+ return path.join(home, ".pi", "agent");
1454
+ }
1455
+
1456
+ /**
1457
+ * THE OVERRIDE GRAMMAR IS NARROW ON PURPOSE: absolute, or `~` / `~/…`. Anything else —
1458
+ * a relative path, or `~user` — is a NAMED REFUSAL, not a resolution.
1459
+ *
1460
+ * Why fail closed rather than resolve: this bundle has to mean the same thing in two
1461
+ * processes that do not share a working directory. `path.resolve` on a relative value
1462
+ * silently makes CWD an authority, and the OMP extension's cwd is whatever the operator
1463
+ * launched omp from while the doctor's is the repository (`run_ts` cd's there). `[측정]`
1464
+ * the same `ENTWURF_META_SESSIONS_DIR=relative-records` resolved to two different stores
1465
+ * that way, so the doctor could report NOT-YET off an empty directory while the extension's
1466
+ * real store held records (#87 A2, Terra review). Refusing is what keeps cwd from becoming
1467
+ * a garden-root carrier. `~` is allowed because it expands from HOME, which both halves
1468
+ * share by construction.
1469
+ *
1470
+ * The refusal is shared: extension and OMP-labeled bridge child both reach it through this
1471
+ * one leaf, so neither can proceed on a value the other would read differently.
1472
+ */
1473
+ export function ompMetaRoots(env: MetaRootEnv = process.env, home: string = metaRootHome(env)): MetaRootBundle {
1474
+ const base = ompMetaRootBase(env, home);
1475
+ const surface = (key: string, leaf: string): string => {
1476
+ const override = env[key];
1477
+ if (override === undefined || override === "") return path.join(base, leaf);
1478
+ if (override === "~" || override.startsWith("~/")) return path.resolve(expandTildeIn(override, home));
1479
+ if (path.isAbsolute(override)) return path.resolve(override);
1480
+ throw new MetaRootPolicyError(
1481
+ `omp meta-root policy refuses ${key}=${JSON.stringify(override)}: a garden root must be absolute or ~-rooted ` +
1482
+ "(`~` or `~/…`). A relative value would resolve against each process's own working directory, and the omp " +
1483
+ "extension and its doctor do not share one — set an absolute path, or unset it to use the default under HOME.",
1484
+ );
1485
+ };
1486
+ return {
1487
+ sessionsDir: surface("ENTWURF_META_SESSIONS_DIR", "meta-sessions"),
1488
+ mailboxDir: surface("ENTWURF_META_MAILBOX_DIR", "meta-mailbox"),
1489
+ sendersDir: surface("ENTWURF_META_SENDERS_DIR", "meta-senders"),
1490
+ receiversDir: surface("ENTWURF_META_RECEIVERS_DIR", "meta-receivers"),
1491
+ };
1492
+ }
1493
+
1494
+ /**
1495
+ * The bridge CHILD half of the same policy, applied to that child's own environment.
1496
+ *
1497
+ * A bridge child selects this by the exact provenance label its managed entry carries —
1498
+ * no probing, no guessing, and no effect on any other harness's child. It then does two
1499
+ * things, in this order and before any lazy default-root consumer has run:
1500
+ *
1501
+ * 1. removes the foreign `PI_CODING_AGENT_DIR` from THIS PROCESS only. The OMP HOST
1502
+ * keeps it — there it is the vendor's own agent dir and deleting it would rewrite
1503
+ * vendor path semantics — but the child has no vendor lookup left to do.
1504
+ * 2. pins the four `ENTWURF_META_*_DIR` overrides to this leaf's answer, so every
1505
+ * `defaultMeta*Dir()` consumer in the bridge reads the OMP bundle literally rather
1506
+ * than recomputing something that merely happens to match. Idempotent: an override
1507
+ * the operator already set was honoured by the leaf and is written back unchanged.
1508
+ *
1509
+ * These four are entwurf's OWN documented override vocabulary, set in our own process —
1510
+ * not a new carrier, not a record field, not a marker.
1511
+ *
1512
+ * The in-process birth extension cannot use this and must not try: it runs INSIDE the omp
1513
+ * host, so mutating that process's env is exactly the thing forbidden above. It passes
1514
+ * explicit directories from {@link ompMetaRoots} instead.
1515
+ */
1516
+ export function applyOmpBridgeChildRootPolicy(
1517
+ env: MetaRootEnv = process.env,
1518
+ home?: string,
1519
+ ): { applied: boolean; roots: MetaRootBundle | null } {
1520
+ // EXACT equality on the RAW label (#87 A3). Trimming was a courtesy that let a
1521
+ // whitespace-drifted entry — one `doctor-omp-mcp` and the writer both call foreign —
1522
+ // still select OMP root mutation in its child. The writer emits the literal and the
1523
+ // doctor compares the literal; this compares the literal too, so all three agree on
1524
+ // what "our entry" means.
1525
+ if (env.ENTWURF_BRIDGE_EXTERNAL_AGENT_ID !== OMP_BRIDGE_PROVENANCE_LABEL) {
1526
+ return { applied: false, roots: null };
1527
+ }
1528
+ const roots = ompMetaRoots(env, home ?? metaRootHome(env));
1529
+ delete env.PI_CODING_AGENT_DIR;
1530
+ env.ENTWURF_META_SESSIONS_DIR = roots.sessionsDir;
1531
+ env.ENTWURF_META_MAILBOX_DIR = roots.mailboxDir;
1532
+ env.ENTWURF_META_SENDERS_DIR = roots.sendersDir;
1533
+ env.ENTWURF_META_RECEIVERS_DIR = roots.receiversDir;
1534
+ return { applied: true, roots };
1535
+ }
1536
+
1323
1537
  /**
1324
1538
  * A boot-unique identity for a live process: pid is reused, but pid + start-time
1325
1539
  * is unique within a boot. Linux reads `/proc/<pid>/stat` field 22 (starttime in