@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
@@ -0,0 +1,267 @@
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
+
57
+ import { accessSync, constants as fsConstants, statSync } from "node:fs";
58
+ import { delimiter, isAbsolute, join } from "node:path";
59
+ import {
60
+ APPEND_FORMAT,
61
+ assertSelector,
62
+ assertTmuxOk,
63
+ type Placement,
64
+ parseWindowFields,
65
+ requireSameContext,
66
+ runTmux,
67
+ type WindowHandle,
68
+ } from "./mux-placement.ts";
69
+
70
+ /** The official runtime this rail opens. FIXED: there is no command carrier, and adding one
71
+ * is a separate ownership decision (docs §11), not a parameter. */
72
+ export const PI_RUNTIME_COMMAND = "pi";
73
+
74
+ /**
75
+ * Why a launch was refused BEFORE any window was opened. Each is a named refusal, never a
76
+ * fallback — a caller that cannot start the official runtime must not be handed a window.
77
+ *
78
+ * `runtime-unresolved` no `PI_RUNTIME_COMMAND` on PATH (or no usable PATH)
79
+ * `runtime-not-absolute` a relative target would resolve against the pane's cwd
80
+ * `runtime-path-whitespace` tmux re-splits a spaced argv element (measured)
81
+ * `runtime-missing` nothing at that path
82
+ * `runtime-not-regular-file` a directory/special file cannot be the runtime
83
+ * `runtime-not-executable` exec would fail after the window already existed
84
+ */
85
+ export type LaunchRejectReason =
86
+ | "runtime-unresolved"
87
+ | "runtime-not-absolute"
88
+ | "runtime-path-whitespace"
89
+ | "runtime-missing"
90
+ | "runtime-not-regular-file"
91
+ | "runtime-not-executable";
92
+
93
+ /** A named precondition failure. Callers surface `reason`; they never soften it into a
94
+ * fallback launch of something else. */
95
+ export class LaunchPreconditionError extends Error {
96
+ readonly reason: LaunchRejectReason;
97
+ constructor(reason: LaunchRejectReason, message: string) {
98
+ super(message);
99
+ this.name = "LaunchPreconditionError";
100
+ this.reason = reason;
101
+ }
102
+ }
103
+
104
+ /** Any ASCII or Unicode whitespace — the tmux re-split hazard is about the space character,
105
+ * but a tab or newline in an argv element is no more launchable and no less a surprise. */
106
+ const WHITESPACE = /\s/;
107
+
108
+ /**
109
+ * The five preconditions, in the order a failure would otherwise bite. Absolute first
110
+ * (everything after it is meaningless for a relative path), whitespace before the filesystem
111
+ * questions (a re-split path's stat is about the wrong file), then present → regular →
112
+ * executable, exactly as `probe-cli-target.ts` orders them.
113
+ */
114
+ export function assertLaunchTarget(runtimePath: string): void {
115
+ if (!isAbsolute(runtimePath)) {
116
+ throw new LaunchPreconditionError(
117
+ "runtime-not-absolute",
118
+ `mux-launch: runtime target ${JSON.stringify(runtimePath)} is not absolute — tmux would resolve it against ` +
119
+ "the new pane's cwd, so which binary started would depend on where the window happened to open",
120
+ );
121
+ }
122
+ if (WHITESPACE.test(runtimePath)) {
123
+ throw new LaunchPreconditionError(
124
+ "runtime-path-whitespace",
125
+ `mux-launch: runtime target ${JSON.stringify(runtimePath)} contains whitespace — tmux RE-SPLITS a single ` +
126
+ "argv element on spaces (measured, 3.6a), so this would launch a truncated path with the remainder as " +
127
+ "an argument and the window would die instantly",
128
+ );
129
+ }
130
+ let stat: ReturnType<typeof statSync>;
131
+ try {
132
+ stat = statSync(runtimePath);
133
+ } catch {
134
+ throw new LaunchPreconditionError(
135
+ "runtime-missing",
136
+ `mux-launch: runtime target ${runtimePath} does not exist — refusing before a window is opened onto a ` +
137
+ "runtime that cannot start",
138
+ );
139
+ }
140
+ if (!stat.isFile()) {
141
+ throw new LaunchPreconditionError(
142
+ "runtime-not-regular-file",
143
+ `mux-launch: runtime target ${runtimePath} is not a regular file — a directory or special file cannot be ` +
144
+ "the official runtime",
145
+ );
146
+ }
147
+ try {
148
+ accessSync(runtimePath, fsConstants.X_OK);
149
+ } catch {
150
+ throw new LaunchPreconditionError(
151
+ "runtime-not-executable",
152
+ `mux-launch: runtime target ${runtimePath} is not executable (X_OK) — exec would fail after the window ` +
153
+ "already existed, which is exactly the state this precondition exists to prevent",
154
+ );
155
+ }
156
+ }
157
+
158
+ /**
159
+ * Find the official runtime on the caller's PATH and prove it launchable.
160
+ *
161
+ * PATH rather than a compiled-in location because the official `pi` is whatever the operator's
162
+ * environment resolves — pinning a path here would silently launch a different install than
163
+ * the one the operator gets by typing `pi`. Resolving it to an ABSOLUTE path (rather than
164
+ * handing tmux the bare word) is what makes the preconditions mean anything: a bare `pi`
165
+ * would be re-resolved inside the pane, after the window exists, against an environment this
166
+ * module never checked.
167
+ *
168
+ * Empty PATH entries are skipped rather than treated as cwd — the historical POSIX reading of
169
+ * `::` is a cwd search, and a launch that depends on where the caller stood is the drift this
170
+ * rail refuses.
171
+ */
172
+ export function resolveRuntimeOnPath(command: string, env: NodeJS.ProcessEnv = process.env): string {
173
+ const raw = env.PATH;
174
+ const entries = typeof raw === "string" ? raw.split(delimiter).filter((p) => p.length > 0) : [];
175
+ for (const dir of entries) {
176
+ if (!isAbsolute(dir)) continue;
177
+ const candidate = join(dir, command);
178
+ try {
179
+ if (!statSync(candidate).isFile()) continue;
180
+ accessSync(candidate, fsConstants.X_OK);
181
+ } catch {
182
+ continue;
183
+ }
184
+ assertLaunchTarget(candidate);
185
+ return candidate;
186
+ }
187
+ throw new LaunchPreconditionError(
188
+ "runtime-unresolved",
189
+ `mux-launch: no executable ${JSON.stringify(command)} on PATH — refusing to open a window for a ` +
190
+ "runtime that is not installed",
191
+ );
192
+ }
193
+
194
+ /**
195
+ * T1-a's own resolution: the fixed official `pi`. Kept as a named function rather than a call
196
+ * site with a constant because the launch contract is "this rail opens `pi`", and a caller that
197
+ * could pass a command would be the carrier this module refuses to have (docs §11). The
198
+ * generic form above exists only so a SEPARATE composition can apply the SAME preconditions to
199
+ * its own fixed runtime — not so this one becomes parameterised.
200
+ */
201
+ export function resolvePiRuntime(env: NodeJS.ProcessEnv = process.env): string {
202
+ return resolveRuntimeOnPath(PI_RUNTIME_COMMAND, env);
203
+ }
204
+
205
+ /**
206
+ * The launch argv. Identical to the leaf's append shape — detached, appended at `{end}`,
207
+ * addressed by session id, printing the same handle format — with exactly one addition: the
208
+ * fixed runtime after `--`.
209
+ *
210
+ * `-d` is kept. "Visible" in T1-a means the window is a real window in the operator's own
211
+ * session rather than a headless child; it does not mean stealing the caller's focus. Moving
212
+ * focus is a different verb with a different owner, and the leaf's acceptance already pins
213
+ * "focus unchanged" as a property worth having.
214
+ *
215
+ * `--` is what stops tmux from handing the target to a shell (measured: an argument of
216
+ * `x; touch <file>` created no file). The selector and the runtime are both validated before
217
+ * this returns, so nothing unvalidated ever reaches tmux's parser.
218
+ */
219
+ export function buildLaunchArgs(sessionId: string, runtimePath: string): string[] {
220
+ assertSelector("session", sessionId);
221
+ assertLaunchTarget(runtimePath);
222
+ return ["new-window", "-d", "-a", "-t", `${sessionId}:{end}`, "-P", "-F", APPEND_FORMAT, "--", runtimePath];
223
+ }
224
+
225
+ /**
226
+ * What T1-a returns: the window handle tmux reported, plus the runtime path that was handed
227
+ * to it. `panePid` is the launched process itself — measured: with `--`, tmux execs the target
228
+ * directly and no shell wrapper survives in the pane.
229
+ *
230
+ * Read `runtimePath` as "what we asked tmux to start", not "what is running". See the module
231
+ * header: no synchronous check can tell those apart, so this type does not pretend to.
232
+ */
233
+ export interface PiLaunch extends WindowHandle {
234
+ runtimePath: string;
235
+ }
236
+
237
+ /**
238
+ * Open one window in the caller's own session and start the fixed official `pi` in it.
239
+ *
240
+ * Order matters and is the whole safety argument: resolve and prove the runtime FIRST, then
241
+ * re-read the caller's placement and refuse a changed context (the leaf's binding), and only
242
+ * then mutate. A precondition failure therefore never leaves a window behind, because no
243
+ * window has been opened yet.
244
+ *
245
+ * Responsibility ends at the returned ids. No identity, no delivery, no supervision.
246
+ */
247
+ export function launchPi(placement: Placement, env: NodeJS.ProcessEnv = process.env): PiLaunch {
248
+ const runtimePath = resolvePiRuntime(env);
249
+ requireSameContext("launchPi", placement, env);
250
+ const run = runTmux(buildLaunchArgs(placement.sessionId, runtimePath), env);
251
+ assertTmuxOk("new-window", run);
252
+ let fields: ReturnType<typeof parseWindowFields>;
253
+ try {
254
+ fields = parseWindowFields(run.stdout);
255
+ } catch (err) {
256
+ // The window exists and its id is exactly what we failed to read. Diffing the inventory
257
+ // to find "the new one" is the guess this rail forbids, so name the orphan instead of
258
+ // killing a window chosen by inference.
259
+ throw new Error(
260
+ `mux-launch: launched ${runtimePath} but could not read the window handle tmux printed — a window may be ` +
261
+ `open in session ${placement.sessionId} that this call cannot identify or close: ${
262
+ err instanceof Error ? err.message : String(err)
263
+ }`,
264
+ );
265
+ }
266
+ return { serverPid: placement.serverPid, sessionId: placement.sessionId, ...fields, runtimePath };
267
+ }
@@ -0,0 +1,387 @@
1
+ /**
2
+ * mux-placement — where a new sibling runtime is OPENED, and nothing else.
3
+ *
4
+ * The operating contract (2026-08-04, GLG): a caller that asks entwurf to open a new
5
+ * runtime is ALWAYS inside tmux. Outside tmux there is no fallback session, no
6
+ * `new-session`, no guess — the request is refused. The window is appended to exactly the
7
+ * server/session the caller's own inherited `TMUX`/`TMUX_PANE` names, because that is the
8
+ * session the operator is looking at. Inheriting the environment IS the addressing: this
9
+ * module never takes a socket path or a session name from a caller.
10
+ *
11
+ * Three actions, deliberately not four:
12
+ *
13
+ * inspectPlacement() the caller's own $session/@window/%pane, or a named refusal
14
+ * appendWindow() one default-shell window at the end of that same session
15
+ * closeWindow() that window, by stable id, in the context it was opened in
16
+ *
17
+ * What this module is NOT: a delivery transport, an address, a liveness fact, a launcher.
18
+ * It opens a place. `entwurf_v2` still owns delivery (`V2-DELIVERY-EXCLUDES-MUX`), and
19
+ * nothing here mints a garden id.
20
+ *
21
+ * Beside those three actions, `runTmux` and `requireSameContext` are exported as a NARROW
22
+ * INTERNAL SEAM for the T1-a launch composition to build on — not a fourth action and not a
23
+ * public operator surface. Say the risk plainly: `runTmux` takes argv this module did not
24
+ * author, so boundary 2 below does not protect it. The grammar is enforced by the `build*Args`
25
+ * builders, and anything reaching for `runTmux` owes its own argv the same validation. Its two
26
+ * production consumers are the launch and fresh-call compositions (`docs/mux-launch-rail.md`
27
+ * §11); the shipped delivery path calls neither.
28
+ *
29
+ * ── Four boundaries, each of which was a real way to touch the wrong thing ──
30
+ *
31
+ * 1. CONTEXT. Both `TMUX` and `TMUX_PANE` must be present and non-empty before anything
32
+ * runs. `TMUX_PANE` alone is not enough: tmux invoked without `TMUX` in the environment
33
+ * resolves to the DEFAULT server, so a caller carrying a stale pane id from a dead
34
+ * server would have mutated the operator's live one. `TMUX` is checked for presence
35
+ * only — never parsed. The socket and session inside that string are tmux's to report,
36
+ * not ours to extract.
37
+ *
38
+ * 2. GRAMMAR. Every selector handed to `-t` is a native id and is validated against its
39
+ * grammar BEFORE tmux is invoked. T0-a measured that tmux's own parser reinterprets
40
+ * values (a trailing `;` can split a command; `#{…}`/`#(…)` expand), so "we used argv,
41
+ * not a shell" is not by itself a safety argument. `%12`, `$3`, `@7` and decimal
42
+ * pids/indices are the entire accepted vocabulary; anything else is refused unrun.
43
+ *
44
+ * 3. FAILURE. `display-message` returning rc=0 is not proof that a pane exists — it answers
45
+ * rc=0 for a nonexistent target (filling only the SERVER fields) and rc=0 for an EMPTY
46
+ * target while silently describing the current pane. That is why the echo-back exists.
47
+ * But the converse does not follow: a NONZERO exit (no server, EACCES, a usage error) is
48
+ * an operational failure and is raised, never laundered into "the anchor did not
49
+ * resolve". Only an rc=0 row is ever parsed.
50
+ *
51
+ * 4. BINDING. A `Placement` is a fact about one server and one session, and the environment
52
+ * passed to a later call could name a different — or restarted — server where the same
53
+ * `$3`/`@7` mean something else entirely. Every mutation re-reads the caller's placement
54
+ * and refuses unless the server pid and session id still match the ones the handle was
55
+ * born in.
56
+ *
57
+ * The machine-readable rows carry ONLY native ids and decimal numbers, joined by `|`. The
58
+ * free-form fields tmux could also report (`socket_path`, `session_name`) are deliberately
59
+ * absent: a session legitimately named `a|b` would otherwise split the row. A display name
60
+ * is a later, separate concern, and nothing here needs one.
61
+ *
62
+ * ── Two more facts measured in T0-a (private tmux 3.6a) ──
63
+ *
64
+ * `renumber-windows on` (GLG's shipped setting) renumbers surviving windows when one in the
65
+ * middle disappears, so the human index is a keybinding affordance and never a handle.
66
+ * `remain-on-exit off` (the default) destroys a window when its pane process ends, so a
67
+ * window can vanish through a path this module never called; see `CloseOutcome`.
68
+ */
69
+
70
+ import { spawnSync } from "node:child_process";
71
+
72
+ // ── Native id grammar (boundary 2) ─────────────────────────────────────────
73
+
74
+ const PANE_ID = /^%[0-9]+$/;
75
+ const SESSION_ID = /^\$[0-9]+$/;
76
+ const WINDOW_ID = /^@[0-9]+$/;
77
+ const DECIMAL = /^[0-9]+$/;
78
+
79
+ /** True only for tmux's own native pane id. */
80
+ export function isPaneId(value: string): boolean {
81
+ return PANE_ID.test(value);
82
+ }
83
+
84
+ /** True only for tmux's own native session id. */
85
+ export function isSessionId(value: string): boolean {
86
+ return SESSION_ID.test(value);
87
+ }
88
+
89
+ /** True only for tmux's own native window id. */
90
+ export function isWindowId(value: string): boolean {
91
+ return WINDOW_ID.test(value);
92
+ }
93
+
94
+ /** True only for a bare decimal (pids, window indices). */
95
+ export function isDecimal(value: string): boolean {
96
+ return DECIMAL.test(value);
97
+ }
98
+
99
+ /**
100
+ * Refuse a selector before it reaches tmux. The throw is deliberate and unconditional: a
101
+ * selector that is not a native id is either a bug or an injection attempt, and neither is
102
+ * something to route into tmux's parser to find out.
103
+ */
104
+ export function assertSelector(kind: "pane" | "session" | "window", value: string): void {
105
+ const okShape = kind === "pane" ? isPaneId(value) : kind === "session" ? isSessionId(value) : isWindowId(value);
106
+ if (!okShape) {
107
+ throw new Error(`mux-placement: refusing a non-native ${kind} selector ${JSON.stringify(value)}`);
108
+ }
109
+ }
110
+
111
+ // ── Facts ──────────────────────────────────────────────────────────────────
112
+
113
+ /**
114
+ * The caller's own coordinates. Native ids and decimals only — see the delimiter note in the
115
+ * module header for why `socket_path`/`session_name` are not here.
116
+ */
117
+ export interface Placement {
118
+ serverPid: string;
119
+ sessionId: string;
120
+ windowId: string;
121
+ windowIndex: string;
122
+ paneId: string;
123
+ panePid: string;
124
+ }
125
+
126
+ /**
127
+ * A window this module opened, carrying the context it was born in. `serverPid`/`sessionId`
128
+ * are what let a later close prove it is acting on the same server and session rather than a
129
+ * restarted one that happens to reuse the id. Index is reported for the human, never used as
130
+ * a handle.
131
+ */
132
+ export interface WindowHandle {
133
+ serverPid: string;
134
+ sessionId: string;
135
+ windowId: string;
136
+ windowIndex: string;
137
+ paneId: string;
138
+ panePid: string;
139
+ }
140
+
141
+ /**
142
+ * Why a placement could not be established. Each one is a refusal, never a fallback:
143
+ * - `no-tmux-context` `TMUX` and/or `TMUX_PANE` missing or empty — not inside tmux
144
+ * - `anchor-malformed` `TMUX_PANE` is present but is not a native `%N` pane id
145
+ * - `anchor-unresolved` tmux resolved no pane for that anchor
146
+ * - `anchor-mismatch` tmux answered about a DIFFERENT pane than the one asked about
147
+ */
148
+ export type PlacementRejectReason = "no-tmux-context" | "anchor-malformed" | "anchor-unresolved" | "anchor-mismatch";
149
+
150
+ export type InspectResult = { ok: true; placement: Placement } | { ok: false; reason: PlacementRejectReason };
151
+
152
+ /**
153
+ * What a close actually did.
154
+ *
155
+ * `already-gone` is NOT a blanket "any error is fine". It is issued only when the window is
156
+ * verifiably absent from a fresh `list-windows` — positive proof of absence, the same
157
+ * discipline socket-probe.ts applies to liveness. Any other failure throws, because the
158
+ * failure we must never swallow is "the window is still there and we did not close it".
159
+ *
160
+ * Why report it at all instead of returning void: `remain-on-exit off` makes disappearance a
161
+ * normal race, not an error, so failing loud on it would be wrong; but a caller that asked to
162
+ * close a window and got silence cannot tell whether its runtime exited on its own. Both
163
+ * readings matter later (T1 will want to know whether a launched runtime died before we tore
164
+ * it down), so the outcome is evidence, not a swallowed exception.
165
+ */
166
+ export type CloseOutcome = "closed" | "already-gone";
167
+
168
+ // ── Pure argv / parsing (the half a deterministic gate can pin) ─────────────
169
+
170
+ /** Field order of `INSPECT_FORMAT`. Parsing and formatting share this list. */
171
+ export const PLACEMENT_FIELDS = ["pid", "session_id", "window_id", "window_index", "pane_id", "pane_pid"] as const;
172
+
173
+ const SEP = "|";
174
+
175
+ /** tmux format string for a placement query — tmux's own facts, never string surgery on `$TMUX`. */
176
+ export const INSPECT_FORMAT = PLACEMENT_FIELDS.map((f) => `#{${f}}`).join(SEP);
177
+
178
+ /** Field order of `APPEND_FORMAT` — what `new-window -P -F` prints for the window it created. */
179
+ export const WINDOW_FIELDS = ["window_id", "window_index", "pane_id", "pane_pid"] as const;
180
+
181
+ export const APPEND_FORMAT = WINDOW_FIELDS.map((f) => `#{${f}}`).join(SEP);
182
+
183
+ /**
184
+ * Read the caller's anchor. BOTH `TMUX` and `TMUX_PANE` must be present and non-empty:
185
+ * `TMUX_PANE` names which pane, `TMUX` is what makes a bare `tmux` resolve to the caller's
186
+ * server instead of the default one. `TMUX` is checked for presence only — parsing the
187
+ * socket/session out of it is the guess this rail forbids.
188
+ */
189
+ export function readAnchor(
190
+ env: NodeJS.ProcessEnv,
191
+ ): { ok: true; anchor: string } | { ok: false; reason: "no-tmux-context" | "anchor-malformed" } {
192
+ const server = env.TMUX;
193
+ const anchor = env.TMUX_PANE;
194
+ if (typeof server !== "string" || server.length === 0) return { ok: false, reason: "no-tmux-context" };
195
+ if (typeof anchor !== "string" || anchor.length === 0) return { ok: false, reason: "no-tmux-context" };
196
+ if (!isPaneId(anchor)) return { ok: false, reason: "anchor-malformed" };
197
+ return { ok: true, anchor };
198
+ }
199
+
200
+ export function buildInspectArgs(anchor: string): string[] {
201
+ assertSelector("pane", anchor);
202
+ return ["display-message", "-p", "-t", anchor, INSPECT_FORMAT];
203
+ }
204
+
205
+ /**
206
+ * Append at the END of the caller's own session. `{end}` is the append semantics we want
207
+ * literally; the lowest-free-index behaviour of a bare `new-window` is a different verb and
208
+ * is not this contract. Targeting by `session_id` (`$0`) rather than name keeps a renamed or
209
+ * duplicate-named session from redirecting the append.
210
+ *
211
+ * `-d` keeps the caller's focus. No `-n`, no `-c`, no shell-command: default shell only.
212
+ */
213
+ export function buildAppendArgs(sessionId: string): string[] {
214
+ assertSelector("session", sessionId);
215
+ return ["new-window", "-d", "-a", "-t", `${sessionId}:{end}`, "-P", "-F", APPEND_FORMAT];
216
+ }
217
+
218
+ export function buildCloseArgs(windowId: string): string[] {
219
+ assertSelector("window", windowId);
220
+ return ["kill-window", "-t", windowId];
221
+ }
222
+
223
+ /** Every window on the server, for the positive absence proof an `already-gone` needs. */
224
+ export function buildListWindowIdsArgs(): string[] {
225
+ return ["list-windows", "-a", "-F", "#{window_id}"];
226
+ }
227
+
228
+ /**
229
+ * Turn an rc=0 placement row into facts — with the echo-back check that tmux's exit status
230
+ * cannot provide, and a grammar check on every field. A nonexistent target still fills the
231
+ * server fields and leaves the rest empty, so "the row was non-empty" proves nothing; an
232
+ * empty pane id means no pane resolved at all, which is unresolved rather than an answer
233
+ * about some other pane.
234
+ */
235
+ export function parsePlacement(anchor: string, stdout: string): InspectResult {
236
+ const line = stdout.split("\n", 1)[0]?.trim() ?? "";
237
+ if (line.length === 0) return { ok: false, reason: "anchor-unresolved" };
238
+ const parts = line.split(SEP);
239
+ if (parts.length !== PLACEMENT_FIELDS.length) return { ok: false, reason: "anchor-unresolved" };
240
+ const [serverPid, sessionId, windowId, windowIndex, paneId, panePid] = parts as string[];
241
+ if (paneId.length === 0) return { ok: false, reason: "anchor-unresolved" };
242
+ if (paneId !== anchor) return { ok: false, reason: "anchor-mismatch" };
243
+ const wellFormed =
244
+ isDecimal(serverPid) &&
245
+ isSessionId(sessionId) &&
246
+ isWindowId(windowId) &&
247
+ isDecimal(windowIndex) &&
248
+ isPaneId(paneId) &&
249
+ isDecimal(panePid);
250
+ if (!wellFormed) return { ok: false, reason: "anchor-unresolved" };
251
+ return { ok: true, placement: { serverPid, sessionId, windowId, windowIndex, paneId, panePid } };
252
+ }
253
+
254
+ export function parseWindowFields(stdout: string): {
255
+ windowId: string;
256
+ windowIndex: string;
257
+ paneId: string;
258
+ panePid: string;
259
+ } {
260
+ const line = stdout.split("\n", 1)[0]?.trim() ?? "";
261
+ const parts = line.split(SEP);
262
+ if (line.length === 0 || parts.length !== WINDOW_FIELDS.length) {
263
+ throw new Error(`mux-placement: new-window did not report a window handle (got ${JSON.stringify(line)})`);
264
+ }
265
+ const [windowId, windowIndex, paneId, panePid] = parts as string[];
266
+ if (!isWindowId(windowId) || !isDecimal(windowIndex) || !isPaneId(paneId) || !isDecimal(panePid)) {
267
+ throw new Error(`mux-placement: new-window reported a non-native handle (got ${JSON.stringify(line)})`);
268
+ }
269
+ return { windowId, windowIndex, paneId, panePid };
270
+ }
271
+
272
+ /**
273
+ * Classify a FAILED `kill-window` against a fresh window inventory. Absent → `already-gone`
274
+ * (positive proof). Present → `null`, and the caller must fail loud: the window is still
275
+ * there and we did not close it.
276
+ */
277
+ export function classifyCloseFailure(windowId: string, listStdout: string): CloseOutcome | null {
278
+ const ids = listStdout
279
+ .split("\n")
280
+ .map((l) => l.trim())
281
+ .filter((l) => l.length > 0);
282
+ return ids.includes(windowId) ? null : "already-gone";
283
+ }
284
+
285
+ // ── The three actions ──────────────────────────────────────────────────────
286
+
287
+ export interface TmuxRun {
288
+ status: number | null;
289
+ stdout: string;
290
+ stderr: string;
291
+ }
292
+
293
+ /**
294
+ * An rc=0 run is the ONLY kind whose stdout may be parsed. A nonzero status (no server,
295
+ * EACCES, a usage error) or a null status (killed by a signal) is an operational failure of
296
+ * the tmux call itself — laundering it into "the anchor did not resolve" would report a
297
+ * missing pane when the truth is that tmux never answered.
298
+ */
299
+ export function assertTmuxOk(label: string, run: TmuxRun): void {
300
+ if (run.status === 0) return;
301
+ const how = run.status === null ? "terminated by signal" : `exit ${run.status}`;
302
+ throw new Error(`mux-placement: tmux ${label} failed (${how})${run.stderr.trim() ? `: ${run.stderr.trim()}` : ""}`);
303
+ }
304
+
305
+ /**
306
+ * Run tmux with the caller's environment INHERITED. No `-S`, no `-L`: the server is whichever
307
+ * one `TMUX` already names, which is the only server this module is allowed to touch — and
308
+ * `readAnchor` has already refused an environment where `TMUX` is absent, which is what would
309
+ * otherwise silently resolve to the default server.
310
+ */
311
+ export function runTmux(args: readonly string[], env: NodeJS.ProcessEnv): TmuxRun {
312
+ const res = spawnSync("tmux", args as string[], { env, encoding: "utf8" });
313
+ if (res.error) throw res.error;
314
+ return { status: res.status, stdout: res.stdout ?? "", stderr: res.stderr ?? "" };
315
+ }
316
+
317
+ /** The caller's own placement, or a named refusal. Never guesses, never creates a session. */
318
+ export function inspectPlacement(env: NodeJS.ProcessEnv = process.env): InspectResult {
319
+ const anchor = readAnchor(env);
320
+ if (!anchor.ok) return anchor;
321
+ const run = runTmux(buildInspectArgs(anchor.anchor), env);
322
+ assertTmuxOk("display-message", run);
323
+ return parsePlacement(anchor.anchor, run.stdout);
324
+ }
325
+
326
+ /** The context a handle or placement belongs to. BOTH halves identify it. */
327
+ export interface PlacementContext {
328
+ serverPid: string;
329
+ sessionId: string;
330
+ }
331
+
332
+ /**
333
+ * Same server AND same session. Either half alone is insufficient: a restarted tmux server
334
+ * hands out `$0` again, and one server holds many sessions — so matching only the session id
335
+ * would accept a different server, and matching only the server pid would accept a different
336
+ * session on the right one.
337
+ */
338
+ export function isSameContext(origin: PlacementContext, now: PlacementContext): boolean {
339
+ return origin.serverPid === now.serverPid && origin.sessionId === now.sessionId;
340
+ }
341
+
342
+ /**
343
+ * Re-read the caller's placement and refuse unless it is still the server and session the
344
+ * handle/placement was born in. Called before every mutation: `$3` on a restarted server is
345
+ * a different session, and an env naming another server would otherwise redirect the whole
346
+ * operation somewhere the caller never looked.
347
+ */
348
+ export function requireSameContext(label: string, origin: PlacementContext, env: NodeJS.ProcessEnv): void {
349
+ const now = inspectPlacement(env);
350
+ if (!now.ok) {
351
+ throw new Error(`mux-placement: ${label} refused — the caller's placement is not resolvable (${now.reason})`);
352
+ }
353
+ if (!isSameContext(origin, now.placement)) {
354
+ throw new Error(
355
+ `mux-placement: ${label} refused — context changed (expected server ${origin.serverPid} session ${origin.sessionId}, ` +
356
+ `now server ${now.placement.serverPid} session ${now.placement.sessionId})`,
357
+ );
358
+ }
359
+ }
360
+
361
+ /** One detached default-shell window at the end of the caller's own session. */
362
+ export function appendWindow(placement: Placement, env: NodeJS.ProcessEnv = process.env): WindowHandle {
363
+ requireSameContext("appendWindow", placement, env);
364
+ const run = runTmux(buildAppendArgs(placement.sessionId), env);
365
+ assertTmuxOk("new-window", run);
366
+ return { serverPid: placement.serverPid, sessionId: placement.sessionId, ...parseWindowFields(run.stdout) };
367
+ }
368
+
369
+ /**
370
+ * Close one window by stable id, in the context it was opened in. Reports whether it was
371
+ * closed or had already gone.
372
+ */
373
+ export function closeWindow(handle: WindowHandle, env: NodeJS.ProcessEnv = process.env): CloseOutcome {
374
+ requireSameContext("closeWindow", handle, env);
375
+ const run = runTmux(buildCloseArgs(handle.windowId), env);
376
+ if (run.status === 0) return "closed";
377
+ // A signal kill is not a "tmux said no" — it is the call failing, and it carries no
378
+ // information about the window at all.
379
+ if (run.status === null) assertTmuxOk("kill-window", run);
380
+ const list = runTmux(buildListWindowIdsArgs(), env);
381
+ assertTmuxOk("list-windows", list);
382
+ const verdict = classifyCloseFailure(handle.windowId, list.stdout);
383
+ if (verdict) return verdict;
384
+ throw new Error(
385
+ `mux-placement: kill-window ${handle.windowId} failed (exit ${run.status}) and the window is still listed: ${run.stderr.trim()}`,
386
+ );
387
+ }