@junghanacs/entwurf 0.39.0 → 0.41.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 +4 -2
- package/BASELINE.md +60 -5
- package/CHANGELOG.md +557 -0
- package/CONTRIBUTING.md +1 -1
- package/DELIVERY.md +52 -2
- package/FAQ.md +8 -4
- package/README.md +37 -17
- package/VERIFY.md +23 -10
- package/docs/acp-backend-rail.md +5 -1
- package/docs/external-mcp-host.md +8 -6
- package/docs/fresh-cut-policy.md +9 -1
- package/docs/setup-clean-host.md +64 -9
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +100 -24
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/cc-retire-pidfd.py +236 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/cc-retire-request.js +473 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/control-retire.js +125 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-retire-production.js +149 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-retire.js +158 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +2 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-visible-resume.js +283 -20
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-composition.js +7 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-dispatch.js +47 -3
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-fresh-call.js +3 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-mailbox-body.js +10 -4
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +96 -16
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +5 -29
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-launch.js +28 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-resume-call.js +26 -11
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-retire-request.js +76 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/remote-pair-receive.js +123 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/remote-pair-send.js +259 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/remote-pair-transport.js +80 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/resume-launch-identity.js +67 -17
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/retirement-event.js +211 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-hook.js +124 -10
- package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-pi-durable.js +53 -2
- package/mcp/entwurf-bridge/dist/scripts/meta-facts.js +1 -1
- package/mcp/entwurf-bridge/dist/scripts/probe-bridge-command.js +1 -0
- package/mcp/entwurf-bridge/dist/scripts/remote-pair-receive.js +65 -0
- package/mcp/entwurf-bridge/src/index.ts +118 -24
- package/mcp/entwurf-bridge/test.sh +2 -2
- package/mcp/entwurf-bridge/tsconfig.build.json +17 -1
- package/package.json +5 -5
- package/pi/meta-bridge/entwurf-meta-receive/scripts/hook-launch.sh +3 -3
- package/pi-extensions/entwurf-control.ts +55 -20
- package/pi-extensions/lib/cc-retire-pidfd.py +236 -0
- package/pi-extensions/lib/cc-retire-request.ts +553 -0
- package/pi-extensions/lib/control-retire.ts +195 -0
- package/pi-extensions/lib/entwurf-control-rpc.ts +17 -1
- package/pi-extensions/lib/entwurf-v2-retire-production.ts +227 -0
- package/pi-extensions/lib/entwurf-v2-retire.ts +269 -0
- package/pi-extensions/lib/entwurf-v2-surface.ts +2 -1
- package/pi-extensions/lib/entwurf-v2-visible-resume.ts +390 -36
- package/pi-extensions/lib/fresh-call-composition.ts +15 -0
- package/pi-extensions/lib/fresh-call-dispatch.ts +45 -4
- package/pi-extensions/lib/herdr-fresh-call.ts +5 -0
- package/pi-extensions/lib/meta-mailbox-body.ts +13 -4
- package/pi-extensions/lib/meta-session.ts +140 -16
- package/pi-extensions/lib/mux-fresh-call.ts +7 -27
- package/pi-extensions/lib/mux-launch.ts +29 -0
- package/pi-extensions/lib/mux-resume-call.ts +33 -12
- package/pi-extensions/lib/pi-retire-request.ts +111 -0
- package/pi-extensions/lib/remote-pair-receive.ts +184 -0
- package/pi-extensions/lib/remote-pair-send.ts +325 -0
- package/pi-extensions/lib/remote-pair-transport.ts +81 -0
- package/pi-extensions/lib/resume-launch-identity.ts +104 -18
- package/pi-extensions/lib/retirement-event.ts +253 -0
- package/pi-extensions/meta-bridge-hook.ts +142 -13
- package/pi-extensions/meta-bridge-pi-durable.ts +56 -2
- package/run.sh +122 -7
- package/scripts/agy-bridge-config.py +4 -1
- package/scripts/build-bridge.sh +14 -0
- package/scripts/check-capability-bundle-reach.ts +135 -2
- package/scripts/check-entwurf-bridge-boot.ts +2 -1
- package/scripts/check-entwurf-fact-provider.ts +1 -0
- package/scripts/check-entwurf-v2-production.ts +2 -0
- package/scripts/check-entwurf-v2-surface.ts +7 -0
- package/scripts/check-gate-qualification.ts +20 -8
- package/scripts/check-herdr-activation.ts +2 -2
- package/scripts/check-herdr-placement.ts +1 -0
- package/scripts/check-hook-launch-topology.ts +6 -15
- package/scripts/check-install-container.sh +2 -2
- package/scripts/check-meta-doctor-oracle.sh +27 -1
- package/scripts/check-meta-facts.ts +8 -3
- package/scripts/check-meta-hook-session-switch.ts +230 -13
- package/scripts/check-meta-identity-consumers.ts +1 -0
- package/scripts/check-meta-listing.ts +1 -0
- package/scripts/check-meta-manifest-schema.py +1 -1
- package/scripts/check-meta-session.ts +31 -0
- package/scripts/check-meta-v3-record.ts +55 -1
- package/scripts/check-mux-resume-call.ts +35 -9
- package/scripts/check-peer-facts.ts +1 -0
- package/scripts/check-pi-durable-contact.ts +3 -1
- package/scripts/check-pi-durable-send.ts +4 -2
- package/scripts/check-pi-mcp-bridge.ts +1 -0
- package/scripts/check-remote-pair-installed.ts +242 -0
- package/scripts/check-resume-launch-identity.ts +62 -23
- package/scripts/lib/meta-hook-bundle.ts +72 -0
- package/scripts/lib/remote-pair-installed-audit.ts +25 -0
- package/scripts/meta-bridge-claude-floor.sh +13 -9
- package/scripts/meta-bridge-doctor.sh +17 -0
- package/scripts/meta-bridge-install.sh +6 -0
- package/scripts/meta-facts.ts +1 -1
- package/scripts/mutants/bridge-boot-resume.json +5 -2
- package/scripts/mutants/cc-retire-pidfd.json +129 -0
- package/scripts/mutants/cc-retire-request.json +171 -0
- package/scripts/mutants/control-retire.json +105 -0
- package/scripts/mutants/control-send-receipt.json +2 -2
- package/scripts/mutants/copilot-receive.json +2 -6
- package/scripts/mutants/entwurf-v2-retire.json +476 -0
- package/scripts/mutants/fresh-call-dispatch.json +37 -0
- package/scripts/mutants/meta-hook-session-switch.json +88 -0
- package/scripts/mutants/meta-identity.json +57 -0
- package/scripts/mutants/mux-fresh-call.json +1 -1
- package/scripts/mutants/mux-resume-call.json +40 -4
- package/scripts/mutants/pi-durable.json +79 -0
- package/scripts/mutants/pi-retire-request.json +72 -0
- package/scripts/mutants/remote-pair-display.json +53 -0
- package/scripts/mutants/remote-pair-installed-audit.json +28 -0
- package/scripts/mutants/remote-pair-receive.json +126 -0
- package/scripts/mutants/remote-pair-roundtrip.json +83 -0
- package/scripts/mutants/remote-pair-send.json +194 -0
- package/scripts/mutants/remote-pair-transport.json +66 -0
- package/scripts/mutants/resume-launch-identity.json +35 -8
- package/scripts/mutants/retirement-event.json +195 -0
- package/scripts/mutants/v2-surface.json +16 -0
- package/scripts/mutants/v2-visible-resume.json +283 -13
- package/scripts/probe-bridge-command.ts +1 -0
- package/scripts/remote-pair-receive.ts +73 -0
- package/scripts/smoke-agy-install-state.sh +2 -2
- package/scripts/smoke-copilot-mcp-state.sh +1 -1
- package/scripts/smoke-herdr-raw-install-live.sh +5 -4
- package/scripts/smoke-meta-install-state.sh +4 -4
- package/scripts/smoke-mux-lifecycle-live.ts +10 -10
- package/scripts/smoke-omp-mcp-state.sh +1 -1
- package/scripts/smoke-pi-durable-fresh-live.ts +5 -3
- package/scripts/smoke-pi-provider-state.sh +3 -3
|
@@ -47,8 +47,8 @@
|
|
|
47
47
|
*/
|
|
48
48
|
|
|
49
49
|
import assert from "node:assert/strict";
|
|
50
|
-
import { spawnSync } from "node:child_process";
|
|
51
|
-
import {
|
|
50
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
51
|
+
import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
|
52
52
|
import { tmpdir } from "node:os";
|
|
53
53
|
import * as path from "node:path";
|
|
54
54
|
import { fileURLToPath } from "node:url";
|
|
@@ -56,10 +56,12 @@ import { resolveMailboxReceiverFacts } from "../pi-extensions/lib/entwurf-delive
|
|
|
56
56
|
import { listEntwurfFacts } from "../pi-extensions/lib/entwurf-fact-provider.ts";
|
|
57
57
|
import { renderEntwurfPeers } from "../pi-extensions/lib/entwurf-peers-render.ts";
|
|
58
58
|
import type { DispatchDecision } from "../pi-extensions/lib/entwurf-v2-decider.ts";
|
|
59
|
+
import { acquireLock, releaseLock } from "../pi-extensions/lib/entwurf-v2-lock.ts";
|
|
59
60
|
import { makeProductionEntwurfV2Deps } from "../pi-extensions/lib/entwurf-v2-production.ts";
|
|
60
61
|
import { runEntwurfV2 } from "../pi-extensions/lib/entwurf-v2-runner.ts";
|
|
61
62
|
import { renderEntwurfV2Result } from "../pi-extensions/lib/entwurf-v2-surface.ts";
|
|
62
63
|
import {
|
|
64
|
+
defaultMetaRecordArmLocksDir,
|
|
63
65
|
listAllMetaIdentitiesDir,
|
|
64
66
|
type MetaIdentity,
|
|
65
67
|
makeStoreRecordReader,
|
|
@@ -70,6 +72,7 @@ import {
|
|
|
70
72
|
upsertMetaSession,
|
|
71
73
|
writeMetaReceiverMarker,
|
|
72
74
|
} from "../pi-extensions/lib/meta-session.ts";
|
|
75
|
+
import { hookImportClosure, installerCopiedLibFiles, stageMetaHookBundle } from "./lib/meta-hook-bundle.ts";
|
|
73
76
|
import { reclaimOnExit } from "./lib/reclaim-on-exit.ts";
|
|
74
77
|
|
|
75
78
|
let passed = 0;
|
|
@@ -91,17 +94,9 @@ const sessionStartLeaf = manifest.hooks.SessionStart?.[0]?.hooks[0] ?? {};
|
|
|
91
94
|
|
|
92
95
|
/** Stage a plugin bundle: entry + lib closure + the launcher, exactly as installed. */
|
|
93
96
|
function makeBundle(root: string): void {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
copyFileSync(
|
|
98
|
-
path.join(REPO_DIR, "pi-extensions", "lib", "meta-session.ts"),
|
|
99
|
-
path.join(root, "lib", "meta-session.ts"),
|
|
100
|
-
);
|
|
101
|
-
copyFileSync(path.join(REPO_DIR, "pi-extensions", "lib", "session-id.js"), path.join(root, "lib", "session-id.js"));
|
|
102
|
-
const launcher = path.join(root, "scripts", "hook-launch.sh");
|
|
103
|
-
copyFileSync(LAUNCHER, launcher);
|
|
104
|
-
chmodSync(launcher, 0o755);
|
|
97
|
+
// Entry + its OWN import closure + the launcher (scripts/lib/meta-hook-bundle.ts): a hand-kept
|
|
98
|
+
// list here could stay green while the installer missed a file the hook now imports.
|
|
99
|
+
stageMetaHookBundle(root, path.join(REPO_DIR, "pi-extensions", "meta-bridge-hook.ts"), LAUNCHER);
|
|
105
100
|
}
|
|
106
101
|
|
|
107
102
|
/** Resolve one manifest element the way Claude does: plain-string substitution, no shell. */
|
|
@@ -558,4 +553,226 @@ ok(
|
|
|
558
553
|
);
|
|
559
554
|
}
|
|
560
555
|
|
|
556
|
+
// ── E. the record-arm binding (#147 L2 B1) ────────────────────────────────────
|
|
557
|
+
// An arm-capable event commits a fresh `attachIncarnation` in the record and copies the SAME
|
|
558
|
+
// value into the receiver marker, under the record-arm lock; every other event preserves it. A
|
|
559
|
+
// dead marker is resume evidence only while those two agree, so every path where the marker was
|
|
560
|
+
// NOT rewritten has to leave them disagreeing, and a normal arm must never leave them so.
|
|
561
|
+
{
|
|
562
|
+
const BIND_NATIVE = "b1b1b1b1-arm-binding-session";
|
|
563
|
+
const BIND_TRANSCRIPT = path.join(CWD, `${BIND_NATIVE}.jsonl`);
|
|
564
|
+
writeFileSync(BIND_TRANSCRIPT, "");
|
|
565
|
+
const drive = (event: string, source = "startup") =>
|
|
566
|
+
driveHook({ event, nativeSessionId: BIND_NATIVE, source, transcriptPath: BIND_TRANSCRIPT });
|
|
567
|
+
const pair = (gid: string) => ({
|
|
568
|
+
record: identityFor(gid).attachIncarnation,
|
|
569
|
+
marker: readMetaReceiverMarker({ gardenId: gid, receiversDir: ROOTS.receiversDir })?.attachIncarnation ?? null,
|
|
570
|
+
});
|
|
571
|
+
const recordBytes = (gid: string) => readFileSync(path.join(ROOTS.sessionsDir, `${gid}.meta.json`), "utf8");
|
|
572
|
+
const armLocks = defaultMetaRecordArmLocksDir(ROOTS.receiversDir);
|
|
573
|
+
const envelope = (event: string) =>
|
|
574
|
+
JSON.stringify({
|
|
575
|
+
hook_event_name: event,
|
|
576
|
+
session_id: BIND_NATIVE,
|
|
577
|
+
transcript_path: BIND_TRANSCRIPT,
|
|
578
|
+
cwd: CWD,
|
|
579
|
+
source: "startup",
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
drive("SessionStart");
|
|
583
|
+
const BIND_GID = gardenIdFor(BIND_NATIVE);
|
|
584
|
+
const first = pair(BIND_GID);
|
|
585
|
+
ok(
|
|
586
|
+
"a first SessionStart (create, then the locked arm) commits a fresh 32-hex binding in the record [QK:MHSS-ARM-RECORD-BOUND]",
|
|
587
|
+
typeof first.record === "string" && /^[0-9a-f]{32}$/.test(first.record),
|
|
588
|
+
);
|
|
589
|
+
ok(
|
|
590
|
+
"…and the receiver marker that arm wrote carries the SAME value [QK:MHSS-ARM-BINDING-PAIRED]",
|
|
591
|
+
first.marker === first.record,
|
|
592
|
+
);
|
|
593
|
+
|
|
594
|
+
drive("UserPromptSubmit");
|
|
595
|
+
const afterUps = pair(BIND_GID);
|
|
596
|
+
ok(
|
|
597
|
+
"a UserPromptSubmit backfill PRESERVES the binding — a keystroke is not a new incarnation [QK:MHSS-UPS-PRESERVES-BINDING]",
|
|
598
|
+
afterUps.record === first.record && afterUps.marker === first.record,
|
|
599
|
+
);
|
|
600
|
+
|
|
601
|
+
drive("SessionStart", "compact");
|
|
602
|
+
const reminted = pair(BIND_GID);
|
|
603
|
+
ok(
|
|
604
|
+
"every arm-capable event mints a NEW binding and the pair still agrees [QK:MHSS-ARM-REMINTS-PAIRED]",
|
|
605
|
+
reminted.record !== first.record && reminted.marker === reminted.record,
|
|
606
|
+
);
|
|
607
|
+
|
|
608
|
+
// An arm whose owner cannot be proven (no launcher provenance): the record commits a new
|
|
609
|
+
// binding, the marker is not rewritten — so they disagree, and a later resume refuses.
|
|
610
|
+
{
|
|
611
|
+
const direct = spawnSync(process.execPath, [path.join(PLUGIN_ROOT, "meta-bridge-hook.ts")], {
|
|
612
|
+
encoding: "utf8",
|
|
613
|
+
input: envelope("SessionStart"),
|
|
614
|
+
env: hookEnv(),
|
|
615
|
+
});
|
|
616
|
+
assert.equal(direct.status, 0, `direct hook drive exited ${direct.status}: ${direct.stderr}`);
|
|
617
|
+
const unproven = pair(BIND_GID);
|
|
618
|
+
ok(
|
|
619
|
+
"an arm with no validated owner commits a new record binding and leaves the marker's old one — never a matching stale pair [QK:MHSS-UNPROVEN-OWNER-UNBINDS]",
|
|
620
|
+
unproven.record !== null &&
|
|
621
|
+
unproven.record !== reminted.record &&
|
|
622
|
+
readMetaReceiverMarker({ gardenId: BIND_GID, receiversDir: ROOTS.receiversDir, verifyOwner: false })
|
|
623
|
+
?.attachIncarnation === reminted.record,
|
|
624
|
+
);
|
|
625
|
+
}
|
|
626
|
+
drive("SessionStart", "resume"); // back to a matching pair for the lock cells
|
|
627
|
+
|
|
628
|
+
// The lock held by someone else: an arm waits its bound and then writes NOTHING (ERROR); a
|
|
629
|
+
// keystroke skips its backfill (WARN). Held by THIS gate's live pid, so it cannot be reclaimed.
|
|
630
|
+
{
|
|
631
|
+
const held = acquireLock(BIND_GID, { dir: armLocks });
|
|
632
|
+
assert.ok(held.ok, "the gate could not take the record-arm lock it plants");
|
|
633
|
+
const before = recordBytes(BIND_GID);
|
|
634
|
+
const markerBefore = pair(BIND_GID).marker;
|
|
635
|
+
const logBefore = readFileSync(HOOK_LOG, "utf8").length;
|
|
636
|
+
drive("SessionStart", "resume");
|
|
637
|
+
const armDelta = readFileSync(HOOK_LOG, "utf8").slice(logBefore);
|
|
638
|
+
ok(
|
|
639
|
+
"an arm that cannot take the record-arm lock writes NOTHING and says so as ERROR (attach not committed) [QK:MHSS-ARM-LOCK-BUSY-NO-WRITE]",
|
|
640
|
+
recordBytes(BIND_GID) === before &&
|
|
641
|
+
pair(BIND_GID).marker === markerBefore &&
|
|
642
|
+
/ERROR record-arm lock busy — attach NOT committed, watch not armed/.test(armDelta) &&
|
|
643
|
+
!/armed watch/.test(armDelta),
|
|
644
|
+
);
|
|
645
|
+
const upsBefore = readFileSync(HOOK_LOG, "utf8").length;
|
|
646
|
+
drive("UserPromptSubmit");
|
|
647
|
+
ok(
|
|
648
|
+
"a keystroke that finds the lock busy skips its backfill, writing nothing [QK:MHSS-UPS-LOCK-BUSY-SKIPS]",
|
|
649
|
+
recordBytes(BIND_GID) === before &&
|
|
650
|
+
/WARN record-arm lock busy — backfill skipped/.test(readFileSync(HOOK_LOG, "utf8").slice(upsBefore)),
|
|
651
|
+
);
|
|
652
|
+
if (held.ok) releaseLock(held.claim, { dir: armLocks });
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// The lock released inside the bound: the arm waits, then commits a matching pair.
|
|
656
|
+
{
|
|
657
|
+
const held = acquireLock(BIND_GID, { dir: armLocks });
|
|
658
|
+
assert.ok(held.ok, "the gate could not take the record-arm lock it plants");
|
|
659
|
+
const before = pair(BIND_GID).record;
|
|
660
|
+
const exe = resolveEl(sessionStartLeaf.command ?? "", PLUGIN_ROOT);
|
|
661
|
+
const argv = (sessionStartLeaf.args ?? []).map((a) => resolveEl(a, PLUGIN_ROOT));
|
|
662
|
+
const child = spawn(exe, argv, { env: hookEnv(), stdio: ["pipe", "ignore", "pipe"] });
|
|
663
|
+
// The close listener is registered at spawn, so a child that exits early is still observed.
|
|
664
|
+
const closed = new Promise<number | null>((resolve) => child.on("close", resolve));
|
|
665
|
+
child.stdin.end(envelope("SessionStart"));
|
|
666
|
+
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
667
|
+
if (held.ok) releaseLock(held.claim, { dir: armLocks });
|
|
668
|
+
const code = await closed;
|
|
669
|
+
const waited = pair(BIND_GID);
|
|
670
|
+
ok(
|
|
671
|
+
"an arm that finds the lock held waits within its bound and, once released, commits a NEW matching pair [QK:MHSS-ARM-WAITS-FOR-LOCK]",
|
|
672
|
+
code === 0 && waited.record !== before && waited.marker === waited.record,
|
|
673
|
+
);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// TWO SUCCESSFUL OVERLAPPING SCHEDULES (Astra D4): the cells above hold the lock in the
|
|
677
|
+
// gate and run one child at a time; these put two real hook processes in flight at once and
|
|
678
|
+
// require BOTH to succeed, then the pair to agree. The lock is held by the gate only to make
|
|
679
|
+
// the overlap certain (both children start while it is held), never as a substitute writer.
|
|
680
|
+
// SUPPORTING SCHEDULE CELLS, not the lock proof: they carry no mutant, because a kill here
|
|
681
|
+
// would rest on process timing. The deterministic lock proof is the three busy cells above
|
|
682
|
+
// (MHSS-ARM-LOCK-BUSY-NO-WRITE, MHSS-UPS-LOCK-BUSY-SKIPS, MHSS-ARM-WAITS-FOR-LOCK). Schedule 2's
|
|
683
|
+
// order (arm before keystroke) relies on the keystroke's node startup outlasting the arm's
|
|
684
|
+
// few-ms critical section; it is asserted, never retried or slept into green.
|
|
685
|
+
const launch = (event: string) => {
|
|
686
|
+
const exe = resolveEl(sessionStartLeaf.command ?? "", PLUGIN_ROOT);
|
|
687
|
+
const argv = (sessionStartLeaf.args ?? []).map((a) => resolveEl(a, PLUGIN_ROOT));
|
|
688
|
+
const child = spawn(exe, argv, { env: hookEnv(), stdio: ["pipe", "ignore", "pipe"] });
|
|
689
|
+
child.stdin.end(envelope(event));
|
|
690
|
+
return new Promise<number | null>((resolve) => child.on("close", resolve));
|
|
691
|
+
};
|
|
692
|
+
const count = (text: string, re: RegExp) => (text.match(re) ?? []).length;
|
|
693
|
+
|
|
694
|
+
// Schedule 1 — arm × arm: both arms are waiting when the lock frees, so they contend.
|
|
695
|
+
{
|
|
696
|
+
const held = acquireLock(BIND_GID, { dir: armLocks });
|
|
697
|
+
assert.ok(held.ok, "the gate could not take the record-arm lock it plants");
|
|
698
|
+
const before = pair(BIND_GID).record;
|
|
699
|
+
const logBefore = readFileSync(HOOK_LOG, "utf8").length;
|
|
700
|
+
const a = launch("SessionStart");
|
|
701
|
+
const b = launch("CwdChanged");
|
|
702
|
+
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
703
|
+
if (held.ok) releaseLock(held.claim, { dir: armLocks });
|
|
704
|
+
const codes = await Promise.all([a, b]);
|
|
705
|
+
const delta = readFileSync(HOOK_LOG, "utf8").slice(logBefore);
|
|
706
|
+
const end = pair(BIND_GID);
|
|
707
|
+
ok(
|
|
708
|
+
"two arms in flight at once BOTH commit (two attaches, two markers, no busy) and end as one matching pair [QK:MHSS-OVERLAP-ARM-ARM-PAIRED]",
|
|
709
|
+
codes.every((c) => c === 0) &&
|
|
710
|
+
count(delta, new RegExp(`INFO attach record ${BIND_GID}\\.meta\\.json`, "g")) === 2 &&
|
|
711
|
+
count(delta, new RegExp(`INFO receiver marker ${BIND_GID} `, "g")) === 2 &&
|
|
712
|
+
!/record-arm lock busy/.test(delta) &&
|
|
713
|
+
end.record !== before &&
|
|
714
|
+
end.marker === end.record,
|
|
715
|
+
);
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// Schedule 2 — arm + UserPromptSubmit: the arm is waiting on the lock when the keystroke
|
|
719
|
+
// starts; the arm takes the freed lock first, and the keystroke's backfill commits after it.
|
|
720
|
+
// Both succeed, the keystroke preserves the arm's binding, and the pair agrees.
|
|
721
|
+
{
|
|
722
|
+
const held = acquireLock(BIND_GID, { dir: armLocks });
|
|
723
|
+
assert.ok(held.ok, "the gate could not take the record-arm lock it plants");
|
|
724
|
+
const before = pair(BIND_GID).record;
|
|
725
|
+
const logBefore = readFileSync(HOOK_LOG, "utf8").length;
|
|
726
|
+
const arm = launch("SessionStart");
|
|
727
|
+
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
728
|
+
const keystroke = launch("UserPromptSubmit");
|
|
729
|
+
if (held.ok) releaseLock(held.claim, { dir: armLocks });
|
|
730
|
+
const codes = await Promise.all([arm, keystroke]);
|
|
731
|
+
const delta = readFileSync(HOOK_LOG, "utf8").slice(logBefore);
|
|
732
|
+
const end = pair(BIND_GID);
|
|
733
|
+
ok(
|
|
734
|
+
"an arm and a keystroke in flight at once BOTH commit, the keystroke preserves the arm's new binding, and the pair agrees [QK:MHSS-OVERLAP-ARM-UPS-PAIRED]",
|
|
735
|
+
codes.every((c) => c === 0) &&
|
|
736
|
+
count(delta, new RegExp(`INFO attach record ${BIND_GID}\\.meta\\.json \\(event=SessionStart`, "g")) === 1 &&
|
|
737
|
+
count(delta, new RegExp(`INFO attach record ${BIND_GID}\\.meta\\.json \\(event=UserPromptSubmit`, "g")) === 1 &&
|
|
738
|
+
delta.search(new RegExp(`INFO attach record ${BIND_GID}\\.meta\\.json \\(event=SessionStart`)) <
|
|
739
|
+
delta.search(new RegExp(`INFO attach record ${BIND_GID}\\.meta\\.json \\(event=UserPromptSubmit`)) &&
|
|
740
|
+
!/record-arm lock busy/.test(delta) &&
|
|
741
|
+
end.record !== before &&
|
|
742
|
+
end.marker === end.record,
|
|
743
|
+
);
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
ok(
|
|
747
|
+
"the record-arm lock lives beside the receivers dir, never in the v2 dispatch lock dir resume holds [QK:MHSS-ARM-LOCK-OWN-DIR]",
|
|
748
|
+
armLocks === path.join(AGENT_ROOT, "meta-record-arm-locks") &&
|
|
749
|
+
armLocks !== ROOTS.locksDir &&
|
|
750
|
+
!/ENTWURF_V2_LOCK_DIR/.test(readFileSync(path.join(REPO_DIR, "pi-extensions", "meta-bridge-hook.ts"), "utf8")),
|
|
751
|
+
);
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
// ── F. the installed closure IS the hook's static `from` import closure ───────
|
|
755
|
+
// The installer copies a fixed set of lib files; the hook imports whatever it imports. If those
|
|
756
|
+
// drift, the installed hook cannot load — and a gate that hand-copied its own list would still be
|
|
757
|
+
// green. SOURCE form only: this gate runs in the source-only qualification snapshot, which has no
|
|
758
|
+
// dist. The compiled-form comparison and the staged compiled-hook run live with the dist consumer,
|
|
759
|
+
// check-capability-bundle-reach ([QK:BUNDLE-REACH-COMPILED-HOOK-CLOSURE] /
|
|
760
|
+
// [QK:BUNDLE-REACH-COMPILED-HOOK-RUNS]; the latter was MHSS-COMPILED-CLOSURE-RUNS here).
|
|
761
|
+
{
|
|
762
|
+
const installer = readFileSync(path.join(REPO_DIR, "scripts", "meta-bridge-install.sh"), "utf8");
|
|
763
|
+
const copied = installerCopiedLibFiles(installer);
|
|
764
|
+
// The installer names each mode-switched module `<name>.$LIB_EXT`; `session-id.js` ships as itself.
|
|
765
|
+
const asInstalled = (closure: string[]) =>
|
|
766
|
+
closure
|
|
767
|
+
.map((rel) => path.basename(rel))
|
|
768
|
+
.map((base) => (base === "session-id.js" ? base : base.replace(/\.(ts|js)$/, ".$LIB_EXT")))
|
|
769
|
+
.sort();
|
|
770
|
+
const sourceClosure = hookImportClosure(path.join(REPO_DIR, "pi-extensions", "meta-bridge-hook.ts"));
|
|
771
|
+
ok(
|
|
772
|
+
"the installer copies exactly the hook's static double-quoted `from` import closure, in source form [QK:MHSS-BUNDLE-CLOSURE-INSTALLED]",
|
|
773
|
+
sourceClosure.includes("lib/entwurf-v2-lock.ts") &&
|
|
774
|
+
JSON.stringify(asInstalled(sourceClosure)) === JSON.stringify(copied),
|
|
775
|
+
);
|
|
776
|
+
}
|
|
777
|
+
|
|
561
778
|
console.log(`\ncheck-meta-hook-session-switch: ${passed} assertions passed`);
|
|
@@ -76,6 +76,7 @@ const identity = (gardenId: string, nativeSessionId: string): MetaIdentity => ({
|
|
|
76
76
|
transcriptPath: null,
|
|
77
77
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
78
78
|
recordUpdatedAt: "2026-03-01T12:30:00.000Z",
|
|
79
|
+
attachIncarnation: null,
|
|
79
80
|
});
|
|
80
81
|
|
|
81
82
|
const GID_A = "20260301T120000-aaaaaa";
|
|
@@ -109,7 +109,7 @@ if hooks is not None:
|
|
|
109
109
|
# the array at runtime and reports the hook as `exit_code: 0,
|
|
110
110
|
# outcome: success`. So the minimal keyset never bought protection from
|
|
111
111
|
# that version — it only kept us on a launch form whose topology no host
|
|
112
|
-
# could guarantee. The real defenses are the `>=2.1.
|
|
112
|
+
# could guarantee. The real defenses are the `>=2.1.306` floor
|
|
113
113
|
# (check-claude-floor-coherence) and hook-launch.sh refusing an empty
|
|
114
114
|
# argv. The marketplace/plugin manifests below stay minimal: the closed-
|
|
115
115
|
# schema lesson that produced this gate was about a DECORATIVE key
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
defaultMetaMailboxDir,
|
|
23
23
|
defaultMetaSessionsDir,
|
|
24
24
|
enqueueMetaMessage,
|
|
25
|
+
findCertifiedIdentityByNativeSessionId,
|
|
25
26
|
META_SCHEMA_VERSION_V3,
|
|
26
27
|
type MetaIdentityMintInput,
|
|
27
28
|
MetaRecordError,
|
|
@@ -32,6 +33,7 @@ import {
|
|
|
32
33
|
readMailboxReceiptState,
|
|
33
34
|
readMetaInbox,
|
|
34
35
|
serializeMetaIdentity,
|
|
36
|
+
UpsertActionMismatchError,
|
|
35
37
|
upsertMetaSession,
|
|
36
38
|
} from "../pi-extensions/lib/meta-session.ts";
|
|
37
39
|
|
|
@@ -155,6 +157,35 @@ check("upsertMetaSession: second call attaches — same file/id, recordUpdatedAt
|
|
|
155
157
|
}
|
|
156
158
|
});
|
|
157
159
|
|
|
160
|
+
check(
|
|
161
|
+
"upsertMetaSession expectAction refuses BEFORE any write when the decision is not that action (#147 L2 B1)",
|
|
162
|
+
() => {
|
|
163
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "meta-session-store-"));
|
|
164
|
+
try {
|
|
165
|
+
assert.throws(
|
|
166
|
+
() => upsertMetaSession({ dir, input: claudeInput(), now: T0, expectAction: "attach" }),
|
|
167
|
+
(err: unknown) => err instanceof UpsertActionMismatchError && err.actual === "create",
|
|
168
|
+
"expectAction refuses before any write when the decision differs [QK:META-UPSERT-EXPECT-ACTION]",
|
|
169
|
+
);
|
|
170
|
+
assert.deepEqual(fs.readdirSync(dir), []); // nothing published
|
|
171
|
+
const first = upsertMetaSession({ dir, input: claudeInput(), now: T0, expectAction: "create" });
|
|
172
|
+
const bytes = fs.readFileSync(first.path, "utf8");
|
|
173
|
+
assert.throws(
|
|
174
|
+
() => upsertMetaSession({ dir, input: claudeInput({ cwd: "/raced" }), now: T1, expectAction: "create" }),
|
|
175
|
+
(err: unknown) => err instanceof UpsertActionMismatchError && err.actual === "attach",
|
|
176
|
+
);
|
|
177
|
+
assert.equal(fs.readFileSync(first.path, "utf8"), bytes); // a raced create never became an attach
|
|
178
|
+
assert.equal(
|
|
179
|
+
findCertifiedIdentityByNativeSessionId(claudeInput().nativeSessionId, dir)?.gardenId,
|
|
180
|
+
first.record.gardenId,
|
|
181
|
+
);
|
|
182
|
+
assert.equal(fs.readFileSync(first.path, "utf8"), bytes); // the lookup is read-only
|
|
183
|
+
} finally {
|
|
184
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
185
|
+
}
|
|
186
|
+
},
|
|
187
|
+
);
|
|
188
|
+
|
|
158
189
|
check("upsertMetaSession: creates the store dir if absent; leaves no .tmp residue", () => {
|
|
159
190
|
const base = fs.mkdtempSync(path.join(os.tmpdir(), "meta-session-store-"));
|
|
160
191
|
const dir = path.join(base, "nested", "meta-sessions"); // does not exist yet
|
|
@@ -16,11 +16,15 @@
|
|
|
16
16
|
* showing the actual rejected version value, and unknown/missing versions
|
|
17
17
|
* crash,
|
|
18
18
|
* - strict keyset: a body carrying any retired or unknown field is rejected
|
|
19
|
-
* as stray, never coerced
|
|
19
|
+
* as stray, never coerced,
|
|
20
|
+
* - `attachIncarnation` (#147 L2 B1) is the one key a body may omit (absent reads
|
|
21
|
+
* as null), is always emitted, is grammar-checked, and survives every attach
|
|
22
|
+
* that does not mint a new one.
|
|
20
23
|
*/
|
|
21
24
|
|
|
22
25
|
import assert from "node:assert/strict";
|
|
23
26
|
import {
|
|
27
|
+
decideUpsert,
|
|
24
28
|
FRESH_CUT_COMMAND,
|
|
25
29
|
type MetaIdentity,
|
|
26
30
|
MetaRecordError,
|
|
@@ -64,6 +68,7 @@ const V3_IDENTITY: MetaIdentity = {
|
|
|
64
68
|
transcriptPath: "/synthetic/project/.transcript.jsonl",
|
|
65
69
|
createdAt: "2026-01-01T00:00:00.000Z",
|
|
66
70
|
recordUpdatedAt: "2026-01-02T03:04:05.000Z",
|
|
71
|
+
attachIncarnation: null,
|
|
67
72
|
};
|
|
68
73
|
|
|
69
74
|
const V3_PI_IDENTITY: MetaIdentity = {
|
|
@@ -76,6 +81,7 @@ const V3_PI_IDENTITY: MetaIdentity = {
|
|
|
76
81
|
transcriptPath: null,
|
|
77
82
|
createdAt: "2026-03-01T12:00:00.000Z",
|
|
78
83
|
recordUpdatedAt: "2026-03-01T12:30:00.000Z",
|
|
84
|
+
attachIncarnation: null,
|
|
79
85
|
};
|
|
80
86
|
|
|
81
87
|
const V1_BODY = JSON.stringify({
|
|
@@ -191,4 +197,52 @@ throws("parseMetaRecordV3 rejects an unknown backend", () =>
|
|
|
191
197
|
parseMetaRecordV3(JSON.stringify({ ...V3_IDENTITY, backend: "gemini" })),
|
|
192
198
|
);
|
|
193
199
|
|
|
200
|
+
// --- attachIncarnation: the one key a V3 record may omit (#147 L2 B1) ---------
|
|
201
|
+
// One schema, one reader: absent reads as null ("no binding observed"), every serialize emits
|
|
202
|
+
// the key, a present value must be 32 lowercase hex, and only a mint replaces it — every other
|
|
203
|
+
// attach preserves it, and nothing infers one.
|
|
204
|
+
{
|
|
205
|
+
const BIND = "0123456789abcdef0123456789abcdef";
|
|
206
|
+
const { attachIncarnation: _omit, ...withoutKey } = V3_IDENTITY;
|
|
207
|
+
// Evaluated inside the assertion: a parser that REFUSES the absent key throws, and that refusal is
|
|
208
|
+
// exactly this claim failing, so it must land on this label rather than escape before it.
|
|
209
|
+
ok(
|
|
210
|
+
"a V3 body without attachIncarnation reads it as null — not a stray, not a defect [QK:META-V3-INCARNATION-ABSENT-NULL]",
|
|
211
|
+
(() => {
|
|
212
|
+
try {
|
|
213
|
+
return parseMetaRecordV3(JSON.stringify(withoutKey)).attachIncarnation === null;
|
|
214
|
+
} catch {
|
|
215
|
+
return false;
|
|
216
|
+
}
|
|
217
|
+
})(),
|
|
218
|
+
);
|
|
219
|
+
throws("a malformed attachIncarnation (not 32 lowercase hex) is refused [QK:META-V3-INCARNATION-MALFORMED]", () =>
|
|
220
|
+
parseMetaRecordV3(JSON.stringify({ ...V3_IDENTITY, attachIncarnation: "ABCDEF" })),
|
|
221
|
+
);
|
|
222
|
+
throws("a non-string attachIncarnation is refused", () =>
|
|
223
|
+
parseMetaRecordV3(JSON.stringify({ ...V3_IDENTITY, attachIncarnation: 7 })),
|
|
224
|
+
);
|
|
225
|
+
ok(
|
|
226
|
+
"serialize always emits the key (null included) and a bound value round-trips [QK:META-V3-INCARNATION-ALWAYS-EMITTED]",
|
|
227
|
+
serializeMetaIdentity(V3_IDENTITY).includes('"attachIncarnation": null') &&
|
|
228
|
+
parseMetaRecordV3(serializeMetaIdentity({ ...V3_IDENTITY, attachIncarnation: BIND })).attachIncarnation === BIND,
|
|
229
|
+
);
|
|
230
|
+
const bound = { ...V3_IDENTITY, attachIncarnation: BIND };
|
|
231
|
+
const input = {
|
|
232
|
+
backend: "claude-code" as const,
|
|
233
|
+
nativeSessionId: V3_IDENTITY.nativeSessionId,
|
|
234
|
+
cwd: "/synthetic/moved",
|
|
235
|
+
};
|
|
236
|
+
const preserved = decideUpsert(bound, input);
|
|
237
|
+
const reminted = decideUpsert(bound, { ...input, attachIncarnation: "f".repeat(32) });
|
|
238
|
+
ok(
|
|
239
|
+
"an attach without a value PRESERVES the binding; a mint replaces it; a create without one is null [QK:META-UPSERT-PRESERVES-INCARNATION]",
|
|
240
|
+
preserved.action === "attach" &&
|
|
241
|
+
preserved.record.attachIncarnation === BIND &&
|
|
242
|
+
preserved.record.cwd === "/synthetic/moved" &&
|
|
243
|
+
reminted.record.attachIncarnation === "f".repeat(32) &&
|
|
244
|
+
decideUpsert(null, input).record.attachIncarnation === null,
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
|
|
194
248
|
console.log(`[check-meta-v3-record] ${passed} assertions ok`);
|
|
@@ -21,7 +21,11 @@
|
|
|
21
21
|
* MUXRESUME-ARGV-HAS-CWD the `-c` actually reaches tmux, after the append shape
|
|
22
22
|
* MUXRESUME-ARGV-RUNTIME-AFTER-DASHDASH the runtime and its flags are exec'd, never parsed as
|
|
23
23
|
* tmux options or run through a shell
|
|
24
|
-
* MUXRESUME-ARGV-CARRIER-FREE nothing beyond append + `-c` + runtime +
|
|
24
|
+
* MUXRESUME-ARGV-CARRIER-FREE nothing beyond append + the fixed scrub + `-c` + runtime + flags
|
|
25
|
+
* MUXRESUME-ARGV-IDENTITY-SCRUB the tmux half empties exactly the two pi identity carriers a tmux
|
|
26
|
+
* server may have inherited — the same scrub fresh-call carries
|
|
27
|
+
* MUXRESUME-RUNTIME-TABLE the runtime is a closed per-backend table: pi → pi, claude-code → claude
|
|
28
|
+
* MUXRESUME-HERDR-REFUSED inside herdr the tmux resume rail refuses by name, opening nothing
|
|
25
29
|
* MUXRESUME-NO-IDENTITY this module never learns which citizen it is reopening
|
|
26
30
|
* MUXRESUME-SURFACE-SEAM both surfaces hand the launcher IN; neither lets the v2
|
|
27
31
|
* composition import mux
|
|
@@ -131,9 +135,16 @@ function main(): void {
|
|
|
131
135
|
// whole line would be green only because this fixture has no extensions, and would go red
|
|
132
136
|
// on the recorded-ACP shape that actually ships.
|
|
133
137
|
const tmuxPrefix = args.slice(0, args.indexOf("--"));
|
|
138
|
+
const envAssignments = tmuxPrefix.flatMap((token, at) => (token === "-e" ? [tmuxPrefix[at + 1]] : []));
|
|
134
139
|
ok(
|
|
135
|
-
"[QK:MUXRESUME-ARGV-CARRIER-FREE] the TMUX half of the argv is exactly the append shape plus -c — no -n window name, no -e
|
|
136
|
-
!tmuxPrefix.includes("-n") &&
|
|
140
|
+
"[QK:MUXRESUME-ARGV-CARRIER-FREE] the TMUX half of the argv is exactly the append shape plus the fixed scrub and -c — no -n window name, no -b, and every -e only EMPTIES an identity carrier; the runtime's own flags live after `--` and are not tmux's business",
|
|
141
|
+
!tmuxPrefix.includes("-n") &&
|
|
142
|
+
!tmuxPrefix.includes("-b") &&
|
|
143
|
+
envAssignments.every((assignment) => assignment === "PI_SESSION_ID=" || assignment === "PI_AGENT_ID="),
|
|
144
|
+
);
|
|
145
|
+
ok(
|
|
146
|
+
"[QK:MUXRESUME-ARGV-IDENTITY-SCRUB] the TMUX half empties exactly PI_SESSION_ID and PI_AGENT_ID — a tmux server started from a pi shell hands its window a stale identity, and a resumed Claude Code, which never re-exports its own, would present it to its bridge",
|
|
147
|
+
JSON.stringify(envAssignments) === JSON.stringify(["PI_SESSION_ID=", "PI_AGENT_ID="]),
|
|
137
148
|
);
|
|
138
149
|
ok(
|
|
139
150
|
"a cwd that classification refuses can never be built into argv either — the builder re-checks rather than trusting its caller",
|
|
@@ -161,19 +172,31 @@ function main(): void {
|
|
|
161
172
|
// ── refusals happen BEFORE any mutation ───────────────────────────────────────
|
|
162
173
|
// No tmux context at all: a bad cwd must still be the reason, because it is decided first
|
|
163
174
|
// and nothing may reach tmux while it is wrong.
|
|
164
|
-
const noTmux = resumeCall({ cwd: path.join(tmp, "gone"), runtimeArgs: [] }, {});
|
|
175
|
+
const noTmux = resumeCall({ backend: "pi", cwd: path.join(tmp, "gone"), runtimeArgs: [] }, {});
|
|
165
176
|
ok(
|
|
166
177
|
"an unusable cwd refuses even outside tmux — the cwd question is settled before placement, so no window can exist by the time it is answered",
|
|
167
178
|
!noTmux.ok && noTmux.reason === "cwd-missing",
|
|
168
179
|
);
|
|
169
|
-
const outsideTmux = resumeCall({ cwd: realDir, runtimeArgs: [] }, { PATH: "/nonexistent" });
|
|
180
|
+
const outsideTmux = resumeCall({ backend: "pi", cwd: realDir, runtimeArgs: [] }, { PATH: "/nonexistent" });
|
|
170
181
|
ok(
|
|
171
182
|
"with a good cwd and no runtime on PATH, the refusal is the runtime — preconditions are proven before placement is even read",
|
|
172
183
|
!outsideTmux.ok && outsideTmux.reason === "runtime-unresolved",
|
|
173
184
|
);
|
|
174
185
|
|
|
186
|
+
// Inside herdr the rail is refused FIRST and by name: a good cwd, a runtime on PATH and no TMUX
|
|
187
|
+
// at all would otherwise reach the placement question and answer something about tmux.
|
|
188
|
+
const insideHerdr = resumeCall(
|
|
189
|
+
{ backend: "pi", cwd: realDir, runtimeArgs: [] },
|
|
190
|
+
{ HERDR_ENV: "1", PATH: runtimeDir },
|
|
191
|
+
);
|
|
192
|
+
ok(
|
|
193
|
+
"[QK:MUXRESUME-HERDR-REFUSED] inside herdr (HERDR_ENV=1) the tmux resume rail refuses as herdr-resume-unsupported before anything else — an ambient TMUX would otherwise open the window somewhere the operator is not looking",
|
|
194
|
+
!insideHerdr.ok && insideHerdr.reason === "herdr-resume-unsupported",
|
|
195
|
+
);
|
|
196
|
+
|
|
175
197
|
// ── every reason is answerable ────────────────────────────────────────────────
|
|
176
198
|
const reasons: ResumeCallRejectReason[] = [
|
|
199
|
+
"herdr-resume-unsupported",
|
|
177
200
|
"no-tmux-context",
|
|
178
201
|
"anchor-malformed",
|
|
179
202
|
"anchor-unresolved",
|
|
@@ -193,7 +216,10 @@ function main(): void {
|
|
|
193
216
|
"every reject reason has a hint a caller can act on — a reason without one is a reason they will guess about",
|
|
194
217
|
reasons.every((r) => typeof RESUME_CALL_REJECT_HINT[r] === "string" && RESUME_CALL_REJECT_HINT[r].length > 0),
|
|
195
218
|
);
|
|
196
|
-
ok(
|
|
219
|
+
ok(
|
|
220
|
+
"[QK:MUXRESUME-RUNTIME-TABLE] the runtime is a closed per-backend table, pi → pi and claude-code → claude — never a command a caller supplies",
|
|
221
|
+
JSON.stringify(RESUME_CALL_RUNTIME) === JSON.stringify({ pi: "pi", "claude-code": "claude" }),
|
|
222
|
+
);
|
|
197
223
|
|
|
198
224
|
// ── boundaries, asserted on source ────────────────────────────────────────────
|
|
199
225
|
const SRC = read("pi-extensions/lib/mux-resume-call.ts");
|
|
@@ -241,7 +267,7 @@ function main(): void {
|
|
|
241
267
|
const MCP_RESUME_BLOCK = (MCP_SURFACE.split('"entwurf_resume_call",')[1] ?? "").split("\n);")[0];
|
|
242
268
|
ok(
|
|
243
269
|
"[QK:MUXRESUME-MCP-TARGET-PASSTHROUGH] the bridge handler passes its target straight to visibleResume — a rewritten, defaulted or substituted id would reopen a DIFFERENT citizen than the one the caller named",
|
|
244
|
-
/async \(\{ target \}\) =>/.test(MCP_RESUME_BLOCK) &&
|
|
270
|
+
/async \(\{ target \}, extra\) =>/.test(MCP_RESUME_BLOCK) &&
|
|
245
271
|
/await visibleResume\(target, makeVisibleResumeDeps\(visibleResumeLaunch\)\)/.test(MCP_RESUME_BLOCK),
|
|
246
272
|
);
|
|
247
273
|
|
|
@@ -249,7 +275,7 @@ function main(): void {
|
|
|
249
275
|
// Not prose parity — three claims an operator acts on, plus the host's own cap.
|
|
250
276
|
const MCP_DESC = (MCP_SURFACE.split('"entwurf_resume_call",')[1] ?? "").split("{")[0];
|
|
251
277
|
const statesTheContract = (text: string): boolean =>
|
|
252
|
-
/dormant/i.test(text) && /
|
|
278
|
+
/dormant/i.test(text) && /submits NO prompt/i.test(text) && /observation/i.test(text) && /launch/i.test(text);
|
|
253
279
|
|
|
254
280
|
ok(
|
|
255
281
|
"MCP bridge: the resume description fits the host's 2048-char tool-description cap",
|
|
@@ -259,7 +285,7 @@ function main(): void {
|
|
|
259
285
|
// appear literally exactly once in its gate source, so a token assembled at runtime names a
|
|
260
286
|
// claim no manifest can bind to.
|
|
261
287
|
ok(
|
|
262
|
-
"[QK:MUXRESUME-DESC-CONTRACT-MCP] MCP bridge: the description states the three things a caller acts on — DORMANT targets only, no
|
|
288
|
+
"[QK:MUXRESUME-DESC-CONTRACT-MCP] MCP bridge: the description states the three things a caller acts on — DORMANT targets only, no prompt is submitted, and TWO receipts whose observation is the fact that matters",
|
|
263
289
|
statesTheContract(MCP_DESC),
|
|
264
290
|
);
|
|
265
291
|
} finally {
|
|
@@ -586,7 +586,7 @@ async function birthCell(
|
|
|
586
586
|
report.hostPid === host.pid,
|
|
587
587
|
);
|
|
588
588
|
ok(
|
|
589
|
-
`[QK:PDC-TOOLS] the registered extension exposes exactly entwurf_self, entwurf_peers, entwurf_v2, entwurf_inbox_read, entwurf_callback, entwurf_fresh_call, in that order (${report.toolNames.join(", ")})`,
|
|
589
|
+
`[QK:PDC-TOOLS] the registered extension exposes exactly entwurf_self, entwurf_peers, entwurf_v2, entwurf_inbox_read, entwurf_callback, entwurf_fresh_call, entwurf_resume_call, entwurf_retire_call, in that order (${report.toolNames.join(", ")})`,
|
|
590
590
|
JSON.stringify(report.toolNames) ===
|
|
591
591
|
JSON.stringify([
|
|
592
592
|
"entwurf_self",
|
|
@@ -595,6 +595,8 @@ async function birthCell(
|
|
|
595
595
|
"entwurf_inbox_read",
|
|
596
596
|
"entwurf_callback",
|
|
597
597
|
"entwurf_fresh_call",
|
|
598
|
+
"entwurf_resume_call",
|
|
599
|
+
"entwurf_retire_call",
|
|
598
600
|
]),
|
|
599
601
|
);
|
|
600
602
|
|
|
@@ -154,7 +154,7 @@ const NATIVE_CALLS = [
|
|
|
154
154
|
{ id: "call_pdm_2", name: "bash", arguments: { command: 'printf %s "$MOCK_NATIVE_MARK"' } },
|
|
155
155
|
{ id: "call_pdm_3", name: "entwurf_self", arguments: {} },
|
|
156
156
|
];
|
|
157
|
-
/** The contact's
|
|
157
|
+
/** The contact's eight tool names, typed here (this gate imports no adapter constant). */
|
|
158
158
|
const CONTACT_TOOLS = [
|
|
159
159
|
"entwurf_self",
|
|
160
160
|
"entwurf_peers",
|
|
@@ -162,6 +162,8 @@ const CONTACT_TOOLS = [
|
|
|
162
162
|
"entwurf_inbox_read",
|
|
163
163
|
"entwurf_callback",
|
|
164
164
|
"entwurf_fresh_call",
|
|
165
|
+
"entwurf_resume_call",
|
|
166
|
+
"entwurf_retire_call",
|
|
165
167
|
];
|
|
166
168
|
const IMPORT_OBSERVER = `data:text/javascript,${encodeURIComponent(
|
|
167
169
|
[
|
|
@@ -1729,7 +1731,7 @@ function finalWireNative(ctx: NativeContext): void {
|
|
|
1729
1731
|
(t) => t.function?.name ?? "",
|
|
1730
1732
|
);
|
|
1731
1733
|
ok(
|
|
1732
|
-
`[QK:PDM-WIRE-TOOLS-OFFERED] the app's first model request offers the module's tool, the native bash and all
|
|
1734
|
+
`[QK:PDM-WIRE-TOOLS-OFFERED] the app's first model request offers the module's tool, the native bash and all eight contact tools together (offered: ${offered.join(", ")})`,
|
|
1733
1735
|
["mock_native_echo", "bash", ...CONTACT_TOOLS].every((name) => offered.includes(name)),
|
|
1734
1736
|
);
|
|
1735
1737
|
ok(
|