@junghanacs/entwurf 0.13.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +52 -17
- package/BASELINE.md +60 -206
- package/CHANGELOG.md +64 -0
- package/CONTRIBUTING.md +14 -10
- package/DELIVERY.md +118 -261
- package/README.md +91 -431
- package/VERIFY.md +74 -95
- package/demo/README.md +1 -1
- package/demo/demo-baseline.sh +1 -3
- package/demo/demo.sh +2 -5
- package/docs/acp-backend-rail.md +236 -0
- package/docs/external-mcp-host.md +131 -0
- package/docs/fresh-cut-policy.md +99 -0
- package/docs/setup-clean-host.md +124 -328
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +160 -67
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/acp-client.js +12 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +11 -10
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/config.js +1 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/engraving.js +42 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/models.js +4 -4
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/overlay.js +11 -9
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-control-rpc.js +7 -5
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-core.js +13 -14
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-resume-args.js +45 -40
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +117 -95
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-decider.js +23 -57
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-lock.js +16 -7
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +5 -53
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-release.js +21 -36
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-runner.js +3 -15
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send-fallback.js +12 -11
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send.js +2 -7
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +30 -67
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-visible-resume.js +256 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +91 -5
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +258 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-launch.js +202 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-placement.js +289 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-resume-call.js +170 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/resume-launch-identity.js +136 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/session-id.js +8 -5
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/socket-discovery.js +3 -3
- package/mcp/entwurf-bridge/dist/scripts/meta-facts.js +51 -0
- package/mcp/entwurf-bridge/dist/scripts/new-session-id.js +9 -4
- package/mcp/entwurf-bridge/src/index.ts +176 -67
- package/mcp/entwurf-bridge/start.sh +2 -2
- package/mcp/entwurf-bridge/test.sh +23 -9
- package/mcp/entwurf-bridge/tsconfig.build.json +11 -2
- package/package.json +25 -11
- package/pi-extensions/entwurf-control.ts +220 -71
- package/pi-extensions/lib/acp/acp-client.ts +22 -0
- package/pi-extensions/lib/acp/augment.ts +42 -3
- package/pi-extensions/lib/acp/backend-adapter.ts +12 -11
- package/pi-extensions/lib/acp/backend.ts +366 -38
- package/pi-extensions/lib/acp/config.ts +1 -1
- package/pi-extensions/lib/acp/engraving.ts +43 -1
- package/pi-extensions/lib/acp/event-mapper.ts +16 -13
- package/pi-extensions/lib/acp/models.ts +4 -4
- package/pi-extensions/lib/acp/overlay.ts +11 -9
- package/pi-extensions/lib/entwurf-control-rpc.ts +7 -5
- package/pi-extensions/lib/entwurf-core.ts +15 -15
- package/pi-extensions/lib/entwurf-resume-args.ts +41 -52
- package/pi-extensions/lib/entwurf-v2-contract-schema.ts +1 -1
- package/pi-extensions/lib/entwurf-v2-contract.ts +120 -99
- package/pi-extensions/lib/entwurf-v2-decider.ts +30 -91
- package/pi-extensions/lib/entwurf-v2-lock.ts +16 -7
- package/pi-extensions/lib/entwurf-v2-production.ts +4 -78
- package/pi-extensions/lib/entwurf-v2-release.ts +25 -49
- package/pi-extensions/lib/entwurf-v2-runner.ts +6 -21
- package/pi-extensions/lib/entwurf-v2-send-fallback.ts +12 -11
- package/pi-extensions/lib/entwurf-v2-send.ts +2 -7
- package/pi-extensions/lib/entwurf-v2-surface.ts +36 -76
- package/pi-extensions/lib/entwurf-v2-visible-resume.ts +370 -0
- package/pi-extensions/lib/meta-session.ts +93 -5
- package/pi-extensions/lib/mux-fresh-call.ts +328 -0
- package/pi-extensions/lib/mux-launch.ts +267 -0
- package/pi-extensions/lib/mux-placement.ts +387 -0
- package/pi-extensions/lib/mux-resume-call.ts +221 -0
- package/pi-extensions/lib/resume-launch-identity.ts +162 -0
- package/pi-extensions/lib/session-id.js +8 -5
- package/pi-extensions/lib/socket-discovery.ts +3 -3
- package/prompts/engraving.md +13 -5
- package/run.sh +688 -282
- package/scripts/agy-bridge-config.py +5 -1
- package/scripts/check-acp-backend-preflight.ts +1 -1
- package/scripts/check-acp-carrier-augment.ts +246 -10
- package/scripts/check-acp-cortex.ts +5 -5
- package/scripts/check-acp-overlay.ts +13 -3
- package/scripts/check-acp-prompt-lifecycle.ts +565 -0
- package/scripts/check-acp-stop-reason.ts +342 -0
- package/scripts/check-acp-stream-hooks.ts +504 -0
- package/scripts/check-elapsed.sh +25 -0
- package/scripts/check-entwurf-bridge-boot.ts +51 -4
- package/scripts/check-entwurf-bridge-pi-free.ts +6 -5
- package/scripts/check-entwurf-control-rpc.ts +4 -3
- package/scripts/check-entwurf-resume-args.ts +72 -70
- package/scripts/check-entwurf-session-identity.ts +14 -10
- package/scripts/check-entwurf-v2-contract.ts +34 -59
- package/scripts/check-entwurf-v2-decider.ts +17 -177
- package/scripts/check-entwurf-v2-lock.ts +5 -2
- package/scripts/check-entwurf-v2-matrix.ts +3 -53
- package/scripts/check-entwurf-v2-production.ts +2 -91
- package/scripts/check-entwurf-v2-release.ts +10 -105
- package/scripts/check-entwurf-v2-runner.ts +4 -85
- package/scripts/check-entwurf-v2-send-fallback.ts +5 -6
- package/scripts/check-entwurf-v2-send.ts +0 -28
- package/scripts/check-entwurf-v2-surface.ts +198 -128
- package/scripts/check-entwurf-v2-visible-resume.ts +445 -0
- package/scripts/check-fresh-cut-gate.sh +1 -1
- package/scripts/check-gate-qualification.ts +101 -7
- package/scripts/check-install-container.sh +10 -2
- package/scripts/check-install-surface.ts +1 -1
- package/scripts/check-keyset-overlap.py +1 -1
- package/scripts/check-meta-facts.ts +249 -0
- package/scripts/check-meta-identity-consumers.ts +1 -1
- package/scripts/check-meta-session.ts +169 -0
- package/scripts/check-mux-launch-tmux.ts +316 -0
- package/scripts/check-mux-launch.ts +288 -0
- package/scripts/check-mux-launcher-fence.ts +264 -0
- package/scripts/check-mux-parent-artifact.ts +195 -0
- package/scripts/check-mux-placement-tmux.ts +322 -0
- package/scripts/check-mux-placement.ts +323 -0
- package/scripts/check-mux-resume-call.ts +282 -0
- package/scripts/check-probe-cli-shim.ts +25 -22
- package/scripts/check-probe-ordering.ts +121 -81
- package/scripts/check-release-gate-outcomes.ts +452 -0
- package/scripts/check-resume-launch-identity.ts +244 -0
- package/scripts/check-socket-discovery.ts +1 -1
- package/scripts/fixtures/mux-parent-transcript.scrubbed.jsonl +3 -0
- package/scripts/inventory-verification-surface.ts +349 -0
- package/scripts/lib/claude-launcher-fence.ts +322 -0
- package/scripts/lib/live-skip.ts +33 -0
- package/scripts/lib/mutation-qualify.ts +109 -3
- package/scripts/lib/probe-acp-turn.ts +33 -12
- package/scripts/lib/step-outcome.sh +88 -0
- package/scripts/meta-bridge-doctor.sh +6 -8
- package/scripts/meta-facts.ts +60 -0
- package/scripts/mutants/acp-augment.json +106 -0
- package/scripts/mutants/acp-cortex.json +2 -2
- package/scripts/mutants/acp-overlay.json +17 -0
- package/scripts/mutants/acp-prompt-lifecycle.json +100 -0
- package/scripts/mutants/acp-stop-reason.json +80 -0
- package/scripts/mutants/acp-stream-hooks.json +158 -0
- package/scripts/mutants/bridge-boot-resume.json +45 -0
- package/scripts/mutants/meta-facts.json +50 -0
- package/scripts/mutants/meta-identity.json +36 -0
- package/scripts/mutants/mux-boundary.json +196 -0
- package/scripts/mutants/mux-fresh-call.json +185 -0
- package/scripts/mutants/mux-launcher-fence.json +123 -0
- package/scripts/mutants/mux-parent-artifact.json +39 -0
- package/scripts/mutants/mux-resume-call.json +148 -0
- package/scripts/mutants/probe-ordering.json +6 -1021
- package/scripts/mutants/release-gate.json +140 -0
- package/scripts/mutants/resume-args.json +76 -0
- package/scripts/mutants/resume-launch-identity.json +96 -0
- package/scripts/mutants/v2-surface.json +74 -23
- package/scripts/mutants/v2-visible-resume.json +215 -0
- package/scripts/new-session-id.ts +9 -4
- package/scripts/smoke-acp-bundled-mcp-live.ts +4 -3
- package/scripts/smoke-acp-carrier-augment-live.ts +2 -2
- package/scripts/smoke-acp-cortex-live.ts +8 -14
- package/scripts/smoke-acp-long-turn-live.ts +185 -0
- package/scripts/smoke-acp-mcp-live.ts +2 -2
- package/scripts/smoke-acp-memory-containment-live.ts +2 -2
- package/scripts/smoke-acp-ordering-probe-live.ts +2 -2
- package/scripts/smoke-acp-overlay-live.ts +2 -2
- package/scripts/smoke-acp-provider-live.ts +2 -2
- package/scripts/smoke-acp-raw-turn-live.ts +3 -3
- package/scripts/smoke-acp-session-reuse-live.ts +2 -2
- package/scripts/smoke-acp-skill-live.ts +2 -2
- package/scripts/smoke-acp-socket-citizen-live.ts +4 -3
- package/scripts/smoke-acp-v2-send-live.ts +4 -3
- package/scripts/smoke-agy-native-push-live.ts +10 -20
- package/scripts/smoke-claude-native-resume-live.sh +13 -3
- package/scripts/smoke-entwurf-chain-live.ts +352 -0
- package/scripts/smoke-entwurf-v2-matrix-live.ts +3 -3
- package/scripts/smoke-meta-honesty.sh +1 -1
- package/scripts/smoke-meta-install-state.sh +4 -0
- package/scripts/smoke-mux-fresh-call-live.ts +365 -0
- package/scripts/smoke-mux-lifecycle-live.ts +1136 -0
- package/scripts/smoke-pi-attach.ts +1 -1
- package/scripts/smoke-user-scope-citizen.sh +1 -1
- package/scripts/tsconfig.json +1 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-preflight.js +0 -160
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn-production.js +0 -273
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn.js +0 -216
- package/pi-extensions/lib/entwurf-v2-spawn-production.ts +0 -373
- package/pi-extensions/lib/entwurf-v2-spawn.ts +0 -323
- package/scripts/check-acp-sdk-surface.ts +0 -236
- package/scripts/check-entwurf-v2-spawn-production.ts +0 -551
- package/scripts/check-entwurf-v2-spawn.ts +0 -399
- package/scripts/smoke-entwurf-v2-spawn-live.ts +0 -188
- package/scripts/smoke-entwurf-v2-spawn-resume-live.ts +0 -469
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* check-mux-placement-tmux — REAL tmux acceptance for the T0-b placement primitives.
|
|
3
|
+
*
|
|
4
|
+
* This is the half that judges topology. `check-mux-placement` pins argv and parsing; here
|
|
5
|
+
* the production functions in `pi-extensions/lib/mux-placement.ts` run against an actual
|
|
6
|
+
* tmux server and the assertions read the server's own `list-sessions`/`list-windows`/
|
|
7
|
+
* `list-panes` output. No fake tmux exists in this repo — a fake would have authored the
|
|
8
|
+
* contract before it was measured, which is how the 2026-08-02 attempt shipped the wrong
|
|
9
|
+
* unit (a separate session instead of a window).
|
|
10
|
+
*
|
|
11
|
+
* Isolation, non-negotiable: the fixture builds its OWN server on a unique `-S` socket under
|
|
12
|
+
* $XDG_RUNTIME_DIR and never touches the operator's server. (`-L` would place the socket in
|
|
13
|
+
* the same directory as the default server; `-S` with an explicit path keeps them apart. The
|
|
14
|
+
* path lives in the runtime dir rather than a deep scratch dir because a unix socket path is
|
|
15
|
+
* capped near 104 bytes.) The operator's server is snapshotted read-only before and after and
|
|
16
|
+
* the two snapshots must be identical.
|
|
17
|
+
*
|
|
18
|
+
* Honesty of the anchor: the production code must receive `TMUX`/`TMUX_PANE` the way a real
|
|
19
|
+
* caller does — INHERITED by a process living inside a pane. The fixture therefore reads
|
|
20
|
+
* those values out of the fixture pane's own process environment (`/proc/<pane_pid>/environ`)
|
|
21
|
+
* instead of assembling them. That is Linux-only, and the check skips elsewhere rather than
|
|
22
|
+
* inventing the values it is supposed to be proving.
|
|
23
|
+
*
|
|
24
|
+
* Proven here:
|
|
25
|
+
* - one session throughout; 1,2 → 1,2,3 → 1,2,3,4 → 1,2
|
|
26
|
+
* - original windows, their pane pids, and the caller's focus all survive
|
|
27
|
+
* - handles are stable @window/%pane
|
|
28
|
+
* - the rc=0 trap is real, and inspectPlacement refuses it instead of guessing
|
|
29
|
+
* - close reports `closed` for a live window and `already-gone` after a natural exit
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import assert from "node:assert/strict";
|
|
33
|
+
import { spawnSync } from "node:child_process";
|
|
34
|
+
import fs from "node:fs";
|
|
35
|
+
import path from "node:path";
|
|
36
|
+
import { appendWindow, closeWindow, inspectPlacement } from "../pi-extensions/lib/mux-placement.ts";
|
|
37
|
+
import { skipLive } from "./lib/live-skip.ts";
|
|
38
|
+
|
|
39
|
+
const LABEL = "check-mux-placement-tmux";
|
|
40
|
+
let passed = 0;
|
|
41
|
+
function ok(label: string, cond: boolean): void {
|
|
42
|
+
assert.ok(cond, label);
|
|
43
|
+
console.log(` ok ${label}`);
|
|
44
|
+
passed++;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ── fixture server (never the operator's) ──────────────────────────────────
|
|
48
|
+
|
|
49
|
+
const SOCKET = path.join(process.env.XDG_RUNTIME_DIR ?? "/run/user/1000", `entwurf-mux-t0b-${process.pid}.sock`);
|
|
50
|
+
const SESSION = `muxt0b${process.pid}`;
|
|
51
|
+
|
|
52
|
+
/** Fixture tmux: always explicit `-S`, always with the ambient TMUX stripped so an ambient
|
|
53
|
+
* server can never be resolved by accident. */
|
|
54
|
+
function fx(...args: string[]): { status: number | null; stdout: string; stderr: string } {
|
|
55
|
+
const env = { ...process.env };
|
|
56
|
+
delete env.TMUX;
|
|
57
|
+
delete env.TMUX_PANE;
|
|
58
|
+
const r = spawnSync("tmux", ["-S", SOCKET, ...args], { env, encoding: "utf8" });
|
|
59
|
+
if (r.error) throw r.error;
|
|
60
|
+
return { status: r.status, stdout: r.stdout ?? "", stderr: r.stderr ?? "" };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function fxLines(...args: string[]): string[] {
|
|
64
|
+
const r = fx(...args);
|
|
65
|
+
assert.equal(r.status, 0, `fixture tmux ${args.join(" ")} failed: ${r.stderr}`);
|
|
66
|
+
return r.stdout
|
|
67
|
+
.split("\n")
|
|
68
|
+
.map((l) => l.trim())
|
|
69
|
+
.filter((l) => l.length > 0);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function windows(): string[] {
|
|
73
|
+
return fxLines("list-windows", "-a", "-F", "#{window_id}|#{window_index}|#{window_active}");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function panes(): string[] {
|
|
77
|
+
return fxLines("list-panes", "-a", "-F", "#{window_id}|#{pane_id}|#{pane_pid}");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function sessionCount(): number {
|
|
81
|
+
return fxLines("list-sessions", "-F", "#{session_id}").length;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Read-only snapshot of the CALLER'S OWN server — the one this gate must prove it never
|
|
86
|
+
* touched. The ambient `TMUX` is kept deliberately: scrubbing it would snapshot the DEFAULT
|
|
87
|
+
* server instead, which is not necessarily the operator's, and the isolation claim would be
|
|
88
|
+
* about the wrong machine. (Only the FIXTURE calls scrub the environment.)
|
|
89
|
+
*/
|
|
90
|
+
function operatorSnapshot(): string | null {
|
|
91
|
+
const r = spawnSync("tmux", ["list-panes", "-a", "-F", "#{session_id}|#{window_id}|#{pane_id}|#{pane_pid}"], {
|
|
92
|
+
env: process.env,
|
|
93
|
+
encoding: "utf8",
|
|
94
|
+
});
|
|
95
|
+
return r.status === 0 ? (r.stdout ?? "") : null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function main(): void {
|
|
99
|
+
if (spawnSync("tmux", ["-V"], { encoding: "utf8" }).status !== 0) {
|
|
100
|
+
skipLive(LABEL, "tmux is not installed — install tmux to run the placement acceptance");
|
|
101
|
+
}
|
|
102
|
+
if (!fs.existsSync("/proc")) {
|
|
103
|
+
skipLive(LABEL, "/proc is unavailable — the inherited-anchor read is Linux-only");
|
|
104
|
+
}
|
|
105
|
+
// The isolation proof is about the caller's OWN server, so this gate must be run from
|
|
106
|
+
// inside tmux. Without that it would be snapshotting something else and calling it proof.
|
|
107
|
+
if (!process.env.TMUX || !process.env.TMUX_PANE) {
|
|
108
|
+
skipLive(LABEL, "run this acceptance from INSIDE tmux — the isolation proof snapshots the caller's own server");
|
|
109
|
+
}
|
|
110
|
+
if (fs.existsSync(SOCKET)) {
|
|
111
|
+
throw new Error(`${LABEL}: fixture socket ${SOCKET} already exists — refusing to reuse a foreign server`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const before = operatorSnapshot();
|
|
115
|
+
|
|
116
|
+
try {
|
|
117
|
+
// ── fixture: a session whose windows are 1 and 2, like the operator's ───────────
|
|
118
|
+
// `-f /dev/null` keeps the operator's tmux.conf out of the FIXTURE (this is a
|
|
119
|
+
// controlled substrate, not a display), and base-index is then set explicitly rather
|
|
120
|
+
// than inherited or assumed. base-index does not apply retroactively, so window 0 is
|
|
121
|
+
// moved. No `-n`: this module authors no window names anywhere.
|
|
122
|
+
assert.equal(fx("-f", "/dev/null", "new-session", "-d", "-s", SESSION).status, 0, "fixture new-session");
|
|
123
|
+
fx("set-option", "-g", "base-index", "1");
|
|
124
|
+
fx("set-option", "-gw", "pane-base-index", "1");
|
|
125
|
+
// renumber-windows on mirrors the operator's shipped setting — the acceptance must run
|
|
126
|
+
// under the same index churn the production code has to survive.
|
|
127
|
+
fx("set-option", "-g", "renumber-windows", "on");
|
|
128
|
+
assert.equal(fx("move-window", "-s", `${SESSION}:0`, "-t", `${SESSION}:1`).status, 0, "fixture move-window");
|
|
129
|
+
assert.equal(fx("new-window", "-d", "-a", "-t", `${SESSION}:{end}`).status, 0, "fixture second window");
|
|
130
|
+
|
|
131
|
+
const w0 = windows();
|
|
132
|
+
ok("fixture: exactly one session", sessionCount() === 1);
|
|
133
|
+
ok(`fixture: windows are 1,2 (${w0.join(" ")})`, w0.length === 2 && w0[0].includes("|1|") && w0[1].includes("|2|"));
|
|
134
|
+
|
|
135
|
+
// ── the anchor is INHERITED, not assembled ─────────────────────────────────────
|
|
136
|
+
const callerPanePid = fxLines("display-message", "-p", "-t", `${SESSION}:1`, "#{pane_pid}")[0];
|
|
137
|
+
const environ = fs.readFileSync(`/proc/${callerPanePid}/environ`, "utf8").split("\0");
|
|
138
|
+
const inherited: NodeJS.ProcessEnv = { ...process.env };
|
|
139
|
+
delete inherited.TMUX;
|
|
140
|
+
delete inherited.TMUX_PANE;
|
|
141
|
+
for (const entry of environ) {
|
|
142
|
+
const eq = entry.indexOf("=");
|
|
143
|
+
if (eq < 0) continue;
|
|
144
|
+
const key = entry.slice(0, eq);
|
|
145
|
+
if (key === "TMUX" || key === "TMUX_PANE") inherited[key] = entry.slice(eq + 1);
|
|
146
|
+
}
|
|
147
|
+
ok("anchor: the fixture pane really inherited TMUX/TMUX_PANE", Boolean(inherited.TMUX && inherited.TMUX_PANE));
|
|
148
|
+
ok("anchor: TMUX names the fixture socket, not the operator's", String(inherited.TMUX).startsWith(SOCKET));
|
|
149
|
+
|
|
150
|
+
// ── the rc=0 trap is real, and production refuses it ───────────────────────────
|
|
151
|
+
const bogus = spawnSync("tmux", ["display-message", "-p", "-t", "%999999", "#{pane_id}"], {
|
|
152
|
+
env: inherited,
|
|
153
|
+
encoding: "utf8",
|
|
154
|
+
});
|
|
155
|
+
ok(
|
|
156
|
+
"trap: display-message on a nonexistent pane exits 0 with empty output (rc is not evidence)",
|
|
157
|
+
bogus.status === 0 && (bogus.stdout ?? "").trim() === "",
|
|
158
|
+
);
|
|
159
|
+
const empty = spawnSync("tmux", ["display-message", "-p", "-t", "", "#{pane_id}"], {
|
|
160
|
+
env: inherited,
|
|
161
|
+
encoding: "utf8",
|
|
162
|
+
});
|
|
163
|
+
ok(
|
|
164
|
+
"trap: an EMPTY target exits 0 and answers about some other pane",
|
|
165
|
+
empty.status === 0 && (empty.stdout ?? "").trim().startsWith("%"),
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
{
|
|
169
|
+
const outside = { ...inherited };
|
|
170
|
+
delete outside.TMUX;
|
|
171
|
+
delete outside.TMUX_PANE;
|
|
172
|
+
const r = inspectPlacement(outside);
|
|
173
|
+
ok(
|
|
174
|
+
"reject: outside tmux → no-tmux-context (no fallback session)",
|
|
175
|
+
r.ok === false && r.reason === "no-tmux-context",
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
{
|
|
179
|
+
const r = inspectPlacement({ ...inherited, TMUX_PANE: "" });
|
|
180
|
+
ok(
|
|
181
|
+
"reject: empty TMUX_PANE → no-tmux-context (never the rc=0 fallback pane)",
|
|
182
|
+
r.ok === false && r.reason === "no-tmux-context",
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
{
|
|
186
|
+
// The dangerous one: a pane id with NO TMUX would make a bare tmux resolve to the
|
|
187
|
+
// DEFAULT server — i.e. mutate the operator's live server with a foreign id.
|
|
188
|
+
const noServer = { ...inherited };
|
|
189
|
+
delete noServer.TMUX;
|
|
190
|
+
const r = inspectPlacement(noServer);
|
|
191
|
+
ok(
|
|
192
|
+
"reject: TMUX_PANE without TMUX → no-tmux-context (never falls through to the default server)",
|
|
193
|
+
r.ok === false && r.reason === "no-tmux-context",
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
{
|
|
197
|
+
const r = inspectPlacement({ ...inherited, TMUX_PANE: "%1; kill-server" });
|
|
198
|
+
ok(
|
|
199
|
+
"reject: a non-native TMUX_PANE → anchor-malformed, refused before tmux is invoked",
|
|
200
|
+
r.ok === false && r.reason === "anchor-malformed",
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
{
|
|
204
|
+
const r = inspectPlacement({ ...inherited, TMUX_PANE: "%999999" });
|
|
205
|
+
ok(
|
|
206
|
+
"reject: a stale pane id → anchor-unresolved despite rc=0",
|
|
207
|
+
r.ok === false && r.reason === "anchor-unresolved",
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ── inspectPlacement: the caller's own coordinates ─────────────────────────────
|
|
212
|
+
const fixtureServerPid = fxLines("display-message", "-p", "#{pid}")[0];
|
|
213
|
+
const fixtureSessionId = fxLines("display-message", "-p", "-t", `${SESSION}:1`, "#{session_id}")[0];
|
|
214
|
+
const got = inspectPlacement(inherited);
|
|
215
|
+
assert.ok(got.ok, "inspectPlacement must resolve inside the fixture");
|
|
216
|
+
const placement = got.placement;
|
|
217
|
+
ok("inspect: server pid is the FIXTURE server, not the operator's", placement.serverPid === fixtureServerPid);
|
|
218
|
+
ok("inspect: session id matches the fixture session", placement.sessionId === fixtureSessionId);
|
|
219
|
+
ok("inspect: pane id echoes the inherited anchor", placement.paneId === inherited.TMUX_PANE);
|
|
220
|
+
ok("inspect: pane pid matches tmux's own view", placement.panePid === callerPanePid);
|
|
221
|
+
ok(
|
|
222
|
+
"inspect: window index is the human 1, id is @-stable",
|
|
223
|
+
placement.windowIndex === "1" && placement.windowId.startsWith("@"),
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
const originalPanes = panes();
|
|
227
|
+
const activeBefore = windows().find((w) => w.endsWith("|1"));
|
|
228
|
+
|
|
229
|
+
// ── appendWindow ×2 ───────────────────────────────────────────────────────────
|
|
230
|
+
const w3 = appendWindow(placement, inherited);
|
|
231
|
+
ok(`append#1: 1,2,3 (${windows().join(" ")})`, windows().length === 3);
|
|
232
|
+
ok("append#1: stable handles", w3.windowId.startsWith("@") && w3.paneId.startsWith("%"));
|
|
233
|
+
ok("append#1: landed at the END (index 3)", w3.windowIndex === "3");
|
|
234
|
+
ok("append#1: still exactly one session", sessionCount() === 1);
|
|
235
|
+
ok("append#1: focus unchanged", windows().find((w) => w.endsWith("|1")) === activeBefore);
|
|
236
|
+
|
|
237
|
+
const w4 = appendWindow(placement, inherited);
|
|
238
|
+
ok(`append#2: 1,2,3,4 (${windows().join(" ")})`, windows().length === 4);
|
|
239
|
+
ok("append#2: landed at the END (index 4)", w4.windowIndex === "4");
|
|
240
|
+
ok("append#2: a distinct window", w4.windowId !== w3.windowId && w4.paneId !== w3.paneId);
|
|
241
|
+
ok("append#2: still exactly one session", sessionCount() === 1);
|
|
242
|
+
ok("append#2: focus unchanged", windows().find((w) => w.endsWith("|1")) === activeBefore);
|
|
243
|
+
ok(
|
|
244
|
+
"append: original windows and their pane pids untouched",
|
|
245
|
+
originalPanes.every((p) => panes().includes(p)),
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
// ── the handle is bound to the context it was born in ─────────────────────────
|
|
249
|
+
// A `$N`/`@N` on a DIFFERENT (or restarted) server names something else entirely, so
|
|
250
|
+
// both mutations re-read the caller's placement and refuse on a mismatch — before
|
|
251
|
+
// touching anything.
|
|
252
|
+
assert.throws(
|
|
253
|
+
() => appendWindow({ ...placement, serverPid: "999999" }, inherited),
|
|
254
|
+
/context changed/,
|
|
255
|
+
"appendWindow must refuse a placement from another server",
|
|
256
|
+
);
|
|
257
|
+
assert.throws(
|
|
258
|
+
() => appendWindow({ ...placement, sessionId: "$999" }, inherited),
|
|
259
|
+
/context changed/,
|
|
260
|
+
"appendWindow must refuse a placement from another session",
|
|
261
|
+
);
|
|
262
|
+
assert.throws(
|
|
263
|
+
() => closeWindow({ ...w4, serverPid: "999999" }, inherited),
|
|
264
|
+
/context changed/,
|
|
265
|
+
"closeWindow must refuse a handle from another server",
|
|
266
|
+
);
|
|
267
|
+
ok("binding: append/close refuse a foreign server or session before mutating", windows().length === 4);
|
|
268
|
+
ok(
|
|
269
|
+
"binding: the handle carries the server pid and session id it was born in",
|
|
270
|
+
w4.serverPid === fixtureServerPid && w4.sessionId === fixtureSessionId,
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
// ── closeWindow on a LIVE window → closed ─────────────────────────────────────
|
|
274
|
+
ok("close: a live window reports closed", closeWindow(w4, inherited) === "closed");
|
|
275
|
+
ok(`close: back to 1,2,3 (${windows().join(" ")})`, windows().length === 3);
|
|
276
|
+
ok(
|
|
277
|
+
"close: the closed window id is gone",
|
|
278
|
+
!windows().some((w) => w.startsWith(`${w3.windowId === w4.windowId ? "" : w4.windowId}|`)),
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
// ── natural exit → the window vanishes on its own → already-gone ──────────────
|
|
282
|
+
// remain-on-exit is off, so ending the pane process removes the window through a path
|
|
283
|
+
// this module never called. An interactive shell ignores SIGTERM; SIGHUP is what ends
|
|
284
|
+
// it (measured in T0-a), so the fixture uses HUP rather than pretending TERM worked.
|
|
285
|
+
process.kill(Number(w3.panePid), "SIGHUP");
|
|
286
|
+
const deadline = Date.now() + 5000;
|
|
287
|
+
while (windows().some((w) => w.startsWith(`${w3.windowId}|`)) && Date.now() < deadline) {
|
|
288
|
+
spawnSync("sleep", ["0.1"]);
|
|
289
|
+
}
|
|
290
|
+
ok(
|
|
291
|
+
"natural exit: the window disappeared without closeWindow",
|
|
292
|
+
!windows().some((w) => w.startsWith(`${w3.windowId}|`)),
|
|
293
|
+
);
|
|
294
|
+
ok(
|
|
295
|
+
"close: an already-vanished window reports already-gone, not a silent success",
|
|
296
|
+
closeWindow(w3, inherited) === "already-gone",
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
// ── restored ─────────────────────────────────────────────────────────────────
|
|
300
|
+
const w5 = windows();
|
|
301
|
+
ok(`restored: windows are 1,2 (${w5.join(" ")})`, w5.length === 2);
|
|
302
|
+
ok("restored: exactly one session", sessionCount() === 1);
|
|
303
|
+
ok(
|
|
304
|
+
"restored: the original pane pids are the SAME processes",
|
|
305
|
+
originalPanes.every((p) => panes().includes(p)),
|
|
306
|
+
);
|
|
307
|
+
ok("restored: focus never moved", windows().find((w) => w.endsWith("|1")) === activeBefore);
|
|
308
|
+
} finally {
|
|
309
|
+
fx("kill-server");
|
|
310
|
+
if (fs.existsSync(SOCKET)) fs.rmSync(SOCKET, { force: true });
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
ok("cleanup: the fixture socket is gone", !fs.existsSync(SOCKET));
|
|
314
|
+
ok("cleanup: the fixture server is unreachable", fx("list-sessions").status !== 0);
|
|
315
|
+
|
|
316
|
+
const after = operatorSnapshot();
|
|
317
|
+
ok("isolation: the operator's server is byte-identical before and after", before === after);
|
|
318
|
+
|
|
319
|
+
console.log(`\n${LABEL}: ${passed} checks passed`);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
main();
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* check-mux-placement — deterministic gate for the T0-b placement primitives
|
|
3
|
+
* (`pi-extensions/lib/mux-placement.ts`).
|
|
4
|
+
*
|
|
5
|
+
* Scope discipline: this gate pins ONLY what is decidable without tmux — the context
|
|
6
|
+
* precondition, the selector grammar, the argv shapes, the parse, the run-status
|
|
7
|
+
* classification, and the close classification. It does not simulate tmux. There is no fake
|
|
8
|
+
* tmux anywhere in this repo, on purpose: a fake would have written the contract before the
|
|
9
|
+
* real thing was measured, which is exactly how the first mux attempt (2026-08-02) shipped
|
|
10
|
+
* the wrong unit. The topology facts — one session throughout, 1,2 → 1,2,3 → 1,2,3,4 → 1,2,
|
|
11
|
+
* surviving pids, unchanged focus, and the context-binding refusals — are judged by
|
|
12
|
+
* `./run.sh check-mux-placement-tmux`, which drives THIS production code against a real
|
|
13
|
+
* private tmux server.
|
|
14
|
+
*
|
|
15
|
+
* Each claim carries its QK token on exactly ONE assertion (the mutant signature must be
|
|
16
|
+
* unique in this file), and that assertion states the whole invariant. The assertions around
|
|
17
|
+
* it explain the invariant without re-emitting the token.
|
|
18
|
+
*
|
|
19
|
+
* MUX-ANCHOR-REQUIRED both TMUX and TMUX_PANE are required before anything runs
|
|
20
|
+
* MUX-SELECTOR-NATIVE-ID every -t selector is a native id, validated before tmux runs
|
|
21
|
+
* MUX-ANCHOR-ECHO-BACK tmux's rc=0 is not evidence; the pane id must echo back
|
|
22
|
+
* MUX-TMUX-FAILURE-LOUD a nonzero/signalled tmux run is raised, never read as a fact
|
|
23
|
+
* MUX-APPEND-END-DETACHED append is `-d -a -t <session_id>:{end}`, no carrier
|
|
24
|
+
* MUX-CONTEXT-BOUND-MUTATION a mutation matches the server pid AND the session id
|
|
25
|
+
* MUX-CLOSE-BY-WINDOW-ID close targets `@window`, and absence needs positive proof
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import assert from "node:assert/strict";
|
|
29
|
+
import {
|
|
30
|
+
APPEND_FORMAT,
|
|
31
|
+
assertTmuxOk,
|
|
32
|
+
buildAppendArgs,
|
|
33
|
+
buildCloseArgs,
|
|
34
|
+
buildInspectArgs,
|
|
35
|
+
buildListWindowIdsArgs,
|
|
36
|
+
classifyCloseFailure,
|
|
37
|
+
INSPECT_FORMAT,
|
|
38
|
+
isDecimal,
|
|
39
|
+
isPaneId,
|
|
40
|
+
isSameContext,
|
|
41
|
+
isSessionId,
|
|
42
|
+
isWindowId,
|
|
43
|
+
parsePlacement,
|
|
44
|
+
parseWindowFields,
|
|
45
|
+
readAnchor,
|
|
46
|
+
} from "../pi-extensions/lib/mux-placement.ts";
|
|
47
|
+
|
|
48
|
+
let passed = 0;
|
|
49
|
+
function ok(label: string, cond: boolean): void {
|
|
50
|
+
assert.ok(cond, label);
|
|
51
|
+
console.log(` ok ${label}`);
|
|
52
|
+
passed++;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const SERVER = "/tmp/tmux-1000/default,8150,11";
|
|
56
|
+
const ANCHOR = "%14";
|
|
57
|
+
const ROW = ["8150", "$11", "@14", "2", "%14", "74235"].join("|");
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Every environment that is NOT a usable tmux context. `TMUX_PANE` alone is the dangerous
|
|
61
|
+
* one: a bare `tmux` with no `TMUX` in the environment resolves to the DEFAULT server, so a
|
|
62
|
+
* stale pane id would have been applied to the operator's live server.
|
|
63
|
+
*/
|
|
64
|
+
const NOT_A_CONTEXT: NodeJS.ProcessEnv[] = [
|
|
65
|
+
{},
|
|
66
|
+
{ TMUX_PANE: ANCHOR },
|
|
67
|
+
{ TMUX_PANE: ANCHOR, TMUX: "" },
|
|
68
|
+
{ TMUX: SERVER },
|
|
69
|
+
{ TMUX: SERVER, TMUX_PANE: "" },
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
/** Selectors that must never reach tmux's parser. */
|
|
73
|
+
const HOSTILE_SELECTORS = ["%1; kill-server", "@1 ; kill-server", "$1;", "#{pane_id}", "#(id)", "1", "%", "", "%1\n"];
|
|
74
|
+
|
|
75
|
+
function main(): void {
|
|
76
|
+
// ── the caller must be inside tmux, with BOTH variables ──────────────────────────
|
|
77
|
+
ok(
|
|
78
|
+
"[QK:MUX-ANCHOR-REQUIRED] a usable anchor needs BOTH a non-empty TMUX and a non-empty TMUX_PANE — TMUX_PANE alone would let a bare tmux resolve to the DEFAULT server",
|
|
79
|
+
NOT_A_CONTEXT.every((env) => {
|
|
80
|
+
const r = readAnchor(env);
|
|
81
|
+
return r.ok === false && r.reason === "no-tmux-context";
|
|
82
|
+
}),
|
|
83
|
+
);
|
|
84
|
+
ok(
|
|
85
|
+
"anchor: with both present, TMUX_PANE is the anchor verbatim and TMUX is never parsed",
|
|
86
|
+
(() => {
|
|
87
|
+
const r = readAnchor({ TMUX: SERVER, TMUX_PANE: ANCHOR });
|
|
88
|
+
return r.ok === true && r.anchor === ANCHOR;
|
|
89
|
+
})(),
|
|
90
|
+
);
|
|
91
|
+
ok(
|
|
92
|
+
"anchor: a present-but-malformed TMUX_PANE is anchor-malformed, not a context problem",
|
|
93
|
+
(() => {
|
|
94
|
+
const r = readAnchor({ TMUX: SERVER, TMUX_PANE: "%1; kill-server" });
|
|
95
|
+
return r.ok === false && r.reason === "anchor-malformed";
|
|
96
|
+
})(),
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
// ── selectors are native ids, checked BEFORE tmux runs ───────────────────────────
|
|
100
|
+
// T0-a measured that tmux reinterprets values it is handed (a trailing `;` can split a
|
|
101
|
+
// command, `#{…}`/`#(…)` expand), so "we passed argv, not a shell string" is not on its
|
|
102
|
+
// own a safety argument. The grammar is the argument.
|
|
103
|
+
ok(
|
|
104
|
+
"[QK:MUX-SELECTOR-NATIVE-ID] every builder refuses a non-native selector BEFORE invoking tmux — no `;`, no `#{}`/`#()`, no bare index, no newline",
|
|
105
|
+
HOSTILE_SELECTORS.every((s) => {
|
|
106
|
+
let refusedInspect = false;
|
|
107
|
+
let refusedAppend = false;
|
|
108
|
+
let refusedClose = false;
|
|
109
|
+
try {
|
|
110
|
+
buildInspectArgs(s);
|
|
111
|
+
} catch {
|
|
112
|
+
refusedInspect = true;
|
|
113
|
+
}
|
|
114
|
+
try {
|
|
115
|
+
buildAppendArgs(s);
|
|
116
|
+
} catch {
|
|
117
|
+
refusedAppend = true;
|
|
118
|
+
}
|
|
119
|
+
try {
|
|
120
|
+
buildCloseArgs(s);
|
|
121
|
+
} catch {
|
|
122
|
+
refusedClose = true;
|
|
123
|
+
}
|
|
124
|
+
return refusedInspect && refusedAppend && refusedClose;
|
|
125
|
+
}),
|
|
126
|
+
);
|
|
127
|
+
ok(
|
|
128
|
+
"grammar: %N / $N / @N / decimal are the entire accepted vocabulary",
|
|
129
|
+
isPaneId("%14") &&
|
|
130
|
+
!isPaneId("@14") &&
|
|
131
|
+
isSessionId("$11") &&
|
|
132
|
+
!isSessionId("11") &&
|
|
133
|
+
isWindowId("@14") &&
|
|
134
|
+
!isWindowId("%14") &&
|
|
135
|
+
isDecimal("74235") &&
|
|
136
|
+
!isDecimal("7 4"),
|
|
137
|
+
);
|
|
138
|
+
ok(
|
|
139
|
+
"grammar: each builder accepts only its OWN id kind",
|
|
140
|
+
(() => {
|
|
141
|
+
const wrongKind = [() => buildInspectArgs("$11"), () => buildAppendArgs("@14"), () => buildCloseArgs("%14")];
|
|
142
|
+
return wrongKind.every((f) => {
|
|
143
|
+
try {
|
|
144
|
+
f();
|
|
145
|
+
return false;
|
|
146
|
+
} catch {
|
|
147
|
+
return true;
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
})(),
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
// ── rc=0 is not evidence ─────────────────────────────────────────────────────────
|
|
154
|
+
// Measured against real tmux 3.6a: `display-message` on a nonexistent pane exits 0, and
|
|
155
|
+
// an EMPTY target exits 0 while answering about the CURRENT pane. Worse, a nonexistent
|
|
156
|
+
// target still fills the SERVER field and leaves the rest empty — so the row is non-empty
|
|
157
|
+
// and would pass a naive "did we get output?" test.
|
|
158
|
+
ok(
|
|
159
|
+
"[QK:MUX-ANCHOR-ECHO-BACK] a row whose pane_id is not the anchor is never accepted: empty, server-only, short, malformed-field and other-pane rows all refuse",
|
|
160
|
+
(() => {
|
|
161
|
+
const cases: Array<[string, string]> = [
|
|
162
|
+
[ANCHOR, ""],
|
|
163
|
+
[ANCHOR, "8150|||||"],
|
|
164
|
+
[ANCHOR, "8150|$11|@14"],
|
|
165
|
+
[ANCHOR, `8150|nope|@14|2|${ANCHOR}|74235`],
|
|
166
|
+
["%13", ROW],
|
|
167
|
+
];
|
|
168
|
+
return cases.every(([anchor, stdout]) => parsePlacement(anchor, stdout).ok === false);
|
|
169
|
+
})(),
|
|
170
|
+
);
|
|
171
|
+
ok(
|
|
172
|
+
"echo-back: an unresolvable row is `anchor-unresolved`, a foreign row is `anchor-mismatch`",
|
|
173
|
+
(() => {
|
|
174
|
+
const unresolved = parsePlacement(ANCHOR, "8150|||||");
|
|
175
|
+
const mismatch = parsePlacement("%13", ROW);
|
|
176
|
+
return (
|
|
177
|
+
unresolved.ok === false &&
|
|
178
|
+
unresolved.reason === "anchor-unresolved" &&
|
|
179
|
+
mismatch.ok === false &&
|
|
180
|
+
mismatch.reason === "anchor-mismatch"
|
|
181
|
+
);
|
|
182
|
+
})(),
|
|
183
|
+
);
|
|
184
|
+
ok(
|
|
185
|
+
"echo-back: an exact match yields the native facts",
|
|
186
|
+
(() => {
|
|
187
|
+
const r = parsePlacement(ANCHOR, `${ROW}\n`);
|
|
188
|
+
return (
|
|
189
|
+
r.ok === true &&
|
|
190
|
+
r.placement.serverPid === "8150" &&
|
|
191
|
+
r.placement.sessionId === "$11" &&
|
|
192
|
+
r.placement.windowId === "@14" &&
|
|
193
|
+
r.placement.paneId === ANCHOR &&
|
|
194
|
+
r.placement.panePid === "74235"
|
|
195
|
+
);
|
|
196
|
+
})(),
|
|
197
|
+
);
|
|
198
|
+
ok(
|
|
199
|
+
"echo-back: the machine row carries native ids only — no free-form name or path that a `|` could split",
|
|
200
|
+
!INSPECT_FORMAT.includes("session_name") &&
|
|
201
|
+
!INSPECT_FORMAT.includes("socket_path") &&
|
|
202
|
+
INSPECT_FORMAT.includes("#{pane_id}") &&
|
|
203
|
+
INSPECT_FORMAT.includes("#{session_id}"),
|
|
204
|
+
);
|
|
205
|
+
ok(
|
|
206
|
+
"echo-back: the query is anchored on the caller's pane",
|
|
207
|
+
buildInspectArgs(ANCHOR).join(" ") === `display-message -p -t ${ANCHOR} ${INSPECT_FORMAT}`,
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
// ── a failed tmux call is a failure, not a fact ───────────────────────────────────
|
|
211
|
+
// rc=0 not being proof of existence does NOT make a nonzero exit ignorable. "no server
|
|
212
|
+
// running", EACCES and a usage error must surface as themselves — reading them as
|
|
213
|
+
// "the anchor did not resolve" would report a missing pane when tmux never answered.
|
|
214
|
+
ok(
|
|
215
|
+
"[QK:MUX-TMUX-FAILURE-LOUD] a nonzero exit and a signal kill both raise, carrying status and stderr; only rc=0 is allowed through to be parsed",
|
|
216
|
+
(() => {
|
|
217
|
+
let nonzero = false;
|
|
218
|
+
let signalled = false;
|
|
219
|
+
try {
|
|
220
|
+
assertTmuxOk("display-message", { status: 1, stdout: "", stderr: "no server running on /tmp/x" });
|
|
221
|
+
} catch (err) {
|
|
222
|
+
nonzero = err instanceof Error && err.message.includes("exit 1") && err.message.includes("no server running");
|
|
223
|
+
}
|
|
224
|
+
try {
|
|
225
|
+
assertTmuxOk("kill-window", { status: null, stdout: "", stderr: "" });
|
|
226
|
+
} catch (err) {
|
|
227
|
+
signalled = err instanceof Error && err.message.includes("terminated by signal");
|
|
228
|
+
}
|
|
229
|
+
let passedThrough = true;
|
|
230
|
+
try {
|
|
231
|
+
assertTmuxOk("display-message", { status: 0, stdout: ROW, stderr: "" });
|
|
232
|
+
} catch {
|
|
233
|
+
passedThrough = false;
|
|
234
|
+
}
|
|
235
|
+
return nonzero && signalled && passedThrough;
|
|
236
|
+
})(),
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
// ── a handle belongs to one server AND one session ───────────────────────────────
|
|
240
|
+
// A restarted tmux server hands out `$0` again, and one server holds many sessions. So a
|
|
241
|
+
// mutation must match BOTH halves before it runs; either half alone would let an env
|
|
242
|
+
// naming a different (or restarted) server redirect the operation to a same-named id the
|
|
243
|
+
// caller never looked at. The wiring — re-reading the placement before every mutation —
|
|
244
|
+
// is proven against real tmux by check-mux-placement-tmux.
|
|
245
|
+
ok(
|
|
246
|
+
"[QK:MUX-CONTEXT-BOUND-MUTATION] a context matches only when the server pid AND the session id both match — either half alone accepts a foreign server or a foreign session",
|
|
247
|
+
(() => {
|
|
248
|
+
const origin = { serverPid: "8150", sessionId: "$11" };
|
|
249
|
+
return (
|
|
250
|
+
isSameContext(origin, { serverPid: "8150", sessionId: "$11" }) &&
|
|
251
|
+
!isSameContext(origin, { serverPid: "9999", sessionId: "$11" }) &&
|
|
252
|
+
!isSameContext(origin, { serverPid: "8150", sessionId: "$12" }) &&
|
|
253
|
+
!isSameContext(origin, { serverPid: "9999", sessionId: "$12" })
|
|
254
|
+
);
|
|
255
|
+
})(),
|
|
256
|
+
);
|
|
257
|
+
ok(
|
|
258
|
+
"binding: a window handle carries the context it was born in",
|
|
259
|
+
(() => {
|
|
260
|
+
const h = { serverPid: "8150", sessionId: "$11", windowId: "@2", windowIndex: "3", paneId: "%2", panePid: "1" };
|
|
261
|
+
return isSameContext(h, { serverPid: h.serverPid, sessionId: h.sessionId });
|
|
262
|
+
})(),
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
// ── append shape ─────────────────────────────────────────────────────────────────
|
|
266
|
+
// `-a … :{end}` is append; a bare `new-window` takes the lowest FREE index, which is a
|
|
267
|
+
// different verb and not this contract. The carrier stays shut: a caller-supplied
|
|
268
|
+
// command, cwd, window name, or label would hand tmux's own parser a value we did not
|
|
269
|
+
// author.
|
|
270
|
+
const appendArgs = buildAppendArgs("$11");
|
|
271
|
+
ok(
|
|
272
|
+
"[QK:MUX-APPEND-END-DETACHED] append is exactly `new-window -d -a -t <session_id>:{end} -P -F <handle format>` — detached, at the end, by session id, with no carrier",
|
|
273
|
+
appendArgs.join(" ") === `new-window -d -a -t $11:{end} -P -F ${APPEND_FORMAT}`,
|
|
274
|
+
);
|
|
275
|
+
ok(
|
|
276
|
+
"append: no -n/-c/-e/-b carrier and no shell-command positional (default shell only)",
|
|
277
|
+
!["-n", "-c", "-e", "-b"].some((f) => appendArgs.includes(f)) &&
|
|
278
|
+
appendArgs.length === 8 &&
|
|
279
|
+
appendArgs[appendArgs.length - 1] === APPEND_FORMAT,
|
|
280
|
+
);
|
|
281
|
+
ok(
|
|
282
|
+
"append: the printed handle is @window/%pane, not an index alone",
|
|
283
|
+
APPEND_FORMAT.includes("#{window_id}") && APPEND_FORMAT.includes("#{pane_id}"),
|
|
284
|
+
);
|
|
285
|
+
ok(
|
|
286
|
+
"append: the reported handle parses to stable ids",
|
|
287
|
+
(() => {
|
|
288
|
+
const h = parseWindowFields("@2|3|%2|76957\n");
|
|
289
|
+
return h.windowId === "@2" && h.paneId === "%2" && h.windowIndex === "3" && h.panePid === "76957";
|
|
290
|
+
})(),
|
|
291
|
+
);
|
|
292
|
+
assert.throws(() => parseWindowFields(""), /did not report a window handle/, "a silent new-window must fail loud");
|
|
293
|
+
assert.throws(
|
|
294
|
+
() => parseWindowFields("w2|3|%2|76957"),
|
|
295
|
+
/non-native handle/,
|
|
296
|
+
"a non-native handle must fail loud rather than be carried",
|
|
297
|
+
);
|
|
298
|
+
ok("append: a handle-less or non-native new-window report fails loud", true);
|
|
299
|
+
|
|
300
|
+
// ── close shape + absence needs proof ────────────────────────────────────────────
|
|
301
|
+
// remain-on-exit off (measured in T0-a): a runtime's own exit can remove the window
|
|
302
|
+
// first. That is a race, not an error — but `already-gone` is only honest when the
|
|
303
|
+
// window is provably NOT in the inventory.
|
|
304
|
+
ok(
|
|
305
|
+
"[QK:MUX-CLOSE-BY-WINDOW-ID] close targets the stable @window and `already-gone` requires positive absence — a still-listed window yields no outcome at all",
|
|
306
|
+
buildCloseArgs("@3").join(" ") === "kill-window -t @3" &&
|
|
307
|
+
classifyCloseFailure("@3", "@0\n@1\n") === "already-gone" &&
|
|
308
|
+
classifyCloseFailure("@3", "@0\n@1\n@3\n") === null,
|
|
309
|
+
);
|
|
310
|
+
ok(
|
|
311
|
+
"close: the absence proof lists window ids server-wide",
|
|
312
|
+
buildListWindowIdsArgs().join(" ") === "list-windows -a -F #{window_id}",
|
|
313
|
+
);
|
|
314
|
+
ok("close: an empty inventory is still positive absence", classifyCloseFailure("@3", "") === "already-gone");
|
|
315
|
+
ok(
|
|
316
|
+
"close: a prefix is not the handle — @30 present does not prove @3 absent, and does prove @30 present",
|
|
317
|
+
classifyCloseFailure("@3", "@30\n") === "already-gone" && classifyCloseFailure("@30", "@30\n") === null,
|
|
318
|
+
);
|
|
319
|
+
|
|
320
|
+
console.log(`\ncheck-mux-placement: ${passed} checks passed`);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
main();
|