@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.
- package/AGENTS.md +13 -2
- package/CHANGELOG.md +63 -0
- package/DELIVERY.md +57 -0
- package/README.md +16 -7
- package/VERIFY.md +4 -4
- package/demo/README.md +3 -1
- package/demo/demo-baseline.sh +12 -1
- package/demo/demo.sh +9 -1
- package/docs/acp-backend-rail.md +103 -4
- package/docs/external-mcp-host.md +1 -1
- package/docs/setup-clean-host.md +3 -3
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +12 -5
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/classify-tmux-cwd.js +47 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +45 -3
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-resume-call.js +18 -47
- package/mcp/entwurf-bridge/dist/scripts/doctor-pi-provider.js +139 -47
- package/mcp/entwurf-bridge/dist/scripts/probe-bridge-command.js +294 -0
- package/mcp/entwurf-bridge/src/index.ts +14 -5
- package/mcp/entwurf-bridge/tsconfig.build.json +15 -5
- package/package.json +9 -9
- package/pi-extensions/entwurf-control.ts +13 -4
- package/pi-extensions/lib/acp/backend.ts +229 -9
- package/pi-extensions/lib/classify-tmux-cwd.ts +50 -0
- package/pi-extensions/lib/mux-fresh-call.ts +57 -4
- package/pi-extensions/lib/mux-resume-call.ts +21 -53
- package/run.sh +70 -25
- package/scripts/agy-bridge-config.py +47 -13
- package/scripts/agy-bridge.sh +73 -23
- package/scripts/check-acp-prompt-lifecycle.ts +221 -9
- package/scripts/check-entwurf-bridge-boot.ts +28 -0
- package/scripts/check-gate-qualification.ts +5 -3
- package/scripts/check-mux-resume-call.ts +11 -10
- package/scripts/check-probe-bridge-command.ts +201 -0
- package/scripts/check-release-gate-outcomes.ts +54 -1
- package/scripts/doctor-pi-provider.ts +155 -51
- package/scripts/meta-bridge-state.py +75 -1
- package/scripts/mutants/acp-prompt-lifecycle.json +25 -3
- package/scripts/mutants/bridge-command-boot.json +107 -0
- package/scripts/mutants/meta-retire.json +47 -0
- package/scripts/mutants/mux-fresh-call.json +48 -4
- package/scripts/mutants/mux-resume-call.json +3 -3
- package/scripts/mutants/release-gate.json +13 -0
- package/scripts/probe-bridge-command.ts +330 -0
- package/scripts/raw-async-delivery/README.md +158 -1
- package/scripts/raw-async-delivery/copilot-ui-server-probe.mjs +337 -0
- package/scripts/smoke-acp-raw-turn-live.ts +1 -1
- package/scripts/smoke-agy-install-state.sh +76 -2
- package/scripts/smoke-entwurf-chain-live.ts +12 -4
- package/scripts/smoke-entwurf-v2-matrix-live.ts +2 -2
- package/scripts/smoke-meta-install-state.sh +169 -3
- package/scripts/smoke-mux-fresh-call-live.ts +1 -1
- package/scripts/smoke-mux-lifecycle-live.ts +1 -1
- package/scripts/smoke-pi-provider-state.sh +135 -6
- 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)
|
|
572
|
-
"
|
|
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
|
-
|
|
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,
|
|
122
|
-
|
|
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
|
|
8
|
-
* happens to be
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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`
|
|
24
|
-
*
|
|
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
|
-
*
|
|
27
|
-
*
|
|
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 {
|
|
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 =
|
|
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 =
|
|
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).
|
|
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
|
|
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 {
|
|
17
|
-
import { existsSync, constants as FS, readFileSync
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
44
|
-
|
|
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
|
|
53
|
-
|
|
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
|
|
63
|
-
let
|
|
111
|
+
let effectiveEntry;
|
|
112
|
+
let userScopeDesc = "";
|
|
113
|
+
let projScopeDesc = "";
|
|
114
|
+
let effectiveScope = "none";
|
|
64
115
|
try {
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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}: ${
|
|
77
|
-
log(` project ${projectPath}: ${
|
|
78
|
-
log(` EFFECTIVE (${effectiveScope}): ${
|
|
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 (
|
|
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 (
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
//
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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(`
|
|
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) {
|