@junghanacs/entwurf 0.13.1 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. package/AGENTS.md +48 -15
  2. package/BASELINE.md +3 -3
  3. package/CHANGELOG.md +45 -0
  4. package/CONTRIBUTING.md +13 -9
  5. package/DELIVERY.md +7 -6
  6. package/README.md +27 -27
  7. package/VERIFY.md +22 -14
  8. package/demo/README.md +1 -1
  9. package/demo/demo-baseline.sh +1 -3
  10. package/demo/demo.sh +2 -5
  11. package/docs/acp-backend-rail.md +9 -4
  12. package/docs/external-mcp-host.md +4 -5
  13. package/docs/setup-clean-host.md +8 -7
  14. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +148 -28
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/overlay.js +8 -6
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-control-rpc.js +7 -5
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-core.js +13 -14
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-resume-args.js +45 -40
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +117 -95
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-decider.js +23 -57
  21. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-lock.js +16 -7
  22. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +5 -53
  23. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-release.js +21 -36
  24. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-runner.js +3 -15
  25. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send-fallback.js +12 -11
  26. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send.js +2 -7
  27. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +30 -67
  28. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-visible-resume.js +256 -0
  29. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +91 -5
  30. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +258 -0
  31. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-launch.js +202 -0
  32. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-placement.js +289 -0
  33. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-resume-call.js +170 -0
  34. package/mcp/entwurf-bridge/dist/pi-extensions/lib/resume-launch-identity.js +136 -0
  35. package/mcp/entwurf-bridge/dist/pi-extensions/lib/session-id.js +8 -5
  36. package/mcp/entwurf-bridge/dist/pi-extensions/lib/socket-discovery.js +3 -3
  37. package/mcp/entwurf-bridge/dist/scripts/meta-facts.js +51 -0
  38. package/mcp/entwurf-bridge/dist/scripts/new-session-id.js +9 -4
  39. package/mcp/entwurf-bridge/src/index.ts +164 -28
  40. package/mcp/entwurf-bridge/start.sh +2 -2
  41. package/mcp/entwurf-bridge/test.sh +23 -9
  42. package/mcp/entwurf-bridge/tsconfig.build.json +11 -2
  43. package/package.json +22 -11
  44. package/pi-extensions/entwurf-control.ts +218 -40
  45. package/pi-extensions/lib/acp/backend.ts +71 -12
  46. package/pi-extensions/lib/acp/overlay.ts +8 -6
  47. package/pi-extensions/lib/entwurf-control-rpc.ts +7 -5
  48. package/pi-extensions/lib/entwurf-core.ts +15 -15
  49. package/pi-extensions/lib/entwurf-resume-args.ts +41 -52
  50. package/pi-extensions/lib/entwurf-v2-contract-schema.ts +1 -1
  51. package/pi-extensions/lib/entwurf-v2-contract.ts +120 -99
  52. package/pi-extensions/lib/entwurf-v2-decider.ts +30 -91
  53. package/pi-extensions/lib/entwurf-v2-lock.ts +16 -7
  54. package/pi-extensions/lib/entwurf-v2-production.ts +4 -78
  55. package/pi-extensions/lib/entwurf-v2-release.ts +25 -49
  56. package/pi-extensions/lib/entwurf-v2-runner.ts +6 -21
  57. package/pi-extensions/lib/entwurf-v2-send-fallback.ts +12 -11
  58. package/pi-extensions/lib/entwurf-v2-send.ts +2 -7
  59. package/pi-extensions/lib/entwurf-v2-surface.ts +36 -76
  60. package/pi-extensions/lib/entwurf-v2-visible-resume.ts +370 -0
  61. package/pi-extensions/lib/meta-session.ts +93 -5
  62. package/pi-extensions/lib/mux-fresh-call.ts +328 -0
  63. package/pi-extensions/lib/mux-launch.ts +267 -0
  64. package/pi-extensions/lib/mux-placement.ts +387 -0
  65. package/pi-extensions/lib/mux-resume-call.ts +221 -0
  66. package/pi-extensions/lib/resume-launch-identity.ts +162 -0
  67. package/pi-extensions/lib/session-id.js +8 -5
  68. package/pi-extensions/lib/socket-discovery.ts +3 -3
  69. package/run.sh +471 -201
  70. package/scripts/agy-bridge-config.py +5 -1
  71. package/scripts/check-acp-backend-preflight.ts +1 -1
  72. package/scripts/check-acp-overlay.ts +13 -3
  73. package/scripts/check-acp-stream-hooks.ts +504 -0
  74. package/scripts/check-elapsed.sh +25 -0
  75. package/scripts/check-entwurf-bridge-boot.ts +51 -4
  76. package/scripts/check-entwurf-bridge-pi-free.ts +6 -5
  77. package/scripts/check-entwurf-control-rpc.ts +4 -3
  78. package/scripts/check-entwurf-resume-args.ts +72 -70
  79. package/scripts/check-entwurf-session-identity.ts +14 -10
  80. package/scripts/check-entwurf-v2-contract.ts +34 -59
  81. package/scripts/check-entwurf-v2-decider.ts +17 -177
  82. package/scripts/check-entwurf-v2-lock.ts +5 -2
  83. package/scripts/check-entwurf-v2-matrix.ts +3 -53
  84. package/scripts/check-entwurf-v2-production.ts +2 -91
  85. package/scripts/check-entwurf-v2-release.ts +10 -105
  86. package/scripts/check-entwurf-v2-runner.ts +4 -85
  87. package/scripts/check-entwurf-v2-send-fallback.ts +5 -6
  88. package/scripts/check-entwurf-v2-send.ts +0 -28
  89. package/scripts/check-entwurf-v2-surface.ts +157 -128
  90. package/scripts/check-entwurf-v2-visible-resume.ts +445 -0
  91. package/scripts/check-fresh-cut-gate.sh +1 -1
  92. package/scripts/check-gate-qualification.ts +98 -7
  93. package/scripts/check-install-container.sh +10 -2
  94. package/scripts/check-install-surface.ts +1 -1
  95. package/scripts/check-keyset-overlap.py +1 -1
  96. package/scripts/check-meta-facts.ts +249 -0
  97. package/scripts/check-meta-identity-consumers.ts +1 -1
  98. package/scripts/check-meta-session.ts +169 -0
  99. package/scripts/check-mux-launch-tmux.ts +316 -0
  100. package/scripts/check-mux-launch.ts +288 -0
  101. package/scripts/check-mux-launcher-fence.ts +264 -0
  102. package/scripts/check-mux-parent-artifact.ts +195 -0
  103. package/scripts/check-mux-placement-tmux.ts +322 -0
  104. package/scripts/check-mux-placement.ts +323 -0
  105. package/scripts/check-mux-resume-call.ts +282 -0
  106. package/scripts/check-probe-cli-shim.ts +25 -22
  107. package/scripts/check-probe-ordering.ts +84 -76
  108. package/scripts/check-release-gate-outcomes.ts +127 -7
  109. package/scripts/check-resume-launch-identity.ts +244 -0
  110. package/scripts/check-socket-discovery.ts +1 -1
  111. package/scripts/fixtures/mux-parent-transcript.scrubbed.jsonl +3 -0
  112. package/scripts/inventory-verification-surface.ts +349 -0
  113. package/scripts/lib/claude-launcher-fence.ts +322 -0
  114. package/scripts/lib/mutation-qualify.ts +109 -3
  115. package/scripts/meta-bridge-doctor.sh +6 -8
  116. package/scripts/meta-facts.ts +60 -0
  117. package/scripts/mutants/acp-overlay.json +17 -0
  118. package/scripts/mutants/acp-stream-hooks.json +158 -0
  119. package/scripts/mutants/bridge-boot-resume.json +45 -0
  120. package/scripts/mutants/meta-facts.json +50 -0
  121. package/scripts/mutants/meta-identity.json +36 -0
  122. package/scripts/mutants/mux-boundary.json +196 -0
  123. package/scripts/mutants/mux-fresh-call.json +185 -0
  124. package/scripts/mutants/mux-launcher-fence.json +123 -0
  125. package/scripts/mutants/mux-parent-artifact.json +39 -0
  126. package/scripts/mutants/mux-resume-call.json +148 -0
  127. package/scripts/mutants/probe-ordering.json +0 -1037
  128. package/scripts/mutants/release-gate.json +35 -0
  129. package/scripts/mutants/resume-args.json +76 -0
  130. package/scripts/mutants/resume-launch-identity.json +96 -0
  131. package/scripts/mutants/v2-surface.json +58 -18
  132. package/scripts/mutants/v2-visible-resume.json +215 -0
  133. package/scripts/new-session-id.ts +9 -4
  134. package/scripts/smoke-acp-raw-turn-live.ts +1 -1
  135. package/scripts/smoke-agy-native-push-live.ts +6 -17
  136. package/scripts/smoke-entwurf-v2-matrix-live.ts +1 -1
  137. package/scripts/smoke-meta-honesty.sh +1 -1
  138. package/scripts/smoke-mux-fresh-call-live.ts +365 -0
  139. package/scripts/smoke-mux-lifecycle-live.ts +1136 -0
  140. package/scripts/smoke-pi-attach.ts +1 -1
  141. package/scripts/smoke-user-scope-citizen.sh +1 -1
  142. package/scripts/tsconfig.json +1 -0
  143. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-preflight.js +0 -160
  144. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn-production.js +0 -273
  145. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn.js +0 -216
  146. package/pi-extensions/lib/entwurf-v2-spawn-production.ts +0 -373
  147. package/pi-extensions/lib/entwurf-v2-spawn.ts +0 -323
  148. package/scripts/check-acp-sdk-surface.ts +0 -275
  149. package/scripts/check-entwurf-v2-spawn-production.ts +0 -551
  150. package/scripts/check-entwurf-v2-spawn.ts +0 -399
  151. package/scripts/smoke-entwurf-v2-spawn-live.ts +0 -188
  152. package/scripts/smoke-entwurf-v2-spawn-resume-live.ts +0 -467
@@ -14,33 +14,13 @@
14
14
  * from mode/action — that would blur the F1 ownership contract).
15
15
  * - `renderEntwurfV2Result` — the result union → `{ text, isError }`, surfacing the carry-overs
16
16
  * the surface must NOT drop: a reject's reason+diagnostic, a control `rejectReason` (N3), a
17
- * spawn `lock-retained` diagnostic, and the N1 delivered+lock-dirty `execution-failed`.
17
+ * and the N1 delivered+lock-dirty `execution-failed`.
18
18
  * - `runAndRenderEntwurfV2FromSurface` — assemble production deps + run + render, so the root
19
19
  * surface never needs to name the `EntwurfV2RunResult` union (it only sees `{text,isError}`).
20
20
  */
21
- import * as path from "node:path";
22
21
  import { makeProductionEntwurfV2Deps } from "./entwurf-v2-production.js";
23
22
  import { runEntwurfV2 } from "./entwurf-v2-runner.js";
24
23
  import { FRESH_CUT_PRESCRIPTION } from "./meta-session.js";
25
- /** The operator-policy SSOT for v2 dispatch's preflight prefix-auto-approve roots (5d-4b).
26
- * ONE shared env var feeds BOTH surfaces (pi-native + MCP) — a pi session and an MCP child
27
- * both inherit it, so there is no per-surface config fork. `prefixRoots` is operator policy,
28
- * not session-local UX, so it is an env var, not a pi flag. */
29
- export const ENTWURF_PREFIX_ROOTS_ENV = "ENTWURF_PREFIX_ROOTS";
30
- /** Parse `ENTWURF_PREFIX_ROOTS` into the preflight's `prefixRoots`. `path.delimiter`-
31
- * separated (`:` on Linux/macOS); entries are trimmed, empty segments dropped. Unset / empty
32
- * / delimiters-only ⇒ `[]` (no prefix promotion — frozen decision 7, no package default).
33
- * It does NOT throw on a nonexistent/typo path: `preflight`'s normalize keeps an absolute
34
- * fallback, so a bad root simply never matches (a typo must not broaden approve, and must not
35
- * turn every owned-outcome dispatch into a loud failure). `~` is left for preflight to expand. */
36
- export function parseEntwurfPrefixRootsEnv(raw = process.env[ENTWURF_PREFIX_ROOTS_ENV]) {
37
- if (!raw)
38
- return [];
39
- return raw
40
- .split(path.delimiter)
41
- .map((s) => s.trim())
42
- .filter((s) => s.length > 0);
43
- }
44
24
  /** Surface params → the runner's `DispatchInput`. `wants_reply`→`wantsReply`; `intent` and
45
25
  * `message` pass through; `mode`/`wantsReply` are left undefined when absent so the decider's
46
26
  * own defaults (follow_up / false) apply — no double-default. */
@@ -55,27 +35,36 @@ export function toDispatchInput(params) {
55
35
  }
56
36
  /**
57
37
  * Detour B (B-a) — actionable rendering of an honest reject. The decider is UNCHANGED:
58
- * a reject stays a reject (Hard Rule 3), and intent is NEVER auto-converted (Hard Rule 2
59
- * owned→fire-and-forget mailbox fallback would break the F1 ownership contract). This only
60
- * appends a one-line "what to do instead" to the reject TEXT, so an honest reject stops
61
- * reading as "delivery impossible". Returns undefined for rejects with no useful next step.
38
+ * a reject stays a reject (Hard Rule 3). This only appends a one-line "what to do instead"
39
+ * to the reject TEXT, so an honest reject stops reading as "delivery impossible". Returns
40
+ * undefined for rejects with no useful next step.
62
41
  */
63
42
  export function actionableRejectHint(reason) {
64
43
  switch (reason) {
65
- case "backend-liveness-unsupported":
66
- // A meta-session backend (e.g. claude-code self-fetch) has no liveness predicate, so
67
- // owned-outcome has nothing to own. Replies go to the mailbox via fire-and-forget.
68
- return ("meta-session backend has no liveness predicate owned-outcome is unsupported. " +
69
- "To reply, dispatch with intent: fire-and-forget — it routes to the meta-mailbox when a " +
70
- "deliverable/active receiver is armed (else it fail-closes as mailbox-undeliverable). " +
71
- "(Intent is not auto-converted; you choose it.)");
72
- case "owned-live-no-autosend":
73
- // A live target is reachable, but owned-outcome is not an auto-send (Q2/F1).
74
- return "target is live — owned-outcome never auto-sends. Use intent: fire-and-forget (with wants_reply if you need a reply).";
75
- case "native-push-no-resume-authority":
76
- // A native-push backend (antigravity) has no pi-child to own, so owned-outcome has
77
- // nothing to own. Delivery is a direct inject via fire-and-forget.
78
- return "native-push target (e.g. antigravity) has no resume/spawn authority owned-outcome is unsupported. Use intent: fire-and-forget to direct-inject into the live conversation.";
44
+ case "dormant-fire-forget-unsupported":
45
+ // The cell that carries the whole cost of the visible-first cut. Naming the real
46
+ // state the citizen exists and is not running matters more than ever now that
47
+ // there is no verb behind it: a caller that reads "reject" as "wrong id" goes
48
+ // looking in the wrong place.
49
+ return ("this citizen's record is intact but its session is not running, and delivery has no way to wake it. " +
50
+ "The resume that used to answer here launched a hidden background child and was withdrawn under the " +
51
+ "visible-first rule; the visible same-id resume is the separate lifecycle verb entwurf_resume_call. " +
52
+ "Reopen the citizen with entwurf_resume_call {target} (pi targets only; it runs no turn), then dispatch again.");
53
+ case "indeterminate-no-spawn":
54
+ // The socket probe did not settle, so liveness is UNKNOWN — an unestablished probe
55
+ // is not a measured death, and dispatching into it could double-deliver. Say
56
+ // "the PROBE was inconclusive", never "the socket answered inconclusively":
57
+ // `indeterminate` also covers a probe that got no answer at all, was refused by
58
+ // permissions, or timed out, and "answered" claims a reply that may never have
59
+ // existed. The wire id is FROZEN and still spells "-no-spawn"; it names the rule
60
+ // (never dispatch into an indeterminate target), not a capability that still
61
+ // exists. So the hint's job is to say plainly what did NOT happen: nothing
62
+ // delivered, nothing started.
63
+ return ("the control-socket probe was inconclusive, so the target's liveness is UNKNOWN — this is not a " +
64
+ "measured death. NOTHING was delivered and NO process was started (the reason id keeps its frozen " +
65
+ "'-no-spawn' wire spelling from an era when one could be; entwurf_v2 starts nothing on any rail). " +
66
+ "Re-run entwurf_peers to re-probe; if it stays indeterminate, check for a stale socket file left at " +
67
+ "that garden id by a session that died without cleaning up.");
79
68
  case "native-push-target-dead":
80
69
  // The adapter probe found no live host process for the conversation.
81
70
  return "native-push conversation is not live (no host process found). Re-open the conversation, then retry — there is nothing to inject into.";
@@ -97,7 +86,7 @@ export function actionableRejectHint(reason) {
97
86
  /** Render the outcome-rich result to `{ text, isError }`. A reject or a thrown/failed/dirty
98
87
  * delivery is `isError:true`; a sent/fallback-sent/enqueued/observed delivery is `isError:false`.
99
88
  * A control in-band `rejected` is a non-delivery (isError:true) and carries N3 `rejectReason`
100
- * when present; a spawn `lock-retained` is fail-closed (isError:true) with its diagnostic. */
89
+ * when present. */
101
90
  export function renderEntwurfV2Result(result) {
102
91
  switch (result.kind) {
103
92
  case "rejected": {
@@ -125,27 +114,6 @@ export function renderEntwurfV2Result(result) {
125
114
  isError: !delivered,
126
115
  };
127
116
  }
128
- if (o.transport === "spawn-bg") {
129
- const res = o.result;
130
- if (res.kind === "lock-retained") {
131
- const d = res.diagnostic;
132
- return {
133
- text: `entwurf_v2 spawn-bg LOCK RETAINED (${res.reason}) — lock NOT released, operator must clear:` +
134
- `\n target: ${d.targetGardenId}` +
135
- `\n lockPath: ${d.lockPath}` +
136
- `\n expectedSocketPath: ${d.expectedSocketPath}` +
137
- `\n observeTimeoutMs: ${d.observeTimeoutMs}, killGraceMs: ${d.killGraceMs}` +
138
- (res.error ? `\n error: ${res.error}` : ""),
139
- isError: true,
140
- };
141
- }
142
- if (res.kind === "spawn-start-failed") {
143
- return { text: `entwurf_v2 spawn-bg failed to start: ${res.error}`, isError: true };
144
- }
145
- const pid = "pid" in res && res.pid !== undefined ? ` (pid ${res.pid})` : "";
146
- const exit = res.kind === "child-exited" ? ` exitCode=${res.exitCode}` : "";
147
- return { text: `entwurf_v2 spawn-bg → ${res.kind}${pid}${exit}, lock released`, isError: false };
148
- }
149
117
  if (o.transport === "native-push") {
150
118
  // direct-inject succeeded; note if the 1-shot re-probe retry fired.
151
119
  return {
@@ -179,12 +147,7 @@ export function renderEntwurfV2Result(result) {
179
147
  * fence types stay behind this one entry point.
180
148
  */
181
149
  export async function runAndRenderEntwurfV2FromSurface(params, opts) {
182
- const prodOpts = {
183
- senderProvider: opts.senderProvider,
184
- agentDir: opts.agentDir,
185
- // Explicit opts win (test / future surface override); otherwise the shared env SSOT.
186
- prefixRoots: opts.prefixRoots ?? parseEntwurfPrefixRootsEnv(),
187
- };
150
+ const prodOpts = { senderProvider: opts.senderProvider };
188
151
  const result = await runEntwurfV2(toDispatchInput(params), makeProductionEntwurfV2Deps(prodOpts));
189
152
  return renderEntwurfV2Result(result);
190
153
  }
@@ -0,0 +1,256 @@
1
+ /**
2
+ * entwurf-v2-visible-resume — stand a dormant pi citizen back up in a window the operator can
3
+ * see, under the same garden id, and say only what was actually observed.
4
+ *
5
+ * ── Where this sits ──
6
+ *
7
+ * surface (entwurf-control.ts · mcp bridge index.ts) composition root: supplies `launch`
8
+ * └─ entwurf-v2-visible-resume (this file) v2 leaves ONLY: lock, record, socket
9
+ * └─ deps.launch(argv, cwd) ── INJECTED SEAM ──→ mux-resume-call → placement leaf
10
+ *
11
+ * The seam is what keeps `docs/mux-launch-rail.md` §11 intact in both directions: v2 never
12
+ * imports mux, mux never imports entwurf core. This module cannot open a window; it can only
13
+ * ask whoever wired it to. That is deliberate — it means the delivery fence stays exactly where
14
+ * `check-mux-launch` scans for it.
15
+ *
16
+ * ── Why a resume is structurally SMALLER than a fresh call on identity, and LARGER on the lock ──
17
+ *
18
+ * `mux-fresh-call` mints no identity: the sibling calls back and its envelope IS the address.
19
+ * A resume already knows the address — that is what "same-id" means — so there is no nonce, no
20
+ * callback, and no correlation receipt. What it gains instead is the lock: two visible resumes
21
+ * of one garden id would put two processes on one transcript, and `entwurf_v2`'s in-domain
22
+ * dispatch takes the SAME per-gid lock, so a resume and a live send serialise against each other
23
+ * for free. The lock is consistency, not defence — there is no watcher behind it.
24
+ *
25
+ * ── The observation is a startup wait, not supervision ──
26
+ *
27
+ * Measured 2026-08-06: the resumed control socket appeared ~2s (ACP) and ~4s (native) after the
28
+ * window opened. A single immediate probe would therefore have observed `dead` and reported a
29
+ * successful resume as unobserved. So the observation is ONE bounded operation with a short
30
+ * interval, and everything that would make it supervision is deliberately absent:
31
+ *
32
+ * - no relaunch, ever. A refused or dead launch is a result, not a retry trigger.
33
+ * - no pane watcher. Until the deadline this module looks at ONE thing: the socket.
34
+ * - no env knob on the timeout. A tunable would make "unobserved" mean different things on
35
+ * different hosts, and the receipt would stop being comparable.
36
+ *
37
+ * On timeout the lock is RELEASED and the window is LEFT OPEN. That is the same rule fresh-call
38
+ * already ships ("a launch with no callback is a real outcome"): the window is visible, the
39
+ * operator can look, and a retained lock on a visible artifact is the worse failure — it would
40
+ * have to be cleared by hand on every slow host.
41
+ *
42
+ * ── Two receipts, never merged ──
43
+ *
44
+ * `launch` says tmux made a window and was asked to start pi. `observation` says whether the
45
+ * citizen's socket came back under the same id. Neither says the resumed citizen did anything:
46
+ * a resume runs no turn (measured — the transcript stayed byte-identical), so there is no turn
47
+ * outcome for this module to claim.
48
+ */
49
+ import { buildResumePiArgs } from "./entwurf-resume-args.js";
50
+ import { ENTWURF_V2_LOCK_DIR, acquireLock as realAcquireLock, releaseLock as realReleaseLock, } from "./entwurf-v2-lock.js";
51
+ import { ResumeBackendUnsupportedError, resolveResumeLaunchIdentity, } from "./resume-launch-identity.js";
52
+ import { isValidSessionId } from "./session-id.js";
53
+ import { CONTROL_SOCKET_DIR, inspectTargetControlSocket, mapInspectionToLiveness, } from "./socket-discovery.js";
54
+ import { probeSocketLiveness } from "./socket-probe.js";
55
+ /** How long ONE observation operation waits for the resumed citizen's socket. Fixed on purpose:
56
+ * see the module header on why this is not an env knob. */
57
+ export const RESUME_OBSERVE_TIMEOUT_MS = 30_000;
58
+ /** How often that one operation re-inspects. Short enough that a 2s startup is reported as the
59
+ * success it is, long enough that a 30s wait is ~60 lstats and not a spin. */
60
+ export const RESUME_OBSERVE_INTERVAL_MS = 500;
61
+ /** Production wiring for everything except the launch seam — the surface owns that, because it
62
+ * is the only layer allowed to know both halves. */
63
+ export function makeVisibleResumeDeps(launch, overrides = {}) {
64
+ return {
65
+ acquireLock: (gid) => realAcquireLock(gid, { dir: ENTWURF_V2_LOCK_DIR }),
66
+ releaseLock: (claim) => realReleaseLock(claim, { dir: ENTWURF_V2_LOCK_DIR }),
67
+ resolveIdentity: resolveResumeLaunchIdentity,
68
+ inspectSocket: (gid) => inspectTargetControlSocket(gid, CONTROL_SOCKET_DIR),
69
+ probeSocket: (socketPath) => probeSocketLiveness(socketPath),
70
+ launch,
71
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
72
+ now: () => Date.now(),
73
+ ...overrides,
74
+ };
75
+ }
76
+ /**
77
+ * Reopen `targetGardenId` in a visible window.
78
+ *
79
+ * Order is the safety argument. The lock is taken FIRST, before the liveness question, so the
80
+ * answer cannot change under us between reading it and acting on it — a competing resume would
81
+ * otherwise pass its own `dead` check while ours was launching. Identity is resolved under that
82
+ * lock and BEFORE any window opens, so a citizen with no transcript (measured: a pi that never
83
+ * took a turn has a socket and a record but no transcriptPath) fails loud with its own cause and
84
+ * leaves nothing behind.
85
+ */
86
+ export async function visibleResume(targetGardenId, deps) {
87
+ // The address grammar is `session-id.js`'s, not a local copy. The socket filename, the lock
88
+ // path and the record key are all keyed on that SAME id, so a second regex here could drift
89
+ // into accepting an id one of those three would reject — and this value becomes a lock path
90
+ // and a socket path a few lines down.
91
+ if (!isValidSessionId(targetGardenId)) {
92
+ return {
93
+ ok: false,
94
+ reason: "target-invalid",
95
+ detail: `${JSON.stringify(targetGardenId)} is not a garden id (YYYYMMDDTHHMMSS-xxxxxx)`,
96
+ };
97
+ }
98
+ const acquired = deps.acquireLock(targetGardenId);
99
+ if (!acquired.ok) {
100
+ return { ok: false, reason: "target-locked", detail: acquired.conflict.detail };
101
+ }
102
+ const claim = acquired.claim;
103
+ let released = false;
104
+ /** Release exactly once, and never let a failed release read as success. A lock this module
105
+ * took and could not give back is a fact the operator has to know about — the next resume or
106
+ * send against that citizen will be refused, and silence would send them looking at the wrong
107
+ * layer. */
108
+ const release = () => {
109
+ if (released)
110
+ return;
111
+ released = true;
112
+ const outcome = deps.releaseLock(claim);
113
+ if (outcome !== "released") {
114
+ throw new Error(`entwurf-v2-visible-resume: attempted to release the lock for ${targetGardenId}; the release returned ` +
115
+ `"${outcome}" instead of "released" (${claim.lockPath}) — the per-gid lock may still be held; clear ` +
116
+ `it by hand before resuming or dispatching to that citizen again.`);
117
+ }
118
+ };
119
+ try {
120
+ // Record authority, under the lock, before any window. Every failure here is a cause-rich
121
+ // throw from the leaf (foreign transcript, relative or deleted session file, no recorded
122
+ // model, unresolvable bridge) and is deliberately NOT re-typed into a reject — with ONE
123
+ // exception, matched on the error's own `reason` FIELD rather than its wording: a citizen
124
+ // of a backend that has no same-id resume. That is a capability boundary, not a defect, so
125
+ // it is a named refusal. The record is not read a second time to ask which backend it was;
126
+ // the error already carries it.
127
+ let identity;
128
+ try {
129
+ identity = deps.resolveIdentity(targetGardenId);
130
+ }
131
+ catch (err) {
132
+ if (err instanceof ResumeBackendUnsupportedError) {
133
+ return {
134
+ ok: false,
135
+ reason: err.reason,
136
+ detail: `${targetGardenId} is a ${err.backend} citizen; only pi citizens stand a control socket up, ` +
137
+ `so only they can be reopened under the same garden id`,
138
+ };
139
+ }
140
+ throw err;
141
+ }
142
+ // Liveness preflight, still under the lock. A live citizen is addressed with
143
+ // `entwurf_v2 fire-and-forget`, not reopened — and refusing here is also what stops two
144
+ // windows existing for one garden id.
145
+ const inspection = await deps.inspectSocket(targetGardenId);
146
+ const mapped = await mapInspectionToLiveness(inspection, deps.probeSocket);
147
+ if ("addressConflict" in mapped) {
148
+ return {
149
+ ok: false,
150
+ reason: "target-address-conflict",
151
+ detail: `the control-socket path for ${targetGardenId} is a symlink or not a socket — that address is ` +
152
+ `corrupt, and resuming would stand a citizen up behind it`,
153
+ };
154
+ }
155
+ if (mapped.liveness === "alive") {
156
+ return {
157
+ ok: false,
158
+ reason: "target-live",
159
+ detail: `${targetGardenId} is already live at ${mapped.socketPath} — send to it with entwurf_v2 fire-and-forget instead`,
160
+ };
161
+ }
162
+ if (mapped.liveness === "indeterminate") {
163
+ return {
164
+ ok: false,
165
+ reason: "target-indeterminate",
166
+ detail: `the control-socket probe for ${targetGardenId} was inconclusive — it is not provably dormant, ` +
167
+ `and resuming a citizen that is actually running would put two processes on one transcript`,
168
+ };
169
+ }
170
+ const runtimeArgs = buildResumePiArgs({
171
+ sessionFile: identity.sessionFile,
172
+ explicitExtensionArgs: identity.explicitExtensionArgs,
173
+ provider: identity.provider,
174
+ model: identity.model,
175
+ });
176
+ const launched = deps.launch({ cwd: identity.cwd, runtimeArgs });
177
+ if (!launched.ok) {
178
+ return { ok: false, reason: "launch-refused", detail: `${launched.reason} — ${launched.hint}` };
179
+ }
180
+ const observation = await observeSameGidSocket(targetGardenId, mapped.socketPath, deps);
181
+ return {
182
+ ok: true,
183
+ launch: { targetGardenId, sessionFile: identity.sessionFile, handle: launched.handle },
184
+ observation,
185
+ };
186
+ }
187
+ finally {
188
+ release();
189
+ }
190
+ }
191
+ /**
192
+ * ONE bounded operation: wait for the SAME garden id's control socket to answer. Alive is the
193
+ * only early exit — an inspection that comes back absent, corrupt or inconclusive during startup
194
+ * is a normal intermediate state, not a verdict, so it is simply not-yet.
195
+ */
196
+ export async function observeSameGidSocket(targetGardenId, socketPath, deps, timeoutMs = RESUME_OBSERVE_TIMEOUT_MS, intervalMs = RESUME_OBSERVE_INTERVAL_MS) {
197
+ const started = deps.now();
198
+ for (;;) {
199
+ const inspection = await deps.inspectSocket(targetGardenId);
200
+ const mapped = await mapInspectionToLiveness(inspection, deps.probeSocket);
201
+ if (!("addressConflict" in mapped) && mapped.liveness === "alive") {
202
+ return { kind: "socket-alive", socketPath: mapped.socketPath, waitedMs: deps.now() - started };
203
+ }
204
+ if (deps.now() - started >= timeoutMs) {
205
+ return { kind: "resume-unobserved", socketPath, waitedMs: deps.now() - started };
206
+ }
207
+ await deps.sleep(intervalMs);
208
+ }
209
+ }
210
+ const REJECT_HINT = {
211
+ "target-invalid": "no citizen has that id; discover targets with entwurf_peers",
212
+ "target-not-pi": "that citizen's backend has no same-id resume — reach it with entwurf_v2, or open a fresh sibling",
213
+ "target-locked": "another resume or dispatch holds this citizen's lock right now",
214
+ "target-live": "that citizen is already running — this verb only reopens dormant ones",
215
+ "target-indeterminate": "the socket probe could not prove the citizen is dormant, so nothing was started",
216
+ "target-address-conflict": "that citizen's control-socket path is corrupt",
217
+ "launch-refused": "tmux was never asked to open a window",
218
+ };
219
+ /**
220
+ * ONE renderer for both surfaces — the same reason `mux-fresh-call` has one: the two
221
+ * registrations are separate literals, and a shared renderer is what keeps the operator-visible
222
+ * answer from drifting apart between native pi and the MCP bridge.
223
+ *
224
+ * The success text keeps the two receipts visibly apart, including in the good case. A reader
225
+ * who sees "window opened" and "socket alive" as one sentence has been told the resume worked;
226
+ * a reader who sees them as two facts can tell which one is missing when only one is there.
227
+ */
228
+ export function renderVisibleResume(result) {
229
+ if (!result.ok) {
230
+ return {
231
+ text: `entwurf_resume_call rejected: ${result.reason} — ${REJECT_HINT[result.reason]}. ` +
232
+ `${result.detail}. No window was opened.`,
233
+ isError: true,
234
+ };
235
+ }
236
+ const h = result.launch.handle;
237
+ const observed = result.observation.kind === "socket-alive";
238
+ return {
239
+ text: `[entwurf resume call ↻]\n` +
240
+ ` target: ${result.launch.targetGardenId}\n` +
241
+ ` session: ${result.launch.sessionFile}\n` +
242
+ ` runtime: ${h.runtimePath} (requested start cwd ${h.cwd})\n` +
243
+ ` window: ${h.windowId} (index ${h.windowIndex}) in session ${h.sessionId}\n` +
244
+ ` pane: ${h.paneId} pid ${h.panePid}\n` +
245
+ `\n` +
246
+ `LAUNCH receipt: tmux created that window and was asked to start pi on the transcript above.\n` +
247
+ (observed
248
+ ? `OBSERVATION receipt: the control socket for ${result.launch.targetGardenId} answered after ` +
249
+ `${result.observation.waitedMs}ms — that citizen is addressable again, and entwurf_v2 fire-and-forget ` +
250
+ `now reaches it. No turn was run by this call.`
251
+ : `OBSERVATION receipt: resume-unobserved — the control socket did not answer within ` +
252
+ `${result.observation.waitedMs}ms. The window is open and visible; read it directly. The lock was ` +
253
+ `released, nothing was retried, and nothing was killed.`),
254
+ isError: false,
255
+ };
256
+ }
@@ -411,7 +411,7 @@ export function parseMetaIdentity(json) {
411
411
  // `wakeMode` describes the last centimetre, not citizen rank. The control-socket
412
412
  // adapter injects the body into a pi turn, so backend `pi` is `direct-inject`;
413
413
  // Claude's mailbox is `self-fetch`; native-push adapters are direct injection.
414
- // Dormant spawn-bg resume is a separate transport/ownership decision and does not
414
+ // Resuming a dormant citizen is a separate lifecycle capability and does not
415
415
  // turn the pi record into a mailbox citizen.
416
416
  // ---------------------------------------------------------------------------
417
417
  /** Bump only on a breaking capability-registry shape change; the parser refuses other versions. */
@@ -890,6 +890,21 @@ export function listAllMetaIdentities(entries, readRecord, opts = {}) {
890
890
  errors.sort((a, b) => (a.filename < b.filename ? -1 : a.filename > b.filename ? 1 : 0));
891
891
  return { identities, errors };
892
892
  }
893
+ /**
894
+ * The fs-bound listing — the same binding shape {@link certifyActiveStoreDir} gives the
895
+ * certification: one resolved dir, kind-carrying entries, the fd-guarded record reader.
896
+ * Exists so an in-repo facts surface (`meta-facts`, #65) reuses THE listing contract
897
+ * instead of re-binding readdir/read by hand — the exact drift that made external
898
+ * consumers carry a decaying copy of the certification. No semantics of its own:
899
+ * everything it returns is {@link listAllMetaIdentities} over the real store.
900
+ */
901
+ export function listAllMetaIdentitiesDir(dir) {
902
+ const resolved = path.resolve(expandTilde(dir));
903
+ return {
904
+ dir: resolved,
905
+ ...listAllMetaIdentities(readActiveStoreEntries(resolved), makeStoreRecordReader(resolved)),
906
+ };
907
+ }
893
908
  /**
894
909
  * The pure core of the `upsert` CLI (3D-4; v3 identity since the #50 cut). Keyed
895
910
  * on RECORD EXISTENCE, never on a backend `source` field:
@@ -1423,8 +1438,13 @@ export function readMetaReceiverMarker(opts) {
1423
1438
  * A narrower scan is not a smaller version of this: asking only about MY
1424
1439
  * `nativeSessionId` writes happily beside a drifted or duplicated record that the
1425
1440
  * doctor refuses, which is how a host ends up certified by one surface and not
1426
- * the other. The write is tmp-file + rename so a crash never leaves a
1427
- * half-written record (#30 crash-safety).
1441
+ * the other.
1442
+ *
1443
+ * The write is split by ACTION (#66): ATTACH rewrites its OWN record atomically
1444
+ * in place (tmp + rename, #30 crash-safety); CREATE publishes exclusively
1445
+ * (tmp + link) and fails loud if the minted garden id's final path already
1446
+ * exists — certification cannot see a future minted gid, so replacement-free
1447
+ * publish is the only collision safety a create has.
1428
1448
  */
1429
1449
  export function upsertMetaSession(opts) {
1430
1450
  const dir = path.resolve(expandTilde(opts.dir ?? defaultMetaSessionsDir()));
@@ -1436,15 +1456,81 @@ export function upsertMetaSession(opts) {
1436
1456
  const existing = cert.records.find((record) => record.identity.nativeSessionId === target)?.identity ?? null;
1437
1457
  const decision = decideUpsert(existing, opts.input, opts.now);
1438
1458
  const file = path.join(dir, metaRecordFilename(decision.record));
1439
- atomicWriteIdentity(file, decision.record);
1459
+ if (decision.action === "create")
1460
+ publishExclusiveIdentity(file, decision.record);
1461
+ else
1462
+ atomicWriteIdentity(file, decision.record);
1440
1463
  return { action: decision.action, record: decision.record, dir, path: file };
1441
1464
  }
1442
- /** tmp-file + rename so a crash never leaves a half-written record (v3 identity write). */
1465
+ /**
1466
+ * ATTACH replace primitive: tmp-file + rename so a crash never leaves a
1467
+ * half-written record (#30 crash-safety). rename(2) REPLACES an existing final
1468
+ * path, which is exactly right for attach (the record being rewritten is the
1469
+ * caller's own) and exactly wrong for create — a freshly minted garden id must
1470
+ * never land on this path (#66).
1471
+ */
1443
1472
  function atomicWriteIdentity(file, identity) {
1444
1473
  const tmp = `${file}.tmp-${process.pid}-${Date.now()}`;
1445
1474
  fs.writeFileSync(tmp, serializeMetaIdentity(identity), { mode: 0o600 });
1446
1475
  fs.renameSync(tmp, file);
1447
1476
  }
1477
+ /**
1478
+ * CREATE publish primitive: same-directory tmp + `link(2)` to the final path.
1479
+ * link fails with EEXIST when the final path already exists — the kernel makes
1480
+ * the existence check and the publish one atomic step, so there is no
1481
+ * check-then-rename TOCTOU window and a same-second 24-bit garden-id suffix
1482
+ * collision can never silently replace whatever already holds the final path
1483
+ * (#66). The occupied entry's bytes are untouched on refusal; tmp cleanup is
1484
+ * attempted on every path, and a published-with-residue state fails loud.
1485
+ *
1486
+ * @internal Exported only as the production subject for the meta-session gate's
1487
+ * deterministic collision oracle (the minted suffix is random, so the public
1488
+ * upsert seam cannot force a collision deterministically). Not an
1489
+ * operator/package API.
1490
+ */
1491
+ export function publishExclusiveIdentity(file, identity) {
1492
+ const tmp = `${file}.tmp-${process.pid}-${Date.now()}`;
1493
+ fs.writeFileSync(tmp, serializeMetaIdentity(identity), { mode: 0o600 });
1494
+ try {
1495
+ fs.linkSync(tmp, file);
1496
+ }
1497
+ catch (err) {
1498
+ try {
1499
+ fs.unlinkSync(tmp);
1500
+ }
1501
+ catch (cleanupErr) {
1502
+ // The primary link error stays the truth of this publish; a cleanup errno
1503
+ // never masks it. ENOENT is silent (no residue = desired state); any other
1504
+ // cleanup failure is an operator diagnostic on stderr, then the primary throws.
1505
+ if (cleanupErr.code !== "ENOENT") {
1506
+ process.stderr.write(`[meta-session] tmp cleanup after a failed CREATE publish also failed (${tmp}): ` +
1507
+ `${cleanupErr.message}\n`);
1508
+ }
1509
+ }
1510
+ if (err.code === "EEXIST") {
1511
+ throw new MetaRecordError(`meta-record CREATE collision: garden id "${identity.gardenId}" already has an entry at ` +
1512
+ `${file}. Exclusive publish refused to replace it — the occupied final-path entry was ` +
1513
+ `not modified and no ACTIVE record was published for nativeSessionId ` +
1514
+ `"${identity.nativeSessionId}".`);
1515
+ }
1516
+ throw err;
1517
+ }
1518
+ try {
1519
+ fs.unlinkSync(tmp);
1520
+ }
1521
+ catch (err) {
1522
+ // ENOENT is the goal state, not a failure: the record is published and no
1523
+ // tmp remains, so there is nothing left to clean.
1524
+ if (err.code === "ENOENT")
1525
+ return;
1526
+ // Never roll the final back: the record IS published (link succeeded).
1527
+ throw new MetaRecordError(`meta-record CREATE for garden id "${identity.gardenId}" is already published at ${file}, ` +
1528
+ `but temp cleanup failed (${tmp}): ${err.message}. The caller aborts here, so ` +
1529
+ `it received no successful birth/registration receipt; remove the temp file and retry — ` +
1530
+ `a retry with the same nativeSessionId will ATTACH to the published record, not mint a ` +
1531
+ `second identity.`);
1532
+ }
1533
+ }
1448
1534
  // ---------------------------------------------------------------------------
1449
1535
  // Mailbox delivery (step 6) — addressed by GARDEN ID. The record store is the
1450
1536
  // authority (a sender may only deliver to a known garden citizen); the mailbox