@junghanacs/entwurf 0.14.0 → 0.14.2

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 (54) hide show
  1. package/AGENTS.md +13 -2
  2. package/CHANGELOG.md +63 -0
  3. package/DELIVERY.md +57 -0
  4. package/README.md +16 -7
  5. package/VERIFY.md +4 -4
  6. package/demo/README.md +3 -1
  7. package/demo/demo-baseline.sh +12 -1
  8. package/demo/demo.sh +9 -1
  9. package/docs/acp-backend-rail.md +103 -4
  10. package/docs/external-mcp-host.md +1 -1
  11. package/docs/setup-clean-host.md +3 -3
  12. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +12 -5
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/classify-tmux-cwd.js +47 -0
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +45 -3
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-resume-call.js +18 -47
  16. package/mcp/entwurf-bridge/dist/scripts/doctor-pi-provider.js +139 -47
  17. package/mcp/entwurf-bridge/dist/scripts/probe-bridge-command.js +294 -0
  18. package/mcp/entwurf-bridge/src/index.ts +14 -5
  19. package/mcp/entwurf-bridge/tsconfig.build.json +15 -5
  20. package/package.json +9 -9
  21. package/pi-extensions/entwurf-control.ts +13 -4
  22. package/pi-extensions/lib/acp/backend.ts +229 -9
  23. package/pi-extensions/lib/classify-tmux-cwd.ts +50 -0
  24. package/pi-extensions/lib/mux-fresh-call.ts +57 -4
  25. package/pi-extensions/lib/mux-resume-call.ts +21 -53
  26. package/run.sh +70 -25
  27. package/scripts/agy-bridge-config.py +47 -13
  28. package/scripts/agy-bridge.sh +73 -23
  29. package/scripts/check-acp-prompt-lifecycle.ts +221 -9
  30. package/scripts/check-entwurf-bridge-boot.ts +28 -0
  31. package/scripts/check-gate-qualification.ts +5 -3
  32. package/scripts/check-mux-resume-call.ts +11 -10
  33. package/scripts/check-probe-bridge-command.ts +201 -0
  34. package/scripts/check-release-gate-outcomes.ts +54 -1
  35. package/scripts/doctor-pi-provider.ts +155 -51
  36. package/scripts/meta-bridge-state.py +75 -1
  37. package/scripts/mutants/acp-prompt-lifecycle.json +25 -3
  38. package/scripts/mutants/bridge-command-boot.json +107 -0
  39. package/scripts/mutants/meta-retire.json +47 -0
  40. package/scripts/mutants/mux-fresh-call.json +48 -4
  41. package/scripts/mutants/mux-resume-call.json +3 -3
  42. package/scripts/mutants/release-gate.json +13 -0
  43. package/scripts/probe-bridge-command.ts +330 -0
  44. package/scripts/raw-async-delivery/README.md +158 -1
  45. package/scripts/raw-async-delivery/copilot-ui-server-probe.mjs +337 -0
  46. package/scripts/smoke-acp-raw-turn-live.ts +1 -1
  47. package/scripts/smoke-agy-install-state.sh +76 -2
  48. package/scripts/smoke-entwurf-chain-live.ts +12 -4
  49. package/scripts/smoke-entwurf-v2-matrix-live.ts +2 -2
  50. package/scripts/smoke-meta-install-state.sh +169 -3
  51. package/scripts/smoke-mux-fresh-call-live.ts +1 -1
  52. package/scripts/smoke-mux-lifecycle-live.ts +1 -1
  53. package/scripts/smoke-pi-provider-state.sh +135 -6
  54. package/scripts/smoke-resident-garden-guard.sh +2 -2
@@ -26,7 +26,8 @@
26
26
  * this read is the receipt.
27
27
  * - entwurf_register_native — explicit/manual fallback binding an ALREADY-RUNNING native
28
28
  * conversation (antigravity) to a garden id. Never a spawn.
29
- * - entwurf_fresh_call — open ONE fresh visible sibling in the operator's own tmux session;
29
+ * - entwurf_fresh_call — open ONE fresh visible sibling in the operator's own tmux session,
30
+ * optionally at ONE literal requested cwd (cross-repo fresh, #73);
30
31
  * returns a LAUNCH receipt only, and the new address arrives later as the
31
32
  * sender envelope of the sibling's nonce callback.
32
33
  * - entwurf_resume_call — reopen ONE DORMANT pi citizen under its OWN garden id in a visible
@@ -568,8 +569,10 @@ server.tool("entwurf_fresh_call", "Open ONE fresh visible sibling in the operato
568
569
  "else: it does NOT mean the runtime started, the first turn ran, or the task was delivered. Nothing polls " +
569
570
  "for the callback; if it never arrives the window is visible and can be read directly. For EXISTING " +
570
571
  "citizens use entwurf_v2 — this tool only creates, and entwurf_peers only reports. Model is REQUIRED and " +
571
- "is passed to the chosen runtime CLI (`provider/model` for pi; model id/alias for Claude Code); there are no " +
572
- "arbitrary command/cwd/env knobs. Do not put secrets in the task model and task argv are visible to " +
572
+ "is passed to the chosen runtime CLI (`provider/model` for pi; model id/alias for Claude Code). An optional " +
573
+ "cwd starts the sibling in ONE literal absolute existing directory (cross-repo fresh) never pick resume " +
574
+ "for a dormant record's cwd; resume is continuity-only. Omitted/empty cwd means the caller's own directory. " +
575
+ "There are no arbitrary command/env knobs. Do not put secrets in the task — model and task argv are visible to " +
573
576
  "same-user processes on this host. Requires that this agent itself runs " +
574
577
  "inside tmux: without a pane anchor there is no session to open a sibling beside.", {
575
578
  backend: z
@@ -594,7 +597,11 @@ server.tool("entwurf_fresh_call", "Open ONE fresh visible sibling in the operato
594
597
  .min(1)
595
598
  .max(16000)
596
599
  .describe("What the sibling should do after it calls you back. Plain instructions; no secrets (see the tool description)."),
597
- }, async ({ backend, model, task }) => {
600
+ cwd: z
601
+ .string()
602
+ .optional()
603
+ .describe("Optional literal ABSOLUTE path of an existing directory to start the sibling in (cross-repo fresh). Omit or pass \"\" to start in this agent's own cwd. Taken exactly as given — no trim, no realpath, no project-name resolution; '#' is refused (tmux format expansion). The receipt echoes what was REQUESTED, never an observation."),
604
+ }, async ({ backend, model, task, cwd }) => {
598
605
  let callerGardenId = null;
599
606
  try {
600
607
  const self = await buildAuthoritativeSelfEnvelope();
@@ -613,7 +620,7 @@ server.tool("entwurf_fresh_call", "Open ONE fresh visible sibling in the operato
613
620
  callerGardenId = null;
614
621
  }
615
622
  try {
616
- const rendered = renderFreshCall(freshCall({ backend, model, task, callerGardenId }));
623
+ const rendered = renderFreshCall(freshCall({ backend, model, task, cwd, callerGardenId }));
617
624
  return rendered.isError ? textErr(rendered.text) : textOk(rendered.text);
618
625
  }
619
626
  catch (err) {
@@ -0,0 +1,47 @@
1
+ /**
2
+ * classify-tmux-cwd — the ONE classification of a start directory that is about to be handed
3
+ * to tmux as a `-c` value. Shared leaf of the resume and fresh launch compositions; it owns
4
+ * the classification and NOTHING else — no hints (each consumer phrases its own: resume says
5
+ * "recorded cwd", fresh says "requested cwd"), no argv, no tmux, no fallback directory.
6
+ *
7
+ * Every rule below is a MEASURED tmux 3.6a behaviour (2026-08-06, private server), and each
8
+ * one is a way a launch would look successful while being wrong:
9
+ *
10
+ * 1. a NONEXISTENT `-c` is silent. tmux exits 0, opens the window, and the child falls back
11
+ * to `$HOME`. A launch whose directory has been deleted would therefore open a visible
12
+ * window in the wrong project and look successful. Nothing downstream can catch that:
13
+ * the launch receipt would be perfectly well-formed.
14
+ * 2. `-c` is FORMAT-EXPANDED. `#{pane_id}` inside the value silently rewrote the path
15
+ * (`<dir>/#{pane_id}` → `<dir>/%0`), and a `#(…)` value was observed running its
16
+ * command. A path is data; tmux reads it as a format. So `#` is refused outright.
17
+ * 3. whitespace is SAFE — argv is an array and nothing re-splits. A dir named `with space`
18
+ * arrived intact. So there is no quoting grammar here, and none is owed.
19
+ *
20
+ * That is the entire defence: one existence check and one character. No escaping layer, no
21
+ * sanitiser, no trim, no realpath/symlink policy — a symlinked project dir is a normal thing
22
+ * to work in, and a value is classified exactly as given.
23
+ */
24
+ import { statSync } from "node:fs";
25
+ import path from "node:path";
26
+ /**
27
+ * Classify a candidate start directory. Split into separate reasons rather than one because
28
+ * the operator's next move differs: an absolute-path bug is a caller defect, a missing
29
+ * directory is a moved/deleted project, and a `#` is a path tmux would rewrite under us.
30
+ */
31
+ export function classifyTmuxCwd(cwd) {
32
+ if (!path.isAbsolute(cwd))
33
+ return "cwd-not-absolute";
34
+ // tmux expands formats inside the `-c` VALUE. `#{…}` rewrote the path silently and `#(…)`
35
+ // was observed executing; neither is something to escape our way out of.
36
+ if (cwd.includes("#"))
37
+ return "cwd-format-token";
38
+ let st;
39
+ try {
40
+ st = statSync(cwd);
41
+ }
42
+ catch {
43
+ // tmux would NOT report this — it opens the window and lands the child in $HOME.
44
+ return "cwd-missing";
45
+ }
46
+ return st.isDirectory() ? null : "cwd-not-directory";
47
+ }
@@ -25,8 +25,27 @@
25
25
  * caller's own inbound surface. Merging them would claim knowledge this module cannot have.
26
26
  * 4. A launch with no callback is a REAL outcome, not an error to retry. No watcher, no poll,
27
27
  * no timeout supervisor. The window is visible; the operator can look.
28
+ *
29
+ * ── The optional REQUESTED cwd (issue #73) ──
30
+ *
31
+ * A fresh sibling starts wherever the caller happens to be — unless the caller names ONE
32
+ * literal start directory. That input exists so a cross-repo fresh consultation never has to
33
+ * ride `entwurf_resume_call` for a dormant record's recorded cwd: resume stays a continuity
34
+ * verb, and placement pressure stays here. The rules are deliberately narrow:
35
+ *
36
+ * - `undefined` and the exact empty string mean OMIT: no `-c` reaches tmux and the argv is
37
+ * byte-identical to the pre-#73 shape. Anything else is taken LITERALLY — no trim, no
38
+ * realpath, no project-name resolution, no store/peers/record lookup. The caller is the
39
+ * only cwd authority this module knows.
40
+ * - the value is classified by the shared `classify-tmux-cwd.ts` leaf BEFORE any mutation
41
+ * (same four stable reasons as resume; the measured tmux 3.6a facts live on that leaf).
42
+ * This module's hints phrase them as the REQUESTED cwd; resume's say RECORDED.
43
+ * - the receipt echoes what was REQUESTED, exactly as `runtimePath` does. It never reports
44
+ * `pane_current_path`: proving where the pane actually landed belongs to acceptance, not
45
+ * to the launch receipt.
28
46
  */
29
47
  import { randomBytes } from "node:crypto";
48
+ import { classifyTmuxCwd } from "./classify-tmux-cwd.js";
30
49
  import { assertLaunchTarget, LaunchPreconditionError, resolveRuntimeOnPath, } from "./mux-launch.js";
31
50
  import { APPEND_FORMAT, assertSelector, assertTmuxOk, inspectPlacement, parseWindowFields, requireSameContext, runTmux, } from "./mux-placement.js";
32
51
  /** The two backends this rail can open. Fixed set, not a profile — a third one is a decision,
@@ -118,16 +137,24 @@ export function mintNonce(randomHex = defaultRandomHex) {
118
137
  function defaultRandomHex() {
119
138
  return randomBytes(12).toString("hex");
120
139
  }
121
- /** Launch argv: the leaf's detached-append shape, the runtime, then the backend's dialect. */
122
- export function buildFreshCallArgs(placement, runtimePath, backendArgs) {
140
+ /** Launch argv: the leaf's detached-append shape, optionally `-c` at the resume-symmetric token
141
+ * position (after `-t`, before `-P -F`), the runtime, then the backend's dialect. An omitted cwd
142
+ * yields the exact pre-#73 argv — no carrier at all. */
143
+ export function buildFreshCallArgs(placement, runtimePath, backendArgs, cwd) {
123
144
  assertSelector("session", placement.sessionId);
124
145
  assertLaunchTarget(runtimePath);
146
+ if (cwd !== undefined) {
147
+ const bad = classifyTmuxCwd(cwd);
148
+ if (bad)
149
+ throw new Error(`mux-fresh-call: refusing to build argv with an unusable cwd (${bad}): ${cwd}`);
150
+ }
125
151
  return [
126
152
  "new-window",
127
153
  "-d",
128
154
  "-a",
129
155
  "-t",
130
156
  `${placement.sessionId}:{end}`,
157
+ ...(cwd === undefined ? [] : ["-c", cwd]),
131
158
  "-P",
132
159
  "-F",
133
160
  APPEND_FORMAT,
@@ -159,6 +186,15 @@ export function freshCall(params, env = process.env, nonce = mintNonce()) {
159
186
  return { ok: false, reason: "task-empty" };
160
187
  if (task.length > TASK_MAX_CHARS)
161
188
  return { ok: false, reason: "task-too-long" };
189
+ // ONLY `undefined` and the exact empty string mean "no cwd". Everything else is the literal
190
+ // value — deliberately untrimmed, so a whitespace-mangled path is refused loudly by the
191
+ // classification below instead of being silently repaired into a different directory.
192
+ const cwd = params.cwd === undefined || params.cwd === "" ? undefined : params.cwd;
193
+ if (cwd !== undefined) {
194
+ const badCwd = classifyTmuxCwd(cwd);
195
+ if (badCwd)
196
+ return { ok: false, reason: badCwd };
197
+ }
162
198
  let runtimePath;
163
199
  try {
164
200
  runtimePath = resolveRuntimeOnPath(FRESH_CALL_RUNTIME[params.backend], env);
@@ -179,7 +215,7 @@ export function freshCall(params, env = process.env, nonce = mintNonce()) {
179
215
  callerGardenId: params.callerGardenId,
180
216
  nonce,
181
217
  });
182
- const run = runTmux(buildFreshCallArgs(placement, runtimePath, buildBackendArgs(params.backend, prompt, model)), env);
218
+ const run = runTmux(buildFreshCallArgs(placement, runtimePath, buildBackendArgs(params.backend, prompt, model), cwd), env);
183
219
  assertTmuxOk("new-window", run);
184
220
  let fields;
185
221
  try {
@@ -199,6 +235,7 @@ export function freshCall(params, env = process.env, nonce = mintNonce()) {
199
235
  ...fields,
200
236
  backend: params.backend,
201
237
  model,
238
+ ...(cwd === undefined ? {} : { cwd }),
202
239
  runtimePath,
203
240
  nonce,
204
241
  },
@@ -212,6 +249,10 @@ const REJECT_HINT = {
212
249
  "anchor-unresolved": "tmux resolved no pane for this agent's anchor",
213
250
  "anchor-mismatch": "tmux answered about a different pane than the one asked about",
214
251
  "caller-identity-unavailable": "this surface has no record-backed garden id for the caller, so the sibling would have no address to call back to",
252
+ "cwd-not-absolute": "the requested cwd is not an absolute path (the value is taken literally — nothing trims or resolves it)",
253
+ "cwd-format-token": "the requested cwd contains '#', which tmux expands as a format inside -c — it would silently rewrite the path or run a command",
254
+ "cwd-missing": "the requested cwd does not exist; tmux would not report this, it would open the window in $HOME and look successful",
255
+ "cwd-not-directory": "the requested cwd exists but is not a directory",
215
256
  "model-empty": "model is empty after trimming; fresh calls require an explicit model",
216
257
  "model-invalid": `model must be one ${MODEL_MAX_CHARS}-character argv-safe id/alias without whitespace or tmux syntax`,
217
258
  "task-empty": "task is empty after trimming",
@@ -244,6 +285,7 @@ export function renderFreshCall(result) {
244
285
  text: `[entwurf fresh call →]\n` +
245
286
  ` backend: ${r.backend} (${r.runtimePath})\n` +
246
287
  ` model: ${r.model} (requested on the runtime CLI)\n` +
288
+ (r.cwd === undefined ? "" : ` cwd: ${r.cwd} (requested start directory — not an observation)\n`) +
247
289
  ` window: ${r.windowId} (index ${r.windowIndex}) in session ${r.sessionId}\n` +
248
290
  ` pane: ${r.paneId} pid ${r.panePid}\n` +
249
291
  ` nonce: ${r.nonce}\n` +
@@ -4,12 +4,13 @@
4
4
  *
5
5
  * ── Why this is a module and not a parameter on fresh-call ──
6
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".
7
+ * `mux-fresh-call` carries a TASK to a runtime it names, and starts the sibling wherever the
8
+ * caller happens to be unless the caller REQUESTS one literal start directory (#73). A resume
9
+ * carries neither a task nor a caller choice: the argv comes from the record (`entwurf-v2-
10
+ * visible-resume` builds it) and the cwd comes from the record too it is the directory the
11
+ * citizen's own transcript header remembers, never something the caller picks. Those are
12
+ * different inputs with a different risk, so they get a different module rather than a fourth
13
+ * parameter on a composition whose contract is "identity is an OUTPUT".
13
14
  *
14
15
  * visible-resume composition → resume-call → placement leaf (unchanged, carrier-free)
15
16
  * resume-call -X-> garden identity, records, locks, delivery
@@ -20,22 +21,15 @@
20
21
  *
21
22
  * `mux-placement`'s `buildAppendArgs` deliberately emits no `-c` ("default shell only"), and it
22
23
  * 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):
24
+ * the `-c` SHAPE lives here, while the classification of the value lives in the shared
25
+ * `classify-tmux-cwd.ts` leaf (fresh-call hands tmux the same flag, and a twin copy of the
26
+ * measured rules would rot apart on the next tmux hazard). The measured tmux 3.6a facts —
27
+ * a nonexistent `-c` silently lands the child in `$HOME`, `#` is format-expanded, whitespace
28
+ * is safe — are documented on that leaf. `|` is fine too: the cwd never enters the `-F` row
29
+ * (see `APPEND_FORMAT` below).
25
30
  *
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.
31
+ * What stays HERE is the phrasing: this module's hints say "recorded cwd", because a resume's
32
+ * directory comes from the record fresh-call's say "requested cwd" for the same reasons.
39
33
  *
40
34
  * ── What the receipt does NOT say ──
41
35
  *
@@ -46,36 +40,13 @@
46
40
  * where the pane actually landed is a separate query against the stable pane id, and it belongs
47
41
  * to acceptance, not to the product's launch receipt.
48
42
  */
49
- import { statSync } from "node:fs";
50
- import path from "node:path";
43
+ import { classifyTmuxCwd } from "./classify-tmux-cwd.js";
51
44
  import { assertLaunchTarget, LaunchPreconditionError, resolveRuntimeOnPath, } from "./mux-launch.js";
52
45
  import { APPEND_FORMAT, assertSelector, assertTmuxOk, inspectPlacement, parseWindowFields, requireSameContext, runTmux, } from "./mux-placement.js";
53
46
  /** The fixed runtime a resume reopens. Only pi stands a control socket up, so only pi has a
54
47
  * same-id resume at all — the backend boundary is enforced upstream by
55
48
  * `resolveResumeLaunchIdentity`, and this constant is the mux half of the same fact. */
56
49
  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
50
  /**
80
51
  * Launch argv: the leaf's detached-append shape plus `-c`, the runtime, then the caller's flags.
81
52
  * `--` is what keeps tmux from reading the runtime or its flags as tmux options.
@@ -83,7 +54,7 @@ export function classifyResumeCwd(cwd) {
83
54
  export function buildResumeCallArgs(placement, cwd, runtimePath, runtimeArgs) {
84
55
  assertSelector("session", placement.sessionId);
85
56
  assertLaunchTarget(runtimePath);
86
- const bad = classifyResumeCwd(cwd);
57
+ const bad = classifyTmuxCwd(cwd);
87
58
  if (bad)
88
59
  throw new Error(`mux-resume-call: refusing to build argv with an unusable cwd (${bad}): ${cwd}`);
89
60
  return [
@@ -110,7 +81,7 @@ export function buildResumeCallArgs(placement, cwd, runtimePath, runtimeArgs) {
110
81
  * `runtimeArgs` is passed through untouched. This module does not know what `--session` means.
111
82
  */
112
83
  export function resumeCall(params, env = process.env) {
113
- const badCwd = classifyResumeCwd(params.cwd);
84
+ const badCwd = classifyTmuxCwd(params.cwd);
114
85
  if (badCwd)
115
86
  return { ok: false, reason: badCwd };
116
87
  let runtimePath;
@@ -1,10 +1,23 @@
1
1
  #!/usr/bin/env node
2
2
  // doctor-pi-provider — fail-loud doctor for the pi provider (entwurfProvider.mcpServers.
3
- // entwurf-bridge) ownership (#46 Task 2). Side-effect FREE (read-only). Uses the config.ts SSOT
3
+ // entwurf-bridge) ownership (#46 Task 2). A BOUNDED BOOT PROBE, not a static inspector: it writes
4
+ // no operator state, but it does exec the configured command on this host (#81). Uses the config.ts SSOT
4
5
  // `readProviderSettingsFile` so the effective (shadow-resolved) view matches what pi actually
5
6
  // loads — NOT a re-implemented merge (GPT D: a python re-impl drifts into "doctor green, runtime
6
7
  // red"). Reports user / project / EFFECTIVE command (project shadows user per-name, the
7
- // resolveProviderConfig rule), plus install-state ownership, and gates on stable-bin resolvability.
8
+ // resolveProviderConfig rule), plus install-state ownership, and gates on the stable bin actually
9
+ // BOOTING (#81) — not merely resolving. `command -v` answering yes is not evidence that pi gets a
10
+ // bridge: on the reference host the bare name resolved through a relocated pnpm shim whose $0-derived
11
+ // target did not exist, so the launcher exited 127, the ACP turn had no mcp__entwurf-bridge__* tool,
12
+ // and THIS doctor still printed ok. Resolvability is kept as the first, cheaper cell so the two
13
+ // failures stay distinguishable (nothing on PATH vs. on PATH but dead).
14
+ //
15
+ // The probe sends `initialize` + `tools/list` only — never a `tools/call` — so it takes no lock,
16
+ // writes no record and delivers nothing. That bounds OUR bridge tightly (start.sh is an `exec node`,
17
+ // so there is no grandchild). It does not make the doctor side-effect free in general: whatever the
18
+ // operator configured is what gets executed, and a foreign launcher's own startup is its own
19
+ // business. Running this doctor is therefore a decision to run that command. See
20
+ // probe-bridge-command.ts, which owns the bounded reap of the child it spawns.
8
21
  //
9
22
  // Env overrides (for the hermetic smoke):
10
23
  // PI_PROVIDER_GLOBAL_SETTINGS default: $PI_CODING_AGENT_DIR/settings.json or ~/.pi/agent/settings.json
@@ -12,12 +25,13 @@
12
25
  // PI_PROVIDER_STATE default: $XDG_DATA_HOME/entwurf/pi-provider/install-state.json
13
26
  //
14
27
  // Exit: 0 ok (incl. honest "never installed / unowned" notes) · 1 hard fail (malformed settings /
15
- // state-owned-but-drifted / stable bin dangling).
16
- import { execSync } from "node:child_process";
17
- import { existsSync, constants as FS, readFileSync, statSync } from "node:fs";
28
+ // state-owned-but-drifted / stable bin dangling / stable bin present but does NOT boot).
29
+ import { execFileSync } from "node:child_process";
30
+ import { accessSync, existsSync, constants as FS, readFileSync } from "node:fs";
18
31
  import { homedir } from "node:os";
19
32
  import { join } from "node:path";
20
- import { readProviderSettingsFile } from "../pi-extensions/lib/acp/config.js";
33
+ import { normalizeMcpServers, readProviderSettingsFile } from "../pi-extensions/lib/acp/config.js";
34
+ import { probeBridgeCommand } from "./probe-bridge-command.js";
21
35
  const BARE = "entwurf-bridge";
22
36
  const KEY = "entwurf-bridge";
23
37
  const home = homedir();
@@ -28,29 +42,64 @@ const xdg = process.env.XDG_DATA_HOME || join(home, ".local", "share");
28
42
  const statePath = process.env.PI_PROVIDER_STATE || join(xdg, "entwurf", "pi-provider", "install-state.json");
29
43
  let hardFail = 0;
30
44
  const log = (s) => process.stdout.write(s + "\n");
31
- function commandOf(settings) {
32
- const entry = settings.mcpServers?.[KEY];
33
- if (entry && typeof entry === "object" && typeof entry.command === "string") {
34
- return entry.command;
35
- }
36
- return undefined;
45
+ // Build the effective entry EXACTLY the way production does (config.ts `resolveProviderConfig`):
46
+ // a shallow PER-NAME merge of the two `mcpServers` maps, then ONE `normalizeMcpServers` over the
47
+ // merged map. Hand-parsing command/args (this doctor's first shape) dropped a malformed arg
48
+ // silently and probed an invocation production never runs.
49
+ //
50
+ // The ORDER is load-bearing too. Normalizing each file separately validates entries production
51
+ // never sees: a malformed server in the global map that the project map shadows is gone by the time
52
+ // pi normalizes, yet a per-file doctor throws on it and calls a session red that in fact starts
53
+ // fine. That is the same "the doctor's subject differs from the runtime's" defect as the silent
54
+ // arg-drop, pointed the other way — and it is not confined to our key, since any unrelated global
55
+ // server could trip it.
56
+ //
57
+ // THROWS (McpServerConfigError) when the MERGED map is malformed — the fail-loud path, caught by
58
+ // the caller. That error names the offending SERVER and reason, not a file: after the merge an
59
+ // entry no longer belongs to one scope, so claiming a filename here would be a guess. A non-stdio
60
+ // (http/sse) entry is returned as `null` so the caller can say WHY it cannot be probed instead of
61
+ // pretending it is absent.
62
+ function mergedBridgeEntry(globalSettings, projectSettings) {
63
+ const mergedRaw = { ...(globalSettings.mcpServers ?? {}), ...(projectSettings.mcpServers ?? {}) };
64
+ const { servers } = normalizeMcpServers(mergedRaw);
65
+ const entry = servers.find((srv) => srv.name === KEY);
66
+ if (entry === undefined)
67
+ return undefined; // not configured in either scope
68
+ if ("url" in entry)
69
+ return null; // http/sse — a real config, but nothing to spawn
70
+ return {
71
+ command: entry.command,
72
+ args: entry.args,
73
+ // The ACP wire shape is a name/value list; collapse it to the env map a spawn wants.
74
+ env: Object.fromEntries(entry.env.map((kv) => [kv.name, kv.value])),
75
+ };
76
+ }
77
+ // Per-scope DISPLAY only — never validation. The scope lines exist so an operator can see which
78
+ // file supplied the effective entry; judging shape here would re-introduce the per-file validation
79
+ // the merge rule above exists to avoid.
80
+ function describeScope(settings) {
81
+ const raw = settings.mcpServers?.[KEY];
82
+ if (raw === undefined)
83
+ return "entwurf-bridge NOT configured";
84
+ const cmd = raw?.command;
85
+ return typeof cmd === "string" ? `'${cmd}'` : "configured (shape judged in the merged view)";
37
86
  }
38
87
  // Does the command resolve in the environment (best local proxy for "where pi/agy runs")?
39
88
  // A bare name is looked up on PATH; a path must be an executable file.
40
89
  function resolvable(cmd) {
41
90
  if (cmd.includes("/")) {
42
91
  try {
43
- statSync(cmd);
44
- // eslint-disable-next-line no-bitwise
45
- return (statSync(cmd).mode & FS.S_IXUSR) !== 0;
92
+ accessSync(cmd, FS.X_OK);
93
+ return true;
46
94
  }
47
95
  catch {
48
96
  return false;
49
97
  }
50
98
  }
51
99
  try {
52
- // `command -v` is a POSIX sh builtin; use the default /bin/sh (NixOS has no /bin/bash).
53
- execSync(`command -v ${cmd}`, { stdio: "ignore" });
100
+ // `command -v` is a POSIX sh builtin. Pass the configured name as argv, never shell text:
101
+ // an unowned override is still operator data and must not become a doctor injection surface.
102
+ execFileSync("sh", ["-c", 'command -v -- "$1" >/dev/null 2>&1', "sh", cmd], { stdio: "ignore" });
54
103
  return true;
55
104
  }
56
105
  catch {
@@ -59,23 +108,39 @@ function resolvable(cmd) {
59
108
  }
60
109
  log("[pi-provider doctor]");
61
110
  // Read via the SSOT — a malformed settings file THROWS here (fail-loud, named file).
62
- let userCmd;
63
- let projCmd;
111
+ let effectiveEntry;
112
+ let userScopeDesc = "";
113
+ let projScopeDesc = "";
114
+ let effectiveScope = "none";
64
115
  try {
65
- userCmd = commandOf(readProviderSettingsFile(globalPath).settings);
66
- projCmd = commandOf(readProviderSettingsFile(projectPath).settings);
116
+ const userSettings = readProviderSettingsFile(globalPath).settings;
117
+ const projSettings = readProviderSettingsFile(projectPath).settings;
118
+ userScopeDesc = describeScope(userSettings);
119
+ projScopeDesc = describeScope(projSettings);
120
+ // EFFECTIVE = project shadows user per-NAME. The whole ENTRY shadows, not a field of it, so
121
+ // command, args and env always come from one scope; the scope label is decided by which map
122
+ // owns the key, which is exactly what the merge spread resolves.
123
+ effectiveEntry = mergedBridgeEntry(userSettings, projSettings);
124
+ const ownsKey = (m) => m !== undefined && Object.hasOwn(m, KEY);
125
+ effectiveScope = ownsKey(projSettings.mcpServers)
126
+ ? "project"
127
+ : ownsKey(userSettings.mcpServers)
128
+ ? "user(global)"
129
+ : "none";
67
130
  }
68
131
  catch (err) {
69
132
  log(` FAIL: ${err instanceof Error ? err.message : String(err)}`);
70
133
  process.exit(1);
71
134
  }
72
- // EFFECTIVE = project shadows user per-name (the resolveProviderConfig merge rule).
73
- const effectiveCmd = projCmd ?? userCmd;
74
- const effectiveScope = projCmd !== undefined ? "project" : userCmd !== undefined ? "user(global)" : "none";
135
+ const effectiveDesc = effectiveEntry === undefined
136
+ ? "entwurf-bridge NOT configured"
137
+ : effectiveEntry === null
138
+ ? "configured as an http/sse server"
139
+ : `'${effectiveEntry.command}'`;
75
140
  log("── scopes (project shadows user per-name)");
76
- log(` user(global) ${globalPath}: ${userCmd ? `'${userCmd}'` : "entwurf-bridge NOT configured"}`);
77
- log(` project ${projectPath}: ${projCmd ? `'${projCmd}'` : "entwurf-bridge NOT configured"}`);
78
- log(` EFFECTIVE (${effectiveScope}): ${effectiveCmd ? `'${effectiveCmd}'` : "none"}`);
141
+ log(` user(global) ${globalPath}: ${userScopeDesc}`);
142
+ log(` project ${projectPath}: ${projScopeDesc}`);
143
+ log(` EFFECTIVE (${effectiveScope}): ${effectiveDesc}`);
79
144
  // install-state ownership (user scope). absent state on a configured effective is either a
80
145
  // pre-Task-2 install or a user-override we deliberately did not own.
81
146
  let ownership;
@@ -94,33 +159,60 @@ else {
94
159
  log(" state: no user-scope install-state.");
95
160
  }
96
161
  log("── verdict");
97
- if (effectiveCmd === undefined) {
162
+ if (effectiveEntry === undefined) {
98
163
  log(" note: no entwurfProvider.mcpServers.entwurf-bridge in any scope (never installed — this is the '?'; run ./run.sh setup).");
99
164
  }
100
- else if (effectiveCmd === BARE) {
101
- if (resolvable(effectiveCmd)) {
102
- log(` ok: effective command is the bare stable bin '${BARE}' and it RESOLVES.`);
103
- }
104
- else {
105
- log(` FAIL: effective command is '${BARE}' but it does NOT resolve (run ./run.sh expose-dev-bin / npm bin-link).`);
106
- hardFail = 1;
107
- }
165
+ else if (effectiveEntry === null) {
166
+ // A real, well-formed config that this bridge cannot be: entwurf-bridge is a stdio server, so
167
+ // an http/sse entry under our key means pi would connect to something that is not us. Nothing
168
+ // here is spawnable, so there is no boot to prove — say that plainly rather than reporting the
169
+ // absence of a failure as ok.
170
+ log(` FAIL: entwurf-bridge is configured as an http/sse server in ${effectiveScope} scope. This bridge is a stdio server — pi would reach something that is not entwurf, and no boot evidence is possible. Restore a stdio entry with ./run.sh setup, or remove the key if the override is deliberate.`);
171
+ hardFail = 1;
108
172
  }
109
173
  else {
110
- // effective is NOT the bare bin. If state says we own it → drift (FAIL). Otherwise classify
111
- // the effective command honestly: our OWN legacy repo start.sh (not yet adopted) is NOT a
112
- // user override — say so distinctly so "run setup" is the clear next step. A truly foreign
113
- // command is an unowned override left as the operator's choice. Neither is a hard fail.
114
- const isLegacyManaged = effectiveCmd.endsWith("/entwurf/mcp/entwurf-bridge/start.sh");
115
- if (ownership && ownership !== "user-override") {
116
- log(` FAIL: state owns entwurf-bridge (ownership=${ownership}) but the effective command drifted to '${effectiveCmd}'.`);
174
+ const cmd = effectiveEntry.command;
175
+ const isBare = cmd === BARE;
176
+ const isLegacyManaged = cmd.endsWith("/entwurf/mcp/entwurf-bridge/start.sh");
177
+ // Runtime truth is independent of ownership truth. An unowned override remains the operator's
178
+ // choice, but it still shadows the stable bridge in production; calling a dead override green
179
+ // repeats #81 under a different spelling. Probe the exact normalized command + args + env for
180
+ // EVERY effective stdio entry, then classify who owns that entry separately below.
181
+ if (!resolvable(cmd)) {
182
+ log(` FAIL: effective command '${cmd}' does NOT resolve or is not executable.`);
117
183
  hardFail = 1;
118
184
  }
119
- else if (isLegacyManaged) {
120
- log(` note: effective is our LEGACY managed repo path ('${effectiveCmd}'), not yet adopted to the bare stable bin. Run ./run.sh setup to normalize (this is the pre-Task-2 '?').`);
121
- }
122
185
  else {
123
- log(` note: entwurf-bridge is an UNOWNED override ('${effectiveCmd}')effective is not the stable bin. Left as the operator's choice (run ./run.sh setup to adopt the bare bin).`);
186
+ log(` effective command '${cmd}' RESOLVES probing the exact configured invocation…`);
187
+ const probe = await probeBridgeCommand({
188
+ command: cmd,
189
+ args: effectiveEntry.args,
190
+ env: { ...process.env, ...effectiveEntry.env },
191
+ });
192
+ if (probe.ok) {
193
+ log(isBare
194
+ ? ` ok: effective command is the bare stable bin '${BARE}' and it BOOTS — ${probe.detail}`
195
+ : ` runtime: configured override BOOTS — ${probe.detail}`);
196
+ }
197
+ else {
198
+ log(` FAIL: effective invocation does NOT serve MCP [${probe.reason}] — ${probe.detail}`);
199
+ log(` Diagnose its launcher and configured args/env; entwurf will not overwrite an unowned command.`);
200
+ hardFail = 1;
201
+ }
202
+ }
203
+ if (!isBare) {
204
+ // Ownership classification never rounds a broken runtime up to green. It only says who may
205
+ // repair the non-canonical entry after the independent boot verdict above.
206
+ if (ownership && ownership !== "user-override") {
207
+ log(` FAIL: state owns entwurf-bridge (ownership=${ownership}) but the effective command drifted to '${cmd}'.`);
208
+ hardFail = 1;
209
+ }
210
+ else if (isLegacyManaged) {
211
+ log(` note: effective is our LEGACY managed repo path ('${cmd}'), not yet adopted to the bare stable bin. Run ./run.sh setup to normalize.`);
212
+ }
213
+ else {
214
+ log(` note: entwurf-bridge is an UNOWNED override ('${cmd}'). Runtime was judged above; run ./run.sh setup only if you choose to adopt the bare stable bin.`);
215
+ }
124
216
  }
125
217
  }
126
218
  if (hardFail) {