@junghanacs/entwurf 0.41.1 → 0.45.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 +2 -1
- package/BASELINE.md +7 -5
- package/CHANGELOG.md +344 -0
- package/DELIVERY.md +37 -20
- package/ENTWURF.json +236 -0
- package/README.md +75 -65
- package/VERIFY.md +9 -9
- package/docs/external-mcp-host.md +1 -1
- package/docs/setup-clean-host.md +59 -33
- package/mcp/entwurf-bridge/dist/ENTWURF.json +236 -0
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +32 -190
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/agent-guidance.js +299 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-self-address.js +10 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +23 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-decider.js +7 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +23 -24
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-visible-resume.js +15 -11
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-composition.js +21 -10
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-fresh-call.js +4 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-sender-identity.js +5 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +29 -24
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-resume-call.js +18 -16
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/native-push/register.js +13 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/omp-fresh-preflight.js +2 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-durable-fresh-preflight.js +1 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/remote-pair-receive.js +25 -17
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/remote-pair-send.js +101 -32
- package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-omp.js +57 -7
- package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-pi-durable.js +23 -45
- package/mcp/entwurf-bridge/dist/scripts/remote-pair-receive.js +12 -12
- package/mcp/entwurf-bridge/src/index.ts +126 -321
- package/native/win32-x64/entwurf_win.node +0 -0
- package/native/win32-x64/manifest.json +2 -2
- package/package.json +5 -4
- package/pi-extensions/lib/agent-guidance.ts +341 -0
- package/pi-extensions/lib/entwurf-self-address.ts +13 -0
- package/pi-extensions/lib/entwurf-v2-contract.ts +25 -2
- package/pi-extensions/lib/entwurf-v2-decider.ts +7 -0
- package/pi-extensions/lib/entwurf-v2-surface.ts +27 -30
- package/pi-extensions/lib/entwurf-v2-visible-resume.ts +15 -16
- package/pi-extensions/lib/fresh-call-composition.ts +21 -10
- package/pi-extensions/lib/herdr-fresh-call.ts +4 -2
- package/pi-extensions/lib/meta-sender-identity.ts +4 -0
- package/pi-extensions/lib/mux-fresh-call.ts +29 -30
- package/pi-extensions/lib/mux-resume-call.ts +18 -19
- package/pi-extensions/lib/native-push/register.ts +17 -2
- package/pi-extensions/lib/omp-fresh-preflight.ts +2 -2
- package/pi-extensions/lib/pi-durable-fresh-preflight.ts +1 -1
- package/pi-extensions/lib/remote-pair-receive.ts +27 -19
- package/pi-extensions/lib/remote-pair-send.ts +124 -34
- package/pi-extensions/meta-bridge-omp.ts +69 -7
- package/pi-extensions/meta-bridge-pi-durable.ts +23 -64
- package/run.sh +182 -22
- package/scripts/build-bridge.sh +10 -0
- package/scripts/check-agy-sender-identity.ts +6 -4
- package/scripts/check-capability-bundle-reach.ts +86 -0
- package/scripts/check-entwurf-self-address.ts +37 -6
- package/scripts/check-entwurf-v2-contract.ts +24 -2
- package/scripts/check-entwurf-v2-production.ts +45 -31
- package/scripts/check-entwurf-v2-surface.ts +94 -98
- package/scripts/check-gate-qualification.ts +17 -13
- package/scripts/check-mux-resume-call.ts +20 -3
- package/scripts/check-native-push-adapter.ts +4 -0
- package/scripts/check-native-push-register.ts +32 -82
- package/scripts/check-remote-pair-installed.ts +2 -2
- package/scripts/dev-bin.sh +112 -1
- package/scripts/lib/chain-terminus-publication.ts +58 -0
- package/scripts/lib/codex-fresh-live-protocol.ts +30 -0
- package/scripts/lib/codex-fresh-source-receipts.ts +112 -3
- package/scripts/lib/pi-durable-fresh-live-receipts.ts +29 -1
- package/scripts/mutants/agent-guidance.json +160 -0
- package/scripts/mutants/bridge-boot-resume.json +6 -8
- package/scripts/mutants/capability-bundle-reach.json +124 -0
- package/scripts/mutants/chain-terminus-publication.json +17 -0
- package/scripts/mutants/codex-native.json +98 -0
- package/scripts/mutants/control-send-receipt.json +23 -6
- package/scripts/mutants/mux-fresh-call.json +30 -4
- package/scripts/mutants/mux-resume-call.json +28 -3
- package/scripts/mutants/native-push-paused.json +17 -0
- package/scripts/mutants/omp-fresh.json +13 -2
- package/scripts/mutants/pack-install.json +46 -0
- package/scripts/mutants/pi-durable.json +17 -5
- package/scripts/mutants/remote-pair-display.json +3 -3
- package/scripts/mutants/remote-pair-receive.json +41 -11
- package/scripts/mutants/remote-pair-roundtrip.json +67 -7
- package/scripts/mutants/remote-pair-send.json +177 -19
- package/scripts/mutants/remote-pair-transport.json +4 -4
- package/scripts/mutants/self-address.json +10 -10
- package/scripts/mutants/source-install.json +66 -0
- package/scripts/mutants/v2-surface.json +50 -64
- package/scripts/mutants/v2-visible-resume.json +11 -0
- package/scripts/remote-pair-receive.ts +12 -12
- package/scripts/smoke-agy-install-state.sh +121 -0
- package/scripts/smoke-agy-native-push-live.ts +13 -0
- package/scripts/smoke-codex-fresh-live.ts +50 -27
- package/scripts/smoke-entwurf-chain-live.ts +7 -9
- package/scripts/smoke-herdr-fresh-call-live.ts +4 -4
- package/scripts/smoke-herdr-plugin-build-live.ts +179 -50
- package/scripts/smoke-mux-lifecycle-live.ts +140 -8
- package/scripts/smoke-omp-fresh-live.ts +5 -4
- package/scripts/smoke-pi-durable-fresh-live.ts +29 -7
- package/scripts/smoke-setup-verdict.sh +27 -2
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
* fenced `XDG_CACHE_HOME`, joined to a session by its EXACT `sessionId` — plus
|
|
46
46
|
* entwurf's own mailbox artifact and hook journal. It proves the public call, the
|
|
47
47
|
* identity join, the delivered callback, the caller's continuation, the child's
|
|
48
|
-
*
|
|
48
|
+
* callback before any work (a read-only check may precede it), and the codex named reject. It does NOT prove the receipt text or
|
|
49
49
|
* the final answer content: those have no source artifact on this axis and are
|
|
50
50
|
* left to GLG's manual validation rather than substituted with a screen read.
|
|
51
51
|
*
|
|
@@ -853,7 +853,7 @@ async function main(): Promise<void> {
|
|
|
853
853
|
);
|
|
854
854
|
}
|
|
855
855
|
|
|
856
|
-
// ── the child
|
|
856
|
+
// ── the child called back before its work ───────────────────────────────────
|
|
857
857
|
const childNative = String(joined?.nativeSessionId ?? childRow?.sessionValue ?? "");
|
|
858
858
|
if (cell.childKind === "pi") {
|
|
859
859
|
const childRecords = transcriptRecords(String(childRow?.sessionValue ?? ""));
|
|
@@ -901,8 +901,8 @@ async function main(): Promise<void> {
|
|
|
901
901
|
// BOTH. `[측정 2026-09-17, oracle, LIVE]` this cell used to require `order[0] ===
|
|
902
902
|
// "entwurf_v2"`, and a Sonnet 5 child failed it by calling `entwurf_peers` first and
|
|
903
903
|
// the callback second. That is not a violation: the framing this rail now sends
|
|
904
|
-
// OFFERS exactly that corroboration ("you
|
|
905
|
-
//
|
|
904
|
+
// OFFERS exactly that corroboration ("you may corroborate the caller before that
|
|
905
|
+
// call"), so the old oracle contradicted our own prompt and would have forbidden
|
|
906
906
|
// the behaviour we asked for. What still must hold is everything the claim was
|
|
907
907
|
// actually about — the callback lands before any work, it completes, and neither the
|
|
908
908
|
// callback verb nor the delivery verb failed or timed out anywhere in the log.
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
* OUT of `pnpm check`. Needs `LIVE=1`, a `herdr` binary, `git`, `npm` — and the NETWORK, because a
|
|
6
6
|
* git-spec pack builds the bridge through `prepare`, which installs devDependencies from the
|
|
7
7
|
* registry. That is the axis `check-herdr-plugin-build` cannot have and why it does not claim it.
|
|
8
|
+
* The two sandbox commits below also need the host's `pnpm` and its already-populated store: that
|
|
9
|
+
* pnpm serves only those test clones' ordinary commit hooks, never the product's git-spec `prepare`.
|
|
8
10
|
*
|
|
9
11
|
* WHAT MAKES IT HERMETIC ANYWAY. Two substitutions, and only two. HOME/XDG are a mkdtemp sandbox,
|
|
10
12
|
* so Herdr's registry, its managed checkout, our runtime, our ledger and both harnesses' wiring all
|
|
@@ -29,7 +31,7 @@
|
|
|
29
31
|
* | 1. remote-commit AVAILABLE | real `herdr plugin install` | the Herdr journey: one install, runtime at the stable root keyed by the commit Herdr resolved, Pi wired at the absolute bridge, ledger `active` |
|
|
30
32
|
* | 2. REINSTALL widening | real `herdr plugin install` | the Herdr journey: Pi reconciled JSON-identical, Claude added as exactly ONE counted owner entry (real vendor CLI), OpenCode given zero Entwurf state |
|
|
31
33
|
* | 3. remote-commit UNAVAILABLE | the shipped runtime leaf, called directly with the FIXED product argv | acquisition-leaf evidence only — a remote that cannot serve a commit. Routing it through Herdr would add a second explanation for one red, so it does not, and it does not call itself a journey |
|
|
32
|
-
* | 4. POST-BUILD GAP | real `herdr plugin install` | the Herdr journey: a
|
|
34
|
+
* | 4. POST-BUILD GAP | real `herdr plugin install` | the Herdr journey: after our runner finished with 0, a test-owned probe leaves one semantic manifest change on disk (its receipt says so); the install then exits non-zero and Herdr's registration does not move, while the runtime and the rebound activation REMAIN, and the next normal install re-binds to HEAD. Nothing here calls that atomic, and the receipt does not name which Herdr check refused |
|
|
33
35
|
*/
|
|
34
36
|
|
|
35
37
|
import assert from "node:assert/strict";
|
|
@@ -111,6 +113,38 @@ const configuredHooksPath = (
|
|
|
111
113
|
""
|
|
112
114
|
).trim();
|
|
113
115
|
|
|
116
|
+
// ── EACH TEST CLONE GETS ITS OWN FROZEN DEPENDENCIES BEFORE ITS ORDINARY COMMIT ─────────────
|
|
117
|
+
//
|
|
118
|
+
// The operator's hook chain reaches the repo's `.husky/pre-commit`, which runs `pnpm lint` and
|
|
119
|
+
// `pnpm typecheck`, and COMMIT_ENV carries the operator's strict `verify-deps-before-run`. A fresh
|
|
120
|
+
// clone has no `node_modules`, so that strict pnpm refuses (measured: ERR_PNPM_VERIFY_DEPS_BEFORE_RUN
|
|
121
|
+
// at the snapshot commit). The clone installs its OWN dependencies from its own lockfile: frozen,
|
|
122
|
+
// offline (a package missing from the store is a named red, never a network fallback) and
|
|
123
|
+
// ignore-scripts (no `prepare` build here). Never the parent's `node_modules` by symlink or copy,
|
|
124
|
+
// which would inherit another workspace's state, and never a relaxed hook environment.
|
|
125
|
+
const storeQuery = spawnSync("pnpm", ["store", "path"], { encoding: "utf8", env: COMMIT_ENV, cwd: REPO });
|
|
126
|
+
assert.ok(
|
|
127
|
+
!storeQuery.error && storeQuery.status === 0,
|
|
128
|
+
`pnpm store path failed (test-clone hooks need pnpm): status=${storeQuery.status} error=${storeQuery.error?.message ?? "none"} stderr=${storeQuery.stderr ?? ""}`,
|
|
129
|
+
);
|
|
130
|
+
const STORE_DIR = (storeQuery.stdout ?? "").trim();
|
|
131
|
+
assert.ok(
|
|
132
|
+
path.isAbsolute(STORE_DIR),
|
|
133
|
+
`pnpm store path did not answer an absolute directory: ${JSON.stringify(STORE_DIR)}`,
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
function prepareCloneDeps(dir: string, tag: string): void {
|
|
137
|
+
const installed = spawnSync(
|
|
138
|
+
"pnpm",
|
|
139
|
+
["install", "--frozen-lockfile", "--offline", "--ignore-scripts", "--store-dir", STORE_DIR],
|
|
140
|
+
{ encoding: "utf8", env: COMMIT_ENV, cwd: dir, timeout: 900_000 },
|
|
141
|
+
);
|
|
142
|
+
assert.ok(
|
|
143
|
+
!installed.error && installed.status === 0,
|
|
144
|
+
`${tag} clone dependencies (frozen, offline, ignore-scripts, store ${STORE_DIR}) failed: status=${installed.status} error=${installed.error?.message ?? "none"} ${installed.stderr || installed.stdout}`,
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
114
148
|
/**
|
|
115
149
|
* One ordinary commit, with its hook invocation OBSERVED rather than steered: `GIT_TRACE2_EVENT`
|
|
116
150
|
* writes git's own child-process events to a sandbox file, and the assertion reads that file for a
|
|
@@ -213,6 +247,7 @@ if (candidatePatch.trim().length > 0) {
|
|
|
213
247
|
fs.writeFileSync(patchFile, candidatePatch);
|
|
214
248
|
const applied = run("git", ["-C", SNAPSHOT, "apply", "--index", patchFile], env);
|
|
215
249
|
assert.equal(applied.status, 0, `the candidate patch did not apply to the snapshot clone: ${applied.stderr}`);
|
|
250
|
+
prepareCloneDeps(SNAPSHOT, "snapshot");
|
|
216
251
|
snapshotHook = commitInClone(SNAPSHOT, "candidate snapshot under test", "snapshot").hookTrace;
|
|
217
252
|
// LOAD-BEARING, before the mirror is made from it: if a hook rewrote a file, or the patch left
|
|
218
253
|
// anything behind, the mirror would carry bytes the candidate does not have.
|
|
@@ -254,14 +289,59 @@ function readJson(file: string): Record<string, unknown> {
|
|
|
254
289
|
function integrationInstall(atom: string) {
|
|
255
290
|
return run("herdr", ["integration", "install", atom], env);
|
|
256
291
|
}
|
|
257
|
-
function pluginInstall(ref: string) {
|
|
258
|
-
return run("herdr", ["plugin", "install", PLUGIN_SPEC, "--ref", ref, "--yes"], env);
|
|
292
|
+
function pluginInstall(ref: string, extraEnv: NodeJS.ProcessEnv = {}) {
|
|
293
|
+
return run("herdr", ["plugin", "install", PLUGIN_SPEC, "--ref", ref, "--yes"], { ...env, ...extraEnv });
|
|
294
|
+
}
|
|
295
|
+
/** One git read whose failure is named: a spawn error, a non-zero status or an empty answer. */
|
|
296
|
+
function gitRead(dir: string, argv: string[], what: string): string {
|
|
297
|
+
const r = run("git", ["-C", dir, ...argv], env);
|
|
298
|
+
const out = (r.stdout ?? "").trim();
|
|
299
|
+
assert.ok(
|
|
300
|
+
!r.error && r.status === 0 && out !== "",
|
|
301
|
+
`${what} could not be read (git ${argv.join(" ")} in ${dir}): status=${r.status} error=${r.error?.message ?? "none"} ${r.stderr ?? ""}`,
|
|
302
|
+
);
|
|
303
|
+
return out;
|
|
259
304
|
}
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Herdr's OWN registration names where it checked this plugin out: `plugins.json` in its config
|
|
308
|
+
* directory holds one entry per installed plugin, and `plugin_root` is the manifest directory
|
|
309
|
+
* INSIDE that managed checkout. This fixture used to scan `plugins/github/<id>*`; on 0.9.5 that
|
|
310
|
+
* directory did not exist (observed: `plugins/github-installations/<id>-<hash>/<generation>/checkout`)
|
|
311
|
+
* and the scan answered "no checkout" with an empty HEAD instead of failing. So the path is READ
|
|
312
|
+
* from the registration rather than assembled from a layout. That holds only while the registry
|
|
313
|
+
* sits at `plugins.json` with its `plugin_id` / `plugin_root` fields — neither is re-measured on
|
|
314
|
+
* every Herdr — and when it stops holding the asserts below name it. Zero or several entries for
|
|
315
|
+
* this id, a relative root, or a checkout that resolves outside this sandbox's herdr config is red.
|
|
316
|
+
*/
|
|
317
|
+
function managedCheckoutRoot(): string {
|
|
318
|
+
const herdrConfig = path.join(env.XDG_CONFIG_HOME as string, "herdr");
|
|
319
|
+
const registry = path.join(herdrConfig, "plugins.json");
|
|
320
|
+
assert.ok(fs.existsSync(registry), `herdr left no plugin registry at ${registry}`);
|
|
321
|
+
const entries: unknown = JSON.parse(fs.readFileSync(registry, "utf8"));
|
|
322
|
+
assert.ok(Array.isArray(entries), `herdr's plugin registry ${registry} is not a list`);
|
|
323
|
+
const mine = entries.filter((e) => (e as { plugin_id?: unknown } | null)?.plugin_id === PLUGIN_ID);
|
|
324
|
+
assert.equal(
|
|
325
|
+
mine.length,
|
|
326
|
+
1,
|
|
327
|
+
`herdr's plugin registry ${registry} holds ${mine.length} entries for ${PLUGIN_ID}, not exactly one`,
|
|
328
|
+
);
|
|
329
|
+
const pluginRoot = (mine[0] as { plugin_root?: unknown }).plugin_root;
|
|
330
|
+
assert.ok(
|
|
331
|
+
typeof pluginRoot === "string" && path.isAbsolute(pluginRoot),
|
|
332
|
+
`the ${PLUGIN_ID} registration in ${registry} has no absolute plugin_root: ${JSON.stringify(pluginRoot)}`,
|
|
333
|
+
);
|
|
334
|
+
const root = gitRead(pluginRoot, ["rev-parse", "--show-toplevel"], "the registered plugin_root's git root");
|
|
335
|
+
// Compared by REAL path: a lexical check would pass a sandbox-spelled link whose target is the
|
|
336
|
+
// operator's own repo, and once the candidate lands that repo's HEAD is the HEAD under test.
|
|
337
|
+
const realConfig = fs.realpathSync(herdrConfig);
|
|
338
|
+
const realRoot = fs.realpathSync(root);
|
|
339
|
+
const inside = path.relative(realConfig, realRoot);
|
|
340
|
+
assert.ok(
|
|
341
|
+
inside !== "" && inside !== ".." && !inside.startsWith(`..${path.sep}`) && !path.isAbsolute(inside),
|
|
342
|
+
`the registered checkout ${root} (real ${realRoot}) is not inside this sandbox's herdr config ${realConfig}`,
|
|
343
|
+
);
|
|
344
|
+
return root;
|
|
265
345
|
}
|
|
266
346
|
|
|
267
347
|
try {
|
|
@@ -277,17 +357,13 @@ try {
|
|
|
277
357
|
const journal = fs.existsSync(journalPath) ? readJson(journalPath) : null;
|
|
278
358
|
const ledger = fs.existsSync(ledgerPath) ? readJson(ledgerPath) : null;
|
|
279
359
|
const identity = (journal?.artifactIdentity ?? {}) as Record<string, string>;
|
|
280
|
-
|
|
281
|
-
//
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
const checkoutHead =
|
|
286
|
-
|
|
287
|
-
const shallow =
|
|
288
|
-
checkoutRoot === ""
|
|
289
|
-
? "no-checkout"
|
|
290
|
-
: (run("git", ["-C", checkoutRoot, "rev-parse", "--is-shallow-repository"], env).stdout ?? "").trim();
|
|
360
|
+
// The install is asserted BEFORE the checkout is located, so a failed install is reported as
|
|
361
|
+
// itself and not as the missing registration it would leave behind.
|
|
362
|
+
assert.equal(first.status, 0, `the first herdr plugin install failed: ${first.stderr || first.stdout}`);
|
|
363
|
+
// The manifest root is a SUBDIRECTORY of the clone, so its git root is asked for, not guessed.
|
|
364
|
+
const checkoutRoot = managedCheckoutRoot();
|
|
365
|
+
const checkoutHead = gitRead(checkoutRoot, ["rev-parse", "HEAD"], "the managed checkout's HEAD");
|
|
366
|
+
const shallow = gitRead(checkoutRoot, ["rev-parse", "--is-shallow-repository"], "the managed checkout's shallowness");
|
|
291
367
|
const piAfterFirst = readJson(piSettings);
|
|
292
368
|
const provider = (piAfterFirst.entwurfProvider ?? {}) as Record<string, unknown>;
|
|
293
369
|
const providerText = JSON.stringify(provider);
|
|
@@ -443,35 +519,66 @@ try {
|
|
|
443
519
|
const scratch = path.join(ROOT, "scratch");
|
|
444
520
|
assert.equal(run("git", ["clone", "--quiet", MIRROR, scratch], env).status, 0, "scratch clone");
|
|
445
521
|
assert.equal(run("git", ["-C", scratch, "checkout", "--quiet", "-b", "gapprobe", HEAD], env).status, 0, "branch");
|
|
446
|
-
// A
|
|
447
|
-
//
|
|
522
|
+
// A test-owned probe makes one SEMANTIC manifest change after our runner has finished, and the
|
|
523
|
+
// cell then asserts the install's exit and Herdr's registration. HISTORY, not this run's
|
|
524
|
+
// evidence: at the time this cell was written herdr re-read the manifest after the build and
|
|
525
|
+
// compared the PARSED struct (`ensure_manifest_unchanged_after_build`, read at src/cli/plugin.rs),
|
|
526
|
+
// so only a semantic change could matter; that is not re-measured on the pinned Herdr and is
|
|
527
|
+
// not named as the cause here. Until 2026-10-23 the probe replaced the literal
|
|
528
|
+
// `version = "0.1.0"`, which the manifest stopped carrying at dd84ac0 — so it silently changed
|
|
529
|
+
// nothing. The new version is now DERIVED at build time from the manifest Herdr checked out.
|
|
448
530
|
const runnerPath = path.join(scratch, "plugins", "herdr", "lib", "build.mjs");
|
|
449
531
|
// The mutation has to run BEFORE the runner's process exits, so it is INSERTED INTO the entry
|
|
450
|
-
// block rather than appended after it
|
|
451
|
-
//
|
|
452
|
-
//
|
|
453
|
-
//
|
|
454
|
-
//
|
|
455
|
-
// plugin version is exactly that.
|
|
532
|
+
// block rather than appended after it (measured earlier: appended top-level code never ran,
|
|
533
|
+
// because `process.exit(runBuild(...))` ends the process first). That block is inside the
|
|
534
|
+
// runner's own try/catch, so a probe guard that throws there ALSO ends in a non-zero build —
|
|
535
|
+
// which would look exactly like the expected gap. The receipt below is what tells them apart:
|
|
536
|
+
// it is written only after the runner's code is known and the rewritten manifest read back.
|
|
456
537
|
const runnerSource = fs.readFileSync(runnerPath, "utf8");
|
|
457
538
|
const entry = "\t\tprocess.exit(runBuild(process.env));";
|
|
458
|
-
assert.
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
"\t\t\tprobeManifest,",
|
|
469
|
-
'\t\t\tfs.readFileSync(probeManifest, "utf8").replace(\'version = "0.1.0"\', \'version = "0.1.1"\'),',
|
|
470
|
-
"\t\t);",
|
|
471
|
-
"\t\tprocess.exit(probeCode);",
|
|
472
|
-
].join("\n"),
|
|
473
|
-
),
|
|
539
|
+
assert.equal(
|
|
540
|
+
runnerSource.split(entry).length - 1,
|
|
541
|
+
1,
|
|
542
|
+
"setup: the runner's entry block is not exactly once the shape this probe patches",
|
|
543
|
+
);
|
|
544
|
+
const scratchManifest = fs.readFileSync(path.join(scratch, "plugins", "herdr", "herdr-plugin.toml"), "utf8");
|
|
545
|
+
assert.equal(
|
|
546
|
+
(scratchManifest.match(/^version = "\d+\.\d+\.\d+"$/gm) ?? []).length,
|
|
547
|
+
1,
|
|
548
|
+
'setup: the manifest does not carry exactly one `version = "X.Y.Z"` line for the probe to rewrite',
|
|
474
549
|
);
|
|
550
|
+
const probedSource = runnerSource.replace(
|
|
551
|
+
entry,
|
|
552
|
+
[
|
|
553
|
+
"\t\tconst probeCode = runBuild(process.env);",
|
|
554
|
+
"\t\t// gap probe (test-owned): one semantic manifest change after a finished runner, then a receipt.",
|
|
555
|
+
"\t\tconst probeReceipt = process.env.HPBL_GAP_PROBE_RECEIPT;",
|
|
556
|
+
"\t\tif (!probeReceipt) {",
|
|
557
|
+
'\t\t\tthrow new Error("gap probe: HPBL_GAP_PROBE_RECEIPT did not reach the build");',
|
|
558
|
+
"\t\t}",
|
|
559
|
+
'\t\tconst probeManifest = path.join(PLUGIN_DIR, "herdr-plugin.toml");',
|
|
560
|
+
'\t\tconst probeBefore = fs.readFileSync(probeManifest, "utf8");',
|
|
561
|
+
'\t\tconst probeLines = probeBefore.match(/^version = "\\d+\\.\\d+\\.\\d+"$/gm) ?? [];',
|
|
562
|
+
"\t\tif (probeLines.length !== 1) {",
|
|
563
|
+
'\t\t\tthrow new Error("gap probe: " + probeLines.length + " version lines, not exactly one");',
|
|
564
|
+
"\t\t}",
|
|
565
|
+
"\t\tconst probeFrom = probeLines[0];",
|
|
566
|
+
"\t\tconst probeTo = probeFrom.replace(/(\\d+)\"$/, (_match, patch) => Number(patch) + 1 + '\"');",
|
|
567
|
+
'\t\tconst probeAfter = probeBefore.replace(/^version = "\\d+\\.\\d+\\.\\d+"$/m, probeTo);',
|
|
568
|
+
"\t\tif (probeAfter === probeBefore || probeFrom === probeTo) {",
|
|
569
|
+
'\t\t\tthrow new Error("gap probe: the version rewrite changed nothing");',
|
|
570
|
+
"\t\t}",
|
|
571
|
+
"\t\tfs.writeFileSync(probeManifest, probeAfter);",
|
|
572
|
+
'\t\tif (fs.readFileSync(probeManifest, "utf8") !== probeAfter) {',
|
|
573
|
+
'\t\t\tthrow new Error("gap probe: the rewritten manifest did not read back");',
|
|
574
|
+
"\t\t}",
|
|
575
|
+
"\t\tfs.writeFileSync(probeReceipt, JSON.stringify({ probeCode, from: probeFrom, to: probeTo }));",
|
|
576
|
+
"\t\tprocess.exit(probeCode);",
|
|
577
|
+
].join("\n"),
|
|
578
|
+
);
|
|
579
|
+
assert.notEqual(probedSource, runnerSource, "setup: patching the runner's entry block changed nothing");
|
|
580
|
+
fs.writeFileSync(runnerPath, probedSource);
|
|
581
|
+
prepareCloneDeps(scratch, "gapprobe");
|
|
475
582
|
const gapHook = commitInClone(scratch, "gap probe", "gapprobe").hookTrace;
|
|
476
583
|
assertExactlyClean(scratch, "the gap-probe scratch clone");
|
|
477
584
|
assert.equal(run("git", ["-C", scratch, "push", "--quiet", MIRROR, "gapprobe"], env).status, 0, "push probe");
|
|
@@ -482,8 +589,29 @@ try {
|
|
|
482
589
|
activatedBackends: string[];
|
|
483
590
|
components: { backend: string; state: string }[];
|
|
484
591
|
};
|
|
485
|
-
|
|
592
|
+
// The receipt lives in this sandbox ROOT, never in the checkout Herdr may delete, and its path
|
|
593
|
+
// reaches ONLY this install — with no default, so a build that does not receive it writes none.
|
|
594
|
+
const receiptPath = path.join(ROOT, "gap-probe-receipt.json");
|
|
595
|
+
assert.ok(!fs.existsSync(receiptPath), `setup: a gap-probe receipt already exists at ${receiptPath}`);
|
|
596
|
+
const gapRun = pluginInstall("gapprobe", { HPBL_GAP_PROBE_RECEIPT: receiptPath });
|
|
486
597
|
const registryAfter = run("herdr", ["plugin", "list"], env).stdout ?? "";
|
|
598
|
+
const gapTail = JSON.stringify((gapRun.stderr || gapRun.stdout || "").trim().slice(-300));
|
|
599
|
+
const receiptText = fs.existsSync(receiptPath) ? fs.readFileSync(receiptPath, "utf8") : null;
|
|
600
|
+
type GapReceipt = { probeCode?: unknown; from?: unknown; to?: unknown };
|
|
601
|
+
const receipt: GapReceipt | null = receiptText === null ? null : JSON.parse(receiptText);
|
|
602
|
+
// Checked HERE, before the reconcile: without a completed-mutation receipt a non-zero install is
|
|
603
|
+
// a probe or setup failure, never the expected gap. One receipt is evidence that ONE attempt
|
|
604
|
+
// finished the mutation — not that the build ran once, and not which Herdr check refused.
|
|
605
|
+
assert.ok(
|
|
606
|
+
receipt !== null &&
|
|
607
|
+
receipt.probeCode === 0 &&
|
|
608
|
+
typeof receipt.from === "string" &&
|
|
609
|
+
typeof receipt.to === "string" &&
|
|
610
|
+
receipt.from !== receipt.to,
|
|
611
|
+
`[${LABEL}] POST-BUILD GAP precondition: no receipt of a finished runner (code 0) followed by a real ` +
|
|
612
|
+
`manifest change, so this install's exit is NOT read as the gap (receipt=${receiptText ?? "absent"} ` +
|
|
613
|
+
`gap-install=${gapRun.status} herdr-tail=${gapTail})`,
|
|
614
|
+
);
|
|
487
615
|
// The gap commit is a DIFFERENT artifact, so the ledger cannot be byte-identical here and
|
|
488
616
|
// claiming it would be false: our runner ran to completion, which means it legally rebound the
|
|
489
617
|
// activation to that commit. What Herdr then failed to do is its own registration.
|
|
@@ -503,12 +631,13 @@ try {
|
|
|
503
631
|
const noDuplicates = (l: { components: { backend: string }[] }) =>
|
|
504
632
|
new Set(l.components.map((c) => c.backend)).size === l.components.length;
|
|
505
633
|
ok(
|
|
506
|
-
`[${LABEL}] POST-BUILD GAP:
|
|
507
|
-
"
|
|
508
|
-
"— the runtime is the gap commit and the ledger has legally rebound to it,
|
|
509
|
-
"Herdr has no cleanup hook to call. The next normal install re-binds to HEAD's
|
|
510
|
-
"same components rather than duplicating them. The gap is NAMED, not atomic
|
|
511
|
-
|
|
634
|
+
`[${LABEL}] POST-BUILD GAP: after our runner finished with 0 and a test-owned probe left one semantic ` +
|
|
635
|
+
"manifest change on disk, the install exits non-zero and Herdr's PREVIOUS registration stands while what " +
|
|
636
|
+
"our runner already did REMAINS — the runtime is the gap commit and the ledger has legally rebound to it, " +
|
|
637
|
+
"active and retained, because Herdr has no cleanup hook to call. The next normal install re-binds to HEAD's " +
|
|
638
|
+
"identity and reconciles the same components rather than duplicating them. The gap is NAMED, not atomic; " +
|
|
639
|
+
"which Herdr check refused is read from its own output below, not claimed (receipt=" +
|
|
640
|
+
`${receiptText} herdr-tail=${gapTail} gap-commit-hooks=${gapHook} gap-install=${gapRun.status} registry-unchanged=${registryBefore === registryAfter} before=${ledgerBefore.artifactIdentity.commit?.slice(0, 8)} after-gap=${ledgerAfterGap.phase}/${ledgerAfterGap.artifactIdentity.commit?.slice(0, 8)}=${gapCommit.slice(0, 8)} reconcile=${reconcile.status}/${ledgerAfterReconcile.phase}/${ledgerAfterReconcile.artifactIdentity.commit?.slice(0, 8)}=${HEAD.slice(0, 8)} backends=${JSON.stringify(ledgerAfterReconcile.activatedBackends)} no-dupes=${noDuplicates(ledgerAfterGap)}/${noDuplicates(ledgerAfterReconcile)})`,
|
|
512
641
|
gapRun.status !== 0 &&
|
|
513
642
|
registryBefore === registryAfter &&
|
|
514
643
|
ledgerAfterGap.phase === "active" &&
|
|
@@ -13,12 +13,15 @@
|
|
|
13
13
|
* itself. So this gate enters where a caller enters, through a real MCP `tools/call`, and follows
|
|
14
14
|
* one citizen all the way around:
|
|
15
15
|
*
|
|
16
|
-
* tools/call entwurf_fresh_call → LAUNCH receipt (tmux coordinates + nonce, nothing else)
|
|
17
|
-
*
|
|
16
|
+
* tools/call entwurf_fresh_call → LAUNCH receipt (tmux coordinates + nonce, nothing else);
|
|
17
|
+
* the native pi cell names placement.tmuxSession, and the
|
|
18
|
+
* harness asks tmux itself that the pane sits in that seat
|
|
19
|
+
* the sibling's pre-task callback → CALLBACK receipt (its garden id, as the sender envelope of
|
|
18
20
|
* the nonce message — never a lookup, never a self-report)
|
|
19
21
|
* tools/call entwurf_v2 f-a-f → LIVE SEND receipt (control-socket, into a running session)
|
|
20
22
|
* tools/call entwurf_resume_call → REFUSED while the citizen is live, with no window opened
|
|
21
23
|
* closeWindow(launch handle) → dormant: pane gone, socket unconnectable, record preserved
|
|
24
|
+
* (native cell: a cross-session close; the seat's anchor stands)
|
|
22
25
|
* tools/call entwurf_v2 f-a-f → refused honestly, because dormant is unreachable by delivery
|
|
23
26
|
* tools/call entwurf_resume_call → RESUME LAUNCH + RESUME OBSERVATION receipts, kept apart
|
|
24
27
|
* tools/call entwurf_v2 f-a-f → the resumed citizen recalls the fact from BEFORE the close
|
|
@@ -199,6 +202,25 @@ function tmux(socket: string, args: string[], env: NodeJS.ProcessEnv): { status:
|
|
|
199
202
|
return { status: r.status, stdout: (r.stdout ?? "").trim() };
|
|
200
203
|
}
|
|
201
204
|
|
|
205
|
+
type TmuxAnswer = ReturnType<typeof tmux>;
|
|
206
|
+
|
|
207
|
+
/** "Unchanged" over two window listings (#108), FAIL-CLOSED: a query that did not answer is never
|
|
208
|
+
* unchanged, because two failed queries print the same nothing. */
|
|
209
|
+
function windowsUnchanged(before: TmuxAnswer, after: TmuxAnswer): boolean {
|
|
210
|
+
return before.status === 0 && after.status === 0 && before.stdout === after.stdout;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** "That window is gone" (#108), FAIL-CLOSED: only an inventory tmux actually answered can prove
|
|
214
|
+
* an absence — a failed query lists nothing, which would otherwise read as "not listed". */
|
|
215
|
+
function windowAbsent(inventory: TmuxAnswer, windowId: string): boolean {
|
|
216
|
+
return inventory.status === 0 && !inventory.stdout.split("\n").includes(windowId);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** The diagnostic line for one listing: which query, and what tmux answered. */
|
|
220
|
+
function listingDetail(query: string, answer: TmuxAnswer): string {
|
|
221
|
+
return `--- ${query} → status ${answer.status} ---\n${answer.stdout || "(empty)"}`;
|
|
222
|
+
}
|
|
223
|
+
|
|
202
224
|
function onPath(cmd: string): boolean {
|
|
203
225
|
return spawnSync("sh", ["-c", `command -v ${cmd}`], { encoding: "utf8" }).status === 0;
|
|
204
226
|
}
|
|
@@ -216,8 +238,8 @@ function pidIsAlive(pid: number): boolean {
|
|
|
216
238
|
/**
|
|
217
239
|
* What the launched window actually shows, read at the moment a nonce callback never arrived.
|
|
218
240
|
*
|
|
219
|
-
* The launch receipt says so itself — "if it never comes
|
|
220
|
-
* directly" — but on a headless release-gate run nobody is there to look, and the window is torn
|
|
241
|
+
* The launch receipt says so itself — "if it never comes — not started, stuck, or held back after its
|
|
242
|
+
* check; this receipt cannot tell which — the window is visible and can be read directly" — but on a headless release-gate run nobody is there to look, and the window is torn
|
|
221
243
|
* down in teardown seconds later. So the harness looks for the operator: is the pane process even
|
|
222
244
|
* alive, and what is painted in it. That separates the three states a bare timeout collapses into
|
|
223
245
|
* one — the runtime never started (or died), it started and is sitting on an error or a prompt, or
|
|
@@ -604,6 +626,7 @@ async function main(): Promise<void> {
|
|
|
604
626
|
cell: string,
|
|
605
627
|
backend: "pi" | "claude-code",
|
|
606
628
|
roots: CellRoots = NATIVE_ROOTS,
|
|
629
|
+
seat?: string,
|
|
607
630
|
): {
|
|
608
631
|
socket: string;
|
|
609
632
|
serverPid: string;
|
|
@@ -650,6 +673,15 @@ async function main(): Promise<void> {
|
|
|
650
673
|
if (tmux(socket, ["new-session", "-d", "-s", "fixture", "-c", scratch, "-n", "anchor"], serverEnv).status !== 0) {
|
|
651
674
|
throw new Error(`${LABEL}: could not start the private ${cell} tmux server at ${socket}`);
|
|
652
675
|
}
|
|
676
|
+
// The optional seat (#108): a SECOND session on this same private server, born with the
|
|
677
|
+
// same server environment as the caller's, so the only difference a seated launch can
|
|
678
|
+
// show is the session it lands in. It dies with this server in teardown, on both paths.
|
|
679
|
+
if (
|
|
680
|
+
seat !== undefined &&
|
|
681
|
+
tmux(socket, ["new-session", "-d", "-s", seat, "-c", scratch, "-n", "anchor"], serverEnv).status !== 0
|
|
682
|
+
) {
|
|
683
|
+
throw new Error(`${LABEL}: could not create the ${cell} seat session ${seat} on ${socket}`);
|
|
684
|
+
}
|
|
653
685
|
const anchorPane = tmux(
|
|
654
686
|
socket,
|
|
655
687
|
["display-message", "-p", "-t", "fixture:anchor", "#{pane_id}"],
|
|
@@ -681,6 +713,23 @@ async function main(): Promise<void> {
|
|
|
681
713
|
return listed.stdout.split("\n").filter((l) => l.trim()).length;
|
|
682
714
|
}
|
|
683
715
|
|
|
716
|
+
/** One session's window ids, by session id — the harness's own tmux question. The status rides
|
|
717
|
+
* with the listing so no caller can read a failed query as an empty session. */
|
|
718
|
+
function sessionWindows(socket: string, sessionId: string, env: NodeJS.ProcessEnv): TmuxAnswer {
|
|
719
|
+
return tmux(socket, ["list-windows", "-t", sessionId, "-F", "#{window_id}"], env);
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/** Every pane of the session NAMED exactly (`=name`), as `session_id window_id pane_id` rows.
|
|
723
|
+
* The seat oracle (#108) asks by name so its id never comes from the receipt under test. */
|
|
724
|
+
function namedSessionPanes(socket: string, name: string, env: NodeJS.ProcessEnv): string[] {
|
|
725
|
+
const listed = tmux(
|
|
726
|
+
socket,
|
|
727
|
+
["list-panes", "-s", "-t", `=${name}`, "-F", "#{session_id} #{window_id} #{pane_id}"],
|
|
728
|
+
env,
|
|
729
|
+
);
|
|
730
|
+
return listed.status === 0 ? listed.stdout.split("\n").filter((l) => l.trim()) : [];
|
|
731
|
+
}
|
|
732
|
+
|
|
684
733
|
/** The pane's ACTUAL start directory, asked by stable pane id in a SEPARATE query.
|
|
685
734
|
* `pane_start_path` rather than `pane_current_path`: measured 2026-08-06, the row a
|
|
686
735
|
* `new-window -P -F` prints is emitted BEFORE the child chdirs, so current-path there
|
|
@@ -700,8 +749,14 @@ async function main(): Promise<void> {
|
|
|
700
749
|
// ════════════════════════════════════════════════════════════════════
|
|
701
750
|
// The pi lifecycle, run once per PROVIDER SHAPE
|
|
702
751
|
// ════════════════════════════════════════════════════════════════════
|
|
703
|
-
async function runPiCell(
|
|
704
|
-
|
|
752
|
+
async function runPiCell(
|
|
753
|
+
cell: string,
|
|
754
|
+
model: string,
|
|
755
|
+
full: boolean,
|
|
756
|
+
roots: CellRoots,
|
|
757
|
+
seat: string | null,
|
|
758
|
+
): Promise<void> {
|
|
759
|
+
const srv = startPrivateServer(cell, "pi", roots, seat ?? undefined);
|
|
705
760
|
const bridge = new BridgeClient(srv.env);
|
|
706
761
|
bridges.push(bridge);
|
|
707
762
|
|
|
@@ -716,6 +771,31 @@ async function main(): Promise<void> {
|
|
|
716
771
|
);
|
|
717
772
|
}
|
|
718
773
|
|
|
774
|
+
// ── the seat, asked of tmux BEFORE the launch (#108) ─────────────
|
|
775
|
+
// Both ids come from the harness's own queries — the seat by its exact name, the caller
|
|
776
|
+
// by the anchor pane the bridge child sits in — so neither can be borrowed from the
|
|
777
|
+
// receipt it is about to judge. The anchor row is kept whole: the close below must leave
|
|
778
|
+
// this exact session, window and pane standing.
|
|
779
|
+
let seatSid = "";
|
|
780
|
+
let callerSid = "";
|
|
781
|
+
let seatAnchor: string[] = [];
|
|
782
|
+
let callerWindowsBefore: TmuxAnswer = { status: null, stdout: "" };
|
|
783
|
+
if (seat !== null) {
|
|
784
|
+
seatAnchor = namedSessionPanes(srv.socket, seat, srv.env);
|
|
785
|
+
seatSid = (seatAnchor[0] ?? "").split(" ")[0] ?? "";
|
|
786
|
+
callerSid = tmux(
|
|
787
|
+
srv.socket,
|
|
788
|
+
["display-message", "-p", "-t", String(srv.env.TMUX_PANE), "#{session_id}"],
|
|
789
|
+
srv.env,
|
|
790
|
+
).stdout;
|
|
791
|
+
ok(
|
|
792
|
+
`${cell}: seat — the private server holds a second session named ${seat} with exactly one anchor pane, and it is not the caller's session (both ids asked of tmux by the harness, before any launch)`,
|
|
793
|
+
seatAnchor.length === 1 && /^\$\d+$/.test(seatSid) && /^\$\d+$/.test(callerSid) && seatSid !== callerSid,
|
|
794
|
+
`--- seat panes ---\n${seatAnchor.join("\n") || "(none)"}\n--- caller session ---\n${callerSid}`,
|
|
795
|
+
);
|
|
796
|
+
callerWindowsBefore = sessionWindows(srv.socket, callerSid, srv.env);
|
|
797
|
+
}
|
|
798
|
+
|
|
719
799
|
// ── receipt 1/4: LAUNCH ─────────────────────────────────────────
|
|
720
800
|
const launch = await bridge.call("entwurf_fresh_call", {
|
|
721
801
|
backend: "pi",
|
|
@@ -723,6 +803,7 @@ async function main(): Promise<void> {
|
|
|
723
803
|
task:
|
|
724
804
|
`Remember this fact for the rest of this session: the color is ${SCENE_FACT}. ` +
|
|
725
805
|
"Then reply with the single word ACK and stop. Do not read files.",
|
|
806
|
+
...(seat === null ? {} : { placement: { tmuxSession: seat } }),
|
|
726
807
|
});
|
|
727
808
|
ok(
|
|
728
809
|
`${cell}: tools/call entwurf_fresh_call returned a launch receipt through the public surface`,
|
|
@@ -738,6 +819,37 @@ async function main(): Promise<void> {
|
|
|
738
819
|
launch.text.includes(model) && launch.text.includes("LAUNCH receipt") && launch.text.includes("does NOT mean"),
|
|
739
820
|
`--- receipt ---\n${launch.text}`,
|
|
740
821
|
);
|
|
822
|
+
if (seat !== null) {
|
|
823
|
+
ok(
|
|
824
|
+
`${cell}: seat — the receipt echoes the REQUESTED seat and resolves it to the seat's own session id, not the caller's`,
|
|
825
|
+
launch.text.includes(`seat: ${seat} (requested tmux session, resolved to ${seatSid})`) &&
|
|
826
|
+
coords.sessionId === seatSid,
|
|
827
|
+
`--- receipt ---\n${launch.text}\n--- seat session (asked by name) ---\n${seatSid}`,
|
|
828
|
+
);
|
|
829
|
+
// The receipt's session id is the target the product CHOSE, not one tmux reported, so
|
|
830
|
+
// it cannot vouch for where the window went. The pane itself is asked instead.
|
|
831
|
+
const seatWindows = sessionWindows(srv.socket, seatSid, srv.env);
|
|
832
|
+
const landed = tmux(
|
|
833
|
+
srv.socket,
|
|
834
|
+
["display-message", "-p", "-t", coords.paneId, "#{session_id} #{window_id}"],
|
|
835
|
+
srv.env,
|
|
836
|
+
).stdout;
|
|
837
|
+
ok(
|
|
838
|
+
`${cell}: seat — tmux agrees: the launched pane sits in the seat session's window, and that window is listed in the seat`,
|
|
839
|
+
landed === `${seatSid} ${coords.windowId}` &&
|
|
840
|
+
seatWindows.status === 0 &&
|
|
841
|
+
seatWindows.stdout.split("\n").includes(coords.windowId),
|
|
842
|
+
`--- pane ${coords.paneId} reports ---\n${landed}\n--- expected ---\n${seatSid} ${coords.windowId}\n` +
|
|
843
|
+
listingDetail(`list-windows -t ${seatSid}`, seatWindows),
|
|
844
|
+
);
|
|
845
|
+
const callerAfterLaunch = sessionWindows(srv.socket, callerSid, srv.env);
|
|
846
|
+
ok(
|
|
847
|
+
`${cell}: seat — the caller's own session holds exactly the windows it held before the launch (both listings answered by tmux)`,
|
|
848
|
+
windowsUnchanged(callerWindowsBefore, callerAfterLaunch),
|
|
849
|
+
`${listingDetail(`list-windows -t ${callerSid} (before launch)`, callerWindowsBefore)}\n` +
|
|
850
|
+
listingDetail(`list-windows -t ${callerSid} (after launch)`, callerAfterLaunch),
|
|
851
|
+
);
|
|
852
|
+
}
|
|
741
853
|
|
|
742
854
|
// ── receipt 2/4: CALLBACK IDENTITY ──────────────────────────────
|
|
743
855
|
const gid = await awaitCallbackSender(nonce, CALLBACK_WAIT_MS);
|
|
@@ -838,6 +950,24 @@ async function main(): Promise<void> {
|
|
|
838
950
|
`${cell}: the pane process is gone`,
|
|
839
951
|
await waitForPidsGone(new Set([Number(coords.panePid)]), DORMANT_WAIT_MS),
|
|
840
952
|
);
|
|
953
|
+
if (seat !== null) {
|
|
954
|
+
// The close crossed sessions: the handle names the seat, the bridge's env names the
|
|
955
|
+
// caller. What must be gone is that one window; what must stand is the seat's own
|
|
956
|
+
// anchor — the same session, window and pane the fixture made — and the caller's set.
|
|
957
|
+
const anchorAfter = namedSessionPanes(srv.socket, seat, srv.env);
|
|
958
|
+
const inventoryAfter = tmux(srv.socket, ["list-windows", "-a", "-F", "#{window_id}"], srv.env);
|
|
959
|
+
const callerAfterClose = sessionWindows(srv.socket, callerSid, srv.env);
|
|
960
|
+
ok(
|
|
961
|
+
`${cell}: seat — the cross-session close removed exactly the launched window: tmux no longer lists it, the seat's original anchor session/window/pane are the ones still standing, and the caller's windows are unchanged`,
|
|
962
|
+
windowAbsent(inventoryAfter, coords.windowId) &&
|
|
963
|
+
anchorAfter.length === seatAnchor.length &&
|
|
964
|
+
anchorAfter.every((row, i) => row === seatAnchor[i]) &&
|
|
965
|
+
windowsUnchanged(callerWindowsBefore, callerAfterClose),
|
|
966
|
+
`--- seat anchor before ---\n${seatAnchor.join("\n")}\n--- seat panes after ---\n${anchorAfter.join("\n") || "(none)"}\n` +
|
|
967
|
+
`${listingDetail("list-windows -a", inventoryAfter)}\n` +
|
|
968
|
+
listingDetail(`list-windows -t ${callerSid} (after close)`, callerAfterClose),
|
|
969
|
+
);
|
|
970
|
+
}
|
|
841
971
|
let dormantState = "";
|
|
842
972
|
ok(
|
|
843
973
|
`${cell}: the citizen is DORMANT — its control socket no longer answers, which is the fact the decider reads`,
|
|
@@ -1052,8 +1182,10 @@ async function main(): Promise<void> {
|
|
|
1052
1182
|
}
|
|
1053
1183
|
|
|
1054
1184
|
await runClaudeCell();
|
|
1055
|
-
|
|
1056
|
-
|
|
1185
|
+
// Only the native cell names a seat (#108): the ACP and Claude Code cells keep the omitted-
|
|
1186
|
+
// placement path, so the default seat stays proven beside the explicit one.
|
|
1187
|
+
await runPiCell("pi-native", PI_NATIVE_MODEL, true, NATIVE_ROOTS, "seat");
|
|
1188
|
+
await runPiCell("pi-acp", PI_ACP_MODEL, false, ACP_ROOTS, null);
|
|
1057
1189
|
|
|
1058
1190
|
ok(
|
|
1059
1191
|
"correlation: all three siblings reported DIFFERENT garden ids and none is the launcher's — asserted after every cell, so it never depends on which one ran first",
|
|
@@ -435,16 +435,17 @@ ok(
|
|
|
435
435
|
// successful callback result. The transcript is where both are visible, in order, in one
|
|
436
436
|
// native session.
|
|
437
437
|
// Since the callback moved out of band (ENTWURF_CALLBACK_TARGET/NONCE in the sibling env), the
|
|
438
|
-
// callback-only prompt carries no address;
|
|
439
|
-
|
|
438
|
+
// callback-only prompt carries no address argument; it names the caller only for the optional
|
|
439
|
+
// read-only check, and the receipt is the no-arg callback itself.
|
|
440
|
+
const bootstrapLine = buildOmpCallbackOnlyPrompt(callerGid)
|
|
440
441
|
.split("\n")
|
|
441
|
-
.find((l) => l.includes("
|
|
442
|
+
.find((l) => l.includes("THE ONE ACTION THIS MESSAGE ASKS FOR")) as string;
|
|
442
443
|
const transcript = fs.readFileSync(transcriptPath as string, "utf8");
|
|
443
444
|
const callbackAt = transcript.indexOf(bootstrapLine);
|
|
444
445
|
const taskAt = transcript.indexOf(sceneFact);
|
|
445
446
|
receipts["5-two-stage-order"] = `callback-only prompt @${callbackAt}, task scene fact @${taskAt}`;
|
|
446
447
|
ok(
|
|
447
|
-
"stage one is in the sibling's own transcript: the extension delivered the callback-ONLY prompt (no-arg
|
|
448
|
+
"stage one is in the sibling's own transcript: the extension delivered the callback-ONLY prompt (no-arg callback before the task; the address rides the sibling env, never this prompt)",
|
|
448
449
|
callbackAt >= 0,
|
|
449
450
|
` expected line: ${bootstrapLine}`,
|
|
450
451
|
);
|