@junghanacs/entwurf 0.13.1 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. package/AGENTS.md +48 -15
  2. package/BASELINE.md +3 -3
  3. package/CHANGELOG.md +45 -0
  4. package/CONTRIBUTING.md +13 -9
  5. package/DELIVERY.md +7 -6
  6. package/README.md +27 -27
  7. package/VERIFY.md +22 -14
  8. package/demo/README.md +1 -1
  9. package/demo/demo-baseline.sh +1 -3
  10. package/demo/demo.sh +2 -5
  11. package/docs/acp-backend-rail.md +9 -4
  12. package/docs/external-mcp-host.md +4 -5
  13. package/docs/setup-clean-host.md +8 -7
  14. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +148 -28
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/overlay.js +8 -6
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-control-rpc.js +7 -5
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-core.js +13 -14
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-resume-args.js +45 -40
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +117 -95
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-decider.js +23 -57
  21. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-lock.js +16 -7
  22. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +5 -53
  23. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-release.js +21 -36
  24. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-runner.js +3 -15
  25. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send-fallback.js +12 -11
  26. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send.js +2 -7
  27. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +30 -67
  28. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-visible-resume.js +256 -0
  29. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +91 -5
  30. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +258 -0
  31. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-launch.js +202 -0
  32. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-placement.js +289 -0
  33. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-resume-call.js +170 -0
  34. package/mcp/entwurf-bridge/dist/pi-extensions/lib/resume-launch-identity.js +136 -0
  35. package/mcp/entwurf-bridge/dist/pi-extensions/lib/session-id.js +8 -5
  36. package/mcp/entwurf-bridge/dist/pi-extensions/lib/socket-discovery.js +3 -3
  37. package/mcp/entwurf-bridge/dist/scripts/meta-facts.js +51 -0
  38. package/mcp/entwurf-bridge/dist/scripts/new-session-id.js +9 -4
  39. package/mcp/entwurf-bridge/src/index.ts +164 -28
  40. package/mcp/entwurf-bridge/start.sh +2 -2
  41. package/mcp/entwurf-bridge/test.sh +23 -9
  42. package/mcp/entwurf-bridge/tsconfig.build.json +11 -2
  43. package/package.json +22 -11
  44. package/pi-extensions/entwurf-control.ts +218 -40
  45. package/pi-extensions/lib/acp/backend.ts +71 -12
  46. package/pi-extensions/lib/acp/overlay.ts +8 -6
  47. package/pi-extensions/lib/entwurf-control-rpc.ts +7 -5
  48. package/pi-extensions/lib/entwurf-core.ts +15 -15
  49. package/pi-extensions/lib/entwurf-resume-args.ts +41 -52
  50. package/pi-extensions/lib/entwurf-v2-contract-schema.ts +1 -1
  51. package/pi-extensions/lib/entwurf-v2-contract.ts +120 -99
  52. package/pi-extensions/lib/entwurf-v2-decider.ts +30 -91
  53. package/pi-extensions/lib/entwurf-v2-lock.ts +16 -7
  54. package/pi-extensions/lib/entwurf-v2-production.ts +4 -78
  55. package/pi-extensions/lib/entwurf-v2-release.ts +25 -49
  56. package/pi-extensions/lib/entwurf-v2-runner.ts +6 -21
  57. package/pi-extensions/lib/entwurf-v2-send-fallback.ts +12 -11
  58. package/pi-extensions/lib/entwurf-v2-send.ts +2 -7
  59. package/pi-extensions/lib/entwurf-v2-surface.ts +36 -76
  60. package/pi-extensions/lib/entwurf-v2-visible-resume.ts +370 -0
  61. package/pi-extensions/lib/meta-session.ts +93 -5
  62. package/pi-extensions/lib/mux-fresh-call.ts +328 -0
  63. package/pi-extensions/lib/mux-launch.ts +267 -0
  64. package/pi-extensions/lib/mux-placement.ts +387 -0
  65. package/pi-extensions/lib/mux-resume-call.ts +221 -0
  66. package/pi-extensions/lib/resume-launch-identity.ts +162 -0
  67. package/pi-extensions/lib/session-id.js +8 -5
  68. package/pi-extensions/lib/socket-discovery.ts +3 -3
  69. package/run.sh +471 -201
  70. package/scripts/agy-bridge-config.py +5 -1
  71. package/scripts/check-acp-backend-preflight.ts +1 -1
  72. package/scripts/check-acp-overlay.ts +13 -3
  73. package/scripts/check-acp-stream-hooks.ts +504 -0
  74. package/scripts/check-elapsed.sh +25 -0
  75. package/scripts/check-entwurf-bridge-boot.ts +51 -4
  76. package/scripts/check-entwurf-bridge-pi-free.ts +6 -5
  77. package/scripts/check-entwurf-control-rpc.ts +4 -3
  78. package/scripts/check-entwurf-resume-args.ts +72 -70
  79. package/scripts/check-entwurf-session-identity.ts +14 -10
  80. package/scripts/check-entwurf-v2-contract.ts +34 -59
  81. package/scripts/check-entwurf-v2-decider.ts +17 -177
  82. package/scripts/check-entwurf-v2-lock.ts +5 -2
  83. package/scripts/check-entwurf-v2-matrix.ts +3 -53
  84. package/scripts/check-entwurf-v2-production.ts +2 -91
  85. package/scripts/check-entwurf-v2-release.ts +10 -105
  86. package/scripts/check-entwurf-v2-runner.ts +4 -85
  87. package/scripts/check-entwurf-v2-send-fallback.ts +5 -6
  88. package/scripts/check-entwurf-v2-send.ts +0 -28
  89. package/scripts/check-entwurf-v2-surface.ts +157 -128
  90. package/scripts/check-entwurf-v2-visible-resume.ts +445 -0
  91. package/scripts/check-fresh-cut-gate.sh +1 -1
  92. package/scripts/check-gate-qualification.ts +98 -7
  93. package/scripts/check-install-container.sh +10 -2
  94. package/scripts/check-install-surface.ts +1 -1
  95. package/scripts/check-keyset-overlap.py +1 -1
  96. package/scripts/check-meta-facts.ts +249 -0
  97. package/scripts/check-meta-identity-consumers.ts +1 -1
  98. package/scripts/check-meta-session.ts +169 -0
  99. package/scripts/check-mux-launch-tmux.ts +316 -0
  100. package/scripts/check-mux-launch.ts +288 -0
  101. package/scripts/check-mux-launcher-fence.ts +264 -0
  102. package/scripts/check-mux-parent-artifact.ts +195 -0
  103. package/scripts/check-mux-placement-tmux.ts +322 -0
  104. package/scripts/check-mux-placement.ts +323 -0
  105. package/scripts/check-mux-resume-call.ts +282 -0
  106. package/scripts/check-probe-cli-shim.ts +25 -22
  107. package/scripts/check-probe-ordering.ts +84 -76
  108. package/scripts/check-release-gate-outcomes.ts +127 -7
  109. package/scripts/check-resume-launch-identity.ts +244 -0
  110. package/scripts/check-socket-discovery.ts +1 -1
  111. package/scripts/fixtures/mux-parent-transcript.scrubbed.jsonl +3 -0
  112. package/scripts/inventory-verification-surface.ts +349 -0
  113. package/scripts/lib/claude-launcher-fence.ts +322 -0
  114. package/scripts/lib/mutation-qualify.ts +109 -3
  115. package/scripts/meta-bridge-doctor.sh +6 -8
  116. package/scripts/meta-facts.ts +60 -0
  117. package/scripts/mutants/acp-overlay.json +17 -0
  118. package/scripts/mutants/acp-stream-hooks.json +158 -0
  119. package/scripts/mutants/bridge-boot-resume.json +45 -0
  120. package/scripts/mutants/meta-facts.json +50 -0
  121. package/scripts/mutants/meta-identity.json +36 -0
  122. package/scripts/mutants/mux-boundary.json +196 -0
  123. package/scripts/mutants/mux-fresh-call.json +185 -0
  124. package/scripts/mutants/mux-launcher-fence.json +123 -0
  125. package/scripts/mutants/mux-parent-artifact.json +39 -0
  126. package/scripts/mutants/mux-resume-call.json +148 -0
  127. package/scripts/mutants/probe-ordering.json +0 -1037
  128. package/scripts/mutants/release-gate.json +35 -0
  129. package/scripts/mutants/resume-args.json +76 -0
  130. package/scripts/mutants/resume-launch-identity.json +96 -0
  131. package/scripts/mutants/v2-surface.json +58 -18
  132. package/scripts/mutants/v2-visible-resume.json +215 -0
  133. package/scripts/new-session-id.ts +9 -4
  134. package/scripts/smoke-acp-raw-turn-live.ts +1 -1
  135. package/scripts/smoke-agy-native-push-live.ts +6 -17
  136. package/scripts/smoke-entwurf-v2-matrix-live.ts +1 -1
  137. package/scripts/smoke-meta-honesty.sh +1 -1
  138. package/scripts/smoke-mux-fresh-call-live.ts +365 -0
  139. package/scripts/smoke-mux-lifecycle-live.ts +1136 -0
  140. package/scripts/smoke-pi-attach.ts +1 -1
  141. package/scripts/smoke-user-scope-citizen.sh +1 -1
  142. package/scripts/tsconfig.json +1 -0
  143. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-preflight.js +0 -160
  144. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn-production.js +0 -273
  145. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn.js +0 -216
  146. package/pi-extensions/lib/entwurf-v2-spawn-production.ts +0 -373
  147. package/pi-extensions/lib/entwurf-v2-spawn.ts +0 -323
  148. package/scripts/check-acp-sdk-surface.ts +0 -275
  149. package/scripts/check-entwurf-v2-spawn-production.ts +0 -551
  150. package/scripts/check-entwurf-v2-spawn.ts +0 -399
  151. package/scripts/smoke-entwurf-v2-spawn-live.ts +0 -188
  152. package/scripts/smoke-entwurf-v2-spawn-resume-live.ts +0 -467
@@ -0,0 +1,289 @@
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
+ import { spawnSync } from "node:child_process";
70
+ // ── Native id grammar (boundary 2) ─────────────────────────────────────────
71
+ const PANE_ID = /^%[0-9]+$/;
72
+ const SESSION_ID = /^\$[0-9]+$/;
73
+ const WINDOW_ID = /^@[0-9]+$/;
74
+ const DECIMAL = /^[0-9]+$/;
75
+ /** True only for tmux's own native pane id. */
76
+ export function isPaneId(value) {
77
+ return PANE_ID.test(value);
78
+ }
79
+ /** True only for tmux's own native session id. */
80
+ export function isSessionId(value) {
81
+ return SESSION_ID.test(value);
82
+ }
83
+ /** True only for tmux's own native window id. */
84
+ export function isWindowId(value) {
85
+ return WINDOW_ID.test(value);
86
+ }
87
+ /** True only for a bare decimal (pids, window indices). */
88
+ export function isDecimal(value) {
89
+ return DECIMAL.test(value);
90
+ }
91
+ /**
92
+ * Refuse a selector before it reaches tmux. The throw is deliberate and unconditional: a
93
+ * selector that is not a native id is either a bug or an injection attempt, and neither is
94
+ * something to route into tmux's parser to find out.
95
+ */
96
+ export function assertSelector(kind, value) {
97
+ const okShape = kind === "pane" ? isPaneId(value) : kind === "session" ? isSessionId(value) : isWindowId(value);
98
+ if (!okShape) {
99
+ throw new Error(`mux-placement: refusing a non-native ${kind} selector ${JSON.stringify(value)}`);
100
+ }
101
+ }
102
+ // ── Pure argv / parsing (the half a deterministic gate can pin) ─────────────
103
+ /** Field order of `INSPECT_FORMAT`. Parsing and formatting share this list. */
104
+ export const PLACEMENT_FIELDS = ["pid", "session_id", "window_id", "window_index", "pane_id", "pane_pid"];
105
+ const SEP = "|";
106
+ /** tmux format string for a placement query — tmux's own facts, never string surgery on `$TMUX`. */
107
+ export const INSPECT_FORMAT = PLACEMENT_FIELDS.map((f) => `#{${f}}`).join(SEP);
108
+ /** Field order of `APPEND_FORMAT` — what `new-window -P -F` prints for the window it created. */
109
+ export const WINDOW_FIELDS = ["window_id", "window_index", "pane_id", "pane_pid"];
110
+ export const APPEND_FORMAT = WINDOW_FIELDS.map((f) => `#{${f}}`).join(SEP);
111
+ /**
112
+ * Read the caller's anchor. BOTH `TMUX` and `TMUX_PANE` must be present and non-empty:
113
+ * `TMUX_PANE` names which pane, `TMUX` is what makes a bare `tmux` resolve to the caller's
114
+ * server instead of the default one. `TMUX` is checked for presence only — parsing the
115
+ * socket/session out of it is the guess this rail forbids.
116
+ */
117
+ export function readAnchor(env) {
118
+ const server = env.TMUX;
119
+ const anchor = env.TMUX_PANE;
120
+ if (typeof server !== "string" || server.length === 0)
121
+ return { ok: false, reason: "no-tmux-context" };
122
+ if (typeof anchor !== "string" || anchor.length === 0)
123
+ return { ok: false, reason: "no-tmux-context" };
124
+ if (!isPaneId(anchor))
125
+ return { ok: false, reason: "anchor-malformed" };
126
+ return { ok: true, anchor };
127
+ }
128
+ export function buildInspectArgs(anchor) {
129
+ assertSelector("pane", anchor);
130
+ return ["display-message", "-p", "-t", anchor, INSPECT_FORMAT];
131
+ }
132
+ /**
133
+ * Append at the END of the caller's own session. `{end}` is the append semantics we want
134
+ * literally; the lowest-free-index behaviour of a bare `new-window` is a different verb and
135
+ * is not this contract. Targeting by `session_id` (`$0`) rather than name keeps a renamed or
136
+ * duplicate-named session from redirecting the append.
137
+ *
138
+ * `-d` keeps the caller's focus. No `-n`, no `-c`, no shell-command: default shell only.
139
+ */
140
+ export function buildAppendArgs(sessionId) {
141
+ assertSelector("session", sessionId);
142
+ return ["new-window", "-d", "-a", "-t", `${sessionId}:{end}`, "-P", "-F", APPEND_FORMAT];
143
+ }
144
+ export function buildCloseArgs(windowId) {
145
+ assertSelector("window", windowId);
146
+ return ["kill-window", "-t", windowId];
147
+ }
148
+ /** Every window on the server, for the positive absence proof an `already-gone` needs. */
149
+ export function buildListWindowIdsArgs() {
150
+ return ["list-windows", "-a", "-F", "#{window_id}"];
151
+ }
152
+ /**
153
+ * Turn an rc=0 placement row into facts — with the echo-back check that tmux's exit status
154
+ * cannot provide, and a grammar check on every field. A nonexistent target still fills the
155
+ * server fields and leaves the rest empty, so "the row was non-empty" proves nothing; an
156
+ * empty pane id means no pane resolved at all, which is unresolved rather than an answer
157
+ * about some other pane.
158
+ */
159
+ export function parsePlacement(anchor, stdout) {
160
+ const line = stdout.split("\n", 1)[0]?.trim() ?? "";
161
+ if (line.length === 0)
162
+ return { ok: false, reason: "anchor-unresolved" };
163
+ const parts = line.split(SEP);
164
+ if (parts.length !== PLACEMENT_FIELDS.length)
165
+ return { ok: false, reason: "anchor-unresolved" };
166
+ const [serverPid, sessionId, windowId, windowIndex, paneId, panePid] = parts;
167
+ if (paneId.length === 0)
168
+ return { ok: false, reason: "anchor-unresolved" };
169
+ if (paneId !== anchor)
170
+ return { ok: false, reason: "anchor-mismatch" };
171
+ const wellFormed = isDecimal(serverPid) &&
172
+ isSessionId(sessionId) &&
173
+ isWindowId(windowId) &&
174
+ isDecimal(windowIndex) &&
175
+ isPaneId(paneId) &&
176
+ isDecimal(panePid);
177
+ if (!wellFormed)
178
+ return { ok: false, reason: "anchor-unresolved" };
179
+ return { ok: true, placement: { serverPid, sessionId, windowId, windowIndex, paneId, panePid } };
180
+ }
181
+ export function parseWindowFields(stdout) {
182
+ const line = stdout.split("\n", 1)[0]?.trim() ?? "";
183
+ const parts = line.split(SEP);
184
+ if (line.length === 0 || parts.length !== WINDOW_FIELDS.length) {
185
+ throw new Error(`mux-placement: new-window did not report a window handle (got ${JSON.stringify(line)})`);
186
+ }
187
+ const [windowId, windowIndex, paneId, panePid] = parts;
188
+ if (!isWindowId(windowId) || !isDecimal(windowIndex) || !isPaneId(paneId) || !isDecimal(panePid)) {
189
+ throw new Error(`mux-placement: new-window reported a non-native handle (got ${JSON.stringify(line)})`);
190
+ }
191
+ return { windowId, windowIndex, paneId, panePid };
192
+ }
193
+ /**
194
+ * Classify a FAILED `kill-window` against a fresh window inventory. Absent → `already-gone`
195
+ * (positive proof). Present → `null`, and the caller must fail loud: the window is still
196
+ * there and we did not close it.
197
+ */
198
+ export function classifyCloseFailure(windowId, listStdout) {
199
+ const ids = listStdout
200
+ .split("\n")
201
+ .map((l) => l.trim())
202
+ .filter((l) => l.length > 0);
203
+ return ids.includes(windowId) ? null : "already-gone";
204
+ }
205
+ /**
206
+ * An rc=0 run is the ONLY kind whose stdout may be parsed. A nonzero status (no server,
207
+ * EACCES, a usage error) or a null status (killed by a signal) is an operational failure of
208
+ * the tmux call itself — laundering it into "the anchor did not resolve" would report a
209
+ * missing pane when the truth is that tmux never answered.
210
+ */
211
+ export function assertTmuxOk(label, run) {
212
+ if (run.status === 0)
213
+ return;
214
+ const how = run.status === null ? "terminated by signal" : `exit ${run.status}`;
215
+ throw new Error(`mux-placement: tmux ${label} failed (${how})${run.stderr.trim() ? `: ${run.stderr.trim()}` : ""}`);
216
+ }
217
+ /**
218
+ * Run tmux with the caller's environment INHERITED. No `-S`, no `-L`: the server is whichever
219
+ * one `TMUX` already names, which is the only server this module is allowed to touch — and
220
+ * `readAnchor` has already refused an environment where `TMUX` is absent, which is what would
221
+ * otherwise silently resolve to the default server.
222
+ */
223
+ export function runTmux(args, env) {
224
+ const res = spawnSync("tmux", args, { env, encoding: "utf8" });
225
+ if (res.error)
226
+ throw res.error;
227
+ return { status: res.status, stdout: res.stdout ?? "", stderr: res.stderr ?? "" };
228
+ }
229
+ /** The caller's own placement, or a named refusal. Never guesses, never creates a session. */
230
+ export function inspectPlacement(env = process.env) {
231
+ const anchor = readAnchor(env);
232
+ if (!anchor.ok)
233
+ return anchor;
234
+ const run = runTmux(buildInspectArgs(anchor.anchor), env);
235
+ assertTmuxOk("display-message", run);
236
+ return parsePlacement(anchor.anchor, run.stdout);
237
+ }
238
+ /**
239
+ * Same server AND same session. Either half alone is insufficient: a restarted tmux server
240
+ * hands out `$0` again, and one server holds many sessions — so matching only the session id
241
+ * would accept a different server, and matching only the server pid would accept a different
242
+ * session on the right one.
243
+ */
244
+ export function isSameContext(origin, now) {
245
+ return origin.serverPid === now.serverPid && origin.sessionId === now.sessionId;
246
+ }
247
+ /**
248
+ * Re-read the caller's placement and refuse unless it is still the server and session the
249
+ * handle/placement was born in. Called before every mutation: `$3` on a restarted server is
250
+ * a different session, and an env naming another server would otherwise redirect the whole
251
+ * operation somewhere the caller never looked.
252
+ */
253
+ export function requireSameContext(label, origin, env) {
254
+ const now = inspectPlacement(env);
255
+ if (!now.ok) {
256
+ throw new Error(`mux-placement: ${label} refused — the caller's placement is not resolvable (${now.reason})`);
257
+ }
258
+ if (!isSameContext(origin, now.placement)) {
259
+ throw new Error(`mux-placement: ${label} refused — context changed (expected server ${origin.serverPid} session ${origin.sessionId}, ` +
260
+ `now server ${now.placement.serverPid} session ${now.placement.sessionId})`);
261
+ }
262
+ }
263
+ /** One detached default-shell window at the end of the caller's own session. */
264
+ export function appendWindow(placement, env = process.env) {
265
+ requireSameContext("appendWindow", placement, env);
266
+ const run = runTmux(buildAppendArgs(placement.sessionId), env);
267
+ assertTmuxOk("new-window", run);
268
+ return { serverPid: placement.serverPid, sessionId: placement.sessionId, ...parseWindowFields(run.stdout) };
269
+ }
270
+ /**
271
+ * Close one window by stable id, in the context it was opened in. Reports whether it was
272
+ * closed or had already gone.
273
+ */
274
+ export function closeWindow(handle, env = process.env) {
275
+ requireSameContext("closeWindow", handle, env);
276
+ const run = runTmux(buildCloseArgs(handle.windowId), env);
277
+ if (run.status === 0)
278
+ return "closed";
279
+ // A signal kill is not a "tmux said no" — it is the call failing, and it carries no
280
+ // information about the window at all.
281
+ if (run.status === null)
282
+ assertTmuxOk("kill-window", run);
283
+ const list = runTmux(buildListWindowIdsArgs(), env);
284
+ assertTmuxOk("list-windows", list);
285
+ const verdict = classifyCloseFailure(handle.windowId, list.stdout);
286
+ if (verdict)
287
+ return verdict;
288
+ throw new Error(`mux-placement: kill-window ${handle.windowId} failed (exit ${run.status}) and the window is still listed: ${run.stderr.trim()}`);
289
+ }
@@ -0,0 +1,170 @@
1
+ /**
2
+ * mux-resume-call — open ONE window in the caller's own tmux session at a cwd this module did
3
+ * not choose, and hand it argv this module did not author.
4
+ *
5
+ * ── Why this is a module and not a parameter on fresh-call ──
6
+ *
7
+ * `mux-fresh-call` carries a TASK to a runtime it names; the sibling starts wherever the caller
8
+ * happens to be. A resume carries neither: the argv comes from the record (`entwurf-v2-visible-
9
+ * resume` builds it) and the cwd comes from the record too — it is the directory the citizen's
10
+ * own transcript header remembers. Those are different inputs with a different risk, so they get
11
+ * a different module rather than a fourth parameter on a composition whose contract is
12
+ * "identity is an OUTPUT".
13
+ *
14
+ * visible-resume composition → resume-call → placement leaf (unchanged, carrier-free)
15
+ * resume-call -X-> garden identity, records, locks, delivery
16
+ *
17
+ * This module never learns which citizen it is reopening. It sees a cwd and an argv tail.
18
+ *
19
+ * ── The cwd is the whole reason this module owns its own tmux argv ──
20
+ *
21
+ * `mux-placement`'s `buildAppendArgs` deliberately emits no `-c` ("default shell only"), and it
22
+ * stays that way — a resume must not widen the leaf's grammar for the three other callers. So
23
+ * the `-c` shape lives here, with the three refusals MEASURED on tmux 3.6a (2026-08-06, private
24
+ * server):
25
+ *
26
+ * 1. a NONEXISTENT `-c` is silent. tmux exits 0, opens the window, and the child falls back to
27
+ * `$HOME`. A resume whose recorded cwd has been deleted would therefore open a visible
28
+ * window in the wrong project and look successful. Nothing downstream can catch that: the
29
+ * launch receipt would be perfectly well-formed.
30
+ * 2. `-c` is FORMAT-EXPANDED. `#{pane_id}` inside the value silently rewrote the path
31
+ * (`<dir>/#{pane_id}` → `<dir>/%0`), and a `#(…)` value was observed running its command.
32
+ * A path is data; tmux reads it as a format. So `#` is refused outright.
33
+ * 3. whitespace is SAFE — argv is an array and nothing re-splits. A dir named `with space`
34
+ * arrived intact. So there is no quoting grammar here, and none is owed. `|` is fine too:
35
+ * the cwd never enters the `-F` row (see `APPEND_FORMAT` below).
36
+ *
37
+ * That is the entire defence: one existence check and one character. No escaping layer, no
38
+ * sanitiser, no symlink policy — a symlinked project dir is a normal thing to work in.
39
+ *
40
+ * ── What the receipt does NOT say ──
41
+ *
42
+ * The handle carries the shared `APPEND_FORMAT` fields only — native ids and decimals. The cwd
43
+ * is reported as what tmux was ASKED for, exactly as `runtimePath` is: `-P -F` prints its row
44
+ * BEFORE the child has chdir'd (measured — `#{pane_current_path}` in that row still reports the
45
+ * client's cwd), so a start-path field there would be a free-form path that is also racy. Proving
46
+ * where the pane actually landed is a separate query against the stable pane id, and it belongs
47
+ * to acceptance, not to the product's launch receipt.
48
+ */
49
+ import { statSync } from "node:fs";
50
+ import path from "node:path";
51
+ import { assertLaunchTarget, LaunchPreconditionError, resolveRuntimeOnPath, } from "./mux-launch.js";
52
+ import { APPEND_FORMAT, assertSelector, assertTmuxOk, inspectPlacement, parseWindowFields, requireSameContext, runTmux, } from "./mux-placement.js";
53
+ /** The fixed runtime a resume reopens. Only pi stands a control socket up, so only pi has a
54
+ * same-id resume at all — the backend boundary is enforced upstream by
55
+ * `resolveResumeLaunchIdentity`, and this constant is the mux half of the same fact. */
56
+ export const RESUME_CALL_RUNTIME = "pi";
57
+ /**
58
+ * Classify a candidate start directory. Split into three reasons rather than one because the
59
+ * operator's next move differs: an absolute-path bug is a caller defect, a missing directory is
60
+ * a moved/deleted project, and a `#` is a path tmux would rewrite under us.
61
+ */
62
+ export function classifyResumeCwd(cwd) {
63
+ if (!path.isAbsolute(cwd))
64
+ return "cwd-not-absolute";
65
+ // tmux expands formats inside the `-c` VALUE. `#{…}` rewrote the path silently and `#(…)`
66
+ // was observed executing; neither is something to escape our way out of.
67
+ if (cwd.includes("#"))
68
+ return "cwd-format-token";
69
+ let st;
70
+ try {
71
+ st = statSync(cwd);
72
+ }
73
+ catch {
74
+ // tmux would NOT report this — it opens the window and lands the child in $HOME.
75
+ return "cwd-missing";
76
+ }
77
+ return st.isDirectory() ? null : "cwd-not-directory";
78
+ }
79
+ /**
80
+ * Launch argv: the leaf's detached-append shape plus `-c`, the runtime, then the caller's flags.
81
+ * `--` is what keeps tmux from reading the runtime or its flags as tmux options.
82
+ */
83
+ export function buildResumeCallArgs(placement, cwd, runtimePath, runtimeArgs) {
84
+ assertSelector("session", placement.sessionId);
85
+ assertLaunchTarget(runtimePath);
86
+ const bad = classifyResumeCwd(cwd);
87
+ if (bad)
88
+ throw new Error(`mux-resume-call: refusing to build argv with an unusable cwd (${bad}): ${cwd}`);
89
+ return [
90
+ "new-window",
91
+ "-d",
92
+ "-a",
93
+ "-t",
94
+ `${placement.sessionId}:{end}`,
95
+ "-c",
96
+ cwd,
97
+ "-P",
98
+ "-F",
99
+ APPEND_FORMAT,
100
+ "--",
101
+ runtimePath,
102
+ ...runtimeArgs,
103
+ ];
104
+ }
105
+ /**
106
+ * Open the window. Order is the safety argument, same as `freshCall`: classify the cwd, resolve
107
+ * and prove the runtime, re-read the caller's placement and refuse a changed context — and only
108
+ * then mutate. Nothing above can leave a window behind.
109
+ *
110
+ * `runtimeArgs` is passed through untouched. This module does not know what `--session` means.
111
+ */
112
+ export function resumeCall(params, env = process.env) {
113
+ const badCwd = classifyResumeCwd(params.cwd);
114
+ if (badCwd)
115
+ return { ok: false, reason: badCwd };
116
+ let runtimePath;
117
+ try {
118
+ runtimePath = resolveRuntimeOnPath(RESUME_CALL_RUNTIME, env);
119
+ }
120
+ catch (err) {
121
+ if (err instanceof LaunchPreconditionError)
122
+ return { ok: false, reason: err.reason };
123
+ throw err;
124
+ }
125
+ const inspected = inspectPlacement(env);
126
+ if (!inspected.ok)
127
+ return { ok: false, reason: inspected.reason };
128
+ const placement = inspected.placement;
129
+ requireSameContext("resumeCall", placement, env);
130
+ const run = runTmux(buildResumeCallArgs(placement, params.cwd, runtimePath, params.runtimeArgs), env);
131
+ assertTmuxOk("new-window", run);
132
+ let fields;
133
+ try {
134
+ fields = parseWindowFields(run.stdout);
135
+ }
136
+ catch (err) {
137
+ // The window exists and its id is precisely what could not be read. Diffing the inventory
138
+ // to find "the new one" is the guess this rail forbids everywhere else, so name the orphan.
139
+ throw new Error(`mux-resume-call: launched ${runtimePath} but could not read the window handle tmux printed — a window may ` +
140
+ `be open in session ${placement.sessionId} that this call cannot identify or close: ${err instanceof Error ? err.message : String(err)}`);
141
+ }
142
+ return {
143
+ ok: true,
144
+ receipt: {
145
+ serverPid: placement.serverPid,
146
+ sessionId: placement.sessionId,
147
+ ...fields,
148
+ runtimePath,
149
+ cwd: params.cwd,
150
+ },
151
+ };
152
+ }
153
+ /** Why each refusal happened, in the caller's terms. A reason a caller cannot act on is a reason
154
+ * they will guess about. */
155
+ export const RESUME_CALL_REJECT_HINT = {
156
+ "no-tmux-context": "this agent is not running inside tmux, so there is no session to reopen the citizen in",
157
+ "anchor-malformed": "TMUX_PANE is not a native pane id",
158
+ "anchor-unresolved": "tmux resolved no pane for this agent's anchor",
159
+ "anchor-mismatch": "tmux answered about a different pane than the one asked about",
160
+ "cwd-not-absolute": "the recorded cwd is not an absolute path",
161
+ "cwd-format-token": "the recorded cwd contains '#', which tmux expands as a format inside -c — it would silently rewrite the path or run a command",
162
+ "cwd-missing": "the recorded cwd no longer exists; tmux would not report this, it would open the window in $HOME and look successful",
163
+ "cwd-not-directory": "the recorded cwd exists but is not a directory",
164
+ "runtime-unresolved": "pi is not installed on PATH",
165
+ "runtime-not-absolute": "the resolved runtime path is not absolute",
166
+ "runtime-path-whitespace": "the resolved runtime path contains whitespace, which tmux would re-split",
167
+ "runtime-missing": "nothing exists at the resolved runtime path",
168
+ "runtime-not-regular-file": "the resolved runtime path is not a regular file",
169
+ "runtime-not-executable": "the resolved runtime path is not executable",
170
+ };
@@ -0,0 +1,136 @@
1
+ /**
2
+ * resume-launch-identity — the RECORD-AUTHORITATIVE half of resuming a pi citizen,
3
+ * kept after the visible-first cut removed everything that used to call it.
4
+ *
5
+ * It was the preamble of the `spawn-bg` watcher's `spawnChild` (entwurf-v2-spawn-production.ts,
6
+ * deleted with that transport): garden id → the meta-record → the transcript that record
7
+ * claims → the provider/model/cwd that transcript's own header carries. None of that is
8
+ * about being hidden or visible. It is the answer to "which being is this, and which
9
+ * conversation is theirs", and it is the same answer a VISIBLE resume will need.
10
+ *
11
+ * So this leaf is preserved deliberately, with no consumer in the shipped tree today.
12
+ * `check-resume-launch-identity` holds its contract so the record-integrity rules below —
13
+ * the transcript header id must equal `record.nativeSessionId`, an addressable read
14
+ * (#52) rather than a plain targeted one, a recorded ACP provider must resolve its
15
+ * bridge — cannot rot while they wait.
16
+ *
17
+ * It takes a bare garden id, not a plan: the plan type it used to destructure belonged to
18
+ * the removed transport, and the identity question never needed the rest of it.
19
+ */
20
+ import { existsSync } from "node:fs";
21
+ import path from "node:path";
22
+ import { getEntwurfExplicitExtensions, readSessionIdentity } from "./entwurf-core.js";
23
+ import { readAddressableMetaIdentity } from "./meta-session.js";
24
+ /**
25
+ * The ONE expected refusal on this leaf: the target is a citizen of a backend that has no
26
+ * same-id resume at all.
27
+ *
28
+ * Everything else this function throws is a defect in the record, the transcript or the
29
+ * environment — a stale path, a foreign session file, an unrecorded model, an unresolvable
30
+ * bridge — and those keep their bare cause-rich `Error`, because a caller cannot act on them
31
+ * except by looking. A Claude Code or agy garden id is different in kind: nothing is broken,
32
+ * the operator simply asked a capability boundary to do something it does not cover, and the
33
+ * honest answer is a named refusal rather than an error report about a store that is fine.
34
+ *
35
+ * It carries a `reason` so the caller matches on a FIELD. Parsing the message string would make
36
+ * the wording load-bearing, and re-reading the record to ask "was it pi?" would read an address
37
+ * twice and could answer differently the second time.
38
+ */
39
+ export class ResumeBackendUnsupportedError extends Error {
40
+ reason = "target-not-pi";
41
+ backend;
42
+ constructor(gardenId, backend) {
43
+ super(`resume-launch-identity: ${gardenId} is a ${backend} citizen — same-id resume is a host-adapter relaunch ` +
44
+ `capability, and its domain currently contains backend pi only (only pi stands a control socket up). ` +
45
+ `This is a capability boundary, not the control-socket rail and not citizen rank.`);
46
+ this.name = "ResumeBackendUnsupportedError";
47
+ this.backend = backend;
48
+ }
49
+ }
50
+ /**
51
+ * Resolve launch identity for a resume. The TARGET is now resolved through the
52
+ * meta-record (#50 C2): `gardenId → record.transcriptPath`. It used to be a global
53
+ * header scan for a JSONL whose header id equalled the garden id — which only worked
54
+ * while entwurf forced pi's session id to BE the garden id. With the record minting the
55
+ * address, that scan cannot find anything (a citizen's header carries pi's own uuid), so
56
+ * keeping it would not have been a "smaller change", it would have been a broken one.
57
+ *
58
+ * The record is also the AUTHORIZATION now (#50 C3). The old gates — `requireEntwurf`
59
+ * (an `entwurf` tag in the session NAME, planted by a name mirror that no longer
60
+ * exists) and the sessionId-bound resume-marker env — are deleted. Record-backed pi
61
+ * citizens are all siblings (LOCKED PROTOCOL 6), so "this garden id names a pi citizen
62
+ * with a recorded transcript" is the whole test, PLUS one integrity check: the resumed
63
+ * file's header id must equal `record.nativeSessionId` (pi owns the transcript, the
64
+ * record remembers whose it is — a mismatch means the transcriptPath is stale or
65
+ * foreign, and resuming it would put a turn into a different being's session).
66
+ *
67
+ * That check is per-record, and per-record is not enough: the v2 lock domain is keyed on
68
+ * GARDEN ID, so two records sharing one `nativeSessionId` would each pass their own
69
+ * integrity check and resume the SAME transcript concurrently under two different locks.
70
+ * Hence {@link readAddressableMetaIdentity} rather than the plain targeted read (#52) —
71
+ * a resume is exactly the moment a record stops being data and becomes an address.
72
+ *
73
+ * Everything else is unchanged authority: readSessionIdentity (first model_change) for
74
+ * provider/model/cwd, getEntwurfExplicitExtensions for bridge re-injection (#29 fail-fast).
75
+ * Throws on anything that makes a resume impossible — a stale/foreign transcript, a
76
+ * deleted session file, an unrecorded model, an unresolvable ACP bridge. Every throw
77
+ * names its own cause; none of them degrades into a silent no-op.
78
+ */
79
+ export function resolveResumeLaunchIdentity(gardenId) {
80
+ const record = readAddressableMetaIdentity(gardenId);
81
+ if (record.backend !== "pi") {
82
+ throw new ResumeBackendUnsupportedError(gardenId, record.backend);
83
+ }
84
+ const sessionFile = record.transcriptPath;
85
+ if (!sessionFile) {
86
+ throw new Error(`resume-launch-identity: ${gardenId} has no recorded transcriptPath — ` +
87
+ `the citizen never wrote a session file (no turn yet), so there is nothing to resume.`);
88
+ }
89
+ // A recorded path is only a resume target while it names ONE file from everywhere. The record
90
+ // schema types transcriptPath as a nullable string and does not require an absolute path, and
91
+ // `existsSync` below would happily resolve a relative one against THIS process's cwd — while
92
+ // the launch resolves `--session <relative>` inside the window's own `-c <record cwd>`. Those
93
+ // are two different files whenever the two directories differ, and the resume would open the
94
+ // wrong transcript with a receipt that looks correct. This is a bad record, not an expected
95
+ // refusal, so it throws with its own cause like every other integrity failure here.
96
+ if (!path.isAbsolute(sessionFile)) {
97
+ throw new Error(`resume-launch-identity: ${gardenId} recorded a RELATIVE transcriptPath ${JSON.stringify(sessionFile)} — ` +
98
+ `a resume resolves --session inside the window's own working directory, so a relative path would name a ` +
99
+ `different file than the one checked here; refusing rather than resuming an unknown transcript.`);
100
+ }
101
+ // A recorded path is only a resume target while the file is actually on disk.
102
+ // Without this check a missing transcript falls through readSessionIdentity's
103
+ // ENOENT swallow and surfaces as "no recorded model" — the wrong cause (F7):
104
+ // the transcript was deleted, or the record carries a phantom path minted
105
+ // before birth guarded on file existence.
106
+ if (!existsSync(sessionFile)) {
107
+ throw new Error(`resume-launch-identity: ${gardenId} recorded transcriptPath "${sessionFile}" ` +
108
+ `does not exist on disk — the transcript was deleted, or the record carries a phantom ` +
109
+ `path from a pre-guard birth; nothing to resume.`);
110
+ }
111
+ const identity = readSessionIdentity(sessionFile);
112
+ const resumeModel = identity?.modelId ?? null;
113
+ if (!identity || !resumeModel) {
114
+ throw new Error(`resume-launch-identity: ${gardenId} has no recorded model — cannot resume.`);
115
+ }
116
+ if (identity.sessionId !== record.nativeSessionId) {
117
+ throw new Error(`resume-launch-identity: ${gardenId} transcript header id "${identity.sessionId ?? "(none)"}" ` +
118
+ `does not match the record's nativeSessionId "${record.nativeSessionId}" — the recorded transcriptPath ` +
119
+ `is stale or points at a foreign session file; refusing to resume another being's transcript.`);
120
+ }
121
+ const explicitExtensions = getEntwurfExplicitExtensions(resumeModel, false, identity.provider);
122
+ if (explicitExtensions.unresolvedAcpIntent) {
123
+ throw new Error(`resume-launch-identity: ${gardenId} recorded provider=entwurf but the bridge ` +
124
+ `extension could not be resolved — refusing to resume with an unknown provider (#29).`);
125
+ }
126
+ if (!identity.cwd) {
127
+ throw new Error(`resume-launch-identity: ${gardenId} header has no cwd (the cold-resume authority, #9).`);
128
+ }
129
+ return {
130
+ sessionFile,
131
+ cwd: identity.cwd,
132
+ explicitExtensionArgs: explicitExtensions.args,
133
+ provider: explicitExtensions.provider ?? identity.provider,
134
+ model: explicitExtensions.modelOverride ?? resumeModel,
135
+ };
136
+ }
@@ -12,8 +12,9 @@
12
12
  * every path, so the id grammar has ONE definition instead of one-per-importer.
13
13
  *
14
14
  * Keep dependency-free except `node:crypto`. The validator/grammar here is the
15
- * same one the 0.9.0 resident garden guard and entwurf spawn collision pre-check
16
- * enforce do NOT fork it.
15
+ * one every V3 record/address surface shares do NOT fork it. Collision safety
16
+ * is NOT a validator/generator concern: it belongs to the record store's
17
+ * exclusive CREATE publish, which fails loud on an occupied final path.
17
18
  */
18
19
  import { randomBytes } from "node:crypto";
19
20
  /** `YYYYMMDDTHHMMSS-[0-9a-f]{6}`. Anchored; no surrounding slop. */
@@ -38,9 +39,11 @@ export function formatSessionTimestamp(now = new Date()) {
38
39
  `T${p(now.getHours())}${p(now.getMinutes())}${p(now.getSeconds())}`);
39
40
  }
40
41
  /**
41
- * Durable garden sessionId minted at the session's true birth. 6 hex suffix
42
- * defeats same-second parallel-spawn collision; callers that spawn still
43
- * header-scan pre-check (assertSessionIdAvailableForSpawn).
42
+ * Durable garden sessionId minted at the session's true birth. The 6-hex
43
+ * (24-bit) suffix makes a same-second collision unlikely, never impossible.
44
+ * No caller pre-checks availability: collision SAFETY is owned by the record
45
+ * store's exclusive CREATE publish (upsertMetaSession), which fails loud on
46
+ * an occupied final path instead of replacing whatever entry holds it.
44
47
  *
45
48
  * @param {Date} [now]
46
49
  * @returns {string}
@@ -28,8 +28,8 @@
28
28
  * The legacy bridge `getLiveSessions` guarded this (`entry.isSymbolicLink()`);
29
29
  * deriving the listing from facts would drop that guard unless we re-assert it
30
30
  * here. A symlinked socket is NEVER probed: a citizen owning one is forced to
31
- * `dead` (→ dormant → resume a fresh process, never SEND to a hijacked
32
- * listener); a record-less one is quarantined out of the listing entirely.
31
+ * `dead` (→ dormant → an honest reject, never SEND to a hijacked listener);
32
+ * a record-less one is quarantined out of the listing entirely.
33
33
  * Both surface as `symlinkedGardenIds`.
34
34
  * - MALFORMED NAME (P3): a `*.sock` whose stem is not a garden id has no citizen
35
35
  * to correlate to and is dropped — but VISIBLY (`malformedNames`), not
@@ -85,7 +85,7 @@ export function isRecordLessSocketCandidate(inspection) {
85
85
  }
86
86
  /**
87
87
  * Inspect the EXACT control-socket path given (no gid re-derivation) and classify it by
88
- * lstat alone. This is the path-addressed core of the inspection: the 5c-3 spawn-bg watcher
88
+ * lstat alone. This is the path-addressed core of the inspection: a resume watcher
89
89
  * observes `plan.expectedSocketPath` and MUST inspect that exact path (its contract forbids
90
90
  * re-deriving a path from the gid), so the path-taking form is the SSOT and
91
91
  * `inspectTargetControlSocket` is the thin gid→path wrapper over it. `lstatFn` is injectable