@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.
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +354 -0
- package/DELIVERY.md +3 -2
- package/README.md +68 -88
- package/VERIFY.md +4 -1
- package/demo/README.md +1 -1
- package/docs/acp-backend-rail.md +1 -1
- package/docs/external-mcp-host.md +147 -9
- package/docs/setup-clean-host.md +125 -6
- 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/acp/backend-adapter.js +19 -10
- 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 +19 -9
- 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/acp/backend-adapter.ts +19 -9
- package/pi-extensions/lib/acp/backend.ts +125 -7
- package/pi-extensions/lib/acp/claude-acp-launch.js +100 -0
- 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 +399 -31
- package/scripts/check-acp-launch-namespace.ts +127 -0
- package/scripts/check-acp-prompt-lifecycle.ts +145 -2
- package/scripts/check-copilot-birth-hook.ts +28 -1
- package/scripts/check-entwurf-self-address.ts +31 -0
- package/scripts/check-gate-qualification.ts +7 -3
- 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 +208 -0
- package/scripts/check-omp-receive-arm.ts +516 -0
- package/scripts/check-setup-qualification.sh +40 -2
- package/scripts/copilot-bridge-oracle.sh +14 -6
- package/scripts/fake-copilot-vendor.sh +4 -2
- package/scripts/inventory-verification-surface.ts +1 -1
- package/scripts/meta-bridge-hook-log.sh +9 -1
- package/scripts/mutants/acp-launch-namespace.json +34 -0
- package/scripts/mutants/acp-prompt-lifecycle.json +67 -2
- package/scripts/mutants/copilot-birth.json +10 -10
- 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/pack-install.json +2 -2
- package/scripts/mutants/self-address.json +34 -0
- package/scripts/mutants/setup-verdict.json +35 -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-config-xdev.py +310 -0
- package/scripts/omp-config-xdev.sh +76 -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 +400 -0
- package/scripts/raw-acp-child-exit-measure/README.md +285 -0
- package/scripts/raw-acp-child-exit-measure/acp-turn-population.py +89 -0
- package/scripts/raw-acp-child-exit-measure/reaper-correlation.py +47 -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/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 +48 -3
- 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: {
|
|
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(
|
|
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
|
|
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
|
-
|
|
1160
|
-
|
|
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(
|
|
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
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
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
|
-
//
|
|
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
|
|
1238
|
-
if (p === "~") return
|
|
1239
|
-
if (p.startsWith("~/")) return path.join(
|
|
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
|