@junghanacs/entwurf 0.31.0 → 0.32.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 (56) hide show
  1. package/AGENTS.md +3 -2
  2. package/CHANGELOG.md +82 -0
  3. package/README.md +46 -36
  4. package/VERIFY.md +35 -16
  5. package/demo/README.md +1 -1
  6. package/docs/acp-backend-rail.md +1 -1
  7. package/docs/setup-clean-host.md +112 -95
  8. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +1 -1
  9. package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-composition.js +1 -1
  10. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +8 -10
  11. package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-durable-fresh-preflight.js +3 -3
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-durable-runtime.js +153 -205
  13. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-pi-durable.js +6 -4
  14. package/mcp/entwurf-bridge/src/index.ts +1 -1
  15. package/package.json +14 -17
  16. package/pi/pi-durable/bootstrap.mjs +142 -19
  17. package/pi/pi-durable/carrier/LICENSE +21 -0
  18. package/pi/pi-durable/carrier/harness-setup.js +123 -0
  19. package/pi/pi-durable/carrier/prompt.js +74 -0
  20. package/pi/pi-durable/carrier/runtime.js +402 -0
  21. package/pi/pi-durable/carrier/sessions.js +56 -0
  22. package/pi/pi-durable/carrier/subagent.js +55 -0
  23. package/pi/pi-durable/carrier/tui.js +655 -0
  24. package/pi/pi-durable/carrier-relocation.mjs +160 -0
  25. package/pi/pi-durable/carrier-resolver.mjs +38 -0
  26. package/pi/pi-durable/overlay/upstream-pin.json +103 -11
  27. package/pi-extensions/lib/fresh-call-composition.ts +1 -1
  28. package/pi-extensions/lib/mux-fresh-call.ts +8 -10
  29. package/pi-extensions/lib/pi-durable-fresh-preflight.ts +3 -3
  30. package/pi-extensions/lib/pi-durable-runtime.ts +197 -263
  31. package/pi-extensions/meta-bridge-pi-durable.ts +11 -7
  32. package/run.sh +508 -235
  33. package/scripts/check-entwurf-bridge-pi-free.ts +7 -5
  34. package/scripts/check-fresh-call-dispatch.ts +1 -1
  35. package/scripts/check-gate-qualification.ts +86 -34
  36. package/scripts/check-herdr-activation.ts +5 -4
  37. package/scripts/check-pi-durable-contact.ts +26 -93
  38. package/scripts/check-pi-durable-receive.ts +22 -59
  39. package/scripts/check-pi-durable-send.ts +235 -75
  40. package/scripts/emit-pi-durable-carrier.ts +355 -0
  41. package/scripts/lib/mutation-qualify.ts +21 -45
  42. package/scripts/lib/pi-durable-operator-guard.ts +89 -0
  43. package/scripts/lib/qualification-receipt.ts +7 -11
  44. package/scripts/mutants/control-send-receipt.json +1 -1
  45. package/scripts/mutants/gate-qualification.json +13 -0
  46. package/scripts/mutants/mux-fresh-call.json +13 -13
  47. package/scripts/mutants/pack-install.json +3 -3
  48. package/scripts/mutants/pi-durable.json +442 -109
  49. package/scripts/mutants/pi-floor.json +24 -2
  50. package/scripts/pi-durable-contact-host.mjs +3 -3
  51. package/scripts/pi-durable-receive-host.mjs +3 -3
  52. package/scripts/pi-durable-send-host.mjs +76 -5
  53. package/scripts/smoke-herdr-raw-install-live.sh +1 -1
  54. package/scripts/smoke-pi-durable-fresh-live.ts +3 -3
  55. package/scripts/smoke-setup-verdict.sh +15 -15
  56. package/scripts/smoke-user-scope-citizen.sh +1 -1
@@ -1,62 +1,49 @@
1
1
  /**
2
- * pi-durable-runtime — where the operator-provided durable runtime lives, and whether it is the pin
3
- * (#129 W, L3).
2
+ * pi-durable-runtime — whether THIS package's pi-durable carrier can run, and the resolver it runs
3
+ * under (#129 W, #130).
4
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:
5
+ * The experimental durable app is source-only upstream: published `@earendil-works/pi-coding-agent`
6
+ * ships no `dist/experimental`. Entwurf therefore ships the app itself as a CARRIER — the pinned
7
+ * upstream files, type-stripped and relocated (pi/pi-durable/carrier/, emitted only by the
8
+ * maintainer's scripts/emit-pi-durable-carrier.ts) — and declares the published SDK set it runs
9
+ * against as exact production dependencies. Nothing is provisioned beside the package: there is no
10
+ * runtime directory, no source checkout and no model data to copy (it rides the published pi-ai).
10
11
  *
11
- * $XDG_DATA_HOME/entwurf/pi-durable/runtime (an absolute XDG_DATA_HOME; else ~/.local/share)
12
+ * The carrier counts as runnable only when, read from where this package is installed:
13
+ * - every pinned carrier file, the license, the resolver and its relocation grammar are present
14
+ * (else `pi-durable-package-incomplete`);
15
+ * - each carrier file and the license carry the pinned sha256 (else `pi-durable-carrier-drift`);
16
+ * - every declared SDK member and direct dependency resolves from the carrier, and every member
17
+ * copy the carrier can reach — at any depth, nested copies included — resolves each of its own
18
+ * member dependencies (else `pi-durable-sdk-absent`);
19
+ * - each of those PHYSICAL edges lands on a package that is the one it names, at exactly the
20
+ * declared version (else `pi-durable-sdk-mismatch`). The members declare each other with caret ranges, so semver
21
+ * satisfaction is not the contract — the bytes a carrier import actually binds are. Sharing one
22
+ * copy with a host install of the same version is an ordinary layout and passes; how many copies
23
+ * of a version exist is not asked.
12
24
  *
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.
25
+ * Every refusal names the first failing item, in repair order. This leaf is the native-set check
26
+ * only: the ordinary Pi HOST on PATH is a different subject (`pi_supported_range` in run.sh).
15
27
  *
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.
28
+ * Read-only: file reads and Node's own package resolution (`module.findPackageJSON`) from the
29
+ * carrier's location. No git, no network, no environment, no credential, settings or session read.
43
30
  */
44
- import { spawnSync } from "node:child_process";
45
31
  import { createHash } from "node:crypto";
46
32
  import * as fs from "node:fs";
47
- import * as os from "node:os";
33
+ import { findPackageJSON } from "node:module";
48
34
  import * as path from "node:path";
49
- /** The fixed locator under the data home. Not configurable. */
50
- export const PI_DURABLE_RUNTIME_SEGMENTS = ["entwurf", "pi-durable", "runtime"];
51
- /** The upstream source resolver the durable app runs under, inside the runtime checkout. */
52
- export const PI_DURABLE_RESOLVER_RELATIVE = path.join("packages", "coding-agent", "src", "experimental", "source-resolver.ts");
35
+ import { pathToFileURL } from "node:url";
53
36
  /** The layout rooted at `root`, as plain paths. */
54
37
  export function piDurableLayoutAt(root) {
38
+ const durable = path.join(root, "pi", "pi-durable");
55
39
  const dist = path.join(root, "mcp", "entwurf-bridge", "dist");
56
40
  return {
57
41
  root,
58
- overlayDir: path.join(root, "pi", "pi-durable", "overlay"),
59
- bootstrap: path.join(root, "pi", "pi-durable", "bootstrap.mjs"),
42
+ overlayDir: path.join(durable, "overlay"),
43
+ carrierDir: path.join(durable, "carrier"),
44
+ resolver: path.join(durable, "carrier-resolver.mjs"),
45
+ relocation: path.join(durable, "carrier-relocation.mjs"),
46
+ bootstrap: path.join(durable, "bootstrap.mjs"),
60
47
  adapter: path.join(dist, "pi-extensions", "meta-bridge-pi-durable.js"),
61
48
  bridgeEntry: path.join(dist, "mcp", "entwurf-bridge", "src", "index.js"),
62
49
  deliverability: path.join(dist, "pi-extensions", "lib", "entwurf-deliverability.js"),
@@ -64,7 +51,8 @@ export function piDurableLayoutAt(root) {
64
51
  }
65
52
  /**
66
53
  * The package closure, found from where THIS module lives: the source depth first, then the compiled
67
- * depth. `null` when neither carries the overlay pin — a packaging defect, refused by name.
54
+ * depth (`<root>/mcp/entwurf-bridge/dist/pi-extensions/lib/`). `null` when neither carries the pin —
55
+ * a packaging defect, refused by name.
68
56
  */
69
57
  export function piDurablePackageLayout(from = import.meta.dirname) {
70
58
  for (const up of [
@@ -78,191 +66,151 @@ export function piDurablePackageLayout(from = import.meta.dirname) {
78
66
  }
79
67
  return null;
80
68
  }
81
- /** The fixed runtime location for this environment, or `null` when neither an absolute
82
- * XDG_DATA_HOME nor HOME exists to anchor it. A relative XDG_DATA_HOME is ignored (XDG spec). */
83
- export function piDurableRuntimeDir(env) {
84
- const xdg = env.XDG_DATA_HOME;
85
- if (typeof xdg === "string" && xdg.length > 0 && path.isAbsolute(xdg)) {
86
- return path.join(xdg, ...PI_DURABLE_RUNTIME_SEGMENTS);
87
- }
88
- const home = env.HOME;
89
- if (typeof home === "string" && home.length > 0 && path.isAbsolute(home)) {
90
- return path.join(home, ".local", "share", ...PI_DURABLE_RUNTIME_SEGMENTS);
91
- }
92
- return null;
93
- }
94
- /** The packaged pin and its one patch. A malformed pin throws (crash, don't warn). */
95
- export function readPiDurablePin(overlayDir) {
69
+ const SHA256 = /^[0-9a-f]{64}$/;
70
+ const EXACT = /^\d+\.\d+\.\d+$/;
71
+ /** The packaged pin's carrier and SDK declarations. A malformed pin throws (crash, don't warn). */
72
+ export function readPiDurableCarrierPin(overlayDir) {
96
73
  const pin = JSON.parse(fs.readFileSync(path.join(overlayDir, "upstream-pin.json"), "utf8"));
97
- if (typeof pin.commit !== "string" || !/^[0-9a-f]{40}$/.test(pin.commit)) {
98
- throw new Error(`pi-durable pin: commit is not a full sha (${JSON.stringify(pin.commit)})`);
99
- }
100
- if (!Array.isArray(pin.patches) || pin.patches.length !== 1 || typeof pin.patches[0] !== "string") {
101
- throw new Error("pi-durable pin: exactly one overlay patch is expected");
102
- }
103
- const md = pin.modelData;
104
- if (typeof md !== "object" ||
105
- md === null ||
106
- typeof md.path !== "string" ||
107
- typeof md.files !== "number" ||
108
- typeof md.aggregateSha256 !== "string" ||
109
- typeof md.manifestSha256 !== "string") {
110
- throw new Error("pi-durable pin: modelData is malformed");
74
+ const files = pin?.carrier?.files;
75
+ const license = pin?.carrier?.license;
76
+ const members = pin?.sdk?.members;
77
+ const direct = pin?.sdk?.direct;
78
+ const versions = (set) => typeof set === "object" && set !== null && Object.values(set).every((v) => typeof v === "string" && EXACT.test(v));
79
+ if (!Array.isArray(files) ||
80
+ files.length === 0 ||
81
+ !files.every((f) => typeof f?.name === "string" && /^[a-z0-9-]+\.js$/.test(f.name) && SHA256.test(f?.sha256)) ||
82
+ !SHA256.test(license?.sha256) ||
83
+ !versions(members) ||
84
+ Object.keys(members).length === 0 ||
85
+ !versions(direct)) {
86
+ throw new Error("pi-durable pin: carrier files/license digests and exact sdk members/direct versions are required");
111
87
  }
112
- return { pin, patchBytes: fs.readFileSync(path.join(overlayDir, pin.patches[0])) };
88
+ return { files, license, members, direct };
113
89
  }
114
90
  const sha256 = (data) => createHash("sha256").update(data).digest("hex");
115
- /** The patch shape the packaged patch is in, fixed on the command line for the knobs the header lists:
116
- * git's default output with 8-hex `index` abbreviations. Two of those knobs are config-only and ride
117
- * beside these as `-c` at the call: `core.abbrev=8` ("auto" scales with the clone's object count) and
118
- * `diff.suppressBlankEmpty=false` (true drops the space before an empty context line). */
119
- export const PI_DURABLE_DIFF_SHAPE = [
120
- "--binary",
121
- "--no-ext-diff",
122
- "--no-color",
123
- "--no-textconv",
124
- "--src-prefix=a/",
125
- "--dst-prefix=b/",
126
- "--no-relative",
127
- "--diff-algorithm=myers",
128
- "--indent-heuristic",
129
- "-U3",
130
- "--inter-hunk-context=0",
131
- ];
132
- /** Wall-clock bound for each local `git` the verifier runs (it also runs inside the bridge process). */
133
- export const PI_DURABLE_GIT_TIMEOUT_MS = 30_000;
134
- export function inspectPiDurableRuntime(runtimeDir, overlayDir) {
135
- const { pin, patchBytes } = readPiDurablePin(overlayDir);
136
- const present = (() => {
137
- try {
138
- return fs.statSync(runtimeDir).isDirectory();
139
- }
140
- catch {
141
- // Bounded environment probe (Hard Rule 15's stated exception): absence is the answer.
142
- return false;
143
- }
144
- })();
145
- const base = { ...process.env, GIT_OPTIONAL_LOCKS: "0" };
146
- const git = (args, env = base) => spawnSync("git", ["-C", runtimeDir, ...args], {
147
- maxBuffer: 64 * 1024 * 1024,
148
- env,
149
- timeout: PI_DURABLE_GIT_TIMEOUT_MS,
150
- });
151
- const headRun = present ? git(["rev-parse", "HEAD"]) : undefined;
152
- // A private copy of the index, so the refresh `git diff` performs lands in a file this process owns.
153
- let scratch;
154
- let cachedRun;
155
- let diffRun;
156
- let untrackedRun;
91
+ /** `name` resolved from `fromFile` by Node's own package resolution: its version and directory. */
92
+ function resolveEdge(from, to, want, fromFile) {
93
+ let manifest;
157
94
  try {
158
- const indexPath = present ? git(["rev-parse", "--git-path", "index"]) : undefined;
159
- const index = indexPath?.status === 0 ? path.resolve(runtimeDir, indexPath.stdout.toString().trim()) : undefined;
160
- if (index !== undefined && fs.existsSync(index)) {
161
- scratch = fs.mkdtempSync(path.join(os.tmpdir(), "pi-durable-index-"));
162
- const copy = path.join(scratch, "index");
163
- fs.copyFileSync(index, copy);
164
- const onCopy = { ...base, GIT_INDEX_FILE: copy };
165
- // HEAD → index: exit 0 = nothing staged, 1 = something staged; anything else is no answer.
166
- cachedRun = git(["diff", "--cached", "--quiet", "--no-ext-diff", "--no-relative"], onCopy);
167
- diffRun = git(["-c", "core.abbrev=8", "-c", "diff.suppressBlankEmpty=false", "diff", ...PI_DURABLE_DIFF_SHAPE], onCopy);
168
- untrackedRun = git(["ls-files", "--others", "--exclude-standard"], onCopy);
169
- }
95
+ manifest = findPackageJSON(to, pathToFileURL(fromFile));
170
96
  }
171
- finally {
172
- if (scratch !== undefined)
173
- fs.rmSync(scratch, { recursive: true, force: true });
97
+ catch {
98
+ // Bounded environment probe: an unresolvable package is the `got: null` answer.
99
+ manifest = undefined;
174
100
  }
175
- const gitAnswered = headRun?.status === 0 &&
176
- (cachedRun?.status === 0 || cachedRun?.status === 1) &&
177
- diffRun?.status === 0 &&
178
- untrackedRun?.status === 0;
179
- const dataDir = path.join(runtimeDir, pin.modelData.path);
180
- const dataFiles = (() => {
181
- try {
182
- return fs
183
- .readdirSync(dataDir)
184
- .filter((n) => n.endsWith(".json") && !n.startsWith("."))
185
- .sort();
186
- }
187
- catch {
188
- return [];
189
- }
190
- })();
191
- const manifest = path.join(dataDir, ".manifest.json");
101
+ if (manifest === undefined)
102
+ return { from, to, want, got: null, name: null, at: null };
103
+ const json = JSON.parse(fs.readFileSync(manifest, "utf8"));
192
104
  return {
193
- runtimeDir,
194
- present,
195
- gitAnswered,
196
- head: headRun?.stdout?.toString().trim() ?? "",
197
- indexMatchesHead: cachedRun?.status === 0,
198
- diffEqualsPatch: diffRun?.status === 0 && Buffer.isBuffer(diffRun.stdout) && diffRun.stdout.equals(patchBytes),
199
- untracked: untrackedRun?.stdout?.toString().trim() ?? "",
200
- resolverPresent: present && fs.existsSync(path.join(runtimeDir, PI_DURABLE_RESOLVER_RELATIVE)),
201
- modelDataFiles: dataFiles.length,
202
- modelDataAggregate: sha256(dataFiles.map((n) => `${sha256(fs.readFileSync(path.join(dataDir, n)))} ${n}\n`).join("")),
203
- manifestSha256: fs.existsSync(manifest) ? sha256(fs.readFileSync(manifest)) : "absent",
105
+ from,
106
+ to,
107
+ want,
108
+ got: typeof json.version === "string" ? json.version : null,
109
+ name: typeof json.name === "string" ? json.name : null,
110
+ at: fs.realpathSync(path.dirname(manifest)),
204
111
  };
205
112
  }
206
- /** The verdict over those facts against the pin: `null` is "this is the pin". */
207
- export function piDurableRuntimeVerdict(facts, pin) {
208
- if (!facts.present)
209
- return "pi-durable-runtime-absent";
210
- if (!facts.gitAnswered)
211
- return "pi-durable-runtime-unverifiable";
212
- if (!facts.indexMatchesHead)
213
- return "pi-durable-runtime-pin-drift";
214
- if (facts.head !== pin.commit || !facts.diffEqualsPatch || facts.untracked !== "" || !facts.resolverPresent) {
215
- return "pi-durable-runtime-pin-drift";
113
+ export function inspectPiDurableRuntime(layout) {
114
+ const pin = readPiDurableCarrierPin(layout.overlayDir);
115
+ const missing = [];
116
+ const drifted = [];
117
+ const pinned = [...pin.files, { name: "LICENSE", sha256: pin.license.sha256 }];
118
+ for (const file of pinned) {
119
+ const at = path.join(layout.carrierDir, file.name);
120
+ if (!fs.existsSync(at))
121
+ missing.push(path.relative(layout.root, at));
122
+ else if (sha256(fs.readFileSync(at)) !== file.sha256)
123
+ drifted.push(path.relative(layout.root, at));
216
124
  }
217
- if (facts.modelDataFiles !== pin.modelData.files ||
218
- facts.modelDataAggregate !== pin.modelData.aggregateSha256 ||
219
- facts.manifestSha256 !== pin.modelData.manifestSha256) {
220
- return "pi-durable-runtime-modeldata-drift";
125
+ for (const at of [layout.resolver, layout.relocation]) {
126
+ if (!fs.existsSync(at))
127
+ missing.push(path.relative(layout.root, at));
221
128
  }
129
+ const edges = [];
130
+ // From the carrier's own location: the bare imports its files make resolve from there.
131
+ const base = path.join(layout.carrierDir, pin.files[0].name);
132
+ // Every member COPY the carrier can reach is walked, at any depth: a nested copy's own member
133
+ // dependencies are edges too. One physical directory is walked once (shared and duplicate copies
134
+ // are ordinary; a member cycle terminates), keyed by its realpath.
135
+ const pending = [];
136
+ for (const [name, want] of Object.entries({ ...pin.members, ...pin.direct })) {
137
+ const edge = resolveEdge("carrier", name, want, base);
138
+ edges.push(edge);
139
+ if (name in pin.members && edge.at !== null)
140
+ pending.push({ name, at: edge.at });
141
+ }
142
+ const walked = new Set();
143
+ for (let next = pending.shift(); next !== undefined; next = pending.shift()) {
144
+ if (walked.has(next.at))
145
+ continue;
146
+ walked.add(next.at);
147
+ const manifest = path.join(next.at, "package.json");
148
+ const deps = JSON.parse(fs.readFileSync(manifest, "utf8")).dependencies ?? {};
149
+ for (const dep of Object.keys(deps)
150
+ .filter((d) => d in pin.members)
151
+ .sort()) {
152
+ const edge = resolveEdge(next.name, dep, pin.members[dep], manifest);
153
+ edges.push(edge);
154
+ if (edge.at !== null)
155
+ pending.push({ name: dep, at: edge.at });
156
+ }
157
+ }
158
+ return { missing, drifted, edges };
159
+ }
160
+ const edgeText = (e) => `${e.from} → ${e.to} wants ${e.want}, ${e.got === null
161
+ ? "resolves nothing"
162
+ : `binds ${e.name === e.to ? "" : `${e.name ?? "(unnamed)"}@`}${e.got} at ${e.at}`}`;
163
+ /** The verdict over those facts, in repair order: `null` is "the carrier can run". */
164
+ export function piDurableRuntimeVerdict(facts) {
165
+ if (facts.missing.length > 0)
166
+ return { reason: "pi-durable-package-incomplete", detail: `missing ${facts.missing.join(", ")}` };
167
+ if (facts.drifted.length > 0)
168
+ return { reason: "pi-durable-carrier-drift", detail: `changed ${facts.drifted.join(", ")}` };
169
+ const absent = facts.edges.find((e) => e.got === null);
170
+ if (absent !== undefined)
171
+ return { reason: "pi-durable-sdk-absent", detail: edgeText(absent) };
172
+ const mismatch = facts.edges.find((e) => e.got !== e.want || e.name !== e.to);
173
+ if (mismatch !== undefined)
174
+ return { reason: "pi-durable-sdk-mismatch", detail: edgeText(mismatch) };
222
175
  return null;
223
176
  }
224
- /** Locate and verify, in that order. The launcher, setup and the fresh preflight all ask this. */
225
- export function checkPiDurableRuntime(env, layout) {
226
- if (layout === null)
227
- return { ok: false, reason: "pi-durable-package-incomplete", runtimeDir: null };
228
- const runtimeDir = piDurableRuntimeDir(env);
229
- if (runtimeDir === null)
230
- return { ok: false, reason: "pi-durable-runtime-locator-unset", runtimeDir: null };
231
- const { pin } = readPiDurablePin(layout.overlayDir);
232
- const reason = piDurableRuntimeVerdict(inspectPiDurableRuntime(runtimeDir, layout.overlayDir), pin);
233
- if (reason !== null)
234
- return { ok: false, reason, runtimeDir };
235
- return { ok: true, runtimeDir, resolver: path.join(runtimeDir, PI_DURABLE_RESOLVER_RELATIVE) };
177
+ /** Locate and verify. The launcher, setup, the fresh preflight and the durable gates all ask this. */
178
+ export function checkPiDurableRuntime(layout) {
179
+ if (layout === null) {
180
+ return { ok: false, reason: "pi-durable-package-incomplete", detail: "no pi-durable pin next to this code" };
181
+ }
182
+ const facts = inspectPiDurableRuntime(layout);
183
+ const verdict = piDurableRuntimeVerdict(facts);
184
+ if (verdict !== null)
185
+ return { ok: false, ...verdict };
186
+ return { ok: true, resolver: layout.resolver, edges: facts.edges };
236
187
  }
237
188
  export const PI_DURABLE_RUNTIME_HINT = {
238
- "pi-durable-package-incomplete": "this Entwurf package carries no pi-durable overlay pin next to its code — reinstall Entwurf; the package itself is incomplete",
239
- "pi-durable-runtime-locator-unset": "neither an absolute XDG_DATA_HOME nor HOME is set, so the fixed runtime location cannot be named",
240
- "pi-durable-runtime-absent": "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)",
241
- "pi-durable-runtime-unverifiable": "the runtime location exists but git could not read it as a checkout, so it cannot be proven to be the pin",
242
- "pi-durable-runtime-pin-drift": "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",
243
- "pi-durable-runtime-modeldata-drift": "the runtime's git-ignored model data is not the pinned bytes — restore the model data the pin records",
189
+ "pi-durable-package-incomplete": "this Entwurf package is missing part of its pi-durable carrier — reinstall Entwurf; nothing outside the package supplies it",
190
+ "pi-durable-carrier-drift": "a pi-durable carrier file is not the bytes pi/pi-durable/overlay/upstream-pin.json records — reinstall Entwurf; the carrier is never edited in place",
191
+ "pi-durable-sdk-absent": "a declared pi SDK package does not resolve from the carrier — reinstall Entwurf so its dependencies are installed",
192
+ "pi-durable-sdk-mismatch": "a pi SDK package the carrier binds is not the declared version (another install's copy, an override or a partial upgrade) — reinstall Entwurf into a tree whose @earendil-works packages are the pinned version",
244
193
  };
245
- /** Exit codes of the CLI below, read by `run.sh`: 0 verified, 4 absent (setup SKIP), 3 anything else. */
246
- export const PI_DURABLE_RUNTIME_EXIT = { verified: 0, absent: 4, refused: 3 };
194
+ /** Exit codes of the CLI below, read by `run.sh`: 0 verified, 3 refused. There is no "absent"
195
+ * answer any more: the carrier ships in the package, so a missing piece is an incomplete install. */
196
+ export const PI_DURABLE_RUNTIME_EXIT = { verified: 0, refused: 3 };
247
197
  /**
248
- * `resolve`: print the verified runtime's source-resolver path (stdout) and exit 0, or print
249
- * `<reason>: <hint>` (stderr) and exit 4 for an absent runtime / 3 for every other refusal.
250
- * The launcher execs node under that resolver; setup maps the three codes to PASS / SKIP / FAIL.
198
+ * `resolve`: print the verified carrier's resolver path (stdout) and exit 0, or print
199
+ * `<reason>: <detail> — <hint>` (stderr) and exit 3. The launcher execs node under that resolver;
200
+ * setup maps the two codes to PASS / FAIL.
251
201
  */
252
- export function runPiDurableRuntimeCli(argv, env, layout, out) {
202
+ export function runPiDurableRuntimeCli(argv, layout, out) {
253
203
  if (argv.length !== 1 || argv[0] !== "resolve") {
254
204
  out.stderr("usage: pi-durable-runtime resolve");
255
205
  return 2;
256
206
  }
257
- const checked = checkPiDurableRuntime(env, layout);
207
+ const checked = checkPiDurableRuntime(layout);
258
208
  if (checked.ok) {
259
209
  out.stdout(checked.resolver);
260
210
  return PI_DURABLE_RUNTIME_EXIT.verified;
261
211
  }
262
- out.stderr(`${checked.reason}${checked.runtimeDir === null ? "" : ` (${checked.runtimeDir})`}: ${PI_DURABLE_RUNTIME_HINT[checked.reason]}`);
263
- return checked.reason === "pi-durable-runtime-absent"
264
- ? PI_DURABLE_RUNTIME_EXIT.absent
265
- : PI_DURABLE_RUNTIME_EXIT.refused;
212
+ out.stderr(`${checked.reason}: ${checked.detail} — ${PI_DURABLE_RUNTIME_HINT[checked.reason]}`);
213
+ return PI_DURABLE_RUNTIME_EXIT.refused;
266
214
  }
267
215
  // RUN AS THE CLI ONLY WHEN THIS FILE IS THE PROCESS ENTRY. Decided by an argv comparison, as in
268
216
  // meta-bridge-hook-codex.ts, and not by the `main` flag on import.meta: that flag landed in Node 24.2.0
@@ -281,7 +229,7 @@ const invokedDirectly = (() => {
281
229
  }
282
230
  })();
283
231
  if (invokedDirectly) {
284
- process.exitCode = runPiDurableRuntimeCli(process.argv.slice(2), process.env, piDurablePackageLayout(), {
232
+ process.exitCode = runPiDurableRuntimeCli(process.argv.slice(2), piDurablePackageLayout(), {
285
233
  stdout: (line) => process.stdout.write(`${line}\n`),
286
234
  stderr: (line) => process.stderr.write(`${line}\n`),
287
235
  });
@@ -2,8 +2,9 @@
2
2
  * meta-bridge-pi-durable — the durable app's native contact, identity leaf (#129 L-identity).
3
3
  *
4
4
  * WHAT THIS IS. The long-running durable app (`@earendil-works/pi-coding-agent`
5
- * `experimental/durable`, source-only upstream) owns its runtime, TUI, models/auth, storage,
6
- * prompt and recovery. This module is the whole Entwurf side of its contact:
5
+ * `experimental/durable`, which upstream omits from its npm distribution — Entwurf's package
6
+ * supplies it as a pinned carrier, #130, not as an operator prerequisite) owns its runtime, TUI,
7
+ * models/auth, storage, prompt and recovery. This module is the whole Entwurf side of its contact:
7
8
  *
8
9
  * createPiDurableContact({ bridgeEntry })
9
10
  * .extension tools-only durable Extension, handed to the overlay's
@@ -13,8 +14,9 @@
13
14
  * release the tools
14
15
  *
15
16
  * The bootstrap that composes the two lives with the overlay (`pi/pi-durable/bootstrap.mjs`), not
16
- * here: this module never imports the durable runtime, so Entwurf carries no path into a
17
- * vendor checkout and no `@earendil-works/pi-durable` dependency.
17
+ * here: this module never imports or initializes the durable app runtime and carries no path into
18
+ * a vendor checkout. That is this module's import boundary, not the package's dependency list: the
19
+ * package does declare the exact `@earendil-works/pi-durable` (1.0.4) for its carrier (#130).
18
20
  *
19
21
  * WHY BIRTH IS AFTER OPEN, BEHIND A GATE. `[read @cd32f77]` The native session id is chosen
20
22
  * inside `openDurable` (`runtime.ts:125`, `sessions.ts:18-55`), and `openDurable` calls
@@ -762,7 +762,7 @@ server.tool(
762
762
  "a running sibling. Nothing polls. " +
763
763
  "For EXISTING citizens use entwurf_v2 — this tool only creates, and entwurf_peers only reports. Model is REQUIRED and passed to the chosen " +
764
764
  "runtime CLI (`provider/model` for pi, an id/alias for Claude Code, `provider/model id` exactly as the catalog names it for pi-durable, a pattern for the rest). On tmux, copilot/omp/codex/pi-durable are refused BEFORE any window " +
765
- "opens when their birth, MCP, receive or visible-identity units are absent, pi-durable also when its operator-provided pinned runtime is absent or drifted, and codex also needs the " +
765
+ "opens when their birth, MCP, receive or visible-identity units are absent, pi-durable also on named refusals of its installed carrier or exact SDK set, and codex also needs the " +
766
766
  "operator-owned app-server socket entwurf never starts; in herdr they are refused by name first. " +
767
767
  "Optional placement.tmuxSession is a TMUX-ONLY seat naming ONE EXISTING session and " +
768
768
  "ALWAYS wins; omitted, the seat follows the CALLER, never the backend opened: a CODEX CALLER opens beside " +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@junghanacs/entwurf",
3
- "version": "0.31.0",
3
+ "version": "0.32.0",
4
4
  "description": "Garden-citizen dispatch substrate and meta-bridge for pi, Claude Code, Copilot CLI, OMP, Antigravity, and Codex CLI; Claude and Snowflake Cortex Code ACP backends.",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -53,6 +53,9 @@
53
53
  "pi/entwurf-capabilities.json",
54
54
  "pi/settings.reference.json",
55
55
  "pi/pi-durable/bootstrap.mjs",
56
+ "pi/pi-durable/carrier-relocation.mjs",
57
+ "pi/pi-durable/carrier-resolver.mjs",
58
+ "pi/pi-durable/carrier/",
56
59
  "pi/pi-durable/overlay/upstream-pin.json",
57
60
  "pi/pi-durable/overlay/runtime-contacts.patch",
58
61
  "pi/meta-bridge/.claude-plugin/marketplace.json",
@@ -90,22 +93,25 @@
90
93
  "@agentclientprotocol/claude-agent-acp": "0.85.1",
91
94
  "@agentclientprotocol/sdk": "1.6.0",
92
95
  "@anthropic-ai/sdk": "0.100.1",
96
+ "@earendil-works/chord": "1.0.4",
97
+ "@earendil-works/pi-agent-core": "1.0.4",
98
+ "@earendil-works/pi-ai": "1.0.4",
99
+ "@earendil-works/pi-codemode": "1.0.4",
100
+ "@earendil-works/pi-coding-agent": "1.0.4",
101
+ "@earendil-works/pi-durable": "1.0.4",
102
+ "@earendil-works/pi-mcp": "1.0.4",
103
+ "@earendil-works/pi-telemetry": "1.0.4",
104
+ "@earendil-works/pi-tui": "1.0.4",
93
105
  "@modelcontextprotocol/sdk": "^1.12.1",
106
+ "proper-lockfile": "4.1.2",
94
107
  "smol-toml": "^1.8.0",
95
108
  "zod": "^3.25.0 || ^4.0.0"
96
109
  },
97
110
  "peerDependencies": {
98
- "@earendil-works/pi-ai": ">=1.0.2 <1.1",
99
- "@earendil-works/pi-coding-agent": ">=1.0.2 <1.1",
100
- "@earendil-works/pi-tui": ">=1.0.2 <1.1",
101
111
  "typebox": "*"
102
112
  },
103
113
  "devDependencies": {
104
114
  "@biomejs/biome": "^2.4.13",
105
- "@earendil-works/pi-agent-core": "1.0.2",
106
- "@earendil-works/pi-ai": "1.0.2",
107
- "@earendil-works/pi-coding-agent": "1.0.2",
108
- "@earendil-works/pi-tui": "1.0.2",
109
115
  "@types/node": "^24.3.0",
110
116
  "husky": "^9.1.7",
111
117
  "rregex": "1.13.1",
@@ -201,15 +207,6 @@
201
207
  "entwurf-copilot-statusline": "./scripts/copilot-statusline.sh"
202
208
  },
203
209
  "peerDependenciesMeta": {
204
- "@earendil-works/pi-ai": {
205
- "optional": true
206
- },
207
- "@earendil-works/pi-coding-agent": {
208
- "optional": true
209
- },
210
- "@earendil-works/pi-tui": {
211
- "optional": true
212
- },
213
210
  "typebox": {
214
211
  "optional": true
215
212
  }