@junghanacs/entwurf 0.30.1 → 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.
- package/AGENTS.md +1 -0
- package/CHANGELOG.md +112 -0
- package/DELIVERY.md +1 -0
- package/README.md +47 -7
- package/VERIFY.md +17 -1
- package/demo/README.md +1 -1
- package/docs/acp-backend-rail.md +1 -1
- package/docs/setup-clean-host.md +104 -4
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +7 -7
- package/mcp/entwurf-bridge/dist/pi-extensions/entwurf-capabilities.json +1 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-deliverability.js +7 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-composition.js +34 -3
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-sender-identity.js +7 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +28 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +30 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-durable-fresh-preflight.js +92 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-durable-runtime.js +288 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-pi-durable.js +744 -0
- package/mcp/entwurf-bridge/src/index.ts +7 -7
- package/mcp/entwurf-bridge/tsconfig.build.json +8 -1
- package/package.json +13 -10
- package/pi/entwurf-capabilities.json +1 -0
- package/pi/pi-durable/bootstrap.mjs +281 -0
- package/pi/pi-durable/overlay/runtime-contacts.patch +91 -0
- package/pi/pi-durable/overlay/upstream-pin.json +16 -0
- package/pi-extensions/lib/entwurf-deliverability.ts +7 -1
- package/pi-extensions/lib/fresh-call-composition.ts +42 -7
- package/pi-extensions/lib/meta-sender-identity.ts +7 -1
- package/pi-extensions/lib/meta-session.ts +28 -2
- package/pi-extensions/lib/mux-fresh-call.ts +34 -2
- package/pi-extensions/lib/pi-durable-fresh-preflight.ts +113 -0
- package/pi-extensions/lib/pi-durable-runtime.ts +384 -0
- package/pi-extensions/meta-bridge-pi-durable.ts +1020 -0
- package/run.sh +200 -33
- package/scripts/check-fresh-call-dispatch.ts +18 -0
- package/scripts/check-gate-qualification.ts +2 -1
- package/scripts/check-pi-durable-contact.ts +834 -0
- package/scripts/check-pi-durable-receive.ts +1116 -0
- package/scripts/check-pi-durable-send.ts +1661 -0
- package/scripts/lib/pi-durable-fresh-live-receipts.ts +139 -0
- package/scripts/lib/pi-durable-r1.ts +100 -0
- package/scripts/lib/pi-durable-reap.ts +27 -0
- package/scripts/lib/pi-durable-receive.ts +137 -0
- package/scripts/mutants/control-send-receipt.json +1 -1
- package/scripts/mutants/copilot-birth.json +2 -2
- package/scripts/mutants/meta-hook-session-switch.json +4 -2
- package/scripts/mutants/mux-fresh-call.json +69 -3
- package/scripts/mutants/omp-birth.json +2 -2
- package/scripts/mutants/omp-fresh.json +6 -4
- package/scripts/mutants/pack-install.json +3 -3
- package/scripts/mutants/pi-durable.json +390 -0
- package/scripts/mutants/pi-floor.json +2 -2
- package/scripts/pi-durable-contact-host.mjs +105 -0
- package/scripts/pi-durable-receive-host.mjs +127 -0
- package/scripts/pi-durable-send-host.mjs +213 -0
- package/scripts/smoke-pi-durable-fresh-live.ts +624 -0
- package/scripts/smoke-setup-verdict.sh +21 -2
- package/scripts/tsconfig.json +1 -0
|
@@ -76,7 +76,7 @@ export class MetaRecordError extends Error {
|
|
|
76
76
|
* every write, including its own. `./run.sh doctor-meta-bridge` already flags a
|
|
77
77
|
* stale deployed writer — run it after the change.
|
|
78
78
|
*/
|
|
79
|
-
export const META_BACKENDS = ["claude-code", "antigravity", "codex", "copilot", "omp"];
|
|
79
|
+
export const META_BACKENDS = ["claude-code", "antigravity", "codex", "copilot", "omp", "pi-durable"];
|
|
80
80
|
export const META_BACKEND_DESCRIPTORS = {
|
|
81
81
|
"claude-code": {
|
|
82
82
|
backend: "claude-code",
|
|
@@ -189,6 +189,24 @@ export const META_BACKEND_DESCRIPTORS = {
|
|
|
189
189
|
deliveryLevel: "D6",
|
|
190
190
|
nativeIdLabel: "sessionId",
|
|
191
191
|
},
|
|
192
|
+
// The long-running durable app (#129). Graded only as far as its RECORDED receipts reach.
|
|
193
|
+
// sessionId the durable session directory name `<13-digit ms>-<uuid>` that
|
|
194
|
+
// `selectSession` creates and `--continue` reopens
|
|
195
|
+
// (`experimental/durable/sessions.ts:18-55` @cd32f77), surfaced as
|
|
196
|
+
// `view.current().session.id`. Not `pi.provider.sessionId`, which a
|
|
197
|
+
// fork changes.
|
|
198
|
+
// D2 receiver armed: the contact mints the record, names the host as sender, and arms
|
|
199
|
+
// a root doorbell (the receiver marker is owned by the host pid as `pi-durable-host`,
|
|
200
|
+
// which the owner join admits). The addressed-enqueue → idle-wake → root-notice → drain
|
|
201
|
+
// chain has ONE recorded receipt, on the scripted-model + native-harness lab cell
|
|
202
|
+
// (`check-pi-durable-receive`, S+H, V not run) — lab evidence, not a vendor-turn D4–D6
|
|
203
|
+
// acceptance, and no sibling-isolation (D3) or LIVE fresh receipt exists yet.
|
|
204
|
+
"pi-durable": {
|
|
205
|
+
backend: "pi-durable",
|
|
206
|
+
wakeMode: "self-fetch",
|
|
207
|
+
deliveryLevel: "D2",
|
|
208
|
+
nativeIdLabel: "sessionId",
|
|
209
|
+
},
|
|
192
210
|
};
|
|
193
211
|
// ---------------------------------------------------------------------------
|
|
194
212
|
// Validation helpers (crash, don't warn)
|
|
@@ -261,7 +279,15 @@ function isoNow(now) {
|
|
|
261
279
|
/** The one live identity schema number. */
|
|
262
280
|
export const META_SCHEMA_VERSION_V3 = 3;
|
|
263
281
|
/** Every backend admitted by the one V3 record-citizen schema. */
|
|
264
|
-
export const META_CITIZEN_BACKENDS = [
|
|
282
|
+
export const META_CITIZEN_BACKENDS = [
|
|
283
|
+
"claude-code",
|
|
284
|
+
"antigravity",
|
|
285
|
+
"codex",
|
|
286
|
+
"copilot",
|
|
287
|
+
"omp",
|
|
288
|
+
"pi-durable",
|
|
289
|
+
"pi",
|
|
290
|
+
];
|
|
265
291
|
export function requireCitizenBackend(value) {
|
|
266
292
|
if (typeof value !== "string" || !META_CITIZEN_BACKENDS.includes(value)) {
|
|
267
293
|
throw new MetaRecordError(`meta-record "backend" must be one of ${META_CITIZEN_BACKENDS.join(" | ")} (got ${describe(value)}).`);
|
|
@@ -95,6 +95,8 @@ import { assertLaunchTarget, LaunchPreconditionError, resolveRuntimeOnPath, } fr
|
|
|
95
95
|
import { APPEND_FORMAT, assertSelector, assertTmuxOk, inspectPlacement, parseWindowFields, requireSameContext, runTmux, } from "./mux-placement.js";
|
|
96
96
|
import { resolveCodexDefaultSocketPath } from "./native-push/codex-ws-client.js";
|
|
97
97
|
import { OMP_PREFLIGHT_HINT, ompFreshPreflight } from "./omp-fresh-preflight.js";
|
|
98
|
+
import { PI_DURABLE_PREFLIGHT_HINT, piDurableFreshPreflight, } from "./pi-durable-fresh-preflight.js";
|
|
99
|
+
import { checkPiDurableRuntime, PI_DURABLE_RUNTIME_HINT, piDurablePackageLayout } from "./pi-durable-runtime.js";
|
|
98
100
|
import { classifyTmuxSessionName, resolveTmuxSessionId } from "./resolve-tmux-session.js";
|
|
99
101
|
/**
|
|
100
102
|
* THE COMPOSITION MOVED, THE RAIL DID NOT (#116 S2-a). Backend argv, the first-turn framing and
|
|
@@ -154,6 +156,10 @@ export const FRESH_CALL_RUNTIME = {
|
|
|
154
156
|
copilot: "entwurf",
|
|
155
157
|
omp: "omp",
|
|
156
158
|
codex: "codex",
|
|
159
|
+
// #129: like copilot, a MANAGED verb and never the bare app. The durable app is source-only
|
|
160
|
+
// and runs under the operator-provided runtime's own resolver; `entwurf pi-durable` is the one
|
|
161
|
+
// fixed invocation that verifies that runtime against the pin and execs the packaged bootstrap.
|
|
162
|
+
"pi-durable": "entwurf",
|
|
157
163
|
};
|
|
158
164
|
/**
|
|
159
165
|
* WHICH RULE PICKS THE TARGET SESSION. Three in strict order, and the order is the contract:
|
|
@@ -197,9 +203,9 @@ export function selectFreshCallSeat(placement) {
|
|
|
197
203
|
* for. That is sufficient and not a compromise: every reader of the carrier trims and tests
|
|
198
204
|
* truthiness (`index.ts:212-217`), so empty and absent are the same answer by construction.
|
|
199
205
|
*
|
|
200
|
-
* It is applied to all
|
|
206
|
+
* It is applied to all six backends because the leak is a property of tmux, not of a vendor. A
|
|
201
207
|
* scrub only on the backend whose measurement surfaced it would encode the claim that the other
|
|
202
|
-
*
|
|
208
|
+
* five are immune, which is false. It costs the legitimate case nothing: a carrier is only ever
|
|
203
209
|
* authoritative when the process that owns it exported it ITSELF, and a fresh `pi` sibling does
|
|
204
210
|
* exactly that after this argv has run. This is a fixed seam and deliberately NOT a general env
|
|
205
211
|
* carrier — an arbitrary `-e` passthrough would hand callers the environment-shaping power this
|
|
@@ -337,6 +343,26 @@ export function freshCall(params, env = process.env, nonce = mintNonce()) {
|
|
|
337
343
|
if (missing)
|
|
338
344
|
return { ok: false, reason: missing };
|
|
339
345
|
}
|
|
346
|
+
if (params.backend === "pi-durable") {
|
|
347
|
+
// The framed first input is computed here only to MEASURE it against the bootstrap payload
|
|
348
|
+
// cap; the same pure function composes it again below. The runtime is located and verified
|
|
349
|
+
// with THIS process's env, before anything is placed. That is the bridge's env, not
|
|
350
|
+
// necessarily the window's: a new-window pane inherits the tmux SERVER's environment
|
|
351
|
+
// (measured, see SCRUBBED_INHERITED_ENV), and the window runs whatever `entwurf` PATH names.
|
|
352
|
+
// The managed verb re-verifies with its own env before it execs anything, so the window never
|
|
353
|
+
// runs an unverified runtime — but when HOME/XDG_DATA_HOME or the package closure differ, this
|
|
354
|
+
// verdict and the window's can disagree, and the window is left showing its own refusal.
|
|
355
|
+
const layout = piDurablePackageLayout();
|
|
356
|
+
const runtime = layout === null ? null : checkPiDurableRuntime(env, layout);
|
|
357
|
+
const missing = piDurableFreshPreflight({
|
|
358
|
+
model,
|
|
359
|
+
firstInput: buildFreshCallPrompt({ backend: params.backend, task, callerGardenId, nonce }),
|
|
360
|
+
layout,
|
|
361
|
+
runtime: runtime === null || runtime.ok ? null : runtime.reason,
|
|
362
|
+
});
|
|
363
|
+
if (missing)
|
|
364
|
+
return { ok: false, reason: missing };
|
|
365
|
+
}
|
|
340
366
|
const inspected = inspectPlacement(env);
|
|
341
367
|
if (!inspected.ok)
|
|
342
368
|
return { ok: false, reason: inspected.reason };
|
|
@@ -448,6 +474,8 @@ const REJECT_HINT = {
|
|
|
448
474
|
// the sentence an operator reads cannot drift away from the predicate that produced it.
|
|
449
475
|
...COPILOT_PREFLIGHT_HINT,
|
|
450
476
|
...OMP_PREFLIGHT_HINT,
|
|
477
|
+
...PI_DURABLE_RUNTIME_HINT,
|
|
478
|
+
...PI_DURABLE_PREFLIGHT_HINT,
|
|
451
479
|
...CODEX_PREFLIGHT_HINT,
|
|
452
480
|
...CODEX_CALLER_PREFLIGHT_HINT,
|
|
453
481
|
...CODEX_CALLER_SEAT_HINT,
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-durable-fresh-preflight — the pre-mutation check a pi-durable fresh call needs (#129 W, L6;
|
|
3
|
+
* `docs/adding-a-harness.md` step 9 clauses 2, 3 and 5).
|
|
4
|
+
*
|
|
5
|
+
* Same question as `omp-fresh-preflight.ts`, at the same moment: before `mux-fresh-call` mutates the
|
|
6
|
+
* operator's tmux session, is everything the fresh contract needs actually in place? A refusal HERE,
|
|
7
|
+
* before placement, leaves no window behind. A pass here does not promise the window's own pass: the
|
|
8
|
+
* managed verb re-verifies inside the window, whose env is the tmux SERVER's and whose `entwurf` is
|
|
9
|
+
* whatever PATH names, so under env or package-closure skew it can still refuse after the window
|
|
10
|
+
* exists — and that refusal is left showing in it.
|
|
11
|
+
*
|
|
12
|
+
* ── The inputs, first ──
|
|
13
|
+
*
|
|
14
|
+
* model the fresh-call model grammar already admits `/`, and a durable launch needs the
|
|
15
|
+
* provider spelled out (the bootstrap refuses a model resolved by inference), so
|
|
16
|
+
* the model here is `<provider>/<model id>` split at the FIRST `/` — a catalog id
|
|
17
|
+
* that itself contains `/` keeps it. A model with no provider part is refused.
|
|
18
|
+
* first input the whole framing (callback first, then the task) rides the bootstrap's closed
|
|
19
|
+
* `{v:2,task}` payload, whose decoder caps `task` at 16000 characters. A framed
|
|
20
|
+
* task over that cap would open a window whose bootstrap refuses its own argv.
|
|
21
|
+
*
|
|
22
|
+
* ── Then the runtime, then the four units ──
|
|
23
|
+
*
|
|
24
|
+
* runtime operator-provided at the fixed locator and verified against the pin
|
|
25
|
+
* (`pi-durable-runtime.ts`); passed in as that check's verdict.
|
|
26
|
+
* birth the compiled contact in this package's closure — it mints the record.
|
|
27
|
+
* MCP hand the compiled entwurf-bridge entry in the same closure — the contact spawns it.
|
|
28
|
+
* receive the compiled owner join in the same closure admits `pi-durable-host`; without
|
|
29
|
+
* it every dispatch to the citizen is the honest `mailbox-undeliverable`.
|
|
30
|
+
* visible identity the packaged bootstrap — the one that hands the TUI the view whose conversation
|
|
31
|
+
* labels carry the garden id.
|
|
32
|
+
*
|
|
33
|
+
* ── What this deliberately does NOT claim ──
|
|
34
|
+
*
|
|
35
|
+
* Filesystem truth about ONE package closure and ONE runtime directory. It does not prove the durable
|
|
36
|
+
* app opens, the bridge connects, the doorbell arms or a garden id renders — that is runtime truth for
|
|
37
|
+
* the clause 7 LIVE receipt. No spawn, no network, no mutation; every path arrives as an argument, so
|
|
38
|
+
* this leaf imports nothing but types.
|
|
39
|
+
*/
|
|
40
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
41
|
+
/** The bootstrap decoder's task cap (`meta-bridge-omp.ts` OMP_BOOTSTRAP_TASK_MAX_CHARS); the beside
|
|
42
|
+
* test holds the two equal. */
|
|
43
|
+
export const PI_DURABLE_FIRST_INPUT_MAX_CHARS = 16000;
|
|
44
|
+
/** `<provider>/<model id>` at the FIRST `/`, or `null` when either side is empty. */
|
|
45
|
+
export function splitPiDurableModel(model) {
|
|
46
|
+
const at = model.indexOf("/");
|
|
47
|
+
if (at <= 0 || at === model.length - 1)
|
|
48
|
+
return null;
|
|
49
|
+
return { provider: model.slice(0, at), model: model.slice(at + 1) };
|
|
50
|
+
}
|
|
51
|
+
/** The compiled owner join, read from the emitted bytes the bridge actually loads. */
|
|
52
|
+
function ownerJoinAdmitsDurableHost(file) {
|
|
53
|
+
try {
|
|
54
|
+
const join = /SENDER_JOINED_RECEIVER_OWNER_KINDS\s*=\s*(\[[^\]]*\])/.exec(readFileSync(file, "utf8"))?.[1];
|
|
55
|
+
return join?.includes('"pi-durable-host"') ?? false;
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
// Bounded environment probe: an unreadable file is a missing unit.
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/** The FIRST missing thing, in repair order, or `null` when a fresh launch may proceed. */
|
|
63
|
+
export function piDurableFreshPreflight(params) {
|
|
64
|
+
if (splitPiDurableModel(params.model) === null)
|
|
65
|
+
return "pi-durable-model-not-provider-qualified";
|
|
66
|
+
if (params.firstInput.length > PI_DURABLE_FIRST_INPUT_MAX_CHARS)
|
|
67
|
+
return "pi-durable-first-input-too-long";
|
|
68
|
+
if (params.layout === null)
|
|
69
|
+
return "pi-durable-package-incomplete";
|
|
70
|
+
if (params.runtime !== null)
|
|
71
|
+
return params.runtime;
|
|
72
|
+
const layout = params.layout;
|
|
73
|
+
if (!existsSync(layout.adapter))
|
|
74
|
+
return "pi-durable-birth-unit-missing";
|
|
75
|
+
if (!existsSync(layout.bridgeEntry))
|
|
76
|
+
return "pi-durable-mcp-hand-missing";
|
|
77
|
+
if (!ownerJoinAdmitsDurableHost(layout.deliverability))
|
|
78
|
+
return "pi-durable-receive-unit-missing";
|
|
79
|
+
if (!existsSync(layout.bootstrap))
|
|
80
|
+
return "pi-durable-visible-identity-missing";
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
/** Repair text beside the predicate that decides it. The runtime reasons keep theirs on the runtime
|
|
84
|
+
* leaf; `mux-fresh-call` spreads both tables. */
|
|
85
|
+
export const PI_DURABLE_PREFLIGHT_HINT = {
|
|
86
|
+
"pi-durable-model-not-provider-qualified": "a pi-durable model is `<provider>/<model id>` — the durable bootstrap resolves no provider by inference",
|
|
87
|
+
"pi-durable-first-input-too-long": "the framed first input exceeds the 16000-character bootstrap payload cap — shorten the task",
|
|
88
|
+
"pi-durable-birth-unit-missing": "this Entwurf package has no compiled pi-durable contact (mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-pi-durable.js), so the sibling would mint no record — reinstall or rebuild Entwurf",
|
|
89
|
+
"pi-durable-mcp-hand-missing": "this Entwurf package has no compiled entwurf-bridge entry, so the sibling's callback tool would not exist — reinstall or rebuild Entwurf",
|
|
90
|
+
"pi-durable-receive-unit-missing": "this Entwurf package's compiled owner join does not admit pi-durable-host, so nothing could ever be delivered to the sibling — reinstall or rebuild Entwurf",
|
|
91
|
+
"pi-durable-visible-identity-missing": "this Entwurf package has no packaged pi-durable bootstrap (pi/pi-durable/bootstrap.mjs), so no TUI would carry the garden id — reinstall Entwurf",
|
|
92
|
+
};
|
|
@@ -0,0 +1,288 @@
|
|
|
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
|
+
import { spawnSync } from "node:child_process";
|
|
45
|
+
import { createHash } from "node:crypto";
|
|
46
|
+
import * as fs from "node:fs";
|
|
47
|
+
import * as os from "node:os";
|
|
48
|
+
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");
|
|
53
|
+
/** The layout rooted at `root`, as plain paths. */
|
|
54
|
+
export function piDurableLayoutAt(root) {
|
|
55
|
+
const dist = path.join(root, "mcp", "entwurf-bridge", "dist");
|
|
56
|
+
return {
|
|
57
|
+
root,
|
|
58
|
+
overlayDir: path.join(root, "pi", "pi-durable", "overlay"),
|
|
59
|
+
bootstrap: path.join(root, "pi", "pi-durable", "bootstrap.mjs"),
|
|
60
|
+
adapter: path.join(dist, "pi-extensions", "meta-bridge-pi-durable.js"),
|
|
61
|
+
bridgeEntry: path.join(dist, "mcp", "entwurf-bridge", "src", "index.js"),
|
|
62
|
+
deliverability: path.join(dist, "pi-extensions", "lib", "entwurf-deliverability.js"),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* 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.
|
|
68
|
+
*/
|
|
69
|
+
export function piDurablePackageLayout(from = import.meta.dirname) {
|
|
70
|
+
for (const up of [
|
|
71
|
+
["..", ".."],
|
|
72
|
+
["..", "..", "..", "..", ".."],
|
|
73
|
+
]) {
|
|
74
|
+
const root = path.resolve(from, ...up);
|
|
75
|
+
const layout = piDurableLayoutAt(root);
|
|
76
|
+
if (fs.existsSync(path.join(layout.overlayDir, "upstream-pin.json")))
|
|
77
|
+
return layout;
|
|
78
|
+
}
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
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) {
|
|
96
|
+
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");
|
|
111
|
+
}
|
|
112
|
+
return { pin, patchBytes: fs.readFileSync(path.join(overlayDir, pin.patches[0])) };
|
|
113
|
+
}
|
|
114
|
+
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;
|
|
157
|
+
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
|
+
}
|
|
170
|
+
}
|
|
171
|
+
finally {
|
|
172
|
+
if (scratch !== undefined)
|
|
173
|
+
fs.rmSync(scratch, { recursive: true, force: true });
|
|
174
|
+
}
|
|
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");
|
|
192
|
+
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",
|
|
204
|
+
};
|
|
205
|
+
}
|
|
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";
|
|
216
|
+
}
|
|
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";
|
|
221
|
+
}
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
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) };
|
|
236
|
+
}
|
|
237
|
+
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",
|
|
244
|
+
};
|
|
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 };
|
|
247
|
+
/**
|
|
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.
|
|
251
|
+
*/
|
|
252
|
+
export function runPiDurableRuntimeCli(argv, env, layout, out) {
|
|
253
|
+
if (argv.length !== 1 || argv[0] !== "resolve") {
|
|
254
|
+
out.stderr("usage: pi-durable-runtime resolve");
|
|
255
|
+
return 2;
|
|
256
|
+
}
|
|
257
|
+
const checked = checkPiDurableRuntime(env, layout);
|
|
258
|
+
if (checked.ok) {
|
|
259
|
+
out.stdout(checked.resolver);
|
|
260
|
+
return PI_DURABLE_RUNTIME_EXIT.verified;
|
|
261
|
+
}
|
|
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;
|
|
266
|
+
}
|
|
267
|
+
// RUN AS THE CLI ONLY WHEN THIS FILE IS THE PROCESS ENTRY. Decided by an argv comparison, as in
|
|
268
|
+
// meta-bridge-hook-codex.ts, and not by the `main` flag on import.meta: that flag landed in Node 24.2.0
|
|
269
|
+
// and the package floor is major 24, so on 24.0/24.1 it is undefined — `resolve` would exit 0 having
|
|
270
|
+
// printed nothing, which setup reads as PASS and the launcher as a resolver.
|
|
271
|
+
const invokedDirectly = (() => {
|
|
272
|
+
const entry = process.argv[1];
|
|
273
|
+
if (typeof entry !== "string" || entry.length === 0)
|
|
274
|
+
return false;
|
|
275
|
+
try {
|
|
276
|
+
return fs.realpathSync(entry) === fs.realpathSync(import.meta.filename);
|
|
277
|
+
}
|
|
278
|
+
catch {
|
|
279
|
+
// Bounded environment probe: an entry that cannot be resolved is not this file.
|
|
280
|
+
return false;
|
|
281
|
+
}
|
|
282
|
+
})();
|
|
283
|
+
if (invokedDirectly) {
|
|
284
|
+
process.exitCode = runPiDurableRuntimeCli(process.argv.slice(2), process.env, piDurablePackageLayout(), {
|
|
285
|
+
stdout: (line) => process.stdout.write(`${line}\n`),
|
|
286
|
+
stderr: (line) => process.stderr.write(`${line}\n`),
|
|
287
|
+
});
|
|
288
|
+
}
|