@junghanacs/entwurf 0.13.0 → 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 (193) hide show
  1. package/AGENTS.md +52 -17
  2. package/BASELINE.md +60 -206
  3. package/CHANGELOG.md +64 -0
  4. package/CONTRIBUTING.md +14 -10
  5. package/DELIVERY.md +118 -261
  6. package/README.md +91 -431
  7. package/VERIFY.md +74 -95
  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 +236 -0
  12. package/docs/external-mcp-host.md +131 -0
  13. package/docs/fresh-cut-policy.md +99 -0
  14. package/docs/setup-clean-host.md +124 -328
  15. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +160 -67
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/acp-client.js +12 -0
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +11 -10
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/config.js +1 -1
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/engraving.js +42 -1
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/models.js +4 -4
  21. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/overlay.js +11 -9
  22. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-control-rpc.js +7 -5
  23. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-core.js +13 -14
  24. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-resume-args.js +45 -40
  25. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +117 -95
  26. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-decider.js +23 -57
  27. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-lock.js +16 -7
  28. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +5 -53
  29. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-release.js +21 -36
  30. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-runner.js +3 -15
  31. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send-fallback.js +12 -11
  32. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send.js +2 -7
  33. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +30 -67
  34. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-visible-resume.js +256 -0
  35. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +91 -5
  36. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +258 -0
  37. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-launch.js +202 -0
  38. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-placement.js +289 -0
  39. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-resume-call.js +170 -0
  40. package/mcp/entwurf-bridge/dist/pi-extensions/lib/resume-launch-identity.js +136 -0
  41. package/mcp/entwurf-bridge/dist/pi-extensions/lib/session-id.js +8 -5
  42. package/mcp/entwurf-bridge/dist/pi-extensions/lib/socket-discovery.js +3 -3
  43. package/mcp/entwurf-bridge/dist/scripts/meta-facts.js +51 -0
  44. package/mcp/entwurf-bridge/dist/scripts/new-session-id.js +9 -4
  45. package/mcp/entwurf-bridge/src/index.ts +176 -67
  46. package/mcp/entwurf-bridge/start.sh +2 -2
  47. package/mcp/entwurf-bridge/test.sh +23 -9
  48. package/mcp/entwurf-bridge/tsconfig.build.json +11 -2
  49. package/package.json +25 -11
  50. package/pi-extensions/entwurf-control.ts +220 -71
  51. package/pi-extensions/lib/acp/acp-client.ts +22 -0
  52. package/pi-extensions/lib/acp/augment.ts +42 -3
  53. package/pi-extensions/lib/acp/backend-adapter.ts +12 -11
  54. package/pi-extensions/lib/acp/backend.ts +366 -38
  55. package/pi-extensions/lib/acp/config.ts +1 -1
  56. package/pi-extensions/lib/acp/engraving.ts +43 -1
  57. package/pi-extensions/lib/acp/event-mapper.ts +16 -13
  58. package/pi-extensions/lib/acp/models.ts +4 -4
  59. package/pi-extensions/lib/acp/overlay.ts +11 -9
  60. package/pi-extensions/lib/entwurf-control-rpc.ts +7 -5
  61. package/pi-extensions/lib/entwurf-core.ts +15 -15
  62. package/pi-extensions/lib/entwurf-resume-args.ts +41 -52
  63. package/pi-extensions/lib/entwurf-v2-contract-schema.ts +1 -1
  64. package/pi-extensions/lib/entwurf-v2-contract.ts +120 -99
  65. package/pi-extensions/lib/entwurf-v2-decider.ts +30 -91
  66. package/pi-extensions/lib/entwurf-v2-lock.ts +16 -7
  67. package/pi-extensions/lib/entwurf-v2-production.ts +4 -78
  68. package/pi-extensions/lib/entwurf-v2-release.ts +25 -49
  69. package/pi-extensions/lib/entwurf-v2-runner.ts +6 -21
  70. package/pi-extensions/lib/entwurf-v2-send-fallback.ts +12 -11
  71. package/pi-extensions/lib/entwurf-v2-send.ts +2 -7
  72. package/pi-extensions/lib/entwurf-v2-surface.ts +36 -76
  73. package/pi-extensions/lib/entwurf-v2-visible-resume.ts +370 -0
  74. package/pi-extensions/lib/meta-session.ts +93 -5
  75. package/pi-extensions/lib/mux-fresh-call.ts +328 -0
  76. package/pi-extensions/lib/mux-launch.ts +267 -0
  77. package/pi-extensions/lib/mux-placement.ts +387 -0
  78. package/pi-extensions/lib/mux-resume-call.ts +221 -0
  79. package/pi-extensions/lib/resume-launch-identity.ts +162 -0
  80. package/pi-extensions/lib/session-id.js +8 -5
  81. package/pi-extensions/lib/socket-discovery.ts +3 -3
  82. package/prompts/engraving.md +13 -5
  83. package/run.sh +688 -282
  84. package/scripts/agy-bridge-config.py +5 -1
  85. package/scripts/check-acp-backend-preflight.ts +1 -1
  86. package/scripts/check-acp-carrier-augment.ts +246 -10
  87. package/scripts/check-acp-cortex.ts +5 -5
  88. package/scripts/check-acp-overlay.ts +13 -3
  89. package/scripts/check-acp-prompt-lifecycle.ts +565 -0
  90. package/scripts/check-acp-stop-reason.ts +342 -0
  91. package/scripts/check-acp-stream-hooks.ts +504 -0
  92. package/scripts/check-elapsed.sh +25 -0
  93. package/scripts/check-entwurf-bridge-boot.ts +51 -4
  94. package/scripts/check-entwurf-bridge-pi-free.ts +6 -5
  95. package/scripts/check-entwurf-control-rpc.ts +4 -3
  96. package/scripts/check-entwurf-resume-args.ts +72 -70
  97. package/scripts/check-entwurf-session-identity.ts +14 -10
  98. package/scripts/check-entwurf-v2-contract.ts +34 -59
  99. package/scripts/check-entwurf-v2-decider.ts +17 -177
  100. package/scripts/check-entwurf-v2-lock.ts +5 -2
  101. package/scripts/check-entwurf-v2-matrix.ts +3 -53
  102. package/scripts/check-entwurf-v2-production.ts +2 -91
  103. package/scripts/check-entwurf-v2-release.ts +10 -105
  104. package/scripts/check-entwurf-v2-runner.ts +4 -85
  105. package/scripts/check-entwurf-v2-send-fallback.ts +5 -6
  106. package/scripts/check-entwurf-v2-send.ts +0 -28
  107. package/scripts/check-entwurf-v2-surface.ts +198 -128
  108. package/scripts/check-entwurf-v2-visible-resume.ts +445 -0
  109. package/scripts/check-fresh-cut-gate.sh +1 -1
  110. package/scripts/check-gate-qualification.ts +101 -7
  111. package/scripts/check-install-container.sh +10 -2
  112. package/scripts/check-install-surface.ts +1 -1
  113. package/scripts/check-keyset-overlap.py +1 -1
  114. package/scripts/check-meta-facts.ts +249 -0
  115. package/scripts/check-meta-identity-consumers.ts +1 -1
  116. package/scripts/check-meta-session.ts +169 -0
  117. package/scripts/check-mux-launch-tmux.ts +316 -0
  118. package/scripts/check-mux-launch.ts +288 -0
  119. package/scripts/check-mux-launcher-fence.ts +264 -0
  120. package/scripts/check-mux-parent-artifact.ts +195 -0
  121. package/scripts/check-mux-placement-tmux.ts +322 -0
  122. package/scripts/check-mux-placement.ts +323 -0
  123. package/scripts/check-mux-resume-call.ts +282 -0
  124. package/scripts/check-probe-cli-shim.ts +25 -22
  125. package/scripts/check-probe-ordering.ts +121 -81
  126. package/scripts/check-release-gate-outcomes.ts +452 -0
  127. package/scripts/check-resume-launch-identity.ts +244 -0
  128. package/scripts/check-socket-discovery.ts +1 -1
  129. package/scripts/fixtures/mux-parent-transcript.scrubbed.jsonl +3 -0
  130. package/scripts/inventory-verification-surface.ts +349 -0
  131. package/scripts/lib/claude-launcher-fence.ts +322 -0
  132. package/scripts/lib/live-skip.ts +33 -0
  133. package/scripts/lib/mutation-qualify.ts +109 -3
  134. package/scripts/lib/probe-acp-turn.ts +33 -12
  135. package/scripts/lib/step-outcome.sh +88 -0
  136. package/scripts/meta-bridge-doctor.sh +6 -8
  137. package/scripts/meta-facts.ts +60 -0
  138. package/scripts/mutants/acp-augment.json +106 -0
  139. package/scripts/mutants/acp-cortex.json +2 -2
  140. package/scripts/mutants/acp-overlay.json +17 -0
  141. package/scripts/mutants/acp-prompt-lifecycle.json +100 -0
  142. package/scripts/mutants/acp-stop-reason.json +80 -0
  143. package/scripts/mutants/acp-stream-hooks.json +158 -0
  144. package/scripts/mutants/bridge-boot-resume.json +45 -0
  145. package/scripts/mutants/meta-facts.json +50 -0
  146. package/scripts/mutants/meta-identity.json +36 -0
  147. package/scripts/mutants/mux-boundary.json +196 -0
  148. package/scripts/mutants/mux-fresh-call.json +185 -0
  149. package/scripts/mutants/mux-launcher-fence.json +123 -0
  150. package/scripts/mutants/mux-parent-artifact.json +39 -0
  151. package/scripts/mutants/mux-resume-call.json +148 -0
  152. package/scripts/mutants/probe-ordering.json +6 -1021
  153. package/scripts/mutants/release-gate.json +140 -0
  154. package/scripts/mutants/resume-args.json +76 -0
  155. package/scripts/mutants/resume-launch-identity.json +96 -0
  156. package/scripts/mutants/v2-surface.json +74 -23
  157. package/scripts/mutants/v2-visible-resume.json +215 -0
  158. package/scripts/new-session-id.ts +9 -4
  159. package/scripts/smoke-acp-bundled-mcp-live.ts +4 -3
  160. package/scripts/smoke-acp-carrier-augment-live.ts +2 -2
  161. package/scripts/smoke-acp-cortex-live.ts +8 -14
  162. package/scripts/smoke-acp-long-turn-live.ts +185 -0
  163. package/scripts/smoke-acp-mcp-live.ts +2 -2
  164. package/scripts/smoke-acp-memory-containment-live.ts +2 -2
  165. package/scripts/smoke-acp-ordering-probe-live.ts +2 -2
  166. package/scripts/smoke-acp-overlay-live.ts +2 -2
  167. package/scripts/smoke-acp-provider-live.ts +2 -2
  168. package/scripts/smoke-acp-raw-turn-live.ts +3 -3
  169. package/scripts/smoke-acp-session-reuse-live.ts +2 -2
  170. package/scripts/smoke-acp-skill-live.ts +2 -2
  171. package/scripts/smoke-acp-socket-citizen-live.ts +4 -3
  172. package/scripts/smoke-acp-v2-send-live.ts +4 -3
  173. package/scripts/smoke-agy-native-push-live.ts +10 -20
  174. package/scripts/smoke-claude-native-resume-live.sh +13 -3
  175. package/scripts/smoke-entwurf-chain-live.ts +352 -0
  176. package/scripts/smoke-entwurf-v2-matrix-live.ts +3 -3
  177. package/scripts/smoke-meta-honesty.sh +1 -1
  178. package/scripts/smoke-meta-install-state.sh +4 -0
  179. package/scripts/smoke-mux-fresh-call-live.ts +365 -0
  180. package/scripts/smoke-mux-lifecycle-live.ts +1136 -0
  181. package/scripts/smoke-pi-attach.ts +1 -1
  182. package/scripts/smoke-user-scope-citizen.sh +1 -1
  183. package/scripts/tsconfig.json +1 -0
  184. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-preflight.js +0 -160
  185. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn-production.js +0 -273
  186. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn.js +0 -216
  187. package/pi-extensions/lib/entwurf-v2-spawn-production.ts +0 -373
  188. package/pi-extensions/lib/entwurf-v2-spawn.ts +0 -323
  189. package/scripts/check-acp-sdk-surface.ts +0 -236
  190. package/scripts/check-entwurf-v2-spawn-production.ts +0 -551
  191. package/scripts/check-entwurf-v2-spawn.ts +0 -399
  192. package/scripts/smoke-entwurf-v2-spawn-live.ts +0 -188
  193. package/scripts/smoke-entwurf-v2-spawn-resume-live.ts +0 -469
@@ -14,57 +14,33 @@
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
21
 
22
- import * as path from "node:path";
23
22
  import type { SenderEnvelope } from "./entwurf-control-rpc.ts";
24
23
  import type { DispatchInput, EntwurfV2Mode } from "./entwurf-v2-decider.ts";
25
24
  import { makeProductionEntwurfV2Deps, type ProductionEntwurfV2Opts } from "./entwurf-v2-production.ts";
26
25
  import { type EntwurfV2RunResult, runEntwurfV2 } from "./entwurf-v2-runner.ts";
27
26
  import { FRESH_CUT_PRESCRIPTION } from "./meta-session.ts";
28
27
 
29
- /** The operator-policy SSOT for v2 dispatch's preflight prefix-auto-approve roots (5d-4b).
30
- * ONE shared env var feeds BOTH surfaces (pi-native + MCP) — a pi session and an MCP child
31
- * both inherit it, so there is no per-surface config fork. `prefixRoots` is operator policy,
32
- * not session-local UX, so it is an env var, not a pi flag. */
33
- export const ENTWURF_PREFIX_ROOTS_ENV = "ENTWURF_PREFIX_ROOTS";
34
-
35
- /** Parse `ENTWURF_PREFIX_ROOTS` into the preflight's `prefixRoots`. `path.delimiter`-
36
- * separated (`:` on Linux/macOS); entries are trimmed, empty segments dropped. Unset / empty
37
- * / delimiters-only ⇒ `[]` (no prefix promotion — frozen decision 7, no package default).
38
- * It does NOT throw on a nonexistent/typo path: `preflight`'s normalize keeps an absolute
39
- * fallback, so a bad root simply never matches (a typo must not broaden approve, and must not
40
- * turn every owned-outcome dispatch into a loud failure). `~` is left for preflight to expand. */
41
- export function parseEntwurfPrefixRootsEnv(raw: string | undefined = process.env[ENTWURF_PREFIX_ROOTS_ENV]): string[] {
42
- if (!raw) return [];
43
- return raw
44
- .split(path.delimiter)
45
- .map((s) => s.trim())
46
- .filter((s) => s.length > 0);
47
- }
48
-
49
28
  /** The raw shape a surface (pi tool / MCP verb) collects. `wants_reply` is snake_case to
50
29
  * match the external `entwurf_v2` convention; the runner sees `wantsReply`. */
51
30
  export interface SurfaceEntwurfV2Params {
52
31
  target: string;
53
- intent: "fire-and-forget" | "owned-outcome";
32
+ intent: "fire-and-forget";
54
33
  mode?: EntwurfV2Mode;
55
34
  wants_reply?: boolean;
56
35
  message: string;
57
36
  }
58
37
 
59
38
  /** ctx-free run options. The caller (entwurf-control.ts / MCP bridge) builds `senderProvider`
60
- * from its own envelope source — this module never touches `ExtensionContext`. Both surfaces
61
- * leave `agentDir`/`prefixRoots` undefined by design: `runAndRenderEntwurfV2FromSurface` falls
62
- * back to the `ENTWURF_PREFIX_ROOTS` env SSOT for `prefixRoots` (5d-4), and `agentDir` stays
63
- * undefined (no surface sets it). Explicit opts still win — kept for tests / a future surface. */
39
+ * from its own envelope source — this module never touches `ExtensionContext`. It is one
40
+ * field: the trust-preflight inputs (`agentDir`/`prefixRoots`, and the `ENTWURF_PREFIX_ROOTS`
41
+ * env SSOT behind them) went with the resume verdict they existed to guard. */
64
42
  export interface EntwurfV2SurfaceRunOptions {
65
43
  senderProvider: () => SenderEnvelope | undefined;
66
- agentDir?: string;
67
- prefixRoots?: readonly string[];
68
44
  }
69
45
 
70
46
  /** What the surface renders: the human/tool text + whether it is an error (a non-delivery). */
@@ -88,29 +64,40 @@ export function toDispatchInput(params: SurfaceEntwurfV2Params): DispatchInput {
88
64
 
89
65
  /**
90
66
  * Detour B (B-a) — actionable rendering of an honest reject. The decider is UNCHANGED:
91
- * a reject stays a reject (Hard Rule 3), and intent is NEVER auto-converted (Hard Rule 2
92
- * owned→fire-and-forget mailbox fallback would break the F1 ownership contract). This only
93
- * appends a one-line "what to do instead" to the reject TEXT, so an honest reject stops
94
- * reading as "delivery impossible". Returns undefined for rejects with no useful next step.
67
+ * a reject stays a reject (Hard Rule 3). This only appends a one-line "what to do instead"
68
+ * to the reject TEXT, so an honest reject stops reading as "delivery impossible". Returns
69
+ * undefined for rejects with no useful next step.
95
70
  */
96
71
  export function actionableRejectHint(reason: string): string | undefined {
97
72
  switch (reason) {
98
- case "backend-liveness-unsupported":
99
- // A meta-session backend (e.g. claude-code self-fetch) has no liveness predicate, so
100
- // owned-outcome has nothing to own. Replies go to the mailbox via fire-and-forget.
73
+ case "dormant-fire-forget-unsupported":
74
+ // The cell that carries the whole cost of the visible-first cut. Naming the real
75
+ // state the citizen exists and is not running matters more than ever now that
76
+ // there is no verb behind it: a caller that reads "reject" as "wrong id" goes
77
+ // looking in the wrong place.
101
78
  return (
102
- "meta-session backend has no liveness predicate owned-outcome is unsupported. " +
103
- "To reply, dispatch with intent: fire-and-forget it routes to the meta-mailbox when a " +
104
- "deliverable/active receiver is armed (else it fail-closes as mailbox-undeliverable). " +
105
- "(Intent is not auto-converted; you choose it.)"
79
+ "this citizen's record is intact but its session is not running, and delivery has no way to wake it. " +
80
+ "The resume that used to answer here launched a hidden background child and was withdrawn under the " +
81
+ "visible-first rule; the visible same-id resume is the separate lifecycle verb entwurf_resume_call. " +
82
+ "Reopen the citizen with entwurf_resume_call {target} (pi targets only; it runs no turn), then dispatch again."
83
+ );
84
+ case "indeterminate-no-spawn":
85
+ // The socket probe did not settle, so liveness is UNKNOWN — an unestablished probe
86
+ // is not a measured death, and dispatching into it could double-deliver. Say
87
+ // "the PROBE was inconclusive", never "the socket answered inconclusively":
88
+ // `indeterminate` also covers a probe that got no answer at all, was refused by
89
+ // permissions, or timed out, and "answered" claims a reply that may never have
90
+ // existed. The wire id is FROZEN and still spells "-no-spawn"; it names the rule
91
+ // (never dispatch into an indeterminate target), not a capability that still
92
+ // exists. So the hint's job is to say plainly what did NOT happen: nothing
93
+ // delivered, nothing started.
94
+ return (
95
+ "the control-socket probe was inconclusive, so the target's liveness is UNKNOWN — this is not a " +
96
+ "measured death. NOTHING was delivered and NO process was started (the reason id keeps its frozen " +
97
+ "'-no-spawn' wire spelling from an era when one could be; entwurf_v2 starts nothing on any rail). " +
98
+ "Re-run entwurf_peers to re-probe; if it stays indeterminate, check for a stale socket file left at " +
99
+ "that garden id by a session that died without cleaning up."
106
100
  );
107
- case "owned-live-no-autosend":
108
- // A live target is reachable, but owned-outcome is not an auto-send (Q2/F1).
109
- return "target is live — owned-outcome never auto-sends. Use intent: fire-and-forget (with wants_reply if you need a reply).";
110
- case "native-push-no-resume-authority":
111
- // A native-push backend (antigravity) has no pi-child to own, so owned-outcome has
112
- // nothing to own. Delivery is a direct inject via fire-and-forget.
113
- 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.";
114
101
  case "native-push-target-dead":
115
102
  // The adapter probe found no live host process for the conversation.
116
103
  return "native-push conversation is not live (no host process found). Re-open the conversation, then retry — there is nothing to inject into.";
@@ -135,7 +122,7 @@ export function actionableRejectHint(reason: string): string | undefined {
135
122
  /** Render the outcome-rich result to `{ text, isError }`. A reject or a thrown/failed/dirty
136
123
  * delivery is `isError:true`; a sent/fallback-sent/enqueued/observed delivery is `isError:false`.
137
124
  * A control in-band `rejected` is a non-delivery (isError:true) and carries N3 `rejectReason`
138
- * when present; a spawn `lock-retained` is fail-closed (isError:true) with its diagnostic. */
125
+ * when present. */
139
126
  export function renderEntwurfV2Result(result: EntwurfV2RunResult): EntwurfV2SurfaceRendered {
140
127
  switch (result.kind) {
141
128
  case "rejected": {
@@ -162,28 +149,6 @@ export function renderEntwurfV2Result(result: EntwurfV2RunResult): EntwurfV2Surf
162
149
  isError: !delivered,
163
150
  };
164
151
  }
165
- if (o.transport === "spawn-bg") {
166
- const res = o.result;
167
- if (res.kind === "lock-retained") {
168
- const d = res.diagnostic;
169
- return {
170
- text:
171
- `entwurf_v2 spawn-bg LOCK RETAINED (${res.reason}) — lock NOT released, operator must clear:` +
172
- `\n target: ${d.targetGardenId}` +
173
- `\n lockPath: ${d.lockPath}` +
174
- `\n expectedSocketPath: ${d.expectedSocketPath}` +
175
- `\n observeTimeoutMs: ${d.observeTimeoutMs}, killGraceMs: ${d.killGraceMs}` +
176
- (res.error ? `\n error: ${res.error}` : ""),
177
- isError: true,
178
- };
179
- }
180
- if (res.kind === "spawn-start-failed") {
181
- return { text: `entwurf_v2 spawn-bg failed to start: ${res.error}`, isError: true };
182
- }
183
- const pid = "pid" in res && res.pid !== undefined ? ` (pid ${res.pid})` : "";
184
- const exit = res.kind === "child-exited" ? ` exitCode=${res.exitCode}` : "";
185
- return { text: `entwurf_v2 spawn-bg → ${res.kind}${pid}${exit}, lock released`, isError: false };
186
- }
187
152
  if (o.transport === "native-push") {
188
153
  // direct-inject succeeded; note if the 1-shot re-probe retry fired.
189
154
  return {
@@ -222,12 +187,7 @@ export async function runAndRenderEntwurfV2FromSurface(
222
187
  params: SurfaceEntwurfV2Params,
223
188
  opts: EntwurfV2SurfaceRunOptions,
224
189
  ): Promise<EntwurfV2SurfaceRendered> {
225
- const prodOpts: ProductionEntwurfV2Opts = {
226
- senderProvider: opts.senderProvider,
227
- agentDir: opts.agentDir,
228
- // Explicit opts win (test / future surface override); otherwise the shared env SSOT.
229
- prefixRoots: opts.prefixRoots ?? parseEntwurfPrefixRootsEnv(),
230
- };
190
+ const prodOpts: ProductionEntwurfV2Opts = { senderProvider: opts.senderProvider };
231
191
  const result = await runEntwurfV2(toDispatchInput(params), makeProductionEntwurfV2Deps(prodOpts));
232
192
  return renderEntwurfV2Result(result);
233
193
  }
@@ -0,0 +1,370 @@
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
+
50
+ import { buildResumePiArgs } from "./entwurf-resume-args.ts";
51
+ import {
52
+ type AcquireLockResult,
53
+ ENTWURF_V2_LOCK_DIR,
54
+ type LockClaim,
55
+ type ReleaseResult,
56
+ acquireLock as realAcquireLock,
57
+ releaseLock as realReleaseLock,
58
+ } from "./entwurf-v2-lock.ts";
59
+ import {
60
+ type LaunchIdentity,
61
+ ResumeBackendUnsupportedError,
62
+ resolveResumeLaunchIdentity,
63
+ } from "./resume-launch-identity.ts";
64
+ import { isValidSessionId } from "./session-id.js";
65
+ import {
66
+ CONTROL_SOCKET_DIR,
67
+ inspectTargetControlSocket,
68
+ mapInspectionToLiveness,
69
+ type TargetSocketInspection,
70
+ } from "./socket-discovery.ts";
71
+ import { probeSocketLiveness, type SocketLiveness } from "./socket-probe.ts";
72
+
73
+ /** How long ONE observation operation waits for the resumed citizen's socket. Fixed on purpose:
74
+ * see the module header on why this is not an env knob. */
75
+ export const RESUME_OBSERVE_TIMEOUT_MS = 30_000;
76
+ /** How often that one operation re-inspects. Short enough that a 2s startup is reported as the
77
+ * success it is, long enough that a 30s wait is ~60 lstats and not a spin. */
78
+ export const RESUME_OBSERVE_INTERVAL_MS = 500;
79
+
80
+ /**
81
+ * The narrow set of EXPECTED refusals. Record, store, transcript and model integrity are NOT in
82
+ * here: `resolveResumeLaunchIdentity` already throws a cause-rich error for each of those
83
+ * ("stale or foreign transcript", "relative transcriptPath", "no recorded model", "unresolvable
84
+ * ACP bridge", …), and copying those causes into an enum would duplicate a truth that already
85
+ * fails loud — the second copy is what goes stale.
86
+ *
87
+ * `target-not-pi` IS in here, and the line between it and those throws is worth stating: asking
88
+ * to resume a Claude Code or agy citizen is not a defect at all. Nothing is corrupt, the record
89
+ * is exactly right, and the operator has simply reached past a capability boundary — only pi
90
+ * stands a control socket up, so only pi has a same-id resume. That deserves a named refusal a
91
+ * caller can act on, not an error report about a store that is fine.
92
+ */
93
+ export type VisibleResumeRejectReason =
94
+ | "target-invalid"
95
+ | "target-not-pi"
96
+ | "target-locked"
97
+ | "target-live"
98
+ | "target-indeterminate"
99
+ | "target-address-conflict"
100
+ | "launch-refused";
101
+
102
+ /** What the injected launcher gives back. Structural on purpose: naming mux's types here would
103
+ * be the import this split exists to avoid. */
104
+ export interface VisibleResumeLaunchHandle {
105
+ serverPid: string;
106
+ sessionId: string;
107
+ windowId: string;
108
+ windowIndex: string;
109
+ paneId: string;
110
+ panePid: string;
111
+ runtimePath: string;
112
+ cwd: string;
113
+ }
114
+
115
+ export type VisibleResumeLaunchOutcome =
116
+ | { ok: true; handle: VisibleResumeLaunchHandle }
117
+ | { ok: false; reason: string; hint: string };
118
+
119
+ /** Fact 1: tmux created a window and was asked to start pi there. `cwd` is the REQUESTED start
120
+ * directory (what tmux was told), the same kind of fact as `runtimePath` — not an observation of
121
+ * where the pane landed. */
122
+ export interface ResumeLaunchReceipt {
123
+ targetGardenId: string;
124
+ sessionFile: string;
125
+ handle: VisibleResumeLaunchHandle;
126
+ }
127
+
128
+ /** Fact 2: the citizen's control socket came back under the SAME garden id, or it did not. This
129
+ * is the only fact in this module that says a citizen is back. */
130
+ export interface ResumeObservationReceipt {
131
+ kind: "socket-alive" | "resume-unobserved";
132
+ socketPath: string;
133
+ waitedMs: number;
134
+ }
135
+
136
+ export type VisibleResumeResult =
137
+ | { ok: true; launch: ResumeLaunchReceipt; observation: ResumeObservationReceipt }
138
+ | { ok: false; reason: VisibleResumeRejectReason; detail: string };
139
+
140
+ export interface VisibleResumeDeps {
141
+ acquireLock: (gardenId: string) => AcquireLockResult;
142
+ releaseLock: (claim: LockClaim) => ReleaseResult;
143
+ resolveIdentity: (gardenId: string) => LaunchIdentity;
144
+ inspectSocket: (gardenId: string) => Promise<TargetSocketInspection>;
145
+ probeSocket: (socketPath: string) => Promise<SocketLiveness>;
146
+ /** The ONLY way this module reaches tmux. The surface supplies it. */
147
+ launch: (input: { cwd: string; runtimeArgs: readonly string[] }) => VisibleResumeLaunchOutcome;
148
+ sleep: (ms: number) => Promise<void>;
149
+ now: () => number;
150
+ }
151
+
152
+ /** Production wiring for everything except the launch seam — the surface owns that, because it
153
+ * is the only layer allowed to know both halves. */
154
+ export function makeVisibleResumeDeps(
155
+ launch: VisibleResumeDeps["launch"],
156
+ overrides: Partial<VisibleResumeDeps> = {},
157
+ ): VisibleResumeDeps {
158
+ return {
159
+ acquireLock: (gid) => realAcquireLock(gid, { dir: ENTWURF_V2_LOCK_DIR }),
160
+ releaseLock: (claim) => realReleaseLock(claim, { dir: ENTWURF_V2_LOCK_DIR }),
161
+ resolveIdentity: resolveResumeLaunchIdentity,
162
+ inspectSocket: (gid) => inspectTargetControlSocket(gid, CONTROL_SOCKET_DIR),
163
+ probeSocket: (socketPath) => probeSocketLiveness(socketPath),
164
+ launch,
165
+ sleep: (ms) => new Promise<void>((resolve) => setTimeout(resolve, ms)),
166
+ now: () => Date.now(),
167
+ ...overrides,
168
+ };
169
+ }
170
+
171
+ /**
172
+ * Reopen `targetGardenId` in a visible window.
173
+ *
174
+ * Order is the safety argument. The lock is taken FIRST, before the liveness question, so the
175
+ * answer cannot change under us between reading it and acting on it — a competing resume would
176
+ * otherwise pass its own `dead` check while ours was launching. Identity is resolved under that
177
+ * lock and BEFORE any window opens, so a citizen with no transcript (measured: a pi that never
178
+ * took a turn has a socket and a record but no transcriptPath) fails loud with its own cause and
179
+ * leaves nothing behind.
180
+ */
181
+ export async function visibleResume(targetGardenId: string, deps: VisibleResumeDeps): Promise<VisibleResumeResult> {
182
+ // The address grammar is `session-id.js`'s, not a local copy. The socket filename, the lock
183
+ // path and the record key are all keyed on that SAME id, so a second regex here could drift
184
+ // into accepting an id one of those three would reject — and this value becomes a lock path
185
+ // and a socket path a few lines down.
186
+ if (!isValidSessionId(targetGardenId)) {
187
+ return {
188
+ ok: false,
189
+ reason: "target-invalid",
190
+ detail: `${JSON.stringify(targetGardenId)} is not a garden id (YYYYMMDDTHHMMSS-xxxxxx)`,
191
+ };
192
+ }
193
+
194
+ const acquired = deps.acquireLock(targetGardenId);
195
+ if (!acquired.ok) {
196
+ return { ok: false, reason: "target-locked", detail: acquired.conflict.detail };
197
+ }
198
+ const claim = acquired.claim;
199
+
200
+ let released = false;
201
+ /** Release exactly once, and never let a failed release read as success. A lock this module
202
+ * took and could not give back is a fact the operator has to know about — the next resume or
203
+ * send against that citizen will be refused, and silence would send them looking at the wrong
204
+ * layer. */
205
+ const release = (): void => {
206
+ if (released) return;
207
+ released = true;
208
+ const outcome = deps.releaseLock(claim);
209
+ if (outcome !== "released") {
210
+ throw new Error(
211
+ `entwurf-v2-visible-resume: attempted to release the lock for ${targetGardenId}; the release returned ` +
212
+ `"${outcome}" instead of "released" (${claim.lockPath}) — the per-gid lock may still be held; clear ` +
213
+ `it by hand before resuming or dispatching to that citizen again.`,
214
+ );
215
+ }
216
+ };
217
+
218
+ try {
219
+ // Record authority, under the lock, before any window. Every failure here is a cause-rich
220
+ // throw from the leaf (foreign transcript, relative or deleted session file, no recorded
221
+ // model, unresolvable bridge) and is deliberately NOT re-typed into a reject — with ONE
222
+ // exception, matched on the error's own `reason` FIELD rather than its wording: a citizen
223
+ // of a backend that has no same-id resume. That is a capability boundary, not a defect, so
224
+ // it is a named refusal. The record is not read a second time to ask which backend it was;
225
+ // the error already carries it.
226
+ let identity: LaunchIdentity;
227
+ try {
228
+ identity = deps.resolveIdentity(targetGardenId);
229
+ } catch (err) {
230
+ if (err instanceof ResumeBackendUnsupportedError) {
231
+ return {
232
+ ok: false,
233
+ reason: err.reason,
234
+ detail:
235
+ `${targetGardenId} is a ${err.backend} citizen; only pi citizens stand a control socket up, ` +
236
+ `so only they can be reopened under the same garden id`,
237
+ };
238
+ }
239
+ throw err;
240
+ }
241
+
242
+ // Liveness preflight, still under the lock. A live citizen is addressed with
243
+ // `entwurf_v2 fire-and-forget`, not reopened — and refusing here is also what stops two
244
+ // windows existing for one garden id.
245
+ const inspection = await deps.inspectSocket(targetGardenId);
246
+ const mapped = await mapInspectionToLiveness(inspection, deps.probeSocket);
247
+ if ("addressConflict" in mapped) {
248
+ return {
249
+ ok: false,
250
+ reason: "target-address-conflict",
251
+ detail:
252
+ `the control-socket path for ${targetGardenId} is a symlink or not a socket — that address is ` +
253
+ `corrupt, and resuming would stand a citizen up behind it`,
254
+ };
255
+ }
256
+ if (mapped.liveness === "alive") {
257
+ return {
258
+ ok: false,
259
+ reason: "target-live",
260
+ detail: `${targetGardenId} is already live at ${mapped.socketPath} — send to it with entwurf_v2 fire-and-forget instead`,
261
+ };
262
+ }
263
+ if (mapped.liveness === "indeterminate") {
264
+ return {
265
+ ok: false,
266
+ reason: "target-indeterminate",
267
+ detail:
268
+ `the control-socket probe for ${targetGardenId} was inconclusive — it is not provably dormant, ` +
269
+ `and resuming a citizen that is actually running would put two processes on one transcript`,
270
+ };
271
+ }
272
+
273
+ const runtimeArgs = buildResumePiArgs({
274
+ sessionFile: identity.sessionFile,
275
+ explicitExtensionArgs: identity.explicitExtensionArgs,
276
+ provider: identity.provider,
277
+ model: identity.model,
278
+ });
279
+ const launched = deps.launch({ cwd: identity.cwd, runtimeArgs });
280
+ if (!launched.ok) {
281
+ return { ok: false, reason: "launch-refused", detail: `${launched.reason} — ${launched.hint}` };
282
+ }
283
+
284
+ const observation = await observeSameGidSocket(targetGardenId, mapped.socketPath, deps);
285
+ return {
286
+ ok: true,
287
+ launch: { targetGardenId, sessionFile: identity.sessionFile, handle: launched.handle },
288
+ observation,
289
+ };
290
+ } finally {
291
+ release();
292
+ }
293
+ }
294
+
295
+ /**
296
+ * ONE bounded operation: wait for the SAME garden id's control socket to answer. Alive is the
297
+ * only early exit — an inspection that comes back absent, corrupt or inconclusive during startup
298
+ * is a normal intermediate state, not a verdict, so it is simply not-yet.
299
+ */
300
+ export async function observeSameGidSocket(
301
+ targetGardenId: string,
302
+ socketPath: string,
303
+ deps: Pick<VisibleResumeDeps, "inspectSocket" | "probeSocket" | "sleep" | "now">,
304
+ timeoutMs: number = RESUME_OBSERVE_TIMEOUT_MS,
305
+ intervalMs: number = RESUME_OBSERVE_INTERVAL_MS,
306
+ ): Promise<ResumeObservationReceipt> {
307
+ const started = deps.now();
308
+ for (;;) {
309
+ const inspection = await deps.inspectSocket(targetGardenId);
310
+ const mapped = await mapInspectionToLiveness(inspection, deps.probeSocket);
311
+ if (!("addressConflict" in mapped) && mapped.liveness === "alive") {
312
+ return { kind: "socket-alive", socketPath: mapped.socketPath, waitedMs: deps.now() - started };
313
+ }
314
+ if (deps.now() - started >= timeoutMs) {
315
+ return { kind: "resume-unobserved", socketPath, waitedMs: deps.now() - started };
316
+ }
317
+ await deps.sleep(intervalMs);
318
+ }
319
+ }
320
+
321
+ const REJECT_HINT: Record<VisibleResumeRejectReason, string> = {
322
+ "target-invalid": "no citizen has that id; discover targets with entwurf_peers",
323
+ "target-not-pi": "that citizen's backend has no same-id resume — reach it with entwurf_v2, or open a fresh sibling",
324
+ "target-locked": "another resume or dispatch holds this citizen's lock right now",
325
+ "target-live": "that citizen is already running — this verb only reopens dormant ones",
326
+ "target-indeterminate": "the socket probe could not prove the citizen is dormant, so nothing was started",
327
+ "target-address-conflict": "that citizen's control-socket path is corrupt",
328
+ "launch-refused": "tmux was never asked to open a window",
329
+ };
330
+
331
+ /**
332
+ * ONE renderer for both surfaces — the same reason `mux-fresh-call` has one: the two
333
+ * registrations are separate literals, and a shared renderer is what keeps the operator-visible
334
+ * answer from drifting apart between native pi and the MCP bridge.
335
+ *
336
+ * The success text keeps the two receipts visibly apart, including in the good case. A reader
337
+ * who sees "window opened" and "socket alive" as one sentence has been told the resume worked;
338
+ * a reader who sees them as two facts can tell which one is missing when only one is there.
339
+ */
340
+ export function renderVisibleResume(result: VisibleResumeResult): { text: string; isError: boolean } {
341
+ if (!result.ok) {
342
+ return {
343
+ text:
344
+ `entwurf_resume_call rejected: ${result.reason} — ${REJECT_HINT[result.reason]}. ` +
345
+ `${result.detail}. No window was opened.`,
346
+ isError: true,
347
+ };
348
+ }
349
+ const h = result.launch.handle;
350
+ const observed = result.observation.kind === "socket-alive";
351
+ return {
352
+ text:
353
+ `[entwurf resume call ↻]\n` +
354
+ ` target: ${result.launch.targetGardenId}\n` +
355
+ ` session: ${result.launch.sessionFile}\n` +
356
+ ` runtime: ${h.runtimePath} (requested start cwd ${h.cwd})\n` +
357
+ ` window: ${h.windowId} (index ${h.windowIndex}) in session ${h.sessionId}\n` +
358
+ ` pane: ${h.paneId} pid ${h.panePid}\n` +
359
+ `\n` +
360
+ `LAUNCH receipt: tmux created that window and was asked to start pi on the transcript above.\n` +
361
+ (observed
362
+ ? `OBSERVATION receipt: the control socket for ${result.launch.targetGardenId} answered after ` +
363
+ `${result.observation.waitedMs}ms — that citizen is addressable again, and entwurf_v2 fire-and-forget ` +
364
+ `now reaches it. No turn was run by this call.`
365
+ : `OBSERVATION receipt: resume-unobserved — the control socket did not answer within ` +
366
+ `${result.observation.waitedMs}ms. The window is open and visible; read it directly. The lock was ` +
367
+ `released, nothing was retried, and nothing was killed.`),
368
+ isError: false,
369
+ };
370
+ }