@junghanacs/entwurf 0.13.1 → 0.14.1

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 (158) hide show
  1. package/AGENTS.md +59 -15
  2. package/BASELINE.md +3 -3
  3. package/CHANGELOG.md +74 -0
  4. package/CONTRIBUTING.md +13 -9
  5. package/DELIVERY.md +7 -6
  6. package/README.md +37 -28
  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 +155 -28
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/overlay.js +8 -6
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/classify-tmux-cwd.js +47 -0
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-control-rpc.js +7 -5
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-core.js +13 -14
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-resume-args.js +45 -40
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +117 -95
  21. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-decider.js +23 -57
  22. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-lock.js +16 -7
  23. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +5 -53
  24. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-release.js +21 -36
  25. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-runner.js +3 -15
  26. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send-fallback.js +12 -11
  27. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send.js +2 -7
  28. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +30 -67
  29. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-visible-resume.js +256 -0
  30. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +91 -5
  31. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +300 -0
  32. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-launch.js +202 -0
  33. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-placement.js +289 -0
  34. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-resume-call.js +141 -0
  35. package/mcp/entwurf-bridge/dist/pi-extensions/lib/resume-launch-identity.js +136 -0
  36. package/mcp/entwurf-bridge/dist/pi-extensions/lib/session-id.js +8 -5
  37. package/mcp/entwurf-bridge/dist/pi-extensions/lib/socket-discovery.js +3 -3
  38. package/mcp/entwurf-bridge/dist/scripts/meta-facts.js +51 -0
  39. package/mcp/entwurf-bridge/dist/scripts/new-session-id.js +9 -4
  40. package/mcp/entwurf-bridge/src/index.ts +173 -28
  41. package/mcp/entwurf-bridge/start.sh +2 -2
  42. package/mcp/entwurf-bridge/test.sh +23 -9
  43. package/mcp/entwurf-bridge/tsconfig.build.json +11 -2
  44. package/package.json +22 -11
  45. package/pi-extensions/entwurf-control.ts +227 -40
  46. package/pi-extensions/lib/acp/backend.ts +71 -12
  47. package/pi-extensions/lib/acp/overlay.ts +8 -6
  48. package/pi-extensions/lib/classify-tmux-cwd.ts +50 -0
  49. package/pi-extensions/lib/entwurf-control-rpc.ts +7 -5
  50. package/pi-extensions/lib/entwurf-core.ts +15 -15
  51. package/pi-extensions/lib/entwurf-resume-args.ts +41 -52
  52. package/pi-extensions/lib/entwurf-v2-contract-schema.ts +1 -1
  53. package/pi-extensions/lib/entwurf-v2-contract.ts +120 -99
  54. package/pi-extensions/lib/entwurf-v2-decider.ts +30 -91
  55. package/pi-extensions/lib/entwurf-v2-lock.ts +16 -7
  56. package/pi-extensions/lib/entwurf-v2-production.ts +4 -78
  57. package/pi-extensions/lib/entwurf-v2-release.ts +25 -49
  58. package/pi-extensions/lib/entwurf-v2-runner.ts +6 -21
  59. package/pi-extensions/lib/entwurf-v2-send-fallback.ts +12 -11
  60. package/pi-extensions/lib/entwurf-v2-send.ts +2 -7
  61. package/pi-extensions/lib/entwurf-v2-surface.ts +36 -76
  62. package/pi-extensions/lib/entwurf-v2-visible-resume.ts +370 -0
  63. package/pi-extensions/lib/meta-session.ts +93 -5
  64. package/pi-extensions/lib/mux-fresh-call.ts +381 -0
  65. package/pi-extensions/lib/mux-launch.ts +267 -0
  66. package/pi-extensions/lib/mux-placement.ts +387 -0
  67. package/pi-extensions/lib/mux-resume-call.ts +189 -0
  68. package/pi-extensions/lib/resume-launch-identity.ts +162 -0
  69. package/pi-extensions/lib/session-id.js +8 -5
  70. package/pi-extensions/lib/socket-discovery.ts +3 -3
  71. package/run.sh +471 -201
  72. package/scripts/agy-bridge-config.py +5 -1
  73. package/scripts/check-acp-backend-preflight.ts +1 -1
  74. package/scripts/check-acp-overlay.ts +13 -3
  75. package/scripts/check-acp-stream-hooks.ts +504 -0
  76. package/scripts/check-elapsed.sh +25 -0
  77. package/scripts/check-entwurf-bridge-boot.ts +51 -4
  78. package/scripts/check-entwurf-bridge-pi-free.ts +6 -5
  79. package/scripts/check-entwurf-control-rpc.ts +4 -3
  80. package/scripts/check-entwurf-resume-args.ts +72 -70
  81. package/scripts/check-entwurf-session-identity.ts +14 -10
  82. package/scripts/check-entwurf-v2-contract.ts +34 -59
  83. package/scripts/check-entwurf-v2-decider.ts +17 -177
  84. package/scripts/check-entwurf-v2-lock.ts +5 -2
  85. package/scripts/check-entwurf-v2-matrix.ts +3 -53
  86. package/scripts/check-entwurf-v2-production.ts +2 -91
  87. package/scripts/check-entwurf-v2-release.ts +10 -105
  88. package/scripts/check-entwurf-v2-runner.ts +4 -85
  89. package/scripts/check-entwurf-v2-send-fallback.ts +5 -6
  90. package/scripts/check-entwurf-v2-send.ts +0 -28
  91. package/scripts/check-entwurf-v2-surface.ts +157 -128
  92. package/scripts/check-entwurf-v2-visible-resume.ts +445 -0
  93. package/scripts/check-fresh-cut-gate.sh +1 -1
  94. package/scripts/check-gate-qualification.ts +99 -7
  95. package/scripts/check-install-container.sh +10 -2
  96. package/scripts/check-install-surface.ts +1 -1
  97. package/scripts/check-keyset-overlap.py +1 -1
  98. package/scripts/check-meta-facts.ts +249 -0
  99. package/scripts/check-meta-identity-consumers.ts +1 -1
  100. package/scripts/check-meta-session.ts +169 -0
  101. package/scripts/check-mux-launch-tmux.ts +316 -0
  102. package/scripts/check-mux-launch.ts +288 -0
  103. package/scripts/check-mux-launcher-fence.ts +264 -0
  104. package/scripts/check-mux-parent-artifact.ts +195 -0
  105. package/scripts/check-mux-placement-tmux.ts +322 -0
  106. package/scripts/check-mux-placement.ts +323 -0
  107. package/scripts/check-mux-resume-call.ts +283 -0
  108. package/scripts/check-probe-cli-shim.ts +25 -22
  109. package/scripts/check-probe-ordering.ts +84 -76
  110. package/scripts/check-release-gate-outcomes.ts +127 -7
  111. package/scripts/check-resume-launch-identity.ts +244 -0
  112. package/scripts/check-socket-discovery.ts +1 -1
  113. package/scripts/fixtures/mux-parent-transcript.scrubbed.jsonl +3 -0
  114. package/scripts/inventory-verification-surface.ts +349 -0
  115. package/scripts/lib/claude-launcher-fence.ts +322 -0
  116. package/scripts/lib/mutation-qualify.ts +109 -3
  117. package/scripts/meta-bridge-doctor.sh +6 -8
  118. package/scripts/meta-bridge-state.py +75 -1
  119. package/scripts/meta-facts.ts +60 -0
  120. package/scripts/mutants/acp-overlay.json +17 -0
  121. package/scripts/mutants/acp-stream-hooks.json +158 -0
  122. package/scripts/mutants/bridge-boot-resume.json +45 -0
  123. package/scripts/mutants/meta-facts.json +50 -0
  124. package/scripts/mutants/meta-identity.json +36 -0
  125. package/scripts/mutants/meta-retire.json +47 -0
  126. package/scripts/mutants/mux-boundary.json +196 -0
  127. package/scripts/mutants/mux-fresh-call.json +229 -0
  128. package/scripts/mutants/mux-launcher-fence.json +123 -0
  129. package/scripts/mutants/mux-parent-artifact.json +39 -0
  130. package/scripts/mutants/mux-resume-call.json +148 -0
  131. package/scripts/mutants/probe-ordering.json +0 -1037
  132. package/scripts/mutants/release-gate.json +35 -0
  133. package/scripts/mutants/resume-args.json +76 -0
  134. package/scripts/mutants/resume-launch-identity.json +96 -0
  135. package/scripts/mutants/v2-surface.json +58 -18
  136. package/scripts/mutants/v2-visible-resume.json +215 -0
  137. package/scripts/new-session-id.ts +9 -4
  138. package/scripts/smoke-acp-raw-turn-live.ts +1 -1
  139. package/scripts/smoke-agy-native-push-live.ts +6 -17
  140. package/scripts/smoke-entwurf-chain-live.ts +11 -3
  141. package/scripts/smoke-entwurf-v2-matrix-live.ts +1 -1
  142. package/scripts/smoke-meta-honesty.sh +1 -1
  143. package/scripts/smoke-meta-install-state.sh +169 -3
  144. package/scripts/smoke-mux-fresh-call-live.ts +365 -0
  145. package/scripts/smoke-mux-lifecycle-live.ts +1136 -0
  146. package/scripts/smoke-pi-attach.ts +1 -1
  147. package/scripts/smoke-user-scope-citizen.sh +1 -1
  148. package/scripts/tsconfig.json +1 -0
  149. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-preflight.js +0 -160
  150. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn-production.js +0 -273
  151. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn.js +0 -216
  152. package/pi-extensions/lib/entwurf-v2-spawn-production.ts +0 -373
  153. package/pi-extensions/lib/entwurf-v2-spawn.ts +0 -323
  154. package/scripts/check-acp-sdk-surface.ts +0 -275
  155. package/scripts/check-entwurf-v2-spawn-production.ts +0 -551
  156. package/scripts/check-entwurf-v2-spawn.ts +0 -399
  157. package/scripts/smoke-entwurf-v2-spawn-live.ts +0 -188
  158. package/scripts/smoke-entwurf-v2-spawn-resume-live.ts +0 -467
@@ -0,0 +1,300 @@
1
+ /**
2
+ * mux-fresh-call — open ONE visible sibling in the caller's own tmux session, hand it its first
3
+ * task in the launch argv, and let it name itself back to the caller.
4
+ *
5
+ * ── Why this is a third module and not a parameter on the leaf ──
6
+ *
7
+ * `mux-placement` owns place and only place; `mux-launch` owns "start the fixed official pi in
8
+ * a window I placed" and refuses a command carrier (docs §11). Both stay that way. The carrier
9
+ * lives HERE, one layer up, because the thing being carried is not a command in the shell sense
10
+ * — it is a TASK plus the instruction that makes the new citizen announce itself. Putting it on
11
+ * the leaf would make every future placement caller inherit a prompt argument it has no use for.
12
+ *
13
+ * caller agent → fresh-call → fixed backend runtime + first-turn framing
14
+ * fresh-call → placement leaf (unchanged, carrier-free)
15
+ * fresh-call -X-> identity minting, delivery transport, task planning
16
+ *
17
+ * ── Invariants ──
18
+ *
19
+ * 1. The first turn does not MINT the garden id — record and socket exist from session start.
20
+ * It exists to SAY that id to the caller, who is the one who cannot see it. (rail §6-a)
21
+ * 2. Correlation is what the DELIVERY layer stamps on the callback, never what the sibling
22
+ * says about itself — asked directly, a fresh cell answers with a uuidv7. (rail §6-b)
23
+ * 3. The two receipts are separate objects and stay that way: `freshCall` returns tmux
24
+ * coordinates synchronously and nothing else; the correlation receipt arrives later on the
25
+ * caller's own inbound surface. Merging them would claim knowledge this module cannot have.
26
+ * 4. A launch with no callback is a REAL outcome, not an error to retry. No watcher, no poll,
27
+ * no timeout supervisor. The window is visible; the operator can look.
28
+ *
29
+ * ── The optional REQUESTED cwd (issue #73) ──
30
+ *
31
+ * A fresh sibling starts wherever the caller happens to be — unless the caller names ONE
32
+ * literal start directory. That input exists so a cross-repo fresh consultation never has to
33
+ * ride `entwurf_resume_call` for a dormant record's recorded cwd: resume stays a continuity
34
+ * verb, and placement pressure stays here. The rules are deliberately narrow:
35
+ *
36
+ * - `undefined` and the exact empty string mean OMIT: no `-c` reaches tmux and the argv is
37
+ * byte-identical to the pre-#73 shape. Anything else is taken LITERALLY — no trim, no
38
+ * realpath, no project-name resolution, no store/peers/record lookup. The caller is the
39
+ * only cwd authority this module knows.
40
+ * - the value is classified by the shared `classify-tmux-cwd.ts` leaf BEFORE any mutation
41
+ * (same four stable reasons as resume; the measured tmux 3.6a facts live on that leaf).
42
+ * This module's hints phrase them as the REQUESTED cwd; resume's say RECORDED.
43
+ * - the receipt echoes what was REQUESTED, exactly as `runtimePath` does. It never reports
44
+ * `pane_current_path`: proving where the pane actually landed belongs to acceptance, not
45
+ * to the launch receipt.
46
+ */
47
+ import { randomBytes } from "node:crypto";
48
+ import { classifyTmuxCwd } from "./classify-tmux-cwd.js";
49
+ import { assertLaunchTarget, LaunchPreconditionError, resolveRuntimeOnPath, } from "./mux-launch.js";
50
+ import { APPEND_FORMAT, assertSelector, assertTmuxOk, inspectPlacement, parseWindowFields, requireSameContext, runTmux, } from "./mux-placement.js";
51
+ /** The two backends this rail can open. Fixed set, not a profile — a third one is a decision,
52
+ * not a config entry. */
53
+ export const FRESH_CALL_BACKENDS = ["pi", "claude-code"];
54
+ /** The fixed runtime each backend resolves on PATH. Same reason `mux-launch` uses PATH rather
55
+ * than a compiled-in location: the official binary is whatever the operator's environment gives
56
+ * when they type the name. */
57
+ export const FRESH_CALL_RUNTIME = {
58
+ pi: "pi",
59
+ "claude-code": "claude",
60
+ };
61
+ /**
62
+ * The callback tool NAME differs per backend and that is not cosmetic: native pi exposes the
63
+ * capability directly (`entwurf_v2`), while a Claude Code session reaches it through the MCP
64
+ * bridge under its namespaced name. Naming the wrong one costs the whole first turn.
65
+ */
66
+ export const FRESH_CALL_CALLBACK_TOOL = {
67
+ pi: "entwurf_v2",
68
+ "claude-code": "mcp__entwurf-bridge__entwurf_v2",
69
+ };
70
+ /** Mirrors the `entwurf_v2` message bound. This is an INTERFACE cap for symmetry with the
71
+ * delivery surface, not a claim that a task of this size was measured through tmux. An argv
72
+ * that the OS refuses is a launch failure and fails loud — it never reads as a delivered task. */
73
+ export const TASK_MAX_CHARS = 16000;
74
+ export const MODEL_MAX_CHARS = 200;
75
+ const MODEL_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._/:\[\]-]*$/;
76
+ /** A model is an explicit launch input, not ambient process state. The grammar admits canonical
77
+ * pi provider/model ids, Claude model ids/aliases, and bracketed context variants, while refusing
78
+ * whitespace and tmux control syntax. It is passed without a shell using each runtime's measured
79
+ * CLI dialect: Pi takes `--model`, value; Claude Code takes `--model=value`. */
80
+ export function isSafeFreshCallModel(model) {
81
+ return model.length > 0 && model.length <= MODEL_MAX_CHARS && MODEL_PATTERN.test(model);
82
+ }
83
+ /**
84
+ * Backend argv AFTER the runtime path. Both orders are MEASURED, and both were measured by
85
+ * getting them wrong first (rail §6-a):
86
+ *
87
+ * pi — prompt BEFORE `--entwurf-control`, then `--model`, value as TWO tokens.
88
+ * Flag-first submitted no message; Pi rejects the equals form for `--model`.
89
+ * claude-code — prompt, then `--allowedTools=` and `--model=` as ONE token each. The space form
90
+ * for allowedTools is variadic and eats the prompt as an option value.
91
+ *
92
+ * Both failures looked identical from outside: window open, record and socket minted, no turn.
93
+ *
94
+ * The equals form is NOT a permission guarantee — on the measured host the tool was already
95
+ * permitted, so the option's effect was unobservable. What was observed is that it does no harm
96
+ * to the argv. Permission stays a documented host precondition.
97
+ */
98
+ export function buildBackendArgs(backend, prompt, model) {
99
+ switch (backend) {
100
+ case "pi":
101
+ return [prompt, "--entwurf-control", "--model", model];
102
+ case "claude-code":
103
+ return [prompt, `--allowedTools=${FRESH_CALL_CALLBACK_TOOL["claude-code"]}`, `--model=${model}`];
104
+ }
105
+ }
106
+ /**
107
+ * The first-turn framing. Order is the contract: the callback is the FIRST action and the task
108
+ * follows it, so a sibling that gets stuck in the task has already told the caller who it is.
109
+ *
110
+ * The three prohibitions are not politeness. Each names a detour that was measured to produce a
111
+ * confidently wrong answer or a wasted turn.
112
+ */
113
+ export function buildFreshCallPrompt(params) {
114
+ const tool = FRESH_CALL_CALLBACK_TOOL[params.backend];
115
+ return [
116
+ "You are a fresh visible citizen that entwurf opened in the operator's tmux session.",
117
+ "",
118
+ `FIRST ACTION, before reading files or anything else: call ${tool} with ` +
119
+ `target=${params.callerGardenId}, intent=fire-and-forget, wants_reply=false, and ` +
120
+ `message set to exactly ${params.nonce} — that string alone, nothing added.`,
121
+ "That call is how the agent that opened you learns your address. Do not skip it, do not",
122
+ "defer it until the task is done, and do not reword the message.",
123
+ "",
124
+ "Do not inspect environment variables, do not call entwurf_self, and do not start an MCP",
125
+ "server yourself. Your own report of your identity is not the address anyone needs.",
126
+ "",
127
+ "After the tool receipt, carry out this task:",
128
+ "",
129
+ params.task,
130
+ ].join("\n");
131
+ }
132
+ /** Correlation tag only. Random, never derived from time, cwd or a peer listing — a nonce that
133
+ * encoded any of those would invite exactly the guessing this rail exists to refuse. */
134
+ export function mintNonce(randomHex = defaultRandomHex) {
135
+ return `mux-fresh-call-${randomHex()}`;
136
+ }
137
+ function defaultRandomHex() {
138
+ return randomBytes(12).toString("hex");
139
+ }
140
+ /** Launch argv: the leaf's detached-append shape, optionally `-c` at the resume-symmetric token
141
+ * position (after `-t`, before `-P -F`), the runtime, then the backend's dialect. An omitted cwd
142
+ * yields the exact pre-#73 argv — no carrier at all. */
143
+ export function buildFreshCallArgs(placement, runtimePath, backendArgs, cwd) {
144
+ assertSelector("session", placement.sessionId);
145
+ assertLaunchTarget(runtimePath);
146
+ if (cwd !== undefined) {
147
+ const bad = classifyTmuxCwd(cwd);
148
+ if (bad)
149
+ throw new Error(`mux-fresh-call: refusing to build argv with an unusable cwd (${bad}): ${cwd}`);
150
+ }
151
+ return [
152
+ "new-window",
153
+ "-d",
154
+ "-a",
155
+ "-t",
156
+ `${placement.sessionId}:{end}`,
157
+ ...(cwd === undefined ? [] : ["-c", cwd]),
158
+ "-P",
159
+ "-F",
160
+ APPEND_FORMAT,
161
+ "--",
162
+ runtimePath,
163
+ ...backendArgs,
164
+ ];
165
+ }
166
+ /**
167
+ * Open the sibling. Order is the safety argument, same as `launchPi`: validate the caller's
168
+ * identity and task, resolve and prove the runtime, re-read the caller's placement and refuse a
169
+ * changed context — and only then mutate. Nothing above can leave a window behind.
170
+ *
171
+ * `callerGardenId` is supplied by the SURFACE that registered this tool, from its own
172
+ * record-backed context. It is not a tool parameter and this module never derives, validates
173
+ * against a store, or guesses it: an empty value is a named refusal, not a lookup.
174
+ */
175
+ export function freshCall(params, env = process.env, nonce = mintNonce()) {
176
+ if (typeof params.callerGardenId !== "string" || params.callerGardenId.length === 0) {
177
+ return { ok: false, reason: "caller-identity-unavailable" };
178
+ }
179
+ const model = params.model.trim();
180
+ if (model.length === 0)
181
+ return { ok: false, reason: "model-empty" };
182
+ if (!isSafeFreshCallModel(model))
183
+ return { ok: false, reason: "model-invalid" };
184
+ const task = params.task.trim();
185
+ if (task.length === 0)
186
+ return { ok: false, reason: "task-empty" };
187
+ if (task.length > TASK_MAX_CHARS)
188
+ return { ok: false, reason: "task-too-long" };
189
+ // ONLY `undefined` and the exact empty string mean "no cwd". Everything else is the literal
190
+ // value — deliberately untrimmed, so a whitespace-mangled path is refused loudly by the
191
+ // classification below instead of being silently repaired into a different directory.
192
+ const cwd = params.cwd === undefined || params.cwd === "" ? undefined : params.cwd;
193
+ if (cwd !== undefined) {
194
+ const badCwd = classifyTmuxCwd(cwd);
195
+ if (badCwd)
196
+ return { ok: false, reason: badCwd };
197
+ }
198
+ let runtimePath;
199
+ try {
200
+ runtimePath = resolveRuntimeOnPath(FRESH_CALL_RUNTIME[params.backend], env);
201
+ }
202
+ catch (err) {
203
+ if (err instanceof LaunchPreconditionError)
204
+ return { ok: false, reason: err.reason };
205
+ throw err;
206
+ }
207
+ const inspected = inspectPlacement(env);
208
+ if (!inspected.ok)
209
+ return { ok: false, reason: inspected.reason };
210
+ const placement = inspected.placement;
211
+ requireSameContext("freshCall", placement, env);
212
+ const prompt = buildFreshCallPrompt({
213
+ backend: params.backend,
214
+ task,
215
+ callerGardenId: params.callerGardenId,
216
+ nonce,
217
+ });
218
+ const run = runTmux(buildFreshCallArgs(placement, runtimePath, buildBackendArgs(params.backend, prompt, model), cwd), env);
219
+ assertTmuxOk("new-window", run);
220
+ let fields;
221
+ try {
222
+ fields = parseWindowFields(run.stdout);
223
+ }
224
+ catch (err) {
225
+ // The window exists and its id is precisely what could not be read. Diffing the inventory
226
+ // to find "the new one" is the guess this rail forbids everywhere else, so name the orphan.
227
+ throw new Error(`mux-fresh-call: launched ${runtimePath} but could not read the window handle tmux printed — a window may ` +
228
+ `be open in session ${placement.sessionId} that this call cannot identify or close: ${err instanceof Error ? err.message : String(err)}`);
229
+ }
230
+ return {
231
+ ok: true,
232
+ receipt: {
233
+ serverPid: placement.serverPid,
234
+ sessionId: placement.sessionId,
235
+ ...fields,
236
+ backend: params.backend,
237
+ model,
238
+ ...(cwd === undefined ? {} : { cwd }),
239
+ runtimePath,
240
+ nonce,
241
+ },
242
+ };
243
+ }
244
+ /** Why each refusal happened, in the caller's terms. A reason a caller cannot act on is a reason
245
+ * they will guess about. */
246
+ const REJECT_HINT = {
247
+ "no-tmux-context": "this agent is not running inside tmux, so there is no session to open a sibling beside",
248
+ "anchor-malformed": "TMUX_PANE is not a native pane id",
249
+ "anchor-unresolved": "tmux resolved no pane for this agent's anchor",
250
+ "anchor-mismatch": "tmux answered about a different pane than the one asked about",
251
+ "caller-identity-unavailable": "this surface has no record-backed garden id for the caller, so the sibling would have no address to call back to",
252
+ "cwd-not-absolute": "the requested cwd is not an absolute path (the value is taken literally — nothing trims or resolves it)",
253
+ "cwd-format-token": "the requested cwd contains '#', which tmux expands as a format inside -c — it would silently rewrite the path or run a command",
254
+ "cwd-missing": "the requested cwd does not exist; tmux would not report this, it would open the window in $HOME and look successful",
255
+ "cwd-not-directory": "the requested cwd exists but is not a directory",
256
+ "model-empty": "model is empty after trimming; fresh calls require an explicit model",
257
+ "model-invalid": `model must be one ${MODEL_MAX_CHARS}-character argv-safe id/alias without whitespace or tmux syntax`,
258
+ "task-empty": "task is empty after trimming",
259
+ "task-too-long": `task exceeds ${TASK_MAX_CHARS} characters`,
260
+ "runtime-unresolved": "the backend's runtime is not installed on PATH",
261
+ "runtime-not-absolute": "the resolved runtime path is not absolute",
262
+ "runtime-path-whitespace": "the resolved runtime path contains whitespace, which tmux would re-split",
263
+ "runtime-missing": "nothing exists at the resolved runtime path",
264
+ "runtime-not-regular-file": "the resolved runtime path is not a regular file",
265
+ "runtime-not-executable": "the resolved runtime path is not executable",
266
+ };
267
+ /**
268
+ * ONE renderer for both surfaces. Not a convenience: the two registrations are separate literals
269
+ * (that is this repo's shape), so a shared renderer is what keeps the operator-visible answer
270
+ * from drifting apart between native pi and the MCP bridge.
271
+ *
272
+ * The success text states the boundary out loud. A caller that reads "launched" and assumes
273
+ * "delivered" is the exact confusion the two-receipt split exists to prevent, so the text refuses
274
+ * to imply it.
275
+ */
276
+ export function renderFreshCall(result) {
277
+ if (!result.ok) {
278
+ return {
279
+ text: `entwurf_fresh_call rejected: ${result.reason} — ${REJECT_HINT[result.reason]}. No window was opened.`,
280
+ isError: true,
281
+ };
282
+ }
283
+ const r = result.receipt;
284
+ return {
285
+ text: `[entwurf fresh call →]\n` +
286
+ ` backend: ${r.backend} (${r.runtimePath})\n` +
287
+ ` model: ${r.model} (requested on the runtime CLI)\n` +
288
+ (r.cwd === undefined ? "" : ` cwd: ${r.cwd} (requested start directory — not an observation)\n`) +
289
+ ` window: ${r.windowId} (index ${r.windowIndex}) in session ${r.sessionId}\n` +
290
+ ` pane: ${r.paneId} pid ${r.panePid}\n` +
291
+ ` nonce: ${r.nonce}\n` +
292
+ `\n` +
293
+ `This is a LAUNCH receipt: tmux created that window and was asked to start the runtime with the model above. It does ` +
294
+ `NOT mean the sibling is running, that its first turn ran, or that the task was delivered.\n` +
295
+ `The sibling's garden id arrives separately — it calls entwurf_v2 back with the nonce above as its ` +
296
+ `first action, and the sender envelope of THAT message is the address. Nothing is polling for it; ` +
297
+ `if it never comes, the window is visible and can be read directly.`,
298
+ isError: false,
299
+ };
300
+ }
@@ -0,0 +1,202 @@
1
+ /**
2
+ * mux-launch — T1-a: open ONE visible window in the caller's own tmux session and start the
3
+ * fixed official `pi` in it. Nothing after that.
4
+ *
5
+ * This is deliberately a SECOND module rather than a fourth verb on `mux-placement`. The
6
+ * placement leaf owns place and only place (`docs/mux-launch-rail.md` §11): a command
7
+ * parameter there would be the carrier that leaf refuses to have, and the deterministic gate
8
+ * pins `appendWindow`'s argv as exactly the no-carrier shape. So the composition lives here,
9
+ * on top of the leaf's grammar, binding and failure discipline, and the leaf stays deletable.
10
+ *
11
+ * The import lines this module must never cross (docs §11, asserted by check-mux-launch):
12
+ *
13
+ * entwurf-v2 contract/decider/runner/production -X-> mux-launch
14
+ * mux-launch -X-> entwurf core
15
+ *
16
+ * T1-a is launch, NOT delegation. This module does not wait for a garden identity, does not
17
+ * read the meta-record store, does not send a task, and has no idea what `entwurf_v2` is.
18
+ * The separate fresh-call composition reuses its precondition helpers but owns first-turn
19
+ * framing and callback correlation itself — see `docs/mux-launch-rail.md` §6-a and §11.
20
+ *
21
+ * ── What the receipt means, and what it deliberately does NOT ──
22
+ *
23
+ * A `PiLaunch` states two things: the ids tmux reported when it created the window, and the
24
+ * absolute runtime path we handed it. It does NOT claim pi is running.
25
+ *
26
+ * That restraint is measured, not modest. `new-window … -- /nonexistent/bin/nope` exits 0,
27
+ * prints a perfectly well-formed handle, and the window is STILL LISTED on an immediate
28
+ * re-read — 10/10 trials on tmux 3.6a, indistinguishable from a good launch at that instant.
29
+ * The window dies milliseconds later, through the same `remain-on-exit off` path
30
+ * `CloseOutcome.already-gone` already describes. So a post-launch presence check would prove
31
+ * nothing while making the receipt LOOK verified, which is worse than not checking: it is the
32
+ * rc=0 trap of T0-a wearing a different hat. This module refuses to perform one.
33
+ *
34
+ * What can honestly be done is to delete the failure class BEFORE a window exists, and that
35
+ * is what `assertLaunchTarget` does — the same precondition discipline
36
+ * `scripts/lib/probe-cli-target.ts` applies to the ACP CLI target: absolute, present, regular
37
+ * file, executable. A refusal is a NAMED reason on an unopened window, never a fallback.
38
+ *
39
+ * ── Cleanup, and why the contract is thin by construction ──
40
+ *
41
+ * There is exactly ONE mutation in this module: a single `new-window`. Everything after it is
42
+ * pure parsing. That is the cleanup design — not a compensating transaction, but a shape with
43
+ * almost nothing to compensate. The one residue: if tmux reports a handle this module cannot
44
+ * parse, the window exists and its id is precisely the thing we failed to read, so there is no
45
+ * honest target to close. That case fails loud and NAMES the orphan rather than killing a
46
+ * window picked by inventory diffing, which is the guess this rail forbids everywhere else.
47
+ *
48
+ * ── The whitespace precondition is not hygiene ──
49
+ *
50
+ * Measured on tmux 3.6a: a single argv element containing a space is RE-SPLIT by tmux, so a
51
+ * runtime at `/tmp/x y/pi` is launched as `/tmp/x` with the argument `y/pi` and the window
52
+ * dies instantly. `--` stops tmux from running the target through a shell (verified: an
53
+ * argument of `x; touch <file>` created no file), but it does not stop that re-split. So the
54
+ * path is checked for whitespace before it is handed over.
55
+ */
56
+ import { accessSync, constants as fsConstants, statSync } from "node:fs";
57
+ import { delimiter, isAbsolute, join } from "node:path";
58
+ import { APPEND_FORMAT, assertSelector, assertTmuxOk, parseWindowFields, requireSameContext, runTmux, } from "./mux-placement.js";
59
+ /** The official runtime this rail opens. FIXED: there is no command carrier, and adding one
60
+ * is a separate ownership decision (docs §11), not a parameter. */
61
+ export const PI_RUNTIME_COMMAND = "pi";
62
+ /** A named precondition failure. Callers surface `reason`; they never soften it into a
63
+ * fallback launch of something else. */
64
+ export class LaunchPreconditionError extends Error {
65
+ reason;
66
+ constructor(reason, message) {
67
+ super(message);
68
+ this.name = "LaunchPreconditionError";
69
+ this.reason = reason;
70
+ }
71
+ }
72
+ /** Any ASCII or Unicode whitespace — the tmux re-split hazard is about the space character,
73
+ * but a tab or newline in an argv element is no more launchable and no less a surprise. */
74
+ const WHITESPACE = /\s/;
75
+ /**
76
+ * The five preconditions, in the order a failure would otherwise bite. Absolute first
77
+ * (everything after it is meaningless for a relative path), whitespace before the filesystem
78
+ * questions (a re-split path's stat is about the wrong file), then present → regular →
79
+ * executable, exactly as `probe-cli-target.ts` orders them.
80
+ */
81
+ export function assertLaunchTarget(runtimePath) {
82
+ if (!isAbsolute(runtimePath)) {
83
+ throw new LaunchPreconditionError("runtime-not-absolute", `mux-launch: runtime target ${JSON.stringify(runtimePath)} is not absolute — tmux would resolve it against ` +
84
+ "the new pane's cwd, so which binary started would depend on where the window happened to open");
85
+ }
86
+ if (WHITESPACE.test(runtimePath)) {
87
+ throw new LaunchPreconditionError("runtime-path-whitespace", `mux-launch: runtime target ${JSON.stringify(runtimePath)} contains whitespace — tmux RE-SPLITS a single ` +
88
+ "argv element on spaces (measured, 3.6a), so this would launch a truncated path with the remainder as " +
89
+ "an argument and the window would die instantly");
90
+ }
91
+ let stat;
92
+ try {
93
+ stat = statSync(runtimePath);
94
+ }
95
+ catch {
96
+ throw new LaunchPreconditionError("runtime-missing", `mux-launch: runtime target ${runtimePath} does not exist — refusing before a window is opened onto a ` +
97
+ "runtime that cannot start");
98
+ }
99
+ if (!stat.isFile()) {
100
+ throw new LaunchPreconditionError("runtime-not-regular-file", `mux-launch: runtime target ${runtimePath} is not a regular file — a directory or special file cannot be ` +
101
+ "the official runtime");
102
+ }
103
+ try {
104
+ accessSync(runtimePath, fsConstants.X_OK);
105
+ }
106
+ catch {
107
+ throw new LaunchPreconditionError("runtime-not-executable", `mux-launch: runtime target ${runtimePath} is not executable (X_OK) — exec would fail after the window ` +
108
+ "already existed, which is exactly the state this precondition exists to prevent");
109
+ }
110
+ }
111
+ /**
112
+ * Find the official runtime on the caller's PATH and prove it launchable.
113
+ *
114
+ * PATH rather than a compiled-in location because the official `pi` is whatever the operator's
115
+ * environment resolves — pinning a path here would silently launch a different install than
116
+ * the one the operator gets by typing `pi`. Resolving it to an ABSOLUTE path (rather than
117
+ * handing tmux the bare word) is what makes the preconditions mean anything: a bare `pi`
118
+ * would be re-resolved inside the pane, after the window exists, against an environment this
119
+ * module never checked.
120
+ *
121
+ * Empty PATH entries are skipped rather than treated as cwd — the historical POSIX reading of
122
+ * `::` is a cwd search, and a launch that depends on where the caller stood is the drift this
123
+ * rail refuses.
124
+ */
125
+ export function resolveRuntimeOnPath(command, env = process.env) {
126
+ const raw = env.PATH;
127
+ const entries = typeof raw === "string" ? raw.split(delimiter).filter((p) => p.length > 0) : [];
128
+ for (const dir of entries) {
129
+ if (!isAbsolute(dir))
130
+ continue;
131
+ const candidate = join(dir, command);
132
+ try {
133
+ if (!statSync(candidate).isFile())
134
+ continue;
135
+ accessSync(candidate, fsConstants.X_OK);
136
+ }
137
+ catch {
138
+ continue;
139
+ }
140
+ assertLaunchTarget(candidate);
141
+ return candidate;
142
+ }
143
+ throw new LaunchPreconditionError("runtime-unresolved", `mux-launch: no executable ${JSON.stringify(command)} on PATH — refusing to open a window for a ` +
144
+ "runtime that is not installed");
145
+ }
146
+ /**
147
+ * T1-a's own resolution: the fixed official `pi`. Kept as a named function rather than a call
148
+ * site with a constant because the launch contract is "this rail opens `pi`", and a caller that
149
+ * could pass a command would be the carrier this module refuses to have (docs §11). The
150
+ * generic form above exists only so a SEPARATE composition can apply the SAME preconditions to
151
+ * its own fixed runtime — not so this one becomes parameterised.
152
+ */
153
+ export function resolvePiRuntime(env = process.env) {
154
+ return resolveRuntimeOnPath(PI_RUNTIME_COMMAND, env);
155
+ }
156
+ /**
157
+ * The launch argv. Identical to the leaf's append shape — detached, appended at `{end}`,
158
+ * addressed by session id, printing the same handle format — with exactly one addition: the
159
+ * fixed runtime after `--`.
160
+ *
161
+ * `-d` is kept. "Visible" in T1-a means the window is a real window in the operator's own
162
+ * session rather than a headless child; it does not mean stealing the caller's focus. Moving
163
+ * focus is a different verb with a different owner, and the leaf's acceptance already pins
164
+ * "focus unchanged" as a property worth having.
165
+ *
166
+ * `--` is what stops tmux from handing the target to a shell (measured: an argument of
167
+ * `x; touch <file>` created no file). The selector and the runtime are both validated before
168
+ * this returns, so nothing unvalidated ever reaches tmux's parser.
169
+ */
170
+ export function buildLaunchArgs(sessionId, runtimePath) {
171
+ assertSelector("session", sessionId);
172
+ assertLaunchTarget(runtimePath);
173
+ return ["new-window", "-d", "-a", "-t", `${sessionId}:{end}`, "-P", "-F", APPEND_FORMAT, "--", runtimePath];
174
+ }
175
+ /**
176
+ * Open one window in the caller's own session and start the fixed official `pi` in it.
177
+ *
178
+ * Order matters and is the whole safety argument: resolve and prove the runtime FIRST, then
179
+ * re-read the caller's placement and refuse a changed context (the leaf's binding), and only
180
+ * then mutate. A precondition failure therefore never leaves a window behind, because no
181
+ * window has been opened yet.
182
+ *
183
+ * Responsibility ends at the returned ids. No identity, no delivery, no supervision.
184
+ */
185
+ export function launchPi(placement, env = process.env) {
186
+ const runtimePath = resolvePiRuntime(env);
187
+ requireSameContext("launchPi", placement, env);
188
+ const run = runTmux(buildLaunchArgs(placement.sessionId, runtimePath), env);
189
+ assertTmuxOk("new-window", run);
190
+ let fields;
191
+ try {
192
+ fields = parseWindowFields(run.stdout);
193
+ }
194
+ catch (err) {
195
+ // The window exists and its id is exactly what we failed to read. Diffing the inventory
196
+ // to find "the new one" is the guess this rail forbids, so name the orphan instead of
197
+ // killing a window chosen by inference.
198
+ throw new Error(`mux-launch: launched ${runtimePath} but could not read the window handle tmux printed — a window may be ` +
199
+ `open in session ${placement.sessionId} that this call cannot identify or close: ${err instanceof Error ? err.message : String(err)}`);
200
+ }
201
+ return { serverPid: placement.serverPid, sessionId: placement.sessionId, ...fields, runtimePath };
202
+ }