@junghanacs/entwurf 0.41.0 → 0.41.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/AGENTS.md +1 -1
  2. package/BASELINE.md +7 -5
  3. package/CHANGELOG.md +426 -0
  4. package/DELIVERY.md +37 -20
  5. package/README.md +83 -81
  6. package/VERIFY.md +10 -10
  7. package/demo/README.md +1 -1
  8. package/docs/acp-backend-rail.md +1 -1
  9. package/docs/external-mcp-host.md +1 -1
  10. package/docs/setup-clean-host.md +95 -29
  11. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +14 -11
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-self-address.js +10 -0
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +23 -2
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-decider.js +7 -1
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +3 -0
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-composition.js +21 -10
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-fresh-call.js +4 -2
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-sender-identity.js +5 -0
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +6 -4
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/native-push/register.js +13 -2
  21. package/mcp/entwurf-bridge/dist/pi-extensions/lib/omp-fresh-preflight.js +2 -2
  22. package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-durable-fresh-preflight.js +1 -1
  23. package/mcp/entwurf-bridge/dist/pi-extensions/lib/remote-pair-receive.js +25 -17
  24. package/mcp/entwurf-bridge/dist/pi-extensions/lib/remote-pair-send.js +94 -28
  25. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-omp.js +57 -7
  26. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-pi-durable.js +2 -2
  27. package/mcp/entwurf-bridge/dist/scripts/remote-pair-receive.js +12 -12
  28. package/mcp/entwurf-bridge/src/index.ts +14 -11
  29. package/native/win32-x64/entwurf_win.node +0 -0
  30. package/native/win32-x64/manifest.json +2 -2
  31. package/package.json +15 -14
  32. package/pi/pi-durable/carrier/tui.js +18 -3
  33. package/pi/pi-durable/overlay/upstream-pin.json +12 -12
  34. package/pi-extensions/lib/acp/backend-adapter.ts +5 -3
  35. package/pi-extensions/lib/acp/backend.ts +12 -9
  36. package/pi-extensions/lib/entwurf-self-address.ts +13 -0
  37. package/pi-extensions/lib/entwurf-v2-contract.ts +25 -2
  38. package/pi-extensions/lib/entwurf-v2-decider.ts +7 -0
  39. package/pi-extensions/lib/entwurf-v2-surface.ts +3 -0
  40. package/pi-extensions/lib/fresh-call-composition.ts +21 -10
  41. package/pi-extensions/lib/herdr-fresh-call.ts +4 -2
  42. package/pi-extensions/lib/meta-sender-identity.ts +4 -0
  43. package/pi-extensions/lib/mux-fresh-call.ts +6 -4
  44. package/pi-extensions/lib/native-push/register.ts +17 -2
  45. package/pi-extensions/lib/omp-fresh-preflight.ts +2 -2
  46. package/pi-extensions/lib/pi-durable-fresh-preflight.ts +1 -1
  47. package/pi-extensions/lib/remote-pair-receive.ts +27 -19
  48. package/pi-extensions/lib/remote-pair-send.ts +117 -30
  49. package/pi-extensions/meta-bridge-omp.ts +69 -7
  50. package/pi-extensions/meta-bridge-pi-durable.ts +3 -3
  51. package/run.sh +218 -49
  52. package/scripts/check-agy-sender-identity.ts +6 -4
  53. package/scripts/check-entwurf-self-address.ts +34 -3
  54. package/scripts/check-entwurf-v2-contract.ts +24 -2
  55. package/scripts/check-entwurf-v2-production.ts +45 -31
  56. package/scripts/check-gate-qualification.ts +11 -9
  57. package/scripts/check-native-push-adapter.ts +4 -0
  58. package/scripts/check-native-push-register.ts +32 -82
  59. package/scripts/check-remote-pair-installed.ts +2 -2
  60. package/scripts/dev-bin.sh +112 -1
  61. package/scripts/lib/chain-terminus-publication.ts +58 -0
  62. package/scripts/lib/codex-fresh-live-protocol.ts +33 -2
  63. package/scripts/lib/codex-fresh-source-receipts.ts +112 -3
  64. package/scripts/lib/pi-durable-fresh-live-receipts.ts +29 -1
  65. package/scripts/mutants/chain-terminus-publication.json +17 -0
  66. package/scripts/mutants/codex-native.json +98 -0
  67. package/scripts/mutants/control-send-receipt.json +3 -3
  68. package/scripts/mutants/mux-fresh-call.json +16 -1
  69. package/scripts/mutants/native-push-paused.json +17 -0
  70. package/scripts/mutants/omp-fresh.json +13 -2
  71. package/scripts/mutants/pack-install.json +50 -4
  72. package/scripts/mutants/pi-durable.json +60 -0
  73. package/scripts/mutants/pi-floor.json +5 -5
  74. package/scripts/mutants/remote-pair-display.json +3 -3
  75. package/scripts/mutants/remote-pair-receive.json +41 -11
  76. package/scripts/mutants/remote-pair-roundtrip.json +67 -7
  77. package/scripts/mutants/remote-pair-send.json +156 -17
  78. package/scripts/mutants/remote-pair-transport.json +4 -4
  79. package/scripts/mutants/source-install.json +66 -0
  80. package/scripts/remote-pair-receive.ts +12 -12
  81. package/scripts/smoke-agy-install-state.sh +121 -0
  82. package/scripts/smoke-agy-native-push-live.ts +13 -0
  83. package/scripts/smoke-codex-fresh-live.ts +52 -28
  84. package/scripts/smoke-entwurf-chain-live.ts +7 -9
  85. package/scripts/smoke-herdr-fresh-call-live.ts +4 -4
  86. package/scripts/smoke-herdr-plugin-build-live.ts +179 -50
  87. package/scripts/smoke-herdr-raw-install-live.sh +10 -3
  88. package/scripts/smoke-mux-lifecycle-live.ts +140 -8
  89. package/scripts/smoke-omp-fresh-live.ts +5 -4
  90. package/scripts/smoke-pi-durable-fresh-live.ts +29 -7
  91. package/scripts/smoke-setup-verdict.sh +27 -2
@@ -430,9 +430,9 @@ ok(
430
430
  // cannot enforce it — these semantic fixtures (the SSOT predicate the 5b decider
431
431
  // mints against) do.
432
432
  eq(
433
- "?6: PRE_PROBE_REJECT_REASONS = bad-target/target-locked/target-address-conflict/record-less-socket",
433
+ "?6: PRE_PROBE_REJECT_REASONS = bad-target/target-locked/target-address-conflict/record-less-socket/native-push-paused",
434
434
  [...PRE_PROBE_REJECT_REASONS],
435
- ["bad-target", "target-locked", "target-address-conflict", "record-less-socket"],
435
+ ["bad-target", "target-locked", "target-address-conflict", "record-less-socket", "native-push-paused"],
436
436
  );
437
437
  // the partition is exhaustive + disjoint: every reject reason is pre-probe XOR not.
438
438
  for (const r of ENTWURF_V2_REJECT_REASONS) {
@@ -467,6 +467,28 @@ ok(
467
467
  "taxonomy: record-less-socket is NOT a resolver reason (target-resolution stage, not a table cell)",
468
468
  !(RESOLVER_REJECT_REASONS as readonly string[]).includes("record-less-socket"),
469
469
  );
470
+
471
+ // #152: native-push-paused — a native-push backend paused by policy (antigravity: its LS
472
+ // refused entwurf's measured external agentapi calls for a missing CSRF token). The decider refuses it BEFORE
473
+ // any adapter probe, so it is pre-probe (null liveness), never a resolver table cell and
474
+ // never one of the post-probe native-push outcomes it must not impersonate (dead /
475
+ // indeterminate).
476
+ ok(
477
+ "taxonomy: native-push-paused is in the enum (#152)",
478
+ (ENTWURF_V2_REJECT_REASONS as readonly string[]).includes("native-push-paused"),
479
+ );
480
+ ok(
481
+ "?6: native-push-paused is pre-probe (#152 — refused before any adapter probe)",
482
+ isPreProbeReject("native-push-paused"),
483
+ );
484
+ ok(
485
+ "taxonomy: native-push-paused is NOT a resolver reason (#152)",
486
+ !(RESOLVER_REJECT_REASONS as readonly string[]).includes("native-push-paused"),
487
+ );
488
+ ok(
489
+ "taxonomy: native-push-paused is NOT a post-probe native-push reject (#152 — not dead, not indeterminate)",
490
+ !(NATIVE_PUSH_REJECT_REASONS as readonly string[]).includes("native-push-paused"),
491
+ );
470
492
  ok(
471
493
  "taxonomy: the retired socket-only-no-resume-authority is GONE from the enum (#50 C4)",
472
494
  !(ENTWURF_V2_REJECT_REASONS as readonly string[]).includes("socket-only-no-resume-authority"),
@@ -344,45 +344,54 @@ async function main(): Promise<void> {
344
344
  ok("A2: a quarantined target is never lock-acquired", spies.acquire.length === 0);
345
345
  }
346
346
 
347
- // ── A3: native-push wiring — ONE injected adapter feeds BOTH decider + executor ─
347
+ // ── A3: antigravity native-push is PAUSED (#152) — the production factory refuses it ─
348
+ // The agy LS refused entwurf's measured external agentapi calls (missing CSRF token), so the public dispatch
349
+ // must stop at the decider with the named pre-probe reject: no adapter resolved, nothing
350
+ // probed or sent, no lock, no mailbox, and no sender envelope asked for. The probe fake is
351
+ // configured ALIVE on purpose — a regression that reached it would deliver, not hide.
348
352
  {
349
353
  const { deps, spies } = makeSpiedFactory({
350
354
  backend: "antigravity",
351
355
  nativePushProbe: { status: "alive", route: { backend: "antigravity", lsAddress: "127.0.0.1:5599" } },
352
356
  });
353
357
  const result = await runEntwurfV2({ target: GID, intent: "fire-and-forget", message: "hi agy" }, deps);
354
- ok("A3: antigravity ff → native-push delivered", result.kind === "executed" && result.transport === "native-push");
355
- ok("A3: decider probed the native-push adapter once (decide side)", spies.nativePushProbe.length === 1);
356
- ok("A3: executor sent via the native-push adapter once (execute side)", spies.nativePushSend.length === 1);
357
- ok("A3: send used the DECIDER-probed route", spies.nativePushSend[0]?.route === "127.0.0.1:5599");
358
- ok("A3: send carried the dispatch message", spies.nativePushSend[0]?.content.includes("hi agy") === true);
359
358
  ok(
360
- "A3: BOTH hands resolved the SAME adapter (for 'antigravity')",
361
- spies.nativePushResolve.length === 2 && spies.nativePushResolve.every((b) => b === "antigravity"),
359
+ "A3: antigravity ff → rejected native-push-paused (#152), observedLiveness null",
360
+ result.kind === "rejected" &&
361
+ result.receipt.reason === "native-push-paused" &&
362
+ result.receipt.observedLiveness === null,
362
363
  );
363
- ok("A3: native-push is LOCK-FREE (no acquire)", spies.acquire.length === 0);
364
- ok("A3: native-push did NOT enqueue a mailbox (not the unsupported path)", spies.enqueue.length === 0);
364
+ ok("A3: paused agy resolved NO native-push adapter (neither hand)", spies.nativePushResolve.length === 0);
365
+ ok(
366
+ "A3: paused agy probed and sent nothing",
367
+ spies.nativePushProbe.length === 0 && spies.nativePushSend.length === 0,
368
+ );
369
+ ok("A3: paused agy took no lock and enqueued no mailbox", spies.acquire.length === 0 && spies.enqueue.length === 0);
370
+ ok("A3: paused agy never asked the sender provider (no execute phase)", spies.senderCalls.length === 0);
365
371
  }
372
+ // ── A3c: native-push wiring — ONE injected adapter feeds BOTH decider + executor (Codex,
373
+ // the active native-push backend while agy is paused) ─
366
374
  {
375
+ const socketPath = "/home/operator/.codex/app-server-control/app-server-control.sock";
367
376
  const { deps, spies } = makeSpiedFactory({
368
377
  backend: "codex",
369
- nativePushProbe: {
370
- status: "alive",
371
- route: { backend: "codex", socketPath: "/home/operator/.codex/app-server-control/app-server-control.sock" },
372
- },
378
+ nativePushProbe: { status: "alive", route: { backend: "codex", socketPath } },
373
379
  });
374
380
  const result = await runEntwurfV2({ target: GID, intent: "fire-and-forget", message: "hi codex" }, deps);
375
381
  ok(
376
382
  "[QK:CODEX-NATIVE-PRODUCTION-WIRING] Codex ff → native-push delivered",
377
383
  result.kind === "executed" && result.transport === "native-push",
378
384
  );
385
+ ok("A3c: decider probed the native-push adapter once (decide side)", spies.nativePushProbe.length === 1);
386
+ ok("A3c: executor sent via the native-push adapter once (execute side)", spies.nativePushSend.length === 1);
379
387
  ok(
380
- "A3c: both hands resolve the Codex adapter",
381
- spies.nativePushResolve.length === 2 && spies.nativePushResolve.every((b) => b === "codex"),
388
+ "A3c: send used the DECIDER-probed route (the planted Codex UDS)",
389
+ spies.nativePushSend[0]?.route === socketPath,
382
390
  );
391
+ ok("A3c: send carried the dispatch message", spies.nativePushSend[0]?.content.includes("hi codex") === true);
383
392
  ok(
384
- "A3c: planted Codex UDS reaches the send hand",
385
- spies.nativePushSend[0]?.route.endsWith("/app-server-control.sock") === true,
393
+ "A3c: BOTH hands resolved the SAME adapter (for 'codex')",
394
+ spies.nativePushResolve.length === 2 && spies.nativePushResolve.every((b) => b === "codex"),
386
395
  );
387
396
  ok(
388
397
  "A3c: Codex native-push remains lock-free and mailbox-free",
@@ -394,16 +403,20 @@ async function main(): Promise<void> {
394
403
  // Codex thread arrived as bare text, so the receiving citizen could not name who called it
395
404
  // and the callback was uncorrelatable. Direct injection has neither the control socket's RPC
396
405
  // framing nor a mailbox file, so the envelope must ride IN the body — the same bytes
397
- // `formatMetaMailboxBody` gives the mailbox rail. ONE rail: both native-push backends, no
398
- // per-backend branch.
406
+ // `formatMetaMailboxBody` gives the mailbox rail. ONE rail, no per-backend branch; it is
407
+ // proven on Codex, the native-push backend public dispatch still reaches while agy is
408
+ // paused (#152).
399
409
  {
400
- const route = { backend: "antigravity", lsAddress: "127.0.0.1:5599" } as const;
410
+ const route = {
411
+ backend: "codex",
412
+ socketPath: "/home/operator/.codex/app-server-control/app-server-control.sock",
413
+ } as const;
401
414
  const { deps, spies } = makeSpiedFactory({
402
- backend: "antigravity",
415
+ backend: "codex",
403
416
  nativePushProbe: { status: "alive", route },
404
417
  });
405
418
  const { result, executeEvents } = await runInTwoPhases(
406
- { target: GID, intent: "fire-and-forget", message: "hi agy", wantsReply: true },
419
+ { target: GID, intent: "fire-and-forget", message: "hi codex", wantsReply: true },
407
420
  deps,
408
421
  spies,
409
422
  );
@@ -417,12 +430,12 @@ async function main(): Promise<void> {
417
430
  result.kind === "executed" && spies.senderCalls.length === 1,
418
431
  );
419
432
  ok(
420
- "[QK:NATIVE-PUSH-SENDER-ENVELOPE] agy direct injection carries the mailbox-serialized trusted envelope",
421
- spies.nativePushSend[0]?.content === formatMetaMailboxBody(SENDER, "hi agy", true),
433
+ "[QK:NATIVE-PUSH-SENDER-ENVELOPE] native-push direct injection carries the mailbox-serialized trusted envelope",
434
+ spies.nativePushSend[0]?.content === formatMetaMailboxBody(SENDER, "hi codex", true),
422
435
  );
423
436
  ok(
424
437
  "A3d: the enveloped body is NOT the raw plan message (an envelope was actually added)",
425
- spies.nativePushSend[0]?.content !== "hi agy",
438
+ spies.nativePushSend[0]?.content !== "hi codex",
426
439
  );
427
440
  ok(
428
441
  "A3d: wants_reply rides the envelope on this rail",
@@ -439,7 +452,7 @@ async function main(): Promise<void> {
439
452
  );
440
453
  }
441
454
  {
442
- // Same rail, other backend — codex renders identically. A backend branch would show here.
455
+ // Same rail, wants_reply=false — the envelope is still rendered, without the reply line.
443
456
  const route = {
444
457
  backend: "codex",
445
458
  socketPath: "/home/operator/.codex/app-server-control/app-server-control.sock",
@@ -447,7 +460,7 @@ async function main(): Promise<void> {
447
460
  const { deps, spies } = makeSpiedFactory({ backend: "codex", nativePushProbe: { status: "alive", route } });
448
461
  const result = await runEntwurfV2({ target: GID, intent: "fire-and-forget", message: "hi codex" }, deps);
449
462
  ok(
450
- "A3d: codex direct injection carries the SAME mailbox-serialized envelope (one rail, no backend branch)",
463
+ "A3d: wants_reply=false direct injection carries the mailbox-serialized envelope for that flag",
451
464
  result.kind === "executed" &&
452
465
  spies.nativePushSend[0]?.content === formatMetaMailboxBody(SENDER, "hi codex", false),
453
466
  );
@@ -503,14 +516,15 @@ async function main(): Promise<void> {
503
516
  }
504
517
 
505
518
  {
506
- // antigravity + dead probe → reject native-push-target-dead, NO send.
519
+ // codex + dead probe → reject native-push-target-dead, NO send. (agy never reaches a
520
+ // probe while paused, #152 — A3.)
507
521
  const { deps, spies } = makeSpiedFactory({
508
- backend: "antigravity",
522
+ backend: "codex",
509
523
  nativePushProbe: { status: "dead", reason: "no host" },
510
524
  });
511
525
  const result = await runEntwurfV2({ target: GID, intent: "fire-and-forget", message: "x" }, deps);
512
526
  ok(
513
- "A3b: antigravity ff + dead → rejected native-push-target-dead",
527
+ "A3b: codex ff + dead → rejected native-push-target-dead",
514
528
  result.kind === "rejected" && result.receipt.reason === "native-push-target-dead",
515
529
  );
516
530
  ok("A3b: no send attempted on a dead target", spies.nativePushSend.length === 0);
@@ -3459,7 +3459,9 @@ let manifestCount: number;
3459
3459
  "retirement-event": 16,
3460
3460
  "control-retire": 9,
3461
3461
  "control-send-receipt": 19,
3462
- "codex-native": 87,
3462
+ "codex-native": 95,
3463
+ "native-push-paused": 1,
3464
+ "chain-terminus-publication": 1,
3463
3465
  "compaction-send-guard": 7,
3464
3466
  "control-socket-disconnect": 4,
3465
3467
  "demo-reproducibility": 12,
@@ -3470,7 +3472,7 @@ let manifestCount: number;
3470
3472
  "pi-bridge-sender": 4,
3471
3473
  "pi-mcp-bridge": 2,
3472
3474
  "pi-mcp-register": 3,
3473
- "pi-durable": 89,
3475
+ "pi-durable": 94,
3474
3476
  "copilot-receive": 20,
3475
3477
  "entwurf-peers": 1,
3476
3478
  "fresh-call-dispatch": 15,
@@ -3491,30 +3493,30 @@ let manifestCount: number;
3491
3493
  "meta-identity": 9,
3492
3494
  "meta-retire": 4,
3493
3495
  "mux-boundary": 16,
3494
- "mux-fresh-call": 65,
3496
+ "mux-fresh-call": 66,
3495
3497
  "mux-launcher-fence": 8,
3496
3498
  "mux-parent-artifact": 3,
3497
- "pack-install": 5,
3499
+ "pack-install": 9,
3498
3500
  "pi-package-ownership": 8,
3499
3501
  "qualification-ledger": 42,
3500
3502
  "mux-resume-call": 13,
3501
3503
  "omp-birth": 13,
3502
- "omp-fresh": 24,
3504
+ "omp-fresh": 25,
3503
3505
  "omp-receive": 11,
3504
3506
  "probe-ordering": 1,
3505
3507
  "release-gate": 21,
3506
3508
  "remote-pair-display": 3,
3507
3509
  "remote-pair-installed-audit": 2,
3508
- "remote-pair-receive": 8,
3509
- "remote-pair-roundtrip": 5,
3510
- "remote-pair-send": 12,
3510
+ "remote-pair-receive": 10,
3511
+ "remote-pair-roundtrip": 9,
3512
+ "remote-pair-send": 21,
3511
3513
  "remote-pair-transport": 4,
3512
3514
  "retired-sent-surface": 2,
3513
3515
  "resume-args": 6,
3514
3516
  "resume-launch-identity": 10,
3515
3517
  "self-address": 8,
3516
3518
  "setup-verdict": 14,
3517
- "source-install": 2,
3519
+ "source-install": 8,
3518
3520
  "typing-call-fence": 1,
3519
3521
  "v2-surface": 14,
3520
3522
  "v2-visible-resume": 40,
@@ -18,6 +18,10 @@
18
18
  * - resolver fail-fast: resolveNativePushAdapter("antigravity") resolves; unknown throws.
19
19
  *
20
20
  * Pure; no backend, no socket, no real process.
21
+ *
22
+ * #152: this is the adapter LEAF only. Public agy delivery, sender replyability and
23
+ * registration are paused before any adapter is resolved; a green here is not agy
24
+ * availability and not a live pass.
21
25
  */
22
26
 
23
27
  import assert from "node:assert/strict";
@@ -4,17 +4,16 @@
4
4
  * agy) and an ISOLATED temp meta-record store (never the real ~/.pi).
5
5
  *
6
6
  * Proves:
7
- * - live probe → CREATE: a fresh live conversation mints a garden citizen whose meta-record
8
- * carries the backend / nativeSessionId / caller-stated cwd; the action is "create".
9
- * - re-register → ATTACH: the same conversation resolves to the SAME garden id and REFRESHES
10
- * the cwd (봉인 5) — it never mints a second id.
11
- * - not-live probe → REFUSE: a dead / indeterminate probe THROWS and writes NO record (no
12
- * garden id is engraved onto a pointer that does not resolve to a live host).
7
+ * - #152 PAUSE: antigravity — the only registerable backend — is refused by name BEFORE any
8
+ * adapter is resolved or probed, and NO record is written. While the pause holds, the create /
9
+ * attach / refuse-not-live mechanics behind that guard are unreachable, so this gate no longer
10
+ * claims them; they return with the pause's lift.
11
+ * - the paused-policy check sits BEFORE the probe in the register source.
13
12
  * - RECEIVER-MARKER ABSTINENCE (보정①): the register source references NO receiver-marker
14
13
  * writer — a native-push citizen has no idle-wake watch, so arming one would smuggle
15
14
  * native-push liveness into the mailbox deliverability atom.
16
15
  *
17
- * Deterministic (fake adapter); the only IO is an isolated mkdtemp store (like the meta gates).
16
+ * Deterministic (trap adapter); the only IO is an isolated mkdtemp store (like the meta gates).
18
17
  */
19
18
 
20
19
  import assert from "node:assert/strict";
@@ -22,8 +21,6 @@ import { mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs";
22
21
  import * as os from "node:os";
23
22
  import * as path from "node:path";
24
23
  import { fileURLToPath } from "node:url";
25
- import { readMetaIdentityByGardenId } from "../pi-extensions/lib/meta-session.ts";
26
- import type { NativePushAdapter, NativePushProbeResult } from "../pi-extensions/lib/native-push/adapter.ts";
27
24
  import { registerNativeConversation } from "../pi-extensions/lib/native-push/register.ts";
28
25
 
29
26
  let passed = 0;
@@ -35,83 +32,31 @@ function ok(label: string, cond: boolean): void {
35
32
 
36
33
  const REPO_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
37
34
 
38
- /** A fake native-push adapter whose probe returns a fixed result and whose send is unused. */
39
- function fakeAdapter(probe: NativePushProbeResult): NativePushAdapter {
40
- return {
41
- id: "antigravity",
42
- retriable: true,
43
- async probe() {
44
- return probe;
45
- },
46
- async send() {
47
- throw new Error("register gate: adapter.send must not be called during registration");
48
- },
49
- };
50
- }
51
-
52
- const ALIVE: NativePushProbeResult = {
53
- status: "alive",
54
- route: { backend: "antigravity", lsAddress: "127.0.0.1:5599" },
55
- };
56
-
57
35
  async function main(): Promise<void> {
58
36
  const dir = mkdtempSync(path.join(os.tmpdir(), "entwurf-register-"));
59
37
  try {
60
- // ── live probe → CREATE ───────────────────────────────────────────────────
61
- const created = await registerNativeConversation(
62
- { backend: "antigravity", nativeSessionId: "conv-abc", cwd: "/work/one" },
63
- { resolveAdapter: () => fakeAdapter(ALIVE), sessionsDir: dir },
64
- );
65
- ok("create: action = create", created.action === "create");
66
- ok("create: gardenId shaped YYYYMMDDTHHMMSS-xxxxxx", /^\d{8}T\d{6}-[0-9a-f]{6}$/.test(created.gardenId));
67
- ok("create: backend antigravity", created.backend === "antigravity");
68
- ok("create: nativeSessionId echoed", created.nativeSessionId === "conv-abc");
69
- ok("create: cwd = caller-stated", created.cwd === "/work/one");
70
- // the on-disk record agrees (read back through the authority).
71
- const rec = readMetaIdentityByGardenId(created.gardenId, dir);
72
- ok("create: record backend antigravity", rec.backend === "antigravity");
73
- ok("create: record nativeSessionId bound", rec.nativeSessionId === "conv-abc");
74
- ok("create: record cwd recorded", rec.cwd === "/work/one");
75
-
76
- // ── re-register same conversation → ATTACH (same gid, cwd refreshed) ───────
77
- const reattached = await registerNativeConversation(
78
- { backend: "antigravity", nativeSessionId: "conv-abc", cwd: "/work/two" },
79
- { resolveAdapter: () => fakeAdapter(ALIVE), sessionsDir: dir },
80
- );
81
- ok("attach: action = attach", reattached.action === "attach");
82
- ok("attach: SAME garden id (no second mint)", reattached.gardenId === created.gardenId);
83
- ok(
84
- "attach: cwd REFRESHED to the new value",
85
- reattached.gardenId === created.gardenId && reattached.cwd === "/work/two",
86
- );
87
- const rec2 = readMetaIdentityByGardenId(created.gardenId, dir);
88
- ok("attach: on-disk cwd refreshed", rec2.cwd === "/work/two");
89
- // exactly ONE record file for this conversation (no duplicate mint).
90
- const records = readdirSync(dir).filter((f) => f.endsWith(".meta.json"));
91
- ok("attach: exactly ONE meta-record (no duplicate garden id)", records.length === 1);
92
-
93
- // ── not-live probe → REFUSE (throw), NO record written ────────────────────
94
- for (const probe of [
95
- { status: "dead", reason: "no host" } as const,
96
- { status: "indeterminate", reason: "no port served" } as const,
97
- ]) {
98
- const before = readdirSync(dir).length;
99
- let threw = false;
100
- try {
101
- await registerNativeConversation(
102
- { backend: "antigravity", nativeSessionId: "conv-ghost", cwd: "/work/x" },
103
- { resolveAdapter: () => fakeAdapter(probe), sessionsDir: dir },
104
- );
105
- } catch (err) {
106
- threw = true;
107
- ok(
108
- `refuse(${probe.status}): error names the non-live probe`,
109
- new RegExp(probe.status).test((err as Error).message),
110
- );
111
- }
112
- ok(`refuse(${probe.status}): THROWS (no garden id for a non-live conversation)`, threw);
113
- ok(`refuse(${probe.status}): NO record written`, readdirSync(dir).length === before);
38
+ // ── #152: antigravity is PAUSED → REFUSE by name, no adapter, NO record ──────
39
+ let resolved = 0;
40
+ const before = readdirSync(dir).length;
41
+ let message = "";
42
+ try {
43
+ await registerNativeConversation(
44
+ { backend: "antigravity", nativeSessionId: "conv-abc", cwd: "/work/one" },
45
+ {
46
+ resolveAdapter: () => {
47
+ resolved++;
48
+ throw new Error("register gate: no adapter may be resolved for a paused backend");
49
+ },
50
+ sessionsDir: dir,
51
+ },
52
+ );
53
+ } catch (err) {
54
+ message = (err as Error).message;
114
55
  }
56
+ ok("paused: register THROWS for antigravity", message !== "");
57
+ ok("paused: the error names the pause (#152)", /paused/.test(message) && /#152/.test(message));
58
+ ok("paused: no adapter resolved (nothing probed)", resolved === 0);
59
+ ok("paused: NO record written", readdirSync(dir).length === before);
115
60
  } finally {
116
61
  rmSync(dir, { recursive: true, force: true });
117
62
  }
@@ -127,6 +72,11 @@ async function main(): Promise<void> {
127
72
  // it DOES reuse the upsert authority (봉인 5) and the adapter probe.
128
73
  ok("wiring: register reuses upsertMetaSession", /upsertMetaSession/.test(src));
129
74
  ok("wiring: register probes the adapter before minting", /\.probe\(/.test(src));
75
+ ok(
76
+ "wiring: the paused-policy check precedes the adapter probe",
77
+ src.indexOf("nativePushPaused(input.backend)") > 0 &&
78
+ src.indexOf("nativePushPaused(input.backend)") < src.indexOf(".probe("),
79
+ );
130
80
 
131
81
  console.log(`\ncheck-native-push-register: ${passed} assertions passed`);
132
82
  }
@@ -190,8 +190,8 @@ try {
190
190
  );
191
191
  const summary = (vitest.stdout.match(/Tests\s+.*$/m) ?? [""])[0].trim();
192
192
  ok(
193
- "[QK:RPI-ROUNDTRIP-ON-INSTALL] the T2 cells (A→B, reverse dispatch, opt-in absent off, other host refuses, both socket dirs) pass against the installed bridge and entry",
194
- vitest.status === 0 && /^Tests\s+5 passed\b/.test(summary),
193
+ "[QK:RPI-ROUNDTRIP-ON-INSTALL] the T2 cells (A→B, reverse dispatch, opt-in absent off, other host refuses, both socket dirs, and the #149 three-route cells: independent, reverse, undeclared sender, cross claim) pass against the installed bridge and entry",
194
+ vitest.status === 0 && /^Tests\s+9 passed\b/.test(summary),
195
195
  `${summary}\n${vitest.stdout
196
196
  .split("\n")
197
197
  .filter((l) => /×|FAIL|Error/.test(l))
@@ -34,6 +34,14 @@
34
34
  # remove [name] honest inverse from state (remove only OUR link; REFUSE if it became foreign).
35
35
  # no name = all managed bins.
36
36
  # verify <name> require OUR symlink → target and that exact link as the PATH winner.
37
+ # exclusive require that NO other root answers to any package.json bin name — not only
38
+ # the PATH winner. A winner check alone let a later PATH entry (an old global
39
+ # shim) stay selectable by a shell with another PATH order, e.g. non-interactive
40
+ # SSH. Read-only: every entry of this PATH plus the global bin dir that `pnpm bin
41
+ # -g` / `npm prefix -g` report. Same resolved target = the same install (allowed);
42
+ # anything else is another root, never guessed as ours. exit 0 none / 5 another
43
+ # root (named, with a conditional removal hint — nothing is removed) / 6 the
44
+ # package manifest or a package manager could not be inspected (fail-closed).
37
45
  #
38
46
  # Overridable for the isolated smoke:
39
47
  # ENTWURF_DEV_BIN_DIR link location (default: ~/.local/bin — where agy itself lives)
@@ -42,6 +50,7 @@
42
50
  # ENTWURF_AGY_STATUSLINE_TARGET entwurf-agy-statusline target (default: $REPO/scripts/agy-statusline.sh)
43
51
  # ENTWURF_AGY_IMPRINT_TARGET entwurf-agy-imprint target (default: $REPO/scripts/agy-imprint.sh)
44
52
  # ENTWURF_COPILOT_STATUSLINE_TARGET entwurf-copilot-statusline target (default: $REPO/scripts/copilot-statusline.sh)
53
+ # ENTWURF_DEV_BIN_PM_TIMEOUT seconds one package-manager query may take (default 10)
45
54
  # XDG_DATA_HOME state root (states under $XDG_DATA_HOME/entwurf/dev-bin/)
46
55
  set -euo pipefail
47
56
 
@@ -68,6 +77,9 @@ bin_target() { # $1 = name → prints target path (env-overridable for smoke)
68
77
  *) return 1 ;;
69
78
  esac
70
79
  }
80
+ # Every name bin_target answers for. Kept apart from MANAGED_BINS (what expose links): whether a
81
+ # name is exposed must not change the target exclusive expects for it.
82
+ TARGET_BINS="entwurf entwurf-bridge entwurf-agy-statusline entwurf-agy-imprint entwurf-copilot-statusline"
71
83
  state_file_for() { echo "$STATE_DIR/$1.install-state.json"; }
72
84
 
73
85
  state_link() { # $1 = state file → recorded linkPath, or empty
@@ -184,6 +196,104 @@ verify_one() { # $1 = name
184
196
  log "[dev-bin verify] $name → $target (PATH winner: $link)"
185
197
  }
186
198
 
199
+ do_exclusive() {
200
+ # Our target per package.json bin name: a managed name through bin_target (smoke-overridable),
201
+ # every other name at its package.json path in this checkout. The manifest is read INSIDE the
202
+ # verdict step: a read that fails outside it would reach the verdict as zero names and pass.
203
+ local -a pairs=()
204
+ local b
205
+ for b in $TARGET_BINS; do pairs+=("$b=$(bin_target "$b")"); done
206
+ python3 - "${ENTWURF_DEV_BIN_PM_TIMEOUT:-10}" "$REPO_DIR" "${pairs[@]}" <<'PY'
207
+ import json, os, shutil, signal, subprocess, sys
208
+
209
+ timeout = float(sys.argv[1])
210
+ manifest = os.path.join(sys.argv[2], "package.json")
211
+ try:
212
+ with open(manifest) as f:
213
+ bins = json.load(f)["bin"]
214
+ if not (isinstance(bins, dict) and "entwurf" in bins
215
+ and all(isinstance(k, str) and k and isinstance(v, str) and v for k, v in bins.items())):
216
+ raise ValueError('"bin" is not a non-empty object of name -> path strings naming entwurf')
217
+ except (OSError, ValueError, KeyError, TypeError) as e:
218
+ print(f"[dev-bin] FAIL: could not inspect the package manifest {manifest} — {type(e).__name__}: {e}."
219
+ " Not read as 'no other root'.", file=sys.stderr)
220
+ print(" nothing was removed, no profile edited, no session stopped.", file=sys.stderr)
221
+ sys.exit(6) # an unreadable manifest is never "0 names, no other root"
222
+ managed = dict(pair.split("=", 1) for pair in sys.argv[3:])
223
+ ours = {name: os.path.realpath(managed.get(name, os.path.join(sys.argv[2], rel)))
224
+ for name, rel in bins.items()}
225
+
226
+ dirs = {} # realpath(dir) -> (dir as seen, [where it was seen])
227
+ def add(d, where):
228
+ key = os.path.realpath(d)
229
+ dirs.setdefault(key, (d, []))[1].append(where)
230
+
231
+ for d in os.environ.get("PATH", "").split(":"):
232
+ add(d or os.getcwd(), "PATH") # an empty POSIX PATH entry means the cwd
233
+
234
+ def query(argv):
235
+ exe = shutil.which(argv[0])
236
+ if exe is None:
237
+ return "skip", f"{argv[0]} not on PATH — its global bin dir was not checked"
238
+ try:
239
+ p = subprocess.Popen([exe] + argv[1:], cwd="/", stdin=subprocess.DEVNULL,
240
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE, start_new_session=True)
241
+ except OSError as e:
242
+ return "error", f"`{' '.join(argv)}` could not start: {e}"
243
+ try:
244
+ out, err = p.communicate(timeout=timeout)
245
+ except subprocess.TimeoutExpired:
246
+ os.killpg(p.pid, signal.SIGKILL)
247
+ p.communicate()
248
+ return "error", f"`{' '.join(argv)}` did not answer within {timeout:g}s"
249
+ if p.returncode != 0:
250
+ tail = err.decode(errors="replace").strip().splitlines()[-1:] or [""]
251
+ return "error", f"`{' '.join(argv)}` exited {p.returncode}: {tail[0]}"
252
+ lines = out.decode(errors="replace").strip().splitlines()
253
+ if len(lines) != 1 or not os.path.isabs(lines[0]):
254
+ return "error", f"`{' '.join(argv)}` printed {len(lines)} line(s), not one absolute path"
255
+ return "ok", lines[0]
256
+
257
+ checked, skipped, errors = ["PATH"], [], []
258
+ for argv, label, to_bin in ((["pnpm", "bin", "-g"], "pnpm global bin", lambda s: s),
259
+ (["npm", "prefix", "-g"], "npm global bin", lambda s: os.path.join(s, "bin"))):
260
+ state, value = query(argv)
261
+ if state == "ok":
262
+ add(to_bin(value), label)
263
+ checked.append(f"{label} {to_bin(value)}")
264
+ elif state == "skip":
265
+ skipped.append(value)
266
+ else:
267
+ errors.append(value)
268
+
269
+ others = []
270
+ for _key, (d, where) in dirs.items():
271
+ for name, target in ours.items():
272
+ path = os.path.join(d, name)
273
+ if os.path.lexists(path) and os.path.realpath(path) != target:
274
+ others.append((path, os.path.realpath(path), where))
275
+
276
+ for path, resolved, where in others:
277
+ print(f"[dev-bin] FAIL: another entwurf root is still selectable: {path} -> {resolved}", file=sys.stderr)
278
+ print(f" seen via: {', '.join(where)}", file=sys.stderr)
279
+ hints = [h for w, h in (("pnpm global bin", "pnpm remove -g @junghanacs/entwurf"),
280
+ ("npm global bin", "npm uninstall -g @junghanacs/entwurf")) if w in where]
281
+ for h in hints:
282
+ print(f" if a global @junghanacs/entwurf installed it: {h}", file=sys.stderr)
283
+ print(" " + ("otherwise " if hints else "") + "remove it through whoever owns it.", file=sys.stderr)
284
+ for e in errors:
285
+ print(f"[dev-bin] FAIL: could not inspect a package manager — {e}. Not read as 'no other root'.", file=sys.stderr)
286
+ if others or errors:
287
+ print(f" this checkout: {ours.get('entwurf', '?')}", file=sys.stderr)
288
+ print(" nothing was removed, no profile edited, no session stopped. Then re-run: ./run.sh setup <project>", file=sys.stderr)
289
+ sys.exit(5 if others else 6)
290
+ print(f"[dev-bin exclusive] no other root under the {len(ours)} package bin names in: {'; '.join(checked)}")
291
+ for s in skipped:
292
+ print(f" not checked: {s}")
293
+ print(" covers only these directories — another shell's PATH or another package manager is not covered.")
294
+ PY
295
+ }
296
+
187
297
  remove_one() { # $1 = name
188
298
  local name="$1" target link sf recorded
189
299
  target="$(bin_target "$name")" || fail "unknown managed bin: $name"
@@ -235,5 +345,6 @@ case "${1:-}" in
235
345
  expose) shift; do_expose "${1:-}" ;;
236
346
  remove) shift; do_remove "${1:-}" ;;
237
347
  verify) shift; [ -n "${1:-}" ] || fail "verify requires one managed bin name"; verify_one "$1" ;;
238
- *) echo "usage: dev-bin.sh <expose|remove> [bin-name] | verify <bin-name>" >&2; exit 2 ;;
348
+ exclusive) do_exclusive ;;
349
+ *) echo "usage: dev-bin.sh <expose|remove> [bin-name] | verify <bin-name> | exclusive" >&2; exit 2 ;;
239
350
  esac
@@ -0,0 +1,58 @@
1
+ /**
2
+ * The terminus wait of `smoke-entwurf-chain-live`, as one function with its fs seams injectable, so
3
+ * the oracle that judges a live chain can be driven offline through the exact publication states.
4
+ *
5
+ * `enqueueMetaMessage` (pi-extensions/lib/meta-session.ts) publishes in two writes: the `.msg` body
6
+ * FIRST, then — after the state stamp — `inbox.signal` LAST. A reader can therefore see the body
7
+ * without the signal for a few milliseconds. The P5 failure on 2026-10-24 is consistent with that by
8
+ * source and by its preserved mailbox: the old wait stopped at the first body and checked the signal
9
+ * at once, and the signal file was written about 5 ms after the body. Its interleaving was not traced.
10
+ * A body without the signal is a publication still in flight, so the wait ends only when both are
11
+ * there, inside the same deadline. What the smoke asserts afterwards (exactly one body, the signal,
12
+ * every identity and read-receipt check) is unchanged and still fails on a missing body, a missing
13
+ * signal or a duplicate.
14
+ */
15
+ import { existsSync } from "node:fs";
16
+ import fsp from "node:fs/promises";
17
+ import path from "node:path";
18
+
19
+ export interface TerminusPublicationIo {
20
+ readdir(dir: string): Promise<string[]>;
21
+ exists(file: string): boolean;
22
+ sleep(ms: number): Promise<void>;
23
+ now(): number;
24
+ }
25
+
26
+ const realIo: TerminusPublicationIo = {
27
+ readdir: (dir) => fsp.readdir(dir),
28
+ exists: (file) => existsSync(file),
29
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
30
+ now: () => Date.now(),
31
+ };
32
+
33
+ export interface TerminusPublication {
34
+ /** Every `.msg` / `.msg.delivered` seen at the last look, so a duplicate stays visible. */
35
+ msgs: string[];
36
+ /** Whether `inbox.signal` existed at the last look. */
37
+ signalled: boolean;
38
+ }
39
+
40
+ /** Wait until the terminus mailbox holds a body AND its doorbell, or the deadline passes. */
41
+ export async function awaitTerminusPublication(
42
+ boxDir: string,
43
+ untilMs: number,
44
+ pollMs: number,
45
+ io: TerminusPublicationIo = realIo,
46
+ ): Promise<TerminusPublication> {
47
+ let msgs: string[] = [];
48
+ let signalled = false;
49
+ while (io.now() < untilMs) {
50
+ msgs = (await io.readdir(boxDir).catch(() => [] as string[])).filter(
51
+ (f) => f.endsWith(".msg") || f.endsWith(".msg.delivered"),
52
+ );
53
+ signalled = io.exists(path.join(boxDir, "inbox.signal"));
54
+ if (msgs.length > 0 && signalled) break;
55
+ await io.sleep(pollMs);
56
+ }
57
+ return { msgs, signalled };
58
+ }