@junghanacs/entwurf 0.30.0 → 0.31.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/AGENTS.md +1 -0
  2. package/CHANGELOG.md +229 -0
  3. package/DELIVERY.md +1 -0
  4. package/README.md +49 -4
  5. package/VERIFY.md +23 -4
  6. package/demo/README.md +1 -1
  7. package/docs/acp-backend-rail.md +111 -18
  8. package/docs/setup-clean-host.md +104 -4
  9. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +7 -7
  10. package/mcp/entwurf-bridge/dist/pi-extensions/entwurf-capabilities.json +1 -0
  11. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +43 -0
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-deliverability.js +7 -1
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-composition.js +34 -3
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-sender-identity.js +7 -1
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +28 -2
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +30 -2
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-durable-fresh-preflight.js +92 -0
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-durable-runtime.js +288 -0
  19. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-pi-durable.js +744 -0
  20. package/mcp/entwurf-bridge/src/index.ts +7 -7
  21. package/mcp/entwurf-bridge/tsconfig.build.json +8 -1
  22. package/package.json +15 -12
  23. package/pi/entwurf-capabilities.json +1 -0
  24. package/pi/pi-durable/bootstrap.mjs +281 -0
  25. package/pi/pi-durable/overlay/runtime-contacts.patch +91 -0
  26. package/pi/pi-durable/overlay/upstream-pin.json +16 -0
  27. package/pi-extensions/lib/acp/backend-adapter.ts +68 -0
  28. package/pi-extensions/lib/acp/backend.ts +41 -5
  29. package/pi-extensions/lib/entwurf-deliverability.ts +7 -1
  30. package/pi-extensions/lib/fresh-call-composition.ts +42 -7
  31. package/pi-extensions/lib/meta-sender-identity.ts +7 -1
  32. package/pi-extensions/lib/meta-session.ts +28 -2
  33. package/pi-extensions/lib/mux-fresh-call.ts +34 -2
  34. package/pi-extensions/lib/pi-durable-fresh-preflight.ts +113 -0
  35. package/pi-extensions/lib/pi-durable-runtime.ts +384 -0
  36. package/pi-extensions/meta-bridge-pi-durable.ts +1020 -0
  37. package/run.sh +214 -36
  38. package/scripts/check-fresh-call-dispatch.ts +18 -0
  39. package/scripts/check-gate-qualification.ts +4 -2
  40. package/scripts/check-herdr-plugin-build.ts +159 -57
  41. package/scripts/check-pi-durable-contact.ts +834 -0
  42. package/scripts/check-pi-durable-receive.ts +1116 -0
  43. package/scripts/check-pi-durable-send.ts +1661 -0
  44. package/scripts/fixtures/herdr-supply.json +8 -8
  45. package/scripts/lib/pi-durable-fresh-live-receipts.ts +139 -0
  46. package/scripts/lib/pi-durable-r1.ts +100 -0
  47. package/scripts/lib/pi-durable-reap.ts +27 -0
  48. package/scripts/lib/pi-durable-receive.ts +137 -0
  49. package/scripts/mutants/acp-prompt-lifecycle.json +2 -2
  50. package/scripts/mutants/acp-vendor-rejection.json +62 -0
  51. package/scripts/mutants/control-send-receipt.json +1 -1
  52. package/scripts/mutants/copilot-birth.json +2 -2
  53. package/scripts/mutants/herdr-plugin-build.json +11 -0
  54. package/scripts/mutants/herdr-supply.json +3 -3
  55. package/scripts/mutants/meta-hook-session-switch.json +4 -2
  56. package/scripts/mutants/mux-fresh-call.json +69 -3
  57. package/scripts/mutants/omp-birth.json +2 -2
  58. package/scripts/mutants/omp-fresh.json +6 -4
  59. package/scripts/mutants/pack-install.json +3 -3
  60. package/scripts/mutants/pi-durable.json +390 -0
  61. package/scripts/mutants/pi-floor.json +2 -2
  62. package/scripts/pi-durable-contact-host.mjs +105 -0
  63. package/scripts/pi-durable-receive-host.mjs +127 -0
  64. package/scripts/pi-durable-send-host.mjs +213 -0
  65. package/scripts/smoke-acp-raw-turn-live.ts +1 -1
  66. package/scripts/smoke-herdr-fresh-call-live.ts +21 -7
  67. package/scripts/smoke-pi-durable-fresh-live.ts +624 -0
  68. package/scripts/smoke-setup-verdict.sh +21 -2
  69. package/scripts/tsconfig.json +1 -0
@@ -0,0 +1,384 @@
1
+ /**
2
+ * pi-durable-runtime — where the operator-provided durable runtime lives, and whether it is the pin
3
+ * (#129 W, L3).
4
+ *
5
+ * The experimental durable app is SOURCE-ONLY upstream: the published Pi CLI ships no
6
+ * `dist/experimental`, and `@earendil-works/pi-durable` on npm is the library, not this app. So its
7
+ * runtime is a checkout the OPERATOR provides — the pinned commit plus this package's overlay patch
8
+ * and the pinned model data — and Entwurf never downloads, installs, moves or deletes it. This leaf
9
+ * only DETECTS it, at ONE fixed place:
10
+ *
11
+ * $XDG_DATA_HOME/entwurf/pi-durable/runtime (an absolute XDG_DATA_HOME; else ~/.local/share)
12
+ *
13
+ * There is no public path override. `ENTWURF_PI_DURABLE_RUNTIME` stays the CHECKOUT-ONLY input of
14
+ * the `check-pi-durable-*` gates; nothing here reads it.
15
+ *
16
+ * A present runtime counts as the pin only when HEAD equals the pinned commit, the index equals HEAD
17
+ * (nothing staged), `git diff --binary` equals the overlay patch bytes exactly, no non-ignored file is
18
+ * untracked, the experimental source resolver is there, and the git-ignored model data matches the
19
+ * pin's recorded digests. Anything else is present-but-wrong, a named refusal — never a guess and
20
+ * never a repair. The index term is not optional: `git diff` compares the worktree to the INDEX, so a
21
+ * staged edit to any other tracked file — or a staged new file, which is not "untracked" either —
22
+ * would otherwise leave the diff equal to the patch.
23
+ *
24
+ * The diff bytes are compared, so the configuration knobs known to move them are pinned on the
25
+ * command line to the form the packaged patch is in: abbreviation length, prefixes, colour,
26
+ * external/textconv drivers, hunk algorithm and context, relative paths, and blank-context spacing
27
+ * (`diff.suppressBlankEmpty`; the packaged patch has blank context lines). That is a bounded list,
28
+ * not every knob git has: e.g. `diff.orderFile` (multi-file order) and line-ending conversion
29
+ * (`core.autocrlf`/`core.eol`) are not neutralized, and a byte change they cause reads as pin drift.
30
+ * Configuration is not disabled wholesale — `safe.directory` lives in global/system config.
31
+ *
32
+ * The overlay data (pin + patch) ships IN this package and is found relative to this module, at the
33
+ * two depths the module is shipped at: the source checkout (`<root>/pi-extensions/lib/`) and the
34
+ * compiled bridge closure (`<root>/mcp/entwurf-bridge/dist/pi-extensions/lib/`) — the same two-depth
35
+ * arithmetic `metaCapabilitiesFilePath` answers. The compiled durable units are named from that same
36
+ * root, so a launcher, a preflight and the bootstrap all read ONE package closure.
37
+ *
38
+ * Read-only: `git diff` and `git ls-files` run against a private COPY of the runtime's index
39
+ * (`GIT_INDEX_FILE`), with GIT_OPTIONAL_LOCKS=0. The copy is not ceremony: `git diff` was measured
40
+ * to refresh and rewrite a stat-dirty index even with optional locks off, so asking the operator's
41
+ * own index would mutate their checkout. No network, no install, no credential, settings or session read.
42
+ * Each `git` is a direct child with a wall-clock bound; one that does not answer in time is unverifiable.
43
+ */
44
+
45
+ import { spawnSync } from "node:child_process";
46
+ import { createHash } from "node:crypto";
47
+ import * as fs from "node:fs";
48
+ import * as os from "node:os";
49
+ import * as path from "node:path";
50
+
51
+ /** The fixed locator under the data home. Not configurable. */
52
+ export const PI_DURABLE_RUNTIME_SEGMENTS = ["entwurf", "pi-durable", "runtime"] as const;
53
+
54
+ /** The upstream source resolver the durable app runs under, inside the runtime checkout. */
55
+ export const PI_DURABLE_RESOLVER_RELATIVE = path.join(
56
+ "packages",
57
+ "coding-agent",
58
+ "src",
59
+ "experimental",
60
+ "source-resolver.ts",
61
+ );
62
+
63
+ /** Every way the runtime can be refused. Stable contract: these cross the launcher, setup and the
64
+ * fresh-call surfaces as named refusals. */
65
+ export type PiDurableRuntimeRejectReason =
66
+ | "pi-durable-package-incomplete"
67
+ | "pi-durable-runtime-locator-unset"
68
+ | "pi-durable-runtime-absent"
69
+ | "pi-durable-runtime-unverifiable"
70
+ | "pi-durable-runtime-pin-drift"
71
+ | "pi-durable-runtime-modeldata-drift";
72
+
73
+ /** The package closure this module belongs to: the overlay data and the compiled durable units. */
74
+ export interface PiDurablePackageLayout {
75
+ readonly root: string;
76
+ readonly overlayDir: string;
77
+ /** The packaged bootstrap the launcher executes under the runtime's resolver. */
78
+ readonly bootstrap: string;
79
+ /** The compiled durable contact (birth, doorbell, visible identity decoration). */
80
+ readonly adapter: string;
81
+ /** The compiled entwurf-bridge entry the contact spawns as its MCP hand. */
82
+ readonly bridgeEntry: string;
83
+ /** The compiled sender/receiver owner join the bridge decides deliverability with. */
84
+ readonly deliverability: string;
85
+ }
86
+
87
+ /** The layout rooted at `root`, as plain paths. */
88
+ export function piDurableLayoutAt(root: string): PiDurablePackageLayout {
89
+ const dist = path.join(root, "mcp", "entwurf-bridge", "dist");
90
+ return {
91
+ root,
92
+ overlayDir: path.join(root, "pi", "pi-durable", "overlay"),
93
+ bootstrap: path.join(root, "pi", "pi-durable", "bootstrap.mjs"),
94
+ adapter: path.join(dist, "pi-extensions", "meta-bridge-pi-durable.js"),
95
+ bridgeEntry: path.join(dist, "mcp", "entwurf-bridge", "src", "index.js"),
96
+ deliverability: path.join(dist, "pi-extensions", "lib", "entwurf-deliverability.js"),
97
+ };
98
+ }
99
+
100
+ /**
101
+ * The package closure, found from where THIS module lives: the source depth first, then the compiled
102
+ * depth. `null` when neither carries the overlay pin — a packaging defect, refused by name.
103
+ */
104
+ export function piDurablePackageLayout(from: string = import.meta.dirname): PiDurablePackageLayout | null {
105
+ for (const up of [
106
+ ["..", ".."],
107
+ ["..", "..", "..", "..", ".."],
108
+ ]) {
109
+ const root = path.resolve(from, ...up);
110
+ const layout = piDurableLayoutAt(root);
111
+ if (fs.existsSync(path.join(layout.overlayDir, "upstream-pin.json"))) return layout;
112
+ }
113
+ return null;
114
+ }
115
+
116
+ /** The fixed runtime location for this environment, or `null` when neither an absolute
117
+ * XDG_DATA_HOME nor HOME exists to anchor it. A relative XDG_DATA_HOME is ignored (XDG spec). */
118
+ export function piDurableRuntimeDir(env: NodeJS.ProcessEnv): string | null {
119
+ const xdg = env.XDG_DATA_HOME;
120
+ if (typeof xdg === "string" && xdg.length > 0 && path.isAbsolute(xdg)) {
121
+ return path.join(xdg, ...PI_DURABLE_RUNTIME_SEGMENTS);
122
+ }
123
+ const home = env.HOME;
124
+ if (typeof home === "string" && home.length > 0 && path.isAbsolute(home)) {
125
+ return path.join(home, ".local", "share", ...PI_DURABLE_RUNTIME_SEGMENTS);
126
+ }
127
+ return null;
128
+ }
129
+
130
+ export interface PiDurablePin {
131
+ readonly commit: string;
132
+ readonly patches: readonly string[];
133
+ readonly modelData: {
134
+ readonly path: string;
135
+ readonly files: number;
136
+ readonly aggregateSha256: string;
137
+ readonly manifestSha256: string;
138
+ };
139
+ }
140
+
141
+ /** The packaged pin and its one patch. A malformed pin throws (crash, don't warn). */
142
+ export function readPiDurablePin(overlayDir: string): { pin: PiDurablePin; patchBytes: Buffer } {
143
+ const pin = JSON.parse(fs.readFileSync(path.join(overlayDir, "upstream-pin.json"), "utf8")) as PiDurablePin;
144
+ if (typeof pin.commit !== "string" || !/^[0-9a-f]{40}$/.test(pin.commit)) {
145
+ throw new Error(`pi-durable pin: commit is not a full sha (${JSON.stringify(pin.commit)})`);
146
+ }
147
+ if (!Array.isArray(pin.patches) || pin.patches.length !== 1 || typeof pin.patches[0] !== "string") {
148
+ throw new Error("pi-durable pin: exactly one overlay patch is expected");
149
+ }
150
+ const md = pin.modelData;
151
+ if (
152
+ typeof md !== "object" ||
153
+ md === null ||
154
+ typeof md.path !== "string" ||
155
+ typeof md.files !== "number" ||
156
+ typeof md.aggregateSha256 !== "string" ||
157
+ typeof md.manifestSha256 !== "string"
158
+ ) {
159
+ throw new Error("pi-durable pin: modelData is malformed");
160
+ }
161
+ return { pin, patchBytes: fs.readFileSync(path.join(overlayDir, pin.patches[0])) };
162
+ }
163
+
164
+ const sha256 = (data: string | Buffer): string => createHash("sha256").update(data).digest("hex");
165
+
166
+ /** The patch shape the packaged patch is in, fixed on the command line for the knobs the header lists:
167
+ * git's default output with 8-hex `index` abbreviations. Two of those knobs are config-only and ride
168
+ * beside these as `-c` at the call: `core.abbrev=8` ("auto" scales with the clone's object count) and
169
+ * `diff.suppressBlankEmpty=false` (true drops the space before an empty context line). */
170
+ export const PI_DURABLE_DIFF_SHAPE = [
171
+ "--binary",
172
+ "--no-ext-diff",
173
+ "--no-color",
174
+ "--no-textconv",
175
+ "--src-prefix=a/",
176
+ "--dst-prefix=b/",
177
+ "--no-relative",
178
+ "--diff-algorithm=myers",
179
+ "--indent-heuristic",
180
+ "-U3",
181
+ "--inter-hunk-context=0",
182
+ ] as const;
183
+
184
+ /** Wall-clock bound for each local `git` the verifier runs (it also runs inside the bridge process). */
185
+ export const PI_DURABLE_GIT_TIMEOUT_MS = 30_000;
186
+
187
+ /** What a runtime directory holds, read without changing it. */
188
+ export interface PiDurableRuntimeFacts {
189
+ readonly runtimeDir: string;
190
+ readonly present: boolean;
191
+ /** `git` answered all four questions (rev-parse, cached diff, diff, ls-files). */
192
+ readonly gitAnswered: boolean;
193
+ readonly head: string;
194
+ /** The index equals HEAD: nothing is staged. */
195
+ readonly indexMatchesHead: boolean;
196
+ readonly diffEqualsPatch: boolean;
197
+ readonly untracked: string;
198
+ readonly resolverPresent: boolean;
199
+ readonly modelDataFiles: number;
200
+ readonly modelDataAggregate: string;
201
+ readonly manifestSha256: string;
202
+ }
203
+
204
+ export function inspectPiDurableRuntime(runtimeDir: string, overlayDir: string): PiDurableRuntimeFacts {
205
+ const { pin, patchBytes } = readPiDurablePin(overlayDir);
206
+ const present = (() => {
207
+ try {
208
+ return fs.statSync(runtimeDir).isDirectory();
209
+ } catch {
210
+ // Bounded environment probe (Hard Rule 15's stated exception): absence is the answer.
211
+ return false;
212
+ }
213
+ })();
214
+ const base = { ...process.env, GIT_OPTIONAL_LOCKS: "0" };
215
+ const git = (args: string[], env: NodeJS.ProcessEnv = base) =>
216
+ spawnSync("git", ["-C", runtimeDir, ...args], {
217
+ maxBuffer: 64 * 1024 * 1024,
218
+ env,
219
+ timeout: PI_DURABLE_GIT_TIMEOUT_MS,
220
+ });
221
+ const headRun = present ? git(["rev-parse", "HEAD"]) : undefined;
222
+ // A private copy of the index, so the refresh `git diff` performs lands in a file this process owns.
223
+ let scratch: string | undefined;
224
+ let cachedRun: ReturnType<typeof git> | undefined;
225
+ let diffRun: ReturnType<typeof git> | undefined;
226
+ let untrackedRun: ReturnType<typeof git> | undefined;
227
+ try {
228
+ const indexPath = present ? git(["rev-parse", "--git-path", "index"]) : undefined;
229
+ const index = indexPath?.status === 0 ? path.resolve(runtimeDir, indexPath.stdout.toString().trim()) : undefined;
230
+ if (index !== undefined && fs.existsSync(index)) {
231
+ scratch = fs.mkdtempSync(path.join(os.tmpdir(), "pi-durable-index-"));
232
+ const copy = path.join(scratch, "index");
233
+ fs.copyFileSync(index, copy);
234
+ const onCopy = { ...base, GIT_INDEX_FILE: copy };
235
+ // HEAD → index: exit 0 = nothing staged, 1 = something staged; anything else is no answer.
236
+ cachedRun = git(["diff", "--cached", "--quiet", "--no-ext-diff", "--no-relative"], onCopy);
237
+ diffRun = git(
238
+ ["-c", "core.abbrev=8", "-c", "diff.suppressBlankEmpty=false", "diff", ...PI_DURABLE_DIFF_SHAPE],
239
+ onCopy,
240
+ );
241
+ untrackedRun = git(["ls-files", "--others", "--exclude-standard"], onCopy);
242
+ }
243
+ } finally {
244
+ if (scratch !== undefined) fs.rmSync(scratch, { recursive: true, force: true });
245
+ }
246
+ const gitAnswered =
247
+ headRun?.status === 0 &&
248
+ (cachedRun?.status === 0 || cachedRun?.status === 1) &&
249
+ diffRun?.status === 0 &&
250
+ untrackedRun?.status === 0;
251
+ const dataDir = path.join(runtimeDir, pin.modelData.path);
252
+ const dataFiles = (() => {
253
+ try {
254
+ return fs
255
+ .readdirSync(dataDir)
256
+ .filter((n) => n.endsWith(".json") && !n.startsWith("."))
257
+ .sort();
258
+ } catch {
259
+ return [];
260
+ }
261
+ })();
262
+ const manifest = path.join(dataDir, ".manifest.json");
263
+ return {
264
+ runtimeDir,
265
+ present,
266
+ gitAnswered,
267
+ head: headRun?.stdout?.toString().trim() ?? "",
268
+ indexMatchesHead: cachedRun?.status === 0,
269
+ diffEqualsPatch: diffRun?.status === 0 && Buffer.isBuffer(diffRun.stdout) && diffRun.stdout.equals(patchBytes),
270
+ untracked: untrackedRun?.stdout?.toString().trim() ?? "",
271
+ resolverPresent: present && fs.existsSync(path.join(runtimeDir, PI_DURABLE_RESOLVER_RELATIVE)),
272
+ modelDataFiles: dataFiles.length,
273
+ modelDataAggregate: sha256(
274
+ dataFiles.map((n) => `${sha256(fs.readFileSync(path.join(dataDir, n)))} ${n}\n`).join(""),
275
+ ),
276
+ manifestSha256: fs.existsSync(manifest) ? sha256(fs.readFileSync(manifest)) : "absent",
277
+ };
278
+ }
279
+
280
+ /** The verdict over those facts against the pin: `null` is "this is the pin". */
281
+ export function piDurableRuntimeVerdict(
282
+ facts: PiDurableRuntimeFacts,
283
+ pin: PiDurablePin,
284
+ ): PiDurableRuntimeRejectReason | null {
285
+ if (!facts.present) return "pi-durable-runtime-absent";
286
+ if (!facts.gitAnswered) return "pi-durable-runtime-unverifiable";
287
+ if (!facts.indexMatchesHead) return "pi-durable-runtime-pin-drift";
288
+ if (facts.head !== pin.commit || !facts.diffEqualsPatch || facts.untracked !== "" || !facts.resolverPresent) {
289
+ return "pi-durable-runtime-pin-drift";
290
+ }
291
+ if (
292
+ facts.modelDataFiles !== pin.modelData.files ||
293
+ facts.modelDataAggregate !== pin.modelData.aggregateSha256 ||
294
+ facts.manifestSha256 !== pin.modelData.manifestSha256
295
+ ) {
296
+ return "pi-durable-runtime-modeldata-drift";
297
+ }
298
+ return null;
299
+ }
300
+
301
+ export type PiDurableRuntimeCheck =
302
+ | { ok: true; runtimeDir: string; resolver: string }
303
+ | { ok: false; reason: PiDurableRuntimeRejectReason; runtimeDir: string | null };
304
+
305
+ /** Locate and verify, in that order. The launcher, setup and the fresh preflight all ask this. */
306
+ export function checkPiDurableRuntime(
307
+ env: NodeJS.ProcessEnv,
308
+ layout: PiDurablePackageLayout | null,
309
+ ): PiDurableRuntimeCheck {
310
+ if (layout === null) return { ok: false, reason: "pi-durable-package-incomplete", runtimeDir: null };
311
+ const runtimeDir = piDurableRuntimeDir(env);
312
+ if (runtimeDir === null) return { ok: false, reason: "pi-durable-runtime-locator-unset", runtimeDir: null };
313
+ const { pin } = readPiDurablePin(layout.overlayDir);
314
+ const reason = piDurableRuntimeVerdict(inspectPiDurableRuntime(runtimeDir, layout.overlayDir), pin);
315
+ if (reason !== null) return { ok: false, reason, runtimeDir };
316
+ return { ok: true, runtimeDir, resolver: path.join(runtimeDir, PI_DURABLE_RESOLVER_RELATIVE) };
317
+ }
318
+
319
+ export const PI_DURABLE_RUNTIME_HINT: Record<PiDurableRuntimeRejectReason, string> = {
320
+ "pi-durable-package-incomplete":
321
+ "this Entwurf package carries no pi-durable overlay pin next to its code — reinstall Entwurf; the package itself is incomplete",
322
+ "pi-durable-runtime-locator-unset":
323
+ "neither an absolute XDG_DATA_HOME nor HOME is set, so the fixed runtime location cannot be named",
324
+ "pi-durable-runtime-absent":
325
+ "no operator-provided durable runtime at the fixed location — Entwurf never installs it: provide the pinned upstream checkout plus the overlay patch and model data there (docs/setup-clean-host.md)",
326
+ "pi-durable-runtime-unverifiable":
327
+ "the runtime location exists but git could not read it as a checkout, so it cannot be proven to be the pin",
328
+ "pi-durable-runtime-pin-drift":
329
+ "the runtime is not the pin: HEAD, the overlay patch bytes, an untracked file or the source resolver differs from the packaged pi/pi-durable/overlay/upstream-pin.json — re-provision it at the pinned commit with exactly the packaged patch",
330
+ "pi-durable-runtime-modeldata-drift":
331
+ "the runtime's git-ignored model data is not the pinned bytes — restore the model data the pin records",
332
+ };
333
+
334
+ /** Exit codes of the CLI below, read by `run.sh`: 0 verified, 4 absent (setup SKIP), 3 anything else. */
335
+ export const PI_DURABLE_RUNTIME_EXIT = { verified: 0, absent: 4, refused: 3 } as const;
336
+
337
+ /**
338
+ * `resolve`: print the verified runtime's source-resolver path (stdout) and exit 0, or print
339
+ * `<reason>: <hint>` (stderr) and exit 4 for an absent runtime / 3 for every other refusal.
340
+ * The launcher execs node under that resolver; setup maps the three codes to PASS / SKIP / FAIL.
341
+ */
342
+ export function runPiDurableRuntimeCli(
343
+ argv: readonly string[],
344
+ env: NodeJS.ProcessEnv,
345
+ layout: PiDurablePackageLayout | null,
346
+ out: { stdout: (line: string) => void; stderr: (line: string) => void },
347
+ ): number {
348
+ if (argv.length !== 1 || argv[0] !== "resolve") {
349
+ out.stderr("usage: pi-durable-runtime resolve");
350
+ return 2;
351
+ }
352
+ const checked = checkPiDurableRuntime(env, layout);
353
+ if (checked.ok) {
354
+ out.stdout(checked.resolver);
355
+ return PI_DURABLE_RUNTIME_EXIT.verified;
356
+ }
357
+ out.stderr(
358
+ `${checked.reason}${checked.runtimeDir === null ? "" : ` (${checked.runtimeDir})`}: ${PI_DURABLE_RUNTIME_HINT[checked.reason]}`,
359
+ );
360
+ return checked.reason === "pi-durable-runtime-absent"
361
+ ? PI_DURABLE_RUNTIME_EXIT.absent
362
+ : PI_DURABLE_RUNTIME_EXIT.refused;
363
+ }
364
+
365
+ // RUN AS THE CLI ONLY WHEN THIS FILE IS THE PROCESS ENTRY. Decided by an argv comparison, as in
366
+ // meta-bridge-hook-codex.ts, and not by the `main` flag on import.meta: that flag landed in Node 24.2.0
367
+ // and the package floor is major 24, so on 24.0/24.1 it is undefined — `resolve` would exit 0 having
368
+ // printed nothing, which setup reads as PASS and the launcher as a resolver.
369
+ const invokedDirectly = (() => {
370
+ const entry = process.argv[1];
371
+ if (typeof entry !== "string" || entry.length === 0) return false;
372
+ try {
373
+ return fs.realpathSync(entry) === fs.realpathSync(import.meta.filename);
374
+ } catch {
375
+ // Bounded environment probe: an entry that cannot be resolved is not this file.
376
+ return false;
377
+ }
378
+ })();
379
+ if (invokedDirectly) {
380
+ process.exitCode = runPiDurableRuntimeCli(process.argv.slice(2), process.env, piDurablePackageLayout(), {
381
+ stdout: (line) => process.stdout.write(`${line}\n`),
382
+ stderr: (line) => process.stderr.write(`${line}\n`),
383
+ });
384
+ }