@junghanacs/entwurf 0.18.2 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +179 -0
- package/README.md +3 -3
- package/VERIFY.md +1 -1
- package/docs/external-mcp-host.md +2 -2
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +18 -7
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +76 -9
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-placement.js +54 -6
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/resolve-tmux-session.js +114 -0
- package/mcp/entwurf-bridge/src/index.ts +22 -7
- package/package.json +1 -1
- package/pi-extensions/entwurf-control.ts +27 -3
- package/pi-extensions/lib/mux-fresh-call.ts +98 -11
- package/pi-extensions/lib/mux-placement.ts +65 -10
- package/pi-extensions/lib/resolve-tmux-session.ts +137 -0
- package/scripts/check-fresh-cut-gate.sh +54 -2
- package/scripts/check-gate-qualification.ts +4 -4
- package/scripts/check-mux-launch-tmux.ts +77 -2
- package/scripts/check-mux-launch.ts +17 -0
- package/scripts/check-mux-placement-tmux.ts +61 -1
- package/scripts/check-mux-placement.ts +33 -0
- package/scripts/mutants/fresh-cut.json +26 -0
- package/scripts/mutants/mux-boundary.json +24 -0
- package/scripts/mutants/mux-fresh-call.json +81 -0
- package/scripts/mutants/omp-birth.json +16 -3
- package/scripts/omp-bridge-doctor.sh +11 -1
- package/scripts/smoke-mux-fresh-call-live.ts +175 -3
- package/scripts/smoke-omp-bridge-state.sh +5 -2
|
@@ -8,11 +8,17 @@
|
|
|
8
8
|
* session the operator is looking at. Inheriting the environment IS the addressing: this
|
|
9
9
|
* module never takes a socket path or a session name from a caller.
|
|
10
10
|
*
|
|
11
|
+
* That sentence is still exactly true of THIS leaf, and #105 is where it started to be worth
|
|
12
|
+
* saying precisely. The fresh-call composition above may now be handed one session NAME by its
|
|
13
|
+
* caller, but it resolves that name to a native `$id` through its own leaf before anything
|
|
14
|
+
* reaches here — `appendWindow` still appends only to the caller's own session, and no function
|
|
15
|
+
* in this file has ever seen or will see a session name.
|
|
16
|
+
*
|
|
11
17
|
* Three actions, deliberately not four:
|
|
12
18
|
*
|
|
13
19
|
* inspectPlacement() the caller's own $session/@window/%pane, or a named refusal
|
|
14
20
|
* appendWindow() one default-shell window at the end of that same session
|
|
15
|
-
* closeWindow() that window, by stable id,
|
|
21
|
+
* closeWindow() that window, by stable id, on the server it was opened on
|
|
16
22
|
*
|
|
17
23
|
* What this module is NOT: a delivery transport, an address, a liveness fact, a launcher.
|
|
18
24
|
* It opens a place. `entwurf_v2` still owns delivery (`V2-DELIVERY-EXCLUDES-MUX`), and
|
|
@@ -51,8 +57,20 @@
|
|
|
51
57
|
* 4. BINDING. A `Placement` is a fact about one server and one session, and the environment
|
|
52
58
|
* passed to a later call could name a different — or restarted — server where the same
|
|
53
59
|
* `$3`/`@7` mean something else entirely. Every mutation re-reads the caller's placement
|
|
54
|
-
*
|
|
55
|
-
*
|
|
60
|
+
* before it runs.
|
|
61
|
+
*
|
|
62
|
+
* The two mutations bind to DIFFERENT halves of that fact, because they are answering
|
|
63
|
+
* different questions. `appendWindow` asks "is the target the caller gave me still the
|
|
64
|
+
* caller's own seat?" and needs both halves — `isSameContext`. `closeWindow` asks "is this
|
|
65
|
+
* handle still the window it was born as?" and needs only the SERVER half — a `@id` is
|
|
66
|
+
* unique for the life of one server, so the session half adds nothing there. `[측정
|
|
67
|
+
* 2026-09-07, private server]` ids are handed out monotonically and are never recycled:
|
|
68
|
+
* after `@2` was killed the next window was `@3`, and after a whole session holding `@4`
|
|
69
|
+
* and `@5` was killed the next was `@6`. Requiring the handle's session to still exist
|
|
70
|
+
* would also break the more informative answer below — a window whose session is gone is
|
|
71
|
+
* `already-gone`, which is a fact the caller wants, not an error. Since #105 a launched
|
|
72
|
+
* window may legitimately live in a session that is not the caller's, and that is exactly
|
|
73
|
+
* when this distinction stops being academic.
|
|
56
74
|
*
|
|
57
75
|
* The machine-readable rows carry ONLY native ids and decimal numbers, joined by `|`. The
|
|
58
76
|
* free-form fields tmux could also report (`socket_path`, `session_name`) are deliberately
|
|
@@ -124,10 +142,13 @@ export interface Placement {
|
|
|
124
142
|
}
|
|
125
143
|
|
|
126
144
|
/**
|
|
127
|
-
* A window this module opened, carrying the context it was born in. `serverPid
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
* a
|
|
145
|
+
* A window this module opened, carrying the context it was born in. `serverPid` is what a later
|
|
146
|
+
* close binds to: a restarted tmux server hands out `@7` again, so without it a close could act
|
|
147
|
+
* on some other server's window. `sessionId` rides along as the RECEIPT of where the window was
|
|
148
|
+
* put — since #105 that may be a session other than the caller's — and it is deliberately not a
|
|
149
|
+
* close precondition, because `@id`s are unique for one server's whole life (measured: after
|
|
150
|
+
* `@2` was killed the next window was `@3`, and after a session holding `@4`/`@5` was killed the
|
|
151
|
+
* next was `@6`). Index is reported for the human, never used as a handle.
|
|
131
152
|
*/
|
|
132
153
|
export interface WindowHandle {
|
|
133
154
|
serverPid: string;
|
|
@@ -358,6 +379,40 @@ export function requireSameContext(label: string, origin: PlacementContext, env:
|
|
|
358
379
|
}
|
|
359
380
|
}
|
|
360
381
|
|
|
382
|
+
/**
|
|
383
|
+
* Same server, whatever the session. The close-side half of boundary 4, kept as its own pure
|
|
384
|
+
* predicate for the same reason `isSameContext` is one: the decision is what a deterministic
|
|
385
|
+
* gate can pin, and the re-read around it is not.
|
|
386
|
+
*/
|
|
387
|
+
export function isSameServer(origin: PlacementContext, now: PlacementContext): boolean {
|
|
388
|
+
return origin.serverPid === now.serverPid;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Re-read the caller's placement and refuse unless the handle was born on the SAME SERVER. The
|
|
393
|
+
* close-side half of boundary 4, and deliberately NOT `requireSameContext`: since #105 a handle
|
|
394
|
+
* can name a window in a session that is not the caller's, and the session half would refuse a
|
|
395
|
+
* legitimate close. What it must still refuse is a handle from a DIFFERENT — or restarted —
|
|
396
|
+
* server, where `@7` names some other window entirely.
|
|
397
|
+
*
|
|
398
|
+
* The session half is not lost, it is covered better: `@id`s are unique for one server's whole
|
|
399
|
+
* life (measured — see boundary 4), so on a matching server the id alone identifies the window,
|
|
400
|
+
* and a window whose session has since been killed is proven absent by `closeWindow`'s own
|
|
401
|
+
* `list-windows -a` read and reported as `already-gone`.
|
|
402
|
+
*/
|
|
403
|
+
export function requireSameServer(label: string, origin: PlacementContext, env: NodeJS.ProcessEnv): void {
|
|
404
|
+
const now = inspectPlacement(env);
|
|
405
|
+
if (!now.ok) {
|
|
406
|
+
throw new Error(`mux-placement: ${label} refused — the caller's placement is not resolvable (${now.reason})`);
|
|
407
|
+
}
|
|
408
|
+
if (!isSameServer(origin, now.placement)) {
|
|
409
|
+
throw new Error(
|
|
410
|
+
`mux-placement: ${label} refused — server changed (handle was born on server ${origin.serverPid}, ` +
|
|
411
|
+
`this environment names server ${now.placement.serverPid})`,
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
361
416
|
/** One detached default-shell window at the end of the caller's own session. */
|
|
362
417
|
export function appendWindow(placement: Placement, env: NodeJS.ProcessEnv = process.env): WindowHandle {
|
|
363
418
|
requireSameContext("appendWindow", placement, env);
|
|
@@ -367,11 +422,11 @@ export function appendWindow(placement: Placement, env: NodeJS.ProcessEnv = proc
|
|
|
367
422
|
}
|
|
368
423
|
|
|
369
424
|
/**
|
|
370
|
-
* Close one window by stable id,
|
|
371
|
-
*
|
|
425
|
+
* Close one window by stable id, on the server it was opened on. Reports whether it was closed
|
|
426
|
+
* or had already gone. The binding is the server, not the session — see `requireSameServer`.
|
|
372
427
|
*/
|
|
373
428
|
export function closeWindow(handle: WindowHandle, env: NodeJS.ProcessEnv = process.env): CloseOutcome {
|
|
374
|
-
|
|
429
|
+
requireSameServer("closeWindow", handle, env);
|
|
375
430
|
const run = runTmux(buildCloseArgs(handle.windowId), env);
|
|
376
431
|
if (run.status === 0) return "closed";
|
|
377
432
|
// A signal kill is not a "tmux said no" — it is the call failing, and it carries no
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* resolve-tmux-session — the ONE resolution of a caller-supplied tmux SESSION NAME into the
|
|
3
|
+
* native `$id` a placement may target. Narrow leaf of the fresh-call composition (#105); it
|
|
4
|
+
* owns the name grammar and the name→id lookup and NOTHING else — it never runs tmux (the
|
|
5
|
+
* runner is injected), never phrases a hint (each consumer owns its own wording), never
|
|
6
|
+
* creates a session, and has no fallback session.
|
|
7
|
+
*
|
|
8
|
+
* Same shape and same discipline as `classify-tmux-cwd.ts`: this file imports nothing at all,
|
|
9
|
+
* not even a node builtin, so it stays deletable on its own and cannot acquire an opinion
|
|
10
|
+
* about mux, entwurf, identity or delivery. The injected runner is matched STRUCTURALLY to
|
|
11
|
+
* `mux-placement.TmuxRun` rather than by a type import, for the same reason.
|
|
12
|
+
*
|
|
13
|
+
* Every rule below is a MEASURED tmux 3.6a behaviour (2026-09-07, private `-S` servers; the
|
|
14
|
+
* research lane's two independent reproductions are in issue #105's thread), and each one is a
|
|
15
|
+
* way a lookup would look successful while addressing the wrong thing:
|
|
16
|
+
*
|
|
17
|
+
* 1. `-t '=NAME'` performs NO format expansion — `=a}b`, `=a|b`, `=a b`, `=a,b` all resolve
|
|
18
|
+
* exactly. The `-f '#{==:#{session_name},NAME}'` FILTER engine does the opposite: a `}`
|
|
19
|
+
* inside the name closes the comparison early, the filter becomes a truthy string, and
|
|
20
|
+
* EVERY session matches (6/6 measured). So the engine here is `-t '='`, never a filter.
|
|
21
|
+
* 2. absence is reported by EXIT CODE, not by output. `list-windows -t '=nosuch'` exits 1
|
|
22
|
+
* with `can't find session: nosuch`, while `display-message -p -t '=NAME'` exits 0 with
|
|
23
|
+
* EMPTY output for a name that exists AND for one that does not — it is unusable as a
|
|
24
|
+
* probe. Only an rc=0 answer is ever parsed here.
|
|
25
|
+
* 3. some names cannot be addressed at all, for two different measured reasons. `#` is
|
|
26
|
+
* FORMAT-EXPANDED when `new-session -s` stores it (`a#{x}` stored as `a`), so the
|
|
27
|
+
* requested name never exists; and `.`/`:` are tmux's own PANE/WINDOW separators inside a
|
|
28
|
+
* `-t` target, so the requested name is SPLIT before any session is matched. Measured
|
|
29
|
+
* with this leaf's own engine: `list-windows -t '=my.project'` → rc=1
|
|
30
|
+
* `can't find pane: project`, and `list-windows -t '=my:project'` → rc=1
|
|
31
|
+
* `can't find session: my`. The exact wording is NOT contract — it moves with which
|
|
32
|
+
* sessions happen to exist (`can't find window: project` once a session `my` is there) —
|
|
33
|
+
* the fact is that the split happens at all. Both characters are ALSO normalised to `_`
|
|
34
|
+
* when stored, so `a.b` and `a:b` are the same stored name `a_b` and whichever is created
|
|
35
|
+
* second is a `duplicate session` error rather than a second seat. The lookup half is the
|
|
36
|
+
* load-bearing one: whatever tmux stored, the REQUESTED name can never address it.
|
|
37
|
+
* NOTE the difference from `classify-tmux-cwd.ts`: there `#(…)`
|
|
38
|
+
* was observed EXECUTING inside a `-c` value; here it expands but does NOT execute (a
|
|
39
|
+
* `q#(touch …)q` name stored as `qq` and wrote no file). Do not copy that leaf's
|
|
40
|
+
* rationale into this one, or relaxing one will silently relax the other.
|
|
41
|
+
* 4. `=` protects a name against `%9`/`@1` id syntax but NOT against `$`: with a session
|
|
42
|
+
* NAMED `$0` beside one whose ID is `$0`, `-t '=$0'` resolves the ID (measured — the
|
|
43
|
+
* name-holder was `$1` and the lookup returned `$0`). And a session literally named
|
|
44
|
+
* `=foo` needs `==foo`. An escaping layer here would be a second parser to keep true.
|
|
45
|
+
*
|
|
46
|
+
* The grammar is `^[A-Za-z0-9][A-Za-z0-9_-]*$`, and it is WIDER than what rules 3-4 force. Say
|
|
47
|
+
* that plainly rather than letting the reasons above cover the whole refusal set: `a}b`, `a|b`,
|
|
48
|
+
* `a b`, `a;b`, `a,b` and `_a` are all created verbatim AND resolved exactly by `-t '=NAME'`
|
|
49
|
+
* (measured 2026-09-07, one server, six sessions, six exact ids). They are refused anyway, and
|
|
50
|
+
* the reason is a DECISION, not a tmux limit — this is the grammar entwurf would need to CREATE
|
|
51
|
+
* a session safely, kept symmetric for lookup, with the remaining foreign-name width closed
|
|
52
|
+
* until an operator need for it is actually observed. That is why the refusal is
|
|
53
|
+
* `tmux-session-name-invalid` ("this rail does not address that shape") and not
|
|
54
|
+
* `tmux-session-missing` ("no such session here"): the caller's repair differs, and telling an
|
|
55
|
+
* operator their perfectly findable session "could never be found" would be a false cause.
|
|
56
|
+
*
|
|
57
|
+
* ONE BOUNDED IMPRECISION, STATED RATHER THAN LAUNDERED: rc≠0 also covers "no server running
|
|
58
|
+
* on <socket>" (measured). This leaf reads every rc≠0 as `tmux-session-missing`, so a server
|
|
59
|
+
* that died between the caller's context proof and this lookup is reported under the narrower
|
|
60
|
+
* word. That is safe — both readings are refusals that mutate nothing, and the consumer's hint
|
|
61
|
+
* names both — and it is preferred over matching tmux's own stderr text, which would pin this
|
|
62
|
+
* leaf to one vendor version's wording.
|
|
63
|
+
*/
|
|
64
|
+
|
|
65
|
+
/** Why a caller-named session could not become a target. Two stable literals — the consuming
|
|
66
|
+
* composition widens its own reject union with this type, so the strings are contract. */
|
|
67
|
+
export type TmuxSessionRejectReason = "tmux-session-name-invalid" | "tmux-session-missing";
|
|
68
|
+
|
|
69
|
+
/** What the injected runner returns. Structurally identical to `mux-placement.TmuxRun`; kept
|
|
70
|
+
* as its own declaration so this leaf imports nothing. */
|
|
71
|
+
export interface TmuxSessionLookupRun {
|
|
72
|
+
status: number | null;
|
|
73
|
+
stdout: string;
|
|
74
|
+
stderr: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export type TmuxSessionLookupResult = { ok: true; sessionId: string } | { ok: false; reason: TmuxSessionRejectReason };
|
|
78
|
+
|
|
79
|
+
const SESSION_NAME = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
|
|
80
|
+
const SESSION_ID = /^\$[0-9]+$/;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Classify a candidate session NAME. Separate from the lookup because the operator's next move
|
|
84
|
+
* differs: an out-of-grammar name is a shape this rail does not address — some of those tmux
|
|
85
|
+
* genuinely cannot resolve (rules 3-4), others it resolves fine and this rail declines anyway —
|
|
86
|
+
* while a missing session is something the operator creates and retries.
|
|
87
|
+
*/
|
|
88
|
+
export function classifyTmuxSessionName(name: string): "tmux-session-name-invalid" | null {
|
|
89
|
+
return SESSION_NAME.test(name) ? null : "tmux-session-name-invalid";
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The lookup argv. `-t '=NAME'` is the exact-name selector; `list-windows` is the engine that
|
|
93
|
+
* answers with an exit code for absence. The builder re-validates rather than trusting its
|
|
94
|
+
* caller, the same way the fresh-call argv builder re-checks a cwd. */
|
|
95
|
+
export function buildTmuxSessionLookupArgs(name: string): string[] {
|
|
96
|
+
if (classifyTmuxSessionName(name)) {
|
|
97
|
+
throw new Error(`resolve-tmux-session: refusing to build a lookup for an unresolvable session name: ${name}`);
|
|
98
|
+
}
|
|
99
|
+
return ["list-windows", "-t", `=${name}`, "-F", "#{session_id}"];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Resolve a session name to its native `$id` on whatever server the runner's environment names.
|
|
104
|
+
* The id is the ONLY thing a caller-supplied name is allowed to become: everything downstream
|
|
105
|
+
* targets `$id`, never the name again.
|
|
106
|
+
*
|
|
107
|
+
* A session always holds at least one window, so an rc=0 answer prints one `$id` line per
|
|
108
|
+
* window and they are all the same id. An rc=0 that cannot be read that way is an operational
|
|
109
|
+
* anomaly, not an answer about the session, and is raised rather than turned into a refusal.
|
|
110
|
+
*/
|
|
111
|
+
export function resolveTmuxSessionId(
|
|
112
|
+
name: string,
|
|
113
|
+
run: (args: string[]) => TmuxSessionLookupRun,
|
|
114
|
+
): TmuxSessionLookupResult {
|
|
115
|
+
const badName = classifyTmuxSessionName(name);
|
|
116
|
+
if (badName) return { ok: false, reason: badName };
|
|
117
|
+
const result = run(buildTmuxSessionLookupArgs(name));
|
|
118
|
+
// A signalled call is not tmux answering — it carries no information about the session at
|
|
119
|
+
// all, so it must never be read as absence.
|
|
120
|
+
if (result.status === null) {
|
|
121
|
+
throw new Error(
|
|
122
|
+
`resolve-tmux-session: the lookup for session "${name}" was killed by a signal: ${result.stderr.trim()}`,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
if (result.status !== 0) return { ok: false, reason: "tmux-session-missing" };
|
|
126
|
+
const lines = result.stdout
|
|
127
|
+
.split("\n")
|
|
128
|
+
.map((line) => line.trim())
|
|
129
|
+
.filter((line) => line.length > 0);
|
|
130
|
+
const first = lines[0];
|
|
131
|
+
if (first === undefined || !SESSION_ID.test(first) || lines.some((line) => line !== first)) {
|
|
132
|
+
throw new Error(
|
|
133
|
+
`resolve-tmux-session: tmux answered rc=0 for session "${name}" with something that is not one native session id: ${JSON.stringify(result.stdout)}`,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
return { ok: true, sessionId: first };
|
|
137
|
+
}
|
|
@@ -403,12 +403,44 @@ fi
|
|
|
403
403
|
# #86 A5 credential tripwire: the retired sync_auth mutation (OAuth alias copy +
|
|
404
404
|
# auth.json.bak) must never resurrect as an invocable surface in run.sh. Comments
|
|
405
405
|
# documenting the removal are allowed; a function definition or call is not.
|
|
406
|
-
|
|
406
|
+
# THE PREDICATE IS A FUNCTION so the self-test below can run the SAME code against a planted
|
|
407
|
+
# fixture. A self-test that re-typed this pipeline would keep passing while the real one went
|
|
408
|
+
# blind, which is precisely the failure this cell exists to prevent.
|
|
409
|
+
#
|
|
410
|
+
# `grep -c`, never `grep -q`. This file runs under pipefail, and a `-q` grep exits at the FIRST
|
|
411
|
+
# match, closing the pipe while `awk` is still writing the remaining ~200KB of a 433KB run.sh.
|
|
412
|
+
# `awk` dies of SIGPIPE (141), pipefail promotes that to the pipeline's status, and the `if`
|
|
413
|
+
# takes the ELSE branch — so this tripwire went blind EXACTLY when a sync_auth surface existed
|
|
414
|
+
# and reported "no credential surface remains". Unlike the omp doctor's load-dependent race this
|
|
415
|
+
# one was deterministic, because the producer is far larger than the pipe buffer.
|
|
416
|
+
sync_auth_surface_count() {
|
|
417
|
+
awk '!/^[[:space:]]*#/' "$1" | grep -c 'sync_auth' || true
|
|
418
|
+
}
|
|
419
|
+
sync_auth_hits="$(sync_auth_surface_count "$REPO/run.sh")"
|
|
420
|
+
if [ "${sync_auth_hits:-0}" -gt 0 ]; then
|
|
407
421
|
bad "D4b the retired sync_auth credential mutation reappeared as code in run.sh"
|
|
408
422
|
else
|
|
409
423
|
ok "D4b no sync_auth credential surface remains in run.sh (comments only)"
|
|
410
424
|
fi
|
|
411
425
|
|
|
426
|
+
# D4b-self. The cell above is green on a clean run.sh — which is exactly the state in which a
|
|
427
|
+
# blind tripwire and a working one are indistinguishable. This plants the surface it exists to
|
|
428
|
+
# catch and requires the SAME predicate to see it. The plant goes NEAR THE TOP of the real,
|
|
429
|
+
# large run.sh on purpose: the defect only appears when the consumer can exit while the producer
|
|
430
|
+
# still has bulk left to write, so a small synthetic fixture would pass even with `grep -q` and
|
|
431
|
+
# would prove nothing. It is a copy under mktemp; run.sh is never written.
|
|
432
|
+
fc_probe="$(mktemp -t fresh-cut-syncauth-probe.XXXXXX)"
|
|
433
|
+
awk 'NR==2{print "sync_auth() { cp \"$HOME/.claude/.credentials.json\" \"$HOME/.claude/auth.json.bak\"; }"}1' \
|
|
434
|
+
"$REPO/run.sh" >"$fc_probe"
|
|
435
|
+
fc_planted="$(sync_auth_surface_count "$fc_probe")"
|
|
436
|
+
fc_clean="$(sync_auth_surface_count "$REPO/run.sh")"
|
|
437
|
+
rm -f "$fc_probe"
|
|
438
|
+
if [ "${fc_planted:-0}" -gt 0 ] && [ "${fc_clean:-0}" -eq 0 ]; then
|
|
439
|
+
ok "D4b-self the tripwire actually FIRES on a planted sync_auth surface (planted=$fc_planted clean=$fc_clean)"
|
|
440
|
+
else
|
|
441
|
+
bad "[QK:FRESHCUT-SYNCAUTH-TRIPWIRE-FIRES] D4b's predicate did not see a planted sync_auth surface (planted=$fc_planted clean=$fc_clean) — the credential tripwire is blind and a green D4b means nothing"
|
|
442
|
+
fi
|
|
443
|
+
|
|
412
444
|
ilp_gate=$(awk '/^install_local_package\(\)/,/^}/{ if ($0 ~ /preflight_v3_store install/) { print NR; exit } }' "$REPO/run.sh")
|
|
413
445
|
ilp_write=$(awk '/^install_local_package\(\)/,/^}/{ if ($0 ~ /register-pi-package.py|mkdir -p "\$project_dir/) { print NR; exit } }' "$REPO/run.sh")
|
|
414
446
|
if [ -n "$ilp_gate" ] && [ -n "$ilp_write" ] && [ "$ilp_gate" -lt "$ilp_write" ]; then
|
|
@@ -425,12 +457,32 @@ else
|
|
|
425
457
|
bad "D6 meta-bridge-install.sh's doctor call is missing or sits after the state snapshot (doctor=$mb_gate prepare=$mb_write)"
|
|
426
458
|
fi
|
|
427
459
|
|
|
428
|
-
|
|
460
|
+
# Same hazard as D4b. This producer happens to emit less than one pipe buffer today, so
|
|
461
|
+
# no SIGPIPE window exists — but that is a size coincidence, not a property, and it moves
|
|
462
|
+
# the moment run.sh grows another match. Counted rather than short-circuited.
|
|
463
|
+
preflight_cut_count() {
|
|
464
|
+
grep -n 'preflight_v3_store()' -A 40 "$1" | grep -cE 'run_ts scripts/meta-bridge-fresh-cut\.ts' || true
|
|
465
|
+
}
|
|
466
|
+
cut_in_preflight="$(preflight_cut_count "$REPO/run.sh")"
|
|
467
|
+
if [ "${cut_in_preflight:-0}" -gt 0 ]; then
|
|
429
468
|
bad "D7 the preflight invokes fresh-cut — an install must never cut a generation by itself"
|
|
430
469
|
else
|
|
431
470
|
ok "D7 the preflight only ever asks the doctor (never runs the cut)"
|
|
432
471
|
fi
|
|
433
472
|
|
|
473
|
+
# D7-self, same argument as D4b-self: a forbidding cell that is green on a clean tree proves
|
|
474
|
+
# nothing until it is shown catching the thing it forbids. A synthetic fixture is enough here
|
|
475
|
+
# because this producer has no SIGPIPE window to reproduce — what is being proven is detection.
|
|
476
|
+
d7_probe="$(mktemp -t fresh-cut-preflight-probe.XXXXXX)"
|
|
477
|
+
printf '%s\n' 'preflight_v3_store() {' ' run_ts scripts/meta-bridge-fresh-cut.ts' '}' >"$d7_probe"
|
|
478
|
+
d7_planted="$(preflight_cut_count "$d7_probe")"
|
|
479
|
+
rm -f "$d7_probe"
|
|
480
|
+
if [ "${d7_planted:-0}" -gt 0 ] && [ "${cut_in_preflight:-0}" -eq 0 ]; then
|
|
481
|
+
ok "D7-self the cell actually FIRES on a preflight that does call fresh-cut (planted=$d7_planted real=$cut_in_preflight)"
|
|
482
|
+
else
|
|
483
|
+
bad "[QK:FRESHCUT-PREFLIGHT-CUT-DETECTED] D7's predicate did not see a planted fresh-cut call in a preflight (planted=$d7_planted real=$cut_in_preflight)"
|
|
484
|
+
fi
|
|
485
|
+
|
|
434
486
|
if [ -e "$FRESH_CUT_GATE_CLAUDE_SENTINEL" ]; then
|
|
435
487
|
bad "D8 an offline gate drive crossed the store refusal and invoked Claude" "$(cat "$FRESH_CUT_GATE_CLAUDE_SENTINEL")"
|
|
436
488
|
else
|
|
@@ -834,20 +834,20 @@ let manifestCount: number;
|
|
|
834
834
|
"copilot-birth": 19,
|
|
835
835
|
"copilot-launch": 14,
|
|
836
836
|
"copilot-receive": 18,
|
|
837
|
-
"fresh-cut":
|
|
837
|
+
"fresh-cut": 3,
|
|
838
838
|
"gate-qualification": 2,
|
|
839
839
|
"meta-facts": 4,
|
|
840
840
|
"meta-hook-session-switch": 17,
|
|
841
841
|
"meta-identity": 4,
|
|
842
842
|
"meta-retire": 3,
|
|
843
|
-
"mux-boundary":
|
|
844
|
-
"mux-fresh-call":
|
|
843
|
+
"mux-boundary": 16,
|
|
844
|
+
"mux-fresh-call": 44,
|
|
845
845
|
"mux-launcher-fence": 7,
|
|
846
846
|
"mux-parent-artifact": 3,
|
|
847
847
|
"pack-install": 2,
|
|
848
848
|
"pi-package-ownership": 6,
|
|
849
849
|
"mux-resume-call": 12,
|
|
850
|
-
"omp-birth":
|
|
850
|
+
"omp-birth": 12,
|
|
851
851
|
"omp-fresh": 24,
|
|
852
852
|
"omp-receive": 11,
|
|
853
853
|
"probe-ordering": 1,
|
|
@@ -24,6 +24,9 @@
|
|
|
24
24
|
* is still listed on an immediate re-read — which is why no post-launch presence check is
|
|
25
25
|
* performed and why the precondition runs before the window exists
|
|
26
26
|
* - a precondition refusal opens NO window at all
|
|
27
|
+
* - the #105 seat, through the real `freshCall`: an absent seat refuses and creates nothing,
|
|
28
|
+
* an existing one puts the window in THAT session with a receipt naming the resolved
|
|
29
|
+
* target, and the resulting handle closes through `closeWindow` from outside that session
|
|
27
30
|
*/
|
|
28
31
|
|
|
29
32
|
import assert from "node:assert/strict";
|
|
@@ -97,7 +100,7 @@ function alive(pid: string): boolean {
|
|
|
97
100
|
}
|
|
98
101
|
}
|
|
99
102
|
|
|
100
|
-
function main(): void {
|
|
103
|
+
async function main(): Promise<void> {
|
|
101
104
|
if (spawnSync("tmux", ["-V"], { encoding: "utf8" }).status !== 0) {
|
|
102
105
|
skipLive(LABEL, "tmux is not installed — install tmux to run the launch acceptance");
|
|
103
106
|
}
|
|
@@ -298,6 +301,78 @@ function main(): void {
|
|
|
298
301
|
originalPanes.every((p) => panes().includes(p)),
|
|
299
302
|
);
|
|
300
303
|
ok("restored: focus never moved", windows().find((w) => w.endsWith("|1")) === activeBefore);
|
|
304
|
+
|
|
305
|
+
// ── the fresh-call composition, against a real server (#105) ──────────────────
|
|
306
|
+
// The seat's deterministic gate can prove the argv, the leaf and the refusals, but the
|
|
307
|
+
// two things that only exist once tmux has answered — WHICH session the window landed
|
|
308
|
+
// in, and what the receipt says about it — had no oracle independent of the production
|
|
309
|
+
// source. This cell is that oracle: the same hermetic runtime above, a SECOND session
|
|
310
|
+
// on this same private server, and the real `freshCall`, read through its return value
|
|
311
|
+
// and the server's own inventory.
|
|
312
|
+
{
|
|
313
|
+
const { freshCall } = await import("../pi-extensions/lib/mux-fresh-call.ts");
|
|
314
|
+
const { closeWindow } = await import("../pi-extensions/lib/mux-placement.ts");
|
|
315
|
+
const call = (placementInput?: { tmuxSession: string }) =>
|
|
316
|
+
freshCall(
|
|
317
|
+
{
|
|
318
|
+
backend: "pi",
|
|
319
|
+
model: "fixture/model",
|
|
320
|
+
task: "fixture task",
|
|
321
|
+
placement: placementInput,
|
|
322
|
+
callerGardenId: "20260101T000000-fixture",
|
|
323
|
+
},
|
|
324
|
+
inherited,
|
|
325
|
+
);
|
|
326
|
+
const inventory = (): string => fxLines("list-windows", "-a", "-F", "#{session_id}|#{window_id}").join(" ");
|
|
327
|
+
|
|
328
|
+
// (i) an ABSENT seat: named refusal, and the server is byte-identical afterwards.
|
|
329
|
+
const beforeAbsent = inventory();
|
|
330
|
+
const absent = call({ tmuxSession: "nosuchseat" });
|
|
331
|
+
ok(
|
|
332
|
+
"seat: an absent seat refuses as tmux-session-missing and creates NOTHING — no window, no session",
|
|
333
|
+
!absent.ok && absent.reason === "tmux-session-missing" && inventory() === beforeAbsent,
|
|
334
|
+
);
|
|
335
|
+
|
|
336
|
+
// (ii) an EXISTING seat on the same server: the window lands THERE, the caller's own
|
|
337
|
+
// session is untouched, and the receipt names the resolved target rather than the caller.
|
|
338
|
+
assert.equal(fx("new-session", "-d", "-s", `${SESSION}-seat`).status, 0, "fixture seat session");
|
|
339
|
+
const seatId = fxLines("list-windows", "-t", `=${SESSION}-seat`, "-F", "#{session_id}")[0];
|
|
340
|
+
const callerWindowsBefore = fxLines("list-windows", "-t", placement.sessionId, "-F", "#{window_id}").join(" ");
|
|
341
|
+
const seated = call({ tmuxSession: `${SESSION}-seat` });
|
|
342
|
+
assert.ok(seated.ok, `the seated fresh call must succeed: ${seated.ok ? "" : seated.reason}`);
|
|
343
|
+
const receipt = seated.receipt;
|
|
344
|
+
ok(
|
|
345
|
+
"seat: the receipt reports the RESOLVED target session and echoes the REQUESTED name — not the caller's session",
|
|
346
|
+
receipt.sessionId === seatId &&
|
|
347
|
+
receipt.sessionId !== placement.sessionId &&
|
|
348
|
+
receipt.tmuxSession === `${SESSION}-seat`,
|
|
349
|
+
);
|
|
350
|
+
ok(
|
|
351
|
+
"seat: tmux agrees — the window is in the seat, and the caller's own session is byte-identical",
|
|
352
|
+
fxLines("list-windows", "-t", seatId, "-F", "#{window_id}").includes(receipt.windowId) &&
|
|
353
|
+
fxLines("list-windows", "-t", placement.sessionId, "-F", "#{window_id}").join(" ") === callerWindowsBefore,
|
|
354
|
+
);
|
|
355
|
+
ok(
|
|
356
|
+
"seat: an omitted seat still lands in the caller's own session and the receipt names none",
|
|
357
|
+
(() => {
|
|
358
|
+
const own = call();
|
|
359
|
+
if (!own.ok) return false;
|
|
360
|
+
const here = own.receipt.sessionId === placement.sessionId && own.receipt.tmuxSession === undefined;
|
|
361
|
+
process.kill(Number(own.receipt.panePid), "SIGKILL");
|
|
362
|
+
return here;
|
|
363
|
+
})(),
|
|
364
|
+
);
|
|
365
|
+
|
|
366
|
+
// (iii) the close side: that handle closes through the production verb, in a session
|
|
367
|
+
// that is NOT the caller's — the reason close binds to the server half.
|
|
368
|
+
ok(
|
|
369
|
+
"seat: the placed window closes through its own handle and tmux stops listing it",
|
|
370
|
+
closeWindow(receipt, inherited) === "closed" &&
|
|
371
|
+
!fxLines("list-windows", "-a", "-F", "#{window_id}").includes(receipt.windowId),
|
|
372
|
+
);
|
|
373
|
+
fx("kill-session", "-t", seatId);
|
|
374
|
+
ok("seat: the fixture is back to one session", sessionCount() === 1);
|
|
375
|
+
}
|
|
301
376
|
} finally {
|
|
302
377
|
fx("kill-server");
|
|
303
378
|
if (fs.existsSync(SOCKET)) fs.rmSync(SOCKET, { force: true });
|
|
@@ -313,4 +388,4 @@ function main(): void {
|
|
|
313
388
|
console.log(`\n${LABEL}: ${passed} checks passed`);
|
|
314
389
|
}
|
|
315
390
|
|
|
316
|
-
main();
|
|
391
|
+
await main();
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
* MUX-LAUNCH-NO-SHELL-ARGV the fixed runtime is passed after `--`, never as a string
|
|
20
20
|
* MUX-LAUNCH-NO-CARRIER argv is the append shape plus the runtime, nothing else
|
|
21
21
|
* MUX-LAUNCH-CORE-IMPORT-FREE the launch module and entwurf delivery never import each other
|
|
22
|
+
* RESOLVE-TMUX-SESSION-IMPORT-FREE the session-lookup leaf imports nothing at all
|
|
22
23
|
*/
|
|
23
24
|
|
|
24
25
|
import assert from "node:assert/strict";
|
|
@@ -270,6 +271,22 @@ function main(): void {
|
|
|
270
271
|
"boundary: the placement leaf does not import the launch module — the leaf stays deletable on its own",
|
|
271
272
|
!importsLaunch("pi-extensions/lib/mux-placement.ts"),
|
|
272
273
|
);
|
|
274
|
+
// ── the session-lookup leaf imports NOTHING (docs §11, #105) ─────────────────
|
|
275
|
+
// It is the one leaf whose entire safety argument is that it cannot acquire an
|
|
276
|
+
// opinion: no tmux of its own (the runner is injected), no mux module, no entwurf
|
|
277
|
+
// core, and not even a node builtin. §11 states that; nothing held it, and the first
|
|
278
|
+
// `import { runTmux } from "./mux-placement.ts"` would quietly turn a decision leaf
|
|
279
|
+
// into a second place that can run tmux.
|
|
280
|
+
ok(
|
|
281
|
+
"[QK:RESOLVE-TMUX-SESSION-IMPORT-FREE] the session-lookup leaf imports nothing at all — not mux, not entwurf core, not even a node builtin — so the only power it has is the runner its caller injects",
|
|
282
|
+
importsOf(fs.readFileSync("pi-extensions/lib/resolve-tmux-session.ts", "utf8")).length === 0,
|
|
283
|
+
);
|
|
284
|
+
ok(
|
|
285
|
+
"boundary: the fresh-call composition is the ONLY shipped source that imports that leaf",
|
|
286
|
+
PRODUCTION_SOURCES.filter((m) =>
|
|
287
|
+
importsOf(fs.readFileSync(m, "utf8")).some((spec) => spec.includes("resolve-tmux-session")),
|
|
288
|
+
).join(",") === FRESH_CALL_MODULE,
|
|
289
|
+
);
|
|
273
290
|
// Prose is not behaviour: the module header names identity vocabulary precisely to say
|
|
274
291
|
// it owns none of it, so this assertion reads CODE with the comments stripped. A check
|
|
275
292
|
// that failed on its own documentation would push the boundary out of the docs.
|
|
@@ -27,6 +27,8 @@
|
|
|
27
27
|
* - handles are stable @window/%pane
|
|
28
28
|
* - the rc=0 trap is real, and inspectPlacement refuses it instead of guessing
|
|
29
29
|
* - close reports `closed` for a live window and `already-gone` after a natural exit
|
|
30
|
+
* - a window placed in ANOTHER session of the same server closes through its own handle,
|
|
31
|
+
* while a handle from another SERVER is still refused (#105 close-side binding)
|
|
30
32
|
*/
|
|
31
33
|
|
|
32
34
|
import assert from "node:assert/strict";
|
|
@@ -259,9 +261,10 @@ function main(): void {
|
|
|
259
261
|
/context changed/,
|
|
260
262
|
"appendWindow must refuse a placement from another session",
|
|
261
263
|
);
|
|
264
|
+
// Close binds to the SERVER half (#105), so its refusal names that half by its own word.
|
|
262
265
|
assert.throws(
|
|
263
266
|
() => closeWindow({ ...w4, serverPid: "999999" }, inherited),
|
|
264
|
-
/
|
|
267
|
+
/server changed/,
|
|
265
268
|
"closeWindow must refuse a handle from another server",
|
|
266
269
|
);
|
|
267
270
|
ok("binding: append/close refuse a foreign server or session before mutating", windows().length === 4);
|
|
@@ -305,6 +308,63 @@ function main(): void {
|
|
|
305
308
|
originalPanes.every((p) => panes().includes(p)),
|
|
306
309
|
);
|
|
307
310
|
ok("restored: focus never moved", windows().find((w) => w.endsWith("|1")) === activeBefore);
|
|
311
|
+
|
|
312
|
+
// ── close binds to the SERVER, not the session (#105) ─────────────────────────
|
|
313
|
+
// Since #105 a launched window may live in a session that is not the caller's, so the
|
|
314
|
+
// close side had to stop requiring the session half. This is that decision judged
|
|
315
|
+
// against a real server: a SECOND session, a window opened into it from the caller's
|
|
316
|
+
// pane the way the fresh-call composition does, and a close through the same handle.
|
|
317
|
+
// Both foreign-server refusal and the two close outcomes above stay exactly as they
|
|
318
|
+
// were — the session half is what changed, and only for close.
|
|
319
|
+
assert.equal(fx("new-session", "-d", "-s", `${SESSION}-b`).status, 0, "fixture second session");
|
|
320
|
+
ok("cross-session: the fixture now holds two sessions", sessionCount() === 2);
|
|
321
|
+
const otherSessionId = fxLines("list-windows", "-t", `=${SESSION}-b`, "-F", "#{session_id}")[0];
|
|
322
|
+
ok("cross-session: the second session resolves to a native id by exact name", /^\$[0-9]+$/.test(otherSessionId));
|
|
323
|
+
const placedRow = fxLines(
|
|
324
|
+
"new-window",
|
|
325
|
+
"-d",
|
|
326
|
+
"-a",
|
|
327
|
+
"-t",
|
|
328
|
+
`${otherSessionId}:{end}`,
|
|
329
|
+
"-P",
|
|
330
|
+
"-F",
|
|
331
|
+
"#{window_id}|#{window_index}|#{pane_id}|#{pane_pid}",
|
|
332
|
+
)[0].split("|");
|
|
333
|
+
const placed = {
|
|
334
|
+
serverPid: placement.serverPid,
|
|
335
|
+
sessionId: otherSessionId,
|
|
336
|
+
windowId: placedRow[0],
|
|
337
|
+
windowIndex: placedRow[1],
|
|
338
|
+
paneId: placedRow[2],
|
|
339
|
+
panePid: placedRow[3],
|
|
340
|
+
};
|
|
341
|
+
ok(
|
|
342
|
+
"cross-session: the window really landed in the OTHER session, and the caller's session is untouched",
|
|
343
|
+
fxLines("list-windows", "-t", otherSessionId, "-F", "#{window_id}").includes(placed.windowId) &&
|
|
344
|
+
!fxLines("list-windows", "-t", placement.sessionId, "-F", "#{window_id}").includes(placed.windowId),
|
|
345
|
+
);
|
|
346
|
+
ok(
|
|
347
|
+
"cross-session: the caller's own session still shows windows 1,2 and its focus is unmoved",
|
|
348
|
+
fxLines("list-windows", "-t", placement.sessionId, "-F", "#{window_id}").length === 2 &&
|
|
349
|
+
windows().find((w) => w.endsWith("|1")) === activeBefore,
|
|
350
|
+
);
|
|
351
|
+
// The predicate change, stated as the two facts that had to move together.
|
|
352
|
+
assert.throws(
|
|
353
|
+
() => closeWindow({ ...placed, serverPid: "999999" }, inherited),
|
|
354
|
+
/server changed/,
|
|
355
|
+
"closeWindow must still refuse a handle from another server",
|
|
356
|
+
);
|
|
357
|
+
ok(
|
|
358
|
+
"cross-session: a window in another session of the SAME server closes through its own handle — the release lifecycle smoke's close path reaches a placed window",
|
|
359
|
+
closeWindow(placed, inherited) === "closed",
|
|
360
|
+
);
|
|
361
|
+
ok(
|
|
362
|
+
"cross-session: it is gone from the other session, and nothing else moved",
|
|
363
|
+
!fxLines("list-windows", "-a", "-F", "#{window_id}").includes(placed.windowId) &&
|
|
364
|
+
fxLines("list-windows", "-t", placement.sessionId, "-F", "#{window_id}").length === 2,
|
|
365
|
+
);
|
|
366
|
+
fx("kill-session", "-t", otherSessionId);
|
|
367
|
+
ok("cross-session: the fixture is back to one session", sessionCount() === 1);
|
|
308
368
|
} finally {
|
|
309
369
|
fx("kill-server");
|
|
310
370
|
if (fs.existsSync(SOCKET)) fs.rmSync(SOCKET, { force: true });
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
* MUX-TMUX-FAILURE-LOUD a nonzero/signalled tmux run is raised, never read as a fact
|
|
23
23
|
* MUX-APPEND-END-DETACHED append is `-d -a -t <session_id>:{end}`, no carrier
|
|
24
24
|
* MUX-CONTEXT-BOUND-MUTATION a mutation matches the server pid AND the session id
|
|
25
|
+
* MUX-CLOSE-SERVER-BOUND a close binds to the server half only — same server, any session
|
|
25
26
|
* MUX-CLOSE-BY-WINDOW-ID close targets `@window`, and absence needs positive proof
|
|
26
27
|
*/
|
|
27
28
|
|
|
@@ -38,6 +39,7 @@ import {
|
|
|
38
39
|
isDecimal,
|
|
39
40
|
isPaneId,
|
|
40
41
|
isSameContext,
|
|
42
|
+
isSameServer,
|
|
41
43
|
isSessionId,
|
|
42
44
|
isWindowId,
|
|
43
45
|
parsePlacement,
|
|
@@ -254,6 +256,37 @@ function main(): void {
|
|
|
254
256
|
);
|
|
255
257
|
})(),
|
|
256
258
|
);
|
|
259
|
+
// ── a close binds to the server, and the session half is covered by id uniqueness ────
|
|
260
|
+
// `appendWindow` and `closeWindow` are answering different questions, so they bind to
|
|
261
|
+
// different halves of the same fact. Append asks "is this target still the caller's own
|
|
262
|
+
// seat?" and needs both. Close asks "is this handle still the window it was born as?" —
|
|
263
|
+
// and since #105 a launched window may legitimately live in a session that is not the
|
|
264
|
+
// caller's, so requiring the session half would refuse a legitimate close. `[측정
|
|
265
|
+
// 2026-09-07]` window ids are handed out monotonically and never recycled within one
|
|
266
|
+
// server's life (after `@2` was killed the next was `@3`; after a whole session holding
|
|
267
|
+
// `@4`/`@5` was killed the next was `@6`), so on a matching server the `@id` alone
|
|
268
|
+
// identifies the window. What must still be refused is a handle from a DIFFERENT or
|
|
269
|
+
// restarted server, where the same id names something else.
|
|
270
|
+
ok(
|
|
271
|
+
"[QK:MUX-CLOSE-SERVER-BOUND] a close-side context matches on the server pid ALONE — another session on the same server is accepted, and every foreign server is refused however familiar its session id looks",
|
|
272
|
+
(() => {
|
|
273
|
+
const origin = { serverPid: "8150", sessionId: "$11" };
|
|
274
|
+
return (
|
|
275
|
+
isSameServer(origin, { serverPid: "8150", sessionId: "$11" }) &&
|
|
276
|
+
isSameServer(origin, { serverPid: "8150", sessionId: "$12" }) &&
|
|
277
|
+
!isSameServer(origin, { serverPid: "9999", sessionId: "$11" }) &&
|
|
278
|
+
!isSameServer(origin, { serverPid: "9999", sessionId: "$12" })
|
|
279
|
+
);
|
|
280
|
+
})(),
|
|
281
|
+
);
|
|
282
|
+
ok(
|
|
283
|
+
"binding: the two predicates are different contracts — append's refuses a foreign session that close's accepts, so neither may be expressed in terms of the other",
|
|
284
|
+
(() => {
|
|
285
|
+
const origin = { serverPid: "8150", sessionId: "$11" };
|
|
286
|
+
const otherSession = { serverPid: "8150", sessionId: "$12" };
|
|
287
|
+
return !isSameContext(origin, otherSession) && isSameServer(origin, otherSession);
|
|
288
|
+
})(),
|
|
289
|
+
);
|
|
257
290
|
ok(
|
|
258
291
|
"binding: a window handle carries the context it was born in",
|
|
259
292
|
(() => {
|