@junghanacs/entwurf 0.14.0 → 0.14.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +12 -1
- package/CHANGELOG.md +29 -0
- package/README.md +15 -6
- package/docs/external-mcp-host.md +1 -1
- 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/src/index.ts +14 -5
- package/package.json +1 -1
- package/pi-extensions/entwurf-control.ts +13 -4
- 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/scripts/check-gate-qualification.ts +2 -1
- package/scripts/check-mux-resume-call.ts +11 -10
- package/scripts/meta-bridge-state.py +75 -1
- 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/smoke-entwurf-chain-live.ts +11 -3
- package/scripts/smoke-meta-install-state.sh +169 -3
|
@@ -0,0 +1,50 @@
|
|
|
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
|
+
|
|
25
|
+
import { statSync } from "node:fs";
|
|
26
|
+
import path from "node:path";
|
|
27
|
+
|
|
28
|
+
/** Why a candidate `-c` value was refused. Four stable literals — both consuming
|
|
29
|
+
* compositions widen their own reject unions with this type, so the strings are contract. */
|
|
30
|
+
export type TmuxCwdRejectReason = "cwd-not-absolute" | "cwd-format-token" | "cwd-missing" | "cwd-not-directory";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Classify a candidate start directory. Split into separate reasons rather than one because
|
|
34
|
+
* the operator's next move differs: an absolute-path bug is a caller defect, a missing
|
|
35
|
+
* directory is a moved/deleted project, and a `#` is a path tmux would rewrite under us.
|
|
36
|
+
*/
|
|
37
|
+
export function classifyTmuxCwd(cwd: string): TmuxCwdRejectReason | null {
|
|
38
|
+
if (!path.isAbsolute(cwd)) return "cwd-not-absolute";
|
|
39
|
+
// tmux expands formats inside the `-c` VALUE. `#{…}` rewrote the path silently and `#(…)`
|
|
40
|
+
// was observed executing; neither is something to escape our way out of.
|
|
41
|
+
if (cwd.includes("#")) return "cwd-format-token";
|
|
42
|
+
let st: ReturnType<typeof statSync>;
|
|
43
|
+
try {
|
|
44
|
+
st = statSync(cwd);
|
|
45
|
+
} catch {
|
|
46
|
+
// tmux would NOT report this — it opens the window and lands the child in $HOME.
|
|
47
|
+
return "cwd-missing";
|
|
48
|
+
}
|
|
49
|
+
return st.isDirectory() ? null : "cwd-not-directory";
|
|
50
|
+
}
|
|
@@ -25,9 +25,28 @@
|
|
|
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
|
|
|
30
48
|
import { randomBytes } from "node:crypto";
|
|
49
|
+
import { classifyTmuxCwd, type TmuxCwdRejectReason } from "./classify-tmux-cwd.ts";
|
|
31
50
|
import {
|
|
32
51
|
assertLaunchTarget,
|
|
33
52
|
LaunchPreconditionError,
|
|
@@ -142,10 +161,12 @@ export function buildFreshCallPrompt(params: {
|
|
|
142
161
|
}
|
|
143
162
|
|
|
144
163
|
/** A launch that was refused, or a placement that could not be established. Every value is a
|
|
145
|
-
* NAMED refusal — this module has no fallback launch.
|
|
164
|
+
* NAMED refusal — this module has no fallback launch and no fallback directory. The cwd members
|
|
165
|
+
* come from the shared classification leaf and their string values are stable contract. */
|
|
146
166
|
export type FreshCallRejectReason =
|
|
147
167
|
| PlacementRejectReason
|
|
148
168
|
| LaunchRejectReason
|
|
169
|
+
| TmuxCwdRejectReason
|
|
149
170
|
| "caller-identity-unavailable"
|
|
150
171
|
| "model-empty"
|
|
151
172
|
| "model-invalid"
|
|
@@ -158,6 +179,10 @@ export type FreshCallRejectReason =
|
|
|
158
179
|
export interface FreshCallReceipt extends WindowHandle {
|
|
159
180
|
backend: FreshCallBackend;
|
|
160
181
|
model: string;
|
|
182
|
+
/** The REQUESTED start directory — present only when the caller supplied one. The same kind
|
|
183
|
+
* of fact as `runtimePath`: what tmux was asked for, never an observation of where the pane
|
|
184
|
+
* landed. */
|
|
185
|
+
cwd?: string;
|
|
161
186
|
runtimePath: string;
|
|
162
187
|
nonce: string;
|
|
163
188
|
}
|
|
@@ -174,20 +199,28 @@ function defaultRandomHex(): string {
|
|
|
174
199
|
return randomBytes(12).toString("hex");
|
|
175
200
|
}
|
|
176
201
|
|
|
177
|
-
/** Launch argv: the leaf's detached-append shape,
|
|
202
|
+
/** Launch argv: the leaf's detached-append shape, optionally `-c` at the resume-symmetric token
|
|
203
|
+
* position (after `-t`, before `-P -F`), the runtime, then the backend's dialect. An omitted cwd
|
|
204
|
+
* yields the exact pre-#73 argv — no carrier at all. */
|
|
178
205
|
export function buildFreshCallArgs(
|
|
179
206
|
placement: Placement,
|
|
180
207
|
runtimePath: string,
|
|
181
208
|
backendArgs: readonly string[],
|
|
209
|
+
cwd?: string,
|
|
182
210
|
): string[] {
|
|
183
211
|
assertSelector("session", placement.sessionId);
|
|
184
212
|
assertLaunchTarget(runtimePath);
|
|
213
|
+
if (cwd !== undefined) {
|
|
214
|
+
const bad = classifyTmuxCwd(cwd);
|
|
215
|
+
if (bad) throw new Error(`mux-fresh-call: refusing to build argv with an unusable cwd (${bad}): ${cwd}`);
|
|
216
|
+
}
|
|
185
217
|
return [
|
|
186
218
|
"new-window",
|
|
187
219
|
"-d",
|
|
188
220
|
"-a",
|
|
189
221
|
"-t",
|
|
190
222
|
`${placement.sessionId}:{end}`,
|
|
223
|
+
...(cwd === undefined ? [] : ["-c", cwd]),
|
|
191
224
|
"-P",
|
|
192
225
|
"-F",
|
|
193
226
|
APPEND_FORMAT,
|
|
@@ -207,7 +240,7 @@ export function buildFreshCallArgs(
|
|
|
207
240
|
* against a store, or guesses it: an empty value is a named refusal, not a lookup.
|
|
208
241
|
*/
|
|
209
242
|
export function freshCall(
|
|
210
|
-
params: { backend: FreshCallBackend; model: string; task: string; callerGardenId: string | null },
|
|
243
|
+
params: { backend: FreshCallBackend; model: string; task: string; cwd?: string; callerGardenId: string | null },
|
|
211
244
|
env: NodeJS.ProcessEnv = process.env,
|
|
212
245
|
nonce: string = mintNonce(),
|
|
213
246
|
): FreshCallResult {
|
|
@@ -220,6 +253,14 @@ export function freshCall(
|
|
|
220
253
|
const task = params.task.trim();
|
|
221
254
|
if (task.length === 0) return { ok: false, reason: "task-empty" };
|
|
222
255
|
if (task.length > TASK_MAX_CHARS) return { ok: false, reason: "task-too-long" };
|
|
256
|
+
// ONLY `undefined` and the exact empty string mean "no cwd". Everything else is the literal
|
|
257
|
+
// value — deliberately untrimmed, so a whitespace-mangled path is refused loudly by the
|
|
258
|
+
// classification below instead of being silently repaired into a different directory.
|
|
259
|
+
const cwd = params.cwd === undefined || params.cwd === "" ? undefined : params.cwd;
|
|
260
|
+
if (cwd !== undefined) {
|
|
261
|
+
const badCwd = classifyTmuxCwd(cwd);
|
|
262
|
+
if (badCwd) return { ok: false, reason: badCwd };
|
|
263
|
+
}
|
|
223
264
|
|
|
224
265
|
let runtimePath: string;
|
|
225
266
|
try {
|
|
@@ -240,7 +281,10 @@ export function freshCall(
|
|
|
240
281
|
callerGardenId: params.callerGardenId,
|
|
241
282
|
nonce,
|
|
242
283
|
});
|
|
243
|
-
const run = runTmux(
|
|
284
|
+
const run = runTmux(
|
|
285
|
+
buildFreshCallArgs(placement, runtimePath, buildBackendArgs(params.backend, prompt, model), cwd),
|
|
286
|
+
env,
|
|
287
|
+
);
|
|
244
288
|
assertTmuxOk("new-window", run);
|
|
245
289
|
|
|
246
290
|
let fields: ReturnType<typeof parseWindowFields>;
|
|
@@ -265,6 +309,7 @@ export function freshCall(
|
|
|
265
309
|
...fields,
|
|
266
310
|
backend: params.backend,
|
|
267
311
|
model,
|
|
312
|
+
...(cwd === undefined ? {} : { cwd }),
|
|
268
313
|
runtimePath,
|
|
269
314
|
nonce,
|
|
270
315
|
},
|
|
@@ -280,6 +325,13 @@ const REJECT_HINT: Record<FreshCallRejectReason, string> = {
|
|
|
280
325
|
"anchor-mismatch": "tmux answered about a different pane than the one asked about",
|
|
281
326
|
"caller-identity-unavailable":
|
|
282
327
|
"this surface has no record-backed garden id for the caller, so the sibling would have no address to call back to",
|
|
328
|
+
"cwd-not-absolute":
|
|
329
|
+
"the requested cwd is not an absolute path (the value is taken literally — nothing trims or resolves it)",
|
|
330
|
+
"cwd-format-token":
|
|
331
|
+
"the requested cwd contains '#', which tmux expands as a format inside -c — it would silently rewrite the path or run a command",
|
|
332
|
+
"cwd-missing":
|
|
333
|
+
"the requested cwd does not exist; tmux would not report this, it would open the window in $HOME and look successful",
|
|
334
|
+
"cwd-not-directory": "the requested cwd exists but is not a directory",
|
|
283
335
|
"model-empty": "model is empty after trimming; fresh calls require an explicit model",
|
|
284
336
|
"model-invalid": `model must be one ${MODEL_MAX_CHARS}-character argv-safe id/alias without whitespace or tmux syntax`,
|
|
285
337
|
"task-empty": "task is empty after trimming",
|
|
@@ -314,6 +366,7 @@ export function renderFreshCall(result: FreshCallResult): { text: string; isErro
|
|
|
314
366
|
`[entwurf fresh call →]\n` +
|
|
315
367
|
` backend: ${r.backend} (${r.runtimePath})\n` +
|
|
316
368
|
` model: ${r.model} (requested on the runtime CLI)\n` +
|
|
369
|
+
(r.cwd === undefined ? "" : ` cwd: ${r.cwd} (requested start directory — not an observation)\n`) +
|
|
317
370
|
` window: ${r.windowId} (index ${r.windowIndex}) in session ${r.sessionId}\n` +
|
|
318
371
|
` pane: ${r.paneId} pid ${r.panePid}\n` +
|
|
319
372
|
` 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
|
*
|
|
@@ -47,8 +41,7 @@
|
|
|
47
41
|
* to acceptance, not to the product's launch receipt.
|
|
48
42
|
*/
|
|
49
43
|
|
|
50
|
-
import {
|
|
51
|
-
import path from "node:path";
|
|
44
|
+
import { classifyTmuxCwd, type TmuxCwdRejectReason } from "./classify-tmux-cwd.ts";
|
|
52
45
|
import {
|
|
53
46
|
assertLaunchTarget,
|
|
54
47
|
LaunchPreconditionError,
|
|
@@ -74,14 +67,9 @@ import {
|
|
|
74
67
|
export const RESUME_CALL_RUNTIME = "pi";
|
|
75
68
|
|
|
76
69
|
/** Why a resume window could not be opened. Every value is a NAMED refusal; this module has no
|
|
77
|
-
* fallback launch and no fallback directory.
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
| LaunchRejectReason
|
|
81
|
-
| "cwd-not-absolute"
|
|
82
|
-
| "cwd-format-token"
|
|
83
|
-
| "cwd-missing"
|
|
84
|
-
| "cwd-not-directory";
|
|
70
|
+
* fallback launch and no fallback directory. The cwd members come from the shared classification
|
|
71
|
+
* leaf and their string values are stable contract. */
|
|
72
|
+
export type ResumeCallRejectReason = PlacementRejectReason | LaunchRejectReason | TmuxCwdRejectReason;
|
|
85
73
|
|
|
86
74
|
/** Coordinates plus what was handed to tmux. `cwd` is the REQUESTED start directory — the same
|
|
87
75
|
* kind of fact as `runtimePath`, namely what tmux was asked for, not an observation. */
|
|
@@ -92,26 +80,6 @@ export interface ResumeCallReceipt extends WindowHandle {
|
|
|
92
80
|
|
|
93
81
|
export type ResumeCallResult = { ok: true; receipt: ResumeCallReceipt } | { ok: false; reason: ResumeCallRejectReason };
|
|
94
82
|
|
|
95
|
-
/**
|
|
96
|
-
* Classify a candidate start directory. Split into three reasons rather than one because the
|
|
97
|
-
* operator's next move differs: an absolute-path bug is a caller defect, a missing directory is
|
|
98
|
-
* a moved/deleted project, and a `#` is a path tmux would rewrite under us.
|
|
99
|
-
*/
|
|
100
|
-
export function classifyResumeCwd(cwd: string): ResumeCallRejectReason | null {
|
|
101
|
-
if (!path.isAbsolute(cwd)) return "cwd-not-absolute";
|
|
102
|
-
// tmux expands formats inside the `-c` VALUE. `#{…}` rewrote the path silently and `#(…)`
|
|
103
|
-
// was observed executing; neither is something to escape our way out of.
|
|
104
|
-
if (cwd.includes("#")) return "cwd-format-token";
|
|
105
|
-
let st: ReturnType<typeof statSync>;
|
|
106
|
-
try {
|
|
107
|
-
st = statSync(cwd);
|
|
108
|
-
} catch {
|
|
109
|
-
// tmux would NOT report this — it opens the window and lands the child in $HOME.
|
|
110
|
-
return "cwd-missing";
|
|
111
|
-
}
|
|
112
|
-
return st.isDirectory() ? null : "cwd-not-directory";
|
|
113
|
-
}
|
|
114
|
-
|
|
115
83
|
/**
|
|
116
84
|
* Launch argv: the leaf's detached-append shape plus `-c`, the runtime, then the caller's flags.
|
|
117
85
|
* `--` is what keeps tmux from reading the runtime or its flags as tmux options.
|
|
@@ -124,7 +92,7 @@ export function buildResumeCallArgs(
|
|
|
124
92
|
): string[] {
|
|
125
93
|
assertSelector("session", placement.sessionId);
|
|
126
94
|
assertLaunchTarget(runtimePath);
|
|
127
|
-
const bad =
|
|
95
|
+
const bad = classifyTmuxCwd(cwd);
|
|
128
96
|
if (bad) throw new Error(`mux-resume-call: refusing to build argv with an unusable cwd (${bad}): ${cwd}`);
|
|
129
97
|
return [
|
|
130
98
|
"new-window",
|
|
@@ -154,7 +122,7 @@ export function resumeCall(
|
|
|
154
122
|
params: { cwd: string; runtimeArgs: readonly string[] },
|
|
155
123
|
env: NodeJS.ProcessEnv = process.env,
|
|
156
124
|
): ResumeCallResult {
|
|
157
|
-
const badCwd =
|
|
125
|
+
const badCwd = classifyTmuxCwd(params.cwd);
|
|
158
126
|
if (badCwd) return { ok: false, reason: badCwd };
|
|
159
127
|
|
|
160
128
|
let runtimePath: string;
|
|
@@ -807,8 +807,9 @@ console.log(`\n[gate-qualification] self-test: ${passed} checks passed`);
|
|
|
807
807
|
"bridge-boot-resume": 3,
|
|
808
808
|
"meta-facts": 4,
|
|
809
809
|
"meta-identity": 4,
|
|
810
|
+
"meta-retire": 3,
|
|
810
811
|
"mux-boundary": 14,
|
|
811
|
-
"mux-fresh-call":
|
|
812
|
+
"mux-fresh-call": 19,
|
|
812
813
|
"mux-launcher-fence": 7,
|
|
813
814
|
"mux-parent-artifact": 3,
|
|
814
815
|
"mux-resume-call": 12,
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
*
|
|
9
9
|
* Every cwd claim below is a MEASURED tmux 3.6a behaviour, not a precaution. They were taken on a
|
|
10
10
|
* private server on 2026-08-06, and each one is a way a resume would look successful while being
|
|
11
|
-
* wrong
|
|
11
|
+
* wrong. Since #73 the classification itself lives in the shared `classify-tmux-cwd.ts` leaf
|
|
12
|
+
* (fresh-call consumes the same rules), so the CWD claims here are asserted against that leaf:
|
|
12
13
|
*
|
|
13
14
|
* MUXRESUME-CWD-MISSING-REFUSED a nonexistent `-c` does NOT fail: tmux exits 0, opens the
|
|
14
15
|
* window, and the child lands in $HOME. Nothing downstream can
|
|
@@ -31,10 +32,10 @@ import fs from "node:fs";
|
|
|
31
32
|
import os from "node:os";
|
|
32
33
|
import path from "node:path";
|
|
33
34
|
import { fileURLToPath } from "node:url";
|
|
35
|
+
import { classifyTmuxCwd } from "../pi-extensions/lib/classify-tmux-cwd.ts";
|
|
34
36
|
import type { Placement } from "../pi-extensions/lib/mux-placement.ts";
|
|
35
37
|
import {
|
|
36
38
|
buildResumeCallArgs,
|
|
37
|
-
classifyResumeCwd,
|
|
38
39
|
RESUME_CALL_REJECT_HINT,
|
|
39
40
|
RESUME_CALL_RUNTIME,
|
|
40
41
|
type ResumeCallRejectReason,
|
|
@@ -77,32 +78,32 @@ function main(): void {
|
|
|
77
78
|
|
|
78
79
|
try {
|
|
79
80
|
// ── cwd classification ────────────────────────────────────────────────────────
|
|
80
|
-
ok("an existing absolute directory is accepted",
|
|
81
|
+
ok("an existing absolute directory is accepted", classifyTmuxCwd(realDir) === null);
|
|
81
82
|
|
|
82
83
|
ok(
|
|
83
84
|
"[QK:MUXRESUME-CWD-MISSING-REFUSED] a cwd that no longer exists is refused HERE, because tmux would not refuse it — measured: rc=0, the window opens, and the child silently falls back to $HOME, so the resume would land a visible citizen in the wrong project and look successful",
|
|
84
|
-
|
|
85
|
+
classifyTmuxCwd(path.join(tmp, "deleted-project")) === "cwd-missing",
|
|
85
86
|
);
|
|
86
87
|
ok(
|
|
87
88
|
"a path that exists but is a FILE is refused as its own cause, not as missing",
|
|
88
|
-
|
|
89
|
+
classifyTmuxCwd(filePath) === "cwd-not-directory",
|
|
89
90
|
);
|
|
90
91
|
ok(
|
|
91
92
|
"[QK:MUXRESUME-CWD-FORMAT-REFUSED] a cwd containing '#' is refused unrun — measured: tmux FORMAT-EXPANDS the -c value, so `<dir>/#{pane_id}` silently became `<dir>/%0` and a `#(…)` value was observed executing its command; a path is data and tmux reads it as a format",
|
|
92
|
-
|
|
93
|
-
|
|
93
|
+
classifyTmuxCwd(path.join(tmp, "#{pane_id}")) === "cwd-format-token" &&
|
|
94
|
+
classifyTmuxCwd(path.join(tmp, "#(touch x)")) === "cwd-format-token",
|
|
94
95
|
);
|
|
95
96
|
ok(
|
|
96
97
|
"the '#' refusal precedes the filesystem question, whose answer would be about a path tmux is not going to use",
|
|
97
|
-
|
|
98
|
+
classifyTmuxCwd(path.join(realDir, "#nope")) === "cwd-format-token",
|
|
98
99
|
);
|
|
99
100
|
ok(
|
|
100
101
|
"a relative cwd is refused before anything touches the filesystem",
|
|
101
|
-
|
|
102
|
+
classifyTmuxCwd("project") === "cwd-not-absolute",
|
|
102
103
|
);
|
|
103
104
|
ok(
|
|
104
105
|
"[QK:MUXRESUME-CWD-WHITESPACE-OK] a cwd containing whitespace is ACCEPTED — measured: argv is an array, tmux does not re-split it, and the directory arrived intact; inventing a quoting grammar here would refuse real project paths for a danger that was measured not to exist",
|
|
105
|
-
|
|
106
|
+
classifyTmuxCwd(spaceDir) === null,
|
|
106
107
|
);
|
|
107
108
|
|
|
108
109
|
// ── argv shape ────────────────────────────────────────────────────────────────
|
|
@@ -74,7 +74,6 @@ MANAGED_SETTINGS_SCALARS: list[tuple[str, list[str], Any]] = [
|
|
|
74
74
|
("promptSuggestionEnabled", ["promptSuggestionEnabled"], False),
|
|
75
75
|
("awaySummaryEnabled", ["awaySummaryEnabled"], False),
|
|
76
76
|
("autoMemoryEnabled", ["autoMemoryEnabled"], False),
|
|
77
|
-
("skipDangerousModePermissionPrompt", ["skipDangerousModePermissionPrompt"], True),
|
|
78
77
|
("verbose", ["verbose"], False),
|
|
79
78
|
("autoCompactEnabled", ["autoCompactEnabled"], False),
|
|
80
79
|
("showTurnDuration", ["showTurnDuration"], False),
|
|
@@ -84,6 +83,19 @@ MANAGED_SETTINGS_SCALARS: list[tuple[str, list[str], Any]] = [
|
|
|
84
83
|
("workflowKeywordTriggerEnabled", ["workflowKeywordTriggerEnabled"], False),
|
|
85
84
|
]
|
|
86
85
|
|
|
86
|
+
# Keys entwurf used to own but now returns to the operator. An old install-state
|
|
87
|
+
# entry is the only authority to undo our prior write; a bare matching value is
|
|
88
|
+
# not provenance and is never changed. apply() consumes each proven old entry
|
|
89
|
+
# exactly once, then uninstall can no longer restore over the operator's choice.
|
|
90
|
+
RETIRED_SETTINGS_SCALARS: list[tuple[str, list[str], Any]] = [
|
|
91
|
+
("skipDangerousModePermissionPrompt", ["skipDangerousModePermissionPrompt"], True),
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
_managed_scalar_names = {name for name, _path, _desired in MANAGED_SETTINGS_SCALARS}
|
|
95
|
+
_retired_scalar_names = {name for name, _path, _last in RETIRED_SETTINGS_SCALARS}
|
|
96
|
+
if overlap := _managed_scalar_names & _retired_scalar_names:
|
|
97
|
+
raise RuntimeError(f"settings scalar cannot be both managed and retired: {sorted(overlap)}")
|
|
98
|
+
|
|
87
99
|
|
|
88
100
|
class StateError(RuntimeError):
|
|
89
101
|
pass
|
|
@@ -391,6 +403,9 @@ def apply(repo: Path, asm: Path) -> None:
|
|
|
391
403
|
if not isinstance(root, dict):
|
|
392
404
|
die(f"{claude_root_config_path()} root must be a JSON object")
|
|
393
405
|
|
|
406
|
+
for name, path_, last_managed_value in RETIRED_SETTINGS_SCALARS:
|
|
407
|
+
relinquish_retired_scalar(state, settings, name, path_, last_managed_value)
|
|
408
|
+
|
|
394
409
|
set_nested(settings, ["enabledPlugins", PLUGIN_REF], True)
|
|
395
410
|
set_nested(settings, ["extraKnownMarketplaces", MARKETPLACE], desired_marketplace(asm))
|
|
396
411
|
for path_, desired in [(["permissions", "allow"], PERMISSION_ALLOW), (["permissions", "deny"], PERMISSION_DENY)]:
|
|
@@ -446,6 +461,53 @@ def restore_entry(obj: dict[str, Any], entry: dict[str, Any]) -> None:
|
|
|
446
461
|
die(f"unknown state entry kind: {kind}")
|
|
447
462
|
|
|
448
463
|
|
|
464
|
+
def settings_state_keys(state: dict[str, Any]) -> dict[str, Any]:
|
|
465
|
+
"""The settings key ledger, or a loud failure.
|
|
466
|
+
|
|
467
|
+
`load_state` validates only the envelope (schemaVersion/owner), so a consumer
|
|
468
|
+
that indexes straight into files.settings.keys turns a corrupt state file into
|
|
469
|
+
a bare KeyError traceback instead of an operator diagnostic. Ownership
|
|
470
|
+
decisions read this ledger, so an unreadable one must stop before any write.
|
|
471
|
+
"""
|
|
472
|
+
files = state.get("files")
|
|
473
|
+
entry = files.get("settings") if isinstance(files, dict) else None
|
|
474
|
+
keys = entry.get("keys") if isinstance(entry, dict) else None
|
|
475
|
+
if not isinstance(keys, dict):
|
|
476
|
+
die(f"install state {state_path()} has no files.settings.keys ledger; re-run install-meta-bridge")
|
|
477
|
+
return keys
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def relinquish_retired_scalar(
|
|
481
|
+
state: dict[str, Any], settings: dict[str, Any], name: str, path: list[str], last_managed_value: Any
|
|
482
|
+
) -> None:
|
|
483
|
+
"""Return one formerly managed scalar to operator ownership.
|
|
484
|
+
|
|
485
|
+
Only a preserved install-state entry proves entwurf wrote this path. If the
|
|
486
|
+
current value still has the exact JSON scalar type+value we last managed,
|
|
487
|
+
restore the snapshot. A changed or absent current value is already the
|
|
488
|
+
operator's and stays untouched. Malformed provenance fails before any file
|
|
489
|
+
write; silently discarding it would make a still-dangerous value look clean.
|
|
490
|
+
"""
|
|
491
|
+
keys = settings_state_keys(state)
|
|
492
|
+
entry = keys.get(name)
|
|
493
|
+
if entry is None:
|
|
494
|
+
return
|
|
495
|
+
if not isinstance(entry, dict) or entry.get("kind") != "scalar" or entry.get("path") != path:
|
|
496
|
+
die(f"retired scalar state entry {name} is malformed; refusing to discard ownership evidence")
|
|
497
|
+
original = entry.get("original")
|
|
498
|
+
if (
|
|
499
|
+
not isinstance(original, dict)
|
|
500
|
+
or set(original) != {"existed", "value"}
|
|
501
|
+
or type(original.get("existed")) is not bool
|
|
502
|
+
):
|
|
503
|
+
die(f"retired scalar state entry {name} has malformed original; refusing to guess")
|
|
504
|
+
|
|
505
|
+
existed, value = get_nested(settings, path)
|
|
506
|
+
if existed and type(value) is type(last_managed_value) and value == last_managed_value:
|
|
507
|
+
restore_entry(settings, entry)
|
|
508
|
+
keys.pop(name)
|
|
509
|
+
|
|
510
|
+
|
|
449
511
|
def preflight_uninstall() -> None:
|
|
450
512
|
load_state(required=True)
|
|
451
513
|
print(f"[meta-bridge-state] uninstall preflight ok ({state_path()})")
|
|
@@ -536,6 +598,18 @@ def check(repo: Path, asm: Path) -> None:
|
|
|
536
598
|
if recorded_asm
|
|
537
599
|
else desired_marketplace(asm)
|
|
538
600
|
)
|
|
601
|
+
state_settings_keys = settings_state_keys(state)
|
|
602
|
+
for name, path_, last_managed_value in RETIRED_SETTINGS_SCALARS:
|
|
603
|
+
if name in state_settings_keys:
|
|
604
|
+
failures.append(f"install-state still owns retired scalar {name}; re-run install-meta-bridge to relinquish it")
|
|
605
|
+
continue
|
|
606
|
+
existed, value = get_nested(settings, path_)
|
|
607
|
+
if existed and type(value) is type(last_managed_value) and value == last_managed_value:
|
|
608
|
+
print(
|
|
609
|
+
f"NOTE: settings {name}={json.dumps(last_managed_value)} is operator-owned; "
|
|
610
|
+
f"entwurf no longer suppresses or restores this warning choice"
|
|
611
|
+
)
|
|
612
|
+
|
|
539
613
|
checks = [
|
|
540
614
|
(["enabledPlugins", PLUGIN_REF], True, "enabled plugin"),
|
|
541
615
|
(["extraKnownMarketplaces", MARKETPLACE], marketplace_expected, "known marketplace"),
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": 1,
|
|
3
|
+
"lane": "meta-retire",
|
|
4
|
+
"mutants": [
|
|
5
|
+
{
|
|
6
|
+
"claim": "META-RETIRE-COMPARE-TYPE",
|
|
7
|
+
"title": "comparing the current value without its JSON scalar type lets a truthy 1 pass as the true entwurf wrote, so relinquishment overwrites a value that was never ours (#71)",
|
|
8
|
+
"subject": "scripts/meta-bridge-state.py",
|
|
9
|
+
"find": [
|
|
10
|
+
" existed, value = get_nested(settings, path)",
|
|
11
|
+
" if existed and type(value) is type(last_managed_value) and value == last_managed_value:"
|
|
12
|
+
],
|
|
13
|
+
"replace": [" existed, value = get_nested(settings, path)", " if existed and value == last_managed_value:"],
|
|
14
|
+
"gate": ["bash", "run.sh", "smoke-meta-install-state"],
|
|
15
|
+
"timeoutSeconds": 300,
|
|
16
|
+
"signature": "[QK:META-RETIRE-COMPARE-TYPE]",
|
|
17
|
+
"signatureSource": "scripts/smoke-meta-install-state.sh"
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"claim": "META-RETIRE-MALFORMED-SILENT",
|
|
21
|
+
"title": "skipping a malformed retired entry instead of failing discards the only evidence that entwurf owns the key, leaving a still-dangerous value looking operator-clean (#71)",
|
|
22
|
+
"subject": "scripts/meta-bridge-state.py",
|
|
23
|
+
"find": [
|
|
24
|
+
" die(f\"retired scalar state entry {name} is malformed; refusing to discard ownership evidence\")"
|
|
25
|
+
],
|
|
26
|
+
"replace": [" return"],
|
|
27
|
+
"gate": ["bash", "run.sh", "smoke-meta-install-state"],
|
|
28
|
+
"timeoutSeconds": 300,
|
|
29
|
+
"signature": "[QK:META-RETIRE-MALFORMED-SILENT]",
|
|
30
|
+
"signatureSource": "scripts/smoke-meta-install-state.sh"
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"claim": "META-RETIRE-FRESH-TOUCH",
|
|
34
|
+
"title": "synthesising provenance when install-state carries none lets relinquishment rewrite a value the operator set themselves, the exact ownership violation #71 exists to stop",
|
|
35
|
+
"subject": "scripts/meta-bridge-state.py",
|
|
36
|
+
"find": [" if entry is None:", " return"],
|
|
37
|
+
"replace": [
|
|
38
|
+
" if entry is None:",
|
|
39
|
+
" entry = {\"kind\": \"scalar\", \"path\": path, \"original\": {\"existed\": False, \"value\": None}}"
|
|
40
|
+
],
|
|
41
|
+
"gate": ["bash", "run.sh", "smoke-meta-install-state"],
|
|
42
|
+
"timeoutSeconds": 300,
|
|
43
|
+
"signature": "[QK:META-RETIRE-FRESH-TOUCH]",
|
|
44
|
+
"signatureSource": "scripts/smoke-meta-install-state.sh"
|
|
45
|
+
}
|
|
46
|
+
]
|
|
47
|
+
}
|
|
@@ -78,15 +78,59 @@
|
|
|
78
78
|
"claim": "FRESHCALL-RECEIPT-WITHOUT-CORRELATION",
|
|
79
79
|
"title": "the launch receipt grows a sibling garden-id field, making an async correlation look like a synchronous return",
|
|
80
80
|
"subject": "pi-extensions/lib/mux-fresh-call.ts",
|
|
81
|
-
"find": ["\
|
|
82
|
-
"replace": [
|
|
83
|
-
"\tbackend: FreshCallBackend;\n\tmodel: string;\n\truntimePath: string;\n\tnonce: string;\n\tgardenId?: string;\n}"
|
|
84
|
-
],
|
|
81
|
+
"find": ["\truntimePath: string;\n\tnonce: string;\n}"],
|
|
82
|
+
"replace": ["\truntimePath: string;\n\tnonce: string;\n\tgardenId?: string;\n}"],
|
|
85
83
|
"gate": ["bash", "run.sh", "check-mux-fresh-call"],
|
|
86
84
|
"timeoutSeconds": 120,
|
|
87
85
|
"signature": "[QK:FRESHCALL-RECEIPT-WITHOUT-CORRELATION]",
|
|
88
86
|
"signatureSource": "test/mux-fresh-call.test.ts"
|
|
89
87
|
},
|
|
88
|
+
{
|
|
89
|
+
"claim": "FRESHCALL-CWD-ARGV",
|
|
90
|
+
"title": "the requested cwd stops reaching tmux, so a cross-repo fresh sibling silently opens in the CALLER's repo and the launch looks successful",
|
|
91
|
+
"subject": "pi-extensions/lib/mux-fresh-call.ts",
|
|
92
|
+
"find": ["\t\t...(cwd === undefined ? [] : [\"-c\", cwd]),\n"],
|
|
93
|
+
"replace": [""],
|
|
94
|
+
"gate": ["bash", "run.sh", "check-mux-fresh-call"],
|
|
95
|
+
"timeoutSeconds": 120,
|
|
96
|
+
"signature": "[QK:FRESHCALL-CWD-ARGV]",
|
|
97
|
+
"signatureSource": "test/mux-fresh-call.test.ts"
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
"claim": "FRESHCALL-CWD-REFUSED-PREMUTATION",
|
|
101
|
+
"title": "the pre-mutation cwd classification is ignored, so a relative/'#'/deleted/file path rides on toward tmux instead of refusing by name",
|
|
102
|
+
"subject": "pi-extensions/lib/mux-fresh-call.ts",
|
|
103
|
+
"find": ["\t\tif (badCwd) return { ok: false, reason: badCwd };"],
|
|
104
|
+
"replace": ["\t\tvoid badCwd;"],
|
|
105
|
+
"gate": ["bash", "run.sh", "check-mux-fresh-call"],
|
|
106
|
+
"timeoutSeconds": 120,
|
|
107
|
+
"signature": "[QK:FRESHCALL-CWD-REFUSED-PREMUTATION]",
|
|
108
|
+
"signatureSource": "test/mux-fresh-call.test.ts"
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
"claim": "FRESHCALL-CWD-RECEIPT-REQUESTED",
|
|
112
|
+
"title": "production freshCall stops assembling the requested cwd into its receipt, so the operator-visible answer silently loses the one placement fact the caller asked for",
|
|
113
|
+
"subject": "pi-extensions/lib/mux-fresh-call.ts",
|
|
114
|
+
"find": ["\t\t\t...(cwd === undefined ? {} : { cwd }),\n"],
|
|
115
|
+
"replace": [""],
|
|
116
|
+
"gate": ["bash", "run.sh", "check-mux-fresh-call"],
|
|
117
|
+
"timeoutSeconds": 120,
|
|
118
|
+
"signature": "[QK:FRESHCALL-CWD-RECEIPT-REQUESTED]",
|
|
119
|
+
"signatureSource": "test/mux-fresh-call.test.ts"
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
"claim": "FRESHCALL-CWD-SURFACE-PARITY",
|
|
123
|
+
"title": "one surface drops the optional cwd from its schema, so cross-repo fresh works from pi and silently cannot exist from the MCP bridge",
|
|
124
|
+
"subject": "mcp/entwurf-bridge/src/index.ts",
|
|
125
|
+
"find": [
|
|
126
|
+
"\t\tcwd: z\n\t\t\t.string()\n\t\t\t.optional()\n\t\t\t.describe(\n\t\t\t\t\"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.\",\n\t\t\t),\n"
|
|
127
|
+
],
|
|
128
|
+
"replace": [""],
|
|
129
|
+
"gate": ["bash", "run.sh", "check-mux-fresh-call"],
|
|
130
|
+
"timeoutSeconds": 120,
|
|
131
|
+
"signature": "[QK:FRESHCALL-CWD-SURFACE-PARITY]",
|
|
132
|
+
"signatureSource": "test/fresh-call-surfaces.contract.test.ts"
|
|
133
|
+
},
|
|
90
134
|
{
|
|
91
135
|
"claim": "FRESHCALL-PI-SURFACE-IDENTITY",
|
|
92
136
|
"title": "native pi takes the caller garden id from the environment instead of its own resident closure — the uuidv7 confusion, reintroduced",
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
{
|
|
6
6
|
"claim": "MUXRESUME-CWD-MISSING-REFUSED",
|
|
7
7
|
"title": "a deleted recorded cwd is accepted — tmux then opens the window in $HOME and the wrong-project resume looks successful",
|
|
8
|
-
"subject": "pi-extensions/lib/
|
|
8
|
+
"subject": "pi-extensions/lib/classify-tmux-cwd.ts",
|
|
9
9
|
"find": ["\t\treturn \"cwd-missing\";"],
|
|
10
10
|
"replace": ["\t\treturn null;"],
|
|
11
11
|
"gate": ["bash", "run.sh", "check-mux-resume-call"],
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
{
|
|
17
17
|
"claim": "MUXRESUME-CWD-FORMAT-REFUSED",
|
|
18
18
|
"title": "the '#' refusal stops matching, so tmux format-expands the path it was handed",
|
|
19
|
-
"subject": "pi-extensions/lib/
|
|
19
|
+
"subject": "pi-extensions/lib/classify-tmux-cwd.ts",
|
|
20
20
|
"find": ["\tif (cwd.includes(\"#\")) return \"cwd-format-token\";"],
|
|
21
21
|
"replace": ["\tif (cwd.includes(\"\\u0000\")) return \"cwd-format-token\";"],
|
|
22
22
|
"gate": ["bash", "run.sh", "check-mux-resume-call"],
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
{
|
|
28
28
|
"claim": "MUXRESUME-CWD-WHITESPACE-OK",
|
|
29
29
|
"title": "a quoting fear is added back as a whitespace refusal, which would reject real project directories tmux handles fine",
|
|
30
|
-
"subject": "pi-extensions/lib/
|
|
30
|
+
"subject": "pi-extensions/lib/classify-tmux-cwd.ts",
|
|
31
31
|
"find": ["\tif (cwd.includes(\"#\")) return \"cwd-format-token\";"],
|
|
32
32
|
"replace": ["\tif (cwd.includes(\"#\") || /\\s/.test(cwd)) return \"cwd-format-token\";"],
|
|
33
33
|
"gate": ["bash", "run.sh", "check-mux-resume-call"],
|